@chengchenccc/oh-my-agent 0.1.1-rc.1
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/README.md +79 -0
- package/dist/cli/args.d.ts +24 -0
- package/dist/cli/args.d.ts.map +1 -0
- package/dist/cli/args.js +120 -0
- package/dist/cli/initial-input.d.ts +32 -0
- package/dist/cli/initial-input.d.ts.map +1 -0
- package/dist/cli/initial-input.js +131 -0
- package/dist/cli.d.ts +3 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +3 -0
- package/dist/core/__fixtures__/echo-model.d.ts +6 -0
- package/dist/core/__fixtures__/echo-model.d.ts.map +1 -0
- package/dist/core/__fixtures__/echo-model.js +10 -0
- package/dist/core/__fixtures__/mcp-echo-server.d.ts +2 -0
- package/dist/core/__fixtures__/mcp-echo-server.d.ts.map +1 -0
- package/dist/core/__fixtures__/mcp-echo-server.js +54 -0
- package/dist/core/agent-runtime.d.ts +21 -0
- package/dist/core/agent-runtime.d.ts.map +1 -0
- package/dist/core/agent-runtime.js +14 -0
- package/dist/core/memory/autonomous-memory.d.ts +21 -0
- package/dist/core/memory/autonomous-memory.d.ts.map +1 -0
- package/dist/core/memory/autonomous-memory.js +202 -0
- package/dist/core/persistence/in-memory-session-store.d.ts +3 -0
- package/dist/core/persistence/in-memory-session-store.d.ts.map +1 -0
- package/dist/core/persistence/in-memory-session-store.js +109 -0
- package/dist/core/persistence/session-store.contract.d.ts +5 -0
- package/dist/core/persistence/session-store.contract.d.ts.map +1 -0
- package/dist/core/persistence/session-store.contract.js +183 -0
- package/dist/core/persistence/session-store.d.ts +33 -0
- package/dist/core/persistence/session-store.d.ts.map +1 -0
- package/dist/core/persistence/session-store.js +22 -0
- package/dist/core/persistence/session-tree.d.ts +49 -0
- package/dist/core/persistence/session-tree.d.ts.map +1 -0
- package/dist/core/persistence/session-tree.js +1 -0
- package/dist/core/plugins/plugin-code.d.ts +13 -0
- package/dist/core/plugins/plugin-code.d.ts.map +1 -0
- package/dist/core/plugins/plugin-code.js +62 -0
- package/dist/core/plugins/plugin-marketplace.d.ts +72 -0
- package/dist/core/plugins/plugin-marketplace.d.ts.map +1 -0
- package/dist/core/plugins/plugin-marketplace.js +340 -0
- package/dist/core/plugins/plugin-resolve.d.ts +32 -0
- package/dist/core/plugins/plugin-resolve.d.ts.map +1 -0
- package/dist/core/plugins/plugin-resolve.js +101 -0
- package/dist/core/plugins/plugin-trust.d.ts +24 -0
- package/dist/core/plugins/plugin-trust.d.ts.map +1 -0
- package/dist/core/plugins/plugin-trust.js +89 -0
- package/dist/core/runtime/agent-event.d.ts +98 -0
- package/dist/core/runtime/agent-event.d.ts.map +1 -0
- package/dist/core/runtime/agent-event.js +1 -0
- package/dist/core/runtime/agent-loop.d.ts +142 -0
- package/dist/core/runtime/agent-loop.d.ts.map +1 -0
- package/dist/core/runtime/agent-loop.js +1008 -0
- package/dist/core/runtime/approval.d.ts +23 -0
- package/dist/core/runtime/approval.d.ts.map +1 -0
- package/dist/core/runtime/approval.js +25 -0
- package/dist/core/runtime/compaction.d.ts +22 -0
- package/dist/core/runtime/compaction.d.ts.map +1 -0
- package/dist/core/runtime/compaction.js +90 -0
- package/dist/core/runtime/context-estimate.d.ts +42 -0
- package/dist/core/runtime/context-estimate.d.ts.map +1 -0
- package/dist/core/runtime/context-estimate.js +34 -0
- package/dist/core/runtime/create-runtime.d.ts +81 -0
- package/dist/core/runtime/create-runtime.d.ts.map +1 -0
- package/dist/core/runtime/create-runtime.js +250 -0
- package/dist/core/runtime/fake-provider.d.ts +3 -0
- package/dist/core/runtime/fake-provider.d.ts.map +1 -0
- package/dist/core/runtime/fake-provider.js +87 -0
- package/dist/core/runtime/logger.d.ts +13 -0
- package/dist/core/runtime/logger.d.ts.map +1 -0
- package/dist/core/runtime/logger.js +38 -0
- package/dist/core/runtime/loop-input.d.ts +39 -0
- package/dist/core/runtime/loop-input.d.ts.map +1 -0
- package/dist/core/runtime/loop-input.js +38 -0
- package/dist/core/runtime/message-cache.d.ts +16 -0
- package/dist/core/runtime/message-cache.d.ts.map +1 -0
- package/dist/core/runtime/message-cache.js +23 -0
- package/dist/core/runtime/model-catalog.d.ts +10 -0
- package/dist/core/runtime/model-catalog.d.ts.map +1 -0
- package/dist/core/runtime/model-catalog.js +14 -0
- package/dist/core/runtime/plugin-runtime.d.ts +41 -0
- package/dist/core/runtime/plugin-runtime.d.ts.map +1 -0
- package/dist/core/runtime/plugin-runtime.js +1 -0
- package/dist/core/runtime/plugin.d.ts +86 -0
- package/dist/core/runtime/plugin.d.ts.map +1 -0
- package/dist/core/runtime/plugin.js +23 -0
- package/dist/core/runtime/process-tree.d.ts +12 -0
- package/dist/core/runtime/process-tree.d.ts.map +1 -0
- package/dist/core/runtime/process-tree.js +40 -0
- package/dist/core/runtime/prompt.d.ts +23 -0
- package/dist/core/runtime/prompt.d.ts.map +1 -0
- package/dist/core/runtime/prompt.js +29 -0
- package/dist/core/runtime/prompts.d.ts +16 -0
- package/dist/core/runtime/prompts.d.ts.map +1 -0
- package/dist/core/runtime/prompts.js +36 -0
- package/dist/core/runtime/retry.d.ts +20 -0
- package/dist/core/runtime/retry.d.ts.map +1 -0
- package/dist/core/runtime/retry.js +60 -0
- package/dist/core/runtime/run-runtime.d.ts +93 -0
- package/dist/core/runtime/run-runtime.d.ts.map +1 -0
- package/dist/core/runtime/run-runtime.js +664 -0
- package/dist/core/runtime/runtime-catalog.d.ts +12 -0
- package/dist/core/runtime/runtime-catalog.d.ts.map +1 -0
- package/dist/core/runtime/runtime-catalog.js +89 -0
- package/dist/core/runtime/stream-rules.d.ts +9 -0
- package/dist/core/runtime/stream-rules.d.ts.map +1 -0
- package/dist/core/runtime/stream-rules.js +44 -0
- package/dist/core/runtime/title.d.ts +7 -0
- package/dist/core/runtime/title.d.ts.map +1 -0
- package/dist/core/runtime/title.js +114 -0
- package/dist/core/runtime/todo.d.ts +16 -0
- package/dist/core/runtime/todo.d.ts.map +1 -0
- package/dist/core/runtime/todo.js +40 -0
- package/dist/core/runtime/tool-filter.d.ts +12 -0
- package/dist/core/runtime/tool-filter.d.ts.map +1 -0
- package/dist/core/runtime/tool-filter.js +23 -0
- package/dist/core/runtime/tool-pruning.d.ts +29 -0
- package/dist/core/runtime/tool-pruning.d.ts.map +1 -0
- package/dist/core/runtime/tool-pruning.js +105 -0
- package/dist/core/session/input-history.d.ts +8 -0
- package/dist/core/session/input-history.d.ts.map +1 -0
- package/dist/core/session/input-history.js +41 -0
- package/dist/core/session/session-file.d.ts +84 -0
- package/dist/core/session/session-file.d.ts.map +1 -0
- package/dist/core/session/session-file.js +392 -0
- package/dist/core/session/session-loop.d.ts +33 -0
- package/dist/core/session/session-loop.d.ts.map +1 -0
- package/dist/core/session/session-loop.js +30 -0
- package/dist/core/settings/project-settings.d.ts +46 -0
- package/dist/core/settings/project-settings.d.ts.map +1 -0
- package/dist/core/settings/project-settings.js +85 -0
- package/dist/core/settings/workspace-context.d.ts +10 -0
- package/dist/core/settings/workspace-context.d.ts.map +1 -0
- package/dist/core/settings/workspace-context.js +48 -0
- package/dist/core/tools/agent-fs-like.d.ts +15 -0
- package/dist/core/tools/agent-fs-like.d.ts.map +1 -0
- package/dist/core/tools/agent-fs-like.js +4 -0
- package/dist/core/tools/ask-question.d.ts +5 -0
- package/dist/core/tools/ask-question.d.ts.map +1 -0
- package/dist/core/tools/ask-question.js +51 -0
- package/dist/core/tools/bash.d.ts +5 -0
- package/dist/core/tools/bash.d.ts.map +1 -0
- package/dist/core/tools/bash.js +109 -0
- package/dist/core/tools/eval.d.ts +10 -0
- package/dist/core/tools/eval.d.ts.map +1 -0
- package/dist/core/tools/eval.js +83 -0
- package/dist/core/tools/file-tools.d.ts +18 -0
- package/dist/core/tools/file-tools.d.ts.map +1 -0
- package/dist/core/tools/file-tools.js +249 -0
- package/dist/core/tools/glob.d.ts +5 -0
- package/dist/core/tools/glob.d.ts.map +1 -0
- package/dist/core/tools/glob.js +54 -0
- package/dist/core/tools/grep.d.ts +5 -0
- package/dist/core/tools/grep.d.ts.map +1 -0
- package/dist/core/tools/grep.js +61 -0
- package/dist/core/tools/index.d.ts +14 -0
- package/dist/core/tools/index.d.ts.map +1 -0
- package/dist/core/tools/index.js +14 -0
- package/dist/core/tools/ls-tree.d.ts +10 -0
- package/dist/core/tools/ls-tree.d.ts.map +1 -0
- package/dist/core/tools/ls-tree.js +177 -0
- package/dist/core/tools/mcp-mount.d.ts +63 -0
- package/dist/core/tools/mcp-mount.d.ts.map +1 -0
- package/dist/core/tools/mcp-mount.js +287 -0
- package/dist/core/tools/read-image.d.ts +6 -0
- package/dist/core/tools/read-image.d.ts.map +1 -0
- package/dist/core/tools/read-image.js +98 -0
- package/dist/core/tools/skill.d.ts +11 -0
- package/dist/core/tools/skill.d.ts.map +1 -0
- package/dist/core/tools/skill.js +65 -0
- package/dist/core/tools/skills.d.ts +10 -0
- package/dist/core/tools/skills.d.ts.map +1 -0
- package/dist/core/tools/skills.js +116 -0
- package/dist/core/tools/todo-store.d.ts +8 -0
- package/dist/core/tools/todo-store.d.ts.map +1 -0
- package/dist/core/tools/todo-store.js +100 -0
- package/dist/core/tools/todo.d.ts +8 -0
- package/dist/core/tools/todo.d.ts.map +1 -0
- package/dist/core/tools/todo.js +91 -0
- package/dist/core/tools/url-guard.d.ts +12 -0
- package/dist/core/tools/url-guard.d.ts.map +1 -0
- package/dist/core/tools/url-guard.js +58 -0
- package/dist/core/tools/web-ports-std.d.ts +6 -0
- package/dist/core/tools/web-ports-std.d.ts.map +1 -0
- package/dist/core/tools/web-ports-std.js +145 -0
- package/dist/core/tools/web-ports.d.ts +56 -0
- package/dist/core/tools/web-ports.d.ts.map +1 -0
- package/dist/core/tools/web-ports.js +62 -0
- package/dist/core/tools/workspace-sandbox.d.ts +20 -0
- package/dist/core/tools/workspace-sandbox.d.ts.map +1 -0
- package/dist/core/tools/workspace-sandbox.js +84 -0
- package/dist/core/workflow/subagent-registry.d.ts +40 -0
- package/dist/core/workflow/subagent-registry.d.ts.map +1 -0
- package/dist/core/workflow/subagent-registry.js +118 -0
- package/dist/core/workflow/workflow-evaluator.d.ts +23 -0
- package/dist/core/workflow/workflow-evaluator.d.ts.map +1 -0
- package/dist/core/workflow/workflow-evaluator.js +21 -0
- package/dist/core/workflow/workflow-executor.d.ts +108 -0
- package/dist/core/workflow/workflow-executor.d.ts.map +1 -0
- package/dist/core/workflow/workflow-executor.js +549 -0
- package/dist/core/workflow/workflow-tools.d.ts +50 -0
- package/dist/core/workflow/workflow-tools.d.ts.map +1 -0
- package/dist/core/workflow/workflow-tools.js +217 -0
- package/dist/index.d.ts +14 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +13 -0
- package/dist/main.d.ts +9 -0
- package/dist/main.d.ts.map +1 -0
- package/dist/main.js +97 -0
- package/dist/modes/json-mode.d.ts +5 -0
- package/dist/modes/json-mode.d.ts.map +1 -0
- package/dist/modes/json-mode.js +46 -0
- package/dist/modes/print-mode.d.ts +20 -0
- package/dist/modes/print-mode.d.ts.map +1 -0
- package/dist/modes/print-mode.js +82 -0
- package/dist/modes/rpc/jsonl.d.ts +23 -0
- package/dist/modes/rpc/jsonl.d.ts.map +1 -0
- package/dist/modes/rpc/jsonl.js +88 -0
- package/dist/modes/rpc/rpc-mode.d.ts +18 -0
- package/dist/modes/rpc/rpc-mode.d.ts.map +1 -0
- package/dist/modes/rpc/rpc-mode.js +326 -0
- package/dist/modes/tui/settings-overlay.d.ts +23 -0
- package/dist/modes/tui/settings-overlay.d.ts.map +1 -0
- package/dist/modes/tui/settings-overlay.js +136 -0
- package/dist/modes/tui/tui-commands-session.d.ts +3 -0
- package/dist/modes/tui/tui-commands-session.d.ts.map +1 -0
- package/dist/modes/tui/tui-commands-session.js +191 -0
- package/dist/modes/tui/tui-commands.d.ts +52 -0
- package/dist/modes/tui/tui-commands.d.ts.map +1 -0
- package/dist/modes/tui/tui-commands.js +373 -0
- package/dist/modes/tui/tui-components.d.ts +81 -0
- package/dist/modes/tui/tui-components.d.ts.map +1 -0
- package/dist/modes/tui/tui-components.js +248 -0
- package/dist/modes/tui/tui-format.d.ts +65 -0
- package/dist/modes/tui/tui-format.d.ts.map +1 -0
- package/dist/modes/tui/tui-format.js +246 -0
- package/dist/modes/tui/tui-frame-provider.d.ts +16 -0
- package/dist/modes/tui/tui-frame-provider.d.ts.map +1 -0
- package/dist/modes/tui/tui-frame-provider.js +31 -0
- package/dist/modes/tui/tui-interactive.d.ts +16 -0
- package/dist/modes/tui/tui-interactive.d.ts.map +1 -0
- package/dist/modes/tui/tui-interactive.js +123 -0
- package/dist/modes/tui/tui-io.d.ts +12 -0
- package/dist/modes/tui/tui-io.d.ts.map +1 -0
- package/dist/modes/tui/tui-io.js +620 -0
- package/dist/modes/tui/tui-mode.d.ts +120 -0
- package/dist/modes/tui/tui-mode.d.ts.map +1 -0
- package/dist/modes/tui/tui-mode.js +391 -0
- package/dist/modes/tui/tui-render.d.ts +54 -0
- package/dist/modes/tui/tui-render.d.ts.map +1 -0
- package/dist/modes/tui/tui-render.js +399 -0
- package/dist/modes/tui/tui-slash.d.ts +12 -0
- package/dist/modes/tui/tui-slash.d.ts.map +1 -0
- package/dist/modes/tui/tui-slash.js +64 -0
- package/dist/modes/tui/tui-tool-render.d.ts +6 -0
- package/dist/modes/tui/tui-tool-render.d.ts.map +1 -0
- package/dist/modes/tui/tui-tool-render.js +79 -0
- package/dist/modes/tui/tui-transcript-reconciler.d.ts +25 -0
- package/dist/modes/tui/tui-transcript-reconciler.d.ts.map +1 -0
- package/dist/modes/tui/tui-transcript-reconciler.js +97 -0
- package/dist/modes/tui/view-state.d.ts +67 -0
- package/dist/modes/tui/view-state.d.ts.map +1 -0
- package/dist/modes/tui/view-state.js +353 -0
- package/dist/prompts/agents/subagent.md +26 -0
- package/dist/prompts/system/base.md +20 -0
- package/dist/prompts/system/memory.md +23 -0
- package/dist/prompts/system/safety.md +12 -0
- package/dist/protocol/index.d.ts +5 -0
- package/dist/protocol/index.d.ts.map +1 -0
- package/dist/protocol/index.js +2 -0
- package/dist/protocol/mapping.d.ts +25 -0
- package/dist/protocol/mapping.d.ts.map +1 -0
- package/dist/protocol/mapping.js +115 -0
- package/dist/protocol/transport.d.ts +1396 -0
- package/dist/protocol/transport.d.ts.map +1 -0
- package/dist/protocol/transport.js +170 -0
- package/package.json +49 -0
|
@@ -0,0 +1,1008 @@
|
|
|
1
|
+
import { debugLog } from "@chengchenccc/agent-contract";
|
|
2
|
+
import { ProviderError } from "@chengchenccc/ai";
|
|
3
|
+
import { compactSession } from "./compaction.js";
|
|
4
|
+
import { estimateContextTokens, isSilentContextOverflow, usageTotalTokens, } from "./context-estimate.js";
|
|
5
|
+
import { buildLoopInput } from "./loop-input.js";
|
|
6
|
+
import { TokenEstimateCache } from "./message-cache.js";
|
|
7
|
+
import { collectTools, validatePlugins } from "./plugin.js";
|
|
8
|
+
import { renderLoopMeta } from "./prompt.js";
|
|
9
|
+
import { retryStream } from "./retry.js";
|
|
10
|
+
import { buildTitleContext, generateTitle } from "./title.js";
|
|
11
|
+
import { pruneOldToolResults } from "./tool-pruning.js";
|
|
12
|
+
export function createOmaSession(opts) {
|
|
13
|
+
validatePlugins(opts.plugins);
|
|
14
|
+
const listeners = new Set();
|
|
15
|
+
// Resolve the plugin runtime: opts.pluginRuntime from run-runtime, or a
|
|
16
|
+
// minimal stub for tests that only need emit (backward-compatible).
|
|
17
|
+
let rt = opts.pluginRuntime ?? {
|
|
18
|
+
streamModel: async function* () { },
|
|
19
|
+
store: opts.store,
|
|
20
|
+
sessionId: opts.sessionId,
|
|
21
|
+
workspaceRoot: "",
|
|
22
|
+
emit: (event) => {
|
|
23
|
+
void emit(event);
|
|
24
|
+
},
|
|
25
|
+
signal: new AbortController().signal,
|
|
26
|
+
};
|
|
27
|
+
// Static plugin tools + per-run resolved tools (Product Tool manifest).
|
|
28
|
+
const baseTools = collectTools(opts.plugins);
|
|
29
|
+
let toolMap = new Map(baseTools.map((t) => [t.name, t]));
|
|
30
|
+
let status = "idle";
|
|
31
|
+
let runUsage;
|
|
32
|
+
let active = false;
|
|
33
|
+
let controller = null;
|
|
34
|
+
const steerQueue = [];
|
|
35
|
+
let acceptingSteer = false;
|
|
36
|
+
let debugModelId = "";
|
|
37
|
+
let debugTurn = 0;
|
|
38
|
+
let debugRunId = "";
|
|
39
|
+
const runIdForDebug = () => debugRunId;
|
|
40
|
+
// TTSR stream-rule injection counts (per Run, reset in runLoop).
|
|
41
|
+
const streamRuleInjections = new Map();
|
|
42
|
+
const tokenEstimateCache = new TokenEstimateCache();
|
|
43
|
+
async function emit(event) {
|
|
44
|
+
for (const l of listeners) {
|
|
45
|
+
try {
|
|
46
|
+
await l(event);
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
/* listener error is logged but not fatal */
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
const SESSION_MESSAGE_SOURCES = new Set([
|
|
54
|
+
"prompt",
|
|
55
|
+
"steer",
|
|
56
|
+
"follow_up",
|
|
57
|
+
"assistant",
|
|
58
|
+
"tool_result",
|
|
59
|
+
"system_reminder",
|
|
60
|
+
]);
|
|
61
|
+
function persist(entries) {
|
|
62
|
+
if (opts.onPersistMessages) {
|
|
63
|
+
const conversational = entries
|
|
64
|
+
.filter((e) => e.type === "message" &&
|
|
65
|
+
typeof e.source === "string" &&
|
|
66
|
+
SESSION_MESSAGE_SOURCES.has(e.source) &&
|
|
67
|
+
typeof e.message === "object" &&
|
|
68
|
+
e.message !== null)
|
|
69
|
+
.map((e) => e.message);
|
|
70
|
+
if (conversational.length > 0)
|
|
71
|
+
opts.onPersistMessages(conversational);
|
|
72
|
+
}
|
|
73
|
+
return opts.store.appendBatch(opts.sessionId, { entries });
|
|
74
|
+
}
|
|
75
|
+
/** One thinking block from a turn's raw thinking (single assembly point:
|
|
76
|
+
* both the text turn and the tool turn persist through here). An empty
|
|
77
|
+
* thinking text with a signature (display: "omitted") still persists
|
|
78
|
+
* the signature must be replayed unchanged in tool-use turns. */
|
|
79
|
+
function buildThinkingBlock(turn) {
|
|
80
|
+
if (!turn.thinking && !turn.thinkingSignature)
|
|
81
|
+
return [];
|
|
82
|
+
// Collapse the interleaved thinking strands into one thinking block for
|
|
83
|
+
// replay: Anthropic requires a single <thinking> per assistant message,
|
|
84
|
+
// and signature/redacted attach at the end. Text passages stay in the
|
|
85
|
+
// ordered blocks when the turn persists them (tool turns keep them).
|
|
86
|
+
const text = turn.thinkingRedacted ? "[reasoning redacted]" : turn.thinking;
|
|
87
|
+
return [
|
|
88
|
+
{
|
|
89
|
+
type: "thinking",
|
|
90
|
+
text,
|
|
91
|
+
...(turn.thinkingSignature ? { signature: turn.thinkingSignature } : {}),
|
|
92
|
+
...(turn.thinkingRedacted ? { redacted: true } : {}),
|
|
93
|
+
},
|
|
94
|
+
];
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* runLoop: inner loop runs model turns and tool calls until the model
|
|
98
|
+
* stops. Each model turn is accumulated purely (streamModelTurn) and
|
|
99
|
+
* then persisted as canonical messages — persistence is a turn-level
|
|
100
|
+
* decision, never inside the stream accumulation. Steer inputs drain
|
|
101
|
+
* at safe boundaries; runEphemeralTurn is a side channel that never
|
|
102
|
+
* persists. Follow-up inputs require a separate startFollowUp() call
|
|
103
|
+
* (the backend orchestrates follow-ups via branch_input_queue).
|
|
104
|
+
*/
|
|
105
|
+
async function runLoop(codingInput, mode) {
|
|
106
|
+
if (active)
|
|
107
|
+
throw new Error("Loop already active");
|
|
108
|
+
active = true;
|
|
109
|
+
status = "running";
|
|
110
|
+
controller = new AbortController();
|
|
111
|
+
// Bind rt.signal to THIS run's controller so plugin model calls
|
|
112
|
+
// (side-channel summaries) honor stop()/abort().
|
|
113
|
+
rt = {
|
|
114
|
+
...rt,
|
|
115
|
+
signal: controller.signal,
|
|
116
|
+
// Ephemeral side-channel turn: shares system prompt + branch messages
|
|
117
|
+
// + tool catalog (for prompt cache) but never persists. Tool calls
|
|
118
|
+
// from the model are discarded — an ephemeral side channel.
|
|
119
|
+
runEphemeralTurn: async (promptText, ephemeralOpts) => {
|
|
120
|
+
const branch = await readBranchMessages();
|
|
121
|
+
const ephemeralMessages = [
|
|
122
|
+
...((codingInput.run.systemPrompt ?? "")
|
|
123
|
+
? [{ role: "system", text: codingInput.run.systemPrompt ?? "" }]
|
|
124
|
+
: []),
|
|
125
|
+
...branch,
|
|
126
|
+
{ role: "user", text: promptText },
|
|
127
|
+
];
|
|
128
|
+
let text = "";
|
|
129
|
+
for await (const chunk of opts.modelStream(ephemeralMessages, ephemeralOpts?.signal ?? controller?.signal, [...toolMap.values()])) {
|
|
130
|
+
if (chunk.delta?.type === "text")
|
|
131
|
+
text += chunk.delta.text;
|
|
132
|
+
}
|
|
133
|
+
return text;
|
|
134
|
+
},
|
|
135
|
+
};
|
|
136
|
+
streamRuleInjections.clear();
|
|
137
|
+
debugModelId = codingInput.run.model.modelId;
|
|
138
|
+
debugTurn = 0;
|
|
139
|
+
debugRunId = codingInput.run.runId;
|
|
140
|
+
runUsage = undefined;
|
|
141
|
+
let runError;
|
|
142
|
+
const runTools = opts.resolveTools ? await opts.resolveTools(codingInput) : [];
|
|
143
|
+
toolMap = new Map([...baseTools, ...runTools].map((t) => [t.name, t]));
|
|
144
|
+
await emit({ type: "agent_start" });
|
|
145
|
+
try {
|
|
146
|
+
const model = opts.resolveModel
|
|
147
|
+
? await opts.resolveModel(codingInput.run.model.modelId)
|
|
148
|
+
: undefined;
|
|
149
|
+
const metaText = renderLoopMeta({
|
|
150
|
+
plugins: opts.plugins,
|
|
151
|
+
workspace: { root: codingInput.workspace.root },
|
|
152
|
+
model,
|
|
153
|
+
});
|
|
154
|
+
const systemPrompt = codingInput.run.systemPrompt ?? "";
|
|
155
|
+
const built = buildLoopInput({
|
|
156
|
+
systemPrompt,
|
|
157
|
+
metaText,
|
|
158
|
+
input: codingInput.input,
|
|
159
|
+
history: codingInput.history,
|
|
160
|
+
}, mode);
|
|
161
|
+
await persist(built.batch.entries);
|
|
162
|
+
let messages = await readBranchMessages();
|
|
163
|
+
let step = 0;
|
|
164
|
+
let forceContinues = 0;
|
|
165
|
+
let overflowCompacted = false;
|
|
166
|
+
let thresholdCompacted = false;
|
|
167
|
+
let usageAnchor = null;
|
|
168
|
+
let naturalStop = false;
|
|
169
|
+
for (const p of opts.plugins) {
|
|
170
|
+
if (p.hooks?.beforeRun) {
|
|
171
|
+
try {
|
|
172
|
+
await p.hooks.beforeRun(messages, rt);
|
|
173
|
+
}
|
|
174
|
+
catch {
|
|
175
|
+
/* plugin setup errors never fail the run */
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
while (step < opts.maxSteps && !naturalStop) {
|
|
180
|
+
if (controller?.signal.aborted)
|
|
181
|
+
break;
|
|
182
|
+
step++;
|
|
183
|
+
acceptingSteer = step < opts.maxSteps;
|
|
184
|
+
// Drain steer queue at safe boundary (steer appends only the input
|
|
185
|
+
// message — no Meta, no new Loop).
|
|
186
|
+
if (steerQueue.length > 0) {
|
|
187
|
+
const steers = steerQueue.splice(0);
|
|
188
|
+
await persist(steers.map((s) => ({
|
|
189
|
+
type: "message",
|
|
190
|
+
productEntryId: s.productEntryId ?? null,
|
|
191
|
+
role: s.message.role,
|
|
192
|
+
source: "steer",
|
|
193
|
+
message: s.message,
|
|
194
|
+
createdAt: Date.now(),
|
|
195
|
+
})));
|
|
196
|
+
messages = await readBranchMessages();
|
|
197
|
+
const drained = steers
|
|
198
|
+
.map((s) => (s.message.role === "user" ? (s.message.text ?? "") : ""))
|
|
199
|
+
.filter((t) => t.length > 0);
|
|
200
|
+
await emit({
|
|
201
|
+
type: "queue_update",
|
|
202
|
+
...(drained.length > 0 ? { drained } : {}),
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
// One step = at most one model call. Overflow recovery stays INSIDE
|
|
206
|
+
while (true) {
|
|
207
|
+
// Prune old tool-result content: a lighter
|
|
208
|
+
// pass that runs BEFORE compaction checks. Old results outside the
|
|
209
|
+
// protect window are truncated to a summary; protected tools are
|
|
210
|
+
// never pruned. May reduce context enough to avoid compaction
|
|
211
|
+
// entirely (omp docs/compaction.md).
|
|
212
|
+
const modelMessages0 = opts.pruneConfig
|
|
213
|
+
? pruneOldToolResults(messages, opts.pruneConfig).messages
|
|
214
|
+
: messages;
|
|
215
|
+
// Snapshot for this model call + proactive (threshold) compaction.
|
|
216
|
+
// Estimation is anchored on the previous call's real usage
|
|
217
|
+
// (oh-my-pi): only entries persisted since the anchor boundary are
|
|
218
|
+
// per-message estimated, so the estimate tracks the provider's
|
|
219
|
+
// own accounting instead of drifting with chars/4. The
|
|
220
|
+
// TokenEstimateCache still avoids re-estimating settled entries.
|
|
221
|
+
let callBoundaryId = null;
|
|
222
|
+
if (opts.contextBudget) {
|
|
223
|
+
const branch = await opts.store.readBranch(opts.sessionId);
|
|
224
|
+
const msgEntries = branch.filter((e) => e.type === "message");
|
|
225
|
+
callBoundaryId = msgEntries.at(-1)?.entryId ?? null;
|
|
226
|
+
if (!thresholdCompacted) {
|
|
227
|
+
const totalTokens = estimateContextTokens(msgEntries, usageAnchor, (e) => tokenEstimateCache.estimate(e.entryId, e.message, opts.contextBudget.estimate));
|
|
228
|
+
if (totalTokens > opts.contextBudget.limit * opts.contextBudget.triggerRatio) {
|
|
229
|
+
thresholdCompacted = true;
|
|
230
|
+
usageAnchor = null;
|
|
231
|
+
tokenEstimateCache.clear();
|
|
232
|
+
await emit({ type: "compaction_start" });
|
|
233
|
+
await compactSession(opts.store, opts.sessionId, opts.summarize, controller?.signal, opts.contextBudget);
|
|
234
|
+
await emit({ type: "compaction_end" });
|
|
235
|
+
messages = await readBranchMessages();
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
// beforeModel hook.
|
|
240
|
+
const transformed = [...modelMessages0];
|
|
241
|
+
for (const p of opts.plugins) {
|
|
242
|
+
if (p.hooks?.beforeModel) {
|
|
243
|
+
const result = p.hooks.beforeModel(transformed, rt);
|
|
244
|
+
transformed.length = 0;
|
|
245
|
+
transformed.push(...result);
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
try {
|
|
249
|
+
const modelMessages = systemPrompt
|
|
250
|
+
? [{ role: "system", text: systemPrompt }, ...transformed]
|
|
251
|
+
: transformed;
|
|
252
|
+
const turn = await streamModelTurn(modelMessages);
|
|
253
|
+
const thinkingBlocks = buildThinkingBlock(turn);
|
|
254
|
+
// Usage anchor (oh-my-pi): the completed call's real token
|
|
255
|
+
// total is authoritative for everything persisted before the
|
|
256
|
+
// call — per-message estimation covers only the delta since.
|
|
257
|
+
if (turn.usage && usageTotalTokens(turn.usage) > 0) {
|
|
258
|
+
usageAnchor = { afterEntryId: callBoundaryId, tokens: usageTotalTokens(turn.usage) };
|
|
259
|
+
}
|
|
260
|
+
// Silent context overflow (oh-my-pi isContextOverflow): some
|
|
261
|
+
// providers (zai, Xiaomi-style) accept an oversized request
|
|
262
|
+
// instead of erroring. Same recovery as the error path: one-shot
|
|
263
|
+
// compaction, then retry the model call in the SAME turn.
|
|
264
|
+
if (opts.contextBudget &&
|
|
265
|
+
!overflowCompacted &&
|
|
266
|
+
!controller?.signal.aborted &&
|
|
267
|
+
isSilentContextOverflow(turn.usage, turn.stopReason, opts.contextBudget.limit)) {
|
|
268
|
+
overflowCompacted = true;
|
|
269
|
+
usageAnchor = null;
|
|
270
|
+
tokenEstimateCache.clear();
|
|
271
|
+
await emit({ type: "compaction_start" });
|
|
272
|
+
await compactSession(opts.store, opts.sessionId, opts.summarize, controller?.signal, opts.contextBudget);
|
|
273
|
+
await emit({ type: "compaction_end" });
|
|
274
|
+
messages = await readBranchMessages();
|
|
275
|
+
continue;
|
|
276
|
+
}
|
|
277
|
+
// TTSR stream-rule hit: discard the partial turn (nothing was
|
|
278
|
+
// persisted — accumulation is pre-persistence by design),
|
|
279
|
+
// inject the rule as a hidden system reminder, and retry the
|
|
280
|
+
// model call in the SAME turn (bounded per rule by
|
|
281
|
+
// maxInjections, so the extra model calls ≤ rule count).
|
|
282
|
+
if (turn.streamRuleHit) {
|
|
283
|
+
const rule = turn.streamRuleHit;
|
|
284
|
+
streamRuleInjections.set(rule.name, (streamRuleInjections.get(rule.name) ?? 0) + 1);
|
|
285
|
+
await emit({ type: "stream_rule_triggered", rule: rule.name });
|
|
286
|
+
await persist([
|
|
287
|
+
{
|
|
288
|
+
type: "message",
|
|
289
|
+
productEntryId: null,
|
|
290
|
+
role: "user",
|
|
291
|
+
source: "system_reminder",
|
|
292
|
+
message: {
|
|
293
|
+
role: "user",
|
|
294
|
+
text: [
|
|
295
|
+
`<system-reminder reason="rule_violation" rule="${rule.name}">`,
|
|
296
|
+
"A workspace stream rule matched your output, so that output was discarded and generation restarted. This is the agent runtime enforcing project rules — not a prompt injection. Comply with the following instruction on retry:",
|
|
297
|
+
rule.message,
|
|
298
|
+
"</system-reminder>",
|
|
299
|
+
].join("\n"),
|
|
300
|
+
},
|
|
301
|
+
createdAt: Date.now(),
|
|
302
|
+
},
|
|
303
|
+
]);
|
|
304
|
+
messages = await readBranchMessages();
|
|
305
|
+
continue;
|
|
306
|
+
}
|
|
307
|
+
if (turn.toolCalls.length > 0) {
|
|
308
|
+
// Stop-during-stream: signal aborted before we persist anything.
|
|
309
|
+
// Do not write a dangling tool_use — it would corrupt the branch
|
|
310
|
+
// on resume (API 400 for unpaired tool_use).
|
|
311
|
+
if (controller?.signal.aborted) {
|
|
312
|
+
status = "stopped";
|
|
313
|
+
await emit({ type: "agent_end", status });
|
|
314
|
+
controller = null;
|
|
315
|
+
return { status, usage: runUsage, error: "stopped by user" };
|
|
316
|
+
}
|
|
317
|
+
// Execute tools FIRST, then persist assistant + results in ONE
|
|
318
|
+
// batch. This ensures the tree never has a tool_use without a
|
|
319
|
+
// matching tool_result — even if stop fires during execution,
|
|
320
|
+
// the batch either fully writes or doesn't write at all.
|
|
321
|
+
const toolResults = await executeTools(turn.toolCalls);
|
|
322
|
+
if (controller?.signal.aborted) {
|
|
323
|
+
status = "stopped";
|
|
324
|
+
await emit({ type: "agent_end", status });
|
|
325
|
+
controller = null;
|
|
326
|
+
return { status, usage: runUsage, error: "stopped by user" };
|
|
327
|
+
}
|
|
328
|
+
// Persist assistant(tool_use) + all tool_results atomically.
|
|
329
|
+
const batch = [
|
|
330
|
+
{
|
|
331
|
+
type: "message",
|
|
332
|
+
role: "assistant",
|
|
333
|
+
source: "assistant",
|
|
334
|
+
message: {
|
|
335
|
+
role: "assistant",
|
|
336
|
+
// Keep any narrative text the model emitted alongside
|
|
337
|
+
// tool calls (DeepSeek interleaves thinking/text with
|
|
338
|
+
// tool_use). Text fragments preserve their order; thinking
|
|
339
|
+
// fragments COLLAPSE into the single thinking block
|
|
340
|
+
// (Anthropic requires one <thinking> per assistant message
|
|
341
|
+
// with one signature) inserted at the position of the
|
|
342
|
+
// first thinking fragment.
|
|
343
|
+
text: turn.text,
|
|
344
|
+
blocks: [
|
|
345
|
+
...(() => {
|
|
346
|
+
const collapsedThinking = {
|
|
347
|
+
type: "thinking",
|
|
348
|
+
text: turn.thinkingRedacted ? "[reasoning redacted]" : turn.thinking,
|
|
349
|
+
...(turn.thinkingSignature ? { signature: turn.thinkingSignature } : {}),
|
|
350
|
+
...(turn.thinkingRedacted ? { redacted: true } : {}),
|
|
351
|
+
};
|
|
352
|
+
const out = [];
|
|
353
|
+
let thinkingInserted = false;
|
|
354
|
+
for (const b of turn.ordered) {
|
|
355
|
+
if (b.type === "thinking") {
|
|
356
|
+
if (!thinkingInserted) {
|
|
357
|
+
out.push(collapsedThinking);
|
|
358
|
+
thinkingInserted = true;
|
|
359
|
+
}
|
|
360
|
+
continue;
|
|
361
|
+
}
|
|
362
|
+
out.push(b);
|
|
363
|
+
}
|
|
364
|
+
if (!thinkingInserted && turn.thinking) {
|
|
365
|
+
out.push(collapsedThinking);
|
|
366
|
+
}
|
|
367
|
+
return out;
|
|
368
|
+
})(),
|
|
369
|
+
...turn.toolCalls.map((tc) => ({
|
|
370
|
+
type: "tool_use",
|
|
371
|
+
id: tc.id,
|
|
372
|
+
name: tc.name,
|
|
373
|
+
input: tc.input,
|
|
374
|
+
})),
|
|
375
|
+
],
|
|
376
|
+
},
|
|
377
|
+
createdAt: Date.now(),
|
|
378
|
+
},
|
|
379
|
+
...toolResults.map((result) => {
|
|
380
|
+
// Vision passthrough: a tool result carrying `images`
|
|
381
|
+
// (read_image) keeps them on the tool_result block so
|
|
382
|
+
// providers map them onto the wire content array.
|
|
383
|
+
const imgs = result.result?.images;
|
|
384
|
+
const images = Array.isArray(imgs) && imgs.length > 0
|
|
385
|
+
? {
|
|
386
|
+
images: imgs,
|
|
387
|
+
}
|
|
388
|
+
: {};
|
|
389
|
+
// Tool result content contract (spec): a string `content`
|
|
390
|
+
// field is the model-visible text verbatim (tool-formatted);
|
|
391
|
+
// everything else stays the JSON dump for both model and UI.
|
|
392
|
+
const res = result.result;
|
|
393
|
+
const raw = typeof res?.content === "string" ? res.content : JSON.stringify(result.result);
|
|
394
|
+
// Tool-failure system reminder (absorbed from oh-my-pi):
|
|
395
|
+
// in-band on the failing result so it survives into the
|
|
396
|
+
// canonical ledger — "the fix sticks" across runs. The
|
|
397
|
+
// message `text` stays the clean JSON for UI display.
|
|
398
|
+
const content = result.isError && opts.toolFailureReminder !== false
|
|
399
|
+
? `${TOOL_FAILURE_REMINDER}\n\n${raw}`
|
|
400
|
+
: raw;
|
|
401
|
+
return {
|
|
402
|
+
type: "message",
|
|
403
|
+
role: "tool",
|
|
404
|
+
source: "tool_result",
|
|
405
|
+
message: {
|
|
406
|
+
role: "tool",
|
|
407
|
+
text: raw,
|
|
408
|
+
blocks: [
|
|
409
|
+
{
|
|
410
|
+
type: "tool_result",
|
|
411
|
+
tool_use_id: result.id,
|
|
412
|
+
content,
|
|
413
|
+
...(result.isError ? { is_error: true } : {}),
|
|
414
|
+
...images,
|
|
415
|
+
},
|
|
416
|
+
],
|
|
417
|
+
},
|
|
418
|
+
createdAt: Date.now(),
|
|
419
|
+
};
|
|
420
|
+
}),
|
|
421
|
+
];
|
|
422
|
+
await persist(batch);
|
|
423
|
+
messages = await readBranchMessages();
|
|
424
|
+
if (toolResults.some((r) => r.terminate) && steerQueue.length === 0) {
|
|
425
|
+
naturalStop = true;
|
|
426
|
+
}
|
|
427
|
+
break;
|
|
428
|
+
}
|
|
429
|
+
// Text turn: persist assistant(text) + thinking. Thinking alone
|
|
430
|
+
// is not a message — a thinking-only turn with no text and no
|
|
431
|
+
// tool calls contributed nothing replayable, and empty content
|
|
432
|
+
// breaks strict model APIs.
|
|
433
|
+
// An abort during the stream discards the partial output: an
|
|
434
|
+
// uncompleted turn never enters the canonical tree.
|
|
435
|
+
if (turn.text && !controller?.signal.aborted) {
|
|
436
|
+
await persist([
|
|
437
|
+
{
|
|
438
|
+
type: "message",
|
|
439
|
+
role: "assistant",
|
|
440
|
+
source: "assistant",
|
|
441
|
+
message: {
|
|
442
|
+
role: "assistant",
|
|
443
|
+
text: turn.text,
|
|
444
|
+
// Preserve the interleaved thinking/text order from the
|
|
445
|
+
// stream. The single collapsed thinking block (with
|
|
446
|
+
// signature) is still emitted for replay compatibility
|
|
447
|
+
// when the stream had a signature, but the ordered list
|
|
448
|
+
// keeps the trace faithful.
|
|
449
|
+
blocks: turn.ordered.length > 0
|
|
450
|
+
? turn.ordered.map((b) => b.type === "thinking"
|
|
451
|
+
? {
|
|
452
|
+
type: "thinking",
|
|
453
|
+
text: turn.thinkingRedacted ? "[reasoning redacted]" : b.text,
|
|
454
|
+
...(turn.thinkingSignature
|
|
455
|
+
? { signature: turn.thinkingSignature }
|
|
456
|
+
: {}),
|
|
457
|
+
...(turn.thinkingRedacted ? { redacted: true } : {}),
|
|
458
|
+
}
|
|
459
|
+
: b)
|
|
460
|
+
: thinkingBlocks.length > 0
|
|
461
|
+
? thinkingBlocks
|
|
462
|
+
: undefined,
|
|
463
|
+
},
|
|
464
|
+
createdAt: Date.now(),
|
|
465
|
+
},
|
|
466
|
+
]);
|
|
467
|
+
}
|
|
468
|
+
// Natural stop: let plugins veto.
|
|
469
|
+
let stopped = true;
|
|
470
|
+
for (const p of opts.plugins) {
|
|
471
|
+
if (p.hooks?.beforeStop) {
|
|
472
|
+
let vetoed = false;
|
|
473
|
+
try {
|
|
474
|
+
p.hooks.beforeStop(() => {
|
|
475
|
+
vetoed = true;
|
|
476
|
+
}, rt);
|
|
477
|
+
}
|
|
478
|
+
catch {
|
|
479
|
+
/* plugin errors never fail the run */
|
|
480
|
+
}
|
|
481
|
+
if (vetoed && forceContinues < opts.maxForceContinues) {
|
|
482
|
+
forceContinues++;
|
|
483
|
+
stopped = false;
|
|
484
|
+
break;
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
// max_tokens/pause_turn truncation semantics: the model ran out
|
|
489
|
+
// of output budget (or paused a long turn) mid-answer — force
|
|
490
|
+
// one continuation when capacity remains, bounded by
|
|
491
|
+
// maxForceContinues.
|
|
492
|
+
const truncated = turn.stopReason === "max_tokens" || turn.stopReason === "pause_turn";
|
|
493
|
+
if (stopped && truncated && forceContinues < opts.maxForceContinues) {
|
|
494
|
+
forceContinues++;
|
|
495
|
+
stopped = false;
|
|
496
|
+
}
|
|
497
|
+
naturalStop = stopped;
|
|
498
|
+
for (const p of opts.plugins) {
|
|
499
|
+
if (p.hooks?.afterStop) {
|
|
500
|
+
try {
|
|
501
|
+
p.hooks.afterStop(!stopped, rt);
|
|
502
|
+
}
|
|
503
|
+
catch {
|
|
504
|
+
/* plugin errors never affect the loop */
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
// Accepted-but-late steer: force one more turn to drain it.
|
|
509
|
+
if (naturalStop && steerQueue.length > 0) {
|
|
510
|
+
naturalStop = false;
|
|
511
|
+
}
|
|
512
|
+
break;
|
|
513
|
+
}
|
|
514
|
+
catch (err) {
|
|
515
|
+
// Explicit stop/abort is a distinct terminal state.
|
|
516
|
+
if (controller?.signal.aborted ||
|
|
517
|
+
(err instanceof ProviderError && err.kind === "aborted")) {
|
|
518
|
+
status = "stopped";
|
|
519
|
+
runError ??= err instanceof Error ? err.message : "stopped by user";
|
|
520
|
+
await emit({ type: "agent_end", status });
|
|
521
|
+
controller = null;
|
|
522
|
+
return { status, usage: runUsage, error: runError };
|
|
523
|
+
}
|
|
524
|
+
// Overflow: one-shot compaction recovery inside the same turn.
|
|
525
|
+
if (err instanceof ProviderError && err.kind === "overflow" && !overflowCompacted) {
|
|
526
|
+
overflowCompacted = true;
|
|
527
|
+
usageAnchor = null;
|
|
528
|
+
tokenEstimateCache.clear();
|
|
529
|
+
await emit({ type: "compaction_start" });
|
|
530
|
+
await compactSession(opts.store, opts.sessionId, opts.summarize, controller?.signal, opts.contextBudget);
|
|
531
|
+
await emit({ type: "compaction_end" });
|
|
532
|
+
messages = await readBranchMessages();
|
|
533
|
+
continue; // retry model call in the SAME turn, no extra step
|
|
534
|
+
}
|
|
535
|
+
// Anything else is terminal: retryStream already applied its
|
|
536
|
+
// bounded policy.
|
|
537
|
+
runError ??= err instanceof Error ? err.message : String(err);
|
|
538
|
+
status = "failed";
|
|
539
|
+
await emit({ type: "agent_end", status });
|
|
540
|
+
controller = null;
|
|
541
|
+
return { status, usage: runUsage, error: runError };
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
// afterModel hook: after the turn's output + tool results are
|
|
545
|
+
// persisted, before turn_end.
|
|
546
|
+
for (const p of opts.plugins) {
|
|
547
|
+
if (p.hooks?.afterModel) {
|
|
548
|
+
try {
|
|
549
|
+
await p.hooks.afterModel(messages, rt);
|
|
550
|
+
}
|
|
551
|
+
catch {
|
|
552
|
+
/* plugin errors never fail the run */
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
await emit({ type: "turn_end", turn: step });
|
|
557
|
+
if (naturalStop)
|
|
558
|
+
break;
|
|
559
|
+
}
|
|
560
|
+
if (controller?.signal.aborted) {
|
|
561
|
+
status = "stopped";
|
|
562
|
+
}
|
|
563
|
+
else if (!naturalStop && step >= opts.maxSteps && status === "running") {
|
|
564
|
+
status = "failed";
|
|
565
|
+
runError ??= `max steps exceeded (${opts.maxSteps})`;
|
|
566
|
+
}
|
|
567
|
+
else if (status === "running") {
|
|
568
|
+
status = "completed";
|
|
569
|
+
}
|
|
570
|
+
for (const p of opts.plugins) {
|
|
571
|
+
if (p.hooks?.afterRun) {
|
|
572
|
+
try {
|
|
573
|
+
await p.hooks.afterRun(status, messages, rt);
|
|
574
|
+
}
|
|
575
|
+
catch {
|
|
576
|
+
/* plugin errors never fail the run */
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
let title;
|
|
581
|
+
// Auto-title retries on EVERY completed turn while the conversation is
|
|
582
|
+
// still untitled (OMA_CONV_TITLED=1 marks it titled — the backend sets
|
|
583
|
+
// it at spawn and re-checks on commit). The first turn may be low
|
|
584
|
+
// signal ("hi") and must not permanently suppress the title.
|
|
585
|
+
if (status === "completed" &&
|
|
586
|
+
process.env.OMA_CONV_TITLED !== "1" &&
|
|
587
|
+
process.env.OMA_TITLE_ENABLED !== "0") {
|
|
588
|
+
const titleBranch = await readBranchMessages();
|
|
589
|
+
const titleCtx = buildTitleContext(titleBranch);
|
|
590
|
+
if (titleCtx) {
|
|
591
|
+
title = (await generateTitle(rt, titleCtx)) ?? undefined;
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
await emit({ type: "agent_end", status });
|
|
595
|
+
// Canonical output (ADR 0017): the run's full message sequence.
|
|
596
|
+
// Every terminal status returns the persisted assistant/tool messages:
|
|
597
|
+
// a failed run (e.g. max steps exceeded) must still surface what it
|
|
598
|
+
// did so a follow-up turn ("continue") has the context.
|
|
599
|
+
const entries = await opts.store.readBranch(opts.sessionId);
|
|
600
|
+
const runMessages = entries
|
|
601
|
+
.filter((e) => e.type === "message" && (e.source === "assistant" || e.source === "tool_result"))
|
|
602
|
+
.map((e) => e.message);
|
|
603
|
+
return { status, messages: runMessages, usage: runUsage, error: runError, title };
|
|
604
|
+
}
|
|
605
|
+
catch (err) {
|
|
606
|
+
// Setup/persistence failure: settle to a terminal state so listeners
|
|
607
|
+
// always receive agent_end and the loop is reusable.
|
|
608
|
+
runError ??= err instanceof Error ? err.message : String(err);
|
|
609
|
+
status = controller?.signal.aborted ? "stopped" : "failed";
|
|
610
|
+
await emit({ type: "agent_end", status });
|
|
611
|
+
return { status, usage: runUsage, error: runError };
|
|
612
|
+
}
|
|
613
|
+
finally {
|
|
614
|
+
active = false;
|
|
615
|
+
controller = null;
|
|
616
|
+
steerQueue.length = 0;
|
|
617
|
+
acceptingSteer = false;
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
/** Accumulate one model turn from the stream — pure, no persistence
|
|
621
|
+
* The caller decides what to persist. */
|
|
622
|
+
async function streamModelTurn(messages) {
|
|
623
|
+
let text = "";
|
|
624
|
+
let thinking = "";
|
|
625
|
+
const ordered = [];
|
|
626
|
+
let thinkingSignature;
|
|
627
|
+
let thinkingRedacted = false;
|
|
628
|
+
const toolCallBuilders = new Map();
|
|
629
|
+
debugTurn++;
|
|
630
|
+
debugLog("oma", `model_start runId=${runIdForDebug()} turn=${debugTurn} model=${debugModelId}`);
|
|
631
|
+
await emit({ type: "message_start" });
|
|
632
|
+
const stream = retryStream((signal) => opts.modelStream(messages, signal, [...toolMap.values()]), {
|
|
633
|
+
maxAttempts: opts.maxRetries ?? 3,
|
|
634
|
+
baseDelayMs: 1000,
|
|
635
|
+
onRetryStart: (attempt) => emit({ type: "retry_start", attempt }),
|
|
636
|
+
onRetryEnd: () => emit({ type: "retry_end" }),
|
|
637
|
+
}, controller?.signal);
|
|
638
|
+
let stopReason;
|
|
639
|
+
let streamRuleHit;
|
|
640
|
+
let turnUsage;
|
|
641
|
+
try {
|
|
642
|
+
for await (const chunk of stream) {
|
|
643
|
+
if (controller?.signal.aborted)
|
|
644
|
+
break;
|
|
645
|
+
if (chunk.stopReason)
|
|
646
|
+
stopReason = chunk.stopReason;
|
|
647
|
+
if (chunk.usage) {
|
|
648
|
+
// Accumulate across all model calls in the Run (not last-wins)...
|
|
649
|
+
runUsage = {
|
|
650
|
+
inputTokens: (runUsage?.inputTokens ?? 0) + (chunk.usage.input ?? 0),
|
|
651
|
+
outputTokens: (runUsage?.outputTokens ?? 0) + (chunk.usage.output ?? 0),
|
|
652
|
+
cacheReadTokens: (runUsage?.cacheReadTokens ?? 0) + (chunk.usage.cacheRead ?? 0),
|
|
653
|
+
cacheWriteTokens: (runUsage?.cacheWriteTokens ?? 0) + (chunk.usage.cacheCreate ?? 0),
|
|
654
|
+
};
|
|
655
|
+
// ...and per turn: this call's own total anchors context
|
|
656
|
+
// estimation and silent-overflow detection (oh-my-pi).
|
|
657
|
+
turnUsage = {
|
|
658
|
+
inputTokens: (turnUsage?.inputTokens ?? 0) + (chunk.usage.input ?? 0),
|
|
659
|
+
outputTokens: (turnUsage?.outputTokens ?? 0) + (chunk.usage.output ?? 0),
|
|
660
|
+
cacheReadTokens: (turnUsage?.cacheReadTokens ?? 0) + (chunk.usage.cacheRead ?? 0),
|
|
661
|
+
cacheWriteTokens: (turnUsage?.cacheWriteTokens ?? 0) + (chunk.usage.cacheCreate ?? 0),
|
|
662
|
+
};
|
|
663
|
+
}
|
|
664
|
+
if (chunk.delta?.type === "text") {
|
|
665
|
+
text += chunk.delta.text;
|
|
666
|
+
ordered.push({ type: "text", text: chunk.delta.text });
|
|
667
|
+
await emit({ type: "message_update", text: chunk.delta.text });
|
|
668
|
+
const hit = opts.streamRules
|
|
669
|
+
? matchStreamRule(opts.streamRules, text, streamRuleInjections)
|
|
670
|
+
: undefined;
|
|
671
|
+
if (hit) {
|
|
672
|
+
streamRuleHit = hit;
|
|
673
|
+
break;
|
|
674
|
+
}
|
|
675
|
+
}
|
|
676
|
+
if (chunk.delta?.type === "reasoning") {
|
|
677
|
+
thinking += chunk.delta.text;
|
|
678
|
+
ordered.push({ type: "thinking", text: chunk.delta.text });
|
|
679
|
+
await emit({ type: "thinking_update", text: chunk.delta.text });
|
|
680
|
+
}
|
|
681
|
+
if (chunk.delta?.type === "reasoning_signature") {
|
|
682
|
+
thinkingSignature = chunk.delta.signature;
|
|
683
|
+
thinkingRedacted = chunk.delta.redacted === true;
|
|
684
|
+
}
|
|
685
|
+
if (chunk.delta?.type === "tool_use") {
|
|
686
|
+
const id = chunk.delta.id;
|
|
687
|
+
if (!toolCallBuilders.has(id)) {
|
|
688
|
+
toolCallBuilders.set(id, { id, name: chunk.delta.name, jsonParts: [] });
|
|
689
|
+
}
|
|
690
|
+
}
|
|
691
|
+
if (chunk.delta?.type === "input_json_delta") {
|
|
692
|
+
const builder = toolCallBuilders.get(chunk.delta.id);
|
|
693
|
+
if (builder)
|
|
694
|
+
builder.jsonParts.push(chunk.delta.partial_json);
|
|
695
|
+
}
|
|
696
|
+
}
|
|
697
|
+
}
|
|
698
|
+
finally {
|
|
699
|
+
// message_end always pairs with message_start, even on failure/abort.
|
|
700
|
+
await emit({ type: "message_end" });
|
|
701
|
+
}
|
|
702
|
+
debugLog("oma", `model_end runId=${runIdForDebug()} turn=${debugTurn} stopReason=${stopReason ?? "none"}`);
|
|
703
|
+
return {
|
|
704
|
+
text,
|
|
705
|
+
thinking,
|
|
706
|
+
ordered,
|
|
707
|
+
...(thinkingSignature ? { thinkingSignature } : {}),
|
|
708
|
+
...(thinkingRedacted ? { thinkingRedacted: true } : {}),
|
|
709
|
+
toolCalls: Array.from(toolCallBuilders.values()).map((b) => ({
|
|
710
|
+
id: b.id,
|
|
711
|
+
name: b.name,
|
|
712
|
+
input: b.jsonParts.length > 0 ? safeParseJson(b.jsonParts.join("")) : {},
|
|
713
|
+
})),
|
|
714
|
+
stopReason,
|
|
715
|
+
...(turnUsage ? { usage: turnUsage } : {}),
|
|
716
|
+
...(streamRuleHit ? { streamRuleHit } : {}),
|
|
717
|
+
};
|
|
718
|
+
}
|
|
719
|
+
async function executeTools(calls) {
|
|
720
|
+
const results = [];
|
|
721
|
+
// Batch execution: consecutive concurrent tools run in parallel via
|
|
722
|
+
// Promise.all; a serial tool acts as a barrier. Results preserve the
|
|
723
|
+
// original tool-call order regardless of completion order.
|
|
724
|
+
async function runOne(call) {
|
|
725
|
+
const tool = toolMap.get(call.name);
|
|
726
|
+
debugLog("oma", `tool_start runId=${runIdForDebug()} name=${call.name} callId=${call.id}`);
|
|
727
|
+
await emit({
|
|
728
|
+
type: "tool_execution_start",
|
|
729
|
+
toolName: call.name,
|
|
730
|
+
callId: call.id,
|
|
731
|
+
// Original model call args (pre-plugin-transform) so the transcript
|
|
732
|
+
// can show what the model asked for.
|
|
733
|
+
input: call.input,
|
|
734
|
+
...(tool?.timeoutMs !== undefined ? { timeoutMs: tool.timeoutMs } : {}),
|
|
735
|
+
});
|
|
736
|
+
let result;
|
|
737
|
+
let isError = false;
|
|
738
|
+
let terminate = false;
|
|
739
|
+
let input = call.input;
|
|
740
|
+
if (tool) {
|
|
741
|
+
// transformToolArgs: rewrite call args before execution.
|
|
742
|
+
// transformToolCallArguments).
|
|
743
|
+
for (const p of opts.plugins) {
|
|
744
|
+
if (p.hooks?.transformToolArgs) {
|
|
745
|
+
try {
|
|
746
|
+
const transformed = p.hooks.transformToolArgs(call.name, input, rt);
|
|
747
|
+
if (transformed && typeof transformed === "object") {
|
|
748
|
+
input = transformed;
|
|
749
|
+
}
|
|
750
|
+
}
|
|
751
|
+
catch {
|
|
752
|
+
/* plugin transform errors never block execution */
|
|
753
|
+
}
|
|
754
|
+
}
|
|
755
|
+
}
|
|
756
|
+
// beforeTool: observe or block. A block
|
|
757
|
+
// result emits an error tool result instead of executing.
|
|
758
|
+
let blocked = false;
|
|
759
|
+
let blockReason = `Blocked by plugin`;
|
|
760
|
+
for (const p of opts.plugins) {
|
|
761
|
+
if (p.hooks?.beforeTool) {
|
|
762
|
+
try {
|
|
763
|
+
const ret = p.hooks.beforeTool(call.name, input, rt);
|
|
764
|
+
if (ret?.block) {
|
|
765
|
+
blocked = true;
|
|
766
|
+
if (ret.reason)
|
|
767
|
+
blockReason = ret.reason;
|
|
768
|
+
break;
|
|
769
|
+
}
|
|
770
|
+
}
|
|
771
|
+
catch {
|
|
772
|
+
/* plugin errors never block execution */
|
|
773
|
+
}
|
|
774
|
+
}
|
|
775
|
+
}
|
|
776
|
+
// Native-tool permission gate (ADR 0020): runs AFTER plugin
|
|
777
|
+
// beforeTool hooks so a plugin block always wins; ask/deny here
|
|
778
|
+
// apply to native high-risk tools too.
|
|
779
|
+
if (!blocked && opts.permissionGate) {
|
|
780
|
+
try {
|
|
781
|
+
const verdict = await opts.permissionGate(call.name, input);
|
|
782
|
+
if (verdict?.block) {
|
|
783
|
+
blocked = true;
|
|
784
|
+
if (verdict.reason)
|
|
785
|
+
blockReason = verdict.reason;
|
|
786
|
+
}
|
|
787
|
+
}
|
|
788
|
+
catch {
|
|
789
|
+
/* permission gate errors never crash the run */
|
|
790
|
+
}
|
|
791
|
+
}
|
|
792
|
+
if (blocked) {
|
|
793
|
+
result = { error: blockReason };
|
|
794
|
+
isError = true;
|
|
795
|
+
}
|
|
796
|
+
else {
|
|
797
|
+
try {
|
|
798
|
+
result = await tool.execute(input, controller?.signal, {
|
|
799
|
+
callId: call.id,
|
|
800
|
+
onOutput: (text) => {
|
|
801
|
+
void emit({
|
|
802
|
+
type: "tool_output",
|
|
803
|
+
toolName: call.name,
|
|
804
|
+
callId: call.id,
|
|
805
|
+
text,
|
|
806
|
+
});
|
|
807
|
+
},
|
|
808
|
+
...(opts.approvalHandler
|
|
809
|
+
? {
|
|
810
|
+
request: (req) => opts.approvalHandler({
|
|
811
|
+
callId: call.id,
|
|
812
|
+
toolName: call.name,
|
|
813
|
+
input,
|
|
814
|
+
source: "tool",
|
|
815
|
+
...(req.reason ? { reason: req.reason } : {}),
|
|
816
|
+
}),
|
|
817
|
+
}
|
|
818
|
+
: {}),
|
|
819
|
+
...(opts.askHandler ? { ask: opts.askHandler } : {}),
|
|
820
|
+
});
|
|
821
|
+
if (result && typeof result === "object") {
|
|
822
|
+
if ("isError" in result) {
|
|
823
|
+
isError = Boolean(result.isError);
|
|
824
|
+
}
|
|
825
|
+
if ("terminate" in result) {
|
|
826
|
+
terminate = Boolean(result.terminate);
|
|
827
|
+
}
|
|
828
|
+
}
|
|
829
|
+
}
|
|
830
|
+
catch (err) {
|
|
831
|
+
result = { error: err instanceof Error ? err.message : String(err) };
|
|
832
|
+
isError = true;
|
|
833
|
+
}
|
|
834
|
+
}
|
|
835
|
+
}
|
|
836
|
+
else {
|
|
837
|
+
result = { error: `Unknown tool: ${call.name}` };
|
|
838
|
+
isError = true;
|
|
839
|
+
}
|
|
840
|
+
debugLog("oma", `tool_end runId=${runIdForDebug()} name=${call.name} callId=${call.id} error=${isError}`);
|
|
841
|
+
await emit({
|
|
842
|
+
type: "tool_execution_end",
|
|
843
|
+
toolName: call.name,
|
|
844
|
+
callId: call.id,
|
|
845
|
+
result: (result ?? {}),
|
|
846
|
+
});
|
|
847
|
+
// afterTool: observe (emit event) or patch (override result fields).
|
|
848
|
+
for (const p of opts.plugins) {
|
|
849
|
+
try {
|
|
850
|
+
const ret = p.hooks?.afterTool?.(call.name, result, rt);
|
|
851
|
+
if (ret) {
|
|
852
|
+
// OmaLoopEvent (has `type`) → emit; patch object →
|
|
853
|
+
// override result fields field-by-field.
|
|
854
|
+
if ("type" in ret) {
|
|
855
|
+
await emit(ret);
|
|
856
|
+
}
|
|
857
|
+
else {
|
|
858
|
+
if (ret.content !== undefined)
|
|
859
|
+
result = ret.content;
|
|
860
|
+
if (ret.isError !== undefined)
|
|
861
|
+
isError = ret.isError;
|
|
862
|
+
if (ret.terminate !== undefined)
|
|
863
|
+
terminate = ret.terminate;
|
|
864
|
+
}
|
|
865
|
+
}
|
|
866
|
+
}
|
|
867
|
+
catch {
|
|
868
|
+
/* plugin errors never affect the loop */
|
|
869
|
+
}
|
|
870
|
+
}
|
|
871
|
+
return { id: call.id, result, isError, terminate };
|
|
872
|
+
}
|
|
873
|
+
let i = 0;
|
|
874
|
+
while (i < calls.length) {
|
|
875
|
+
if (controller?.signal.aborted)
|
|
876
|
+
break;
|
|
877
|
+
const call = calls[i];
|
|
878
|
+
const isConcurrent = toolMap.get(call.name)?.executionMode === "concurrent";
|
|
879
|
+
if (!isConcurrent) {
|
|
880
|
+
// Serial tool: run alone (barrier before and after).
|
|
881
|
+
if (controller?.signal.aborted)
|
|
882
|
+
break;
|
|
883
|
+
const r = await runOne(call);
|
|
884
|
+
if (controller?.signal.aborted)
|
|
885
|
+
break;
|
|
886
|
+
results.push(r);
|
|
887
|
+
i++;
|
|
888
|
+
continue;
|
|
889
|
+
}
|
|
890
|
+
// Collect a maximal run of consecutive concurrent tools.
|
|
891
|
+
const batch = [call];
|
|
892
|
+
let j = i + 1;
|
|
893
|
+
while (j < calls.length) {
|
|
894
|
+
const next = calls[j];
|
|
895
|
+
if (toolMap.get(next.name)?.executionMode !== "concurrent")
|
|
896
|
+
break;
|
|
897
|
+
batch.push(next);
|
|
898
|
+
j++;
|
|
899
|
+
}
|
|
900
|
+
// Run the whole batch in parallel.
|
|
901
|
+
const batchResults = await Promise.all(batch.map((c) => runOne(c)));
|
|
902
|
+
if (controller?.signal.aborted)
|
|
903
|
+
break;
|
|
904
|
+
results.push(...batchResults);
|
|
905
|
+
i = j;
|
|
906
|
+
}
|
|
907
|
+
return results;
|
|
908
|
+
}
|
|
909
|
+
async function readBranchMessages() {
|
|
910
|
+
const entries = await opts.store.readBranch(opts.sessionId);
|
|
911
|
+
// Find latest CompactionEntry
|
|
912
|
+
let compactionSummary = null;
|
|
913
|
+
let coveredIds = null;
|
|
914
|
+
for (let i = entries.length - 1; i >= 0; i--) {
|
|
915
|
+
if (entries[i]?.type === "compaction") {
|
|
916
|
+
const comp = entries[i];
|
|
917
|
+
compactionSummary = comp.summary;
|
|
918
|
+
coveredIds = new Set(comp.coversEntryIds);
|
|
919
|
+
break;
|
|
920
|
+
}
|
|
921
|
+
}
|
|
922
|
+
return entries
|
|
923
|
+
.filter((e) => {
|
|
924
|
+
if (e.type !== "message")
|
|
925
|
+
return false;
|
|
926
|
+
// If compaction exists, skip covered entries
|
|
927
|
+
if (coveredIds?.has(e.entryId))
|
|
928
|
+
return false;
|
|
929
|
+
return true;
|
|
930
|
+
})
|
|
931
|
+
.map((e) => {
|
|
932
|
+
const msg = e.message;
|
|
933
|
+
// Prepend compaction summary as a system note if entries were compacted
|
|
934
|
+
return msg;
|
|
935
|
+
})
|
|
936
|
+
.flatMap((msg, _i, _arr) => {
|
|
937
|
+
// Insert summary as first user message if compaction applied
|
|
938
|
+
if (_i === 0 && compactionSummary && coveredIds && coveredIds.size > 0) {
|
|
939
|
+
return [
|
|
940
|
+
{ role: "user", text: `[Context summary: ${compactionSummary}]` },
|
|
941
|
+
msg,
|
|
942
|
+
];
|
|
943
|
+
}
|
|
944
|
+
return [msg];
|
|
945
|
+
});
|
|
946
|
+
}
|
|
947
|
+
return {
|
|
948
|
+
sessionId: opts.sessionId,
|
|
949
|
+
get status() {
|
|
950
|
+
return status;
|
|
951
|
+
},
|
|
952
|
+
async startLoop(deps) {
|
|
953
|
+
return runLoop(deps, "normal");
|
|
954
|
+
},
|
|
955
|
+
async startFollowUp(deps) {
|
|
956
|
+
return runLoop(deps, "follow_up");
|
|
957
|
+
},
|
|
958
|
+
steer(input) {
|
|
959
|
+
if (status !== "running" || !acceptingSteer) {
|
|
960
|
+
throw new Error("Steer is only accepted during a loop with remaining turn capacity");
|
|
961
|
+
}
|
|
962
|
+
steerQueue.push(input);
|
|
963
|
+
},
|
|
964
|
+
stop() {
|
|
965
|
+
controller?.abort();
|
|
966
|
+
},
|
|
967
|
+
async compact() {
|
|
968
|
+
await emit({ type: "compaction_start" });
|
|
969
|
+
await compactSession(opts.store, opts.sessionId, opts.summarize, controller?.signal, opts.contextBudget);
|
|
970
|
+
await emit({ type: "compaction_end" });
|
|
971
|
+
},
|
|
972
|
+
onEvent(listener) {
|
|
973
|
+
listeners.add(listener);
|
|
974
|
+
return () => {
|
|
975
|
+
listeners.delete(listener);
|
|
976
|
+
};
|
|
977
|
+
},
|
|
978
|
+
emit(event) {
|
|
979
|
+
void emit(event);
|
|
980
|
+
},
|
|
981
|
+
};
|
|
982
|
+
}
|
|
983
|
+
function safeParseJson(json) {
|
|
984
|
+
try {
|
|
985
|
+
return JSON.parse(json);
|
|
986
|
+
}
|
|
987
|
+
catch {
|
|
988
|
+
return {};
|
|
989
|
+
}
|
|
990
|
+
}
|
|
991
|
+
/** First stream rule matching `text` that still has injection budget.
|
|
992
|
+
* ponytail: full re-scan per text delta (O(deltas × rules × len)); anchor
|
|
993
|
+
* incremental matching if long generations measurably regress. */
|
|
994
|
+
function matchStreamRule(rules, text, injections) {
|
|
995
|
+
for (const rule of rules) {
|
|
996
|
+
if ((injections.get(rule.name) ?? 0) >= (rule.maxInjections ?? 1))
|
|
997
|
+
continue;
|
|
998
|
+
if (rule.pattern.test(text))
|
|
999
|
+
return rule;
|
|
1000
|
+
}
|
|
1001
|
+
return undefined;
|
|
1002
|
+
}
|
|
1003
|
+
const TOOL_FAILURE_REMINDER = [
|
|
1004
|
+
"<system-reminder>",
|
|
1005
|
+
"This tool call FAILED. Do not proceed as if it succeeded or claim it worked.",
|
|
1006
|
+
"Diagnose the error below; if the cause is fixable (wrong arguments, missing file, transient state), correct it and call the tool again. Only move on if the failure is genuinely permanent, and say so.",
|
|
1007
|
+
"</system-reminder>",
|
|
1008
|
+
].join("\n");
|