Skip to content

feat(sdk): add webhook helper for forward-route handlers#419

Open
danielmillerp wants to merge 3 commits into
nextfrom
dm/sdk-webhook-helper
Open

feat(sdk): add webhook helper for forward-route handlers#419
danielmillerp wants to merge 3 commits into
nextfrom
dm/sdk-webhook-helper

Conversation

@danielmillerp

@danielmillerp danielmillerp commented Jun 22, 2026

Copy link
Copy Markdown
Contributor

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:

  • shape the payload (generic, or GitHub/Gitea PR → clean title+body+diff prompt),
  • resolve params — inline, or fetched from a params_source URL (config-by-id; the platform's config-resolve endpoint),
  • session continuity — get-or-create a task on a stable session key so repeat events (PR opened/synchronize/…) fold into one task,
  • dispatch the turn (sync message/send returns the reply inline; async event/send, with optional wait to poll),
  • stamp canonical task_metadata (channel/peer_id/sender) plus any source-returned metadata (which can't override the canonical fields).

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:

@acp.post("/github-pr")
async def github_pr(request: Request):
    body = await request.json()
    result = await handle_webhook(
        agent_name="my-agent",
        payload=body,
        acp_type="sync",
        shaper="github_pr",
        params_source="https://<host>/public/v5/agent_configs/<id>/resolve",
        params_source_headers={"x-api-key": ..., "x-selected-account-id": ...},
        wait=True,
    )
    return {"task_id": result.task_id, "reply": result.reply}

Config-by-id is ingress-independent: point params_source at the platform's config-resolve endpoint (the scaleapi GET /v5/agent_configs/{id}/resolve server-side counterpart) and the resolved params are forwarded opaquely to task/create.

Design notes (open-source SDK)

This module ships in the open-source SDK, so two things were kept deliberate:

  • No secrets / internal coupling. Credentials come from the caller via 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).
  • Formats data, doesn't dictate behavior. shape_github_pr emits facts only (title / action / URL / description / diff) — no "review this PR" instruction is baked in. The actual task lives in the config's system_prompt, not the helper. The opinionated parts (shaper enum generic/github_pr; MAX_BODY_CHARS/MAX_DIFF_CHARS constants) are opt-in conveniences over a fully-generic base — shaper="generic" + inline params sidesteps both, so nothing traps the user. (fetch is injectable; shaper is currently a closed enum.)

Testing

  • 14 unit tests (tests/lib/test_webhooks.py): session-key folding, generic + GitHub-PR shaping, params/config-by-id resolution (envelope + bare-object + error), and handle_webhook sync/async with adk mocked. 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 — plus agentex.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-keyed task/create, sync message/send and async event/send with quiescence-based reply polling, and best-effort task_metadata stamping. 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 both BaseACPServer.lifespan_context and AgentexWorker._register_agent so both startup paths are guarded.
  • Tests: 14 webhook unit tests + 20+ version-guard tests, including end-to-end flows through httpx.MockTransport without 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

Filename Overview
src/agentex/lib/sdk/utils/webhooks.py New webhook helper: payload shaping (generic + GitHub PR), remote params resolution, session-continuity get-or-create, sync/async dispatch, and stale-reply filtering. Minor style nits (magic numbers, reply docstring) but logic is sound and previous review issues are all addressed.
src/agentex/lib/core/compat/version_guard.py New runtime SDK↔backend contract-version guard; full SemVer §11 comparison, graceful no-op on unreachable/unparseable versions, and env-var bypass. Correct and well-tested.
src/agentex/lib/core/temporal/workers/worker.py Wires assert_backend_compatible into _register_agent before register_agent, mirroring the lifespan guard in base_acp_server. Clean two-line addition.
src/agentex/lib/sdk/fastacp/base/base_acp_server.py Adds assert_backend_compatible call at ACP server lifespan startup before register_agent. Minimal, safe change.
tests/lib/test_webhooks.py 14 unit tests covering session-key folding, generic/GitHub shaping, params resolution (envelope, bare-object, error), sync/async dispatch, and the stale-reply regression. Good coverage including the new _await_reply snapshot logic.
tests/test_version_guard.py Comprehensive tests: SemVer parse correctness, anchored-regex rejection, prerelease precedence, skip-env bypass, unknown-version no-crash, and end-to-end flows through a real httpx MockTransport. Very thorough.
tests/lib/core/temporal/workers/test_worker_version_guard.py Tests that the guard fires before register_agent, that IncompatibleBackendError blocks registration, and that no AGENTEX_BASE_URL skips both. All three key cases covered.

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}"
Loading
%%{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}"
Loading

Reviews (4): Last reviewed commit: "feat(sdk): add webhook helper for forwar..." | Re-trigger Greptile

NiteshDhanpal and others added 2 commits June 17, 2026 21:10
@danielmillerp danielmillerp changed the base branch from main to next June 22, 2026 04:22
@danielmillerp danielmillerp force-pushed the dm/sdk-webhook-helper branch 2 times, most recently from 73fc765 to 62aaff5 Compare June 22, 2026 04:23
Comment thread src/agentex/lib/sdk/utils/webhooks.py Outdated
Comment thread src/agentex/lib/sdk/utils/webhooks.py
Comment thread src/agentex/lib/sdk/utils/webhooks.py
@danielmillerp danielmillerp force-pushed the dm/sdk-webhook-helper branch from 62aaff5 to e91aff6 Compare June 22, 2026 04:36
Comment thread src/agentex/lib/sdk/utils/webhooks.py Outdated
Comment thread src/agentex/lib/sdk/utils/webhooks.py Outdated

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"):

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should extract these into constant and broaden the list + be case insensitive

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Are we sure github and gitea have the same shape? Just want a quick sanity check

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is each message a distinct paragraph that we want to separate in that way?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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",

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we want to add slack shaper too?

@danielmillerp danielmillerp Jun 23, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 declan-scale left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread src/agentex/lib/sdk/utils/webhooks.py Outdated

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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.)

Comment thread src/agentex/lib/sdk/utils/webhooks.py Outdated

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 [])}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread src/agentex/lib/sdk/utils/webhooks.py
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>
@danielmillerp danielmillerp force-pushed the dm/sdk-webhook-helper branch from 0d11673 to 66a3997 Compare June 23, 2026 00:58
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants