feat(sdk): add webhook helper for forward-route handlers#419
feat(sdk): add webhook helper for forward-route handlers#419danielmillerp wants to merge 3 commits into
Conversation
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
73fc765 to
62aaff5
Compare
62aaff5 to
e91aff6
Compare
|
|
||
| def render_generic(body: dict[str, Any]) -> str: | ||
| """Generic payload → prompt text: first of text/message/goal/prompt, else raw JSON.""" | ||
| for key in ("text", "message", "goal", "prompt"): |
There was a problem hiding this comment.
Should extract these into constant and broaden the list + be case insensitive
There was a problem hiding this comment.
Done — extracted into a module-level GENERIC_PROMPT_KEYS constant, broadened it (text, message, prompt, goal, content, body, description, title), and made the lookup case-insensitive (lowercases the payload keys once). Added two tests for the case-insensitive + broadened behavior.
|
|
||
|
|
||
| def shape_github_pr(body: dict[str, Any]) -> tuple[str, str | None, str]: | ||
| """Shape a GitHub/Gitea pull-request webhook into (prompt, peer_id, sender). |
There was a problem hiding this comment.
Are we sure github and gitea have the same shape? Just want a quick sanity check
There was a problem hiding this comment.
Sanity-checked: yes — Gitea mirrors GitHub's PR webhook JSON, and the shaper only reads the common subset (action, pull_request.{number,title,body,html_url,diff_url}, repository.full_name, sender.login), falling back to render_generic if pull_request is absent. Added patch_url as a diff-URL fallback since Gitea sends that alongside diff_url. (HMAC verification — GitHub sha256= / Gitea X-Hub-Signature-256 — lives in the agentex forward path and is also compatible.)
| text = (getattr(content, "content", "") or "").strip() | ||
| if text: | ||
| parts.append(text) | ||
| return "\n\n".join(parts) if parts else None |
There was a problem hiding this comment.
Is each message a distinct paragraph that we want to separate in that way?
There was a problem hiding this comment.
Intentional — these are distinct agent-authored text messages (separate sends), not fragments of one message, so joining them as paragraphs (\n\n) keeps them readable. In the common case the agent emits a single text message, so this is a no-op; it only matters when an agent sends multiple, where paragraph separation is what we want.
| agent_name: str, | ||
| payload: dict[str, Any], | ||
| acp_type: Literal["sync", "async"] = "sync", | ||
| shaper: Literal["generic", "github_pr"] = "generic", |
There was a problem hiding this comment.
Do we want to add slack shaper too?
There was a problem hiding this comment.
A proper Slack shaper (unwrapping event.text / thread_ts → peer_id) is more than this PR should take on, especially since Slack is still blocked on the dedicated app approval. The helper is built to take it as a drop-in: one shape_slack() function + a "slack" value on the shaper Literal. We'll add it when we tackle Slack next.
declan-scale
left a comment
There was a problem hiding this comment.
A couple questions, also wondering if we want to wait for the resolve pr to land and put it in the sgp sdk and then use that rather than raw fetch.
|
|
||
| name = session_key(agent_name, channel, peer_id or "") | ||
| # NOTE: the SDK's task/create carries only name/params (CreateTaskParams has no | ||
| # task_metadata field), so task_metadata is returned on the result for the caller |
There was a problem hiding this comment.
the UpdateTaskRequest on the task allows to update the task_metadata - just wondering if we're not "stamping" task_metadata on the task because of a functionality limitation (in this case not wanting to do a second update call) or if just returning this result for the caller to use makes more sense?
There was a problem hiding this comment.
Good call — switched to (intentionally) stamping it. We now create the task, then do a best-effort adk.tasks.update(task_id, task_metadata=...) follow-up so the metadata (channel/sender/peer_id + display_name etc. from the resolve source) lands on the task and labels it in the UI. The update is wrapped so a failure just logs and never breaks the run, and task_metadata is still returned on the result. (CreateTaskParams still has no metadata field, hence the follow-up rather than setting it at create time.)
|
|
||
| async def _message_ids(task_id: str) -> set[str | None]: | ||
| messages = await adk.messages.list(task_id=task_id) | ||
| return {getattr(m, "id", None) for m in (messages or [])} |
There was a problem hiding this comment.
I don't actually know why TaskMessage.id is optional in the SDK, but wondering if it makes sense to filter out messages without an ID here? otherwise if we do allow messages without IDs getting through, the greptile comment above does seem like await_reply will end up missing subsequent ones.
There was a problem hiding this comment.
Fixed — _message_ids now collects only real (non-None) ids, and _await_reply only treats id-bearing, previously-unseen messages as this turn's reply. That removes the collision you flagged: a None in the seen-set would have made any later id-less message look already-seen and get dropped. id-less messages can't be tracked across polls, so they're ignored rather than risk a false match.
e91aff6 to
0d11673
Compare
Add agentex.lib.sdk.utils.webhooks.handle_webhook — a reusable helper an agent
calls from a forward-route handler (@acp.post on the route the server's
/agents/forward/name/{agent}/{path} ingress proxies to). It shapes the payload
(generic or GitHub PR), resolves task params (inline or fetched from a config
resolve URL for config-by-id), get-or-creates a task on a stable session key so
repeat events fold into one task, drives the turn (sync message / async event),
and returns/polls the reply.
This keeps webhook triggering on the supported forward mechanism + its built-in
GitHub/Slack signature auth, instead of a parallel ingress. Config-by-id is
ingress-independent: point params_source at the platform's config-resolve endpoint.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
0d11673 to
66a3997
Compare
What
Adds
agentex.lib.sdk.utils.webhooks.handle_webhook— a reusable helper an agent calls from a forward-route handler (a@acp.post(...)route that the server's/agents/forward/name/{agent}/{path}ingress proxies to).It does, agent-side via
adk:params_sourceURL (config-by-id; the platform's config-resolve endpoint),message/sendreturns the reply inline; asyncevent/send, with optionalwaitto poll),Why
Triggering an agent from a webhook is already supported via the forward mechanism (which has built-in GitHub/Slack signature auth). This helper provides the shaping/resolve/dispatch convenience on that supported path instead of a parallel ingress, so an agent's webhook handler is ~10 lines.
Before — every agent hand-writes the full pipeline in its handler: shape the raw payload into a prompt, fetch+map the config, hash a stable session key, get-or-create the task, dispatch, then dig the reply back out of the message list. Each agent re-implements the edge cases (diff truncation, sync vs async, id-less messages) and gets them wrong in its own way.
After — the same five steps become keyword arguments; the messy implementation lives once in
webhooks.py:Config-by-id is ingress-independent: point
params_sourceat the platform's config-resolve endpoint (the scaleapiGET /v5/agent_configs/{id}/resolveserver-side counterpart) and the resolved params are forwarded opaquely totask/create.Design notes (open-source SDK)
This module ships in the open-source SDK, so two things were kept deliberate:
params_source_headers(docstring uses.../<host>placeholders). It shapes only public webhook formats (GitHub/Gitea/Slack) and forwards opaque params; the config-resolve endpoint is referenced generically, not by an internal URL. Self-contained leaf module (adk+ stdlib).shape_github_premits facts only (title / action / URL / description / diff) — no "review this PR" instruction is baked in. The actual task lives in the config'ssystem_prompt, not the helper. The opinionated parts (shaperenumgeneric/github_pr;MAX_BODY_CHARS/MAX_DIFF_CHARSconstants) are opt-in conveniences over a fully-generic base —shaper="generic"+ inlineparamssidesteps both, so nothing traps the user. (fetchis injectable;shaperis currently a closed enum.)Testing
tests/lib/test_webhooks.py): session-key folding, generic + GitHub-PR shaping, params/config-by-id resolution (envelope + bare-object + error), andhandle_webhooksync/async withadkmocked. ruff clean.🤖 Generated with Claude Code
Greptile Summary
This PR adds two new utilities to the SDK:
agentex.lib.sdk.utils.webhooks.handle_webhook— a forward-route helper that shapes webhook payloads, resolves remote config params, gets-or-creates a session-stable task, dispatches the turn (sync or async with optional poll), and returns the reply — plusagentex.lib.core.compat.version_guard, a runtime SDK↔backend contract-version guard that fails fast at startup if the backend is below the SDK's minimum supported contract.webhooks.py: covers payload shaping (generic + GitHub/Gitea PR), remote params resolution via injectable fetcher, session continuity via SHA-1-keyedtask/create, syncmessage/sendand asyncevent/sendwith quiescence-based reply polling, and best-efforttask_metadatastamping. Previous review issues (stale reply on reused tasks, id-less message filtering, diff surfacing, metadata drop) are all addressed.version_guard.py: full SemVer §11 comparison with prerelease/build metadata support; wired into bothBaseACPServer.lifespan_contextandAgentexWorker._register_agentso both startup paths are guarded.httpx.MockTransportwithout network I/O.Confidence Score: 5/5
Safe to merge — the new webhook helper and version guard are well-contained additions that don't modify existing behaviour, only add to it at two guarded callsites.
Both new modules are new additions with no changes to existing dispatch paths, the previous review's substantive issues are fully addressed, and the test suite exercises the critical edge cases (stale reply filtering, id-less messages, semver prerelease ordering) end-to-end.
No files require special attention.
Important Files Changed
Sequence Diagram
%%{init: {'theme': 'neutral'}}%% sequenceDiagram participant W as Webhook source (GitHub/Slack) participant I as Agentex ingress /agents/forward/name/… participant H as Agent route handler (@acp.post) participant HW as handle_webhook() participant PS as params_source (config-resolve endpoint) participant ADK as ADK client W->>I: POST event (HMAC-signed) I->>I: Verify signature I->>H: Proxy payload H->>HW: handle_webhook(payload, …) HW->>HW: shape payload → prompt + peer_id + sender opt params_source set HW->>PS: GET /resolve PS-->>HW: "{params, task_metadata}" end HW->>ADK: "acp.create_task(name=session_key)" ADK-->>HW: task (get-or-created) HW->>ADK: tasks.update(task_metadata) [best-effort] alt "acp_type == sync" HW->>ADK: acp.send_message(content) ADK-->>HW: messages list HW-->>H: "WebhookResult(reply=inline_text)" else "acp_type == async" HW->>ADK: messages.list() — snapshot seen_ids HW->>ADK: acp.send_event(content) opt "wait == True" loop poll until quiescent or timeout HW->>ADK: messages.list() ADK-->>HW: new messages end end HW-->>H: "WebhookResult(reply=polled_text or None)" end H-->>I: "{task_id, reply}"%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%% sequenceDiagram participant W as Webhook source (GitHub/Slack) participant I as Agentex ingress /agents/forward/name/… participant H as Agent route handler (@acp.post) participant HW as handle_webhook() participant PS as params_source (config-resolve endpoint) participant ADK as ADK client W->>I: POST event (HMAC-signed) I->>I: Verify signature I->>H: Proxy payload H->>HW: handle_webhook(payload, …) HW->>HW: shape payload → prompt + peer_id + sender opt params_source set HW->>PS: GET /resolve PS-->>HW: "{params, task_metadata}" end HW->>ADK: "acp.create_task(name=session_key)" ADK-->>HW: task (get-or-created) HW->>ADK: tasks.update(task_metadata) [best-effort] alt "acp_type == sync" HW->>ADK: acp.send_message(content) ADK-->>HW: messages list HW-->>H: "WebhookResult(reply=inline_text)" else "acp_type == async" HW->>ADK: messages.list() — snapshot seen_ids HW->>ADK: acp.send_event(content) opt "wait == True" loop poll until quiescent or timeout HW->>ADK: messages.list() ADK-->>HW: new messages end end HW-->>H: "WebhookResult(reply=polled_text or None)" end H-->>I: "{task_id, reply}"Reviews (4): Last reviewed commit: "feat(sdk): add webhook helper for forwar..." | Re-trigger Greptile