Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
235 changes: 231 additions & 4 deletions packages/core/src/agent/engine.ts

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion packages/core/src/agent/gate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,7 @@ export type SessionApprovalPolicy = "plan" | "dont-ask" | "ask" | "accept-edits"
// (dispatch's own default — server.ts's session.dispatch) — a card a headless coordinator can never
// answer, so in practice a silent hang/timeout-deny on every real call. Same fix shape as
// task_stop's own entry above.
const READ_ONLY = new Set(["read", "glob", "grep", "ls", "bash_output", "Skill", "ToolSearch", "ask_user", "AskQuestion", "task_create", "task_update", "task_list", "task_get", "exit_plan_mode", "enter_plan_mode", "spawn_agent", "send_message", "task_stop", "agent_list", "agent_output", "lsp", "push_notification", "list_sessions", "manage_session"]);
const READ_ONLY = new Set(["read", "glob", "grep", "ls", "bash_output", "Skill", "ToolSearch", "functions.exec", "functions.wait", "ask_user", "AskQuestion", "task_create", "task_update", "task_list", "task_get", "exit_plan_mode", "enter_plan_mode", "spawn_agent", "send_message", "task_stop", "agent_list", "agent_output", "lsp", "push_notification", "list_sessions", "manage_session"]);
// `computer` (Phase 5 CU) is MUTATING: a computer-use action drives real mouse/keyboard/screen, so
// it must pass the gate on EVERY call (spec §4.6: "every CU action passes the permission gate") —
// ask → per-action approval card, auto → allow, plan → deny (CU makes changes). Note this is the
Expand Down
40 changes: 40 additions & 0 deletions packages/core/src/agent/tools/functions-exec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { z } from "zod";
import { MAX_CELL_ID_CHARS, MAX_FUNCTIONS_EXEC_SOURCE_CHARS } from "../../functions-exec/protocol";
import { functionsExecSandboxAvailable } from "../../functions-exec/sandbox";
import type { ToolRegistry } from "./registry";

export const FUNCTIONS_EXEC_TOOL = "functions.exec";
export const FUNCTIONS_WAIT_TOOL = "functions.wait";

export const functionsExecArgs = z.object({
source: z.string().min(1).max(MAX_FUNCTIONS_EXEC_SOURCE_CHARS),
timeoutMs: z.number().int().min(1).max(60_000).optional(),
}).strict();

export const functionsWaitArgs = z.object({
cellId: z.string().min(1).max(MAX_CELL_ID_CHARS),
}).strict();

