@cairnvibe/sdk 0.2.13 → 0.3.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.
@@ -0,0 +1,113 @@
1
+ import { type AgentEvent, type CriticVerdict, type HistoryTurn, type VerbResponse } from "@cairnvibe/core";
2
+ /** 4 exchanges — matches the cap both original drivers independently used. */
3
+ export declare const MAX_HISTORY_TURNS = 8;
4
+ export declare function looksMultiStep(question: string): boolean;
5
+ export declare function summarizeVerbForHistory(verb: VerbResponse): string;
6
+ export interface AgentLoopStepEvent {
7
+ verb: VerbResponse;
8
+ /** 0-based. */
9
+ iteration: number;
10
+ /** True if isTerminalVerb(verb) says this ends the loop right after
11
+ * this hook returns (TERMINAL_VERBS membership, except a navigate
12
+ * marked continueAfter — see isTerminalVerb's own doc comment). Lets a
13
+ * caller act differently for a continuing vs. final step without
14
+ * re-deriving that check itself. */
15
+ terminal: boolean;
16
+ }
17
+ export interface AgentLoopStepResultEvent {
18
+ verb: VerbResponse;
19
+ iteration: number;
20
+ observation: string | null | undefined;
21
+ }
22
+ export interface AgentLoopDeps {
23
+ /** Resolve the next step given the CURRENT working history. Return
24
+ * `null` for a response that failed to parse/validate — the HTTP
25
+ * path's own real case (a raw fetch response might not conform);
26
+ * realtime's in-process resolveVerb never produces this, since it
27
+ * always returns a valid VerbResponse itself. A null return ends the
28
+ * loop immediately with outcome "unparseable". */
29
+ getNextStep(loopHistory: HistoryTurn[], iteration: number): Promise<VerbResponse | null>;
30
+ /** Fires immediately after getNextStep resolves, before the terminal/
31
+ * continuing branch is decided — the real-time side-effect point (send
32
+ * the verb to a client, trigger an ack on the first continuing step,
33
+ * check for a superseding barge-in). Returning true aborts the loop
34
+ * immediately: no further side effects, outcome "aborted". */
35
+ onStep?(event: AgentLoopStepEvent): boolean | Promise<boolean>;
36
+ /** Execute a continuing verb (click/fill/read/call_tool/batch) for
37
+ * real; return its observation text (or null/undefined for "no
38
+ * result", folded into history as "no result" exactly like both
39
+ * original drivers already did). */
40
+ executeStep(verb: VerbResponse, iteration: number): Promise<string | null | undefined>;
41
+ /** Fires after executeStep resolves, before folding the observation
42
+ * into working history — a second real-time abort checkpoint (e.g. a
43
+ * barge-in generation check after awaiting a real tool result, which
44
+ * can itself take a while). Returning true aborts the loop with
45
+ * outcome "aborted", discarding this step's observation. */
46
+ onStepResult?(event: AgentLoopStepResultEvent): boolean | Promise<boolean>;
47
+ /**
48
+ * Phase 3 step 3 — a genuinely separate pass over the step's REAL
49
+ * observation, decoupled from the Executor/model's own self-report
50
+ * (the direct fix for the diagnosed bug: a batch succeeded and the
51
+ * model kept looping instead of recognizing it). Fires after
52
+ * onStepResult/the history fold. Returning a "task_complete" or
53
+ * "give_up" verdict ends the loop right here — even though the
54
+ * model's own verb was never a TERMINAL_VERBS member — instead of
55
+ * asking the model again and hoping it notices. Returning "continue"
56
+ * (including after the caller's own closure has silently handled a
57
+ * "replan" by fetching a fresh Plan — driveAgentLoop itself has no
58
+ * concept of a Plan, only of "keep going or stop") keeps the loop
59
+ * going exactly as if this hook were absent. Returning null/undefined
60
+ * behaves the same as "continue" — a caller can choose not to run the
61
+ * Critic on a particular step without a special no-op verdict shape.
62
+ */
63
+ runCritic?(event: AgentLoopStepResultEvent): Promise<CriticVerdict | null | undefined>;
64
+ /**
65
+ * Phase 3 step 5 — a pure, fire-and-forget event consumer for a
66
+ * Talker-style narration layer ("Revisable by Design"'s pattern):
67
+ * never awaited, never able to affect control flow. driveAgentLoop
68
+ * itself emits "act" (right after a step's onStep/abort check passes —
69
+ * only for a verb that's actually going to execute, never a discarded
70
+ * one) and "obs" (right after onStepResult's own abort check passes),
71
+ * since it already has that data at exactly those points. A caller's
72
+ * own onStep/runCritic closures can call this SAME callback directly —
73
+ * it's just a plain reference they already have via the deps object
74
+ * they constructed — to emit "thk" (Critic reasoning) or "inj"
75
+ * (injected filler narration, e.g. a Talker ack phrase) events too;
76
+ * driveAgentLoop has no opinion on those.
77
+ */
78
+ onEvent?(event: AgentEvent): void;
79
+ /** Defaults to 6 — a hard cap, not a target, matching both original drivers. */
80
+ maxIterations?: number;
81
+ }
82
+ export type AgentLoopOutcome = {
83
+ outcome: "terminal";
84
+ finalVerb: VerbResponse;
85
+ workingHistory: HistoryTurn[];
86
+ }
87
+ /** The Critic independently confirmed the (last) task's doneContract
88
+ * is satisfied — the real fix for the diagnosed bug. The caller
89
+ * synthesizes its own terminal-shaped response (e.g. `{verb: "explain",
90
+ * text: verdict.reasoning}`) from `verdict`, same as it would for a
91
+ * model-produced terminal verb. */
92
+ | {
93
+ outcome: "critic-complete";
94
+ verdict: CriticVerdict;
95
+ workingHistory: HistoryTurn[];
96
+ }
97
+ /** The Critic (or the harness's own stall-count fail-safe, inside the
98
+ * caller's runCritic closure) decided continuing wouldn't help. */
99
+ | {
100
+ outcome: "critic-give-up";
101
+ verdict: CriticVerdict;
102
+ workingHistory: HistoryTurn[];
103
+ } | {
104
+ outcome: "unparseable";
105
+ workingHistory: HistoryTurn[];
106
+ } | {
107
+ outcome: "gave-up";
108
+ workingHistory: HistoryTurn[];
109
+ } | {
110
+ outcome: "aborted";
111
+ workingHistory: HistoryTurn[];
112
+ };
113
+ export declare function driveAgentLoop(initialHistory: HistoryTurn[], deps: AgentLoopDeps): Promise<AgentLoopOutcome>;
@@ -0,0 +1,128 @@
1
+ "use strict";
2
+ // The shared skeleton behind both agent-loop drivers — index.tsx's
3
+ // runTypedAgentLoop (HTTP/typed transport) and realtime-server.ts's
4
+ // finalizeTurn (WebSocket/voice relay) independently re-implemented the
5
+ // exact same "ask, check terminal, execute a continuing step for real,
6
+ // fold the result into working history, ask again, up to a hard
7
+ // iteration cap" shape — a real, live duplication risk (any future fix
8
+ // to one had to be remembered and re-applied to the other by hand).
9
+ // This module is the first step of the Phase 3 multi-agent redesign
10
+ // (see DEVELOPMENT.md/the plan file's "Phase 3" entry): extract exactly
11
+ // that shared shape, with ZERO behavior change, so Planner/Critic
12
+ // wiring in later steps has one real place to attach to instead of two.
13
+ //
14
+ // Deliberately does NOT own transport-specific side effects — sending a
15
+ // message to a client, speaking, committing to a connection's real
16
+ // cross-turn memory, barge-in cancellation timing. Those stay in each
17
+ // transport's own getNextStep/onStep/onStepResult/executeStep closures,
18
+ // and in what the caller does with this function's return value, exactly
19
+ // as before this extraction. Plain TypeScript only (no JSX, no Node
20
+ // built-ins) — imported as raw source by index.tsx's browser bundle AND
21
+ // compiled to dist/ for realtime-server.ts's Node build.
22
+ Object.defineProperty(exports, "__esModule", { value: true });
23
+ exports.MAX_HISTORY_TURNS = void 0;
24
+ exports.looksMultiStep = looksMultiStep;
25
+ exports.summarizeVerbForHistory = summarizeVerbForHistory;
26
+ exports.driveAgentLoop = driveAgentLoop;
27
+ const core_1 = require("@cairnvibe/core");
28
+ /** 4 exchanges — matches the cap both original drivers independently used. */
29
+ exports.MAX_HISTORY_TURNS = 8;
30
+ /**
31
+ * Architecture Pillar 4 — a cheap, LOCAL signal for "this goal probably
32
+ * needs more than one real step," checked BEFORE the first step even
33
+ * runs, so a caller can start the Planner call in PARALLEL with the
34
+ * first getNextStep instead of only after that first step already came
35
+ * back non-terminal (the "lazy gate" the plan singles out for
36
+ * replacement — realtime-server.ts's own onStep used to build planPromise
37
+ * only once `!terminal && iteration === 0` was already true, one full
38
+ * model round trip later than it needed to be). Deliberately
39
+ * conservative, on purpose: a false negative here just falls back to
40
+ * that same lazy-after-step-1 behavior — unchanged, zero regression —
41
+ * while a false positive costs one Planner call that would have started
42
+ * a moment later anyway, never a wrong answer. Genuine UI-pattern-aware
43
+ * classification (Pillar 2, not built yet) can replace this heuristic
44
+ * later without changing what calls it. Lives here (not server.ts) so
45
+ * BOTH transports can use the exact same check: this file is plain,
46
+ * dependency-free TypeScript imported as raw source by index.tsx's
47
+ * browser bundle AND compiled for realtime-server.ts's Node build — a
48
+ * server-only file (server.ts imports the Anthropic/Groq SDKs) can never
49
+ * be imported from the client widget.
50
+ */
51
+ const MULTI_STEP_SIGNAL = /\b(then|after that|once (you|it|that|i)|and then|next,|first[,.]? .*\bthen\b)\b/;
52
+ function looksMultiStep(question) {
53
+ return MULTI_STEP_SIGNAL.test(question.toLowerCase());
54
+ }
55
+ function summarizeVerbForHistory(verb) {
56
+ if ("text" in verb && verb.text)
57
+ return verb.text;
58
+ switch (verb.verb) {
59
+ case "highlight":
60
+ case "open":
61
+ return `(highlighted ${verb.target})`;
62
+ case "navigate":
63
+ return `(navigated to ${verb.route})`;
64
+ case "do":
65
+ return `(ran ${verb.action}${verb.target ? ` on ${verb.target}` : ""})`;
66
+ case "tour":
67
+ return verb.steps.map((s) => s.text).join(" ");
68
+ case "click":
69
+ return `(clicked ${verb.target})`;
70
+ case "fill":
71
+ return `(typed "${verb.value}" into ${verb.target})`;
72
+ case "read":
73
+ return `(read ${verb.target})`;
74
+ case "call_tool":
75
+ return `(called ${verb.name})`;
76
+ case "drag":
77
+ return `(dragged ${verb.target} to ${verb.to})`;
78
+ case "select":
79
+ return `(selected "${verb.value}" in ${verb.target})`;
80
+ case "key":
81
+ return `(pressed ${verb.key}${verb.target ? ` on ${verb.target}` : ""})`;
82
+ case "batch":
83
+ return `(${verb.actions.length} steps: ${verb.actions.map((a) => a.verb).join(", ")})`;
84
+ default:
85
+ return "(no response)";
86
+ }
87
+ }
88
+ async function driveAgentLoop(initialHistory, deps) {
89
+ const maxIterations = deps.maxIterations ?? 6;
90
+ let loopHistory = initialHistory;
91
+ for (let i = 0; i < maxIterations; i++) {
92
+ const verb = await deps.getNextStep(loopHistory, i);
93
+ if (verb === null)
94
+ return { outcome: "unparseable", workingHistory: loopHistory };
95
+ const terminal = (0, core_1.isTerminalVerb)(verb);
96
+ if (deps.onStep) {
97
+ const abort = await deps.onStep({ verb, iteration: i, terminal });
98
+ if (abort)
99
+ return { outcome: "aborted", workingHistory: loopHistory };
100
+ }
101
+ deps.onEvent?.({ type: "act", verb, at: Date.now() });
102
+ if (terminal) {
103
+ return { outcome: "terminal", finalVerb: verb, workingHistory: loopHistory };
104
+ }
105
+ const observation = await deps.executeStep(verb, i);
106
+ if (deps.onStepResult) {
107
+ const abort = await deps.onStepResult({ verb, iteration: i, observation });
108
+ if (abort)
109
+ return { outcome: "aborted", workingHistory: loopHistory };
110
+ }
111
+ deps.onEvent?.({ type: "obs", observation: observation ?? "no result", ok: observation !== null && observation !== undefined, at: Date.now() });
112
+ loopHistory = [
113
+ ...loopHistory,
114
+ { role: "assistant", text: `${summarizeVerbForHistory(verb)}. Result: ${observation ?? "no result"}` },
115
+ ].slice(-exports.MAX_HISTORY_TURNS);
116
+ if (deps.runCritic) {
117
+ const verdict = await deps.runCritic({ verb, iteration: i, observation });
118
+ if (verdict?.verdict === "task_complete")
119
+ return { outcome: "critic-complete", verdict, workingHistory: loopHistory };
120
+ if (verdict?.verdict === "give_up")
121
+ return { outcome: "critic-give-up", verdict, workingHistory: loopHistory };
122
+ // "continue", "replan" (already handled inside the caller's own
123
+ // runCritic closure — see this field's own doc comment), or no
124
+ // verdict at all: fall through and keep looping, unchanged.
125
+ }
126
+ }
127
+ return { outcome: "gave-up", workingHistory: loopHistory };
128
+ }