@zhin.js/agent 1.1.39 → 1.1.40
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/assistant/job-storage.d.ts +238 -0
- package/lib/assistant/job-storage.js +143 -0
- package/lib/assistant/job-storage.js.map +1 -0
- package/lib/assistant/job-store.d.ts +7 -0
- package/lib/assistant/job-store.js +41 -3
- package/lib/assistant/job-store.js.map +1 -1
- package/lib/config/perception-config.d.ts +9 -0
- package/lib/config/perception-config.js +27 -0
- package/lib/config/perception-config.js.map +1 -1
- package/lib/context/context-system.d.ts +1 -0
- package/lib/context/context-system.js +8 -8
- package/lib/context/context-system.js.map +1 -1
- package/lib/context/im-turn-context-adapter.js +2 -0
- package/lib/context/im-turn-context-adapter.js.map +1 -1
- package/lib/context/skill-context.d.ts +3 -0
- package/lib/context/skill-context.js +6 -0
- package/lib/context/skill-context.js.map +1 -0
- package/lib/context/turn-envelope.d.ts +4 -0
- package/lib/context/turn-envelope.js +24 -7
- package/lib/context/turn-envelope.js.map +1 -1
- package/lib/context/turn-user-message.d.ts +3 -1
- package/lib/context/turn-user-message.js +14 -1
- package/lib/context/turn-user-message.js.map +1 -1
- package/lib/core/agent-core-run.d.ts +1 -0
- package/lib/core/agent-core-run.js +47 -20
- package/lib/core/agent-core-run.js.map +1 -1
- package/lib/core/structured-reply-recovery.d.ts +6 -2
- package/lib/core/structured-reply-recovery.js +22 -8
- package/lib/core/structured-reply-recovery.js.map +1 -1
- package/lib/core/turn-completion-usage.d.ts +2 -1
- package/lib/core/turn-completion-usage.js +14 -5
- package/lib/core/turn-completion-usage.js.map +1 -1
- package/lib/core/turn-event-mapper.d.ts +1 -1
- package/lib/core/turn-event-mapper.js +8 -5
- package/lib/core/turn-event-mapper.js.map +1 -1
- package/lib/event/turn-event.d.ts +7 -1
- package/lib/event/turn-event.js.map +1 -1
- package/lib/index.d.ts +4 -1
- package/lib/index.js +2 -0
- package/lib/index.js.map +1 -1
- package/lib/media/perception.d.ts +3 -4
- package/lib/media/perception.js +59 -37
- package/lib/media/perception.js.map +1 -1
- package/lib/memory/compaction-runtime.d.ts +3 -2
- package/lib/memory/compaction-runtime.js +22 -14
- package/lib/memory/compaction-runtime.js.map +1 -1
- package/lib/outbound/agent-output.js +2 -0
- package/lib/outbound/agent-output.js.map +1 -1
- package/lib/outbound/chat-failure.d.ts +27 -0
- package/lib/outbound/chat-failure.js +53 -0
- package/lib/outbound/chat-failure.js.map +1 -0
- package/lib/outbound/structured-reply.d.ts +7 -0
- package/lib/outbound/structured-reply.js +52 -16
- package/lib/outbound/structured-reply.js.map +1 -1
- package/lib/plugin-runtime/agent-runtime.js +49 -8
- package/lib/plugin-runtime/agent-runtime.js.map +1 -1
- package/lib/plugin-runtime/ai-completion.d.ts +4 -3
- package/lib/plugin-runtime/ai-completion.js +64 -10
- package/lib/plugin-runtime/ai-completion.js.map +1 -1
- package/lib/plugin-runtime/capability-ingress.js +3 -1
- package/lib/plugin-runtime/capability-ingress.js.map +1 -1
- package/lib/plugin-runtime/deferred-capability-plan.d.ts +4 -1
- package/lib/plugin-runtime/deferred-capability-plan.js +25 -9
- package/lib/plugin-runtime/deferred-capability-plan.js.map +1 -1
- package/lib/plugin-runtime/full-agent-turn-engine.js +1 -0
- package/lib/plugin-runtime/full-agent-turn-engine.js.map +1 -1
- package/lib/plugin-runtime/index.d.ts +2 -0
- package/lib/plugin-runtime/index.js +2 -0
- package/lib/plugin-runtime/index.js.map +1 -1
- package/lib/prompt/assembly.js +0 -9
- package/lib/prompt/assembly.js.map +1 -1
- package/lib/prompt/git-context.js +1 -1
- package/lib/prompt/git-context.js.map +1 -1
- package/lib/prompt/rules/chat-rules.js +8 -9
- package/lib/prompt/rules/chat-rules.js.map +1 -1
- package/lib/prompt/rules/critical-rules.js +10 -10
- package/lib/prompt/rules/critical-rules.js.map +1 -1
- package/lib/prompt/system-prompt.js +4 -5
- package/lib/prompt/system-prompt.js.map +1 -1
- package/lib/security/file-role-policy.js +6 -6
- package/lib/security/file-role-policy.js.map +1 -1
- package/lib/security/policy-facade.js +1 -1
- package/lib/security/policy-facade.js.map +1 -1
- package/lib/security/usage-quota.d.ts +4 -0
- package/lib/security/usage-quota.js +23 -2
- package/lib/security/usage-quota.js.map +1 -1
- package/lib/session/approval-port.d.ts +2 -0
- package/lib/session/approval-port.js.map +1 -1
- package/lib/session/approval-review-agent.d.ts +1 -1
- package/lib/session/approval-review-agent.js +19 -4
- package/lib/session/approval-review-agent.js.map +1 -1
- package/lib/session/turn-ingress-session.js +7 -1
- package/lib/session/turn-ingress-session.js.map +1 -1
- package/lib/tool/tool-approval-gate.js +4 -2
- package/lib/tool/tool-approval-gate.js.map +1 -1
- package/lib/tool/turn-tool-runtime.js +24 -5
- package/lib/tool/turn-tool-runtime.js.map +1 -1
- package/lib/tool-catalog/deferred-turn-controller.js +1 -4
- package/lib/tool-catalog/deferred-turn-controller.js.map +1 -1
- package/lib/trace/context-budget.d.ts +4 -3
- package/lib/trace/context-budget.js +28 -8
- package/lib/trace/context-budget.js.map +1 -1
- package/lib/trace/turn-trace-format.js +4 -1
- package/lib/trace/turn-trace-format.js.map +1 -1
- package/lib/trace/turn-trace.d.ts +6 -0
- package/lib/trace/turn-trace.js +8 -2
- package/lib/trace/turn-trace.js.map +1 -1
- package/lib/trace/turn-usage-log.d.ts +14 -0
- package/lib/trace/turn-usage-log.js +83 -0
- package/lib/trace/turn-usage-log.js.map +1 -0
- package/lib/usage/usage-store.d.ts +199 -0
- package/lib/usage/usage-store.js +121 -0
- package/lib/usage/usage-store.js.map +1 -0
- package/package.json +12 -12
|
@@ -1,30 +1,32 @@
|
|
|
1
|
-
import { AUTOCOMPACT_BUFFER_TOKENS, autoCompactAgentMessagesIfNeeded, createAgentCompactionState, estimateAgentMessagesTokens } from '@zhin.js/ai';
|
|
1
|
+
import { projectPromptContext, AUTOCOMPACT_BUFFER_TOKENS, autoCompactAgentMessagesIfNeeded, createAgentCompactionState, estimateAgentMessagesTokens } from '@zhin.js/ai';
|
|
2
2
|
import { resolveWorkspacePrompt } from '../prompt/workspace-prompt.js';
|
|
3
3
|
/** Default share of the context window that triggers auto-compaction (#730). */
|
|
4
4
|
export const DEFAULT_COMPACTION_THRESHOLD_RATIO = 0.7;
|
|
5
5
|
/**
|
|
6
6
|
* Message-token count above which auto-compaction runs: `thresholdRatio` of
|
|
7
7
|
* the window minus what the system prompt and tools already use. It never
|
|
8
|
-
* exceeds the older "window minus buffer" rule (when that is positive)
|
|
9
|
-
*
|
|
8
|
+
* exceeds the older "window minus buffer" rule (when that is positive).
|
|
9
|
+
* Fixed request tokens are deducted from both limits; retention cannot raise
|
|
10
|
+
* the trigger beyond the available window.
|
|
10
11
|
*/
|
|
11
12
|
export function resolveCompactionTriggerTokens(compaction, contextWindow, reservedTokens = 0) {
|
|
12
13
|
const ratio = compaction?.thresholdRatio ?? DEFAULT_COMPACTION_THRESHOLD_RATIO;
|
|
13
|
-
const
|
|
14
|
-
const target = Math.floor(contextWindow * ratio) - Math.max(0, reservedTokens);
|
|
15
|
-
const floor = Math.ceil(keepRecent * 1.25);
|
|
14
|
+
const target = Math.floor(contextWindow * ratio);
|
|
16
15
|
const ceiling = contextWindow - AUTOCOMPACT_BUFFER_TOKENS;
|
|
17
16
|
const bounded = ceiling > 0 ? Math.min(target, ceiling) : target;
|
|
18
|
-
return Math.max(bounded,
|
|
17
|
+
return Math.max(0, bounded - Math.max(0, reservedTokens));
|
|
19
18
|
}
|
|
20
19
|
function resolveAgentCompactionConfig(compaction, contextWindow, reservedTokens) {
|
|
20
|
+
const triggerTokens = resolveCompactionTriggerTokens(compaction, contextWindow, reservedTokens);
|
|
21
|
+
// 沿用触发预算与保留预算的 1.25 倍余量;小窗口收紧保留量,不抬高触发线。
|
|
22
|
+
const keepRecentTokens = Math.min(compaction?.keepRecentTokens ?? 20_000, Math.floor(triggerTokens / 1.25));
|
|
21
23
|
return {
|
|
22
24
|
enabled: compaction?.enabled !== false,
|
|
23
25
|
auto: compaction?.auto !== false,
|
|
24
|
-
keepRecentTokens
|
|
26
|
+
keepRecentTokens,
|
|
25
27
|
minKeepCount: compaction?.minKeepCount ?? 2,
|
|
26
28
|
contextWindow,
|
|
27
|
-
triggerTokens
|
|
29
|
+
triggerTokens,
|
|
28
30
|
};
|
|
29
31
|
}
|
|
30
32
|
const TOLD_LEDGER_SUMMARY_INSTRUCTION = 'The user has already been told the following. Keep every item in the summary '
|
|
@@ -89,10 +91,16 @@ export class AgentCompactionRuntime {
|
|
|
89
91
|
customInstructions,
|
|
90
92
|
signal,
|
|
91
93
|
});
|
|
92
|
-
if (
|
|
93
|
-
|
|
94
|
-
|
|
94
|
+
if (signal?.aborted)
|
|
95
|
+
return messages;
|
|
96
|
+
if (result.summary?.trim() && result.compactedCount) {
|
|
97
|
+
// Use the actual summarized prefix before micro-compaction replaces objects.
|
|
98
|
+
// Current-turn messages are persisted later; never infer their row boundary
|
|
99
|
+
// by independently applying the retention budget to stored history.
|
|
100
|
+
await options.host.contextRepository.saveCompactionSummary?.(options.sessionId, result.summary, messages.slice(0, result.compactedCount), signal);
|
|
95
101
|
}
|
|
102
|
+
if (signal?.aborted)
|
|
103
|
+
return messages;
|
|
96
104
|
let out = result.messages;
|
|
97
105
|
if (options.loopHooks) {
|
|
98
106
|
out = await options.loopHooks.runTransformContext(out, {
|
|
@@ -103,7 +111,7 @@ export class AgentCompactionRuntime {
|
|
|
103
111
|
return out;
|
|
104
112
|
}
|
|
105
113
|
async compactSession(repo, options) {
|
|
106
|
-
const cfg = resolveAgentCompactionConfig(options.compactionConfig, options.contextWindow);
|
|
114
|
+
const cfg = resolveAgentCompactionConfig(options.compactionConfig, options.contextWindow, options.reservedTokens);
|
|
107
115
|
if (cfg.enabled === false) {
|
|
108
116
|
return { ok: false, message: 'compaction 已禁用(ai.agent.compaction.enabled=false)' };
|
|
109
117
|
}
|
|
@@ -116,7 +124,7 @@ export class AgentCompactionRuntime {
|
|
|
116
124
|
...options,
|
|
117
125
|
force: true,
|
|
118
126
|
});
|
|
119
|
-
const saved = estimateAgentMessagesTokens(messages) - estimateAgentMessagesTokens(compacted);
|
|
127
|
+
const saved = estimateAgentMessagesTokens(projectPromptContext(messages)) - estimateAgentMessagesTokens(projectPromptContext(compacted));
|
|
120
128
|
return {
|
|
121
129
|
ok: true,
|
|
122
130
|
message: `已压缩会话,约节省 ${Math.max(0, saved)} tokens(保留最近 ~${cfg.keepRecentTokens ?? 20_000} tokens)`,
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"compaction-runtime.js","sourceRoot":"","sources":["../../src/memory/compaction-runtime.ts"],"names":[],"mappings":"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","sourcesContent":["import { AUTOCOMPACT_BUFFER_TOKENS, autoCompactAgentMessagesIfNeeded, createAgentCompactionState, estimateAgentMessagesTokens, type AgentCompactionConfig, type AgentCompactionState, type ContextRepository, type Model, type AgentMessage, type LlmCompletionPort } from '@zhin.js/ai';\nimport type { CompactionConfig } from '../config/zhin-agent-config.js';\nimport type { PluginAILoopHookRegistry } from '../plugin-loop-hooks.js';\nimport { resolveWorkspacePrompt } from '../prompt/workspace-prompt.js';\n\nexport interface CompactionContextHost {\n readonly contextRepository: ContextRepository;\n}\n\nexport interface CompactionRuntimeOptions {\n host: CompactionContextHost;\n sessionId: string;\n model: Model;\n transport: LlmCompletionPort;\n compactionConfig?: CompactionConfig;\n contextWindow: number;\n mode?: 'text' | 'multimodal';\n customInstructions?: string;\n /** Tokens the system prompt and tool schemas take from the window. */\n reservedTokens?: number;\n /** Rendered \"already told\" ledger; the summary must keep these facts. */\n toldLedger?: string;\n force?: boolean;\n loopHooks?: PluginAILoopHookRegistry | null;\n}\n\n/** Default share of the context window that triggers auto-compaction (#730). */\nexport const DEFAULT_COMPACTION_THRESHOLD_RATIO = 0.7;\n\n/**\n * Message-token count above which auto-compaction runs: `thresholdRatio` of\n * the window minus what the system prompt and tools already use. It never\n * exceeds the older \"window minus buffer\" rule (when that is positive) and\n * never drops below 1.25 × keepRecentTokens, so a compaction frees room.\n */\nexport function resolveCompactionTriggerTokens(\n compaction: CompactionConfig | undefined,\n contextWindow: number,\n reservedTokens = 0,\n): number {\n const ratio = compaction?.thresholdRatio ?? DEFAULT_COMPACTION_THRESHOLD_RATIO;\n const keepRecent = compaction?.keepRecentTokens ?? 20_000;\n const target = Math.floor(contextWindow * ratio) - Math.max(0, reservedTokens);\n const floor = Math.ceil(keepRecent * 1.25);\n const ceiling = contextWindow - AUTOCOMPACT_BUFFER_TOKENS;\n const bounded = ceiling > 0 ? Math.min(target, ceiling) : target;\n return Math.max(bounded, floor);\n}\n\nfunction resolveAgentCompactionConfig(\n compaction: CompactionConfig | undefined,\n contextWindow: number,\n reservedTokens?: number,\n): AgentCompactionConfig {\n return {\n enabled: compaction?.enabled !== false,\n auto: compaction?.auto !== false,\n keepRecentTokens: compaction?.keepRecentTokens ?? 20_000,\n minKeepCount: compaction?.minKeepCount ?? 2,\n contextWindow,\n triggerTokens: resolveCompactionTriggerTokens(compaction, contextWindow, reservedTokens),\n };\n}\n\nconst TOLD_LEDGER_SUMMARY_INSTRUCTION = 'The user has already been told the following. Keep every item in the summary '\n + '(under \"Already told the user\") so later turns neither repeat nor contradict them:';\n\nconst MAX_COMPACTION_SESSIONS = 5000;\n\n/** One Agent host's compaction state and operations. */\nexport class AgentCompactionRuntime {\n readonly #states = new Map<string, AgentCompactionState>();\n\n constructor(private readonly maxSessions: number = MAX_COMPACTION_SESSIONS) {\n if (!Number.isInteger(maxSessions) || maxSessions < 1) {\n throw new TypeError('AgentCompactionRuntime maxSessions must be a positive integer');\n }\n }\n\n get stateCount(): number {\n return this.#states.size;\n }\n\n clear(): void {\n this.#states.clear();\n }\n\n touch(sessionId: string): void {\n const state = this.#states.get(sessionId);\n if (!state) return;\n this.#states.delete(sessionId);\n this.#states.set(sessionId, state);\n }\n\n evictIfOverPressure(): number {\n if (this.#states.size <= this.maxSessions * 0.8) return 0;\n const targetSize = Math.floor(this.maxSessions * 0.6);\n let removed = 0;\n for (const sessionId of this.#states.keys()) {\n if (this.#states.size <= targetSize) break;\n this.#states.delete(sessionId);\n removed += 1;\n }\n return removed;\n }\n\n async transformContext(\n messages: AgentMessage[],\n signal: AbortSignal | undefined,\n options: CompactionRuntimeOptions,\n ): Promise<AgentMessage[]> {\n if (signal?.aborted) return messages;\n\n const cfg = resolveAgentCompactionConfig(options.compactionConfig, options.contextWindow, options.reservedTokens);\n if (cfg.enabled === false) return messages;\n\n const state = this.#getState(options.sessionId);\n const compactionPrompt = resolveWorkspacePrompt('compaction', options.model.sdk);\n const customInstructions = [\n compactionPrompt?.trim(),\n options.customInstructions?.trim(),\n options.toldLedger?.trim() ? `${TOLD_LEDGER_SUMMARY_INSTRUCTION}\\n${options.toldLedger.trim()}` : undefined,\n ].filter(Boolean).join('\\n\\n') || undefined;\n\n const result = await autoCompactAgentMessagesIfNeeded({\n transport: options.transport,\n model: options.model,\n messages,\n config: cfg,\n state,\n force: options.force,\n customInstructions,\n signal,\n });\n\n if (result.summary?.trim()) {\n const anchorId = await options.host.contextRepository.resolveCompactionAnchorId(\n options.sessionId,\n cfg.keepRecentTokens ?? 20_000,\n cfg.minKeepCount ?? 2,\n );\n await options.host.contextRepository.saveSummary(\n options.sessionId,\n result.summary,\n anchorId,\n );\n }\n\n let out = result.messages;\n if (options.loopHooks) {\n out = await options.loopHooks.runTransformContext(out, {\n sessionId: options.sessionId,\n signal,\n });\n }\n return out;\n }\n\n async compactSession(\n repo: ContextRepository,\n options: Omit<CompactionRuntimeOptions, 'host'> & { host: CompactionContextHost },\n ): Promise<{ ok: boolean; message: string }> {\n const cfg = resolveAgentCompactionConfig(options.compactionConfig, options.contextWindow);\n if (cfg.enabled === false) {\n return { ok: false, message: 'compaction 已禁用(ai.agent.compaction.enabled=false)' };\n }\n\n const loaded = await repo.loadContext(options.sessionId);\n const messages = loaded.messages;\n if (messages.length < 2) {\n return { ok: false, message: '消息过少,无需压缩' };\n }\n\n const compacted = await this.transformContext(messages, undefined, {\n ...options,\n force: true,\n });\n\n const saved = estimateAgentMessagesTokens(messages) - estimateAgentMessagesTokens(compacted);\n return {\n ok: true,\n message: `已压缩会话,约节省 ${Math.max(0, saved)} tokens(保留最近 ~${cfg.keepRecentTokens ?? 20_000} tokens)`,\n };\n }\n\n #getState(sessionId: string): AgentCompactionState {\n const existing = this.#states.get(sessionId);\n if (existing) {\n this.touch(sessionId);\n return existing;\n }\n const state = createAgentCompactionState();\n this.#states.set(sessionId, state);\n return state;\n }\n}\n"]}
|
|
1
|
+
{"version":3,"file":"compaction-runtime.js","sourceRoot":"","sources":["../../src/memory/compaction-runtime.ts"],"names":[],"mappings":"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","sourcesContent":["import { projectPromptContext, AUTOCOMPACT_BUFFER_TOKENS, autoCompactAgentMessagesIfNeeded, createAgentCompactionState, estimateAgentMessagesTokens, type AgentCompactionConfig, type AgentCompactionState, type ContextRepository, type Model, type AgentMessage, type LlmCompletionPort } from '@zhin.js/ai';\nimport type { CompactionConfig } from '../config/zhin-agent-config.js';\nimport type { PluginAILoopHookRegistry } from '../plugin-loop-hooks.js';\nimport { resolveWorkspacePrompt } from '../prompt/workspace-prompt.js';\n\nexport interface CompactionContextHost {\n readonly contextRepository: ContextRepository;\n}\n\nexport interface CompactionRuntimeOptions {\n host: CompactionContextHost;\n sessionId: string;\n model: Model;\n transport: LlmCompletionPort;\n compactionConfig?: CompactionConfig;\n contextWindow: number;\n mode?: 'text' | 'multimodal';\n customInstructions?: string;\n /** Tokens the system prompt and tool schemas take from the window. */\n reservedTokens?: number;\n /** Rendered \"already told\" ledger; the summary must keep these facts. */\n toldLedger?: string;\n force?: boolean;\n loopHooks?: PluginAILoopHookRegistry | null;\n}\n\n/** Default share of the context window that triggers auto-compaction (#730). */\nexport const DEFAULT_COMPACTION_THRESHOLD_RATIO = 0.7;\n\n/**\n * Message-token count above which auto-compaction runs: `thresholdRatio` of\n * the window minus what the system prompt and tools already use. It never\n * exceeds the older \"window minus buffer\" rule (when that is positive).\n * Fixed request tokens are deducted from both limits; retention cannot raise\n * the trigger beyond the available window.\n */\nexport function resolveCompactionTriggerTokens(\n compaction: CompactionConfig | undefined,\n contextWindow: number,\n reservedTokens = 0,\n): number {\n const ratio = compaction?.thresholdRatio ?? DEFAULT_COMPACTION_THRESHOLD_RATIO;\n const target = Math.floor(contextWindow * ratio);\n const ceiling = contextWindow - AUTOCOMPACT_BUFFER_TOKENS;\n const bounded = ceiling > 0 ? Math.min(target, ceiling) : target;\n return Math.max(0, bounded - Math.max(0, reservedTokens));\n}\n\nfunction resolveAgentCompactionConfig(\n compaction: CompactionConfig | undefined,\n contextWindow: number,\n reservedTokens?: number,\n): AgentCompactionConfig {\n const triggerTokens = resolveCompactionTriggerTokens(compaction, contextWindow, reservedTokens);\n // 沿用触发预算与保留预算的 1.25 倍余量;小窗口收紧保留量,不抬高触发线。\n const keepRecentTokens = Math.min(compaction?.keepRecentTokens ?? 20_000, Math.floor(triggerTokens / 1.25));\n return {\n enabled: compaction?.enabled !== false,\n auto: compaction?.auto !== false,\n keepRecentTokens,\n minKeepCount: compaction?.minKeepCount ?? 2,\n contextWindow,\n triggerTokens,\n };\n}\n\nconst TOLD_LEDGER_SUMMARY_INSTRUCTION = 'The user has already been told the following. Keep every item in the summary '\n + '(under \"Already told the user\") so later turns neither repeat nor contradict them:';\n\nconst MAX_COMPACTION_SESSIONS = 5000;\n\n/** One Agent host's compaction state and operations. */\nexport class AgentCompactionRuntime {\n readonly #states = new Map<string, AgentCompactionState>();\n\n constructor(private readonly maxSessions: number = MAX_COMPACTION_SESSIONS) {\n if (!Number.isInteger(maxSessions) || maxSessions < 1) {\n throw new TypeError('AgentCompactionRuntime maxSessions must be a positive integer');\n }\n }\n\n get stateCount(): number {\n return this.#states.size;\n }\n\n clear(): void {\n this.#states.clear();\n }\n\n touch(sessionId: string): void {\n const state = this.#states.get(sessionId);\n if (!state) return;\n this.#states.delete(sessionId);\n this.#states.set(sessionId, state);\n }\n\n evictIfOverPressure(): number {\n if (this.#states.size <= this.maxSessions * 0.8) return 0;\n const targetSize = Math.floor(this.maxSessions * 0.6);\n let removed = 0;\n for (const sessionId of this.#states.keys()) {\n if (this.#states.size <= targetSize) break;\n this.#states.delete(sessionId);\n removed += 1;\n }\n return removed;\n }\n\n async transformContext(\n messages: AgentMessage[],\n signal: AbortSignal | undefined,\n options: CompactionRuntimeOptions,\n ): Promise<AgentMessage[]> {\n if (signal?.aborted) return messages;\n\n const cfg = resolveAgentCompactionConfig(options.compactionConfig, options.contextWindow, options.reservedTokens);\n if (cfg.enabled === false) return messages;\n\n const state = this.#getState(options.sessionId);\n const compactionPrompt = resolveWorkspacePrompt('compaction', options.model.sdk);\n const customInstructions = [\n compactionPrompt?.trim(),\n options.customInstructions?.trim(),\n options.toldLedger?.trim() ? `${TOLD_LEDGER_SUMMARY_INSTRUCTION}\\n${options.toldLedger.trim()}` : undefined,\n ].filter(Boolean).join('\\n\\n') || undefined;\n\n const result = await autoCompactAgentMessagesIfNeeded({\n transport: options.transport,\n model: options.model,\n messages,\n config: cfg,\n state,\n force: options.force,\n customInstructions,\n signal,\n });\n\n if (signal?.aborted) return messages;\n if (result.summary?.trim() && result.compactedCount) {\n // Use the actual summarized prefix before micro-compaction replaces objects.\n // Current-turn messages are persisted later; never infer their row boundary\n // by independently applying the retention budget to stored history.\n await options.host.contextRepository.saveCompactionSummary?.(\n options.sessionId,\n result.summary,\n messages.slice(0, result.compactedCount),\n signal,\n );\n }\n if (signal?.aborted) return messages;\n\n let out = result.messages;\n if (options.loopHooks) {\n out = await options.loopHooks.runTransformContext(out, {\n sessionId: options.sessionId,\n signal,\n });\n }\n return out;\n }\n\n async compactSession(\n repo: ContextRepository,\n options: Omit<CompactionRuntimeOptions, 'host'> & { host: CompactionContextHost },\n ): Promise<{ ok: boolean; message: string }> {\n const cfg = resolveAgentCompactionConfig(options.compactionConfig, options.contextWindow, options.reservedTokens);\n if (cfg.enabled === false) {\n return { ok: false, message: 'compaction 已禁用(ai.agent.compaction.enabled=false)' };\n }\n\n const loaded = await repo.loadContext(options.sessionId);\n const messages = loaded.messages;\n if (messages.length < 2) {\n return { ok: false, message: '消息过少,无需压缩' };\n }\n\n const compacted = await this.transformContext(messages, undefined, {\n ...options,\n force: true,\n });\n\n const saved = estimateAgentMessagesTokens(projectPromptContext(messages)) - estimateAgentMessagesTokens(projectPromptContext(compacted));\n return {\n ok: true,\n message: `已压缩会话,约节省 ${Math.max(0, saved)} tokens(保留最近 ~${cfg.keepRecentTokens ?? 20_000} tokens)`,\n };\n }\n\n #getState(sessionId: string): AgentCompactionState {\n const existing = this.#states.get(sessionId);\n if (existing) {\n this.touch(sessionId);\n return existing;\n }\n const state = createAgentCompactionState();\n this.#states.set(sessionId, state);\n return state;\n }\n}\n"]}
|
|
@@ -9,6 +9,8 @@ export function renderAgentOutput(output) {
|
|
|
9
9
|
return String(segment.data.content);
|
|
10
10
|
if (segment.type === 'mention')
|
|
11
11
|
return `@${segment.data.target}`;
|
|
12
|
+
if (segment.type === 'reaction')
|
|
13
|
+
return `[reaction ${String(segment.data.emoji ?? '')}]`;
|
|
12
14
|
return `[${segment.type}]`;
|
|
13
15
|
}).join('')
|
|
14
16
|
: renderToPlainText([element])).join('\n');
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"agent-output.js","sourceRoot":"","sources":["../../src/outbound/agent-output.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,iBAAiB,EAAsB,MAAM,aAAa,CAAC;AASpE,+EAA+E;AAC/E,MAAM,UAAU,iBAAiB,CAAC,MAAqC;IACrE,OAAO,MAAM,CAAC,GAAG,CAAC,OAAO,CAAC,EAAE,CAAC,OAAO,CAAC,IAAI,KAAK,SAAS;QACrD,CAAC,CAAC,OAAO,CAAC,QAAQ,CAAC,GAAG,CAAC,OAAO,CAAC,EAAE;YAC7B,IAAI,OAAO,CAAC,IAAI,KAAK,MAAM;gBAAE,OAAO,MAAM,CAAC,OAAO,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;YAC9D,IAAI,OAAO,CAAC,IAAI,KAAK,UAAU;gBAAE,OAAO,MAAM,CAAC,OAAO,CAAC,IAAI,CAAC,OAAO,CAAC,CAAC;YACrE,IAAI,OAAO,CAAC,IAAI,KAAK,SAAS;gBAAE,OAAO,IAAI,OAAO,CAAC,IAAI,CAAC,MAAM,EAAE,CAAC;YACjE,OAAO,IAAI,OAAO,CAAC,IAAI,GAAG,CAAC;QAC7B,CAAC,CAAC,CAAC,IAAI,CAAC,EAAE,CAAC;QACb,CAAC,CAAC,iBAAiB,CAAC,CAAC,OAAO,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;AAC/C,CAAC;AAED;;;GAGG;AACH,MAAM,UAAU,mBAAmB,CAAC,MAAqC;IACvE,MAAM,QAAQ,GAA2B,EAAE,CAAC;IAC5C,KAAK,MAAM,OAAO,IAAI,MAAM,EAAE,CAAC;QAC7B,IAAI,OAAO,CAAC,IAAI,KAAK,SAAS,IAAI,QAAQ,CAAC,MAAM,KAAK,CAAC;YAAE,QAAQ,CAAC,IAAI,CAAC,CAAC,OAAO,CAAC,CAAC,CAAC;;YAC7E,QAAQ,CAAC,EAAE,CAAC,CAAC,CAAC,CAAE,CAAC,IAAI,CAAC,OAAO,CAAC,CAAC;IACtC,CAAC;IACD,OAAO,QAAQ,CAAC;AAClB,CAAC","sourcesContent":["import { renderToPlainText, type OutputElement } from '@zhin.js/ai';\nimport type { Segment } from '@zhin.js/im-contract';\n\n/** IM output belongs to Agent; the reusable AI engine stays transport-neutral. */\nexport type AgentOutputElement = OutputElement | {\n type: 'message';\n segments: Segment[];\n};\n\n/** Human-readable projection for logs, metrics and the already-told ledger. */\nexport function renderAgentOutput(output: readonly AgentOutputElement[]): string {\n return output.map(element => element.type === 'message'\n ? element.segments.map(segment => {\n if (segment.type === 'text') return String(segment.data.text);\n if (segment.type === 'markdown') return String(segment.data.content);\n if (segment.type === 'mention') return `@${segment.data.target}`;\n return `[${segment.type}]`;\n }).join('')\n : renderToPlainText([element])).join('\\n');\n}\n\n/**\n * Chat messages in send order: every `message` element starts its own message;\n * other elements (tool media, cards, text) join the message before them.\n */\nexport function splitOutputMessages(output: readonly AgentOutputElement[]): AgentOutputElement[][] {\n const messages: AgentOutputElement[][] = [];\n for (const element of output) {\n if (element.type === 'message' || messages.length === 0) messages.push([element]);\n else messages.at(-1)!.push(element);\n }\n return messages;\n}\n"]}
|
|
1
|
+
{"version":3,"file":"agent-output.js","sourceRoot":"","sources":["../../src/outbound/agent-output.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,iBAAiB,EAAsB,MAAM,aAAa,CAAC;AASpE,+EAA+E;AAC/E,MAAM,UAAU,iBAAiB,CAAC,MAAqC;IACrE,OAAO,MAAM,CAAC,GAAG,CAAC,OAAO,CAAC,EAAE,CAAC,OAAO,CAAC,IAAI,KAAK,SAAS;QACrD,CAAC,CAAC,OAAO,CAAC,QAAQ,CAAC,GAAG,CAAC,OAAO,CAAC,EAAE;YAC7B,IAAI,OAAO,CAAC,IAAI,KAAK,MAAM;gBAAE,OAAO,MAAM,CAAC,OAAO,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;YAC9D,IAAI,OAAO,CAAC,IAAI,KAAK,UAAU;gBAAE,OAAO,MAAM,CAAC,OAAO,CAAC,IAAI,CAAC,OAAO,CAAC,CAAC;YACrE,IAAI,OAAO,CAAC,IAAI,KAAK,SAAS;gBAAE,OAAO,IAAI,OAAO,CAAC,IAAI,CAAC,MAAM,EAAE,CAAC;YACjE,IAAI,OAAO,CAAC,IAAI,KAAK,UAAU;gBAAE,OAAO,aAAa,MAAM,CAAE,OAAO,CAAC,IAA4B,CAAC,KAAK,IAAI,EAAE,CAAC,GAAG,CAAC;YAClH,OAAO,IAAI,OAAO,CAAC,IAAI,GAAG,CAAC;QAC7B,CAAC,CAAC,CAAC,IAAI,CAAC,EAAE,CAAC;QACb,CAAC,CAAC,iBAAiB,CAAC,CAAC,OAAO,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;AAC/C,CAAC;AAED;;;GAGG;AACH,MAAM,UAAU,mBAAmB,CAAC,MAAqC;IACvE,MAAM,QAAQ,GAA2B,EAAE,CAAC;IAC5C,KAAK,MAAM,OAAO,IAAI,MAAM,EAAE,CAAC;QAC7B,IAAI,OAAO,CAAC,IAAI,KAAK,SAAS,IAAI,QAAQ,CAAC,MAAM,KAAK,CAAC;YAAE,QAAQ,CAAC,IAAI,CAAC,CAAC,OAAO,CAAC,CAAC,CAAC;;YAC7E,QAAQ,CAAC,EAAE,CAAC,CAAC,CAAC,CAAE,CAAC,IAAI,CAAC,OAAO,CAAC,CAAC;IACtC,CAAC;IACD,OAAO,QAAQ,CAAC;AAClB,CAAC","sourcesContent":["import { renderToPlainText, type OutputElement } from '@zhin.js/ai';\nimport type { Segment } from '@zhin.js/im-contract';\n\n/** IM output belongs to Agent; the reusable AI engine stays transport-neutral. */\nexport type AgentOutputElement = OutputElement | {\n type: 'message';\n segments: Segment[];\n};\n\n/** Human-readable projection for logs, metrics and the already-told ledger. */\nexport function renderAgentOutput(output: readonly AgentOutputElement[]): string {\n return output.map(element => element.type === 'message'\n ? element.segments.map(segment => {\n if (segment.type === 'text') return String(segment.data.text);\n if (segment.type === 'markdown') return String(segment.data.content);\n if (segment.type === 'mention') return `@${segment.data.target}`;\n if (segment.type === 'reaction') return `[reaction ${String((segment.data as { emoji?: unknown }).emoji ?? '')}]`;\n return `[${segment.type}]`;\n }).join('')\n : renderToPlainText([element])).join('\\n');\n}\n\n/**\n * Chat messages in send order: every `message` element starts its own message;\n * other elements (tool media, cards, text) join the message before them.\n */\nexport function splitOutputMessages(output: readonly AgentOutputElement[]): AgentOutputElement[][] {\n const messages: AgentOutputElement[][] = [];\n for (const element of output) {\n if (element.type === 'message' || messages.length === 0) messages.push([element]);\n else messages.at(-1)!.push(element);\n }\n return messages;\n}\n"]}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What the bot says in a chat when a turn failed.
|
|
3
|
+
*
|
|
4
|
+
* A group member does not explain its outages: it says it lagged and asks again. Members get only
|
|
5
|
+
* that line; the owner (master), who runs the bot, also gets a short cause in brackets. The full
|
|
6
|
+
* error stays in the logs, and non-chat origins keep the precise operator messages.
|
|
7
|
+
*/
|
|
8
|
+
import { type FinishFailureKind, type ProviderErrorKind } from '@zhin.js/ai';
|
|
9
|
+
export type ChatFailureKind = ProviderErrorKind | FinishFailureKind
|
|
10
|
+
/** The reply could not be put into the message format. */
|
|
11
|
+
| 'format'
|
|
12
|
+
/** The whole turn timed out. */
|
|
13
|
+
| 'turn_timeout'
|
|
14
|
+
/** Any other turn error; `detail` carries its message for the owner. */
|
|
15
|
+
| 'turn_error';
|
|
16
|
+
export interface ChatFailureOptions {
|
|
17
|
+
/** The sender is the bot's owner: append the cause. */
|
|
18
|
+
readonly master?: boolean;
|
|
19
|
+
/** Error message for `turn_error`, shown to the owner only (clipped). */
|
|
20
|
+
readonly detail?: string;
|
|
21
|
+
/** 0 ≤ value < 1, for choosing a line; defaults to Math.random. */
|
|
22
|
+
readonly random?: number;
|
|
23
|
+
}
|
|
24
|
+
/** The chat line for a failure; '' when the bot should say nothing (a cancelled request). */
|
|
25
|
+
export declare function chatFailureLine(kind: ChatFailureKind, options?: ChatFailureOptions): string;
|
|
26
|
+
/** Kind of a user-safe failure message from the provider layer; `unknown` for anything else. */
|
|
27
|
+
export declare function chatFailureKindOf(errorMessage: string | undefined): ChatFailureKind;
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What the bot says in a chat when a turn failed.
|
|
3
|
+
*
|
|
4
|
+
* A group member does not explain its outages: it says it lagged and asks again. Members get only
|
|
5
|
+
* that line; the owner (master), who runs the bot, also gets a short cause in brackets. The full
|
|
6
|
+
* error stays in the logs, and non-chat origins keep the precise operator messages.
|
|
7
|
+
*/
|
|
8
|
+
import { providerFailureKind } from '@zhin.js/ai';
|
|
9
|
+
const TRANSIENT_LINES = ['刚卡了一下,你再说一遍?', '脑子短路了一下,再问我一次', '刚走神了,你再说一次'];
|
|
10
|
+
const TIRED_LINES = ['今天先歇会儿,晚点再找我', '有点累了,晚点再聊'];
|
|
11
|
+
const CONFUSED_LINES = ['这段聊太长了我有点乱,换个说法?'];
|
|
12
|
+
const REFUSE_LINES = ['这个我不接'];
|
|
13
|
+
const LINES = {
|
|
14
|
+
rate_limit: TRANSIENT_LINES, timeout: TRANSIENT_LINES, server: TRANSIENT_LINES, network: TRANSIENT_LINES,
|
|
15
|
+
empty_response: TRANSIENT_LINES, malformed_tool_call: TRANSIENT_LINES, provider_error: TRANSIENT_LINES,
|
|
16
|
+
length: TRANSIENT_LINES, bad_request: TRANSIENT_LINES, unknown: TRANSIENT_LINES, format: TRANSIENT_LINES,
|
|
17
|
+
turn_timeout: TRANSIENT_LINES, turn_error: TRANSIENT_LINES,
|
|
18
|
+
quota_exhausted: TIRED_LINES, billing: TIRED_LINES, auth: TIRED_LINES,
|
|
19
|
+
context_overflow: CONFUSED_LINES,
|
|
20
|
+
safety_block: REFUSE_LINES,
|
|
21
|
+
aborted: [],
|
|
22
|
+
};
|
|
23
|
+
/** Short cause for the owner; never shown to other members. */
|
|
24
|
+
const OWNER_CAUSE = {
|
|
25
|
+
rate_limit: '模型限流', timeout: '模型超时', server: '模型服务不可用', network: '连不上模型服务',
|
|
26
|
+
empty_response: '模型回了空', malformed_tool_call: '工具调用格式坏了', provider_error: '模型中途出错',
|
|
27
|
+
length: '输出超长', bad_request: '模型拒绝了请求', unknown: '模型调用失败', format: '回复格式没对上',
|
|
28
|
+
turn_timeout: '处理超时', turn_error: undefined,
|
|
29
|
+
quota_exhausted: '模型额度用完了', billing: '模型账户欠费', auth: '模型 API 密钥有问题',
|
|
30
|
+
context_overflow: '上下文超长', safety_block: '内容被模型拦截', aborted: undefined,
|
|
31
|
+
};
|
|
32
|
+
/** The chat line for a failure; '' when the bot should say nothing (a cancelled request). */
|
|
33
|
+
export function chatFailureLine(kind, options = {}) {
|
|
34
|
+
const lines = LINES[kind];
|
|
35
|
+
if (lines.length === 0)
|
|
36
|
+
return '';
|
|
37
|
+
const line = lines[Math.floor((options.random ?? Math.random()) * lines.length)] ?? lines[0];
|
|
38
|
+
if (!options.master)
|
|
39
|
+
return line;
|
|
40
|
+
const cause = kind === 'turn_error' ? clip(options.detail) : OWNER_CAUSE[kind];
|
|
41
|
+
return cause ? `${line}(${cause})` : line;
|
|
42
|
+
}
|
|
43
|
+
/** Kind of a user-safe failure message from the provider layer; `unknown` for anything else. */
|
|
44
|
+
export function chatFailureKindOf(errorMessage) {
|
|
45
|
+
return providerFailureKind(errorMessage) ?? 'unknown';
|
|
46
|
+
}
|
|
47
|
+
function clip(detail) {
|
|
48
|
+
const text = detail?.replace(/\s+/g, ' ').trim();
|
|
49
|
+
if (!text)
|
|
50
|
+
return undefined;
|
|
51
|
+
return text.length > 60 ? `${text.slice(0, 60)}…` : text;
|
|
52
|
+
}
|
|
53
|
+
//# sourceMappingURL=chat-failure.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"chat-failure.js","sourceRoot":"","sources":["../../src/outbound/chat-failure.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * What the bot says in a chat when a turn failed.\n *\n * A group member does not explain its outages: it says it lagged and asks again. Members get only\n * that line; the owner (master), who runs the bot, also gets a short cause in brackets. The full\n * error stays in the logs, and non-chat origins keep the precise operator messages.\n */\nimport { providerFailureKind, type FinishFailureKind, type ProviderErrorKind } from '@zhin.js/ai';\n\nexport type ChatFailureKind =\n | ProviderErrorKind\n | FinishFailureKind\n /** The reply could not be put into the message format. */\n | 'format'\n /** The whole turn timed out. */\n | 'turn_timeout'\n /** Any other turn error; `detail` carries its message for the owner. */\n | 'turn_error';\n\nconst TRANSIENT_LINES = ['刚卡了一下,你再说一遍?', '脑子短路了一下,再问我一次', '刚走神了,你再说一次'] as const;\nconst TIRED_LINES = ['今天先歇会儿,晚点再找我', '有点累了,晚点再聊'] as const;\nconst CONFUSED_LINES = ['这段聊太长了我有点乱,换个说法?'] as const;\nconst REFUSE_LINES = ['这个我不接'] as const;\n\nconst LINES: Readonly<Record<ChatFailureKind, readonly string[]>> = {\n rate_limit: TRANSIENT_LINES, timeout: TRANSIENT_LINES, server: TRANSIENT_LINES, network: TRANSIENT_LINES,\n empty_response: TRANSIENT_LINES, malformed_tool_call: TRANSIENT_LINES, provider_error: TRANSIENT_LINES,\n length: TRANSIENT_LINES, bad_request: TRANSIENT_LINES, unknown: TRANSIENT_LINES, format: TRANSIENT_LINES,\n turn_timeout: TRANSIENT_LINES, turn_error: TRANSIENT_LINES,\n quota_exhausted: TIRED_LINES, billing: TIRED_LINES, auth: TIRED_LINES,\n context_overflow: CONFUSED_LINES,\n safety_block: REFUSE_LINES,\n aborted: [],\n};\n\n/** Short cause for the owner; never shown to other members. */\nconst OWNER_CAUSE: Readonly<Record<ChatFailureKind, string | undefined>> = {\n rate_limit: '模型限流', timeout: '模型超时', server: '模型服务不可用', network: '连不上模型服务',\n empty_response: '模型回了空', malformed_tool_call: '工具调用格式坏了', provider_error: '模型中途出错',\n length: '输出超长', bad_request: '模型拒绝了请求', unknown: '模型调用失败', format: '回复格式没对上',\n turn_timeout: '处理超时', turn_error: undefined,\n quota_exhausted: '模型额度用完了', billing: '模型账户欠费', auth: '模型 API 密钥有问题',\n context_overflow: '上下文超长', safety_block: '内容被模型拦截', aborted: undefined,\n};\n\nexport interface ChatFailureOptions {\n /** The sender is the bot's owner: append the cause. */\n readonly master?: boolean;\n /** Error message for `turn_error`, shown to the owner only (clipped). */\n readonly detail?: string;\n /** 0 ≤ value < 1, for choosing a line; defaults to Math.random. */\n readonly random?: number;\n}\n\n/** The chat line for a failure; '' when the bot should say nothing (a cancelled request). */\nexport function chatFailureLine(kind: ChatFailureKind, options: ChatFailureOptions = {}): string {\n const lines = LINES[kind];\n if (lines.length === 0) return '';\n const line = lines[Math.floor((options.random ?? Math.random()) * lines.length)] ?? lines[0]!;\n if (!options.master) return line;\n const cause = kind === 'turn_error' ? clip(options.detail) : OWNER_CAUSE[kind];\n return cause ? `${line}(${cause})` : line;\n}\n\n/** Kind of a user-safe failure message from the provider layer; `unknown` for anything else. */\nexport function chatFailureKindOf(errorMessage: string | undefined): ChatFailureKind {\n return providerFailureKind(errorMessage) ?? 'unknown';\n}\n\nfunction clip(detail: string | undefined): string | undefined {\n const text = detail?.replace(/\\s+/g, ' ').trim();\n if (!text) return undefined;\n return text.length > 60 ? `${text.slice(0, 60)}…` : text;\n}\n"]}
|
|
@@ -1,9 +1,16 @@
|
|
|
1
1
|
import { type AiOutboundSegmentProfile } from '@zhin.js/core';
|
|
2
2
|
import type { AgentOutputElement } from './agent-output.js';
|
|
3
|
+
/** Segment of the internal message that carries a reaction; the reply port applies it, never sends it. */
|
|
4
|
+
export declare const REACTION_SEGMENT_TYPE = "reaction";
|
|
3
5
|
/** Provider-facing schema for exactly this endpoint profile (cached, stable identity). */
|
|
4
6
|
export declare function structuredReplySchema(profile?: AiOutboundSegmentProfile): Record<string, unknown>;
|
|
5
7
|
/** The final-reply contract for this endpoint; lists only the segment types it can deliver. */
|
|
6
8
|
export declare function structuredReplyInstruction(profile?: AiOutboundSegmentProfile): string;
|
|
7
9
|
export declare const STRUCTURED_REPLY_INSTRUCTION: string;
|
|
10
|
+
/**
|
|
11
|
+
* Tool-call markup a model wrote as text because the provider did not parse it (DeepSeek DSML,
|
|
12
|
+
* XML-style function calls). It is never a chat message, whatever else the reply contains.
|
|
13
|
+
*/
|
|
14
|
+
export declare const TOOL_CALL_MARKUP: RegExp;
|
|
8
15
|
/** Reject the whole response on mismatch: never deliver raw JSON or partial segments. */
|
|
9
16
|
export declare function decodeStructuredReply(raw: string, profile?: AiOutboundSegmentProfile): AgentOutputElement[];
|
|
@@ -3,27 +3,30 @@ import { aiOutboundJsonSchema, buildAiOutboundJsonSchema, DEFAULT_AI_OUTBOUND_SE
|
|
|
3
3
|
const ajv = new Ajv();
|
|
4
4
|
// Compiled, stateless contract validators keyed by segment set; no generation-owned data.
|
|
5
5
|
const validators = new Map();
|
|
6
|
+
/** Segment of the internal message that carries a reaction; the reply port applies it, never sends it. */
|
|
7
|
+
export const REACTION_SEGMENT_TYPE = 'reaction';
|
|
6
8
|
const DEFAULT_PROFILE = { types: DEFAULT_AI_OUTBOUND_SEGMENT_TYPES };
|
|
7
9
|
const schemas = new Map([[key(DEFAULT_PROFILE), aiOutboundJsonSchema]]);
|
|
8
10
|
function key(profile) {
|
|
9
|
-
return JSON.stringify([[...profile.types].sort(), profile.extensions ?? {}]);
|
|
11
|
+
return JSON.stringify([[...profile.types].sort(), profile.extensions ?? {}, profile.reaction?.values.map(item => item.value) ?? []]);
|
|
10
12
|
}
|
|
11
13
|
/** Provider-facing schema for exactly this endpoint profile (cached, stable identity). */
|
|
12
14
|
export function structuredReplySchema(profile = DEFAULT_PROFILE) {
|
|
13
15
|
const id = key(profile);
|
|
14
16
|
let schema = schemas.get(id);
|
|
15
17
|
if (!schema)
|
|
16
|
-
schemas.set(id, schema = buildAiOutboundJsonSchema(profile.types, profile.extensions));
|
|
18
|
+
schemas.set(id, schema = buildAiOutboundJsonSchema(profile.types, profile.extensions, profile.reaction));
|
|
17
19
|
return schema;
|
|
18
20
|
}
|
|
19
21
|
const SEGMENT_GUIDE = [
|
|
20
|
-
[['text'], '
|
|
21
|
-
[['mention'], '
|
|
22
|
-
[['markdown'], '
|
|
23
|
-
[['image', 'audio', 'video', 'file'], '
|
|
24
|
-
[['keyboard'], '
|
|
25
|
-
[['share'], '
|
|
26
|
-
[['dice'
|
|
22
|
+
[['text'], 'plain text.'],
|
|
23
|
+
[['mention'], 'target must be a real platform user ID string ("123", not 123), from context or member lookup; never an endpoint ID, role or guessed nickname. Use Sender id for the sender; look up others by name, ask for clarification in text if missing/ambiguous. Plain @name does not notify. Reply is already in this conversation: mention only to call someone in.'],
|
|
24
|
+
[['markdown'], 'formatted text (lists, bold, code, tables); degrades to plain text where unsupported.'],
|
|
25
|
+
[['image', 'audio', 'video', 'file'], 'media.kind is url, path (workspace file) or base64. Use only media produced or given to you, as separate segments, never Markdown image syntax.'],
|
|
26
|
+
[['keyboard'], 'quick-reply buttons: at most 5 per row; tapping sends payload as the next user message. Use only for concrete next steps, after the related text.'],
|
|
27
|
+
[['share'], 'link card with url and title.'],
|
|
28
|
+
[['dice'], 'platform random dice.'],
|
|
29
|
+
[['rps'], 'platform random rock-paper-scissors.'],
|
|
27
30
|
];
|
|
28
31
|
/** The final-reply contract for this endpoint; lists only the segment types it can deliver. */
|
|
29
32
|
export function structuredReplyInstruction(profile = DEFAULT_PROFILE) {
|
|
@@ -33,17 +36,29 @@ export function structuredReplyInstruction(profile = DEFAULT_PROFILE) {
|
|
|
33
36
|
return hint ? [`- ${type} on this platform: ${hint}`] : [];
|
|
34
37
|
});
|
|
35
38
|
return [
|
|
36
|
-
'
|
|
37
|
-
'Each item of messages is one chat message, sent in order. Usually send one; split into two or three only where a person would send separate messages, such as a quick reaction followed by the answer. Example: {"messages":[{"segments":[{"type":"text","data":{"text":"调好了"}}]},{"segments":[{"type":"text","data":{"text":"你再试试"}}]}]}.',
|
|
39
|
+
'Final reply: JSON matching the supplied schema, {"messages":[{"segments":[...]}]}, no fence/prose. Each item is one chat message; preserve message and segment order. Usually one message; split into 2–3 only where natural.',
|
|
38
40
|
`Segment types available on this platform: ${types.join(', ')}.`,
|
|
39
|
-
...SEGMENT_GUIDE.
|
|
41
|
+
...SEGMENT_GUIDE.flatMap(([covers, line]) => {
|
|
42
|
+
const offered = covers.filter(type => types.includes(type));
|
|
43
|
+
return offered.length ? [`- ${offered.join(' / ')}: ${line}`] : [];
|
|
44
|
+
}),
|
|
40
45
|
...hints,
|
|
41
|
-
'
|
|
42
|
-
|
|
43
|
-
'Business tool calls use their normal tool schemas; this message schema describes only the final user-facing answer, including the json response tool when supplied. Use {"messages":[]} only when no reply is needed.',
|
|
46
|
+
'Never invent IDs or references. Business calls use their own tool schemas; this schema is only for the final answer (including the json response tool). Use {"messages":[]} only when no reply is needed.',
|
|
47
|
+
...(profile.reaction ? reactionInstruction(profile.reaction.values) : []),
|
|
44
48
|
].join('\n');
|
|
45
49
|
}
|
|
50
|
+
function reactionInstruction(values) {
|
|
51
|
+
return [
|
|
52
|
+
`"reaction" reacts to the message being answered: ${values.map(item => `${item.value} ${item.label}`).join(', ')}; "" for none.`,
|
|
53
|
+
'For thanks, agreement, jokes or news needing no words, {"messages":[],"reaction":"<value>"} may suffice. A short message may accompany it; avoid repeating its meaning.',
|
|
54
|
+
];
|
|
55
|
+
}
|
|
46
56
|
export const STRUCTURED_REPLY_INSTRUCTION = structuredReplyInstruction();
|
|
57
|
+
/**
|
|
58
|
+
* Tool-call markup a model wrote as text because the provider did not parse it (DeepSeek DSML,
|
|
59
|
+
* XML-style function calls). It is never a chat message, whatever else the reply contains.
|
|
60
|
+
*/
|
|
61
|
+
export const TOOL_CALL_MARKUP = /<[||]{1,2}\s*DSML|<\/?(?:invoke|tool_call|tool_calls|function_calls?|antml:[a-z_]+)\b/iu;
|
|
47
62
|
/** Reject the whole response on mismatch: never deliver raw JSON or partial segments. */
|
|
48
63
|
export function decodeStructuredReply(raw, profile = DEFAULT_PROFILE) {
|
|
49
64
|
let value;
|
|
@@ -57,6 +72,15 @@ export function decodeStructuredReply(raw, profile = DEFAULT_PROFILE) {
|
|
|
57
72
|
if (value && typeof value === 'object' && !Array.isArray(value) && 'segments' in value && !('messages' in value)) {
|
|
58
73
|
value = { messages: [value] };
|
|
59
74
|
}
|
|
75
|
+
// "reaction" is required only to satisfy strict schemas; a reply without it simply has none.
|
|
76
|
+
if (profile.reaction && value && typeof value === 'object' && !Array.isArray(value) && !('reaction' in value)) {
|
|
77
|
+
value = { ...value, reaction: '' };
|
|
78
|
+
}
|
|
79
|
+
// Where reactions are not offered, a reaction the model wrote anyway is dropped, not a format error.
|
|
80
|
+
if (!profile.reaction && value && typeof value === 'object' && !Array.isArray(value) && 'reaction' in value) {
|
|
81
|
+
const { reaction: _ignored, ...rest } = value;
|
|
82
|
+
value = rest;
|
|
83
|
+
}
|
|
60
84
|
const id = key(profile);
|
|
61
85
|
let validateReply = validators.get(id);
|
|
62
86
|
if (!validateReply)
|
|
@@ -72,8 +96,20 @@ export function decodeStructuredReply(raw, profile = DEFAULT_PROFILE) {
|
|
|
72
96
|
.map(error => `${error.instancePath || '/'} ${error.message}`).join('; ');
|
|
73
97
|
throw new Error(`Agent final reply does not match the message schema: ${detail}`);
|
|
74
98
|
}
|
|
75
|
-
|
|
99
|
+
for (const message of value.messages) {
|
|
100
|
+
for (const segment of message.segments) {
|
|
101
|
+
const data = segment.data;
|
|
102
|
+
if ([data.text, data.content].some(field => typeof field === 'string' && TOOL_CALL_MARKUP.test(field))) {
|
|
103
|
+
throw new Error('Agent final reply contains tool-call markup');
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
const messages = value.messages.filter(message => message.segments.length > 0)
|
|
76
108
|
.map(message => ({ type: 'message', segments: message.segments.map(segment => withoutEmptyModelFields(segment, profile)) }));
|
|
109
|
+
// React first, then talk, like a person tapping a reaction before typing.
|
|
110
|
+
return value.reaction
|
|
111
|
+
? [{ type: 'message', segments: [{ type: REACTION_SEGMENT_TYPE, data: { emoji: value.reaction } }] }, ...messages]
|
|
112
|
+
: messages;
|
|
77
113
|
}
|
|
78
114
|
function stringifyNumbersWhereStringsExpected(value, errors) {
|
|
79
115
|
for (const error of errors) {
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"structured-reply.js","sourceRoot":"","sources":["../../src/outbound/structured-reply.ts"],"names":[],"mappings":"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","sourcesContent":["import Ajv, { type ErrorObject, type ValidateFunction } from 'ajv';\nimport {\n aiOutboundJsonSchema,\n buildAiOutboundJsonSchema,\n DEFAULT_AI_OUTBOUND_SEGMENT_TYPES,\n type AiOutboundSegmentProfile,\n type AiOutboundSegmentType,\n} from '@zhin.js/core';\nimport type { Segment } from '@zhin.js/im-contract';\nimport type { AgentOutputElement } from './agent-output.js';\n\nconst ajv = new Ajv();\n// Compiled, stateless contract validators keyed by segment set; no generation-owned data.\nconst validators = new Map<string, ValidateFunction<StructuredReply>>();\n\ninterface StructuredReply {\n messages: { segments: Segment[] }[];\n}\nconst DEFAULT_PROFILE: AiOutboundSegmentProfile = { types: DEFAULT_AI_OUTBOUND_SEGMENT_TYPES };\nconst schemas = new Map<string, Record<string, unknown>>([[key(DEFAULT_PROFILE), aiOutboundJsonSchema]]);\n\nfunction key(profile: AiOutboundSegmentProfile): string {\n return JSON.stringify([[...profile.types].sort(), profile.extensions ?? {}]);\n}\n\n/** Provider-facing schema for exactly this endpoint profile (cached, stable identity). */\nexport function structuredReplySchema(profile: AiOutboundSegmentProfile = DEFAULT_PROFILE): Record<string, unknown> {\n const id = key(profile);\n let schema = schemas.get(id);\n if (!schema) schemas.set(id, schema = buildAiOutboundJsonSchema(profile.types, profile.extensions));\n return schema;\n}\n\nconst SEGMENT_GUIDE: readonly (readonly [readonly AiOutboundSegmentType[], string])[] = [\n [['text'], 'text: {\"type\":\"text\",\"data\":{\"text\":\"...\"}} for plain text.'],\n [['mention'], 'mention: {\"type\":\"mention\",\"data\":{\"target\":\"<user id>\"}}. The target is a real user platform ID from the current conversation context or a member lookup tool result, never an endpoint ID, role or guessed nickname, and always a JSON string even for numeric IDs (\"123\", never 123). Use the Sender id to mention the current sender; to mention someone else by name, look the member up with the available platform tools; if the name is ambiguous or no ID is available, ask for clarification in a text segment.'],\n [['markdown'], 'markdown: {\"type\":\"markdown\",\"data\":{\"content\":\"...\"}} for formatted text (lists, bold, code, tables); platforms without Markdown receive plain text.'],\n [['image', 'audio', 'video', 'file'], 'image / audio / video / file: {\"type\":\"image\",\"data\":{\"media\":{\"kind\":\"url\",\"value\":\"https://...\"}}}; kind is url, path (a workspace file) or base64. Only reference media you produced or were given; never Markdown image syntax.'],\n [['keyboard'], 'keyboard: {\"type\":\"keyboard\",\"data\":{\"rows\":[[{\"label\":\"查看详情\",\"payload\":\"/detail 1\"}]]}} offers quick-reply buttons (at most 5 per row); tapping one sends its payload as the user\\'s next message. Place it after the text it belongs to and use it only for concrete next steps.'],\n [['share'], 'share: {\"type\":\"share\",\"data\":{\"url\":\"https://...\",\"title\":\"...\"}} for a link card.'],\n [['dice', 'rps'], 'dice / rps: {\"type\":\"dice\",\"data\":{}} or {\"type\":\"rps\",\"data\":{}} sends the platform\\'s random dice or rock-paper-scissors.'],\n];\n\n/** The final-reply contract for this endpoint; lists only the segment types it can deliver. */\nexport function structuredReplyInstruction(profile: AiOutboundSegmentProfile = DEFAULT_PROFILE): string {\n const { types } = profile;\n const hints = types.flatMap(type => {\n const hint = profile.extensions?.[type]?.hint;\n return hint ? [`- ${type} on this platform: ${hint}`] : [];\n });\n return [\n 'Your final reply must be a JSON object matching the supplied schema: {\"messages\":[{\"segments\":[...]}]} with no Markdown fence or surrounding prose.',\n 'Each item of messages is one chat message, sent in order. Usually send one; split into two or three only where a person would send separate messages, such as a quick reaction followed by the answer. Example: {\"messages\":[{\"segments\":[{\"type\":\"text\",\"data\":{\"text\":\"调好了\"}}]},{\"segments\":[{\"type\":\"text\",\"data\":{\"text\":\"你再试试\"}}]}]}.',\n `Segment types available on this platform: ${types.join(', ')}.`,\n ...SEGMENT_GUIDE.filter(([covers]) => covers.some(type => types.includes(type))).map(([, line]) => `- ${line}`),\n ...hints,\n 'Do not invent IDs or media references. Plain @name text does not notify anyone. Keep mentions, media and buttons as their own segments and preserve the intended order.',\n 'The reply already goes to this conversation: mention someone only to call that person in.',\n 'Business tool calls use their normal tool schemas; this message schema describes only the final user-facing answer, including the json response tool when supplied. Use {\"messages\":[]} only when no reply is needed.',\n ].join('\\n');\n}\n\nexport const STRUCTURED_REPLY_INSTRUCTION = structuredReplyInstruction();\n\n/** Reject the whole response on mismatch: never deliver raw JSON or partial segments. */\nexport function decodeStructuredReply(\n raw: string,\n profile: AiOutboundSegmentProfile = DEFAULT_PROFILE,\n): AgentOutputElement[] {\n let value: unknown;\n try { value = JSON.parse(raw); } catch { throw new Error('Agent final reply is not valid JSON'); }\n // Replies persisted before multi-message replies are a single {segments} message; models may echo it.\n if (value && typeof value === 'object' && !Array.isArray(value) && 'segments' in value && !('messages' in value)) {\n value = { messages: [value] };\n }\n const id = key(profile);\n let validateReply = validators.get(id);\n if (!validateReply) validators.set(id, validateReply = ajv.compile<StructuredReply>(structuredReplySchema(profile)));\n // Models often write IDs (mention targets, reply ids) as JSON numbers; a number where the schema\n // wants a string is the same ID, not a format error worth a correction call.\n if (!validateReply(value)) stringifyNumbersWhereStringsExpected(value, validateReply.errors ?? []);\n if (!validateReply(value)) {\n const errors = validateReply.errors ?? [];\n const fields = errors.filter(error => error.keyword === 'type' && error.instancePath.includes('/data/'));\n const detail = (fields.length ? fields : errors).slice(0, 8)\n .map(error => `${error.instancePath || '/'} ${error.message}`).join('; ');\n throw new Error(`Agent final reply does not match the message schema: ${detail}`);\n }\n return value.messages.filter(message => message.segments.length > 0)\n .map(message => ({ type: 'message', segments: message.segments.map(segment => withoutEmptyModelFields(segment, profile)) }));\n}\n\nfunction stringifyNumbersWhereStringsExpected(value: unknown, errors: readonly ErrorObject[]): void {\n for (const error of errors) {\n if (error.keyword !== 'type' || (error.params as { type?: unknown }).type !== 'string') continue;\n const path = error.instancePath.split('/').slice(1).map(part => part.replaceAll('~1', '/').replaceAll('~0', '~'));\n const field = path.pop();\n let parent: unknown = value;\n for (const part of path) parent = (parent as Record<string, unknown> | undefined)?.[part];\n if (field === undefined || !parent || typeof parent !== 'object') continue;\n const current = (parent as Record<string, unknown>)[field];\n if (typeof current === 'number' && Number.isSafeInteger(current)) (parent as Record<string, unknown>)[field] = String(current);\n }\n}\n\n/** Declared extension fields are required in the schema; \"\" means the model had no value. */\nfunction withoutEmptyModelFields(segment: Segment, profile: AiOutboundSegmentProfile): Segment {\n const fields = profile.extensions?.[segment.type as AiOutboundSegmentType]?.fields;\n const data = segment.data as Record<string, unknown>;\n if (!fields?.some(field => data[field] === '')) return segment;\n return { ...segment, data: Object.fromEntries(Object.entries(data)\n .filter(([field, value]) => !(fields.includes(field) && value === ''))) } as Segment;\n}\n"]}
|
|
1
|
+
{"version":3,"file":"structured-reply.js","sourceRoot":"","sources":["../../src/outbound/structured-reply.ts"],"names":[],"mappings":"AAAA,OAAO,GAAgD,MAAM,KAAK,CAAC;AACnE,OAAO,EACL,oBAAoB,EACpB,yBAAyB,EACzB,iCAAiC,GAGlC,MAAM,eAAe,CAAC;AAIvB,MAAM,GAAG,GAAG,IAAI,GAAG,EAAE,CAAC;AACtB,0FAA0F;AAC1F,MAAM,UAAU,GAAG,IAAI,GAAG,EAA6C,CAAC;AAOxE,0GAA0G;AAC1G,MAAM,CAAC,MAAM,qBAAqB,GAAG,UAAU,CAAC;AAChD,MAAM,eAAe,GAA6B,EAAE,KAAK,EAAE,iCAAiC,EAAE,CAAC;AAC/F,MAAM,OAAO,GAAG,IAAI,GAAG,CAAkC,CAAC,CAAC,GAAG,CAAC,eAAe,CAAC,EAAE,oBAAoB,CAAC,CAAC,CAAC,CAAC;AAEzG,SAAS,GAAG,CAAC,OAAiC;IAC5C,OAAO,IAAI,CAAC,SAAS,CAAC,CAAC,CAAC,GAAG,OAAO,CAAC,KAAK,CAAC,CAAC,IAAI,EAAE,EAAE,OAAO,CAAC,UAAU,IAAI,EAAE,EAAE,OAAO,CAAC,QAAQ,EAAE,MAAM,CAAC,GAAG,CAAC,IAAI,CAAC,EAAE,CAAC,IAAI,CAAC,KAAK,CAAC,IAAI,EAAE,CAAC,CAAC,CAAC;AACvI,CAAC;AAED,0FAA0F;AAC1F,MAAM,UAAU,qBAAqB,CAAC,UAAoC,eAAe;IACvF,MAAM,EAAE,GAAG,GAAG,CAAC,OAAO,CAAC,CAAC;IACxB,IAAI,MAAM,GAAG,OAAO,CAAC,GAAG,CAAC,EAAE,CAAC,CAAC;IAC7B,IAAI,CAAC,MAAM;QAAE,OAAO,CAAC,GAAG,CAAC,EAAE,EAAE,MAAM,GAAG,yBAAyB,CAAC,OAAO,CAAC,KAAK,EAAE,OAAO,CAAC,UAAU,EAAE,OAAO,CAAC,QAAQ,CAAC,CAAC,CAAC;IACtH,OAAO,MAAM,CAAC;AAChB,CAAC;AAED,MAAM,aAAa,GAAqE;IACtF,CAAC,CAAC,MAAM,CAAC,EAAE,aAAa,CAAC;IACzB,CAAC,CAAC,SAAS,CAAC,EAAE,+VAA+V,CAAC;IAC9W,CAAC,CAAC,UAAU,CAAC,EAAE,uFAAuF,CAAC;IACvG,CAAC,CAAC,OAAO,EAAE,OAAO,EAAE,OAAO,EAAE,MAAM,CAAC,EAAE,iJAAiJ,CAAC;IACxL,CAAC,CAAC,UAAU,CAAC,EAAE,mJAAmJ,CAAC;IACnK,CAAC,CAAC,OAAO,CAAC,EAAE,+BAA+B,CAAC;IAC5C,CAAC,CAAC,MAAM,CAAC,EAAE,uBAAuB,CAAC;IACnC,CAAC,CAAC,KAAK,CAAC,EAAE,sCAAsC,CAAC;CAClD,CAAC;AAEF,+FAA+F;AAC/F,MAAM,UAAU,0BAA0B,CAAC,UAAoC,eAAe;IAC5F,MAAM,EAAE,KAAK,EAAE,GAAG,OAAO,CAAC;IAC1B,MAAM,KAAK,GAAG,KAAK,CAAC,OAAO,CAAC,IAAI,CAAC,EAAE;QACjC,MAAM,IAAI,GAAG,OAAO,CAAC,UAAU,EAAE,CAAC,IAAI,CAAC,EAAE,IAAI,CAAC;QAC9C,OAAO,IAAI,CAAC,CAAC,CAAC,CAAC,KAAK,IAAI,sBAAsB,IAAI,EAAE,CAAC,CAAC,CAAC,CAAC,EAAE,CAAC;IAC7D,CAAC,CAAC,CAAC;IACH,OAAO;QACL,+NAA+N;QAC/N,6CAA6C,KAAK,CAAC,IAAI,CAAC,IAAI,CAAC,GAAG;QAChE,GAAG,aAAa,CAAC,OAAO,CAAC,CAAC,CAAC,MAAM,EAAE,IAAI,CAAC,EAAE,EAAE;YAC1C,MAAM,OAAO,GAAG,MAAM,CAAC,MAAM,CAAC,IAAI,CAAC,EAAE,CAAC,KAAK,CAAC,QAAQ,CAAC,IAAI,CAAC,CAAC,CAAC;YAC5D,OAAO,OAAO,CAAC,MAAM,CAAC,CAAC,CAAC,CAAC,KAAK,OAAO,CAAC,IAAI,CAAC,KAAK,CAAC,KAAK,IAAI,EAAE,CAAC,CAAC,CAAC,CAAC,EAAE,CAAC;QACrE,CAAC,CAAC;QACF,GAAG,KAAK;QACR,2MAA2M;QAC3M,GAAG,CAAC,OAAO,CAAC,QAAQ,CAAC,CAAC,CAAC,mBAAmB,CAAC,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAC,CAAC,CAAC,CAAC,EAAE,CAAC;KAC1E,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;AACf,CAAC;AAED,SAAS,mBAAmB,CAAC,MAAqE;IAChG,OAAO;QACL,oDAAoD,MAAM,CAAC,GAAG,CAAC,IAAI,CAAC,EAAE,CAAC,GAAG,IAAI,CAAC,KAAK,IAAI,IAAI,CAAC,KAAK,EAAE,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,gBAAgB;QAChI,yKAAyK;KAC1K,CAAC;AACJ,CAAC;AAED,MAAM,CAAC,MAAM,4BAA4B,GAAG,0BAA0B,EAAE,CAAC;AAEzE;;;GAGG;AACH,MAAM,CAAC,MAAM,gBAAgB,GAAG,yFAAyF,CAAC;AAE1H,yFAAyF;AACzF,MAAM,UAAU,qBAAqB,CACnC,GAAW,EACX,UAAoC,eAAe;IAEnD,IAAI,KAAc,CAAC;IACnB,IAAI,CAAC;QAAC,KAAK,GAAG,IAAI,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC;IAAC,CAAC;IAAC,MAAM,CAAC;QAAC,MAAM,IAAI,KAAK,CAAC,qCAAqC,CAAC,CAAC;IAAC,CAAC;IAClG,sGAAsG;IACtG,IAAI,KAAK,IAAI,OAAO,KAAK,KAAK,QAAQ,IAAI,CAAC,KAAK,CAAC,OAAO,CAAC,KAAK,CAAC,IAAI,UAAU,IAAI,KAAK,IAAI,CAAC,CAAC,UAAU,IAAI,KAAK,CAAC,EAAE,CAAC;QACjH,KAAK,GAAG,EAAE,QAAQ,EAAE,CAAC,KAAK,CAAC,EAAE,CAAC;IAChC,CAAC;IACD,6FAA6F;IAC7F,IAAI,OAAO,CAAC,QAAQ,IAAI,KAAK,IAAI,OAAO,KAAK,KAAK,QAAQ,IAAI,CAAC,KAAK,CAAC,OAAO,CAAC,KAAK,CAAC,IAAI,CAAC,CAAC,UAAU,IAAI,KAAK,CAAC,EAAE,CAAC;QAC9G,KAAK,GAAG,EAAE,GAAG,KAAK,EAAE,QAAQ,EAAE,EAAE,EAAE,CAAC;IACrC,CAAC;IACD,qGAAqG;IACrG,IAAI,CAAC,OAAO,CAAC,QAAQ,IAAI,KAAK,IAAI,OAAO,KAAK,KAAK,QAAQ,IAAI,CAAC,KAAK,CAAC,OAAO,CAAC,KAAK,CAAC,IAAI,UAAU,IAAI,KAAK,EAAE,CAAC;QAC5G,MAAM,EAAE,QAAQ,EAAE,QAAQ,EAAE,GAAG,IAAI,EAAE,GAAG,KAAgC,CAAC;QACzE,KAAK,GAAG,IAAI,CAAC;IACf,CAAC;IACD,MAAM,EAAE,GAAG,GAAG,CAAC,OAAO,CAAC,CAAC;IACxB,IAAI,aAAa,GAAG,UAAU,CAAC,GAAG,CAAC,EAAE,CAAC,CAAC;IACvC,IAAI,CAAC,aAAa;QAAE,UAAU,CAAC,GAAG,CAAC,EAAE,EAAE,aAAa,GAAG,GAAG,CAAC,OAAO,CAAkB,qBAAqB,CAAC,OAAO,CAAC,CAAC,CAAC,CAAC;IACrH,iGAAiG;IACjG,6EAA6E;IAC7E,IAAI,CAAC,aAAa,CAAC,KAAK,CAAC;QAAE,oCAAoC,CAAC,KAAK,EAAE,aAAa,CAAC,MAAM,IAAI,EAAE,CAAC,CAAC;IACnG,IAAI,CAAC,aAAa,CAAC,KAAK,CAAC,EAAE,CAAC;QAC1B,MAAM,MAAM,GAAG,aAAa,CAAC,MAAM,IAAI,EAAE,CAAC;QAC1C,MAAM,MAAM,GAAG,MAAM,CAAC,MAAM,CAAC,KAAK,CAAC,EAAE,CAAC,KAAK,CAAC,OAAO,KAAK,MAAM,IAAI,KAAK,CAAC,YAAY,CAAC,QAAQ,CAAC,QAAQ,CAAC,CAAC,CAAC;QACzG,MAAM,MAAM,GAAG,CAAC,MAAM,CAAC,MAAM,CAAC,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,MAAM,CAAC,CAAC,KAAK,CAAC,CAAC,EAAE,CAAC,CAAC;aACzD,GAAG,CAAC,KAAK,CAAC,EAAE,CAAC,GAAG,KAAK,CAAC,YAAY,IAAI,GAAG,IAAI,KAAK,CAAC,OAAO,EAAE,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;QAC5E,MAAM,IAAI,KAAK,CAAC,wDAAwD,MAAM,EAAE,CAAC,CAAC;IACpF,CAAC;IACD,KAAK,MAAM,OAAO,IAAI,KAAK,CAAC,QAAQ,EAAE,CAAC;QACrC,KAAK,MAAM,OAAO,IAAI,OAAO,CAAC,QAAQ,EAAE,CAAC;YACvC,MAAM,IAAI,GAAG,OAAO,CAAC,IAA+B,CAAC;YACrD,IAAI,CAAC,IAAI,CAAC,IAAI,EAAE,IAAI,CAAC,OAAO,CAAC,CAAC,IAAI,CAAC,KAAK,CAAC,EAAE,CAAC,OAAO,KAAK,KAAK,QAAQ,IAAI,gBAAgB,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC,EAAE,CAAC;gBACvG,MAAM,IAAI,KAAK,CAAC,6CAA6C,CAAC,CAAC;YACjE,CAAC;QACH,CAAC;IACH,CAAC;IACD,MAAM,QAAQ,GAAyB,KAAK,CAAC,QAAQ,CAAC,MAAM,CAAC,OAAO,CAAC,EAAE,CAAC,OAAO,CAAC,QAAQ,CAAC,MAAM,GAAG,CAAC,CAAC;SACjG,GAAG,CAAC,OAAO,CAAC,EAAE,CAAC,CAAC,EAAE,IAAI,EAAE,SAAS,EAAE,QAAQ,EAAE,OAAO,CAAC,QAAQ,CAAC,GAAG,CAAC,OAAO,CAAC,EAAE,CAAC,uBAAuB,CAAC,OAAO,EAAE,OAAO,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC;IAC/H,0EAA0E;IAC1E,OAAO,KAAK,CAAC,QAAQ;QACnB,CAAC,CAAC,CAAC,EAAE,IAAI,EAAE,SAAS,EAAE,QAAQ,EAAE,CAAC,EAAE,IAAI,EAAE,qBAAqB,EAAE,IAAI,EAAE,EAAE,KAAK,EAAE,KAAK,CAAC,QAAQ,EAAE,EAAa,CAAC,EAAE,EAAE,GAAG,QAAQ,CAAC;QAC7H,CAAC,CAAC,QAAQ,CAAC;AACf,CAAC;AAED,SAAS,oCAAoC,CAAC,KAAc,EAAE,MAA8B;IAC1F,KAAK,MAAM,KAAK,IAAI,MAAM,EAAE,CAAC;QAC3B,IAAI,KAAK,CAAC,OAAO,KAAK,MAAM,IAAK,KAAK,CAAC,MAA6B,CAAC,IAAI,KAAK,QAAQ;YAAE,SAAS;QACjG,MAAM,IAAI,GAAG,KAAK,CAAC,YAAY,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,GAAG,CAAC,IAAI,CAAC,EAAE,CAAC,IAAI,CAAC,UAAU,CAAC,IAAI,EAAE,GAAG,CAAC,CAAC,UAAU,CAAC,IAAI,EAAE,GAAG,CAAC,CAAC,CAAC;QAClH,MAAM,KAAK,GAAG,IAAI,CAAC,GAAG,EAAE,CAAC;QACzB,IAAI,MAAM,GAAY,KAAK,CAAC;QAC5B,KAAK,MAAM,IAAI,IAAI,IAAI;YAAE,MAAM,GAAI,MAA8C,EAAE,CAAC,IAAI,CAAC,CAAC;QAC1F,IAAI,KAAK,KAAK,SAAS,IAAI,CAAC,MAAM,IAAI,OAAO,MAAM,KAAK,QAAQ;YAAE,SAAS;QAC3E,MAAM,OAAO,GAAI,MAAkC,CAAC,KAAK,CAAC,CAAC;QAC3D,IAAI,OAAO,OAAO,KAAK,QAAQ,IAAI,MAAM,CAAC,aAAa,CAAC,OAAO,CAAC;YAAG,MAAkC,CAAC,KAAK,CAAC,GAAG,MAAM,CAAC,OAAO,CAAC,CAAC;IACjI,CAAC;AACH,CAAC;AAED,6FAA6F;AAC7F,SAAS,uBAAuB,CAAC,OAAgB,EAAE,OAAiC;IAClF,MAAM,MAAM,GAAG,OAAO,CAAC,UAAU,EAAE,CAAC,OAAO,CAAC,IAA6B,CAAC,EAAE,MAAM,CAAC;IACnF,MAAM,IAAI,GAAG,OAAO,CAAC,IAA+B,CAAC;IACrD,IAAI,CAAC,MAAM,EAAE,IAAI,CAAC,KAAK,CAAC,EAAE,CAAC,IAAI,CAAC,KAAK,CAAC,KAAK,EAAE,CAAC;QAAE,OAAO,OAAO,CAAC;IAC/D,OAAO,EAAE,GAAG,OAAO,EAAE,IAAI,EAAE,MAAM,CAAC,WAAW,CAAC,MAAM,CAAC,OAAO,CAAC,IAAI,CAAC;aAC/D,MAAM,CAAC,CAAC,CAAC,KAAK,EAAE,KAAK,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,MAAM,CAAC,QAAQ,CAAC,KAAK,CAAC,IAAI,KAAK,KAAK,EAAE,CAAC,CAAC,CAAC,EAAa,CAAC;AACzF,CAAC","sourcesContent":["import Ajv, { type ErrorObject, type ValidateFunction } from 'ajv';\nimport {\n aiOutboundJsonSchema,\n buildAiOutboundJsonSchema,\n DEFAULT_AI_OUTBOUND_SEGMENT_TYPES,\n type AiOutboundSegmentProfile,\n type AiOutboundSegmentType,\n} from '@zhin.js/core';\nimport type { Segment } from '@zhin.js/im-contract';\nimport type { AgentOutputElement } from './agent-output.js';\n\nconst ajv = new Ajv();\n// Compiled, stateless contract validators keyed by segment set; no generation-owned data.\nconst validators = new Map<string, ValidateFunction<StructuredReply>>();\n\ninterface StructuredReply {\n messages: { segments: Segment[] }[];\n reaction?: string;\n}\n\n/** Segment of the internal message that carries a reaction; the reply port applies it, never sends it. */\nexport const REACTION_SEGMENT_TYPE = 'reaction';\nconst DEFAULT_PROFILE: AiOutboundSegmentProfile = { types: DEFAULT_AI_OUTBOUND_SEGMENT_TYPES };\nconst schemas = new Map<string, Record<string, unknown>>([[key(DEFAULT_PROFILE), aiOutboundJsonSchema]]);\n\nfunction key(profile: AiOutboundSegmentProfile): string {\n return JSON.stringify([[...profile.types].sort(), profile.extensions ?? {}, profile.reaction?.values.map(item => item.value) ?? []]);\n}\n\n/** Provider-facing schema for exactly this endpoint profile (cached, stable identity). */\nexport function structuredReplySchema(profile: AiOutboundSegmentProfile = DEFAULT_PROFILE): Record<string, unknown> {\n const id = key(profile);\n let schema = schemas.get(id);\n if (!schema) schemas.set(id, schema = buildAiOutboundJsonSchema(profile.types, profile.extensions, profile.reaction));\n return schema;\n}\n\nconst SEGMENT_GUIDE: readonly (readonly [readonly AiOutboundSegmentType[], string])[] = [\n [['text'], 'plain text.'],\n [['mention'], 'target must be a real platform user ID string (\"123\", not 123), from context or member lookup; never an endpoint ID, role or guessed nickname. Use Sender id for the sender; look up others by name, ask for clarification in text if missing/ambiguous. Plain @name does not notify. Reply is already in this conversation: mention only to call someone in.'],\n [['markdown'], 'formatted text (lists, bold, code, tables); degrades to plain text where unsupported.'],\n [['image', 'audio', 'video', 'file'], 'media.kind is url, path (workspace file) or base64. Use only media produced or given to you, as separate segments, never Markdown image syntax.'],\n [['keyboard'], 'quick-reply buttons: at most 5 per row; tapping sends payload as the next user message. Use only for concrete next steps, after the related text.'],\n [['share'], 'link card with url and title.'],\n [['dice'], 'platform random dice.'],\n [['rps'], 'platform random rock-paper-scissors.'],\n];\n\n/** The final-reply contract for this endpoint; lists only the segment types it can deliver. */\nexport function structuredReplyInstruction(profile: AiOutboundSegmentProfile = DEFAULT_PROFILE): string {\n const { types } = profile;\n const hints = types.flatMap(type => {\n const hint = profile.extensions?.[type]?.hint;\n return hint ? [`- ${type} on this platform: ${hint}`] : [];\n });\n return [\n 'Final reply: JSON matching the supplied schema, {\"messages\":[{\"segments\":[...]}]}, no fence/prose. Each item is one chat message; preserve message and segment order. Usually one message; split into 2–3 only where natural.',\n `Segment types available on this platform: ${types.join(', ')}.`,\n ...SEGMENT_GUIDE.flatMap(([covers, line]) => {\n const offered = covers.filter(type => types.includes(type));\n return offered.length ? [`- ${offered.join(' / ')}: ${line}`] : [];\n }),\n ...hints,\n 'Never invent IDs or references. Business calls use their own tool schemas; this schema is only for the final answer (including the json response tool). Use {\"messages\":[]} only when no reply is needed.',\n ...(profile.reaction ? reactionInstruction(profile.reaction.values) : []),\n ].join('\\n');\n}\n\nfunction reactionInstruction(values: readonly { readonly value: string; readonly label: string }[]): string[] {\n return [\n `\"reaction\" reacts to the message being answered: ${values.map(item => `${item.value} ${item.label}`).join(', ')}; \"\" for none.`,\n 'For thanks, agreement, jokes or news needing no words, {\"messages\":[],\"reaction\":\"<value>\"} may suffice. A short message may accompany it; avoid repeating its meaning.',\n ];\n}\n\nexport const STRUCTURED_REPLY_INSTRUCTION = structuredReplyInstruction();\n\n/**\n * Tool-call markup a model wrote as text because the provider did not parse it (DeepSeek DSML,\n * XML-style function calls). It is never a chat message, whatever else the reply contains.\n */\nexport const TOOL_CALL_MARKUP = /<[||]{1,2}\\s*DSML|<\\/?(?:invoke|tool_call|tool_calls|function_calls?|antml:[a-z_]+)\\b/iu;\n\n/** Reject the whole response on mismatch: never deliver raw JSON or partial segments. */\nexport function decodeStructuredReply(\n raw: string,\n profile: AiOutboundSegmentProfile = DEFAULT_PROFILE,\n): AgentOutputElement[] {\n let value: unknown;\n try { value = JSON.parse(raw); } catch { throw new Error('Agent final reply is not valid JSON'); }\n // Replies persisted before multi-message replies are a single {segments} message; models may echo it.\n if (value && typeof value === 'object' && !Array.isArray(value) && 'segments' in value && !('messages' in value)) {\n value = { messages: [value] };\n }\n // \"reaction\" is required only to satisfy strict schemas; a reply without it simply has none.\n if (profile.reaction && value && typeof value === 'object' && !Array.isArray(value) && !('reaction' in value)) {\n value = { ...value, reaction: '' };\n }\n // Where reactions are not offered, a reaction the model wrote anyway is dropped, not a format error.\n if (!profile.reaction && value && typeof value === 'object' && !Array.isArray(value) && 'reaction' in value) {\n const { reaction: _ignored, ...rest } = value as Record<string, unknown>;\n value = rest;\n }\n const id = key(profile);\n let validateReply = validators.get(id);\n if (!validateReply) validators.set(id, validateReply = ajv.compile<StructuredReply>(structuredReplySchema(profile)));\n // Models often write IDs (mention targets, reply ids) as JSON numbers; a number where the schema\n // wants a string is the same ID, not a format error worth a correction call.\n if (!validateReply(value)) stringifyNumbersWhereStringsExpected(value, validateReply.errors ?? []);\n if (!validateReply(value)) {\n const errors = validateReply.errors ?? [];\n const fields = errors.filter(error => error.keyword === 'type' && error.instancePath.includes('/data/'));\n const detail = (fields.length ? fields : errors).slice(0, 8)\n .map(error => `${error.instancePath || '/'} ${error.message}`).join('; ');\n throw new Error(`Agent final reply does not match the message schema: ${detail}`);\n }\n for (const message of value.messages) {\n for (const segment of message.segments) {\n const data = segment.data as Record<string, unknown>;\n if ([data.text, data.content].some(field => typeof field === 'string' && TOOL_CALL_MARKUP.test(field))) {\n throw new Error('Agent final reply contains tool-call markup');\n }\n }\n }\n const messages: AgentOutputElement[] = value.messages.filter(message => message.segments.length > 0)\n .map(message => ({ type: 'message', segments: message.segments.map(segment => withoutEmptyModelFields(segment, profile)) }));\n // React first, then talk, like a person tapping a reaction before typing.\n return value.reaction\n ? [{ type: 'message', segments: [{ type: REACTION_SEGMENT_TYPE, data: { emoji: value.reaction } } as Segment] }, ...messages]\n : messages;\n}\n\nfunction stringifyNumbersWhereStringsExpected(value: unknown, errors: readonly ErrorObject[]): void {\n for (const error of errors) {\n if (error.keyword !== 'type' || (error.params as { type?: unknown }).type !== 'string') continue;\n const path = error.instancePath.split('/').slice(1).map(part => part.replaceAll('~1', '/').replaceAll('~0', '~'));\n const field = path.pop();\n let parent: unknown = value;\n for (const part of path) parent = (parent as Record<string, unknown> | undefined)?.[part];\n if (field === undefined || !parent || typeof parent !== 'object') continue;\n const current = (parent as Record<string, unknown>)[field];\n if (typeof current === 'number' && Number.isSafeInteger(current)) (parent as Record<string, unknown>)[field] = String(current);\n }\n}\n\n/** Declared extension fields are required in the schema; \"\" means the model had no value. */\nfunction withoutEmptyModelFields(segment: Segment, profile: AiOutboundSegmentProfile): Segment {\n const fields = profile.extensions?.[segment.type as AiOutboundSegmentType]?.fields;\n const data = segment.data as Record<string, unknown>;\n if (!fields?.some(field => data[field] === '')) return segment;\n return { ...segment, data: Object.fromEntries(Object.entries(data)\n .filter(([field, value]) => !(fields.includes(field) && value === ''))) } as Segment;\n}\n"]}
|
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
import { AgentUsageCollector, agentUsageStoreToken } from '../usage/usage-store.js';
|
|
2
|
+
import { getLogger } from '@zhin.js/logger';
|
|
3
|
+
import { logTurnUsage } from '../trace/turn-usage-log.js';
|
|
1
4
|
import { createToken } from '@zhin.js/plugin-runtime';
|
|
2
5
|
import { applyTurnTrust } from '../security/trust-level.js';
|
|
3
6
|
import { PersistentTurnJournal } from '../journal/persistent-turn-journal.js';
|
|
@@ -163,6 +166,7 @@ export const agentTurnEngineToken = createToken('zhin.agent.turn-engine', 'Compl
|
|
|
163
166
|
export class AgentRuntime extends SnapshotAttachedRuntime {
|
|
164
167
|
options;
|
|
165
168
|
#ingress = new CapabilityIngress();
|
|
169
|
+
#usageLogger = getLogger('ai.usage');
|
|
166
170
|
constructor(options) {
|
|
167
171
|
super();
|
|
168
172
|
this.options = options;
|
|
@@ -190,8 +194,19 @@ export class AgentRuntime extends SnapshotAttachedRuntime {
|
|
|
190
194
|
}
|
|
191
195
|
async #executeLeased(lease, owner, request, selection, observe, admit = () => undefined) {
|
|
192
196
|
let active = true;
|
|
197
|
+
const usageStore = lease.value.resources.get(lease.value.root)?.get(agentUsageStoreToken.id);
|
|
198
|
+
const usage = usageStore ? new AgentUsageCollector(String(owner), request, {
|
|
199
|
+
provider: selection.binding.providerAlias, model: selection.binding.model,
|
|
200
|
+
}) : undefined;
|
|
201
|
+
let usageStatus = 'failed';
|
|
193
202
|
let activeTrace;
|
|
194
203
|
let releaseTrace;
|
|
204
|
+
let outcome;
|
|
205
|
+
const logContext = {
|
|
206
|
+
traceId: request.identity.traceId,
|
|
207
|
+
scene: request.origin.kind === 'im' ? `${request.origin.platform}/${request.origin.scope}` : request.origin.kind,
|
|
208
|
+
selectedModel: `${selection.binding.providerAlias}/${selection.binding.model}`,
|
|
209
|
+
};
|
|
195
210
|
try {
|
|
196
211
|
if (!active)
|
|
197
212
|
throw new Error('Agent generation operation is not active');
|
|
@@ -211,7 +226,7 @@ export class AgentRuntime extends SnapshotAttachedRuntime {
|
|
|
211
226
|
const turnRequest = trust.restricted
|
|
212
227
|
? Object.freeze({ ...request, policy: trust.policy })
|
|
213
228
|
: request;
|
|
214
|
-
const trace = this.options.turnTraceStore
|
|
229
|
+
const trace = this.options.turnTraceStore || this.#usageLogger.isLevelEnabled('info')
|
|
215
230
|
? new TurnTraceCollector({
|
|
216
231
|
turnId: request.identity.turnId,
|
|
217
232
|
sessionKey: request.session.key,
|
|
@@ -220,7 +235,7 @@ export class AgentRuntime extends SnapshotAttachedRuntime {
|
|
|
220
235
|
: undefined;
|
|
221
236
|
releaseTrace = trace ? registerTurnTrace(trace) : undefined;
|
|
222
237
|
activeTrace = trace;
|
|
223
|
-
const turn = createIngressTurn(lease.value, turnRequest, capabilities, trace);
|
|
238
|
+
const turn = createIngressTurn(lease.value, turnRequest, capabilities, trace, usage);
|
|
224
239
|
const catalog = Object.freeze({
|
|
225
240
|
...capabilities,
|
|
226
241
|
tools: Object.freeze(capabilities.tools.map(({ execute: _execute, ...tool }) => Object.freeze(tool))),
|
|
@@ -229,7 +244,7 @@ export class AgentRuntime extends SnapshotAttachedRuntime {
|
|
|
229
244
|
const engine = resolveTurnEngine(lease.value);
|
|
230
245
|
const disabled = Boolean(request.session.key.startsWith('workroom:') || request.input.metadata?.workroom
|
|
231
246
|
|| request.principal.roles.includes('workroom_assignment') || request.execution?.kind === 'schedule');
|
|
232
|
-
|
|
247
|
+
outcome = await runWithAgentDecisionTurn(lease.value, request.identity.turnId, decision => runWithAgentTurnConfiguration({ activeBinding: selection.binding }, () => executeAgentTurn(turn, async function* () {
|
|
233
248
|
const stream = engine.run({
|
|
234
249
|
turn,
|
|
235
250
|
capabilities: catalog,
|
|
@@ -261,18 +276,42 @@ export class AgentRuntime extends SnapshotAttachedRuntime {
|
|
|
261
276
|
await stream.return(undefined);
|
|
262
277
|
}
|
|
263
278
|
}, observe)), { disabled });
|
|
279
|
+
usageStatus = outcome.status;
|
|
280
|
+
return outcome;
|
|
281
|
+
}
|
|
282
|
+
catch (error) {
|
|
283
|
+
usageStatus = request.signal.aborted ? 'cancelled' : 'failed';
|
|
284
|
+
if (!activeTrace && this.#usageLogger.isLevelEnabled('info')) {
|
|
285
|
+
activeTrace = new TurnTraceCollector({ turnId: request.identity.turnId, sessionKey: request.session.key });
|
|
286
|
+
}
|
|
287
|
+
activeTrace?.finalize({ status: request.signal.aborted ? 'cancelled' : 'failed' });
|
|
288
|
+
throw error;
|
|
264
289
|
}
|
|
265
290
|
finally {
|
|
266
291
|
active = false;
|
|
267
292
|
releaseTrace?.();
|
|
268
293
|
if (activeTrace)
|
|
269
|
-
this.#saveTrace(activeTrace);
|
|
294
|
+
this.#saveTrace(activeTrace, { ...logContext, status: outcome?.status });
|
|
295
|
+
if (usage && usageStore) {
|
|
296
|
+
try {
|
|
297
|
+
await usageStore.save(usage.finish(usageStatus));
|
|
298
|
+
}
|
|
299
|
+
catch {
|
|
300
|
+
// No model response, prompt or database error text enters this diagnostic.
|
|
301
|
+
try {
|
|
302
|
+
this.#usageLogger.error('op=usage_persist status=failed; 本轮用量未写入数据库');
|
|
303
|
+
}
|
|
304
|
+
catch { /* Transport failure cannot change delivery. */ }
|
|
305
|
+
}
|
|
306
|
+
}
|
|
270
307
|
}
|
|
271
308
|
}
|
|
272
|
-
#saveTrace(trace) {
|
|
309
|
+
#saveTrace(trace, context) {
|
|
273
310
|
trace.finalize();
|
|
311
|
+
const snapshot = trace.snapshot();
|
|
312
|
+
logTurnUsage(this.#usageLogger, snapshot, context);
|
|
274
313
|
// Fire-and-forget: a slow or failing trace write must not delay the reply.
|
|
275
|
-
void this.options.turnTraceStore?.save(
|
|
314
|
+
void this.options.turnTraceStore?.save(snapshot).catch(() => undefined);
|
|
276
315
|
}
|
|
277
316
|
}
|
|
278
317
|
function resolveTurnEngine(snapshot) {
|
|
@@ -338,11 +377,13 @@ export async function expandMcpTools(capabilities, allowedServers) {
|
|
|
338
377
|
}
|
|
339
378
|
return expanded;
|
|
340
379
|
}
|
|
341
|
-
function createIngressTurn(snapshot, request, capabilities, trace) {
|
|
380
|
+
function createIngressTurn(snapshot, request, capabilities, trace, usage) {
|
|
342
381
|
const journal = new PersistentTurnJournal({
|
|
343
382
|
sessionId: request.session.key,
|
|
344
383
|
turnId: request.identity.turnId,
|
|
345
384
|
}, resolveJournalStore(snapshot), request.principal);
|
|
385
|
+
const traced = trace ? createTracingJournal(journal, trace) : journal;
|
|
386
|
+
const observed = usage ? { append(event) { usage.record(event); return traced.append(event); } } : traced;
|
|
346
387
|
return createTurnIngress({
|
|
347
388
|
...request,
|
|
348
389
|
identity: {
|
|
@@ -357,7 +398,7 @@ function createIngressTurn(snapshot, request, capabilities, trace) {
|
|
|
357
398
|
},
|
|
358
399
|
ports: {
|
|
359
400
|
...request.ports,
|
|
360
|
-
journal: createJournalFeed(
|
|
401
|
+
journal: createJournalFeed(observed, {
|
|
361
402
|
turnId: request.identity.turnId,
|
|
362
403
|
}),
|
|
363
404
|
},
|