/**
* Registers only the model-facing handles. AgentEngine owns cell state and runs both through its
* normal dispatch path; the worker itself never gains a direct filesystem or network capability.
*/
export function registerFunctionsExecTools(registry: ToolRegistry, supported = functionsExecSandboxAvailable()): void {
if (!supported) return;
registry.register({
name: FUNCTIONS_EXEC_TOOL,
description: "Run bounded JavaScript in an isolated worker. Load this deferred tool with ToolSearch first. JavaScript has no direct filesystem, process, or network access; use tools.bash, tools.read, tools.web_fetch, or tools.web_search, which each use Norma's normal permission path. Use tools.text(), tools.image(), tools.audio(), tools.notify(), and await tools.yield().",
args: functionsExecArgs,
modes: ["code"],
deferred: true,
run() { throw new Error("functions.exec requires the AgentEngine runtime bridge"); },
});
registry.register({
name: FUNCTIONS_WAIT_TOOL,
description: "Wait for the next checkpoint from a yielded functions.exec cell. This deferred tool is available only while a cell has yielded or has a pending terminal result.",
args: functionsWaitArgs,
modes: ["code"],
deferred: true,
run() { throw new Error("functions.wait requires the AgentEngine runtime bridge"); },
});
}
2 changes: 2 additions & 0 deletions packages/core/src/daemon.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import { registerBashTool } from "./agent/tools/bash";
import { registerBackgroundTools } from "./agent/tools/background";
import { registerSkillTools } from "./agent/tools/skill";
import { registerToolSearchTool } from "./agent/tools/toolsearch";
import { registerFunctionsExecTools } from "./agent/tools/functions-exec";
import { registerAskUserTool } from "./agent/tools/ask-user";
import { registerAskQuestionTool } from "./agent/tools/ask-question";
import { registerTaskTools } from "./agent/tools/tasks";
Expand Down Expand Up @@ -670,6 +671,7 @@ export async function startDaemon(opts: {
registerBackgroundTools(registry, { bgRegistry }, { deferred: true });
registerSkillTools(registry, { skills: skillStore });
registerToolSearchTool(registry);
registerFunctionsExecTools(registry);
questions = new QuestionBroker();
taskStore = new TaskStore();
registerAskUserTool(registry);
Expand Down
8 changes: 7 additions & 1 deletion packages/core/src/functions-exec/runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,12 @@ export interface FunctionsExecRuntimeDeps {
hasSeatbelt?: boolean;
}

/** The engine-facing worker contract, kept narrow so alternate process hosts can preserve the same bridge. */
export interface FunctionsExecRuntimeBridge {
cancel(sessionId: string, cellId: string): boolean;
execute(input: FunctionsExecRuntimeInput): Promise<FunctionsExecRuntimeResult>;
}

const DEFAULT_TIMEOUT_MS = 10_000;
const MAX_TIMEOUT_MS = 60_000;

Expand All @@ -61,7 +67,7 @@ function activeKey(sessionId: string, cellId: string): string {
return sessionId + "\u0000" + cellId;
}

export class FunctionsExecRuntime {
export class FunctionsExecRuntime implements FunctionsExecRuntimeBridge {
private readonly active = new Map<string, () => void>();

constructor(private readonly deps: FunctionsExecRuntimeDeps = {}) {}
Expand Down
5 changes: 3 additions & 2 deletions packages/core/src/providers/codex-oauth.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import type { SecretStore } from "../auth/secret-store";
import type { ModelInfo, Provider, ProviderEvent, TurnRequest } from "./types";
import { ResponsesSseParser } from "./responses-sse";
import { buildRequestBody, mapHttpError } from "./openai-compatible";
import { buildRequestBody, mapHttpError, wireToolNames } from "./openai-compatible";
import { refreshTokens, type OAuthTokens } from "./pkce";
import { CODEX, CODEX_MODELS } from "./codex-config";

Expand Down Expand Up @@ -86,7 +86,8 @@ export class CodexOAuthProvider implements Provider {
}
if (!res.ok) { yield await mapHttpError(res.status, res.headers.get("retry-after"), res.text()); return; }

const parser = new ResponsesSseParser();
const names = wireToolNames(req.tools);
const parser = new ResponsesSseParser((name) => names.get(name) ?? name);
const reader = res.body!.getReader();
try {
while (true) {
Expand Down
42 changes: 37 additions & 5 deletions packages/core/src/providers/openai-compatible.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { createHash } from "node:crypto";
import type { ModelInfo, Provider, ProviderEvent, TurnInputItem, TurnRequest, ToolSpec } from "./types";
import { ResponsesSseParser } from "./responses-sse";
import { parseProviderErrorCode } from "./errors";
Expand Down Expand Up @@ -25,7 +26,28 @@ export interface OpenAICompatibleConfig {
* Responses API ResponseItem shape (not yet live-verified against the codex backend;
* the parity doc only covers message/function_call_output — Task 12's live gate confirms).
*/
export function mapInput(items: TurnInputItem[]): unknown[] {
const RESPONSES_FUNCTION_NAME = /^[A-Za-z0-9_-]{1,64}$/;

/** Encode namespaced internal tools into the restricted Responses API function-name grammar. */
export function wireToolName(name: string): string {
if (RESPONSES_FUNCTION_NAME.test(name)) return name;
const encoded = `norma_${Buffer.from(name, "utf8").toString("base64url")}`;
if (RESPONSES_FUNCTION_NAME.test(encoded)) return encoded;
return `norma_${createHash("sha256").update(name).digest("hex").slice(0, 58)}`;
}

/** Maps the constrained Responses wire names back to their internal tool names for one request. */
export function wireToolNames(tools: ToolSpec[] | undefined): Map<string, string> {
const names = new Map<string, string>();
for (const tool of tools ?? []) {
const wire = wireToolName(tool.name);
if (names.has(wire)) throw new Error(`tool names collide after Responses transport encoding: ${tool.name}`);
names.set(wire, tool.name);
}
return names;
}

export function mapInput(items: TurnInputItem[], toWireName: (name: string) => string = wireToolName): unknown[] {
return items.map((i) => {
if (i.type === "message") {
// Map role to the appropriate content item type per the Responses API schema.
Expand All @@ -34,7 +56,7 @@ export function mapInput(items: TurnInputItem[]): unknown[] {
return { type: "message", role: i.role, content: [{ type: contentType, text: i.content }] };
}
if (i.type === "function_call") {
return { type: "function_call", call_id: i.callId, name: i.name, arguments: i.argsJson };
return { type: "function_call", call_id: i.callId, name: toWireName(i.name), arguments: i.argsJson };
}
if (i.type === "reasoning") return JSON.parse(i.itemJson); // opaque passthrough — never inspected
// Computer-use image (Phase 5 CU): a user message carrying an input_image. This is the ONLY
Expand All @@ -44,15 +66,24 @@ export function mapInput(items: TurnInputItem[]): unknown[] {
if (i.type === "image") {
const content: unknown[] = [];
if (i.alt) content.push({ type: "input_text", text: i.alt });
content.push({ type: "input_image", image_url: i.imageUrl });
content.push({ type: "input_image", image_url: i.imageUrl, ...(i.detail === undefined ? {} : { detail: i.detail }) });
return { type: "message", role: "user", content };
}
if (i.type === "audio") {
const match = /^data:audio\/(wav|mpeg);base64,([a-z0-9+/=]+)$/iu.exec(i.dataUrl);
if (!match) throw new Error("audio input requires a base64 audio/mpeg or audio/wav data URL");
return {
type: "message",
role: "user",
content: [{ type: "input_audio", input_audio: { data: match[2], format: match[1]!.toLowerCase() === "mpeg" ? "mp3" : "wav" } }],
};
}
return { type: "function_call_output", call_id: i.callId, output: i.output };
});
}

export function mapTools(tools: ToolSpec[] | undefined): unknown[] {
return (tools ?? []).map((t) => ({ type: "function", name: t.name, description: t.description, parameters: t.parameters, strict: false }));
return (tools ?? []).map((t) => ({ type: "function", name: wireToolName(t.name), description: t.description, parameters: t.parameters, strict: false }));
}

/**
Expand Down Expand Up @@ -135,7 +166,8 @@ export class OpenAICompatibleProvider implements Provider {
}
if (!res.ok) { yield await mapHttpError(res.status, res.headers.get("retry-after"), res.text()); return; }

const parser = new ResponsesSseParser();
const names = wireToolNames(req.tools);
const parser = new ResponsesSseParser((name) => names.get(name) ?? name);
const reader = res.body!.getReader();
try {
while (true) {
Expand Down
4 changes: 3 additions & 1 deletion packages/core/src/providers/responses-sse.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ export class ResponsesSseParser {
private decoder = new TextDecoder();
private sawToolCall = false;

constructor(private readonly fromWireToolName: (name: string) => string = (name) => name) {}

push(chunk: Uint8Array): ProviderEvent[] {
// Normalize after appending so \r\n split across two push() calls is handled correctly.
this.buf = (this.buf + this.decoder.decode(chunk, { stream: true })).replace(/\r\n/g, "\n");
Expand Down Expand Up @@ -61,7 +63,7 @@ export class ResponsesSseParser {
return [{
type: "tool_call",
callId: String(data.item.call_id),
name: String(data.item.name),
name: this.fromWireToolName(String(data.item.name)),
argsJson: String(data.item.arguments ?? ""),
}];
}
Expand Down
5 changes: 4 additions & 1 deletion packages/core/src/providers/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,10 @@ export type TurnInputItem =
// in-turn by the engine's image drain (never a persisted session event — see engine.ts's
// pendingImages), so `eventToInput` has no case for it and cross-turn history never reconstructs
// a past image.
| { type: "image"; imageUrl: string; alt?: string };
| { type: "image"; imageUrl: string; alt?: string; detail?: "auto" | "low" | "high" | "original" }
// Transient functions.exec audio. Like images, it exists only in the in-memory continuation for
// the next provider request and is never reconstructed from session history.
| { type: "audio"; dataUrl: string };

export interface ToolSpec {
name: string;
Expand Down
Loading