66`params` dict forwarded verbatim to task/create (agentex does not interpret it —
77the 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
1728from __future__ import annotations
2132
2233from 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
2538from src .domain .channels .router import ChannelRouter
2639from src .domain .channels .webhook import MAX_BODY_BYTES , WebhookChannel
2740from src .domain .services .task_message_service import DTaskMessageService
3346
3447router = 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
4057def _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 }
0 commit comments