agent-shell / src /shell /call.ts
uos1231234's picture
agent-shell published to Hugging Face
06c3755
Raw History Blame Contribute Delete
7.74 kB
// shell/call.ts: the single entry point of the shell.
// 1. gate(state) -> if not Running, throw ShellTerminatedError without calling protocol
// 2. streamChat(url, request) -> consume chunks
// 3. aggregate content / tool_calls / usage
// 4. return the new metrics and a ChatCompletionResponse
//
// Guards are NOT evaluated here (beyond the entry gate): the IM runs
// `runGuards` on the returned metrics after applying its wall-clock elapsed
// time, so there is exactly one evaluation point per round. The shell
// returns enough information for the IM to decide what to do next.
import type { ShellConfig } from './config.js'
import type { Metrics, Usage } from './metrics.js'
import { addStep, addUsage, addToolCalls } from './metrics.js'
import type { ToolRegistry } from './registry.js'
import { runGuards } from './guards.js'
import { gate } from './gate.js'
import type { State } from './state.js'
import type { FinalPrompt } from './compose.js'
import type { StreamChunk, ChatCompletionResponse, ToolCall } from '../protocol/types.js'
import { accumulateToolCall, finalizeToolCalls, type ToolCallAccumulator } from '../protocol/tool-calls.js'
import { normalizeStrictAlternation } from '../protocol/messages.js'
export type ShellDeps = {
config: ShellConfig
registry: ToolRegistry
state: State // current state of the shell (held by the IM)
metrics: Metrics // current metrics of the shell (held by the IM)
streamChat: (
url: string,
request: { model: string; messages: FinalPrompt['messages']; tools?: FinalPrompt['tools']; [k: string]: unknown },
) => AsyncIterable<StreamChunk>
url: string
model: string
// v0.21: 流式增量旁路(可选观察者)。shellCall 消费 streamChat 的每个
// chunk 时先回调这里,再进入聚合 switch——纯观察,不改变聚合结果。
// 默认 undefined:零行为变化(信号关 delta-bridge 的接入口,见
// src/signals/wiring/delta-bridge.ts)。
onStreamChunk?: ((chunk: StreamChunk) => void) | undefined
/**
* v0.41 D19:provider 是否要求严格角色交替(Anthropic 要求,OpenAI 兼容栈
* 容忍)。true 时出站前合并相邻的 role:'user' 消息(protocol/messages.ts)。
*
* 这是 **provider 的客观属性**,事实源在 providers.json 的
* ModelCapabilities.strictAlternation,经装配层逐轮热解析后透传到这里。
* 缺省 undefined = 不转写 = 既有会话的 wire 形状逐字节不变。
*/
strictAlternation?: boolean | undefined
}
export type ShellCallResult = {
response: ChatCompletionResponse
updatedMetrics: Metrics // guards are evaluated by the IM on these
toolCalls: ToolCall[] // convenience: the same data as response.choices[0].message.tool_calls
// 思维链(provider 流式回包的 reasoning_content 聚合,2026-09-12 用户拍板
// 全量落盘)。仅在模型本轮流出了思考文本时存在;不走 response.message——
// ChatMessage 是出站 wire 词汇,思维链是回包词汇,混进 wire 类型会误导
// 后续把 reasoning 发回请求的改动。
reasoning?: string
}
// Run one call: gate → protocol → return response + updated metrics.
export const shellCall = async (
deps: ShellDeps,
request: FinalPrompt,
): Promise<ShellCallResult> => {
// 1. Gate.
const existingHits = runGuards(deps.metrics, deps.config)
gate(deps.state, existingHits)
// 2. Send the request through the protocol layer.
const toolAccs: ToolCallAccumulator[] = []
let content = ''
let reasoning = ''
let finishReason: ChatCompletionResponse['choices'][number]['finish_reason'] | null = null
let usage: Usage | null = null
// The FinalPrompt is already in the OpenAI shape; we just hand it to the protocol layer.
// An empty tools array is omitted: OpenAI-shaped providers reject "tools": []
// — absence of the field is the correct form.
//
// v0.41 D19: 严格交替 provider 的出站转写(相邻 user 合并)。这是与上面
// "空 tools 省略"同一类的 provider 严格性适配,落点是所有 streamChat 实现
// (内置 client / llm-adapter / mock)的必经之路——放在 protocol/client.ts
// 里的话注入的 streamChat 会绕过它,测试也永远抓不到交替问题。
// canonical 不受影响:信封与续跑提醒在落盘 / 恢复 / 轨迹视图里仍是两个独立回合。
const wireMessages = deps.strictAlternation === true
? normalizeStrictAlternation(request.messages)
: request.messages
const fullRequest = {
model: deps.model,
messages: wireMessages,
...(request.tools.length > 0 ? { tools: request.tools } : {}),
}
for await (const chunk of deps.streamChat(deps.url, fullRequest)) {
// v0.21: 流式增量旁路——先观察,后聚合(observer 不影响聚合结果)。
if (deps.onStreamChunk !== undefined) deps.onStreamChunk(chunk)
switch (chunk.type) {
case 'content_delta':
content += chunk.text
break
case 'reasoning_delta':
reasoning += chunk.text
break
case 'tool_call_delta': {
// The first delta for a new index initializes the accumulator.
const existing = toolAccs[chunk.index] ?? { id: '', name: '', arguments: '' }
toolAccs[chunk.index] = accumulateToolCall(existing, chunk)
break
}
case 'finish':
finishReason = chunk.reason
break
case 'usage':
usage = chunk.usage
break
case 'done':
break
}
}
// 3. Aggregate.
const toolCalls = finalizeToolCalls(toolAccs)
const response: ChatCompletionResponse = {
id: `shell-${Date.now()}-${Math.random().toString(36).slice(2)}`,
model: deps.model,
choices: [{
index: 0,
message: {
role: 'assistant',
content: content.length > 0 ? content : null,
...(toolCalls.length > 0 ? { tool_calls: toolCalls } : {}),
},
finish_reason: finishReason ?? (toolCalls.length > 0 ? 'tool_calls' : 'stop'),
}],
...(usage ? { usage } : {}),
}
// 4. Apply usage to metrics. The IM runs the guards on the returned
// metrics after also applying its wall-clock elapsed time - a single
// evaluation point keeps the time guard honest on the final round.
//
// `stepCount` advances unconditionally: a step is "one shell call round",
// not "one usage chunk". The previous code coupled stepCount to a usage
// chunk being present, which meant providers that never emit usage
// (e.g. many open-source / vllm / ollama deployments) made the `iter`
// guard unreachable. ADR-009 says every guard must be reachable on the
// happy path; this fixes the `iter` half. (The `time` guard is fixed in
// the IM loop via `advanceElapsed`.)
let updatedMetrics: Metrics = addStep(deps.metrics)
if (usage) {
updatedMetrics = addUsage(updatedMetrics, usage)
}
// Tool calls happened this turn -> count them.
if (toolCalls.length > 0) {
updatedMetrics = addToolCalls(updatedMetrics, toolCalls.length)
}
// Per-request size for the token guard: provider-reported prompt tokens
// when available, else a text estimate (4 chars/token, same heuristic as
// im/context-projection.ts; shell must not import im). Providers that
// never emit usage stay guarded instead of bypassing the token guard.
const requestTokens = usage?.promptTokens
?? Math.ceil(JSON.stringify(wireMessages).length / 4)
updatedMetrics = { ...updatedMetrics, lastRequestTokens: requestTokens }
return {
response,
updatedMetrics,
toolCalls,
...(reasoning.length > 0 ? { reasoning } : {}),
}
}