feat(gui): host goal handlers and slash command dispatch on web prompts
- web host boots CommandService + command-goal; session.prompt intercepts a lone text block starting with '/' and executes it as a command (unknown -> RPC unknown-command, usage error -> RPC command-error, so the composer restores the draft and shows the error strip) instead of sending it to the model — symmetric with the ACP adapter - prompt response carries command result text on the wire for future UI - api-proxy also lands the host-side goals RPC handlers
This commit is contained in:
@@ -11,8 +11,10 @@ import type { Context } from 'cordis'
|
||||
import type { Agent, AgentStatus } from '@deepseek-ai/dsh-agent'
|
||||
import type { ContentBlock, MessageSource } from '@deepseek-ai/dsh-llm'
|
||||
import type { Session, SessionEvent, SessionHeader, SessionId } from '@deepseek-ai/dsh-session'
|
||||
import type {} from '@deepseek-ai/dsh-commands'
|
||||
import type { SessionPersistence } from '@deepseek-ai/dsh-session-persistence'
|
||||
import type { ApiProxy, HistoryEntry, HostFrame, MuxFrame, SessionSummary, ToolEventView } from '@deepseek-ai/dsh-host-apiproxy/api'
|
||||
import type { GoalView } from '@deepseek-ai/dsh-host-apiproxy/api'
|
||||
import type { ClientResponse, RpcError, RpcReceipt, RpcRequest, RpcResponse } from '@deepseek-ai/dsh-host-apiproxy/api/rpc'
|
||||
import { RpcId } from '@deepseek-ai/dsh-host-apiproxy/api/rpc'
|
||||
|
||||
@@ -104,6 +106,17 @@ function frame<F>(payload: F): RpcRequest<F> {
|
||||
return { rpcId: RpcId(randomUUID()), payload }
|
||||
}
|
||||
|
||||
/**
|
||||
* Slash-command candidate: the web composer sends exactly one text block, so
|
||||
* only that exact shape dispatches; multi-block content is never flattened.
|
||||
*/
|
||||
function commandCandidate(content: ContentBlock[]): string | undefined {
|
||||
const [first, ...rest] = content
|
||||
if (first === undefined || rest.length > 0) return undefined
|
||||
if (first.type !== 'text' || !first.text.startsWith('/')) return undefined
|
||||
return first.text
|
||||
}
|
||||
|
||||
/** SessionSummary projection for attached (in-memory) sessions. */
|
||||
function summarize(session: Session, running: boolean): SessionSummary {
|
||||
return {
|
||||
@@ -208,6 +221,22 @@ function backscanArgs(events: readonly SessionEvent[], callId: string): { name:
|
||||
return undefined
|
||||
}
|
||||
|
||||
/** Project a server-side GoalView into the wire GoalView shape. */
|
||||
function goalView(g: import('@deepseek-ai/dsh-goal').GoalView): GoalView {
|
||||
return {
|
||||
id: g.id,
|
||||
revision: g.revision,
|
||||
objective: g.objective,
|
||||
phase: g.phase,
|
||||
...(g.blockedReason !== undefined ? { blockedReason: g.blockedReason } : {}),
|
||||
maxGoalRounds: g.maxGoalRounds,
|
||||
roundsStarted: g.roundsStarted,
|
||||
createdAt: g.createdAt,
|
||||
updatedAt: g.updatedAt,
|
||||
activation: g.activation,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Thrown by the cold-resume path when the id names no servable session
|
||||
* (absent from the store, or a pre-project legacy log without a cwd).
|
||||
@@ -318,6 +347,30 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
|
||||
const found = await agentFor(sessionId)
|
||||
if ('error' in found) return err(request, found.error)
|
||||
const agent = found.agent
|
||||
// Host-side slash-command dispatch (symmetric with the ACP adapter): a
|
||||
// leading-/ single-text-block prompt executes through the command
|
||||
// registry instead of reaching the model. Commands are mode-agnostic,
|
||||
// so queue and steer dispatch identically.
|
||||
const commandLine = commandCandidate(content)
|
||||
if (commandLine !== undefined) {
|
||||
// Unary handlers carry no request signal; the dispatch owns a fresh
|
||||
// one (commands here are synchronous mutations, so nothing aborts it).
|
||||
const result = await ctx.commands.execute(agent, commandLine, new AbortController().signal)
|
||||
if (result === undefined) {
|
||||
const space = commandLine.search(/\s/u)
|
||||
const token = space === -1 ? commandLine : commandLine.slice(0, space)
|
||||
return err(request, { code: 'unknown-command', message: `unknown command: ${token}`, details: {} })
|
||||
}
|
||||
// Usage/state errors travel as RPC errors so the client restores the
|
||||
// composer's draft and shows the message on its error strip.
|
||||
if (result.kind === 'error') {
|
||||
return err(request, { code: 'command-error', message: result.text, details: {} })
|
||||
}
|
||||
return ok(request, {
|
||||
accepted: true as const,
|
||||
command: { kind: 'success' as const, ...result.text === undefined ? {} : { text: result.text } },
|
||||
})
|
||||
}
|
||||
// The rpcId rides MessageSource into user/message (merge declaration in api/sessions.ts; provisional correlation).
|
||||
const source: MessageSource = { kind: 'user', rpcId: request.rpcId }
|
||||
try {
|
||||
@@ -425,5 +478,93 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
|
||||
respond(_message: ClientResponse): Promise<RpcReceipt> {
|
||||
return Promise.resolve({ accepted: false, reason: 'not-pending' })
|
||||
},
|
||||
|
||||
goals: {
|
||||
async get(request) {
|
||||
const { sessionId } = request.payload
|
||||
const found = await agentFor(sessionId as SessionId)
|
||||
if ('error' in found) return err(request, found.error)
|
||||
const goal = ctx.goals.get(found.agent)
|
||||
return ok(request, { goal: goal ? goalView(goal) : null })
|
||||
},
|
||||
|
||||
async create(request) {
|
||||
const { sessionId, objective, maxGoalRounds } = request.payload
|
||||
const found = await agentFor(sessionId as SessionId)
|
||||
if ('error' in found) return err(request, found.error)
|
||||
try {
|
||||
const goal = ctx.goals.create(found.agent, {
|
||||
objective,
|
||||
...(maxGoalRounds !== undefined ? { maxGoalRounds } : {}),
|
||||
})
|
||||
return ok(request, { goal: goalView(goal) })
|
||||
} catch (error: unknown) {
|
||||
return err(request, { code: 'internal', message: String(error), details: {} })
|
||||
}
|
||||
},
|
||||
|
||||
async edit(request) {
|
||||
const { sessionId, ref, objective, maxGoalRounds } = request.payload
|
||||
const found = await agentFor(sessionId as SessionId)
|
||||
if ('error' in found) return err(request, found.error)
|
||||
try {
|
||||
const goal = ctx.goals.edit(found.agent, ref, {
|
||||
...(objective !== undefined ? { objective } : {}),
|
||||
...(maxGoalRounds !== undefined ? { maxGoalRounds } : {}),
|
||||
})
|
||||
return ok(request, { goal: goalView(goal) })
|
||||
} catch (error: unknown) {
|
||||
return err(request, { code: 'internal', message: String(error), details: {} })
|
||||
}
|
||||
},
|
||||
|
||||
async pause(request) {
|
||||
const { sessionId, ref } = request.payload
|
||||
const found = await agentFor(sessionId as SessionId)
|
||||
if ('error' in found) return err(request, found.error)
|
||||
try {
|
||||
const goal = ctx.goals.pause(found.agent, ref)
|
||||
return ok(request, { goal: goalView(goal) })
|
||||
} catch (error: unknown) {
|
||||
return err(request, { code: 'internal', message: String(error), details: {} })
|
||||
}
|
||||
},
|
||||
|
||||
async resume(request) {
|
||||
const { sessionId, ref } = request.payload
|
||||
const found = await agentFor(sessionId as SessionId)
|
||||
if ('error' in found) return err(request, found.error)
|
||||
try {
|
||||
const goal = ctx.goals.resume(found.agent, ref)
|
||||
return ok(request, { goal: goalView(goal) })
|
||||
} catch (error: unknown) {
|
||||
return err(request, { code: 'internal', message: String(error), details: {} })
|
||||
}
|
||||
},
|
||||
|
||||
async complete(request) {
|
||||
const { sessionId, ref } = request.payload
|
||||
const found = await agentFor(sessionId as SessionId)
|
||||
if ('error' in found) return err(request, found.error)
|
||||
try {
|
||||
const goal = ctx.goals.complete(found.agent, ref)
|
||||
return ok(request, { goal: goalView(goal) })
|
||||
} catch (error: unknown) {
|
||||
return err(request, { code: 'internal', message: String(error), details: {} })
|
||||
}
|
||||
},
|
||||
|
||||
async clear(request) {
|
||||
const { sessionId, ref } = request.payload
|
||||
const found = await agentFor(sessionId as SessionId)
|
||||
if ('error' in found) return err(request, found.error)
|
||||
try {
|
||||
ctx.goals.clear(found.agent, ref)
|
||||
return ok(request, { cleared: true as const })
|
||||
} catch (error: unknown) {
|
||||
return err(request, { code: 'internal', message: String(error), details: {} })
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,6 +37,10 @@ import * as toolWorkflow from '@deepseek-ai/dsh-tool-workflow'
|
||||
import * as timeoutPolicy from '@deepseek-ai/dsh-timeout-policy'
|
||||
import SpillLocal from '@deepseek-ai/dsh-spill-local'
|
||||
import * as spillPolicy from '@deepseek-ai/dsh-spill-policy'
|
||||
import GoalService from '@deepseek-ai/dsh-goal'
|
||||
import * as goalSession from '@deepseek-ai/dsh-goal-session'
|
||||
import CommandService from '@deepseek-ai/dsh-commands'
|
||||
import * as commandGoal from '@deepseek-ai/dsh-command-goal'
|
||||
|
||||
/** Options for bootHost — the assembly-layer composition knobs. */
|
||||
export interface BootHostOptions {
|
||||
@@ -129,5 +133,12 @@ export async function bootHost(options: BootHostOptions): Promise<HostHandle> {
|
||||
// Oversized tool output spills to session-scoped files (repl-agent budget).
|
||||
await ctx.plugin(SpillLocal, {})
|
||||
await ctx.plugin(spillPolicy, { maxInlineBytes: 50000 })
|
||||
// Goal service and automatic same-session continuation.
|
||||
await ctx.plugin(GoalService, {})
|
||||
await ctx.plugin(goalSession)
|
||||
// Human slash commands: the registry plus the /goal producer; the api-proxy
|
||||
// prompt path dispatches leading-/ single-text-block prompts through them.
|
||||
await ctx.plugin(CommandService)
|
||||
await ctx.plugin(commandGoal)
|
||||
return { ctx, defaults, dispose: () => ctx.fiber.dispose() }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user