From db146f0eba2c4b26987beff5a7a2e243a2c2ebe8 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Sat, 8 Aug 2026 01:52:42 +0800 Subject: [PATCH] refactor(host): share the turn-start route refusal between prompt and skill.invoke turnAgentFor owns the addressed-agent resolution and the model-unavailable refusal both turn-starting methods repeat; the duplication gate flagged the copied block. --- packages/host/apiproxy/src/api-proxy.ts | 66 +++++++++++++------------ 1 file changed, 35 insertions(+), 31 deletions(-) diff --git a/packages/host/apiproxy/src/api-proxy.ts b/packages/host/apiproxy/src/api-proxy.ts index 6384a4d408..3970a801a3 100644 --- a/packages/host/apiproxy/src/api-proxy.ts +++ b/packages/host/apiproxy/src/api-proxy.ts @@ -1248,6 +1248,35 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro return llm === undefined || llm.listProviders().some(entry => entry.id === provider) } + /** + * Resolve the addressed agent for a turn-starting method and refuse when no + * adapter serves its current route: a route nothing serves cannot start a + * turn, and letting it try spends the whole pre-step path to fail inside + * the adapter with a message about registration. Refusing here names the + * model the session is pointed at while the draft is still in the composer. + * This is the enforcement boundary shared by `session.prompt` and + * `skill.invoke`: a client that disables its input is an affordance, and + * both methods stay callable regardless. + */ + async function turnAgentFor( + request: RpcRequest, sessionId: SessionId, + ): Promise<{ agent: Agent } | { refused: RpcResponse }> { + const found = await agentFor(sessionId) + if ('error' in found) return { refused: err(request, found.error) } + const agent = found.agent + const target = targetFor(agent).current + if (!routeServed(target.provider)) { + return { + refused: err(request, { + code: 'model-unavailable', + message: `no adapter serves provider "${target.provider}"; select a model for this session`, + details: { provider: target.provider, model: target.model }, + }), + } + } + return { agent } + } + /** Missing-service report shared by the settings domain (skills-domain stance). */ function settingsAbsent(): RpcError { return { code: 'internal', message: 'settings service is absent: this deployment does not mount a settings provider (e.g. @deepseek-ai/dsh-settings-local) in its composition', details: {} } @@ -1784,23 +1813,9 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro async prompt(request) { const { sessionId, mode, content } = request.payload - const found = await agentFor(sessionId) - if ('error' in found) return err(request, found.error) - const agent = found.agent - // A route no adapter serves cannot start a turn, and letting it try - // spends the whole pre-step path to fail inside the adapter with a - // message about registration. Refusing here names the model the - // session is pointed at while the draft is still in the composer. - // This is the enforcement boundary: a client that disables its input - // is an affordance, and this method stays callable regardless. - const target = targetFor(agent).current - if (!routeServed(target.provider)) { - return err(request, { - code: 'model-unavailable', - message: `no adapter serves provider "${target.provider}"; select a model for this session`, - details: { provider: target.provider, model: target.model }, - }) - } + const resolved = await turnAgentFor<{ accepted: true }>(request, sessionId) + if ('refused' in resolved) return resolved.refused + const agent = resolved.agent // The rpcId rides MessageSource into user/message (merge declaration in api/sessions.ts; provisional correlation). const source: MessageSource = { kind: 'user', rpcId: request.rpcId } try { @@ -2377,20 +2392,9 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro async invoke(request) { const { sessionId, name, text } = request.payload - const found = await agentFor(sessionId) - if ('error' in found) return err(request, found.error) - const agent = found.agent - // Same turn-start refusal boundary as sessions.prompt: injection - // starts a turn, so a route no adapter serves is refused while the - // composer still shows the draft. - const target = targetFor(agent).current - if (!routeServed(target.provider)) { - return err(request, { - code: 'model-unavailable', - message: `no adapter serves provider "${target.provider}"; select a model for this session`, - details: { provider: target.provider, model: target.model }, - }) - } + const resolved = await turnAgentFor<{ accepted: true }>(request, sessionId) + if ('refused' in resolved) return resolved.refused + const agent = resolved.agent const skillRegistry = ctx.get('skills') if (skillRegistry === undefined) { return err(request, { code: 'internal', message: 'skill registry is absent: this deployment does not mount @deepseek-ai/dsh-skill in its composition (cordis.yml or explicit assembly)', details: {} })