@code-yeongyu/senpi-codemode 2026.7.25-2

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.
Files changed (63) hide show
  1. package/CHANGELOG.md +250 -0
  2. package/LICENSE +22 -0
  3. package/README.md +161 -0
  4. package/package.json +58 -0
  5. package/src/bridge/http-server.ts +236 -0
  6. package/src/bridge/protocol.ts +198 -0
  7. package/src/bridge/reserved.ts +9 -0
  8. package/src/bridges/agent-bridge.ts +197 -0
  9. package/src/bridges/output-bridge.ts +96 -0
  10. package/src/bridges/schema-injection.ts +3 -0
  11. package/src/codemode/runtime.ts +258 -0
  12. package/src/codemode/tools.ts +106 -0
  13. package/src/completion/handler.ts +192 -0
  14. package/src/completion/tool-bridge.ts +55 -0
  15. package/src/config/settings.ts +215 -0
  16. package/src/extension/runtime-factory.ts +114 -0
  17. package/src/extension/session-manager-proxy.ts +116 -0
  18. package/src/extension/session-manager.ts +215 -0
  19. package/src/host-sdk.ts +1 -0
  20. package/src/index.ts +181 -0
  21. package/src/interpreters/detect.ts +161 -0
  22. package/src/kernels/jl/kernel.ts +37 -0
  23. package/src/kernels/jl/prelude.jl +283 -0
  24. package/src/kernels/jl/runner.jl +327 -0
  25. package/src/kernels/js/context-manager.ts +296 -0
  26. package/src/kernels/js/inline-worker-entry.js +23 -0
  27. package/src/kernels/js/inline-worker.ts +15 -0
  28. package/src/kernels/js/kernel-contract.ts +38 -0
  29. package/src/kernels/js/local-module-loader.ts +108 -0
  30. package/src/kernels/js/prelude.ts +15 -0
  31. package/src/kernels/js/rewrite-imports.ts +164 -0
  32. package/src/kernels/js/run-queue.ts +82 -0
  33. package/src/kernels/js/worker-core.d.ts +18 -0
  34. package/src/kernels/js/worker-core.js +94 -0
  35. package/src/kernels/js/worker-entry.js +23 -0
  36. package/src/kernels/js/worker-host.ts +117 -0
  37. package/src/kernels/js/worker-indirect-eval.js +88 -0
  38. package/src/kernels/js/worker-runtime.js +401 -0
  39. package/src/kernels/py/kernel-contract.ts +32 -0
  40. package/src/kernels/py/kernel.ts +290 -0
  41. package/src/kernels/py/prelude.py +954 -0
  42. package/src/kernels/py/process.ts +119 -0
  43. package/src/kernels/py/transport.ts +237 -0
  44. package/src/kernels/rb/kernel.ts +26 -0
  45. package/src/kernels/rb/prelude.rb +270 -0
  46. package/src/kernels/rb/runner.rb +204 -0
  47. package/src/kernels/shared/subprocess-contract.ts +22 -0
  48. package/src/kernels/shared/subprocess-kernel.ts +266 -0
  49. package/src/kernels/shared/subprocess-process.ts +174 -0
  50. package/src/kernels/shared/subprocess-queue.ts +101 -0
  51. package/src/kernels/shared/subprocess-run.ts +98 -0
  52. package/src/output/output-meta.ts +89 -0
  53. package/src/output/streaming-output.ts +296 -0
  54. package/src/prompt/eval-prompt.ts +319 -0
  55. package/src/timeouts/bridge-timeout.ts +16 -0
  56. package/src/timeouts/idle-timeout.ts +84 -0
  57. package/src/tool/cell-handler.ts +279 -0
  58. package/src/tool/eval-tool.ts +285 -0
  59. package/src/tool/image.ts +274 -0
  60. package/src/tool/json-tree.ts +247 -0
  61. package/src/tool/render.ts +876 -0
  62. package/src/tool/status-events.ts +12 -0
  63. package/src/tool/types.ts +114 -0
