@taicho-ai/framework 0.1.0
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 +4 -0
- package/package.json +48 -0
- package/src/coaching/patterns.ts +103 -0
- package/src/coaching/proposal.ts +29 -0
- package/src/coaching/retrieval.ts +27 -0
- package/src/coaching/supersede.ts +14 -0
- package/src/coaching/teach.ts +74 -0
- package/src/core/agent-status.ts +79 -0
- package/src/core/auth/constants.ts +53 -0
- package/src/core/auth/login.ts +110 -0
- package/src/core/auth/pkce.ts +18 -0
- package/src/core/auth/profile.ts +38 -0
- package/src/core/auth/refresh.ts +57 -0
- package/src/core/auth/status.ts +26 -0
- package/src/core/command-guard.ts +126 -0
- package/src/core/conversation-artifacts.ts +40 -0
- package/src/core/conversation-replay.ts +160 -0
- package/src/core/costs.ts +97 -0
- package/src/core/discovery.ts +22 -0
- package/src/core/draft.ts +6 -0
- package/src/core/e2e-model.ts +315 -0
- package/src/core/embed.ts +221 -0
- package/src/core/events.ts +133 -0
- package/src/core/firecrawl.ts +25 -0
- package/src/core/headless.ts +260 -0
- package/src/core/instrument.ts +88 -0
- package/src/core/logger.ts +143 -0
- package/src/core/mcp/adapter.ts +34 -0
- package/src/core/mcp/manager.ts +145 -0
- package/src/core/mcp/oauth.ts +119 -0
- package/src/core/memory.ts +13 -0
- package/src/core/mock-model.ts +70 -0
- package/src/core/model.ts +91 -0
- package/src/core/pricing.ts +27 -0
- package/src/core/providers/openai-codex.ts +67 -0
- package/src/core/registry.ts +67 -0
- package/src/core/run.ts +909 -0
- package/src/core/schedule-cli.ts +55 -0
- package/src/core/scheduler.ts +356 -0
- package/src/core/tasks.ts +108 -0
- package/src/core/team-cli.ts +118 -0
- package/src/core/team-routing.ts +58 -0
- package/src/core/tools.ts +1104 -0
- package/src/core/turn-audit.ts +100 -0
- package/src/core/verification.ts +102 -0
- package/src/core/workflow-run.ts +119 -0
- package/src/index.ts +8 -0
- package/src/knowledge/retrieval.ts +73 -0
- package/src/knowledge/sync.ts +28 -0
- package/src/skills/retrieval.ts +22 -0
- package/src/store/annotations.ts +107 -0
- package/src/store/artifacts.ts +338 -0
- package/src/store/config.ts +187 -0
- package/src/store/conversation.ts +107 -0
- package/src/store/db.ts +34 -0
- package/src/store/files.ts +51 -0
- package/src/store/knowledge.ts +201 -0
- package/src/store/mcp-store.ts +65 -0
- package/src/store/migrate.ts +278 -0
- package/src/store/plans.ts +260 -0
- package/src/store/policy.ts +66 -0
- package/src/store/prefs.ts +64 -0
- package/src/store/roster.ts +308 -0
- package/src/store/run-transcript.ts +103 -0
- package/src/store/schedules.ts +94 -0
- package/src/store/seed-skills.ts +75 -0
- package/src/store/skills.ts +89 -0
- package/src/store/sources.ts +58 -0
- package/src/store/spend-ledger.ts +114 -0
- package/src/store/task-state.ts +267 -0
- package/src/store/teams.ts +227 -0
- package/src/store/thread.ts +72 -0
- package/src/store/trace.ts +98 -0
- package/src/store/vectors.ts +26 -0
- package/src/store/workflows.ts +144 -0
package/src/core/run.ts
ADDED
|
@@ -0,0 +1,909 @@
|
|
|
1
|
+
/** Orchestrates ONE agent run: assemble prompt -> build tools -> runLoop -> write trace.
|
|
2
|
+
* RunDeps are the seams (model, approval, child-run spawning); makeDeps wires the real ones. */
|
|
3
|
+
import type { Database } from "bun:sqlite";
|
|
4
|
+
import { generateText, type ModelMessage } from "ai";
|
|
5
|
+
import type { AgentDef } from "@taicho-ai/contracts/agent";
|
|
6
|
+
import type { RunTrace, VerificationRecord, VerificationVerdict } from "@taicho-ai/contracts/trace";
|
|
7
|
+
import { assemble, runLoop, compactionThreshold, type RosterTeam, type StepInfo, type StepEvent } from "@taicho-ai/agent";
|
|
8
|
+
import { listTeams, loadTeam, membersOf, createTeamWithMembers } from "../store/teams";
|
|
9
|
+
import { loadWorkflow, laneFor, orchestrationSlice, writeWorkflowSteps } from "../store/workflows";
|
|
10
|
+
import type { TeamDef } from "@taicho-ai/contracts/team";
|
|
11
|
+
import type { WorkflowRunState } from "@taicho-ai/graph";
|
|
12
|
+
import { routeToTeam } from "./team-routing";
|
|
13
|
+
import { writePlan, foldPlan, appendPlanEvent, indexPlan, currentPlanId, renderPlan } from "../store/plans";
|
|
14
|
+
import { planIdForGoal, TERMINAL_ITEM_STATUS, type PlanState, type PlanItemStatus } from "@taicho-ai/contracts/plan";
|
|
15
|
+
import { effectiveTools, mergeTeamPolicies, DEFAULT_TEAM_ID } from "@taicho-ai/contracts/team";
|
|
16
|
+
import { runChecker } from "./verification";
|
|
17
|
+
import { canDelegate, visibleToRows, acl } from "./registry";
|
|
18
|
+
import { rankAgents, type AgentHit } from "./discovery";
|
|
19
|
+
import { toolsForAgent } from "./tools";
|
|
20
|
+
import { readArtifact } from "../store/artifacts";
|
|
21
|
+
import { listAnnotations } from "../store/annotations";
|
|
22
|
+
import { artifactHandle } from "@taicho-ai/contracts/artifact";
|
|
23
|
+
import { searchKnowledge } from "../knowledge/retrieval";
|
|
24
|
+
import { getActiveSkills } from "../store/skills";
|
|
25
|
+
import { rankSkills } from "../skills/retrieval";
|
|
26
|
+
import { createAgent, loadAgent, loadIndex, type NewAgentDraft } from "../store/roster";
|
|
27
|
+
import { reserveRunId, writeTrace } from "../store/trace";
|
|
28
|
+
import { appendRunTranscript, writeChildRuns, writeRunCheckpoint, writeRunFailure, writeRunFinal, writeRunInput, type RunTranscriptEvent } from "../store/run-transcript";
|
|
29
|
+
import type { ProposalDraft } from "../coaching/proposal";
|
|
30
|
+
import { pricerFor } from "./pricing";
|
|
31
|
+
import type { TaichoConfig } from "../store/config";
|
|
32
|
+
import { recentRunsDigest } from "./memory";
|
|
33
|
+
import { recordUserTurn, recordTurnOutcome, recordTurnFailure } from "./turn-audit";
|
|
34
|
+
import { listPolicies } from "../store/policy";
|
|
35
|
+
import type { PolicyNote } from "@taicho-ai/contracts/policy";
|
|
36
|
+
import type { McpManager } from "./mcp/manager";
|
|
37
|
+
import type { McpServerConfig } from "../store/config";
|
|
38
|
+
import { scopesFor, type SpendLedger } from "../store/spend-ledger";
|
|
39
|
+
import type { Verdict } from "./command-guard";
|
|
40
|
+
import { log, redact } from "./logger";
|
|
41
|
+
import { trace as otelTrace, context, SpanStatusCode, ioAttrs, type Span, type Telemetry } from "@taicho-ai/telemetry";
|
|
42
|
+
|
|
43
|
+
export type Model = Parameters<typeof generateText>[0]["model"];
|
|
44
|
+
|
|
45
|
+
/** Best-effort task query for KB auto-recall: the delegated goal, else the last user turn's text. */
|
|
46
|
+
function lastUserText(messages: ModelMessage[]): string {
|
|
47
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
48
|
+
const m = messages[i];
|
|
49
|
+
if (m.role !== "user") continue;
|
|
50
|
+
const c = m.content;
|
|
51
|
+
if (typeof c === "string") return c;
|
|
52
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
53
|
+
if (Array.isArray(c)) return c.map((p: any) => (typeof p === "string" ? p : p?.text ?? "")).join(" ");
|
|
54
|
+
}
|
|
55
|
+
return "";
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const MAX_DELEGATION_DEPTH = 5;
|
|
59
|
+
const MAX_RUNS_PER_REQUEST = 50;
|
|
60
|
+
|
|
61
|
+
/** A short label for an approval span (what the captain is being asked to decide). Free-form fields
|
|
62
|
+
* (esp. a `run_command` command) are value-scrubbed BEFORE the length cap — this label reaches the
|
|
63
|
+
* live status bar, the approval span, and the persisted transcript, so an embedded `Bearer …`/api
|
|
64
|
+
* key must never surface (redact runs pre-slice so truncation can't expose a partial secret). */
|
|
65
|
+
function approvalLabel(req: ApprovalRequest): string {
|
|
66
|
+
switch (req.kind) {
|
|
67
|
+
case "create_agent": return redact(`create_agent ${req.draft.id}`);
|
|
68
|
+
case "create_team": return redact(`create_team ${req.draft.id}`);
|
|
69
|
+
case "propose_workflow": return redact(`propose_workflow ${req.draft.team}/${req.draft.name}`);
|
|
70
|
+
case "run_command": return redact(`run_command ${req.command}`).slice(0, 60);
|
|
71
|
+
case "add_mcp": return redact(`add_mcp ${req.name}`);
|
|
72
|
+
case "propose_skill": return redact(`propose_skill ${req.draft.name}`);
|
|
73
|
+
case "propose_coaching": return "propose_coaching";
|
|
74
|
+
case "ask_human": return "ask_human";
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Plan 22: the payload of an on-the-fly team proposal (root's `create_team` tool). `grant` is the extra
|
|
79
|
+
* tools every member receives; the tool caps it at what the PROPOSER already holds, so a model can never
|
|
80
|
+
* escalate itself by inventing a team. `members` are agent ids to staff it with. */
|
|
81
|
+
export interface NewTeamProposal { id: string; charter: string; lead?: string; members: string[]; grant: string[]; }
|
|
82
|
+
/** Plan 25: a workflow root proposes for a team. `steps` are the raw sugar step objects (validated by the
|
|
83
|
+
* tool). The engine writes it only after the captain approves — the model never writes workflow canon. */
|
|
84
|
+
export interface WorkflowProposal { team: string; name: string; brief?: string; steps: unknown[]; }
|
|
85
|
+
|
|
86
|
+
export type ApprovalRequest =
|
|
87
|
+
| { kind: "create_agent"; draft: NewAgentDraft }
|
|
88
|
+
| { kind: "create_team"; draft: NewTeamProposal }
|
|
89
|
+
| { kind: "propose_workflow"; draft: WorkflowProposal }
|
|
90
|
+
| { kind: "propose_coaching"; draft: ProposalDraft }
|
|
91
|
+
| { kind: "ask_human"; question: string; options: string[] }
|
|
92
|
+
| { kind: "add_mcp"; name: string; spec: McpServerConfig }
|
|
93
|
+
| { kind: "propose_skill"; draft: { name: string; description: string; body: string; tags: string[] } }
|
|
94
|
+
| { kind: "run_command"; command: string; cwd?: string; reason?: string };
|
|
95
|
+
export type ApprovalDecision =
|
|
96
|
+
| { type: "approve" }
|
|
97
|
+
| { type: "reject" }
|
|
98
|
+
| { type: "edit"; draft: Record<string, string> }
|
|
99
|
+
| { type: "answered"; answer: string };
|
|
100
|
+
export interface RunResult { runId: string; text: string; trace: RunTrace; }
|
|
101
|
+
|
|
102
|
+
/** Mutable per-run context handed to tools' execute fns. */
|
|
103
|
+
export interface RunContext {
|
|
104
|
+
ws: string;
|
|
105
|
+
db: Database;
|
|
106
|
+
runId: string;
|
|
107
|
+
agentId: string;
|
|
108
|
+
embed?: (text: string) => Promise<Float32Array>; // present only when an embedder is configured (semantic KB)
|
|
109
|
+
classifyCommand?: (command: string) => Verdict; // test seam
|
|
110
|
+
runShell?: (command: string, cwd: string) => { exitCode: number; stdout: string; stderr: string }; // test seam
|
|
111
|
+
runSandboxed?: (command: string, cwd: string, writableRoot?: string) => { exitCode: number; stdout: string; stderr: string; enforced: boolean }; // test seam (Plan 08 sandbox); writableRoot anchors the confined write set to ctx.ws (NOT the model cwd)
|
|
112
|
+
/** Plan 08 injection guard: flipped to `entered: true` (recording the source tool names) the moment
|
|
113
|
+
* read_url OR any granted MCP tool returns — attacker-influenceable content that has entered this
|
|
114
|
+
* run. Once set, run_command forces the captain's approval (a dcg `allow` no longer auto-runs it):
|
|
115
|
+
* ingest-untrusted-then-execute is the classic prompt-injection→execution chain. Set by the
|
|
116
|
+
* instrument() seam in tools.ts. */
|
|
117
|
+
untrusted: { entered: boolean; sources: string[] };
|
|
118
|
+
ingestSource?: string; // when set (a source-ingestion run), remember stamps this instead of agentId:runId
|
|
119
|
+
artifacts: string[];
|
|
120
|
+
inputArtifacts: string[]; // artifact handles this run handed DOWN to children (hand-off graph)
|
|
121
|
+
outputArtifacts: string[]; // artifact handles this run received UP from children
|
|
122
|
+
delegatedOut: string[];
|
|
123
|
+
requestApproval: (req: ApprovalRequest) => Promise<ApprovalDecision>;
|
|
124
|
+
createAgent: (draft: NewAgentDraft) => Promise<AgentDef>;
|
|
125
|
+
createTeam: (proposal: NewTeamProposal) => Promise<TeamDef>;
|
|
126
|
+
/** Plan 25: write an approved workflow proposal into teams/<team>/workflow.md (engine-only write). */
|
|
127
|
+
proposeWorkflow?: (p: WorkflowProposal) => void;
|
|
128
|
+
canDelegate: (toId: string) => boolean;
|
|
129
|
+
runChild: (brief: { to: string; goal: string; context?: string; criteria?: string; inputArtifacts?: string[]; callId?: string; viaTeam?: string }) => Promise<RunResult>;
|
|
130
|
+
/** Plan 25: run a team's structured workflow deterministically — the engine walks the steps, stopping at
|
|
131
|
+
* any human gate. Null when the team has no `steps:` workflow. Wired to runTeamWorkflow (dynamic import
|
|
132
|
+
* to avoid the run.ts ↔ workflow-run.ts cycle). */
|
|
133
|
+
runWorkflow?: (teamId: string, input?: { artifacts?: { name: string; handle: string }[] }) => Promise<WorkflowRunState | null>;
|
|
134
|
+
/** Plan 25 Ph6: answer a workflow run parked at a human gate (choice + optional note) and drive the rest. */
|
|
135
|
+
resumeWorkflow?: (teamId: string, runId: string, choice: string, note?: string) => Promise<WorkflowRunState | null>;
|
|
136
|
+
findAgents: (query: string, k: number) => AgentHit[];
|
|
137
|
+
agentExists: (id: string) => boolean;
|
|
138
|
+
notes: string[];
|
|
139
|
+
workItems: { n: number };
|
|
140
|
+
childSpend: { tokens: number; costUsd: number };
|
|
141
|
+
/** Spend from THIS run's own delegation-checker calls (Plan 06). Kept separate from childSpend
|
|
142
|
+
* (which is child RUNS) but folded into trace.aggregate the same way — the checker makes real,
|
|
143
|
+
* metered model calls this run caused, so the aggregate must include them to stay honest. costUsd
|
|
144
|
+
* is 0 for subscription runs (unpriced), and aggregate.costUsd is null there anyway. */
|
|
145
|
+
verifierSpend: { tokens: number; costUsd: number };
|
|
146
|
+
childTraces: RunTrace[];
|
|
147
|
+
/** Plan 19: `to` may name an agent or a team. Resolves the team to the agent that will actually run
|
|
148
|
+
* (its lead, or the best-ranked member), then applies the ACL / cycle / depth / run-count guards to
|
|
149
|
+
* THAT agent. `note` describes a team routing decision, so a bad keyword pick is never silent. */
|
|
150
|
+
resolveDelegation: (
|
|
151
|
+
to: string,
|
|
152
|
+
goal: string,
|
|
153
|
+
) => { ok: true; agentId: string; team?: string; viaTeam?: string; note?: string } | { ok: false; error: string };
|
|
154
|
+
/** Plan 18: the agent's live plan. `write` mints a version only when the item SET changes; `tick`
|
|
155
|
+
* appends a transition. `tick` returns `rejected` when the model tries to set a TERMINAL status on an
|
|
156
|
+
* item bound to a child run — those are engine-owned, and the attempt is still recorded (by:"model")
|
|
157
|
+
* because a model marking a failed delegation `done` is exactly what you want visible in a trace. */
|
|
158
|
+
plan?: {
|
|
159
|
+
write: (p: { goal: string; items: { id: string; text: string; assignee?: string }[] }) => { handle: string; minted: boolean };
|
|
160
|
+
read: (handle?: string) => PlanState | null;
|
|
161
|
+
tick: (p: { itemId: string; status: PlanItemStatus; note?: string; by: "model" | "engine"; boundRunId?: string }) =>
|
|
162
|
+
{ ok: true } | { ok: false; rejected: string; boundRunId?: string };
|
|
163
|
+
/** The live plan's handle, or null. Used by the tail-slot injector and the trace. */
|
|
164
|
+
handle: () => string | null;
|
|
165
|
+
};
|
|
166
|
+
/** Criteria→verdict records for this run's delegations; written to trace.verification + transcript. */
|
|
167
|
+
verifications: VerificationRecord[];
|
|
168
|
+
/** The independent delegation checker: child output + criteria → verdict, via the delegating
|
|
169
|
+
* agent's own resolved model (same plumbing the loop uses). */
|
|
170
|
+
checkCriteria: (p: { goal: string; criteria: string; output: string }) => Promise<{ verdict: VerificationVerdict; tokens: number; costUsd: number | null; costNote?: string; checkerError?: boolean }>;
|
|
171
|
+
/** Surface a breadcrumb to the captain (a failed verification, a team routing decision) or a plan
|
|
172
|
+
* snapshot (Plan 18), routed via onStep. Phase-less by construction, so the status reducer ignores it. */
|
|
173
|
+
emit?: (info: { note?: string; plan?: PlanState }) => void;
|
|
174
|
+
/** Plan 04: fire-and-forget a goal onto another agent in the BACKGROUND. Returns a taskId
|
|
175
|
+
* immediately (the cascade runs off-turn via the same executeRun). Present only when the host
|
|
176
|
+
* wired a scheduler (the REPL); undefined in headless/unit contexts. */
|
|
177
|
+
dispatchTask?: (brief: { to: string; goal: string; context?: string; criteria?: string; inputArtifacts?: string[] }) => Promise<{ taskId: string } | { error: string }>;
|
|
178
|
+
/** Plan 04: block until a background task settles (bounded by timeoutMs). Status + summary +
|
|
179
|
+
* resultRef only — hand-off stays BY REFERENCE, never the inlined payload. */
|
|
180
|
+
awaitTask?: (taskId: string, timeoutMs?: number) => Promise<TaskAwaitResult>;
|
|
181
|
+
/** The shared instrumentation seam (Plan 02 + Plan 10). tools.ts wraps every tool `execute()` and
|
|
182
|
+
* run.ts wraps `requestApproval` to (a) push tool/approval span events into `spanEvents` (flushed
|
|
183
|
+
* to transcript.jsonl for the waterfall) and (b) emit a live typed phase via `emitStep`. */
|
|
184
|
+
spanEvents: RunTranscriptEvent[];
|
|
185
|
+
emitStep?: (info: StepInfo) => void;
|
|
186
|
+
/** Plan 16: OpenTelemetry handle, so the tool `instrument()` wrapper can open a `<tool>` span per
|
|
187
|
+
* call (named + carrying args/result), under which a delegated child run nests. Undefined ⇒ off. */
|
|
188
|
+
telemetry?: Telemetry;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** What check_task / await_task hand back: a reference, never the payload (Plan 01 discipline). */
|
|
192
|
+
export interface TaskAwaitResult { status: string; summary?: string; resultRef?: string; runId?: string; error?: string; }
|
|
193
|
+
|
|
194
|
+
export interface RunDeps {
|
|
195
|
+
ws: string;
|
|
196
|
+
db: Database;
|
|
197
|
+
model: Model;
|
|
198
|
+
requestApproval: (req: ApprovalRequest) => Promise<ApprovalDecision>;
|
|
199
|
+
onStep?: (info: StepEvent) => void;
|
|
200
|
+
pollSteer?: () => string | null;
|
|
201
|
+
/** Plan 04 Phase 4: per-run steer routing. run.ts binds the loop's pollSteer to THIS run's id, so
|
|
202
|
+
* a steer routed to a specific runId reaches only that run (not a random descendant). Falls back
|
|
203
|
+
* to the flat pollSteer when absent (unit tests). */
|
|
204
|
+
pollSteerFor?: (info: { runId: string; agentId: string; triggeredBy: string }) => string | null;
|
|
205
|
+
signal?: AbortSignal;
|
|
206
|
+
priceUsd?: (u: { inputTokens: number; outputTokens: number }) => number;
|
|
207
|
+
runCounter?: { n: number };
|
|
208
|
+
resolveModel?: (agentId: string) => { model: Model; modelId: string; subscription?: boolean; captureCost?: boolean };
|
|
209
|
+
configDefaults?: TaichoConfig["defaults"];
|
|
210
|
+
globalPolicyCache?: { notes?: PolicyNote[] };
|
|
211
|
+
mcp?: McpManager;
|
|
212
|
+
embed?: (text: string) => Promise<Float32Array>; // semantic KB embedder; undefined ⇒ keyword+graph
|
|
213
|
+
onRunStart?: (info: { runId: string; agent: string; triggeredBy: string; messages: ModelMessage[]; spawnCallId?: string }) => void;
|
|
214
|
+
/** Plan 04 Phase 4: called when a run finishes (any outcome) so the host can drop it from its
|
|
215
|
+
* active-run map / steer routing table. */
|
|
216
|
+
onRunEnd?: (info: { runId: string; agent: string; triggeredBy: string; outcome: RunTrace["outcome"] }) => void;
|
|
217
|
+
/** Plan 04 Phase 2: start a detached BACKGROUND run for a dispatched task. The host (REPL) owns
|
|
218
|
+
* the scheduler + settle/notify; the engine just hands it a resolved child agent + brief.
|
|
219
|
+
* Returns the taskId immediately (fire-and-forget). Undefined ⇒ dispatch_task is unavailable. */
|
|
220
|
+
dispatch?: (opts: { agent: AgentDef; goal: string; context?: string; criteria?: string; inputArtifacts?: string[]; parentRunId: string; parentAgentId: string; parentAncestry: string[] }) => { taskId: string } | { error: string };
|
|
221
|
+
/** Plan 04 Phase 2: back await_task — block until a background task settles (host-owned). The
|
|
222
|
+
* optional awaiterAgentId (stamped by run.ts) lets the host fail fast on a same-agent self-block. */
|
|
223
|
+
awaitTask?: (taskId: string, timeoutMs?: number, awaiterAgentId?: string) => Promise<TaskAwaitResult>;
|
|
224
|
+
/** Plan 09: squad-wide spend ledger, shared by ALL runs in a session (including delegated children),
|
|
225
|
+
* enforced in the loop and persisted across sessions. Undefined ⇒ no squad ceilings configured. */
|
|
226
|
+
spendLedger?: SpendLedger;
|
|
227
|
+
/** Plan 16: OpenTelemetry handle, shared by ALL runs in a session. When set, executeRun opens a run
|
|
228
|
+
* span (making it active so the AI SDK's gen_ai spans + delegated child runs nest under it) and feeds
|
|
229
|
+
* run/model metrics. Undefined ⇒ telemetry disabled (no OTLP endpoint configured). */
|
|
230
|
+
telemetry?: Telemetry;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/** Coarse provider label (gen_ai.system) for the model spans + metrics. taicho emits its own spans
|
|
234
|
+
* (not the AI SDK's), so this is the provider attribute source; a best-effort id-shape heuristic. */
|
|
235
|
+
function providerLabel(modelId: string | undefined, subscription: boolean): string {
|
|
236
|
+
if (subscription) return "openai"; // ChatGPT subscription rides the Codex (OpenAI) backend
|
|
237
|
+
if (!modelId) return "unknown";
|
|
238
|
+
if (modelId.includes("/")) return "openrouter"; // namespaced vendor/model
|
|
239
|
+
if (modelId.startsWith("claude")) return "anthropic";
|
|
240
|
+
if (/^(gpt|o[13]|text-|chatgpt)/.test(modelId)) return "openai";
|
|
241
|
+
return "unknown";
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/** Build RunDeps with real wiring; tests override pieces (e.g. requestApproval). */
|
|
245
|
+
export function makeDeps(opts: {
|
|
246
|
+
ws: string; db: Database; model: Model;
|
|
247
|
+
requestApproval?: (req: ApprovalRequest) => Promise<ApprovalDecision>;
|
|
248
|
+
onStep?: RunDeps["onStep"];
|
|
249
|
+
pollSteer?: () => string | null;
|
|
250
|
+
pollSteerFor?: RunDeps["pollSteerFor"];
|
|
251
|
+
signal?: AbortSignal;
|
|
252
|
+
priceUsd?: RunDeps["priceUsd"];
|
|
253
|
+
runCounter?: { n: number };
|
|
254
|
+
resolveModel?: RunDeps["resolveModel"];
|
|
255
|
+
configDefaults?: RunDeps["configDefaults"];
|
|
256
|
+
globalPolicyCache?: { notes?: PolicyNote[] };
|
|
257
|
+
mcp?: McpManager;
|
|
258
|
+
embed?: (text: string) => Promise<Float32Array>;
|
|
259
|
+
onRunStart?: RunDeps["onRunStart"];
|
|
260
|
+
onRunEnd?: RunDeps["onRunEnd"];
|
|
261
|
+
dispatch?: RunDeps["dispatch"];
|
|
262
|
+
awaitTask?: RunDeps["awaitTask"];
|
|
263
|
+
spendLedger?: RunDeps["spendLedger"];
|
|
264
|
+
telemetry?: RunDeps["telemetry"];
|
|
265
|
+
}): RunDeps {
|
|
266
|
+
return {
|
|
267
|
+
ws: opts.ws, db: opts.db, model: opts.model,
|
|
268
|
+
requestApproval: opts.requestApproval ?? (async () => ({ type: "reject" })),
|
|
269
|
+
onStep: opts.onStep, pollSteer: opts.pollSteer, pollSteerFor: opts.pollSteerFor,
|
|
270
|
+
signal: opts.signal, priceUsd: opts.priceUsd,
|
|
271
|
+
runCounter: opts.runCounter ?? { n: 0 },
|
|
272
|
+
resolveModel: opts.resolveModel, configDefaults: opts.configDefaults,
|
|
273
|
+
globalPolicyCache: opts.globalPolicyCache ?? {},
|
|
274
|
+
mcp: opts.mcp, embed: opts.embed,
|
|
275
|
+
onRunStart: opts.onRunStart, onRunEnd: opts.onRunEnd,
|
|
276
|
+
dispatch: opts.dispatch, awaitTask: opts.awaitTask,
|
|
277
|
+
spendLedger: opts.spendLedger,
|
|
278
|
+
telemetry: opts.telemetry,
|
|
279
|
+
};
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
export async function executeRun(
|
|
283
|
+
deps: RunDeps,
|
|
284
|
+
opts: { agent: AgentDef; messages: ModelMessage[]; brief?: { from: string; goal: string; context?: string; criteria?: string; fromRun: string }; inputArtifacts?: string[]; triggeredBy: string; depth?: number; ancestry?: string[]; ingestSource?: string; taintedContext?: boolean; spawnCallId?: string; viaTeam?: string },
|
|
285
|
+
): Promise<RunResult> {
|
|
286
|
+
const depth = opts.depth ?? 0;
|
|
287
|
+
const ancestry = opts.ancestry ?? [];
|
|
288
|
+
deps.runCounter!.n += 1;
|
|
289
|
+
|
|
290
|
+
const memoryBlock = recentRunsDigest(deps.ws, opts.agent.id);
|
|
291
|
+
|
|
292
|
+
// reserveRunId atomically claims a unique id (exclusive-create placeholder), so concurrent
|
|
293
|
+
// same-target delegations in one model turn can't collide; writeTrace overwrites it at the end.
|
|
294
|
+
const runId = reserveRunId(deps.ws, opts.agent.id);
|
|
295
|
+
const started = new Date().toISOString();
|
|
296
|
+
const t0 = performance.now();
|
|
297
|
+
deps.onRunStart?.({ runId, agent: opts.agent.id, triggeredBy: opts.triggeredBy, messages: opts.messages, spawnCallId: opts.spawnCallId });
|
|
298
|
+
writeRunInput(deps.ws, runId, {
|
|
299
|
+
runId,
|
|
300
|
+
triggeredBy: opts.triggeredBy,
|
|
301
|
+
agent: opts.agent.id,
|
|
302
|
+
task: opts.brief?.goal ?? "(chat)",
|
|
303
|
+
messagesPassedToModel: opts.messages,
|
|
304
|
+
parentRunId: opts.brief?.fromRun,
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
// Plan 01 Ph5 — the turn-outcome audit is an ENGINE seam (was App-local; PR #17), so every caller
|
|
308
|
+
// (REPL, headless, tests) gets identical ledger + task + replay audit. A user CONVERSATION turn is
|
|
309
|
+
// triggeredBy the LITERAL "user" and NOT a `/kb sync` ingest (ingestSource): those get run evidence
|
|
310
|
+
// but no ledger/task, exactly as before. Autonomous automation is likewise NOT a conversation turn —
|
|
311
|
+
// a background dispatch cascade is triggeredBy a taskId, and a scheduler fire (cron/interval/watch,
|
|
312
|
+
// `taicho schedule run`) is triggeredBy "schedule:<id>" — so both are excluded by the exact-"user"
|
|
313
|
+
// check here, and never pollute the target agent's append-only ledger or its boot-replay cache. Open
|
|
314
|
+
// the user turn + task record now, before any model call, so an interrupted/failed run still leaves
|
|
315
|
+
// the turn audited. Closed at run end by recordTurnOutcome (or by recordTurnFailure on a pre-run throw).
|
|
316
|
+
const isUserTurn = opts.triggeredBy === "user" && !opts.ingestSource;
|
|
317
|
+
const userTurn = isUserTurn
|
|
318
|
+
? recordUserTurn(deps.ws, deps.db, { agent: opts.agent.id, runId, text: lastUserText(opts.messages) })
|
|
319
|
+
: undefined;
|
|
320
|
+
|
|
321
|
+
// Exception-safe seam: recordUserTurn just opened a `submitted` ledger turn + a `running` task. The
|
|
322
|
+
// PRE-loop setup below (deps.resolveModel — the OpenRouter/explicit-model guard — plus prompt assembly
|
|
323
|
+
// and tool build) can THROW before the loop ever runs. Guard it so such a throw settles the turn to a
|
|
324
|
+
// terminal `failed` outcome instead of leaving a dangling `submitted` turn + a `running` task. Scope is
|
|
325
|
+
// PRE-loop ONLY: `turnClosed` is set the instant runLoop returns a terminal result (below), so a throw
|
|
326
|
+
// in POST-loop finalization PROPAGATES and never re-settles an ALREADY-COMPLETED run as a pre-run
|
|
327
|
+
// failure. (runLoop itself does NOT throw for model errors — it returns result.error → outcome
|
|
328
|
+
// "failed", which the normal close records via recordTurnOutcome, not the catch.)
|
|
329
|
+
let turnClosed = false;
|
|
330
|
+
|
|
331
|
+
// Plan 16: open THIS run's OpenTelemetry span BEFORE the try, so a pre-loop throw still closes it in
|
|
332
|
+
// the catch. It parents to whatever span is active — for a delegated child that is the parent run's
|
|
333
|
+
// `delegate_task` tool span (the AI SDK sets it active during execute), so a delegation is ONE
|
|
334
|
+
// distributed trace. It is made active around runLoop (below) so the AI SDK's gen_ai spans and any
|
|
335
|
+
// child runs nest under it. finishRunSpan is idempotent: called on the normal finalize AND in the
|
|
336
|
+
// catch, so the span always closes and the active-run gauge always decrements exactly once.
|
|
337
|
+
const tel = deps.telemetry;
|
|
338
|
+
// The run's input = the delegated goal, else this turn's user text — so the run/sub-run node shows
|
|
339
|
+
// WHAT it was asked (gated by captureContent, like the model-span prompt/completion).
|
|
340
|
+
const runInput = opts.brief?.goal ?? lastUserText(opts.messages);
|
|
341
|
+
// A meaningful, mockup-grade label: "root · user turn", "researcher · delegated".
|
|
342
|
+
const runKind = opts.triggeredBy === "user" ? "user turn"
|
|
343
|
+
: opts.ingestSource ? "ingest"
|
|
344
|
+
: opts.triggeredBy.startsWith("schedule:") ? "scheduled"
|
|
345
|
+
: opts.triggeredBy.startsWith("task_") ? "task"
|
|
346
|
+
: "delegated";
|
|
347
|
+
const runSpan: Span | undefined = tel?.tracerFor(opts.agent.id).startSpan(`${opts.agent.id} · ${runKind}`, {
|
|
348
|
+
attributes: {
|
|
349
|
+
"taicho.agent": opts.agent.id,
|
|
350
|
+
"taicho.run.id": runId,
|
|
351
|
+
"taicho.triggered_by": opts.triggeredBy,
|
|
352
|
+
"taicho.depth": depth,
|
|
353
|
+
...(tel.captureContent && runInput ? ioAttrs("input", runInput) : {}),
|
|
354
|
+
},
|
|
355
|
+
});
|
|
356
|
+
if (runSpan) tel!.runStarted(opts.agent.id);
|
|
357
|
+
let runSpanDone = false;
|
|
358
|
+
const finishRunSpan = (o: RunTrace["outcome"], attrs?: Record<string, string | number>, err?: string, output?: string) => {
|
|
359
|
+
if (!tel || !runSpan || runSpanDone) return;
|
|
360
|
+
runSpanDone = true;
|
|
361
|
+
if (attrs) runSpan.setAttributes(attrs);
|
|
362
|
+
if (tel.captureContent && output) runSpan.setAttributes(ioAttrs("output", output)); // WHAT it produced
|
|
363
|
+
runSpan.setAttribute("taicho.run.outcome", o);
|
|
364
|
+
if (err) runSpan.setStatus({ code: SpanStatusCode.ERROR, message: err });
|
|
365
|
+
runSpan.end();
|
|
366
|
+
tel.runFinished({ agent: opts.agent.id, outcome: o, durationMs: performance.now() - t0 });
|
|
367
|
+
};
|
|
368
|
+
|
|
369
|
+
try {
|
|
370
|
+
// Resolve THIS agent's model up front (before tools are built) so the delegation checker can run
|
|
371
|
+
// on the same model plumbing the loop uses — an independent call on the delegating agent's model.
|
|
372
|
+
const picked = deps.resolveModel?.(opts.agent.id);
|
|
373
|
+
const subscription = picked?.subscription === true;
|
|
374
|
+
const model = picked?.model ?? deps.model;
|
|
375
|
+
const priceUsd = picked ? pricerFor(picked.modelId) : deps.priceUsd;
|
|
376
|
+
|
|
377
|
+
// Plan 16: the run span was opened before the try (so the catch can close it); now that the model is
|
|
378
|
+
// resolved, stamp the model id and build the loop's telemetry (gen_ai spans + per-call metrics).
|
|
379
|
+
if (runSpan && picked?.modelId) runSpan.setAttribute("gen_ai.request.model", picked.modelId);
|
|
380
|
+
const loopTelemetry = tel && runSpan
|
|
381
|
+
? {
|
|
382
|
+
tracer: tel.tracerFor(opts.agent.id),
|
|
383
|
+
captureContent: tel.captureContent,
|
|
384
|
+
model: picked?.modelId ?? "model",
|
|
385
|
+
provider: providerLabel(picked?.modelId, subscription),
|
|
386
|
+
agent: opts.agent.id,
|
|
387
|
+
onModelCall: (m: { inputTokens: number; outputTokens: number; costUsd: number; durationMs: number }) =>
|
|
388
|
+
tel.recordModelCall({
|
|
389
|
+
provider: providerLabel(picked?.modelId, subscription),
|
|
390
|
+
model: picked?.modelId ?? "unknown",
|
|
391
|
+
inputTokens: m.inputTokens, outputTokens: m.outputTokens,
|
|
392
|
+
costUsd: subscription ? null : m.costUsd, durationMs: m.durationMs,
|
|
393
|
+
}),
|
|
394
|
+
}
|
|
395
|
+
: undefined;
|
|
396
|
+
|
|
397
|
+
// The shared instrumentation seam (Plan 02 waterfall spans + Plan 10 live status). Tool spans are
|
|
398
|
+
// pushed by the execute() wrapper in tools.ts; approval spans by the wrapped requestApproval below.
|
|
399
|
+
// Both are merged (by ts) into transcript.jsonl at run end and streamed live via emitStep.
|
|
400
|
+
const spanEvents: RunTranscriptEvent[] = [];
|
|
401
|
+
// Plan 18: how many plan transitions THIS run wrote (including refused attempts, which are real
|
|
402
|
+
// events). Recorded on the trace so /costs-style rollups and the OTel span can see plan activity.
|
|
403
|
+
const planEvents = { n: 0 };
|
|
404
|
+
const emitStep = deps.onStep
|
|
405
|
+
? (info: StepInfo) => deps.onStep!({ ...info, agent: opts.agent.id, runId })
|
|
406
|
+
: undefined;
|
|
407
|
+
// Time approval / ask_human waits as their own `approval` spans — core, not optional: in this
|
|
408
|
+
// system the human wait frequently dominates wall-clock, and a waterfall that folds it into the
|
|
409
|
+
// enclosing tool span would lie about where the time went.
|
|
410
|
+
const wrappedRequestApproval = async (req: ApprovalRequest): Promise<ApprovalDecision> => {
|
|
411
|
+
const label = approvalLabel(req);
|
|
412
|
+
spanEvents.push({ ts: new Date().toISOString(), kind: "approval_start", data: { kind: req.kind, label } });
|
|
413
|
+
emitStep?.({ phase: "approval_start", tool: label, argsPreview: label });
|
|
414
|
+
try {
|
|
415
|
+
return await deps.requestApproval(req);
|
|
416
|
+
} finally {
|
|
417
|
+
spanEvents.push({ ts: new Date().toISOString(), kind: "approval_end", data: { kind: req.kind, label } });
|
|
418
|
+
emitStep?.({ phase: "approval_end", tool: label });
|
|
419
|
+
}
|
|
420
|
+
};
|
|
421
|
+
|
|
422
|
+
const ctx: RunContext = {
|
|
423
|
+
ws: deps.ws, db: deps.db, runId, agentId: opts.agent.id, embed: deps.embed,
|
|
424
|
+
ingestSource: opts.ingestSource,
|
|
425
|
+
artifacts: [], inputArtifacts: [], outputArtifacts: [], delegatedOut: [],
|
|
426
|
+
// Plan 08 injection guard — armed by ingestion tools (read_url / read_artifact / recall / read_source /
|
|
427
|
+
// MCP + delegation results). Defense-in-depth: a child spawned by a TAINTED parent starts pre-armed
|
|
428
|
+
// (`taintedContext`), because the parent's brief/context may itself carry injected instructions —
|
|
429
|
+
// this closes the synchronous cross-run brief-laundering path (parent ingests → hides a command in
|
|
430
|
+
// the child's brief → child auto-runs it). See the cross-run residual note for what remains.
|
|
431
|
+
untrusted: { entered: opts.taintedContext === true, sources: opts.taintedContext ? ["parent-brief"] : [] },
|
|
432
|
+
spanEvents, emitStep, telemetry: tel,
|
|
433
|
+
requestApproval: wrappedRequestApproval,
|
|
434
|
+
createAgent: (draft) => createAgent(deps.ws, deps.db, draft, opts.agent.id, deps.configDefaults),
|
|
435
|
+
// Plan 22: on-the-fly team creation (root's approval-gated tool). One service call: writes the team
|
|
436
|
+
// file and staffs it. The grant ceiling is enforced in the tool BEFORE the approval card is shown.
|
|
437
|
+
createTeam: (p) => createTeamWithMembers(deps.ws, deps.db, { id: p.id, charter: p.charter, lead: p.lead, tools: { grant: p.grant } }, p.members),
|
|
438
|
+
proposeWorkflow: (p) => writeWorkflowSteps(deps.ws, p.team, { name: p.name, brief: p.brief, steps: p.steps }),
|
|
439
|
+
canDelegate: (toId) => canDelegate(opts.agent, loadIndex(deps.db).find((r) => r.id === toId) ?? { id: toId }),
|
|
440
|
+
runWorkflow: async (teamId, input) => {
|
|
441
|
+
const { runTeamWorkflow } = await import("./workflow-run");
|
|
442
|
+
return runTeamWorkflow(deps, teamId, input);
|
|
443
|
+
},
|
|
444
|
+
resumeWorkflow: async (teamId, runId, choice, note) => {
|
|
445
|
+
const { resumeTeamWorkflow } = await import("./workflow-run");
|
|
446
|
+
return resumeTeamWorkflow(deps, teamId, runId, choice, note);
|
|
447
|
+
},
|
|
448
|
+
runChild: async ({ to, goal, context, criteria, inputArtifacts, callId, viaTeam }) => {
|
|
449
|
+
const child = await loadAgent(deps.ws, to);
|
|
450
|
+
return executeRun(deps, {
|
|
451
|
+
agent: child,
|
|
452
|
+
messages: [{ role: "user", content: context ? `${goal}\n\nContext: ${context}` : goal }],
|
|
453
|
+
brief: { from: opts.agent.id, goal, context, criteria, fromRun: runId },
|
|
454
|
+
inputArtifacts, // handed to the child by REFERENCE (handles + summaries in the prompt, not inlined bodies)
|
|
455
|
+
triggeredBy: runId,
|
|
456
|
+
depth: depth + 1,
|
|
457
|
+
ancestry: [...ancestry, opts.agent.id],
|
|
458
|
+
// Plan 23: the team this child runs UNDER — so it gets its workflow lane. Set by resolveDelegation
|
|
459
|
+
// (the team it was routed through, or an inherited team when a lead hands work to a member).
|
|
460
|
+
viaTeam,
|
|
461
|
+
// Plan 02 Ph6: the spawning delegate_task callId, so the LIVE waterfall nests this child under
|
|
462
|
+
// that EXACT tool span (deterministic even for concurrent delegations in one turn).
|
|
463
|
+
spawnCallId: callId,
|
|
464
|
+
// Plan 08 injection guard: if THIS run has ingested untrusted content by the time it delegates,
|
|
465
|
+
// the brief it hands down is itself untrusted — pre-arm the child so its run_command is gated.
|
|
466
|
+
taintedContext: ctx.untrusted.entered,
|
|
467
|
+
});
|
|
468
|
+
},
|
|
469
|
+
verifications: [],
|
|
470
|
+
// Plan 18: the plan seam. One plan per agent (id derived from the goal, so re-stating a goal
|
|
471
|
+
// continues it rather than forking a second one). Always present on ctx — tools.ts decides whether
|
|
472
|
+
// the AGENT can reach it, and the tail-slot injector reads it regardless.
|
|
473
|
+
plan: {
|
|
474
|
+
write: ({ goal, items }) => {
|
|
475
|
+
const { plan, minted } = writePlan(deps.ws, { id: planIdForGoal(goal), owner: opts.agent.id, goal, items, producer: opts.agent.id, runId });
|
|
476
|
+
const s = foldPlan(deps.ws, plan.id)!;
|
|
477
|
+
indexPlan(deps.db, s);
|
|
478
|
+
return { handle: s.handle, minted };
|
|
479
|
+
},
|
|
480
|
+
read: (handle) => {
|
|
481
|
+
const h = handle ?? currentPlanId(deps.db, opts.agent.id);
|
|
482
|
+
return h ? foldPlan(deps.ws, h) : null;
|
|
483
|
+
},
|
|
484
|
+
handle: () => {
|
|
485
|
+
const id = currentPlanId(deps.db, opts.agent.id);
|
|
486
|
+
return id ? (foldPlan(deps.ws, id)?.handle ?? null) : null;
|
|
487
|
+
},
|
|
488
|
+
tick: ({ itemId, status, note, by, boundRunId }) => {
|
|
489
|
+
const id = currentPlanId(deps.db, opts.agent.id);
|
|
490
|
+
if (!id) return { ok: false, rejected: "no live plan — call write_plan first" };
|
|
491
|
+
const before = foldPlan(deps.ws, id);
|
|
492
|
+
const item = before?.items.find((i) => i.id === itemId);
|
|
493
|
+
if (!item) return { ok: false, rejected: `no item "${itemId}" in plan ${id}` };
|
|
494
|
+
|
|
495
|
+
// ENGINE OWNS a bound item's terminal status. The model may still narrate progress
|
|
496
|
+
// (in_progress), but it may not declare a delegated item done or failed — the child's real
|
|
497
|
+
// outcome decides that. Record the attempt so it is visible, flagged so it cannot win the fold.
|
|
498
|
+
if (by === "model" && item.boundRunId && TERMINAL_ITEM_STATUS.has(status)) {
|
|
499
|
+
appendPlanEvent(deps.ws, id, {
|
|
500
|
+
item: itemId, status, by: "model", runId, boundRunId: item.boundRunId, rejected: true,
|
|
501
|
+
note: `refused: ${itemId} is owned by run ${item.boundRunId}`,
|
|
502
|
+
});
|
|
503
|
+
planEvents.n += 1;
|
|
504
|
+
return { ok: false, rejected: "engine-owned", boundRunId: item.boundRunId };
|
|
505
|
+
}
|
|
506
|
+
if (by === "model" && status === "dropped" && !note?.trim())
|
|
507
|
+
return { ok: false, rejected: "dropping an item requires a note saying why" };
|
|
508
|
+
|
|
509
|
+
appendPlanEvent(deps.ws, id, { item: itemId, status, by, runId, boundRunId: boundRunId ?? item.boundRunId, note });
|
|
510
|
+
planEvents.n += 1;
|
|
511
|
+
const after = foldPlan(deps.ws, id);
|
|
512
|
+
if (after) indexPlan(deps.db, after);
|
|
513
|
+
return { ok: true };
|
|
514
|
+
},
|
|
515
|
+
},
|
|
516
|
+
checkCriteria: async (p) => {
|
|
517
|
+
const run = () => runChecker({
|
|
518
|
+
model, agent: opts.agent, subscription, priceUsd,
|
|
519
|
+
captureProviderCost: picked?.captureCost, signal: deps.signal,
|
|
520
|
+
// Plan 09: the checker runs on the same shared ledger the primary loop uses, so its tokens
|
|
521
|
+
// (and USD, when priced) count against the ceilings and are bounded by them — not invisible.
|
|
522
|
+
// Plan 19: it is spend the DELEGATING agent caused, so it meters against ITS team, not the child's.
|
|
523
|
+
spendLedger: deps.spendLedger,
|
|
524
|
+
spendScopes: scopesFor(opts.agent.teams),
|
|
525
|
+
goal: p.goal, criteria: p.criteria, output: p.output,
|
|
526
|
+
});
|
|
527
|
+
// Plan 16: the independent verification checker as its own "VERIFY" span — nests under the active
|
|
528
|
+
// delegate_task tool span, named with the verdict once known ("checker · criteria pass/fail").
|
|
529
|
+
const span = tel?.tracerFor(opts.agent.id).startSpan("checker", {
|
|
530
|
+
attributes: { "taicho.kind": "verify", ...(tel.captureContent ? ioAttrs("input", p.criteria) : {}) },
|
|
531
|
+
});
|
|
532
|
+
if (!span) return run();
|
|
533
|
+
try {
|
|
534
|
+
const r = await context.with(otelTrace.setSpan(context.active(), span), run);
|
|
535
|
+
span.updateName(`checker · criteria ${r.verdict.pass ? "pass" : "fail"}`);
|
|
536
|
+
span.setAttribute("taicho.verify.pass", r.verdict.pass);
|
|
537
|
+
if (tel!.captureContent) span.setAttributes(ioAttrs("output", r.verdict.reasons?.join("; ") || (r.verdict.pass ? "pass" : "fail")));
|
|
538
|
+
return r;
|
|
539
|
+
} catch (e) {
|
|
540
|
+
span.setStatus({ code: SpanStatusCode.ERROR, message: e instanceof Error ? e.message : String(e) });
|
|
541
|
+
throw e;
|
|
542
|
+
} finally {
|
|
543
|
+
span.end();
|
|
544
|
+
}
|
|
545
|
+
},
|
|
546
|
+
// Forward the WHOLE info, not just `note` — Plan 18's plan snapshot rides this seam too. Both
|
|
547
|
+
// fields are phase-less, so statusReducer's guard drops them and the live status map is untouched.
|
|
548
|
+
emit: emitStep ? (info) => emitStep(info) : undefined,
|
|
549
|
+
// Plan 04: dispatch resolves the child agent (like runChild) then hands it to the host scheduler,
|
|
550
|
+
// which starts a detached run and returns the taskId immediately. Undefined when unwired.
|
|
551
|
+
// parentAncestry threads the dispatching run's ancestry so the cross-agent cycle guard spans
|
|
552
|
+
// dispatch hops — a detached A→B→A ping-pong is refused by delegationGuard, not just per-agent caps.
|
|
553
|
+
dispatchTask: deps.dispatch
|
|
554
|
+
? async ({ to, goal, context, criteria, inputArtifacts }) => {
|
|
555
|
+
const child = await loadAgent(deps.ws, to);
|
|
556
|
+
return deps.dispatch!({ agent: child, goal, context, criteria, inputArtifacts, parentRunId: runId, parentAgentId: opts.agent.id, parentAncestry: ancestry });
|
|
557
|
+
}
|
|
558
|
+
: undefined,
|
|
559
|
+
// awaiterAgentId lets the host fail fast on a self-block deadlock: awaiting a task queued behind
|
|
560
|
+
// THIS agent's own concurrency slot (the awaiting run holds it) would park until timeout.
|
|
561
|
+
awaitTask: deps.awaitTask
|
|
562
|
+
? (taskId, timeoutMs) => deps.awaitTask!(taskId, timeoutMs, opts.agent.id)
|
|
563
|
+
: undefined,
|
|
564
|
+
// Discovery respects the caller's visibility ACL, consistent with the inline-roster path. `acl`
|
|
565
|
+
// understands the Plan 19 `team:<id>` entry, so a member's find_agents is scoped to its own team.
|
|
566
|
+
findAgents: (query, k) =>
|
|
567
|
+
rankAgents(
|
|
568
|
+
loadIndex(deps.db)
|
|
569
|
+
.filter((r) => acl(opts.agent.canSee, r))
|
|
570
|
+
.filter((r) => r.id !== opts.agent.id),
|
|
571
|
+
query,
|
|
572
|
+
k,
|
|
573
|
+
),
|
|
574
|
+
agentExists: (id) => loadIndex(deps.db).some((r) => r.id === id),
|
|
575
|
+
notes: [],
|
|
576
|
+
workItems: { n: 0 },
|
|
577
|
+
childSpend: { tokens: 0, costUsd: 0 },
|
|
578
|
+
verifierSpend: { tokens: 0, costUsd: 0 },
|
|
579
|
+
childTraces: [],
|
|
580
|
+
// Plan 19: `to` may name an agent OR a team. Ids share one namespace (roster.createAgent and
|
|
581
|
+
// teams.createTeam both enforce it), so a bare "news" is unambiguous; "team:news" is accepted too.
|
|
582
|
+
// RESOLVE FIRST, then cycle-check the resolved AGENT — checking the team id would let
|
|
583
|
+
// root → news → editor → news → editor loop forever, since the team id is never in `ancestry`.
|
|
584
|
+
resolveDelegation: (to, goal) => {
|
|
585
|
+
const explicitTeam = to.startsWith("team:");
|
|
586
|
+
const bareId = explicitTeam ? to.slice(5) : to;
|
|
587
|
+
// An agent id wins a bare lookup; the shared namespace means only one of the two can exist.
|
|
588
|
+
const row = explicitTeam ? undefined : loadIndex(deps.db).find((r) => r.id === bareId);
|
|
589
|
+
const team = row ? null : loadTeam(deps.ws, bareId);
|
|
590
|
+
|
|
591
|
+
if (!team && !row) return { ok: false, error: `no agent or team "${to}"` };
|
|
592
|
+
if (!canDelegate(opts.agent, team ? { id: team.id, isTeam: true } : row!))
|
|
593
|
+
return { ok: false, error: `not permitted to delegate to "${to}"` };
|
|
594
|
+
|
|
595
|
+
let target = row?.id ?? "";
|
|
596
|
+
let note: string | undefined;
|
|
597
|
+
if (team) {
|
|
598
|
+
const route = routeToTeam(team, membersOf(deps.db, team.id), goal, [opts.agent.id, ...ancestry]);
|
|
599
|
+
if (!route.ok) return { ok: false, error: route.error };
|
|
600
|
+
target = route.agentId;
|
|
601
|
+
note = `routed ${team.id} → ${target} (${route.why})`;
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
if (target === opts.agent.id || ancestry.includes(target))
|
|
605
|
+
return { ok: false, error: `delegation cycle: "${target}" is already an ancestor` };
|
|
606
|
+
// inclusive: root is depth 0, so this allows up to MAX_DELEGATION_DEPTH levels of descendants.
|
|
607
|
+
// A led team consumes a level, which is why the message says so.
|
|
608
|
+
if (depth + 1 > MAX_DELEGATION_DEPTH)
|
|
609
|
+
return { ok: false, error: `max delegation depth (${MAX_DELEGATION_DEPTH}) reached (a team with a lead consumes one level)` };
|
|
610
|
+
if (deps.runCounter!.n >= MAX_RUNS_PER_REQUEST) return { ok: false, error: `max runs per request (${MAX_RUNS_PER_REQUEST}) reached` };
|
|
611
|
+
// Plan 23: the team context the CHILD runs under (for its workflow lane). When delegated through a
|
|
612
|
+
// team id, that's the team. When a lead — itself running under team X (opts.viaTeam) — hands work
|
|
613
|
+
// DIRECTLY to a member of X, the child inherits X, so the lead's hand-offs give members their lanes.
|
|
614
|
+
let viaTeam = team?.id;
|
|
615
|
+
if (!viaTeam && row && opts.viaTeam && (row.teams ?? []).includes(opts.viaTeam)) viaTeam = opts.viaTeam;
|
|
616
|
+
return { ok: true, agentId: target, team: team?.id, viaTeam, note };
|
|
617
|
+
},
|
|
618
|
+
};
|
|
619
|
+
|
|
620
|
+
// Visibility from the registry index only — never load every agent's identity (unbounded roster).
|
|
621
|
+
const visibleRows = visibleToRows(opts.agent, loadIndex(deps.db));
|
|
622
|
+
|
|
623
|
+
// Plan 19: the roster shows TEAMS this agent may address, plus the agents no shown team accounts
|
|
624
|
+
// for. Root therefore reads five team lines instead of sixty agent lines, and is pointed at the
|
|
625
|
+
// address it should be using. A squad with no teams/ directory takes the `[]` path and renders
|
|
626
|
+
// exactly as it did before this plan. The scan is a handful of files (teams are captain-owned).
|
|
627
|
+
const rosterTeams: RosterTeam[] = listTeams(deps.ws)
|
|
628
|
+
.filter((t) => t.id !== DEFAULT_TEAM_ID) // `default` is everyone — never a delegation address; keeps the roster byte-identical for a squad with no explicit teams
|
|
629
|
+
.filter((t) => canDelegate(opts.agent, { id: t.id, isTeam: true }))
|
|
630
|
+
.map((t) => ({ id: t.id, charter: t.charter, lead: t.lead, memberCount: membersOf(deps.db, t.id).length }))
|
|
631
|
+
.filter((t) => t.memberCount > 0); // an empty team is an address that goes nowhere — don't advertise it
|
|
632
|
+
const shown = new Set(rosterTeams.map((t) => t.id));
|
|
633
|
+
// Plan 22: an agent is accounted for by a shown team if ANY of its teams is shown (membership is
|
|
634
|
+
// many-to-many). `r.teams` carries the implicit `default`, which is never in `shown`, so it can't
|
|
635
|
+
// spuriously hide an otherwise-unaffiliated agent from the direct-reports list.
|
|
636
|
+
const visible = visibleRows.filter((r) => !(r.teams ?? []).some((t) => shown.has(t)));
|
|
637
|
+
|
|
638
|
+
// The agent's own teams (Plan 22: possibly several): each charter is a standing instruction, and the
|
|
639
|
+
// union of their tool policies layers over the member's own grant (deny wins; the DEFAULT_WORKER_TOOLS
|
|
640
|
+
// floor is protected per-team at load). The implicit `default` team is not among these — it carries no
|
|
641
|
+
// policy and its generic charter would only add noise.
|
|
642
|
+
const ownTeams = opts.agent.teams.map((t) => loadTeam(deps.ws, t)).filter((t): t is NonNullable<typeof t> => !!t);
|
|
643
|
+
const teamPolicy = mergeTeamPolicies(ownTeams.map((t) => t.tools));
|
|
644
|
+
const agentTools = effectiveTools(opts.agent.tools, teamPolicy);
|
|
645
|
+
|
|
646
|
+
// Plan 23: the agent's LANE in the workflow of the team it is running UNDER (opts.viaTeam). Sparse and
|
|
647
|
+
// optional — no workflow file, or no section for this agent → nothing is injected (the agentic
|
|
648
|
+
// fallback). The `orchestration` slice is injected for that team's LEAD only. The whole feature is
|
|
649
|
+
// inert unless a run has a team context AND that team authored a workflow.md.
|
|
650
|
+
const workflow = opts.viaTeam ? loadWorkflow(deps.ws, opts.viaTeam) : null;
|
|
651
|
+
const workflowLane = workflow ? laneFor(workflow, opts.agent.id) : undefined;
|
|
652
|
+
const viaTeamDef = opts.viaTeam ? (ownTeams.find((t) => t.id === opts.viaTeam) ?? loadTeam(deps.ws, opts.viaTeam)) : null;
|
|
653
|
+
const orchestration = workflow && viaTeamDef?.lead === opts.agent.id ? orchestrationSlice(workflow) : undefined;
|
|
654
|
+
|
|
655
|
+
let applied: PolicyNote[] = [];
|
|
656
|
+
try {
|
|
657
|
+
const own = listPolicies(deps.ws, opts.agent.id).filter((n) => n.status === "approved");
|
|
658
|
+
const cache = deps.globalPolicyCache!;
|
|
659
|
+
if (!cache.notes) {
|
|
660
|
+
cache.notes = loadIndex(deps.db)
|
|
661
|
+
.flatMap((r) => listPolicies(deps.ws, r.id))
|
|
662
|
+
.filter((n) => n.status === "approved" && n.scope === "global");
|
|
663
|
+
}
|
|
664
|
+
const globals = cache.notes.filter((n) => n.agent !== opts.agent.id);
|
|
665
|
+
applied = [...own, ...globals];
|
|
666
|
+
} catch (e) {
|
|
667
|
+
log.error(`policy load failed for ${opts.agent.id}`, e);
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
// Auto-inject relevant squad knowledge for agents that use the KB (like coaching notes): keyword+
|
|
671
|
+
// graph normally, semantic when an embedder is configured. Skipped for agents without `recall`.
|
|
672
|
+
let knowledgeBlock: string | undefined;
|
|
673
|
+
let knowledgeIds: string[] = [];
|
|
674
|
+
// Plan 19: a team that grants `recall` gives every member the KB, auto-injection included.
|
|
675
|
+
if (agentTools.includes("recall")) {
|
|
676
|
+
const q = opts.brief?.goal ?? lastUserText(opts.messages);
|
|
677
|
+
if (q.trim()) {
|
|
678
|
+
try {
|
|
679
|
+
const kb = await searchKnowledge({ db: deps.db, query: q, embed: deps.embed, k: 5, hops: 1 });
|
|
680
|
+
if (kb.hits.length) {
|
|
681
|
+
knowledgeIds = kb.hits.map((h) => h.id);
|
|
682
|
+
knowledgeBlock = "## Relevant knowledge (shared squad memory — call recall for more)\n" +
|
|
683
|
+
kb.hits.map((h) => `- [${h.id}] ${h.title}${h.summary ? " — " + h.summary : ""}`).join("\n");
|
|
684
|
+
}
|
|
685
|
+
} catch (e) { log.error(`kb recall failed for ${opts.agent.id}`, e); }
|
|
686
|
+
}
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
// Inject the agent's FULL skill toolkit (name + when-to-use) for EVERY agent, so it always knows
|
|
690
|
+
// what it has — and can answer "how many skills do I have" — the way the "Your team" roster block
|
|
691
|
+
// lists all agents. Only fall back to keyword-ranked top-K when the library is large. The full
|
|
692
|
+
// procedure loads via use_skill on demand. (Keyword-only injection hid skills for meta questions.)
|
|
693
|
+
let skillsBlock: string | undefined;
|
|
694
|
+
let skillIds: string[] = [];
|
|
695
|
+
try {
|
|
696
|
+
const all = getActiveSkills(deps.db);
|
|
697
|
+
if (all.length) {
|
|
698
|
+
const CAP = 40;
|
|
699
|
+
let shown: { id: string; name: string; description: string }[];
|
|
700
|
+
let header: string;
|
|
701
|
+
if (all.length <= CAP) {
|
|
702
|
+
shown = all.map((s) => ({ id: s.id, name: s.name, description: s.description }));
|
|
703
|
+
header = `## Your skills (${all.length}) — call use_skill(name) to load a procedure before you act`;
|
|
704
|
+
} else {
|
|
705
|
+
shown = rankSkills(all, opts.brief?.goal ?? lastUserText(opts.messages), CAP);
|
|
706
|
+
header = `## Your skills (${all.length}, showing the ${CAP} most relevant — call find_skills to search the rest)`;
|
|
707
|
+
}
|
|
708
|
+
skillIds = shown.map((s) => s.id);
|
|
709
|
+
skillsBlock = header + "\n" + shown.map((s) => `- ${s.name}: ${s.description}`).join("\n");
|
|
710
|
+
}
|
|
711
|
+
} catch (e) { log.error(`skill inject failed for ${opts.agent.id}`, e); }
|
|
712
|
+
|
|
713
|
+
// Input artifacts handed in by a delegating parent: render HANDLES + summaries, never inline the
|
|
714
|
+
// body — the child pulls what it needs with read_artifact (size-capped). This is hand-off by reference.
|
|
715
|
+
// Plan 01 Ph4: any OPEN annotation on the handed artifact rides along here — that is the annotation →
|
|
716
|
+
// revision path. An input artifact carrying open feedback IS a revision brief: the child addresses the
|
|
717
|
+
// points and saves a new version linked back. A verification verdict (Plan 06) surfaces identically.
|
|
718
|
+
let inputArtifactsBlock: string | undefined;
|
|
719
|
+
let sawOpenFeedback = false;
|
|
720
|
+
if (opts.inputArtifacts?.length) {
|
|
721
|
+
const lines = opts.inputArtifacts.map((h) => {
|
|
722
|
+
const a = readArtifact(deps.ws, h);
|
|
723
|
+
if (!a) return `- [${h}] (unavailable)`;
|
|
724
|
+
const handle = artifactHandle(a);
|
|
725
|
+
const open = listAnnotations(deps.ws, handle, { status: "open" });
|
|
726
|
+
if (open.length) sawOpenFeedback = true;
|
|
727
|
+
const feedback = open.map((an) =>
|
|
728
|
+
` ↳ ${an.kind === "verification" ? "verdict" : "feedback"} from ${an.author}: ${an.body}` +
|
|
729
|
+
(an.verdict ? ` [${an.verdict.pass ? "PASS" : "FAIL: " + an.verdict.reasons.join("; ")}]` : ""));
|
|
730
|
+
return [`- [${handle}] ${a.title} (${a.type})${a.summary ? " — " + a.summary : ""}`, ...feedback].join("\n");
|
|
731
|
+
});
|
|
732
|
+
inputArtifactsBlock = "## Input artifacts (handed to you by reference)\n" +
|
|
733
|
+
"Read one with read_artifact(id) — do NOT expect its body inlined here." +
|
|
734
|
+
(sawOpenFeedback
|
|
735
|
+
? " An artifact with open feedback below is a REVISION: address EVERY point (list_annotations for detail), " +
|
|
736
|
+
"then save_artifact with the SAME id (a new version) and parents:[the handle].\n"
|
|
737
|
+
: "\n") +
|
|
738
|
+
lines.join("\n");
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
const { system } = assemble(opts.agent, {
|
|
742
|
+
visibleAgents: visible,
|
|
743
|
+
teams: rosterTeams,
|
|
744
|
+
teamCharter: ownTeams.map((t) => t.charterBody).filter(Boolean).join("\n\n") || undefined,
|
|
745
|
+
workflowTeam: opts.viaTeam,
|
|
746
|
+
workflowLane,
|
|
747
|
+
orchestration,
|
|
748
|
+
canPlan: agentTools.includes("write_plan"),
|
|
749
|
+
brief: opts.brief ? { to: opts.agent.id, ...opts.brief } : undefined,
|
|
750
|
+
policies: applied,
|
|
751
|
+
memoryBlock,
|
|
752
|
+
knowledgeBlock,
|
|
753
|
+
skillsBlock,
|
|
754
|
+
inputArtifactsBlock,
|
|
755
|
+
});
|
|
756
|
+
const tools = toolsForAgent(opts.agent, ctx, deps.mcp, teamPolicy);
|
|
757
|
+
|
|
758
|
+
const runLoopCall = () => runLoop({
|
|
759
|
+
model, agent: opts.agent, system, messages: opts.messages, tools,
|
|
760
|
+
telemetry: loopTelemetry, // Plan 16: gen_ai spans + model metrics for this run's calls
|
|
761
|
+
onStep: emitStep, // stamps agent + runId (Plan 02/10) then forwards to deps.onStep
|
|
762
|
+
// Per-run steer routing (Phase 4): a steer aimed at THIS runId reaches only this run; fall back
|
|
763
|
+
// to the flat queue for unit contexts that don't route.
|
|
764
|
+
pollSteer: (deps.pollSteerFor || deps.pollSteer)
|
|
765
|
+
? () => deps.pollSteerFor?.({ runId, agentId: opts.agent.id, triggeredBy: opts.triggeredBy }) ?? deps.pollSteer?.() ?? null
|
|
766
|
+
: undefined,
|
|
767
|
+
signal: deps.signal,
|
|
768
|
+
priceUsd,
|
|
769
|
+
codexBackend: subscription, // subscription:true ⇒ Codex backend ⇒ system goes in `instructions`
|
|
770
|
+
captureProviderCost: picked?.captureCost, // OpenRouter reports real cost in providerMetadata
|
|
771
|
+
// Plan 05: config disposes the compaction threshold — per-model window table × defaults.compactAt
|
|
772
|
+
// (default ~70%). The loop MEASURES context every iteration and FOLDS the oldest round-trips once
|
|
773
|
+
// this estimate is crossed (system + original brief + recent N kept verbatim).
|
|
774
|
+
compactThresholdTokens: compactionThreshold(picked?.modelId, deps.configDefaults?.compactAt),
|
|
775
|
+
// Phase 5 recovery: flush each transcript event live + checkpoint the message array per iteration,
|
|
776
|
+
// so a crash mid-run leaves legible evidence and a resume point instead of nothing.
|
|
777
|
+
onEvent: (e) => appendRunTranscript(deps.ws, runId, e),
|
|
778
|
+
checkpoint: (s) => writeRunCheckpoint(deps.ws, runId, s),
|
|
779
|
+
// Plan 09: squad-wide ceilings are metered + enforced here, the same place per-run caps are. Shared
|
|
780
|
+
// across every run (parent + delegated children) so the whole squad's spend counts against them.
|
|
781
|
+
spendLedger: deps.spendLedger,
|
|
782
|
+
// Plan 19: this run is ALSO metered against its own team's ceiling, so a team can be capped
|
|
783
|
+
// independently. A delegated child is metered against ITS team, not its parent's — the spend is
|
|
784
|
+
// the child's to answer for.
|
|
785
|
+
spendScopes: scopesFor(opts.agent.teams),
|
|
786
|
+
// Plan 18: render the live plan fresh before EVERY model call. Only for agents that hold the tools,
|
|
787
|
+
// so an agent without a plan pays nothing. The slot never enters `messages` — see plan-inject.ts.
|
|
788
|
+
pollPlan: agentTools.includes("write_plan")
|
|
789
|
+
? () => { const s = ctx.plan?.read(); return s ? renderPlan(s) : null; }
|
|
790
|
+
: undefined,
|
|
791
|
+
// Plan 12 (reopened): per-request transport deadline for the model fetch. Also bounds consumeStream()
|
|
792
|
+
// in case the underlying stream ignores the abort signal. Config-disposed via defaults.modelRequestTimeoutMs.
|
|
793
|
+
modelRequestTimeoutMs: deps.configDefaults?.modelRequestTimeoutMs,
|
|
794
|
+
});
|
|
795
|
+
// Plan 16: run the loop with THIS run's span active, so the AI SDK's gen_ai spans and any delegated
|
|
796
|
+
// child runs nest under it (context propagation via AsyncLocalStorage). No span ⇒ call it directly.
|
|
797
|
+
const result = runSpan
|
|
798
|
+
? await context.with(otelTrace.setSpan(context.active(), runSpan), runLoopCall)
|
|
799
|
+
: await runLoopCall();
|
|
800
|
+
// The run has COMPLETED the moment runLoop returns a terminal result — mark the turn closed HERE,
|
|
801
|
+
// BEFORE the post-loop finalization/close block. This scopes the catch's recordTurnFailure to genuine
|
|
802
|
+
// PRE-loop throws only: a throw in finalization (writeTrace / recordTurnOutcome → rebuildReplayCache /
|
|
803
|
+
// writeThread / …) must PROPAGATE, never re-settle a completed run as a pre-run failure (which would
|
|
804
|
+
// duplicate the ledger turn, flip the user turn's context decision included→excluded, and mark the
|
|
805
|
+
// task failed). Even a `result.error` outcome ("failed") is a run that RAN — recorded by the normal
|
|
806
|
+
// close below, not by the catch.
|
|
807
|
+
turnClosed = true;
|
|
808
|
+
const outcome: RunTrace["outcome"] =
|
|
809
|
+
result.aborted ? "interrupted" : result.exhausted ? "blocked" : result.error ? "failed" : "completed";
|
|
810
|
+
if (result.error) log.error(`run ${runId} failed`, result.error);
|
|
811
|
+
|
|
812
|
+
// Plan 18: one fold, reused by the trace and the OTel run span below.
|
|
813
|
+
const planAtEnd = ctx.plan?.read();
|
|
814
|
+
const trace: RunTrace = {
|
|
815
|
+
id: runId, agent: opts.agent.id, task: opts.brief?.goal ?? "(chat)", triggeredBy: opts.triggeredBy,
|
|
816
|
+
ledger: { retrieved: applied.map((n) => n.id), applied: applied.map((n) => n.id), skipped: [], knowledge: knowledgeIds, skills: skillIds },
|
|
817
|
+
toolCalls: Object.entries(result.toolCalls).map(([tool, count]) => ({ tool, count })),
|
|
818
|
+
artifacts: ctx.artifacts, inputArtifacts: ctx.inputArtifacts, outputArtifacts: ctx.outputArtifacts,
|
|
819
|
+
delegatedOut: ctx.delegatedOut, verification: ctx.verifications, outcome,
|
|
820
|
+
tokens: result.tokens, contextTokens: result.contextTokens, costUsd: subscription ? null : result.costUsd,
|
|
821
|
+
costNote: subscription ? "subscription" : undefined,
|
|
822
|
+
// This run's own delegation-checker spend (Plan 06), surfaced separately from the primary loop so
|
|
823
|
+
// /costs can add it to this run's own-spend total (the checker creates no child trace, so it's
|
|
824
|
+
// counted exactly once). USD stays 0 for subscription runs — honest, never a fabricated price.
|
|
825
|
+
verifierTokens: ctx.verifierSpend.tokens,
|
|
826
|
+
verifierCostUsd: subscription ? 0 : ctx.verifierSpend.costUsd,
|
|
827
|
+
model: picked?.modelId, // the /costs "by provider/model" dimension; undefined in headless/unit contexts without a resolver
|
|
828
|
+
// aggregate = this run's own loop + child RUNS + this run's delegation-checker calls. All three
|
|
829
|
+
// are real model spend this run caused; verifier spend is 0 for subscription (costUsd stays null).
|
|
830
|
+
aggregate: {
|
|
831
|
+
tokens: result.tokens + ctx.childSpend.tokens + ctx.verifierSpend.tokens,
|
|
832
|
+
costUsd: subscription ? null : result.costUsd + ctx.childSpend.costUsd + ctx.verifierSpend.costUsd,
|
|
833
|
+
},
|
|
834
|
+
// Plan 18: the plan handle + how many transitions this run wrote. A run with no plan records neither.
|
|
835
|
+
plan: planAtEnd?.handle,
|
|
836
|
+
planEvents: planEvents.n || undefined,
|
|
837
|
+
notes: ctx.notes,
|
|
838
|
+
durationMs: Math.round(performance.now() - t0), started,
|
|
839
|
+
};
|
|
840
|
+
// The loop's model events (model_request/response/tool_call) were flushed LIVE via onEvent
|
|
841
|
+
// (incremental recovery, Plan 04 Phase 5). The tool/approval SPAN events (Plan 02/10) buffer in
|
|
842
|
+
// ctx.spanEvents — flush them here. The waterfall reader pairs spans by callId/iteration and times
|
|
843
|
+
// them by each event's `ts`, so append order is irrelevant. Do NOT re-write result.transcript:
|
|
844
|
+
// onEvent already persisted it live, and doing both would double-write every loop event.
|
|
845
|
+
for (const event of ctx.spanEvents) appendRunTranscript(deps.ws, runId, event);
|
|
846
|
+
// Verdicts are part of the run's story ("why did it retry?") — record them in the transcript too.
|
|
847
|
+
for (const v of ctx.verifications) appendRunTranscript(deps.ws, runId, { ts: new Date().toISOString(), kind: "verification", data: v });
|
|
848
|
+
writeChildRuns(deps.ws, runId, ctx.childTraces);
|
|
849
|
+
const finalText = result.error ? `error: ${result.error}` : result.text;
|
|
850
|
+
writeRunFinal(deps.ws, runId, finalText);
|
|
851
|
+
writeRunFailure(deps.ws, runId, trace, finalText);
|
|
852
|
+
writeTrace(deps.ws, trace);
|
|
853
|
+
// Plan 01 Ph5 seam (close): record the assistant turn + context decision + task update, and rebuild
|
|
854
|
+
// the derived boot-replay cache (Plan 05 Ph3 folds older turns into a rolling summary here). Guarded
|
|
855
|
+
// to user conversation turns; children (triggeredBy = runId) and ingest runs are untouched.
|
|
856
|
+
if (isUserTurn) {
|
|
857
|
+
recordTurnOutcome(deps.ws, deps.db, {
|
|
858
|
+
agent: opts.agent.id, runId, userTurn, trace, children: ctx.childTraces, text: finalText,
|
|
859
|
+
keepRecentTurns: deps.configDefaults?.replayKeepTurns,
|
|
860
|
+
});
|
|
861
|
+
}
|
|
862
|
+
// Plan 16: close the run span with the final rollups (tokens, cost, context) + outcome/status.
|
|
863
|
+
// Plan 18: plan attributes ride along, so an agent can ask its OWN backend "which of my plan items
|
|
864
|
+
// failed verification more than once this week" — the question a terminal waterfall could never answer.
|
|
865
|
+
finishRunSpan(outcome, {
|
|
866
|
+
"taicho.tokens": result.tokens,
|
|
867
|
+
// The run's token TOTALS live under taicho.* — NOT gen_ai.usage.* — on purpose. A run/turn/delegation
|
|
868
|
+
// span is a CONTAINER, not an inference call; a gen_ai-convention backend (LangSmith) prices ANY span
|
|
869
|
+
// carrying gen_ai.usage.* as a model call, so stamping the aggregate here double-counted every token on
|
|
870
|
+
// top of the real per-iteration `chat` spans (which correctly carry gen_ai.usage in loop.ts) — inflating
|
|
871
|
+
// reported cost 2x at the trace root and up to 6x once delegation roll-ups stack. The backend rolls up
|
|
872
|
+
// the child call spans for the true per-run cost; we keep the split here only for our OWN queries.
|
|
873
|
+
"taicho.tokens.input": result.inputTokens,
|
|
874
|
+
"taicho.tokens.output": result.outputTokens,
|
|
875
|
+
"taicho.context.tokens": result.contextTokens,
|
|
876
|
+
...(subscription ? {} : { "taicho.cost.usd": result.costUsd }),
|
|
877
|
+
...(planAtEnd
|
|
878
|
+
? {
|
|
879
|
+
"taicho.plan.handle": planAtEnd.handle,
|
|
880
|
+
"taicho.plan.items.total": planAtEnd.counts.total,
|
|
881
|
+
"taicho.plan.items.done": planAtEnd.counts.done,
|
|
882
|
+
"taicho.plan.items.open": planAtEnd.counts.open,
|
|
883
|
+
"taicho.plan.items.failed": planAtEnd.counts.failed,
|
|
884
|
+
}
|
|
885
|
+
: {}),
|
|
886
|
+
}, result.error, finalText);
|
|
887
|
+
deps.onRunEnd?.({ runId, agent: opts.agent.id, triggeredBy: opts.triggeredBy, outcome });
|
|
888
|
+
return { runId, text: finalText, trace };
|
|
889
|
+
} catch (err) {
|
|
890
|
+
// Plan 16: a throw anywhere in the run must still close the span + decrement the active-run gauge
|
|
891
|
+
// (idempotent — a no-op if finalize already closed it on the normal path).
|
|
892
|
+
finishRunSpan("failed", undefined, err instanceof Error ? err.message : String(err));
|
|
893
|
+
// A PRE-loop throw (resolveModel / prompt + tool build) between recordUserTurn and runLoop returning
|
|
894
|
+
// would strand the open turn — settle it to a terminal `failed` outcome so the ledger + task never
|
|
895
|
+
// dangle, then re-throw so the caller still sees the real error. `turnClosed` is already true once
|
|
896
|
+
// runLoop has returned, so a POST-loop finalization throw skips this and simply propagates unchanged.
|
|
897
|
+
if (isUserTurn && userTurn && !turnClosed) {
|
|
898
|
+
try {
|
|
899
|
+
recordTurnFailure(deps.ws, deps.db, {
|
|
900
|
+
agent: opts.agent.id, runId, userTurn,
|
|
901
|
+
error: err instanceof Error ? err.message : String(err),
|
|
902
|
+
});
|
|
903
|
+
} catch (closeErr) {
|
|
904
|
+
log.error(`could not close dangling turn for ${opts.agent.id}/${runId}`, closeErr);
|
|
905
|
+
}
|
|
906
|
+
}
|
|
907
|
+
throw err;
|
|
908
|
+
}
|
|
909
|
+
}
|