diff --git a/docs/RUNNER-PROTOCOL.md b/docs/RUNNER-PROTOCOL.md new file mode 100644 index 0000000000..aec6ba67c4 --- /dev/null +++ b/docs/RUNNER-PROTOCOL.md @@ -0,0 +1,102 @@ +# Agent runner protocol + +How the engine talks to an agent runner: a local OpenAI-compatible provider whose "model" is a coding +CLI (Claude Code, Codex) that runs its own tools. The desktop app's shim +(redrob-desktop `apps/desktop/node-host/harness.mjs`) is the one runner today. The shapes below are the +ones redrob-desktop `apps/desktop/src/v2/features/HARNESS-NEEDS.md` uses; nothing was changed from it. + +## Final shapes + +### Provider options + +```jsonc +"provider": { + "claude-code": { + "npm": "@ai-sdk/openai-compatible", + "options": { + "baseURL": "http://127.0.0.1:/v1", + "providerExecutedTools": true, // read runner tool calls from the stream (below) + "agentRunner": true // no title/summary run on this model + } + } +} +``` + +Either flag marks the provider as a runner for `x-redrob-directory`. + +### Request headers (engine to runner) + +| Header | Value | +| --- | --- | +| `x-session-affinity`, `X-Session-Id` | engine session id | +| `x-redrob-directory` | the session's folder, `encodeURIComponent`-encoded. Sent only to runner providers. | +| `x-parent-session-id` | parent session id, for a subagent session | + +### Tools the runner ran (runner to engine, in the stream) + +One `tool_calls` delta per finished CLI tool call, in a normal chat-completions chunk: + +```json +{ + "choices": [{ + "index": 0, + "delta": { + "tool_calls": [{ + "index": 0, + "id": "runner_", + "type": "function", + "function": { "name": "bash", "arguments": "{\"command\":\"ls\"}" }, + "provider_executed": true, + "title": "ls", + "result": { "output": "a\nb\n" } + }] + } + }] +} +``` + +- `result` is `{ "output": string }` or `{ "error": string }`. +- `function.arguments` is a JSON string (an object is accepted too). +- Each call needs its own `index` and a unique `id`; send the whole call in one delta. +- Use `finish_reason: "stop"`, not `"tool_calls"`. +- The engine stores each as a `tool` part of the assistant message (`state.status` `completed` or + `error`, `metadata.providerExecuted: true`) and runs nothing locally. A call shows once it has + finished. +- Engine side: `packages/redrob/src/session/llm/runner-tools.ts`. + +Not replayed: on later turns the runner's own calls are left out of the prompt the engine sends back, +so the history has no `tool_calls` without tool messages. + +### Approval mid-turn (runner to engine) + +``` +POST /session/:sessionID/permission +{ "permission": "bash", "patterns": ["git push"], "metadata": { ... }, "always": ["git push"] } + +200 { "reply": "once" | "always" | "reject", "message"?: string } +``` + +- The engine finds the session's folder from the id in the path; no directory header is needed. +- Blocks until the person answers the session's permission card. +- `metadata` and `always` are optional. `always` defaults to `patterns`: the patterns an "always" + answer allows for the rest of the session. +- The session's own permission rules and earlier "always" answers apply: an allowed pattern returns + `"always"` at once, a denied one returns `"reject"` with a `message`. +- A reject with feedback typed by the person returns it as `message`. +- Closing the HTTP request drops the pending request. +- The request carries `metadata.runner: true` unless the runner sets `metadata.runner` itself. +- Permission names are the engine's tool names (`bash`, `edit`, `write`, `read`, `webfetch`, ...); + patterns are the command or the file paths. + +### Titles and summaries + +- A chat on an `agentRunner` provider is titled after the first line of its first message; no model + call is made for it (unless the `title` agent names its own model). +- Compaction is not skipped. It fires automatically only when the runner reports token usage that + crosses the threshold; a runner that reports none is compacted only when the person asks. That + request arrives without tools, and the shim answers it as a read-only side request. + +### Not done + +- `x-redrob-turn` (telling a retry from a new turn) is not sent; the shim does not need it for + correctness. diff --git a/packages/redrob/src/permission/index.ts b/packages/redrob/src/permission/index.ts index c0efc85840..8e6f419ec4 100644 --- a/packages/redrob/src/permission/index.ts +++ b/packages/redrob/src/permission/index.ts @@ -100,8 +100,12 @@ const layer = Layer.effect( yield* events.publish(Event.Asked, info) return yield* Effect.ensuring( Deferred.await(deferred), - Effect.sync(() => { + // Still pending here means nobody answered: the asker went away (a cancelled turn, or a runner + // that dropped its HTTP call). Tell the clients, so an open card does not linger. + Effect.suspend(() => { + if (!pending.has(id)) return Effect.void pending.delete(id) + return events.publish(Event.Replied, { sessionID: info.sessionID, requestID: id, reply: "reject" }).pipe(Effect.ignore) }), ) }) diff --git a/packages/redrob/src/plugin/index.ts b/packages/redrob/src/plugin/index.ts index e5865fbd81..1bb9fcaf3f 100644 --- a/packages/redrob/src/plugin/index.ts +++ b/packages/redrob/src/plugin/index.ts @@ -7,6 +7,7 @@ import type { WorkspaceAdapter as PluginWorkspaceAdapter, } from "@redrob-code/plugin" import { Config } from "@/config/config" +import { pluginShell } from "./shell" import { createRedrobClient } from "@redrob-labs/sdk" import { ServerAuth } from "@/server/auth" import { Session } from "@/session/session" @@ -156,8 +157,8 @@ const layer = Layer.effect( get serverUrl(): URL { return Server.url ?? new URL("http://localhost:4096") }, - // @ts-expect-error - $: typeof Bun === "undefined" ? undefined : Bun.$, + // @ts-expect-error: undefined outside Bun, as before + $: pluginShell(), } for (const plugin of flags.disableDefaultPlugins ? [] : internalPlugins()) { diff --git a/packages/redrob/src/plugin/shell.ts b/packages/redrob/src/plugin/shell.ts new file mode 100644 index 0000000000..6021f2cde4 --- /dev/null +++ b/packages/redrob/src/plugin/shell.ts @@ -0,0 +1,177 @@ +import type { PluginInput } from "@redrob-code/plugin" +import { Shell } from "@redrob-code/core/shell" +import { Process } from "@/util/process" + +type PluginShell = PluginInput["$"] +type ShellPromise = ReturnType +type ShellOutput = Awaited +type Expression = Parameters[1] + +/** + * The `$` handed to plugins. + * + * On Windows `Bun.$` opens a console window for every command it runs, which flashes on top of the + * desktop app each time a plugin checks git or runs a tool. There the plugins get this shell instead: + * the same tagged-template API (`await $\`cmd\``, `.text()`, `.json()`, `.lines()`, `.quiet()`, + * `.nothrow()`, `.throws()`, `.cwd()`, `.env()`, `$.escape()`, `$.cwd()`, `$.env()`, `$.nothrow()`), + * run through the shared spawn helper with `windowsHide`. The command runs in Git Bash when Git for + * Windows is installed (the closest to Bun's bash-like syntax), otherwise in cmd.exe. Interpolated values + * are quoted for that shell. Output is buffered, never echoed, and `.stdin` is not available. + * + * Everywhere else plugins keep `Bun.$` itself. + */ +export function pluginShell(): PluginShell | undefined { + if (process.platform === "win32") return make({ throws: true }) + return typeof Bun === "undefined" ? undefined : (Bun.$ as unknown as PluginShell) +} + +type Defaults = { + cwd?: string + env?: Record + throws: boolean +} + +type Target = { file: string; posix: boolean } + +let target: Target | undefined +function shellTarget(): Target { + if (target) return target + const bash = Shell.gitbash() + target = bash ? { file: bash, posix: true } : { file: process.env.ComSpec || "cmd.exe", posix: false } + return target +} + +function quote(value: string): string { + if (value !== "" && /^[\w@%+=:,./\\-]+$/.test(value)) return value + if (shellTarget().posix) return `'${value.replace(/'/g, `'\\''`)}'` + return `"${value.replace(/"/g, '""')}"` +} + +function render(strings: TemplateStringsArray, expressions: Expression[]): string { + let out = strings[0] ?? "" + for (let i = 0; i < expressions.length; i++) { + out += expression(expressions[i]) + (strings[i + 1] ?? "") + } + return out +} + +function expression(value: unknown): string { + if (Array.isArray(value)) return value.map(expression).join(" ") + if (typeof value === "object" && value !== null && "raw" in value && typeof value.raw === "string") return value.raw + if (typeof ReadableStream !== "undefined" && value instanceof ReadableStream) { + throw new Error("The plugin shell on Windows does not take a stream as input") + } + return quote(String(value)) +} + +function make(defaults: Defaults): PluginShell { + const shell = ((strings: TemplateStringsArray, ...expressions: Expression[]) => + command(render(strings, expressions), defaults)) as PluginShell + shell.braces = (pattern: string) => { + if (typeof Bun !== "undefined") return Bun.$.braces(pattern) + return [pattern] + } + shell.escape = quote + shell.env = (env?: Record) => make({ ...defaults, env }) + shell.cwd = (cwd?: string) => make({ ...defaults, cwd }) + shell.nothrow = () => make({ ...defaults, throws: false }) + shell.throws = (throws: boolean) => make({ ...defaults, throws }) + return shell +} + +function command(text: string, defaults: Defaults): ShellPromise { + const state = { ...defaults } + let started: Promise | undefined + const start = () => (started ??= execute(text, state)) + const self = { + get stdin(): WritableStream { + throw new Error("The plugin shell on Windows has no stdin") + }, + cwd(cwd: string) { + state.cwd = cwd + return self + }, + env(env: Record | undefined) { + state.env = env + return self + }, + quiet() { + return self + }, + nothrow() { + state.throws = false + return self + }, + throws(throws: boolean) { + state.throws = throws + return self + }, + async *lines() { + const output = await start() + for (const line of output.text().split(/\r?\n/)) { + if (line) yield line + } + }, + text(encoding?: BufferEncoding) { + return start().then((output) => output.text(encoding)) + }, + json() { + return start().then((output) => output.json()) + }, + arrayBuffer() { + return start().then((output) => output.arrayBuffer()) + }, + blob() { + return start().then((output) => output.blob()) + }, + then( + onfulfilled?: ((value: ShellOutput) => A | PromiseLike) | null, + onrejected?: ((reason: any) => B | PromiseLike) | null, + ) { + return start().then(onfulfilled, onrejected) + }, + catch(onrejected?: ((reason: any) => B | PromiseLike) | null) { + return start().catch(onrejected) + }, + finally(onfinally?: (() => void) | null) { + return start().finally(onfinally) + }, + [Symbol.toStringTag]: "ShellPromise", + } + // Bun.$ runs a command even when nobody awaits it; modifiers chained in the same tick still apply. + queueMicrotask(() => void start().catch(() => undefined)) + return self as unknown as ShellPromise +} + +async function execute(text: string, state: Defaults): Promise { + const shell = shellTarget() + const env = state.env + ? Object.fromEntries(Object.entries(state.env).filter((entry): entry is [string, string] => entry[1] !== undefined)) + : undefined + const result = shell.posix + ? await Process.run([shell.file, "-c", text], { cwd: state.cwd, env, nothrow: true }) + : await Process.run([text], { cwd: state.cwd, env, shell: true, nothrow: true }) + const output = toOutput(result) + if (result.code !== 0 && state.throws) { + const error = new Error( + `Failed with exit code ${result.code}${result.stderr.length ? `\n${result.stderr.toString().trim()}` : ""}`, + ) + error.name = "ShellError" + throw Object.assign(error, output) + } + return output +} + +function toOutput(result: Process.Result): ShellOutput { + const stdout = result.stdout + return { + stdout, + stderr: result.stderr, + exitCode: result.code, + text: (encoding?: BufferEncoding) => stdout.toString(encoding), + json: () => JSON.parse(stdout.toString()), + arrayBuffer: () => stdout.buffer.slice(stdout.byteOffset, stdout.byteOffset + stdout.byteLength) as ArrayBuffer, + bytes: () => new Uint8Array(stdout), + blob: () => new Blob([new Uint8Array(stdout)]), + } +} diff --git a/packages/redrob/src/server/routes/instance/httpapi/groups/permission.ts b/packages/redrob/src/server/routes/instance/httpapi/groups/permission.ts index a4f6d17821..41cfeeaa30 100644 --- a/packages/redrob/src/server/routes/instance/httpapi/groups/permission.ts +++ b/packages/redrob/src/server/routes/instance/httpapi/groups/permission.ts @@ -2,6 +2,7 @@ import { PermissionV1 } from "@redrob-code/core/v1/permission" import { Permission } from "@/permission" import { Schema } from "effect" import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" +import { SessionID } from "@/session/schema" import { PermissionNotFoundError } from "../errors" import { Authorization } from "../middleware/authorization" import { InstanceContextMiddleware } from "../middleware/instance-context" @@ -14,6 +15,21 @@ const ReplyPayload = Schema.Struct({ message: Schema.optional(Schema.String), }) +/** + * A permission raised from outside a local tool call, by an agent runner (Claude Code, Codex) that + * runs its own tools. See docs/RUNNER-PROTOCOL.md. + */ +export const RunnerAskPayload = Schema.Struct({ + permission: Schema.String, + patterns: Schema.Array(Schema.String), + metadata: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)), + always: Schema.optional(Schema.Array(Schema.String)), +}) +export const RunnerAskResult = Schema.Struct({ + reply: PermissionV1.Reply, + message: Schema.optional(Schema.String), +}) + export const PermissionApi = HttpApi.make("permission") .add( HttpApiGroup.make("permission") @@ -41,6 +57,20 @@ export const PermissionApi = HttpApi.make("permission") description: "Approve or deny a permission request from the AI assistant.", }), ), + HttpApiEndpoint.post("ask", "/session/:sessionID/permission", { + params: { sessionID: SessionID }, + query: WorkspaceRoutingQuery, + payload: RunnerAskPayload, + success: described(RunnerAskResult, "The person's answer"), + error: [HttpApiError.BadRequest], + }).annotateMerge( + OpenApi.annotations({ + identifier: "permission.ask", + summary: "Ask the person for a permission", + description: + "Raise a permission request for a session from outside a tool call and wait for the answer. Used by agent runners that run their own tools.", + }), + ), ) .annotateMerge( OpenApi.annotations({ diff --git a/packages/redrob/src/server/routes/instance/httpapi/handlers/permission.ts b/packages/redrob/src/server/routes/instance/httpapi/handlers/permission.ts index c6b820a168..37e985609b 100644 --- a/packages/redrob/src/server/routes/instance/httpapi/handlers/permission.ts +++ b/packages/redrob/src/server/routes/instance/httpapi/handlers/permission.ts @@ -1,13 +1,20 @@ import { PermissionV1 } from "@redrob-code/core/v1/permission" +import { EventV2 } from "@redrob-code/core/event" +import { EventV2Bridge } from "@/event-v2-bridge" import { Permission } from "@/permission" +import { Session } from "@/session/session" +import { SessionID } from "@/session/schema" import { Effect } from "effect" import { HttpApiBuilder } from "effect/unstable/httpapi" import { InstanceHttpApi } from "../api" import { PermissionNotFoundError } from "../errors" +import { RunnerAskPayload, RunnerAskResult } from "../groups/permission" export const permissionHandlers = HttpApiBuilder.group(InstanceHttpApi, "permission", (handlers) => Effect.gen(function* () { const svc = yield* Permission.Service + const session = yield* Session.Service + const events = yield* EventV2Bridge.Service const list = Effect.fn("PermissionHttpApi.list")(function* () { return yield* svc.list() @@ -36,6 +43,53 @@ export const permissionHandlers = HttpApiBuilder.group(InstanceHttpApi, "permiss return true }) - return handlers.handle("list", list).handle("reply", reply) + /* + * Raises the same `permission.asked` a local tool would, for a runner that ran the tool itself, and + * blocks until the person answers. The session's own rules and earlier "always" answers apply, so a + * pattern the person already allowed comes back "always" without a card. A request the runner + * drops (its HTTP call closes) is interrupted here, which removes it from the pending list. + */ + const ask = Effect.fn("PermissionHttpApi.ask")(function* (ctx: { + params: { sessionID: SessionID } + payload: typeof RunnerAskPayload.Type + }) { + const info = yield* session.get(ctx.params.sessionID).pipe(Effect.orElseSucceed(() => undefined)) + const id = PermissionV1.ID.ascending() + let answered: PermissionV1.Reply | undefined + const unsubscribe = yield* events.listen((event) => { + if (event.type !== Permission.Event.Replied.type) return Effect.void + const data = event.data as EventV2.Data + if (data.requestID === id) answered = data.reply + return Effect.void + }) + return yield* svc + .ask({ + id, + sessionID: ctx.params.sessionID, + permission: ctx.payload.permission, + patterns: [...ctx.payload.patterns], + metadata: { runner: true, ...ctx.payload.metadata }, + always: [...(ctx.payload.always ?? ctx.payload.patterns)], + ruleset: info?.permission ?? [], + }) + .pipe( + Effect.map((): typeof RunnerAskResult.Type => ({ reply: answered ?? "always" })), + Effect.catchTag("PermissionCorrectedError", (error) => + Effect.succeed({ reply: "reject", message: error.feedback }), + ), + Effect.catchTag("PermissionDeniedError", () => + Effect.succeed({ + reply: "reject", + message: "Denied by the session's permission rules.", + }), + ), + Effect.catchTag("PermissionRejectedError", () => + Effect.succeed({ reply: "reject" }), + ), + Effect.ensuring(unsubscribe), + ) + }) + + return handlers.handle("list", list).handle("reply", reply).handle("ask", ask) }), ) diff --git a/packages/redrob/src/session/llm.ts b/packages/redrob/src/session/llm.ts index c4d151be0c..c01c765a71 100644 --- a/packages/redrob/src/session/llm.ts +++ b/packages/redrob/src/session/llm.ts @@ -29,6 +29,7 @@ import * as OtelTracer from "@effect/opentelemetry/Tracer" import { LLMAISDK } from "./llm/ai-sdk" import { LLMNativeRuntime } from "./llm/native-runtime" import { LLMRequestPrep } from "./llm/request" +import { LLMRunnerTools } from "./llm/runner-tools" export const OUTPUT_TOKEN_MAX = ProviderTransform.OUTPUT_TOKEN_MAX @@ -223,7 +224,7 @@ const live: Layer.Layer< // Runtime seam: native is an opt-in adapter over @redrob-code/llm. It // either returns a ready LLMEvent stream or a concrete fallback reason. - if (flags.experimentalNativeLlm) { + if (flags.experimentalNativeLlm && item.options?.providerExecutedTools !== true) { const native = LLMNativeRuntime.stream({ model: input.model, provider: item, @@ -275,6 +276,8 @@ const live: Layer.Layer< }) // Default runtime path: AI SDK owns provider execution and tool dispatch; // LLMAISDK.toLLMEvents below normalizes fullStream parts for the processor. + const copilotRaw = input.model.providerID.includes("github-copilot") + const runnerTools = item.options?.providerExecutedTools === true return { type: "ai-sdk" as const, result: streamText({ @@ -292,7 +295,8 @@ const live: Layer.Layer< ) }, // Copilot returns the authoritative billed amount only in provider-specific response fields. - includeRawChunks: input.model.providerID.includes("github-copilot"), + // Agent runners report the tools they ran in fields only the raw chunk keeps. + includeRawChunks: copilotRaw || runnerTools, async experimental_repairToolCall(failed) { const lower = failed.toolCall.toolName.toLowerCase() if (lower !== failed.toolCall.toolName && prepared.tools[lower]) { @@ -339,6 +343,7 @@ const live: Layer.Layer< return args.params }, }, + ...(runnerTools ? [LLMRunnerTools.providerExecutedTools(copilotRaw)] : []), ], }), experimental_telemetry: { diff --git a/packages/redrob/src/session/llm/request.ts b/packages/redrob/src/session/llm/request.ts index aec193ba1d..427f4ec34d 100644 --- a/packages/redrob/src/session/llm/request.ts +++ b/packages/redrob/src/session/llm/request.ts @@ -184,6 +184,9 @@ export const prepare = Effect.fn("LLMRequestPrep.prepare")(function* (input: Pre const redrobProjectID = input.model.providerID.startsWith("redrob") ? (yield* InstanceState.context).project.id : undefined + // An agent runner (Claude Code, Codex behind the desktop's local shim) runs its own tools, so it + // needs the folder the session works in. Only runners get it: other providers never see local paths. + const runnerDirectory = isAgentRunner(input.provider) ? (yield* InstanceState.context).directory : undefined return { system, @@ -204,6 +207,7 @@ export const prepare = Effect.fn("LLMRequestPrep.prepare")(function* (input: Pre "x-session-affinity": input.sessionID, "X-Session-Id": input.sessionID, "User-Agent": USER_AGENT, + ...(runnerDirectory ? { "x-redrob-directory": encodeURIComponent(runnerDirectory) } : {}), }), ...(input.parentSessionID ? { "x-parent-session-id": input.parentSessionID } : {}), ...input.model.headers, @@ -220,6 +224,14 @@ function resolveTools(input: Pick input.user.tools?.[k] !== false && !disabled.has(k)) } +/** + * A provider whose model is an agent CLI that runs its own tools (config `options.agentRunner` or + * `options.providerExecutedTools`). See docs/RUNNER-PROTOCOL.md. + */ +export function isAgentRunner(provider: Pick): boolean { + return provider.options?.agentRunner === true || provider.options?.providerExecutedTools === true +} + export function hasToolCalls(messages: ModelMessage[]): boolean { for (const msg of messages) { if (!Array.isArray(msg.content)) continue diff --git a/packages/redrob/src/session/llm/runner-tools.ts b/packages/redrob/src/session/llm/runner-tools.ts new file mode 100644 index 0000000000..63bfe45814 --- /dev/null +++ b/packages/redrob/src/session/llm/runner-tools.ts @@ -0,0 +1,128 @@ +import type { LanguageModelV3Middleware, LanguageModelV3StreamPart } from "@ai-sdk/provider" + +/** + * Tool calls an agent runner already ran (Claude Code, Codex behind the desktop's local shim). + * + * Under provider option `providerExecutedTools: true` the provider streams each finished CLI tool call + * as an OpenAI `choices[0].delta.tool_calls[*]` entry carrying `"provider_executed": true`, an optional + * `"title"`, and `"result": { "output": string } | { "error": string }`. The openai-compatible parser + * drops those extra fields and would hand the call to a local tool, so this middleware reads them from + * the raw chunk instead, swallows the parser's own parts for those ids, and emits a provider-executed + * `tool-call` + `tool-result` pair. The AI SDK then runs nothing locally and the session processor + * stores them as completed (or errored) `tool` parts. Shape: docs/RUNNER-PROTOCOL.md. + * + * Needs `includeRawChunks: true` on the stream call; `forwardRaw` says whether the caller wanted the raw + * parts itself. + */ +export function providerExecutedTools(forwardRaw: boolean): LanguageModelV3Middleware { + return { + specificationVersion: "v3", + // The runner keeps its own record of the tools it ran; replaying them as OpenAI tool_calls with no + // tool messages after them is a malformed history, so they leave the prompt. + async transformParams({ params }) { + return { + ...params, + prompt: params.prompt.map((message) => + message.role === "assistant" + ? { + ...message, + content: message.content.filter( + (part) => !(part.type === "tool-call" && part.providerExecuted) && part.type !== "tool-result", + ), + } + : message, + ), + } + }, + async wrapStream({ doStream }) { + const { stream, ...rest } = await doStream() + const ran = new Set() + return { + ...rest, + stream: stream.pipeThrough( + new TransformStream({ + transform(part, controller) { + if (part.type === "raw") { + for (const call of runnerCalls(part.rawValue)) { + if (ran.has(call.id)) continue + ran.add(call.id) + controller.enqueue({ + type: "tool-call", + toolCallId: call.id, + toolName: call.name, + input: call.input, + providerExecuted: true, + dynamic: true, + }) + controller.enqueue( + call.error !== undefined + ? { + type: "tool-result", + toolCallId: call.id, + toolName: call.name, + result: call.error, + isError: true, + dynamic: true, + } + : { + type: "tool-result", + toolCallId: call.id, + toolName: call.name, + result: { title: call.title ?? call.name, output: call.output ?? "", metadata: {} }, + dynamic: true, + }, + ) + } + if (forwardRaw) controller.enqueue(part) + return + } + if ( + (part.type === "tool-input-start" || part.type === "tool-input-delta" || part.type === "tool-input-end") && + ran.has(part.id) + ) + return + if (part.type === "tool-call" && ran.has(part.toolCallId)) return + controller.enqueue(part) + }, + }), + ), + } + }, + } +} + +type RunnerCall = { id: string; name: string; input: string; title?: string; output?: string; error?: string } + +function runnerCalls(raw: unknown): RunnerCall[] { + if (!isRecord(raw) || !Array.isArray(raw.choices)) return [] + const calls: RunnerCall[] = [] + for (const choice of raw.choices) { + if (!isRecord(choice) || !isRecord(choice.delta) || !Array.isArray(choice.delta.tool_calls)) continue + for (const item of choice.delta.tool_calls) { + if (!isRecord(item) || item.provider_executed !== true || typeof item.id !== "string") continue + const fn = isRecord(item.function) ? item.function : {} + const args = fn.arguments + const result = isRecord(item.result) ? item.result : {} + calls.push({ + id: item.id, + name: typeof fn.name === "string" && fn.name ? fn.name : "tool", + input: typeof args === "string" ? args : args === undefined ? "" : JSON.stringify(args), + title: typeof item.title === "string" ? item.title : undefined, + output: text(result.output), + error: result.error === undefined || result.error === null ? undefined : (text(result.error) ?? "Error"), + }) + } + } + return calls +} + +function text(value: unknown): string | undefined { + if (value === undefined || value === null) return undefined + return typeof value === "string" ? value : JSON.stringify(value) +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value) +} + +export * as LLMRunnerTools from "./runner-tools" diff --git a/packages/redrob/src/session/processor.ts b/packages/redrob/src/session/processor.ts index 7a43dfaf17..1fea185bf1 100644 --- a/packages/redrob/src/session/processor.ts +++ b/packages/redrob/src/session/processor.ts @@ -349,6 +349,8 @@ const layer = Layer.effect( ? { ...value.providerMetadata, providerExecuted: true } : value.providerMetadata, })) + // A provider that already ran the call (an agent runner) is not looping on our tools. + if (value.providerExecuted) return const parts = yield* MessageV2.parts(ctx.assistantMessage.id).pipe( Effect.provideService(Database.Service, database), diff --git a/packages/redrob/src/session/prompt.ts b/packages/redrob/src/session/prompt.ts index 4aca4e586b..e64aa7efec 100644 --- a/packages/redrob/src/session/prompt.ts +++ b/packages/redrob/src/session/prompt.ts @@ -252,6 +252,25 @@ const layer = Layer.effect( ? yield* provider.getModel(ag.model.providerID, ag.model.modelID) : ((yield* provider.getSmallModel(input.providerID)) ?? (yield* provider.getModel(input.providerID, input.modelID))) + // An agent runner (Claude Code, Codex) would spend a full CLI run of the person's plan on a + // title. Name the chat after its first line instead. + const runner = yield* provider.getProvider(mdl.providerID).pipe( + Effect.map((info) => info.options?.agentRunner === true), + Effect.orElseSucceed(() => false), + ) + if (runner) { + const line = firstUser.parts + .flatMap((p) => (p.type === "text" && !p.synthetic ? [p.text] : p.type === "subtask" ? [p.prompt] : [])) + .join("\n") + .split("\n") + .map((l) => l.trim()) + .find((l) => l.length > 0) + if (!line) return + yield* sessions + .setTitle({ sessionID: input.session.id, title: line.length > 100 ? line.substring(0, 97) + "..." : line }) + .pipe(Effect.catchCause((cause) => Effect.logError("failed to set title", { error: Cause.squash(cause) }))) + return + } const msgs = onlySubtasks ? [{ role: "user" as const, content: subtasks.map((p) => p.prompt).join("\n") }] : yield* MessageV2.toModelMessagesEffect(context, mdl) diff --git a/packages/sdk/js/src/v2/gen/sdk.gen.ts b/packages/sdk/js/src/v2/gen/sdk.gen.ts index 6333bb0c9e..e0e6e83989 100644 --- a/packages/sdk/js/src/v2/gen/sdk.gen.ts +++ b/packages/sdk/js/src/v2/gen/sdk.gen.ts @@ -138,6 +138,8 @@ import type { PartUpdateResponses, PathGetErrors, PathGetResponses, + PermissionAskErrors, + PermissionAskResponses, PermissionListErrors, PermissionListResponses, PermissionReplyErrors, @@ -3780,6 +3782,53 @@ export class Permission extends HeyApiClient { }) } + /** + * Ask the person for a permission + * + * Raise a permission request for a session from outside a tool call and wait for the answer. Used by agent runners that run their own tools. + */ + public ask( + parameters: { + sessionID: string + directory?: string + workspace?: string + permission?: string + patterns?: Array + metadata?: { + [key: string]: unknown + } + always?: Array + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "path", key: "sessionID" }, + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + { in: "body", key: "permission" }, + { in: "body", key: "patterns" }, + { in: "body", key: "metadata" }, + { in: "body", key: "always" }, + ], + }, + ], + ) + return (options?.client ?? this.client).post({ + url: "/session/{sessionID}/permission", + ...options, + ...params, + headers: { + "Content-Type": "application/json", + ...options?.headers, + ...params.headers, + }, + }) + } + /** * Respond to permission * diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 14f344d1e6..d05cc7da8b 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -10580,6 +10580,46 @@ export type PermissionReplyResponses = { export type PermissionReplyResponse = PermissionReplyResponses[keyof PermissionReplyResponses] +export type PermissionAskData = { + body?: { + permission: string + patterns: Array + metadata?: { + [key: string]: unknown + } + always?: Array + } + path: { + sessionID: string + } + query?: { + directory?: string + workspace?: string + } + url: "/session/{sessionID}/permission" +} + +export type PermissionAskErrors = { + /** + * BadRequest | InvalidRequestError + */ + 400: EffectHttpApiErrorBadRequest | InvalidRequestError +} + +export type PermissionAskError = PermissionAskErrors[keyof PermissionAskErrors] + +export type PermissionAskResponses = { + /** + * The person's answer + */ + 200: { + reply: "once" | "always" | "reject" + message?: string + } +} + +export type PermissionAskResponse = PermissionAskResponses[keyof PermissionAskResponses] + export type ProviderListData = { body?: never path?: never