Skip to content

Commit a4e9d62

Browse files
danielmillerpclaude
andcommitted
feat(channels): add remote params source and GitHub PR channel
Build on the generic webhook channel toward non-chat triggering, keeping the channel layer source-agnostic: - Remote params: a route binding can carry a params_source URL that the channel GETs at dispatch time to obtain the opaque task/create params, instead of inlining them. The source endpoint owns whatever produces the params; the channel just forwards the result and never interprets it. An optional auth header for the fetch is configured generically via env. Inline params remains for one-off routes. - Any task_metadata the source returns is stamped on the task for traceability. - GitHubPRChannel: shapes a pull-request webhook into a clean review prompt (title + body + optional inline diff), reuses the sha256= HMAC auth, and keys the conversation on repo#number so repeat events fold into one task. - Per-route channel selection via a channel registry and a binding 'channel' field, keeping source-specific shaping out of the generic webhook channel. Covered by unit tests for remote-params resolution, route parsing, metadata stamping, and PR payload shaping. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 239fb1a commit a4e9d62

7 files changed

Lines changed: 620 additions & 23 deletions

File tree

agentex/src/api/routes/channels.py

Lines changed: 52 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,23 @@
66
`params` dict forwarded verbatim to task/create (agentex does not interpret it —
77
the bound agent does).
88
9-
Route bindings live in the CHANNELS_WEBHOOK_ROUTES env var (JSON) for now — the
10-
seam where a real per-config store (resolving a saved agent_config_id) plugs in later:
11-
12-
CHANNELS_WEBHOOK_ROUTES='{"demo": {"secret": "<shared-secret>",
13-
"agent_name": "golden-agent",
14-
"params": {"system_prompt": "You are ...", "mcps": []}}}'
9+
Route bindings live in the CHANNELS_WEBHOOK_ROUTES env var (JSON) for now. A route
10+
supplies the turn's params one of two ways:
11+
12+
- remote (`params_source`): a URL the channel GETs at dispatch time to obtain the
13+
params. The source owns whatever produces them; the channel just forwards the
14+
result. Auth headers for the fetch are configured generically via
15+
CHANNELS_PARAMS_SOURCE_HEADERS (a JSON object of header name -> value).
16+
- inline (`params`): an opaque dict passed directly, for one-off routes.
17+
18+
CHANNELS_WEBHOOK_ROUTES='{
19+
"pr-review": {"secret": "<shared-secret>", "agent_name": "<agent>",
20+
"channel": "github_pr",
21+
"params_source": "https://<host>/<resolve-endpoint>"},
22+
"demo": {"secret": "<shared-secret>", "agent_name": "<agent>",
23+
"params": {"system_prompt": "You are ..."}}}'
24+
25+
(The env-backed store is the current seam; a DB-backed route store replaces it later.)
1526
"""
1627

1728
from __future__ import annotations
@@ -21,7 +32,9 @@
2132

2233
from fastapi import APIRouter, HTTPException, Query, Request
2334

24-
from src.domain.channels.base import ChannelBinding
35+
from src.domain.channels.base import Channel, ChannelBinding
36+
from src.domain.channels.github_pr import GitHubPRChannel
37+
from src.domain.channels.params_source import ParamsSourceError, resolve_binding_params
2538
from src.domain.channels.router import ChannelRouter
2639
from src.domain.channels.webhook import MAX_BODY_BYTES, WebhookChannel
2740
from src.domain.services.task_message_service import DTaskMessageService
@@ -33,8 +46,12 @@
3346

3447
router = APIRouter(prefix="/channels", tags=["Channels"])
3548

36-
# Channel registry — add "slack": SlackChannel() here when it lands.
37-
_WEBHOOK = WebhookChannel()
49+
# Channel registry — keyed by ChannelBinding.channel. Add "slack": SlackChannel() here
50+
# when it lands. All entries reach the same ingress endpoint below; the binding selects.
51+
_CHANNELS: dict[str, Channel] = {
52+
"webhook": WebhookChannel(),
53+
"github_pr": GitHubPRChannel(),
54+
}
3855

3956

4057
def _webhook_binding(route_id: str) -> ChannelBinding | None:
@@ -50,13 +67,18 @@ def _webhook_binding(route_id: str) -> ChannelBinding | None:
5067
cfg = routes.get(route_id)
5168
if not isinstance(cfg, dict) or not cfg.get("secret") or not cfg.get("agent_name"):
5269
return None
53-
# `params` is opaque — forwarded verbatim to task/create; agentex does not
54-
# interpret it (an agent like golden-agent reads system_prompt/mcps/etc. there).
70+
# A binding supplies its params either remotely (params_source URL, fetched at
71+
# dispatch time) or inline (the opaque `params` dict, forwarded verbatim to
72+
# task/create; agentex does not interpret it — the bound agent does).
73+
params_source = cfg.get("params_source")
5574
params = cfg.get("params")
75+
channel = cfg.get("channel")
5676
return ChannelBinding(
5777
secret=cfg["secret"],
5878
agent_name=cfg["agent_name"],
79+
channel=channel if isinstance(channel, str) and channel else "webhook",
5980
params=params if isinstance(params, dict) else {},
81+
params_source=params_source if isinstance(params_source, str) else None,
6082
)
6183

6284

@@ -82,26 +104,41 @@ async def handle_webhook(
82104
binding = _webhook_binding(route_id)
83105
if binding is None:
84106
raise HTTPException(status_code=404, detail="unknown route")
107+
channel = _CHANNELS.get(binding.channel)
108+
if channel is None:
109+
logger.error("[channels] route %s bound to unknown channel %r", route_id, binding.channel)
110+
raise HTTPException(status_code=500, detail="misconfigured route channel")
85111

86112
raw = await request.body()
87113
if len(raw) > MAX_BODY_BYTES:
88114
raise HTTPException(status_code=413, detail="payload too large")
89-
if not _WEBHOOK.authenticate(binding, request, raw):
115+
if not channel.authenticate(binding, request, raw):
90116
raise HTTPException(status_code=401, detail="unauthorized")
91117
if "application/json" not in request.headers.get("content-type", ""):
92118
raise HTTPException(status_code=400, detail="expected application/json")
93119
try:
94120
body = json.loads(raw)
95121
except json.JSONDecodeError:
96-
raise HTTPException(status_code=400, detail="invalid json")
122+
raise HTTPException(status_code=400, detail="invalid json") from None
97123
if not isinstance(body, dict):
98124
raise HTTPException(status_code=400, detail="json body must be an object")
99125

126+
# Remote params: if the route has a params_source, fetch the params now (no-op for
127+
# inline-params bindings). Done after auth so an unauthenticated request never
128+
# triggers an outbound fetch.
129+
try:
130+
binding = await resolve_binding_params(binding)
131+
except ParamsSourceError as exc:
132+
logger.error(
133+
"[channels] params source resolution failed for route %s: %s", route_id, exc
134+
)
135+
raise HTTPException(status_code=500, detail="params resolution failed") from exc
136+
100137
# Resolve the agent's ACP type so the router picks the right turn method
101138
# (sync -> message/send returns the reply inline; async -> event/send).
102139
agent = await agents_use_case.get(name=binding.agent_name)
103140

104-
inbound = _WEBHOOK.to_inbound(route_id, body)
141+
inbound = channel.to_inbound(route_id, body)
105142
router_ = ChannelRouter(agents_acp_use_case, task_message_service)
106143
result = await router_.dispatch(inbound, binding, agent.acp_type)
107144

@@ -115,7 +152,7 @@ async def handle_webhook(
115152

116153
response = {
117154
"ok": True,
118-
"channel": "webhook",
155+
"channel": inbound.channel,
119156
"route_id": route_id,
120157
"task_id": result.task_id,
121158
}

agentex/src/domain/channels/base.py

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -43,15 +43,31 @@ def session_key(self, agent_name: str) -> str:
4343
class ChannelBinding:
4444
"""A route's binding to one agent.
4545
46+
A binding provides the turn's task params one of two ways:
47+
48+
- **inline** (`params` set): the params are given directly — a one-off with no
49+
remote lookup.
50+
- **remote** (`params_source` set): a URL the channel GETs at dispatch time to
51+
obtain the params (see `domain.channels.params_source`). The source endpoint
52+
owns whatever produces those params; the channel layer just forwards the result
53+
and never interprets it.
54+
4655
`params` is an OPAQUE dict forwarded verbatim as the task/create params — the
4756
agentex platform does not interpret it. Whatever a given agent expects there
48-
(e.g. golden-agent's system_prompt / mcps / harness / model) is that agent's
49-
concern, not the channel layer's. Later this can be sourced from a saved config.
57+
(system prompt, tools, model, …) is that agent's concern, not the channel layer's.
5058
"""
5159

5260
secret: str
5361
agent_name: str
62+
# Which channel implementation handles this route (registry key, e.g. "webhook",
63+
# "github_pr", "slack"). Defaults to the generic webhook channel.
64+
channel: str = "webhook"
5465
params: dict[str, Any] = field(default_factory=dict)
66+
# When set, `params` is fetched from this URL at dispatch time (the source owns
67+
# what they contain; the channel layer just forwards them).
68+
params_source: str | None = None
69+
# Extra metadata to stamp on the task (e.g. returned alongside remote params).
70+
extra_task_metadata: dict[str, str] = field(default_factory=dict)
5571
# Headers the router forwards to the agent (auth/delegation). Empty for local/open.
5672
forward_headers: dict[str, str] = field(default_factory=dict)
5773

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
"""GitHubPRChannel: shape a GitHub/Gitea pull-request webhook into a clean prompt.
2+
3+
A thin payload-shaper on top of the generic webhook channel. It reuses
4+
`WebhookChannel`'s `sha256=` HMAC authentication (the GitHub/Gitea
5+
`X-Hub-Signature-256` scheme) and only overrides normalization: a PR event becomes
6+
a single review prompt (title + body + metadata, plus an inline diff when the caller
7+
includes one), keyed on `repo#number` so repeated events for the same PR (opened,
8+
synchronize, reopened, ...) fold into one task instead of spawning a new one each time.
9+
10+
This keeps PR-specific shaping out of the generic `WebhookChannel` (which stays
11+
source-agnostic by design). Posting the reply back as a PR comment is the outbound
12+
half — a CI Action can call this endpoint with `?wait=true` and post the returned
13+
review.
14+
"""
15+
16+
from __future__ import annotations
17+
18+
from typing import Any
19+
20+
from src.domain.channels.base import InboundMessage
21+
from src.domain.channels.webhook import WebhookChannel
22+
23+
# Keep the shaped prompt bounded — PR bodies and diffs can be large.
24+
_MAX_BODY_CHARS = 4000
25+
_MAX_DIFF_CHARS = 30000
26+
27+
28+
class GitHubPRChannel(WebhookChannel):
29+
name = "github_pr"
30+
31+
def to_inbound(self, route_id: str, body: dict[str, Any]) -> InboundMessage:
32+
pull_request = body.get("pull_request")
33+
if not isinstance(pull_request, dict):
34+
# Not a PR event (ping, issue comment, ...) — defer to generic rendering.
35+
return super().to_inbound(route_id, body)
36+
37+
return InboundMessage(
38+
text=_render_pr_prompt(body, pull_request),
39+
channel=self.name,
40+
route_id=route_id,
41+
peer_id=_pr_peer_id(body, pull_request) or route_id,
42+
sender_id=_actor(body),
43+
raw=body,
44+
)
45+
46+
47+
def _repo_full_name(body: dict[str, Any]) -> str | None:
48+
repo = body.get("repository")
49+
if isinstance(repo, dict):
50+
full_name = repo.get("full_name")
51+
if isinstance(full_name, str) and full_name:
52+
return full_name
53+
return None
54+
55+
56+
def _pr_peer_id(body: dict[str, Any], pull_request: dict[str, Any]) -> str | None:
57+
"""Stable per-PR conversation scope so repeat events fold into one task."""
58+
number = pull_request.get("number")
59+
repo = _repo_full_name(body)
60+
if repo and number is not None:
61+
return f"{repo}#{number}"
62+
if number is not None:
63+
return f"pr#{number}"
64+
return None
65+
66+
67+
def _actor(body: dict[str, Any]) -> str:
68+
sender = body.get("sender")
69+
if isinstance(sender, dict):
70+
login = sender.get("login")
71+
if isinstance(login, str) and login:
72+
return login
73+
return "github"
74+
75+
76+
def _inline_diff(body: dict[str, Any], pull_request: dict[str, Any]) -> str | None:
77+
"""A diff the caller chose to inline (webhook payloads don't carry it natively)."""
78+
for source in (body, pull_request):
79+
diff = source.get("diff")
80+
if isinstance(diff, str) and diff.strip():
81+
return diff.strip()
82+
return None
83+
84+
85+
def _render_pr_prompt(body: dict[str, Any], pull_request: dict[str, Any]) -> str:
86+
repo = _repo_full_name(body)
87+
number = pull_request.get("number")
88+
title = (pull_request.get("title") or "").strip()
89+
action = (body.get("action") or "").strip()
90+
description = (pull_request.get("body") or "").strip()
91+
html_url = pull_request.get("html_url") or pull_request.get("url")
92+
93+
header = "Pull request"
94+
if repo and number is not None:
95+
header = f"Pull request {repo}#{number}"
96+
elif number is not None:
97+
header = f"Pull request #{number}"
98+
99+
lines = [f"{header}: {title}" if title else header]
100+
if action:
101+
lines.append(f"Action: {action}")
102+
if html_url:
103+
lines.append(f"URL: {html_url}")
104+
if description:
105+
lines.append("")
106+
lines.append("Description:")
107+
lines.append(description[:_MAX_BODY_CHARS])
108+
109+
diff = _inline_diff(body, pull_request)
110+
if diff:
111+
lines.append("")
112+
lines.append("Diff:")
113+
lines.append(diff[:_MAX_DIFF_CHARS])
114+
115+
return "\n".join(lines)
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
"""Resolve a channel binding's task params from a configured remote source.
2+
3+
A route binding can carry a ``params_source``: a URL the channel GETs at dispatch
4+
time to obtain the opaque params forwarded to ``task/create``. This keeps the channel
5+
layer generic — it fetches and forwards params without interpreting them. The source
6+
endpoint owns whatever mapping produces those params; the channel never learns what
7+
they mean.
8+
9+
Response shape (lenient)::
10+
11+
{ "params": { ... }, "task_metadata": { ... } } # task_metadata optional
12+
13+
A bare JSON object with no ``params`` key is treated as the params dict itself.
14+
"""
15+
16+
from __future__ import annotations
17+
18+
import os
19+
from collections.abc import Awaitable, Callable
20+
from typing import Any
21+
22+
from src.domain.channels.base import ChannelBinding
23+
from src.utils.logging import make_logger
24+
25+
logger = make_logger(__name__)
26+
27+
# Injectable fetcher: url -> response JSON. Default uses httpx; tests inject a fake.
28+
ParamsFetcher = Callable[[str], Awaitable[dict[str, Any]]]
29+
30+
# Optional generic auth header sent when fetching a params source, configured via env
31+
# so no credential is hard-coded. JSON object of header name -> value, e.g.
32+
# CHANNELS_PARAMS_SOURCE_HEADERS='{"x-api-key": "...", "x-selected-account-id": "..."}'.
33+
# The channel forwards these opaquely — it does not interpret what they mean.
34+
_AUTH_HEADERS_ENV = "CHANNELS_PARAMS_SOURCE_HEADERS"
35+
36+
37+
class ParamsSourceError(RuntimeError):
38+
"""Raised when a binding's params_source cannot be resolved."""
39+
40+
41+
async def _default_fetch(url: str) -> dict[str, Any]:
42+
"""GET the params source over HTTP. Imported lazily so inline-only bindings carry
43+
no httpx dependency."""
44+
import json as _json
45+
46+
import httpx
47+
48+
headers = {"accept": "application/json"}
49+
raw_headers = os.environ.get(_AUTH_HEADERS_ENV)
50+
if raw_headers:
51+
try:
52+
extra = _json.loads(raw_headers)
53+
except _json.JSONDecodeError:
54+
raise ParamsSourceError(f"{_AUTH_HEADERS_ENV} is not valid JSON") from None
55+
if isinstance(extra, dict):
56+
headers.update({str(k): str(v) for k, v in extra.items()})
57+
58+
try:
59+
async with httpx.AsyncClient(timeout=30.0) as client:
60+
response = await client.get(url, headers=headers)
61+
response.raise_for_status()
62+
return response.json()
63+
except httpx.HTTPError as exc:
64+
# Covers connection/timeout (RequestError) and non-2xx (HTTPStatusError) so
65+
# the route handler's ParamsSourceError catch logs + returns a clean 500.
66+
raise ParamsSourceError(f"params source request failed: {exc}") from exc
67+
except ValueError as exc: # json.JSONDecodeError subclasses ValueError
68+
raise ParamsSourceError(f"params source returned invalid JSON: {exc}") from exc
69+
70+
71+
async def resolve_binding_params(
72+
binding: ChannelBinding, *, fetch: ParamsFetcher | None = None
73+
) -> ChannelBinding:
74+
"""Populate ``binding.params`` from its ``params_source`` when set.
75+
76+
Precedence: a binding with a ``params_source`` fetches its params remotely. A
77+
binding with only inline ``params`` is returned untouched. Any ``task_metadata``
78+
the source returns is captured for stamping on the task. Mutates and returns the
79+
same binding.
80+
"""
81+
if not binding.params_source:
82+
return binding
83+
84+
do_fetch = fetch or _default_fetch
85+
payload = await do_fetch(binding.params_source)
86+
if not isinstance(payload, dict):
87+
raise ParamsSourceError("params source returned a non-object response")
88+
89+
metadata = payload.get("task_metadata")
90+
if isinstance(metadata, dict):
91+
binding.extra_task_metadata = {str(k): str(v) for k, v in metadata.items()}
92+
93+
params = payload.get("params")
94+
if isinstance(params, dict):
95+
binding.params = params
96+
else:
97+
# Lenient: a bare object with no "params" key is the params dict itself —
98+
# minus a top-level task_metadata, which is captured above, not a param.
99+
binding.params = {k: v for k, v in payload.items() if k != "task_metadata"}
100+
101+
logger.info("[channels] resolved remote params for agent %s", binding.agent_name)
102+
return binding

0 commit comments

Comments
 (0)