@zocomputer/agent-sdk 0.5.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/LICENSE +21 -0
- package/README.md +673 -0
- package/dist/attachments.js +52 -0
- package/dist/gateway-fetch.js +67 -0
- package/dist/index.js +4169 -0
- package/dist/initiator-auth.js +49 -0
- package/dist/platform/agent-sandbox/index.js +691 -0
- package/dist/platform/cloud-tools/image.js +498 -0
- package/dist/platform/cloud-tools/index.js +507 -0
- package/dist/platform/cloud-tools/web-search.js +87 -0
- package/dist/platform/runtime-ai/gateway.js +87 -0
- package/dist/platform/runtime-ai/index.js +91 -0
- package/dist/platform/runtime-ai/register.js +88 -0
- package/dist/platform/runtime-ai/session-fetch.js +54 -0
- package/dist/platform/runtime-auth/index.js +141 -0
- package/dist/state-files.js +287 -0
- package/dist/state-sandbox.js +383 -0
- package/dist/state.js +29 -0
- package/dist/steer-inbox.js +84 -0
- package/dist/steer.js +83 -0
- package/package.json +143 -0
- package/platform/agent-sandbox/api-client.ts +196 -0
- package/platform/agent-sandbox/index.ts +27 -0
- package/platform/agent-sandbox/pure.ts +83 -0
- package/platform/agent-sandbox/sftp.ts +141 -0
- package/platform/agent-sandbox/ssh-connection.ts +165 -0
- package/platform/agent-sandbox/ssh-exec.ts +98 -0
- package/platform/agent-sandbox/ssh-session.ts +487 -0
- package/platform/agent-sandbox/zo-backend.ts +88 -0
- package/platform/agent-sandbox/zo-sandbox.ts +39 -0
- package/platform/cloud-tools/image-path.ts +44 -0
- package/platform/cloud-tools/image.ts +225 -0
- package/platform/cloud-tools/index.ts +22 -0
- package/platform/cloud-tools/state-files.ts +368 -0
- package/platform/cloud-tools/tool-meta.ts +32 -0
- package/platform/cloud-tools/web-search.ts +15 -0
- package/platform/runtime-ai/gateway.ts +76 -0
- package/platform/runtime-ai/index.ts +20 -0
- package/platform/runtime-ai/register.ts +26 -0
- package/platform/runtime-ai/session-fetch.ts +124 -0
- package/platform/runtime-auth/index.ts +331 -0
- package/src/async-tasks.ts +273 -0
- package/src/attachments.ts +109 -0
- package/src/backgroundable.ts +88 -0
- package/src/bounded-output.ts +159 -0
- package/src/dir-conventions.ts +238 -0
- package/src/extract/cache.ts +40 -0
- package/src/extract/docx.ts +18 -0
- package/src/extract/pdf.ts +54 -0
- package/src/extract/sheet.ts +56 -0
- package/src/file-kind.ts +258 -0
- package/src/file-view.ts +80 -0
- package/src/gateway-fetch.ts +115 -0
- package/src/glob-match.ts +13 -0
- package/src/hooks.ts +213 -0
- package/src/index.ts +419 -0
- package/src/initiator-auth.ts +81 -0
- package/src/instructions.ts +224 -0
- package/src/list-files.ts +41 -0
- package/src/mock-model.ts +572 -0
- package/src/park-delivery.ts +247 -0
- package/src/path-locks.ts +52 -0
- package/src/read-file-content.ts +142 -0
- package/src/read-text.ts +40 -0
- package/src/redeliver.ts +155 -0
- package/src/run.ts +159 -0
- package/src/sandbox-io.ts +414 -0
- package/src/state-files.ts +460 -0
- package/src/state-sandbox.ts +710 -0
- package/src/state.ts +96 -0
- package/src/steer-inbox.ts +105 -0
- package/src/steer-tool.ts +83 -0
- package/src/steer.ts +146 -0
- package/src/task.ts +320 -0
- package/src/tools/bash.ts +143 -0
- package/src/tools/edit.ts +58 -0
- package/src/tools/glob.ts +56 -0
- package/src/tools/grep.ts +188 -0
- package/src/tools/read.ts +319 -0
- package/src/tools/tasks.ts +241 -0
- package/src/tools/webfetch.ts +368 -0
- package/src/tools/write.ts +42 -0
- package/src/walk.ts +112 -0
- package/src/watch-output.ts +104 -0
- package/src/web-fetch.ts +324 -0
- package/src/web-page.ts +179 -0
- package/src/workspace-io.ts +225 -0
- package/src/workspace.ts +41 -0
package/src/state.ts
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
// External-state declarations — the `agent/state/<name>.ts` file-per-capability
|
|
2
|
+
// convention from plans/dcosson/external-state.md. A declaration names what the
|
|
3
|
+
// agent's code depends on (interface, access, the author's visibility intent);
|
|
4
|
+
// which engine/store actually serves it is bound by the Zo control plane, never
|
|
5
|
+
// declared here. eve has no `state` discovery slot, so discovery is Zo-built end
|
|
6
|
+
// to end: deploy validation extracts the declaration with a strict literal
|
|
7
|
+
// parser (apps/api/src/state/declaration-parse.ts), which is why the argument to
|
|
8
|
+
// `defineExternalState` must stay a statically analyzable literal — no spreads,
|
|
9
|
+
// no computed values, no identifiers. The runtime (`@zo/state`, later) imports
|
|
10
|
+
// the module and reads the same object.
|
|
11
|
+
|
|
12
|
+
/** What the agent's code programs against. A small, closed, slowly growing set. */
|
|
13
|
+
export type StateInterface = "files" | "sql" | "kv" | "http" | "exec";
|
|
14
|
+
|
|
15
|
+
/** Read-only or read-write, as the code needs. */
|
|
16
|
+
export type StateAccess = "r" | "rw";
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* The visibility model the author designed for. Drives the zero-config default
|
|
20
|
+
* and consent copy; never a constraint — the data's owner can override it.
|
|
21
|
+
*/
|
|
22
|
+
export type StateIntent = "private" | "shared";
|
|
23
|
+
|
|
24
|
+
/** How instances subdivide. There is deliberately no `turn` (use eve's defineState). */
|
|
25
|
+
export type StatePartition = "none" | "team" | "user" | "session";
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* The author's preferred engine/partition/lifecycle, consumed at rung two of
|
|
29
|
+
* the binding resolution ladder. Hints, not constraints.
|
|
30
|
+
*/
|
|
31
|
+
export interface SuggestedStateDefaults {
|
|
32
|
+
/** Engine catalog key, e.g. "zo-blob-r2". Free-form: the catalog is control-plane code. */
|
|
33
|
+
readonly engine?: string;
|
|
34
|
+
readonly partition?: StatePartition;
|
|
35
|
+
/** Per-transition lifecycle overrides on the (engine, partition) defaults. */
|
|
36
|
+
readonly lifecycle?: Readonly<Record<string, string | number | boolean>>;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface ExternalStateDeclaration {
|
|
40
|
+
/**
|
|
41
|
+
* What tools reference at runtime. Must equal the declaring filename
|
|
42
|
+
* (`agent/state/<name>.ts`) — deploy validation enforces the match, which
|
|
43
|
+
* also makes duplicate names structurally impossible.
|
|
44
|
+
*/
|
|
45
|
+
readonly name: string;
|
|
46
|
+
readonly interface: StateInterface;
|
|
47
|
+
readonly access: StateAccess;
|
|
48
|
+
readonly intent: StateIntent;
|
|
49
|
+
readonly suggestedDefaults?: SuggestedStateDefaults;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Mirrors the deploy parser's name rule: lowercase, digit/underscore/hyphen tail. */
|
|
53
|
+
export const STATE_NAME_PATTERN = /^[a-z][a-z0-9_-]{0,63}$/;
|
|
54
|
+
|
|
55
|
+
const INTERFACES: readonly StateInterface[] = ["files", "sql", "kv", "http", "exec"];
|
|
56
|
+
const ACCESSES: readonly StateAccess[] = ["r", "rw"];
|
|
57
|
+
const INTENTS: readonly StateIntent[] = ["private", "shared"];
|
|
58
|
+
const PARTITIONS: readonly StatePartition[] = ["none", "team", "user", "session"];
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* The declaration helper an agent default-exports from `agent/state/<name>.ts`.
|
|
62
|
+
* Validates eagerly so a bad declaration fails at module load in local dev, not
|
|
63
|
+
* first at deploy; deploy validation re-checks the source statically regardless
|
|
64
|
+
* (it never executes agent code).
|
|
65
|
+
*/
|
|
66
|
+
export function defineExternalState(
|
|
67
|
+
declaration: ExternalStateDeclaration,
|
|
68
|
+
): ExternalStateDeclaration {
|
|
69
|
+
if (!STATE_NAME_PATTERN.test(declaration.name)) {
|
|
70
|
+
throw new Error(
|
|
71
|
+
`external-state declaration name "${declaration.name}" must match ${STATE_NAME_PATTERN} and equal the declaring filename`,
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
if (!INTERFACES.includes(declaration.interface)) {
|
|
75
|
+
throw new Error(
|
|
76
|
+
`external-state declaration "${declaration.name}": unknown interface "${String(declaration.interface)}" (expected ${INTERFACES.join(" | ")})`,
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
if (!ACCESSES.includes(declaration.access)) {
|
|
80
|
+
throw new Error(
|
|
81
|
+
`external-state declaration "${declaration.name}": unknown access "${String(declaration.access)}" (expected ${ACCESSES.join(" | ")})`,
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
if (!INTENTS.includes(declaration.intent)) {
|
|
85
|
+
throw new Error(
|
|
86
|
+
`external-state declaration "${declaration.name}": unknown intent "${String(declaration.intent)}" (expected ${INTENTS.join(" | ")})`,
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
const partition = declaration.suggestedDefaults?.partition;
|
|
90
|
+
if (partition !== undefined && !PARTITIONS.includes(partition)) {
|
|
91
|
+
throw new Error(
|
|
92
|
+
`external-state declaration "${declaration.name}": unknown suggested partition "${String(partition)}" (expected ${PARTITIONS.join(" | ")})`,
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
return Object.freeze(declaration);
|
|
96
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
// The steer inbox: a per-session NDJSON file under a caller-owned dir. Writers
|
|
2
|
+
// (a cockpit API route, a TUI) append one line per steered message; the tool
|
|
3
|
+
// wrapper (./steer-tool.ts) and the park-delivery hook (./hooks.ts) drain it.
|
|
4
|
+
// Drain is race-safe against concurrent appends: rename the live file to a
|
|
5
|
+
// temp name first, so an append that races the drain recreates the live file
|
|
6
|
+
// and nothing is lost — it just waits for the next drain.
|
|
7
|
+
|
|
8
|
+
import {
|
|
9
|
+
appendFileSync,
|
|
10
|
+
linkSync,
|
|
11
|
+
mkdirSync,
|
|
12
|
+
readFileSync,
|
|
13
|
+
renameSync,
|
|
14
|
+
rmSync,
|
|
15
|
+
} from "node:fs";
|
|
16
|
+
import { join } from "node:path";
|
|
17
|
+
import { parseSteerLine, serializeSteerLine, type SteerMessage } from "./steer";
|
|
18
|
+
|
|
19
|
+
export interface SteerInboxOptions {
|
|
20
|
+
/** Directory holding the per-session inbox files (created on first append). */
|
|
21
|
+
dir: string;
|
|
22
|
+
/** Injectable clock for tests; defaults to `Date.now`. */
|
|
23
|
+
now?: () => number;
|
|
24
|
+
/** Injectable id source for tests; defaults to `crypto.randomUUID`. */
|
|
25
|
+
newId?: () => string;
|
|
26
|
+
/** Injectable file reader for tests (drain's failure path); defaults to `readFileSync`. */
|
|
27
|
+
readFile?: (path: string) => string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface SteerInbox {
|
|
31
|
+
/** Queue a new steered message for a session; returns the stored message. */
|
|
32
|
+
append(sessionId: string, text: string): SteerMessage;
|
|
33
|
+
/** Re-queue an existing message (failed delivery), keeping its id and time. */
|
|
34
|
+
appendMessage(sessionId: string, message: SteerMessage): void;
|
|
35
|
+
/** Take every queued message for a session; `[]` when none are queued. */
|
|
36
|
+
drain(sessionId: string): SteerMessage[];
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Process-wide so two inbox instances over one dir can't collide on temp names.
|
|
40
|
+
let drainSequence = 0;
|
|
41
|
+
|
|
42
|
+
export function createSteerInbox(options: SteerInboxOptions): SteerInbox {
|
|
43
|
+
const now = options.now ?? Date.now;
|
|
44
|
+
const newId = options.newId ?? (() => crypto.randomUUID());
|
|
45
|
+
const readFile = options.readFile ?? ((path: string) => readFileSync(path, "utf8"));
|
|
46
|
+
|
|
47
|
+
// Session ids are opaque; encode so they can't traverse or collide as paths.
|
|
48
|
+
const fileFor = (sessionId: string) =>
|
|
49
|
+
join(options.dir, `${encodeURIComponent(sessionId)}.ndjson`);
|
|
50
|
+
|
|
51
|
+
function appendMessage(sessionId: string, message: SteerMessage): void {
|
|
52
|
+
mkdirSync(options.dir, { recursive: true });
|
|
53
|
+
appendFileSync(fileFor(sessionId), `${serializeSteerLine(message)}\n`, "utf8");
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return {
|
|
57
|
+
append(sessionId, text) {
|
|
58
|
+
const message: SteerMessage = { id: newId(), text, at: now() };
|
|
59
|
+
appendMessage(sessionId, message);
|
|
60
|
+
return message;
|
|
61
|
+
},
|
|
62
|
+
appendMessage,
|
|
63
|
+
drain(sessionId) {
|
|
64
|
+
const file = fileFor(sessionId);
|
|
65
|
+
const claimed = `${file}.drain-${process.pid}-${drainSequence++}`;
|
|
66
|
+
try {
|
|
67
|
+
renameSync(file, claimed);
|
|
68
|
+
} catch {
|
|
69
|
+
// Nothing queued (ENOENT) — or another drainer claimed it first.
|
|
70
|
+
return [];
|
|
71
|
+
}
|
|
72
|
+
let raw: string;
|
|
73
|
+
try {
|
|
74
|
+
raw = readFile(claimed);
|
|
75
|
+
} catch {
|
|
76
|
+
// The claim succeeded but the read failed (transient I/O). Put the
|
|
77
|
+
// claimed lines back under the live name so they survive for the next
|
|
78
|
+
// drain — deleting them here would drop user messages on the floor.
|
|
79
|
+
// linkSync (not renameSync) because rename clobbers: a racing append
|
|
80
|
+
// may have recreated the live file, and link atomically refuses
|
|
81
|
+
// (EEXIST) instead of overwriting the racer's messages.
|
|
82
|
+
try {
|
|
83
|
+
linkSync(claimed, file);
|
|
84
|
+
rmSync(claimed, { force: true });
|
|
85
|
+
} catch {
|
|
86
|
+
// Live file exists again — splice our lines in via append (the
|
|
87
|
+
// retry read may succeed where the first didn't).
|
|
88
|
+
try {
|
|
89
|
+
appendFileSync(file, readFileSync(claimed));
|
|
90
|
+
rmSync(claimed, { force: true });
|
|
91
|
+
} catch {
|
|
92
|
+
// Leave the claimed file on disk as a last resort — an orphaned
|
|
93
|
+
// temp file beats silently dropped user messages.
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
return [];
|
|
97
|
+
}
|
|
98
|
+
rmSync(claimed, { force: true });
|
|
99
|
+
return raw
|
|
100
|
+
.split("\n")
|
|
101
|
+
.map(parseSteerLine)
|
|
102
|
+
.filter((message): message is SteerMessage => message !== null);
|
|
103
|
+
},
|
|
104
|
+
};
|
|
105
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
// The delivery half of steering: wrap a tool so that when its execute
|
|
2
|
+
// resolves, any steered messages queued for the session ride out on the
|
|
3
|
+
// result — the model reads them before its next inference step (eve has no
|
|
4
|
+
// pre-inference injection; see ./steer.ts). Two seams per tool:
|
|
5
|
+
//
|
|
6
|
+
// - `execute`: after the original resolves, drain the session's inbox and
|
|
7
|
+
// attach the messages to the raw output (UI clients read them off
|
|
8
|
+
// `action.result` events). Only successful results participate — a thrown
|
|
9
|
+
// error is eve-owned and can't carry fields.
|
|
10
|
+
// - `toModelOutput`: a tool's own projection narrows the raw result, which
|
|
11
|
+
// would silently drop the attached field (read strips unknown keys, for
|
|
12
|
+
// example). The wrapper strips the steer, delegates to the original
|
|
13
|
+
// projection, then re-merges — text outputs get the rendered block
|
|
14
|
+
// appended, json outputs get the field re-attached.
|
|
15
|
+
//
|
|
16
|
+
// eve's defineTool brands the definition object, so the wrapper re-stamps a
|
|
17
|
+
// copy instead of mutating the original.
|
|
18
|
+
|
|
19
|
+
import { defineTool, type ToolDefinition, type ToolModelOutput } from "eve/tools";
|
|
20
|
+
import {
|
|
21
|
+
attachSteerToOutput,
|
|
22
|
+
mergeSteerIntoModelOutput,
|
|
23
|
+
readSteerMessages,
|
|
24
|
+
stripSteerFromOutput,
|
|
25
|
+
} from "./steer";
|
|
26
|
+
import type { SteerInbox } from "./steer-inbox";
|
|
27
|
+
|
|
28
|
+
/** The drain-side subset of the inbox the wrapper needs. */
|
|
29
|
+
export type SteerSource = Pick<SteerInbox, "drain">;
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Wrap one tool with steer delivery. Structurally output-preserving for
|
|
33
|
+
* record outputs (the steer field spreads in alongside the tool's own keys);
|
|
34
|
+
* a non-record output is wrapped when steers are pending, which the
|
|
35
|
+
* `toModelOutput` seam undoes before the tool's own projection runs.
|
|
36
|
+
*/
|
|
37
|
+
export function withSteerDelivery<TInput, TOutput>(
|
|
38
|
+
tool: ToolDefinition<TInput, TOutput>,
|
|
39
|
+
inbox: SteerSource,
|
|
40
|
+
): ToolDefinition<TInput, TOutput> {
|
|
41
|
+
const originalToModelOutput = tool.toModelOutput?.bind(tool);
|
|
42
|
+
|
|
43
|
+
const wrapped: ToolDefinition<TInput, TOutput> = {
|
|
44
|
+
...tool,
|
|
45
|
+
async execute(input, ctx) {
|
|
46
|
+
const output = await tool.execute(input, ctx);
|
|
47
|
+
const messages = inbox.drain(ctx.session.id);
|
|
48
|
+
if (messages.length === 0) return output;
|
|
49
|
+
// The one type-level concession: an attached record output is TOutput
|
|
50
|
+
// plus the steer field; a non-record output becomes the recoverable
|
|
51
|
+
// wrapper shape. eve treats tool outputs as unknown on the wire, and
|
|
52
|
+
// the toModelOutput wrapper below recovers the original before the
|
|
53
|
+
// tool's own projection sees it.
|
|
54
|
+
return attachSteerToOutput(output, messages) as TOutput;
|
|
55
|
+
},
|
|
56
|
+
...(originalToModelOutput
|
|
57
|
+
? {
|
|
58
|
+
async toModelOutput(output: TOutput): Promise<ToolModelOutput> {
|
|
59
|
+
const messages = readSteerMessages(output);
|
|
60
|
+
if (!messages) return originalToModelOutput(output);
|
|
61
|
+
const original = stripSteerFromOutput(
|
|
62
|
+
// readSteerMessages returning non-null proves output is a record.
|
|
63
|
+
output as Record<string, unknown>,
|
|
64
|
+
) as TOutput;
|
|
65
|
+
const narrowed = await originalToModelOutput(original);
|
|
66
|
+
return mergeSteerIntoModelOutput(narrowed, messages);
|
|
67
|
+
},
|
|
68
|
+
}
|
|
69
|
+
: {}),
|
|
70
|
+
};
|
|
71
|
+
return defineTool(wrapped);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* A conditional wrapper for wiring call sites: identity when no inbox is
|
|
76
|
+
* configured, `withSteerDelivery` otherwise.
|
|
77
|
+
*/
|
|
78
|
+
export function createSteerWrapper(
|
|
79
|
+
inbox: SteerSource | null,
|
|
80
|
+
): <TInput, TOutput>(tool: ToolDefinition<TInput, TOutput>) => ToolDefinition<TInput, TOutput> {
|
|
81
|
+
if (!inbox) return (tool) => tool;
|
|
82
|
+
return (tool) => withSteerDelivery(tool, inbox);
|
|
83
|
+
}
|
package/src/steer.ts
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
// The steer contract: pure types and helpers for delivering a user's
|
|
2
|
+
// mid-turn messages to the model as soon as possible. eve has no
|
|
3
|
+
// pre-inference injection — hooks are observe-only for model context, and
|
|
4
|
+
// send() rejects while a turn is running — so a steered message rides the
|
|
5
|
+
// next completing tool result instead (./steer-tool.ts attaches it under
|
|
6
|
+
// STEER_FIELD), with leftovers a turn ends before delivering going out on
|
|
7
|
+
// park (./hooks.ts). Dependency-free and exported at the
|
|
8
|
+
// `@zocomputer/agent-sdk/steer` subpath so UI clients (which write the inbox
|
|
9
|
+
// and read `user_steer` off `action.result` events) consume it without the
|
|
10
|
+
// extraction deps.
|
|
11
|
+
|
|
12
|
+
/** Result key carrying steered messages on a tool output. */
|
|
13
|
+
export const STEER_FIELD = "user_steer";
|
|
14
|
+
|
|
15
|
+
/** Key the original output hides under when it isn't a record. */
|
|
16
|
+
export const STEER_WRAPPED_OUTPUT_FIELD = "steer_wrapped_output";
|
|
17
|
+
|
|
18
|
+
/** Conventional inbox dirname under an agent's state dir. */
|
|
19
|
+
export const STEER_DIRNAME = "steer";
|
|
20
|
+
|
|
21
|
+
/** The note the model reads alongside steered messages. */
|
|
22
|
+
export const STEER_NOTE =
|
|
23
|
+
"The user sent these messages while this tool was running. They take priority: address them now and adjust your current approach before continuing.";
|
|
24
|
+
|
|
25
|
+
/** One steered message, as queued by a UI and delivered to the model. */
|
|
26
|
+
export interface SteerMessage {
|
|
27
|
+
/** Unique id — the park-delivery dedupe key (`steer:<id>`). */
|
|
28
|
+
id: string;
|
|
29
|
+
/** The user's message, verbatim. */
|
|
30
|
+
text: string;
|
|
31
|
+
/** Queue time (epoch ms). */
|
|
32
|
+
at: number;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** What rides under STEER_FIELD on a tool output. */
|
|
36
|
+
export interface SteerPayload {
|
|
37
|
+
note: string;
|
|
38
|
+
messages: SteerMessage[];
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function buildSteerPayload(messages: readonly SteerMessage[]): SteerPayload {
|
|
42
|
+
return { note: STEER_NOTE, messages: [...messages] };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
46
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function isSteerMessage(value: unknown): value is SteerMessage {
|
|
50
|
+
return (
|
|
51
|
+
isRecord(value) &&
|
|
52
|
+
typeof value.id === "string" &&
|
|
53
|
+
typeof value.text === "string" &&
|
|
54
|
+
typeof value.at === "number"
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Attach steered messages to a tool output. A record output keeps its own
|
|
60
|
+
* keys with the payload spread in alongside (merging with any messages a
|
|
61
|
+
* previous attach already carried); a non-record output is wrapped so
|
|
62
|
+
* `stripSteerFromOutput` can recover it exactly.
|
|
63
|
+
*/
|
|
64
|
+
export function attachSteerToOutput(
|
|
65
|
+
output: unknown,
|
|
66
|
+
messages: readonly SteerMessage[],
|
|
67
|
+
): Record<string, unknown> {
|
|
68
|
+
if (isRecord(output)) {
|
|
69
|
+
const existing = readSteerMessages(output) ?? [];
|
|
70
|
+
return { ...output, [STEER_FIELD]: buildSteerPayload([...existing, ...messages]) };
|
|
71
|
+
}
|
|
72
|
+
return {
|
|
73
|
+
[STEER_WRAPPED_OUTPUT_FIELD]: output,
|
|
74
|
+
[STEER_FIELD]: buildSteerPayload(messages),
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Inverse of `attachSteerToOutput`: drop the steer field and unwrap a
|
|
80
|
+
* non-record original. Only unwraps when the wrapper key is the sole
|
|
81
|
+
* remaining key, so a record that legitimately contains it survives.
|
|
82
|
+
*/
|
|
83
|
+
export function stripSteerFromOutput(record: Record<string, unknown>): unknown {
|
|
84
|
+
const { [STEER_FIELD]: _steer, ...rest } = record;
|
|
85
|
+
const keys = Object.keys(rest);
|
|
86
|
+
if (keys.length === 1 && keys[0] === STEER_WRAPPED_OUTPUT_FIELD) {
|
|
87
|
+
return rest[STEER_WRAPPED_OUTPUT_FIELD];
|
|
88
|
+
}
|
|
89
|
+
return rest;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Read the steered messages off a tool output, structurally validated.
|
|
94
|
+
* Returns null when the output isn't a record, carries no steer payload, or
|
|
95
|
+
* the payload holds no well-formed message; malformed entries are dropped.
|
|
96
|
+
*/
|
|
97
|
+
export function readSteerMessages(output: unknown): SteerMessage[] | null {
|
|
98
|
+
if (!isRecord(output)) return null;
|
|
99
|
+
const payload = output[STEER_FIELD];
|
|
100
|
+
if (!isRecord(payload) || !Array.isArray(payload.messages)) return null;
|
|
101
|
+
const messages = payload.messages.filter(isSteerMessage);
|
|
102
|
+
return messages.length > 0 ? messages : null;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Render steered messages as a text block (for text-mode model outputs). */
|
|
106
|
+
export function formatSteerText(messages: readonly SteerMessage[]): string {
|
|
107
|
+
const lines = messages.map((message) => `- ${message.text}`);
|
|
108
|
+
return `[${STEER_FIELD}] ${STEER_NOTE}\n${lines.join("\n")}`;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** The shape eve's `toModelOutput` produces — mirrored here to stay dependency-free. */
|
|
112
|
+
export type SteerModelOutput =
|
|
113
|
+
| { type: "text"; value: string }
|
|
114
|
+
| { type: "json"; value: unknown };
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Merge steered messages into a tool's already-narrowed model output: text
|
|
118
|
+
* outputs get the rendered block appended, json outputs get the field
|
|
119
|
+
* re-attached.
|
|
120
|
+
*/
|
|
121
|
+
export function mergeSteerIntoModelOutput(
|
|
122
|
+
output: SteerModelOutput,
|
|
123
|
+
messages: readonly SteerMessage[],
|
|
124
|
+
): SteerModelOutput {
|
|
125
|
+
if (output.type === "text") {
|
|
126
|
+
return { type: "text", value: `${output.value}\n\n${formatSteerText(messages)}` };
|
|
127
|
+
}
|
|
128
|
+
return { type: "json", value: attachSteerToOutput(output.value, messages) };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** Serialize one message as an NDJSON line (no trailing newline). */
|
|
132
|
+
export function serializeSteerLine(message: SteerMessage): string {
|
|
133
|
+
return JSON.stringify(message);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** Parse one NDJSON line; null on blank or malformed input. */
|
|
137
|
+
export function parseSteerLine(line: string): SteerMessage | null {
|
|
138
|
+
const trimmed = line.trim();
|
|
139
|
+
if (trimmed === "") return null;
|
|
140
|
+
try {
|
|
141
|
+
const parsed: unknown = JSON.parse(trimmed);
|
|
142
|
+
return isSteerMessage(parsed) ? parsed : null;
|
|
143
|
+
} catch {
|
|
144
|
+
return null;
|
|
145
|
+
}
|
|
146
|
+
}
|