feat: add session subagent cli control
This commit is contained in:
+180
-1
@@ -1,4 +1,5 @@
|
||||
import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { spawn } from "node:child_process";
|
||||
import { closeSync, existsSync, openSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
@@ -8,7 +9,7 @@ import { ManagerClient } from "../../src/mgr/client.js";
|
||||
import { runOnce } from "../../src/runner/run-once.js";
|
||||
import { renderRunnerJobDryRun } from "../../src/runner/k8s-job.js";
|
||||
import { renderCodexProviderSecretPlan } from "./secret-render.js";
|
||||
import type { JsonRecord, JsonValue, RunRecord } from "../../src/common/types.js";
|
||||
import type { BackendProfile, CommandRecord, JsonRecord, JsonValue, RunRecord, SessionSummary } from "../../src/common/types.js";
|
||||
import { AgentRunError, errorToJson } from "../../src/common/errors.js";
|
||||
import type { RunnerOnceOptions } from "../../src/runner/run-once.js";
|
||||
import { isBackendProfile } from "../../src/common/backend-profiles.js";
|
||||
@@ -38,6 +39,14 @@ async function dispatch(args: ParsedArgs): Promise<JsonValue> {
|
||||
if (group === "server" && command === "stop") return stopServer(args);
|
||||
if (group === "backends" && command === "list") return client(args).get("/api/v1/backends");
|
||||
if (group === "secrets" && command === "codex" && id === "render") return renderCodexSecret(args);
|
||||
if (group === "sessions" && command === "ps") return listSessions(args);
|
||||
if (group === "sessions" && command === "show" && id) return client(args).get(`/api/v1/sessions/${encodeURIComponent(id)}${readerQuery(args)}`);
|
||||
if (group === "sessions" && command === "read" && id) return client(args).post(`/api/v1/sessions/${encodeURIComponent(id)}/read`, { readerId: optionalFlag(args, "reader-id") ?? "cli" });
|
||||
if (group === "sessions" && command === "trace" && id) return sessionEvents(args, id, "trace");
|
||||
if (group === "sessions" && command === "output" && id) return sessionEvents(args, id, "output");
|
||||
if (group === "sessions" && command === "turn") return sessionTurn(args, id ?? null);
|
||||
if (group === "sessions" && command === "steer" && id) return sessionSteer(args, id);
|
||||
if (group === "sessions" && command === "cancel" && id) return sessionCancel(args, id);
|
||||
if (group === "queue" && command === "submit") return submitQueueTask(args);
|
||||
if (group === "queue" && command === "list") return listQueueTasks(args);
|
||||
if (group === "queue" && command === "show" && id) return client(args).get(`/api/v1/queue/tasks/${encodeURIComponent(id)}`);
|
||||
@@ -111,6 +120,92 @@ async function listRunnerJobs(args: ParsedArgs): Promise<JsonValue> {
|
||||
return client(args).get(`/api/v1/runs/${encodeURIComponent(runId)}/runner-jobs${commandId ? `?commandId=${encodeURIComponent(commandId)}` : ""}`);
|
||||
}
|
||||
|
||||
async function listSessions(args: ParsedArgs): Promise<JsonValue> {
|
||||
const params = new URLSearchParams();
|
||||
const state = optionalFlag(args, "state") ?? (args.flags.get("running") === true ? "running" : args.flags.get("unread") === true ? "unread" : args.flags.get("all") === true ? "all" : null);
|
||||
const profile = optionalFlag(args, "profile") ?? optionalFlag(args, "backend-profile");
|
||||
const readerId = optionalFlag(args, "reader-id");
|
||||
const cursor = optionalFlag(args, "cursor");
|
||||
const limit = optionalFlag(args, "limit");
|
||||
if (state) params.set("state", state);
|
||||
if (profile) params.set("profile", normalizeProfile(profile));
|
||||
if (readerId) params.set("readerId", readerId);
|
||||
if (cursor) params.set("cursor", cursor);
|
||||
if (limit) params.set("limit", limit);
|
||||
const query = params.toString();
|
||||
return client(args).get(`/api/v1/sessions${query ? `?${query}` : ""}`);
|
||||
}
|
||||
|
||||
async function sessionEvents(args: ParsedArgs, sessionId: string, kind: "trace" | "output"): Promise<JsonValue> {
|
||||
const params = new URLSearchParams();
|
||||
const afterSeq = optionalFlag(args, "after-seq");
|
||||
const limit = optionalFlag(args, "limit");
|
||||
const runId = optionalFlag(args, "run-id");
|
||||
if (afterSeq) params.set("afterSeq", afterSeq);
|
||||
if (limit) params.set("limit", limit);
|
||||
if (runId) params.set("runId", runId);
|
||||
const query = params.toString();
|
||||
return client(args).get(`/api/v1/sessions/${encodeURIComponent(sessionId)}/${kind}${query ? `?${query}` : ""}`);
|
||||
}
|
||||
|
||||
async function sessionTurn(args: ParsedArgs, positionalSessionId: string | null): Promise<JsonRecord> {
|
||||
const body = await optionalJsonFile(args);
|
||||
const sessionId = positionalSessionId ?? optionalFlag(args, "session-id") ?? newSessionId();
|
||||
const requestedProfile = optionalFlag(args, "profile") ?? optionalFlag(args, "backend-profile") ?? (typeof body.backendProfile === "string" ? String(body.backendProfile) : "codex");
|
||||
const profile = normalizeProfile(requestedProfile);
|
||||
const prompt = await readPrompt(args);
|
||||
body.tenantId = optionalFlag(args, "tenant-id") ?? stringField(body, "tenantId", "unidesk");
|
||||
body.projectId = optionalFlag(args, "project-id") ?? stringField(body, "projectId", "default");
|
||||
body.providerId = optionalFlag(args, "provider-id") ?? stringField(body, "providerId", "G14");
|
||||
body.backendProfile = profile;
|
||||
body.workspaceRef = jsonObjectFlag(args, "workspace-json") ?? objectField(body, "workspaceRef", { kind: "opaque", path: "." });
|
||||
body.executionPolicy = jsonObjectFlag(args, "execution-policy-json") ?? objectField(body, "executionPolicy", defaultExecutionPolicy(profile));
|
||||
body.traceSink = body.traceSink ?? null;
|
||||
const sessionRef = objectField(body, "sessionRef", {});
|
||||
const sessionMetadata = objectField(sessionRef, "metadata", {});
|
||||
const title = optionalFlag(args, "title");
|
||||
if (title) sessionMetadata.title = title;
|
||||
body.sessionRef = { ...sessionRef, sessionId, metadata: sessionMetadata };
|
||||
const run = await client(args).post("/api/v1/runs", body) as RunRecord;
|
||||
const commandBody: JsonRecord = { type: "turn", payload: { prompt } };
|
||||
const commandIdempotencyKey = optionalFlag(args, "command-idempotency-key") ?? optionalFlag(args, "idempotency-key");
|
||||
if (commandIdempotencyKey) commandBody.idempotencyKey = commandIdempotencyKey;
|
||||
const command = await client(args).post(`/api/v1/runs/${encodeURIComponent(run.id)}/commands`, commandBody) as CommandRecord;
|
||||
let runnerJob: JsonValue = null;
|
||||
if (args.flags.get("no-runner-job") !== true) {
|
||||
const runnerBody = await optionalRunnerJsonFile(args);
|
||||
runnerBody.commandId = command.id;
|
||||
copyOptionalFlag(args, runnerBody, "image");
|
||||
copyOptionalFlag(args, runnerBody, "namespace");
|
||||
copyOptionalFlag(args, runnerBody, "attempt-id", "attemptId");
|
||||
copyOptionalFlag(args, runnerBody, "runner-id", "runnerId");
|
||||
copyOptionalFlag(args, runnerBody, "source-commit", "sourceCommit");
|
||||
copyOptionalFlag(args, runnerBody, "runner-manager-url", "managerUrl");
|
||||
copyOptionalFlag(args, runnerBody, "service-account-name", "serviceAccountName");
|
||||
const runnerIdempotencyKey = optionalFlag(args, "runner-idempotency-key");
|
||||
if (runnerIdempotencyKey) runnerBody.idempotencyKey = runnerIdempotencyKey;
|
||||
runnerJob = await client(args).post(`/api/v1/runs/${encodeURIComponent(run.id)}/runner-jobs`, runnerBody);
|
||||
}
|
||||
return { action: "session-turn", sessionId, profile, run, command, runnerJob, pollCommands: { ps: `./scripts/agentrun sessions ps --reader-id cli --profile ${profile}`, show: `./scripts/agentrun sessions show ${sessionId} --reader-id cli`, trace: `./scripts/agentrun sessions trace ${sessionId} --after-seq 0 --limit 100`, output: `./scripts/agentrun sessions output ${sessionId} --after-seq 0 --limit 100`, read: `./scripts/agentrun sessions read ${sessionId} --reader-id cli`, steer: `./scripts/agentrun sessions steer ${sessionId} --prompt-file <file>`, cancel: `./scripts/agentrun sessions cancel ${sessionId}` } };
|
||||
}
|
||||
|
||||
async function sessionSteer(args: ParsedArgs, sessionId: string): Promise<JsonRecord> {
|
||||
const session = await client(args).get(`/api/v1/sessions/${encodeURIComponent(sessionId)}${readerQuery(args)}`) as SessionSummary;
|
||||
const runId = session.activeRunId ?? session.lastRunId;
|
||||
if (!runId) throw new AgentRunError("schema-invalid", `session ${sessionId} has no run to steer`, { httpStatus: 2 });
|
||||
const prompt = await readPrompt(args);
|
||||
const body: JsonRecord = { type: "steer", payload: { prompt } };
|
||||
const idempotencyKey = optionalFlag(args, "idempotency-key");
|
||||
if (idempotencyKey) body.idempotencyKey = idempotencyKey;
|
||||
const command = await client(args).post(`/api/v1/runs/${encodeURIComponent(runId)}/commands`, body);
|
||||
return { action: "session-steer", sessionId, runId, command };
|
||||
}
|
||||
|
||||
async function sessionCancel(args: ParsedArgs, sessionId: string): Promise<JsonRecord> {
|
||||
const result = await client(args).post(`/api/v1/sessions/${encodeURIComponent(sessionId)}/control`, { action: "cancel", ...cancelBody(args) });
|
||||
return { action: "session-cancel", sessionId, result };
|
||||
}
|
||||
|
||||
async function submitQueueTask(args: ParsedArgs): Promise<JsonValue> {
|
||||
const body = await jsonFile(args);
|
||||
const idempotencyKey = optionalFlag(args, "idempotency-key");
|
||||
@@ -410,6 +505,82 @@ async function optionalJsonFile(args: ParsedArgs): Promise<JsonRecord> {
|
||||
return jsonFile(args);
|
||||
}
|
||||
|
||||
async function optionalRunnerJsonFile(args: ParsedArgs): Promise<JsonRecord> {
|
||||
const file = optionalFlag(args, "runner-json-file");
|
||||
if (!file) return {};
|
||||
const value = JSON.parse(await readFile(file, "utf8")) as unknown;
|
||||
if (typeof value === "object" && value !== null && !Array.isArray(value)) return value as JsonRecord;
|
||||
throw new AgentRunError("schema-invalid", "runner json file must contain an object", { httpStatus: 2 });
|
||||
}
|
||||
|
||||
async function readPrompt(args: ParsedArgs): Promise<string> {
|
||||
const promptFlag = optionalFlag(args, "prompt");
|
||||
if (promptFlag) return promptFlag;
|
||||
const promptFile = optionalFlag(args, "prompt-file");
|
||||
if (promptFile) {
|
||||
const text = await readFile(promptFile, "utf8");
|
||||
if (text.trim().length === 0) throw new AgentRunError("schema-invalid", "prompt file is empty", { httpStatus: 2 });
|
||||
return text;
|
||||
}
|
||||
if (args.flags.get("prompt-stdin") === true) {
|
||||
const text = await readStdinText();
|
||||
if (text.trim().length === 0) throw new AgentRunError("schema-invalid", "stdin prompt is empty", { httpStatus: 2 });
|
||||
return text;
|
||||
}
|
||||
const inline = args.positional.slice(3).join(" ").trim();
|
||||
if (inline.length > 0) return inline;
|
||||
throw new AgentRunError("schema-invalid", "prompt is required; use --prompt, --prompt-file, --prompt-stdin, or trailing text", { httpStatus: 2 });
|
||||
}
|
||||
|
||||
async function readStdinText(): Promise<string> {
|
||||
const chunks: Buffer[] = [];
|
||||
for await (const chunk of process.stdin) chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
||||
return Buffer.concat(chunks).toString("utf8");
|
||||
}
|
||||
|
||||
function normalizeProfile(value: string): BackendProfile {
|
||||
const normalized = value.trim().toLowerCase();
|
||||
const profile = normalized === "m3" || normalized === "minimax" || normalized === "minimax_m3" ? "minimax-m3" : normalized;
|
||||
if (!isBackendProfile(profile)) throw new AgentRunError("schema-invalid", `backend profile ${value} is not supported in v0.1`, { httpStatus: 2 });
|
||||
return profile;
|
||||
}
|
||||
|
||||
function newSessionId(): string {
|
||||
return `ses_${randomUUID().replace(/-/gu, "")}`;
|
||||
}
|
||||
|
||||
function stringField(record: JsonRecord, key: string, fallback: string): string {
|
||||
const value = record[key];
|
||||
return typeof value === "string" && value.trim().length > 0 ? value.trim() : fallback;
|
||||
}
|
||||
|
||||
function objectField(record: JsonRecord, key: string, fallback: JsonRecord): JsonRecord {
|
||||
const value = record[key];
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value) ? value as JsonRecord : fallback;
|
||||
}
|
||||
|
||||
function jsonObjectFlag(args: ParsedArgs, name: string): JsonRecord | null {
|
||||
const value = optionalFlag(args, name);
|
||||
if (!value) return null;
|
||||
const parsed = JSON.parse(value) as unknown;
|
||||
if (typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)) return parsed as JsonRecord;
|
||||
throw new AgentRunError("schema-invalid", `--${name} must be a JSON object`, { httpStatus: 2 });
|
||||
}
|
||||
|
||||
function defaultExecutionPolicy(profile: BackendProfile): JsonRecord {
|
||||
return { sandbox: "workspace-write", approval: "never", timeoutMs: 900000, network: "enabled", secretScope: { allowCredentialEcho: false, providerCredentials: [{ profile, secretRef: { name: `agentrun-v01-provider-${profile}`, keys: ["auth.json", "config.toml"] } }] } };
|
||||
}
|
||||
|
||||
function copyOptionalFlag(args: ParsedArgs, target: JsonRecord, flagName: string, key = flagName.replace(/-([a-z])/gu, (_, letter: string) => letter.toUpperCase())): void {
|
||||
const value = optionalFlag(args, flagName);
|
||||
if (value) target[key] = value;
|
||||
}
|
||||
|
||||
function readerQuery(args: ParsedArgs): string {
|
||||
const readerId = optionalFlag(args, "reader-id");
|
||||
return readerId ? `?readerId=${encodeURIComponent(readerId)}` : "";
|
||||
}
|
||||
|
||||
function parseArgs(argv: string[]): ParsedArgs {
|
||||
const positional: string[] = [];
|
||||
const flags = new Map<string, string | boolean>();
|
||||
@@ -453,6 +624,14 @@ function help(): JsonRecord {
|
||||
"runs events <runId> --after-seq <n> --limit <n>",
|
||||
"runs result <runId> [--command-id <commandId>]",
|
||||
"runs cancel <runId> [--reason <text>]",
|
||||
"sessions ps [--state default|running|unread|terminal|idle|all] [--profile codex|deepseek|minimax-m3|M3] [--reader-id <reader>]",
|
||||
"sessions show <sessionId> [--reader-id <reader>]",
|
||||
"sessions turn [sessionId] --json-file <run-base.json> --prompt-file <file> [--profile minimax-m3|M3] [--runner-json-file <job.json>]",
|
||||
"sessions steer <sessionId> --prompt-file <file>",
|
||||
"sessions cancel <sessionId> [--reason <text>]",
|
||||
"sessions trace <sessionId> [--after-seq <n>] [--limit <n>] [--run-id <runId>]",
|
||||
"sessions output <sessionId> [--after-seq <n>] [--limit <n>] [--run-id <runId>]",
|
||||
"sessions read <sessionId> [--reader-id <reader>]",
|
||||
"commands create <runId> --type turn|steer|interrupt --json-file <payload.json>",
|
||||
"commands show <commandId> --run-id <runId>",
|
||||
"commands result <commandId> --run-id <runId>",
|
||||
|
||||
Reference in New Issue
Block a user