@@ -0,0 +1,236 @@
1
+ import { once } from "node:events";
2
+ import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
3
+ import type { AddressInfo, Socket } from "node:net";
4
+ import { type BridgeError, generateBridgeToken, verifyBridgeToken } from "./protocol.ts";
5
+
6
+ const DEFAULT_BODY_LIMIT_BYTES = 1024 * 1024;
7
+ const LOOPBACK_HOST = "127.0.0.1";
8
+
9
+ export interface BridgeHttpCallRequest {
10
+ callId: string;
11
+ toolName: string;
12
+ args: unknown;
13
+ signal: AbortSignal;
14
+ }
15
+
16
+ export interface BridgeHttpCompletionRequest {
17
+ prompt: string;
18
+ opts?: unknown;
19
+ signal: AbortSignal;
20
+ }
21
+
22
+ export type BridgeHttpEmitEvent =
23
+ | { kind: "text"; stream: "stdout" | "stderr"; data: string }
24
+ | { kind: "display"; mimeType: string; dataBase64: string }
25
+ | { kind: "log"; message: string }
26
+ | { kind: "phase"; title: string };
27
+
28
+ export interface BridgeServerOptions {
29
+ token?: string;
30
+ bodyLimitBytes?: number;
31
+ onCall: (request: BridgeHttpCallRequest) => Promise<unknown>;
32
+ onEmit: (event: BridgeHttpEmitEvent, signal: AbortSignal) => Promise<void>;
33
+ onCompletion: (request: BridgeHttpCompletionRequest) => Promise<unknown>;
34
+ }
35
+
36
+ export interface BridgeServerHandle {
37
+ port: number;
38
+ token: string;
39
+ close: () => Promise<void>;
40
+ }
41
+
42
+ type JsonReply = { ok: true; value: unknown } | { ok: false; error: BridgeError };
43
+ type Route = "/call" | "/emit" | "/completion";
44
+
45
+ export async function startBridgeServer(options: BridgeServerOptions): Promise<BridgeServerHandle> {
46
+ const token = options.token ?? generateBridgeToken();
47
+ const sockets = new Set<Socket>();
48
+ let closing: Promise<void> | undefined;
49
+ const server = createServer((request, response) => {
50
+ void handleRequest(request, response, token, options);
51
+ });
52
+ server.on("connection", (socket) => {
53
+ sockets.add(socket);
54
+ socket.on("close", () => sockets.delete(socket));
55
+ });
56
+ server.listen(0, LOOPBACK_HOST);
57
+ await once(server, "listening");
58
+ const address = server.address();
59
+ if (!address || typeof address === "string") throw new Error("Bridge server did not bind to a TCP port");
60
+
61
+ return {
62
+ port: (address as AddressInfo).port,
63
+ token,
64
+ close: async () => {
65
+ closing ??= closeServer(server, sockets);
66
+ await closing;
67
+ },
68
+ };
69
+ }
70
+
71
+ async function handleRequest(
72
+ request: IncomingMessage,
73
+ response: ServerResponse,
74
+ token: string,
75
+ options: BridgeServerOptions,
76
+ ): Promise<void> {
77
+ const abortController = new AbortController();
78
+ request.on("close", () => abortController.abort());
79
+ if (request.method !== "POST") {
80
+ sendJson(response, 404, { ok: false, error: transportError("not_found", "Bridge route was not found") });
81
+ return;
82
+ }
83
+ const route = routeFromUrl(request.url ?? "");
84
+ if (!route) {
85
+ sendJson(response, 404, { ok: false, error: transportError("not_found", "Bridge route was not found") });
86
+ return;
87
+ }
88
+ const auth = parseBearerToken(request.headers.authorization);
89
+ if (!auth || !verifyBridgeToken(token, auth).ok) {
90
+ sendJson(response, 401, { ok: false, error: transportError("unauthorized", "Bridge authorization failed") });
91
+ return;
92
+ }
93
+
94
+ const parsed = await readJsonBody(request, options.bodyLimitBytes ?? DEFAULT_BODY_LIMIT_BYTES);
95
+ if (!parsed.ok) {
96
+ sendJson(response, parsed.status, { ok: false, error: transportError(parsed.code, parsed.message) });
97
+ return;
98
+ }
99
+
100
+ if (route === "/emit") {
101
+ const event = parseEmitEvent(parsed.value);
102
+ if (!event.ok) {
103
+ sendJson(response, 400, { ok: false, error: transportError("invalid_request", event.message) });
104
+ return;
105
+ }
106
+ try {
107
+ await options.onEmit(event.event, abortController.signal);
108
+ response.writeHead(204).end();
109
+ } catch (error) {
110
+ sendJson(response, 200, { ok: false, error: bridgeError(error) });
111
+ }
112
+ return;
113
+ }
114
+
115
+ const reply =
116
+ route === "/call"
117
+ ? await dispatchCall(parsed.value, options, abortController.signal)
118
+ : await dispatchCompletion(parsed.value, options, abortController.signal);
119
+ sendJson(response, 200, reply);
120
+ }
121
+
122
+ async function dispatchCall(body: unknown, options: BridgeServerOptions, signal: AbortSignal): Promise<JsonReply> {
123
+ if (!isRecord(body) || typeof body.callId !== "string" || typeof body.toolName !== "string" || !("args" in body)) {
124
+ return { ok: false, error: transportError("invalid_request", "Bridge call request was invalid") };
125
+ }
126
+ try {
127
+ return {
128
+ ok: true,
129
+ value: await options.onCall({ callId: body.callId, toolName: body.toolName, args: body.args, signal }),
130
+ };
131
+ } catch (error) {
132
+ return { ok: false, error: bridgeError(error) };
133
+ }
134
+ }
135
+
136
+ async function dispatchCompletion(
137
+ body: unknown,
138
+ options: BridgeServerOptions,
139
+ signal: AbortSignal,
140
+ ): Promise<JsonReply> {
141
+ if (!isRecord(body) || typeof body.prompt !== "string") {
142
+ return { ok: false, error: transportError("invalid_request", "Bridge completion request was invalid") };
143
+ }
144
+ try {
145
+ return { ok: true, value: await options.onCompletion({ prompt: body.prompt, opts: body.opts, signal }) };
146
+ } catch (error) {
147
+ return { ok: false, error: bridgeError(error) };
148
+ }
149
+ }
150
+
151
+ async function readJsonBody(
152
+ request: IncomingMessage,
153
+ limit: number,
154
+ ): Promise<{ ok: true; value: unknown } | { ok: false; status: number; code: string; message: string }> {
155
+ let raw = "";
156
+ for await (const chunk of request) {
157
+ raw += String(chunk);
158
+ if (Buffer.byteLength(raw, "utf8") > limit) {
159
+ return {
160
+ ok: false,
161
+ status: 413,
162
+ code: "body_too_large",
163
+ message: `Bridge request body exceeds ${limit} bytes`,
164
+ };
165
+ }
166
+ }
167
+ try {
168
+ return { ok: true, value: JSON.parse(raw) as unknown };
169
+ } catch {
170
+ return { ok: false, status: 400, code: "invalid_json", message: "Bridge request body was not valid JSON" };
171
+ }
172
+ }
173
+
174
+ function parseEmitEvent(value: unknown): { ok: true; event: BridgeHttpEmitEvent } | { ok: false; message: string } {
175
+ if (!isRecord(value) || typeof value.kind !== "string")
176
+ return { ok: false, message: "Bridge emit request was invalid" };
177
+ if (
178
+ value.kind === "text" &&
179
+ (value.stream === "stdout" || value.stream === "stderr") &&
180
+ typeof value.data === "string"
181
+ ) {
182
+ return { ok: true, event: { kind: value.kind, stream: value.stream, data: value.data } };
183
+ }
184
+ if (value.kind === "display" && typeof value.mimeType === "string" && typeof value.dataBase64 === "string") {
185
+ return { ok: true, event: { kind: value.kind, mimeType: value.mimeType, dataBase64: value.dataBase64 } };
186
+ }
187
+ if (value.kind === "log" && typeof value.message === "string") {
188
+ return { ok: true, event: { kind: value.kind, message: value.message } };
189
+ }
190
+ if (value.kind === "phase" && typeof value.title === "string") {
191
+ return { ok: true, event: { kind: value.kind, title: value.title } };
192
+ }
193
+ return { ok: false, message: "Bridge emit request was invalid" };
194
+ }
195
+
196
+ function routeFromUrl(rawUrl: string): Route | undefined {
197
+ const path = new URL(rawUrl, "http://127.0.0.1").pathname;
198
+ if (path === "/call" || path === "/emit" || path === "/completion") return path;
199
+ return undefined;
200
+ }
201
+
202
+ function parseBearerToken(header: string | undefined): string | undefined {
203
+ const prefix = "Bearer ";
204
+ if (!header?.startsWith(prefix)) return undefined;
205
+ return header.slice(prefix.length);
206
+ }
207
+
208
+ function sendJson(response: ServerResponse, status: number, body: JsonReply): void {
209
+ if (response.destroyed) return;
210
+ response.writeHead(status, { "content-type": "application/json; charset=utf-8" });
211
+ response.end(JSON.stringify(body));
212
+ }
213
+
214
+ function bridgeError(error: unknown): BridgeError {
215
+ if (error instanceof Error) return { name: error.name, message: error.message, stack: error.stack };
216
+ return { message: String(error) };
217
+ }
218
+
219
+ function transportError(code: string, message: string): BridgeError {
220
+ return { code, message };
221
+ }
222
+
223
+ function isRecord(value: unknown): value is Record<string, unknown> {
224
+ return typeof value === "object" && value !== null;
225
+ }
226
+
227
+ async function closeServer(server: ReturnType<typeof createServer>, sockets: Set<Socket>): Promise<void> {
228
+ server.closeAllConnections?.();
229
+ for (const socket of sockets) socket.destroy();
230
+ await new Promise<void>((resolve, reject) => {
231
+ server.close((error) => {
232
+ if (error && "code" in error && error.code !== "ERR_SERVER_NOT_RUNNING") reject(error);
233
+ else resolve();
234
+ });
235
+ });
236
+ }
@@ -0,0 +1,198 @@
1
+ import { createHash, randomBytes, randomUUID, timingSafeEqual } from "node:crypto";
2
+ import { type Static, Type } from "typebox";
3
+ import { Value } from "typebox/value";
4
+
5
+ export const BRIDGE_FRAME_MAX_BYTES = 10 * 1024 * 1024;
6
+
7
+ const bridgeErrorSchema = Type.Object({
8
+ name: Type.Optional(Type.String()),
9
+ message: Type.String(),
10
+ stack: Type.Optional(Type.String()),
11
+ code: Type.Optional(Type.String()),
12
+ });
13
+
14
+ const statusEventSchema = Type.Object({ op: Type.String() }, { additionalProperties: true });
15
+
16
+ const connectionConfigSchema = Type.Object({
17
+ port: Type.Integer({ minimum: 1, maximum: 65_535 }),
18
+ token: Type.String({ minLength: 1 }),
19
+ localRoots: Type.Optional(Type.Record(Type.String(), Type.String())),
20
+ artifactsDir: Type.Optional(Type.String()),
21
+ parallelPoolWidth: Type.Optional(Type.Integer({ minimum: 1 })),
22
+ });
23
+
24
+ const hostToKernelMessageSchema = Type.Union([
25
+ Type.Object({
26
+ type: Type.Literal("init"),
27
+ sessionId: Type.String({ minLength: 1 }),
28
+ connection: connectionConfigSchema,
29
+ }),
30
+ Type.Object({
31
+ type: Type.Literal("run"),
32
+ cellId: Type.String({ minLength: 1 }),
33
+ code: Type.String(),
34
+ timeoutMs: Type.Optional(Type.Integer({ minimum: 1 })),
35
+ }),
36
+ Type.Object({
37
+ type: Type.Literal("tool-reply"),
38
+ callId: Type.String({ minLength: 1 }),
39
+ ok: Type.Literal(true),
40
+ value: Type.Unknown(),
41
+ }),
42
+ Type.Object({
43
+ type: Type.Literal("tool-reply"),
44
+ callId: Type.String({ minLength: 1 }),
45
+ ok: Type.Literal(false),
46
+ error: bridgeErrorSchema,
47
+ }),
48
+ Type.Object({
49
+ type: Type.Literal("interrupt"),
50
+ reason: Type.Optional(Type.String()),
51
+ }),
52
+ Type.Object({
53
+ type: Type.Literal("close"),
54
+ }),
55
+ ]);
56
+
57
+ const kernelToHostMessageSchema = Type.Union([
58
+ Type.Object({ type: Type.Literal("ready") }),
59
+ Type.Object({ type: Type.Literal("init-failed"), error: bridgeErrorSchema }),
60
+ Type.Object({
61
+ type: Type.Literal("text"),
62
+ stream: Type.Union([Type.Literal("stdout"), Type.Literal("stderr")]),
63
+ data: Type.String(),
64
+ }),
65
+ Type.Object({
66
+ type: Type.Literal("display"),
67
+ mimeType: Type.String({ minLength: 1 }),
68
+ dataBase64: Type.String(),
69
+ }),
70
+ Type.Object({
71
+ type: Type.Literal("tool-call"),
72
+ callId: Type.String({ minLength: 1 }),
73
+ toolName: Type.String({ minLength: 1 }),
74
+ args: Type.Unknown(),
75
+ }),
76
+ Type.Object({ type: Type.Literal("log"), message: Type.String() }),
77
+ Type.Object({ type: Type.Literal("phase"), title: Type.String() }),
78
+ Type.Object({ type: Type.Literal("status"), event: statusEventSchema }),
79
+ Type.Object({
80
+ type: Type.Literal("result"),
81
+ cellId: Type.String({ minLength: 1 }),
82
+ ok: Type.Literal(true),
83
+ valueRepr: Type.Optional(Type.String()),
84
+ durationMs: Type.Integer({ minimum: 0 }),
85
+ }),
86
+ Type.Object({
87
+ type: Type.Literal("result"),
88
+ cellId: Type.String({ minLength: 1 }),
89
+ ok: Type.Literal(false),
90
+ error: bridgeErrorSchema,
91
+ durationMs: Type.Integer({ minimum: 0 }),
92
+ }),
93
+ Type.Object({ type: Type.Literal("closed") }),
94
+ ]);
95
+
96
+ const bridgeMessageSchema = Type.Union([hostToKernelMessageSchema, kernelToHostMessageSchema]);
97
+
98
+ export type EvalStatusEvent = { op: string } & Record<string, unknown>;
99
+ export type BridgeError = Static<typeof bridgeErrorSchema>;
100
+ export type BridgeConnectionConfig = Static<typeof connectionConfigSchema>;
101
+ export type HostToKernelMessage = Static<typeof hostToKernelMessageSchema>;
102
+ type KernelToHostMessageSchema = Static<typeof kernelToHostMessageSchema>;
103
+ type BridgeMessageSchema = Static<typeof bridgeMessageSchema>;
104
+ export type KernelToHostMessage =
105
+ | Exclude<KernelToHostMessageSchema, { type: "status" }>
106
+ | { type: "status"; event: EvalStatusEvent };
107
+ export type BridgeMessage =
108
+ | Exclude<BridgeMessageSchema, { type: "status" }>
109
+ | { type: "status"; event: EvalStatusEvent };
110
+
111
+ export type BridgeDecodeErrorCode =
112
+ | "empty_frame"
113
+ | "frame_too_large"
114
+ | "multiple_frames"
115
+ | "malformed_json"
116
+ | "invalid_message";
117
+
118
+ export interface BridgeDecodeError {
119
+ code: BridgeDecodeErrorCode;
120
+ message: string;
121
+ }
122
+
123
+ export type BridgeDecodeResult<T> = { ok: true; value: T } | { ok: false; error: BridgeDecodeError };
124
+ export type BridgeMessageDecodeResult = { ok: true; message: BridgeMessage } | { ok: false; error: BridgeDecodeError };
125
+
126
+ export type BridgeActivityReply =
127
+ | { state: "active"; sessionId: string }
128
+ | { state: "inactive"; sessionId?: string }
129
+ | { state: "blocked"; reason: string }
130
+ | { state: "error"; error: BridgeError };
131
+
132
+ export type BridgeTokenVerifyResult = { ok: true } | { ok: false; error: { code: "token_mismatch"; message: string } };
133
+
134
+ export interface BridgeFrameOptions {
135
+ maxBytes?: number;
136
+ }
137
+
138
+ export function generateCorrelationId(): string {
139
+ return randomUUID();
140
+ }
141
+
142
+ export function generateBridgeToken(byteLength = 32): string {
143
+ return randomBytes(byteLength).toString("base64url");
144
+ }
145
+
146
+ export function verifyBridgeToken(expected: string, received: string): BridgeTokenVerifyResult {
147
+ const expectedHash = tokenHash(expected);
148
+ const receivedHash = tokenHash(received);
149
+ if (timingSafeEqual(expectedHash, receivedHash)) return { ok: true };
150
+ return { ok: false, error: { code: "token_mismatch", message: "Bridge bearer token did not match" } };
151
+ }
152
+
153
+ export function encodeBridgeFrame(message: BridgeMessage): string {
154
+ return `${JSON.stringify(message)}\n`;
155
+ }
156
+
157
+ export function parseBridgeJsonLine(line: string, options: BridgeFrameOptions = {}): BridgeDecodeResult<unknown> {
158
+ const maxBytes = options.maxBytes ?? BRIDGE_FRAME_MAX_BYTES;
159
+ const byteLength = Buffer.byteLength(line, "utf8");
160
+ if (byteLength > maxBytes) {
161
+ return { ok: false, error: { code: "frame_too_large", message: `Bridge frame exceeds ${maxBytes} bytes` } };
162
+ }
163
+ const trimmedLine = line.endsWith("\n") ? line.slice(0, -1) : line;
164
+ if (trimmedLine.length === 0) {
165
+ return { ok: false, error: { code: "empty_frame", message: "Bridge frame was empty" } };
166
+ }
167
+ if (trimmedLine.includes("\n")) {
168
+ return {
169
+ ok: false,
170
+ error: { code: "multiple_frames", message: "Bridge frame contained more than one LF record" },
171
+ };
172
+ }
173
+ try {
174
+ return { ok: true, value: JSON.parse(trimmedLine) };
175
+ } catch (error) {
176
+ const message = error instanceof Error ? error.message : "Invalid JSON bridge frame";
177
+ return { ok: false, error: { code: "malformed_json", message } };
178
+ }
179
+ }
180
+
181
+ export function decodeBridgeFrame(line: string, options: BridgeFrameOptions = {}): BridgeMessageDecodeResult {
182
+ const parsed = parseBridgeJsonLine(line, options);
183
+ if (!parsed.ok) return parsed;
184
+ if (Value.Check(bridgeMessageSchema, parsed.value)) {
185
+ return { ok: true, message: parsed.value };
186
+ }
187
+ const firstError = Value.Errors(bridgeMessageSchema, parsed.value)[0];
188
+ const message = firstError ? `Invalid bridge message: ${firstError.message}` : "Invalid bridge message";
189
+ return { ok: false, error: { code: "invalid_message", message } };
190
+ }
191
+
192
+ export function isKernelToHostMessage(message: BridgeMessage): message is KernelToHostMessage {
193
+ return Value.Check(kernelToHostMessageSchema, message);
194
+ }
195
+
196
+ function tokenHash(token: string): Buffer {
197
+ return createHash("sha256").update(token, "utf8").digest();
198
+ }
@@ -0,0 +1,9 @@
1
+ /** Cross-kernel bridge names shared by preludes and host adapters. */
2
+ /** Canonical oh-my-pi agent bridge tool name. */
3
+ export const RESERVED_AGENT_TOOL = "__agent__" as const;
4
+ /** ADAPTATION: senpi delegates output through a reserved kernel-side tool name. */
5
+ export const RESERVED_OUTPUT_TOOL = "__output__" as const;
6
+ /** Canonical oh-my-pi eval-timeout pause operation. */
7
+ export const TIMEOUT_PAUSE_OP = "timeout-pause" as const;
8
+ /** Canonical oh-my-pi eval-timeout resume operation. */
9
+ export const TIMEOUT_RESUME_OP = "timeout-resume" as const;
@@ -0,0 +1,197 @@
1
+ import type { AgentToolResult } from "@code-yeongyu/senpi";
2
+ import { type Static, Type } from "typebox";
3
+ import { Check, Errors } from "typebox/value";
4
+ import type { EvalStatusEvent, ExecuteTool } from "../tool/types.ts";
5
+
6
+ const agentArgsSchema = Type.Object(
7
+ {
8
+ prompt: Type.String({ minLength: 1 }),
9
+ agent: Type.Optional(Type.String({ minLength: 1 })),
10
+ model: Type.Optional(Type.String({ minLength: 1 })),
11
+ label: Type.Optional(Type.String()),
12
+ schema: Type.Optional(Type.Unknown()),
13
+ handle: Type.Optional(Type.Boolean()),
14
+ isolated: Type.Optional(Type.Boolean()),
15
+ apply: Type.Optional(Type.Boolean()),
16
+ merge: Type.Optional(Type.Boolean()),
17
+ },
18
+ { additionalProperties: false },
19
+ );
20
+
21
+ const unsupportedIsolationWarning = "isolated/apply/merge unsupported (no isolation in task engine)";
22
+ const taskIdPattern = /\bst_[A-Za-z0-9_-]+\b/;
23
+ const droppedOptionNames = ["isolated", "apply", "merge"] as const;
24
+
25
+ type AgentArgs = Static<typeof agentArgsSchema>;
26
+ type TaskParams = {
27
+ readonly prompt: string;
28
+ readonly subagent_type?: string;
29
+ readonly model?: string;
30
+ readonly name?: string;
31
+ readonly run_in_background: boolean;
32
+ };
33
+ type ProgressContext = { readonly fallbackId: string; readonly warning?: string };
34
+
35
+ export type AgentExecuteTool = ExecuteTool & {
36
+ readonly isToolAvailable?: (name: string) => boolean;
37
+ };
38
+
39
+ export interface RunEvalAgentOptions {
40
+ readonly callId: string;
41
+ readonly taskToolName: string;
42
+ readonly executeTool: AgentExecuteTool;
43
+ readonly signal?: AbortSignal;
44
+ readonly emitStatus?: (event: EvalStatusEvent) => void;
45
+ }
46
+
47
+ export type EvalAgentResult =
48
+ | { readonly text: string }
49
+ | { readonly text: string; readonly data: unknown }
50
+ | { readonly text: string; readonly parseError: string }
51
+ | { readonly text: string; readonly id: string; readonly handle: string };
52
+
53
+ class AgentArgumentsError extends Error {
54
+ readonly name = "AgentArgumentsError";
55
+
56
+ constructor(summary: string) {
57
+ super(`agent() received invalid arguments: ${summary}`);
58
+ }
59
+ }
60
+
61
+ class AgentUnavailableError extends Error {
62
+ readonly name = "AgentUnavailableError";
63
+
64
+ constructor(toolName: string) {
65
+ super(`agent() unavailable: no "${toolName}" tool is registered in this session`);
66
+ }
67
+ }
68
+
69
+ class AgentHandleError extends Error {
70
+ readonly name = "AgentHandleError";
71
+
72
+ constructor() {
73
+ super("agent() background task result did not include a task id");
74
+ }
75
+ }
76
+
77
+ export async function runEvalAgent(args: unknown, options: RunEvalAgentOptions): Promise<EvalAgentResult> {
78
+ const parsed = parseAgentArgs(args);
79
+ const structured = Object.hasOwn(parsed, "schema");
80
+ const warning = droppedOptionsWarning(parsed);
81
+ const fallbackId = parsed.label ?? options.callId;
82
+ if (warning) options.emitStatus?.({ op: "agent", id: fallbackId, status: "running", warning });
83
+
84
+ // omp assertNotPlanMode intentionally dropped: senpi has no plan mode
85
+ // senpi-task children lose task tools, so recursion self-gates without a depth counter.
86
+ if (options.executeTool.isToolAvailable?.(options.taskToolName) === false) {
87
+ throw new AgentUnavailableError(options.taskToolName);
88
+ }
89
+
90
+ let result: AgentToolResult<unknown>;
91
+ try {
92
+ result = await options.executeTool(options.taskToolName, toTaskParams(parsed, structured), {
93
+ ...(options.signal ? { signal: options.signal } : {}),
94
+ ...(options.emitStatus
95
+ ? {
96
+ onUpdate: (update: AgentToolResult<unknown>) =>
97
+ options.emitStatus?.(toProgressEvent(update, { fallbackId, ...(warning ? { warning } : {}) })),
98
+ }
99
+ : {}),
100
+ });
101
+ } catch (error) {
102
+ if (isUnavailableToolError(error)) throw new AgentUnavailableError(options.taskToolName);
103
+ throw error;
104
+ }
105
+
106
+ const text = resultText(result);
107
+ if (parsed.handle === true) {
108
+ const id = resultTaskId(result, text);
109
+ if (!id) throw new AgentHandleError();
110
+ return { text, id, handle: `agent://${id}` };
111
+ }
112
+ if (!structured) return { text };
113
+ return parseStructuredText(text);
114
+ }
115
+
116
+ function parseAgentArgs(value: unknown): AgentArgs {
117
+ if (Check(agentArgsSchema, value)) return value;
118
+ const summary = Errors(agentArgsSchema, value)
119
+ .map((error) => `${error.instancePath || "/"} ${error.message}`)
120
+ .join("; ");
121
+ throw new AgentArgumentsError(summary || "invalid value");
122
+ }
123
+
124
+ function toTaskParams(args: AgentArgs, structured: boolean): TaskParams {
125
+ return {
126
+ prompt: structured
127
+ ? `${args.prompt}\n\nRespond ONLY with JSON matching this JSON-Schema:\n${JSON.stringify(args.schema)}`
128
+ : args.prompt,
129
+ ...(args.agent === undefined ? {} : { subagent_type: args.agent }),
130
+ ...(args.model === undefined ? {} : { model: args.model }),
131
+ ...(args.label === undefined ? {} : { name: args.label }),
132
+ run_in_background: args.handle === true,
133
+ };
134
+ }
135
+
136
+ function parseStructuredText(text: string): EvalAgentResult {
137
+ try {
138
+ const data: unknown = JSON.parse(text);
139
+ return { text, data };
140
+ } catch (error) {
141
+ if (error instanceof SyntaxError) return { text, parseError: error.message };
142
+ throw error;
143
+ }
144
+ }
145
+
146
+ function droppedOptionsWarning(args: AgentArgs): string | undefined {
147
+ return droppedOptionNames.some((name) => Object.hasOwn(args, name)) ? unsupportedIsolationWarning : undefined;
148
+ }
149
+
150
+ function toProgressEvent(update: AgentToolResult<unknown>, context: ProgressContext): EvalStatusEvent {
151
+ const details = isRecord(update.details) ? update.details : undefined;
152
+ const id = firstString(details, ["task_id", "taskId", "id"]) ?? context.fallbackId;
153
+ const status = firstString(details, ["status"]) ?? "running";
154
+ const agent = firstString(details, ["subagent_type", "agent"]);
155
+ return {
156
+ op: "agent",
157
+ id,
158
+ status,
159
+ ...(agent === undefined ? {} : { agent }),
160
+ ...(context.warning === undefined ? {} : { warning: context.warning }),
161
+ };
162
+ }
163
+
164
+ function resultText(result: AgentToolResult<unknown>): string {
165
+ return result.content
166
+ .filter((part): part is Extract<(typeof result.content)[number], { type: "text" }> => part.type === "text")
167
+ .map((part) => part.text)
168
+ .join("\n");
169
+ }
170
+
171
+ function resultTaskId(result: AgentToolResult<unknown>, text: string): string | undefined {
172
+ const details = isRecord(result.details) ? result.details : undefined;
173
+ const detailId = firstString(details, ["task_id", "taskId", "id"]);
174
+ if (detailId) return detailId;
175
+ return text.match(taskIdPattern)?.[0];
176
+ }
177
+
178
+ function firstString(
179
+ record: Readonly<Record<string, unknown>> | undefined,
180
+ keys: readonly string[],
181
+ ): string | undefined {
182
+ if (!record) return undefined;
183
+ for (const key of keys) {
184
+ const value = record[key];
185
+ if (typeof value === "string" && value.length > 0) return value;
186
+ }
187
+ return undefined;
188
+ }
189
+
190
+ function isUnavailableToolError(error: unknown): boolean {
191
+ if (!isRecord(error)) return false;
192
+ return error.code === "unknown_tool" || error.code === "inactive_tool";
193
+ }
194
+
195
+ function isRecord(value: unknown): value is Readonly<Record<string, unknown>> {
196
+ return typeof value === "object" && value !== null && !Array.isArray(value);
197
+ }