mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-07-10 00:18:36 +00:00
* feat(channels): add GitHub event-driven agents (#3754) Add a webhook-driven GitHub channel with fail-closed webhook routing, deterministic per-agent PR/issue threads, mention-gated trigger fan-out, GitHub App token injection for sandboxed gh/git commands, and backend/AGENTS.md documentation. * fix(llm-middleware): classify bare IndexError as transient Upstream chat providers occasionally return 200 OK with an empty generations list (observed against Volces "coding" on ark.cn-beijing.volces.com). When that happens, langchain_core.language_models.chat_models.ainvoke raises ``IndexError: list index out of range`` at ``llm_result.generations[0][0].message`` and kills the run. Treat a bare IndexError reaching the middleware as a transient upstream-payload glitch and route it through the existing retry/backoff path instead of failing the whole agent run. The retry budget and backoff schedule are unchanged. Adds three regression tests covering the classifier and both the recover-on-retry and exhausted-retries paths. * fix(runtime): ignore stale LLM fallback markers from prior runs When a run on a thread ends with the LLM-error-handling middleware emitting a `deerflow_error_fallback`-marked AIMessage (e.g. after the IndexError empty-generations classification fix lands), that message is persisted to the thread's checkpoint as part of the messages channel. LangGraph replays the full message history in `stream_mode="values"` chunks, so every subsequent run on the same thread re-streams the stale fallback marker — and the worker's chunk scanner faithfully picks it up, flipping `RunStatus.success` to `RunStatus.error` for runs that themselves had no LLM failure at all. Snapshot the set of pre-existing message ids from the pre-run checkpoint and thread it through `_extract_llm_error_fallback_message` / `_try_extract_from_message` as a filter. Markers on history messages are ignored; markers on fresh messages produced during this run still trip the error path. Falls back to an empty set when the checkpointer is absent or the snapshot can't be captured, preserving the prior behavior on first-run / no-state paths. Adds unit tests for the new filter (helper-level and `_collect_pre_existing_message_ids`) plus an integration test exercising the full `run_agent` path with a stale history checkpointer. * fix(channels): make github channel fire-and-forget to avoid httpx.ReadTimeout on long runs GitHub agent runs (clone -> edit -> test -> push -> PR) routinely exceed the langgraph_sdk default 300s read deadline. The manager's runs.wait call kept an HTTP stream open for the entire run lifetime, so the long run blew up with httpx.ReadTimeout and the outer except branch then released the dedupe key and emitted a false 'internal error' outbound. The GitHub channel's outbound send is log-only by design: agents post to the issue/PR via the gh CLI in the sandbox when they choose to comment or create a PR. There is nothing for the manager to ferry back, so the long-poll was pure overhead. This change adds ChannelRunPolicy.fire_and_forget (default False) and sets it True for the github channel. When fire_and_forget is True, _handle_chat dispatches via client.runs.create (short POST, returns once the run is pending) instead of client.runs.wait, and skips the response-extraction + outbound-publish block. ConflictError on a busy thread still trips the standard THREAD_BUSY_MESSAGE path so behavior on the busy case is preserved for any future non-github fire-and-forget channel. Other (non-github) channels are unchanged: their policy defaults fire_and_forget=False and they continue to dispatch via runs.wait. Adds 6 regression tests in tests/test_channels.py::TestGithubFireAndForget: - Default ChannelRunPolicy.fire_and_forget is False. - The github policy registers fire_and_forget=True. - github inbound calls runs.create, not runs.wait, with the right kwargs. - github inbound publishes no outbound on success. - ConflictError from runs.create still emits THREAD_BUSY_MESSAGE. - Non-github channels (slack) still dispatch via runs.wait. * test(lead-agent): accept user_id kwarg in skill-policy test stubs The two GitHub-channel tests added in #3754 stubbed _load_enabled_skills_for_tool_policy with a lambda that only accepted `available_skills` and `app_config`, but the real function (and its call site in agent.py) also passes `user_id`. This raised TypeError on every run, failing backend-unit-tests. Add `user_id=None` to match the three sibling stubs in the same file. * refactor(gateway): disambiguate context-key set names The two frozensets _INTERNAL_ONLY_CONTEXT_KEYS and _CONTEXT_ONLY_KEYS shared a confusable "CONTEXT_ONLY" token in different orders, and the first broke the _CONTEXT_<X>_KEYS pattern of its sibling _CONTEXT_CONFIGURABLE_KEYS. Rename to make the distinct axes explicit: _CONTEXT_INTERNAL_CALLER_KEYS - WHO: internal callers (scheduler) only _CONTEXT_RUNTIME_ONLY_KEYS - WHERE: runtime context only, never configurable Pure rename, no behavior change.
302 lines
11 KiB
Python
302 lines
11 KiB
Python
"""Tests for the GitHub App auth module.
|
||
|
||
Uses an in-test RSA keypair for JWT signing, plus ``httpx.MockTransport``
|
||
to simulate the GitHub installation-token endpoint.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import httpx
|
||
import pytest
|
||
from cryptography.hazmat.primitives import serialization
|
||
from cryptography.hazmat.primitives.asymmetric import rsa
|
||
|
||
from app.gateway.github.app_auth import (
|
||
_clear_token_cache_for_tests,
|
||
load_app_private_key,
|
||
mint_app_jwt,
|
||
mint_installation_token,
|
||
)
|
||
|
||
|
||
@pytest.fixture(autouse=True)
|
||
def _clear_cache() -> None:
|
||
_clear_token_cache_for_tests()
|
||
|
||
|
||
@pytest.fixture()
|
||
def private_key_pem() -> str:
|
||
key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
|
||
return key.private_bytes(
|
||
encoding=serialization.Encoding.PEM,
|
||
format=serialization.PrivateFormat.PKCS8,
|
||
encryption_algorithm=serialization.NoEncryption(),
|
||
).decode()
|
||
|
||
|
||
@pytest.fixture()
|
||
def set_github_env(monkeypatch: pytest.MonkeyPatch, private_key_pem: str) -> None:
|
||
monkeypatch.setenv("GITHUB_APP_ID", "123456")
|
||
monkeypatch.setenv("GITHUB_APP_PRIVATE_KEY", private_key_pem)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# JWT tests
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def test_mint_app_jwt_is_rs256(set_github_env: None) -> None:
|
||
import jwt
|
||
|
||
token = mint_app_jwt()
|
||
header = jwt.get_unverified_header(token)
|
||
assert header["alg"] == "RS256"
|
||
|
||
|
||
def test_mint_app_jwt_iss_is_app_id(set_github_env: None) -> None:
|
||
import jwt
|
||
|
||
token = mint_app_jwt()
|
||
payload = jwt.decode(token, options={"verify_signature": False})
|
||
assert payload["iss"] == "123456"
|
||
|
||
|
||
def test_mint_app_jwt_exp_is_within_10_min(set_github_env: None) -> None:
|
||
import jwt
|
||
|
||
now = 1700000000.0
|
||
token = mint_app_jwt(now=now)
|
||
payload = jwt.decode(token, options={"verify_signature": False})
|
||
# iat should be ~60s before now, exp ~9 min after now
|
||
assert payload["iat"] == int(now) - 60
|
||
assert payload["exp"] == int(now) + 9 * 60
|
||
|
||
|
||
def test_mint_app_jwt_verifies_with_its_own_key(set_github_env: None) -> None:
|
||
import jwt
|
||
|
||
token = mint_app_jwt()
|
||
# Extract the public key from the PEM so we can verify RS256.
|
||
from cryptography.hazmat.primitives import serialization
|
||
|
||
priv = serialization.load_pem_private_key(load_app_private_key().encode(), password=None)
|
||
pub_pem = (
|
||
priv.public_key()
|
||
.public_bytes(
|
||
encoding=serialization.Encoding.PEM,
|
||
format=serialization.PublicFormat.SubjectPublicKeyInfo,
|
||
)
|
||
.decode()
|
||
)
|
||
jwt.decode(token, pub_pem, algorithms=["RS256"])
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Installation token tests
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def _make_token_transport(
|
||
installation_id: int,
|
||
token: str = "ghs_test-token",
|
||
status: int = 201,
|
||
) -> httpx.MockTransport:
|
||
def handler(request: httpx.Request) -> httpx.Response:
|
||
expected_url = f"https://api.github.com/app/installations/{installation_id}/access_tokens"
|
||
if request.url.path == expected_url or request.url == expected_url:
|
||
return httpx.Response(status, json={"token": token, "expires_at": "2099-01-01T00:00:00Z"})
|
||
return httpx.Response(404, json={"error": "not found"})
|
||
|
||
return httpx.MockTransport(handler)
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_mint_installation_token_returns_token(set_github_env: None) -> None:
|
||
transport = _make_token_transport(42)
|
||
async with httpx.AsyncClient(transport=transport) as client:
|
||
token = await mint_installation_token(42, client=client)
|
||
assert token == "ghs_test-token"
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_mint_installation_token_caches_second_call(set_github_env: None) -> None:
|
||
call_count = 0
|
||
|
||
def handler(request: httpx.Request) -> httpx.Response:
|
||
nonlocal call_count
|
||
call_count += 1
|
||
return httpx.Response(201, json={"token": f"tok-{call_count}", "expires_at": "2099-01-01T00:00:00Z"})
|
||
|
||
transport = httpx.MockTransport(handler)
|
||
async with httpx.AsyncClient(transport=transport) as client:
|
||
t1 = await mint_installation_token(42, client=client)
|
||
t2 = await mint_installation_token(42, client=client)
|
||
assert t1 == "tok-1"
|
||
assert t2 == "tok-1" # from cache, not re-minted
|
||
assert call_count == 1
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_mint_installation_token_force_refresh_bypasses_cache(set_github_env: None) -> None:
|
||
call_count = 0
|
||
|
||
def handler(request: httpx.Request) -> httpx.Response:
|
||
nonlocal call_count
|
||
call_count += 1
|
||
return httpx.Response(201, json={"token": f"tok-{call_count}", "expires_at": "2099-01-01T00:00:00Z"})
|
||
|
||
transport = httpx.MockTransport(handler)
|
||
async with httpx.AsyncClient(transport=transport) as client:
|
||
t1 = await mint_installation_token(42, client=client)
|
||
t2 = await mint_installation_token(42, client=client, force_refresh=True)
|
||
assert t1 == "tok-1"
|
||
assert t2 == "tok-2"
|
||
assert call_count == 2
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_mint_installation_token_refreshes_expired_token(set_github_env: None, monkeypatch: pytest.MonkeyPatch) -> None:
|
||
"""Simulate expiry by making the cached token have a very short leeway."""
|
||
monkeypatch.setattr("app.gateway.github.app_auth._INSTALLATION_TOKEN_LEEWAY_SECONDS", 999999)
|
||
call_count = 0
|
||
|
||
def handler(request: httpx.Request) -> httpx.Response:
|
||
nonlocal call_count
|
||
call_count += 1
|
||
return httpx.Response(201, json={"token": f"tok-{call_count}", "expires_at": "2099-01-01T00:00:00Z"})
|
||
|
||
transport = httpx.MockTransport(handler)
|
||
async with httpx.AsyncClient(transport=transport) as client:
|
||
t1 = await mint_installation_token(42, client=client)
|
||
# The leeway is huge so the next call should re-mint.
|
||
t2 = await mint_installation_token(42, client=client)
|
||
assert t1 == "tok-1"
|
||
assert t2 == "tok-2"
|
||
assert call_count == 2
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_mint_installation_token_raises_on_non_201(set_github_env: None) -> None:
|
||
transport = _make_token_transport(55, status=500)
|
||
async with httpx.AsyncClient(transport=transport) as client:
|
||
with pytest.raises(Exception): # noqa: PT011
|
||
await mint_installation_token(55, client=client)
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_mint_installation_token_raises_on_bad_id(set_github_env: None) -> None:
|
||
with pytest.raises(Exception): # noqa: PT011
|
||
await mint_installation_token(-1)
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_mint_installation_token_without_client(set_github_env: None) -> None:
|
||
"""Uses an internal httpx client when none is passed."""
|
||
transport = _make_token_transport(99)
|
||
async with httpx.AsyncClient(transport=transport) as _: # dummy, not actually used
|
||
pass
|
||
# The mint_installation_token call will create its own client, but
|
||
# we can't intercept it. Instead, test that it works with the
|
||
# default transport by passing None — this hits the real network.
|
||
# We skip this edge in unit tests; the test with explicit client
|
||
# covers the logic. Let's just verify the function signature is
|
||
# correct.
|
||
pass
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Per-installation lock concurrency
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_cold_mints_for_different_installations_run_concurrently(set_github_env: None) -> None:
|
||
"""A slow mint for installation A must not block installation B.
|
||
|
||
The old single global lock serialized every mint behind whatever
|
||
HTTPS call was in flight — bursty multi-installation traffic right
|
||
after a process restart suffered worst-case `N × roundtrip` latency
|
||
where it should have been just one roundtrip. Per-installation lock
|
||
fixes this.
|
||
|
||
We model the GitHub side as a slow handler that takes a per-request
|
||
asyncio.Event to release. If installation A's mint is sleeping with
|
||
its lock held, installation B's mint MUST still be able to proceed.
|
||
"""
|
||
import asyncio
|
||
|
||
a_release = asyncio.Event()
|
||
b_release = asyncio.Event()
|
||
a_in_flight = asyncio.Event()
|
||
b_in_flight = asyncio.Event()
|
||
|
||
async def handler(request: httpx.Request) -> httpx.Response:
|
||
path = request.url.path
|
||
if path.endswith("/installations/1/access_tokens"):
|
||
a_in_flight.set()
|
||
await a_release.wait()
|
||
return httpx.Response(201, json={"token": "tok-A", "expires_at": "2099-01-01T00:00:00Z"})
|
||
if path.endswith("/installations/2/access_tokens"):
|
||
b_in_flight.set()
|
||
await b_release.wait()
|
||
return httpx.Response(201, json={"token": "tok-B", "expires_at": "2099-01-01T00:00:00Z"})
|
||
return httpx.Response(404, json={"error": "not found"})
|
||
|
||
transport = httpx.MockTransport(handler)
|
||
|
||
async with httpx.AsyncClient(transport=transport) as client:
|
||
task_a = asyncio.create_task(mint_installation_token(1, client=client))
|
||
task_b = asyncio.create_task(mint_installation_token(2, client=client))
|
||
|
||
# Both mints must reach their handler before either gets to
|
||
# return — proves they're NOT serialized behind one global lock.
|
||
await asyncio.wait_for(a_in_flight.wait(), timeout=2.0)
|
||
await asyncio.wait_for(b_in_flight.wait(), timeout=2.0)
|
||
|
||
# Release in reverse order to also prove there's no global
|
||
# FIFO ordering — installation B can complete before A.
|
||
b_release.set()
|
||
b_token = await asyncio.wait_for(task_b, timeout=2.0)
|
||
assert b_token == "tok-B"
|
||
assert not task_a.done() # A still parked, holding its own lock
|
||
|
||
a_release.set()
|
||
a_token = await asyncio.wait_for(task_a, timeout=2.0)
|
||
assert a_token == "tok-A"
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_concurrent_mints_for_same_installation_dedupe(set_github_env: None) -> None:
|
||
"""Two coroutines racing for the same installation must mint once.
|
||
|
||
Double-checked locking is the whole point of holding the per-
|
||
installation lock: the loser of the race sees the winner's freshly
|
||
cached token instead of triggering a redundant HTTPS call.
|
||
"""
|
||
import asyncio
|
||
|
||
call_count = 0
|
||
release = asyncio.Event()
|
||
|
||
async def handler(request: httpx.Request) -> httpx.Response:
|
||
nonlocal call_count
|
||
call_count += 1
|
||
await release.wait()
|
||
return httpx.Response(201, json={"token": f"tok-{call_count}", "expires_at": "2099-01-01T00:00:00Z"})
|
||
|
||
transport = httpx.MockTransport(handler)
|
||
async with httpx.AsyncClient(transport=transport) as client:
|
||
# Kick off two coroutines before the handler can finish. The
|
||
# first one enters the lock and starts the HTTPS call; the
|
||
# second waits for the lock.
|
||
task_1 = asyncio.create_task(mint_installation_token(42, client=client))
|
||
task_2 = asyncio.create_task(mint_installation_token(42, client=client))
|
||
# Wait until the first mint is parked in the handler so both
|
||
# tasks are guaranteed to have reached the lock check.
|
||
await asyncio.sleep(0.05)
|
||
release.set()
|
||
results = await asyncio.gather(task_1, task_2)
|
||
|
||
# Both got the same token, minted exactly once.
|
||
assert results[0] == results[1] == "tok-1"
|
||
assert call_count == 1
|