Skip to content
Open
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
24 changes: 11 additions & 13 deletions pi-subagents/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

10 changes: 3 additions & 7 deletions pi-subagents/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -53,8 +53,7 @@
"peerDependencies": {
"@earendil-works/pi-ai": "^1.0.0",
"@earendil-works/pi-coding-agent": "^1.0.0",
"@earendil-works/pi-tui": "^1.0.0",
"typebox": "~1.3.27"
"@earendil-works/pi-tui": "^1.0.0"
},
"peerDependenciesMeta": {
"@earendil-works/pi-ai": {
Expand All @@ -65,12 +64,10 @@
},
"@earendil-works/pi-tui": {
"optional": true
},
"typebox": {
"optional": true
}
},
"dependencies": {
"@earendil-works/pi-schema": "1.5.0",
"yaml": "2.9.1"
},
"overrides": {
Expand All @@ -80,7 +77,6 @@
"@earendil-works/pi-ai": "1.3.0",
"@earendil-works/pi-coding-agent": "1.3.0",
"@types/node": "26.6.5",
"typescript": "7.0.2",
"typebox": "1.3.37"
"typescript": "7.0.2"
}
}
13 changes: 7 additions & 6 deletions pi-subagents/src/diff.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
import path from "node:path";
import type { ToolDefinition } from "@earendil-works/pi-coding-agent";
import { Type } from "typebox";
import { bool, obj, opt, str, type Static } from "@earendil-works/pi-schema";
import { git } from "./workspace.ts";

const MAX_CHARS = 24000;
const Params = Type.Object({
path: Type.Optional(Type.String({ minLength: 1, maxLength: 2048 })),
stat: Type.Optional(Type.Boolean()),
}, { additionalProperties: false });
const Params = obj({
path: opt(str({ minLength: 1, maxLength: 2048 })),
stat: opt(bool()),
});
type DiffArguments = Static<typeof Params>;

export function diffPath(value?: string): string[] {
if (value === undefined) return [];
Expand All @@ -32,7 +33,7 @@ export function diffTool(root: string, head?: string): ToolDefinition<typeof Par
description: "Read a bounded staged/unstaged diff against launch HEAD and untracked paths. No committed-range review. Use read for untracked contents.",
parameters: Params,
annotations: { readOnlyHint: true, openWorldHint: false },
async execute(_id, args, signal) {
async execute(_id, args: DiffArguments, signal) {
const filter = diffPath(args.path);
if (!head) throw new Error("No Git HEAD at launch. Supply a diff artifact or inspect files; do not claim this diff was reviewed.");
// head sits in option position; only a full object id captured at launch is accepted.
Expand Down
7 changes: 4 additions & 3 deletions pi-subagents/src/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import {
createAgentSession, DefaultResourceLoader, SessionManager, SettingsManager,
type AgentSession, type ContextUsage, type ModelRuntime, type ToolDefinition,
} from "@earendil-works/pi-coding-agent";
import { Type } from "typebox";
import { obj, str, type Static } from "@earendil-works/pi-schema";
import { captureHead, diffTool } from "./diff.ts";
import { formatMessage } from "./transcript.ts";
import type { ContextEstimate, FailureKind, SubAgent, PreparedTask, Profile, ResumeFrom, RunContext, Task, Telemetry, Thinking } from "./types.ts";
Expand All @@ -25,7 +25,8 @@ function publishTelemetry(context: RunContext, snapshot: Telemetry): void {

export const INSPECT_TOOLS = ["read", "grep", "find", "ls", "diff", "contact_agent"];
export const EDIT_TOOLS = [...INSPECT_TOOLS, "bash", "edit", "write"];
const QuestionParams = Type.Object({ message: Type.String({ minLength: 1, maxLength: 8192 }) }, { additionalProperties: false });
const QuestionParams = obj({ message: str({ minLength: 1, maxLength: 8192 }) });
type QuestionArguments = Static<typeof QuestionParams>;
const BOUNDARY = `You are a sub-agent, not the agent. Perform only your task. You cannot delegate or resume other agents.
Use contact_agent for missing decisions; do not improvise around missing required safety policy or tools.
Your tools run with the host's OS permissions, not in a sandbox. Stay in your assigned workspace and scope.
Expand All @@ -43,7 +44,7 @@ export function contactAgentTool(context: RunContext): ToolDefinition<typeof Que
name: "contact_agent", label: "Ask agent",
description: "Ask the owning agent one material question and wait for its reply. Not for routine progress or completion. Cancellation/deadline ends the wait.",
parameters: QuestionParams,
async execute(_id, args, signal) {
async execute(_id, args: QuestionArguments, signal) {
return { content: [{ type: "text", text: await context.ask(args.message, signal) }], details: undefined };
},
};
Expand Down
121 changes: 64 additions & 57 deletions pi-subagents/src/tool.ts
Original file line number Diff line number Diff line change
@@ -1,26 +1,34 @@
import { Type } from "typebox";
import { arr, bool, lit, nul, num, obj, opt, str, union, type Schema } from "@earendil-works/pi-schema";
import { THINKING_LEVELS, type RunRecord, type Task } from "./types.ts";
import { keys, modelName, object, text, thinkingLevel } from "./validation.ts";

const ThinkingSchema = Type.String({ enum: [...THINKING_LEVELS] });
const TaskSchema = Type.Object({
agent: Type.String({ minLength: 1, maxLength: 64 }),
task: Type.String({ minLength: 1, maxLength: 32768 }),
cwd: Type.Optional(Type.String({ minLength: 1, maxLength: 4096 })),
model: Type.Optional(Type.String({ minLength: 3, maxLength: 512 })),
thinking: Type.Optional(ThinkingSchema),
}, { additionalProperties: false });
/** Object-root tool input with seven closed per-action branches; no cross-action field mixes. */
export const Parameters = Type.Union([
Type.Object({ action: Type.Literal("run"), tasks: Type.Array(TaskSchema, { minItems: 1, maxItems: 4 }) }, { additionalProperties: false }),
Type.Object({ action: Type.Literal("list"), cwd: Type.Optional(Type.String({ minLength: 1, maxLength: 4096, description: "Profile discovery directory for list" })) }, { additionalProperties: false }),
Type.Object({ action: Type.Literal("status"), id: Type.Optional(Type.String({ minLength: 1, maxLength: 64 })) }, { additionalProperties: false }),
Type.Object({ action: Type.Literal("stop"), id: Type.String({ minLength: 1, maxLength: 64 }) }, { additionalProperties: false }),
Type.Object({ action: Type.Literal("resume"), id: Type.String({ minLength: 1, maxLength: 64 }) }, { additionalProperties: false }),
Type.Object({ action: Type.Literal("steer"), id: Type.String({ minLength: 1, maxLength: 64 }), message: Type.String({ minLength: 1, maxLength: 8192 }) }, { additionalProperties: false }),
Type.Object({ action: Type.Literal("followUp"), id: Type.String({ minLength: 1, maxLength: 64 }), message: Type.String({ minLength: 1, maxLength: 8192 }) }, { additionalProperties: false }),
Type.Object({ action: Type.Literal("reply"), id: Type.String({ minLength: 1, maxLength: 64 }), requestId: Type.String({ minLength: 1, maxLength: 64 }), message: Type.String({ minLength: 1, maxLength: 8192 }) }, { additionalProperties: false }),
], { type: "object" });
/** `{ type: "string", enum }`: Google-compatible string enum. */
function stringEnum<const T extends readonly string[]>(values: T): Schema<T[number]> {
return { type: "string", enum: [...values] };
}

const ThinkingSchema = stringEnum(THINKING_LEVELS);
const TaskSchema = obj({
agent: str({ minLength: 1, maxLength: 64 }),
task: str({ minLength: 1, maxLength: 32768 }),
cwd: opt(str({ minLength: 1, maxLength: 4096 })),
model: opt(str({ minLength: 3, maxLength: 512 })),
thinking: opt(ThinkingSchema),
});
/** Object-root tool input with eight closed per-action branches; no cross-action field mixes. */
export const Parameters = {
...union([
obj({ action: lit("run"), tasks: arr(TaskSchema, { minItems: 1, maxItems: 4 }) }),
obj({ action: lit("list"), cwd: opt(str({ minLength: 1, maxLength: 4096, description: "Profile discovery directory for list" })) }),
obj({ action: lit("status"), id: opt(str({ minLength: 1, maxLength: 64 })) }),
obj({ action: lit("stop"), id: str({ minLength: 1, maxLength: 64 }) }),
obj({ action: lit("resume"), id: str({ minLength: 1, maxLength: 64 }) }),
obj({ action: lit("steer"), id: str({ minLength: 1, maxLength: 64 }), message: str({ minLength: 1, maxLength: 8192 }) }),
obj({ action: lit("followUp"), id: str({ minLength: 1, maxLength: 64 }), message: str({ minLength: 1, maxLength: 8192 }) }),
obj({ action: lit("reply"), id: str({ minLength: 1, maxLength: 64 }), requestId: str({ minLength: 1, maxLength: 64 }), message: str({ minLength: 1, maxLength: 8192 }) }),
]),
type: "object",
};

export type Request =
| { action: "run"; tasks: Task[] }
Expand Down Expand Up @@ -163,46 +171,45 @@ export function resultPreview(result: unknown): string {
return serialize({ ...summary, truncated: true, message: "Fields omitted. Full result is in details/structuredContent; for runs, use status by exact ID and read metadata before acting." });
}

const closed = { additionalProperties: false };
const ModeSchema = Type.String({ enum: ["inspect", "edit"] });
const StateSchema = Type.String({ enum: ["starting", "running", "waiting_for_agent", "cancelling", "completed", "failed", "cancelled", "cleanup_unknown"] });
const ResumeSchema = Type.Object({
eligible: Type.Boolean(), attempts: Type.Number(), maxAttempts: Type.Number(),
kind: Type.Optional(Type.String({ enum: ["transient_provider", "provider", "cancelled", "unknown"] })),
}, closed);
const RunSchema = Type.Object({
id: Type.String(), state: StateSchema,
agent: Type.Optional(Type.String()), taskPreview: Type.Optional(Type.String()),
model: Type.Optional(Type.String()), thinking: Type.Optional(ThinkingSchema),
currentTool: Type.Optional(Type.String()),
contextUsage: Type.Optional(Type.Object({
tokens: Type.Union([Type.Number(), Type.Null()]),
contextWindow: Type.Union([Type.Number(), Type.Null()]),
percent: Type.Union([Type.Number(), Type.Null()]),
}, closed)),
question: Type.Optional(Type.Object({ id: Type.String(), message: Type.String() }, closed)),
reportPath: Type.Optional(Type.String()), metadataPath: Type.Optional(Type.String()),
error: Type.Optional(Type.String()), notificationError: Type.Optional(Type.String()),
resume: Type.Optional(ResumeSchema),
truncated: Type.Optional(Type.Literal(true)),
}, closed);
const ModeSchema = stringEnum(["inspect", "edit"]);
const StateSchema = stringEnum(["starting", "running", "waiting_for_agent", "cancelling", "completed", "failed", "cancelled", "cleanup_unknown"]);
const ResumeSchema = obj({
eligible: bool(), attempts: num(), maxAttempts: num(),
kind: opt(stringEnum(["transient_provider", "provider", "cancelled", "unknown"])),
});
const RunSchema = obj({
id: str(), state: StateSchema,
agent: opt(str()), taskPreview: opt(str()),
model: opt(str()), thinking: opt(ThinkingSchema),
currentTool: opt(str()),
contextUsage: opt(obj({
tokens: union([num(), nul()]),
contextWindow: union([num(), nul()]),
percent: union([num(), nul()]),
})),
question: opt(obj({ id: str(), message: str() })),
reportPath: opt(str()), metadataPath: opt(str()),
error: opt(str()), notificationError: opt(str()),
resume: opt(ResumeSchema),
truncated: opt(lit(true)),
});
/** The same compact contract reaches the model and native/codemode callers. */
export const OutputSchema = Type.Union([
Type.Object({
runs: Type.Array(Type.Union([
Type.Object({ id: Type.String(), cwd: Type.String(), workspace: Type.String(), model: Type.String(), thinking: ThinkingSchema }, closed),
Type.Object({ id: Type.String(), agent: Type.String(), state: StateSchema, taskPreview: Type.String(),
resume: Type.Optional(Type.Object({ eligible: Type.Literal(true), attempts: Type.Number(), maxAttempts: Type.Number() }, closed)),
}, closed),
export const OutputSchema = union([
obj({
runs: arr(union([
obj({ id: str(), cwd: str(), workspace: str(), model: str(), thinking: ThinkingSchema }),
obj({ id: str(), agent: str(), state: StateSchema, taskPreview: str(),
resume: opt(obj({ eligible: lit(true), attempts: num(), maxAttempts: num() })),
}),
])),
blocked: Type.Optional(Type.Union([Type.Literal(true), Type.Object({ id: Type.String(), metadataPath: Type.String() }, closed)])),
}, closed),
Type.Array(Type.Object({
name: Type.String(), description: Type.String(), mode: ModeSchema,
model: Type.Optional(Type.String()), thinking: Type.Optional(Type.String()),
}, closed)),
blocked: opt(union([lit(true), obj({ id: str(), metadataPath: str() })])),
}),
arr(obj({
name: str(), description: str(), mode: ModeSchema,
model: opt(str()), thinking: opt(str()),
})),
RunSchema,
Type.Object({ ok: Type.Literal(true) }, closed),
obj({ ok: lit(true) }),
]);

export const DESCRIPTION = `Delegate only when requested by the operator or applicable instructions.
Expand Down
8 changes: 5 additions & 3 deletions pi-subagents/test/contract.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import test from "node:test";
import assert from "node:assert/strict";
import { mkdir, writeFile } from "node:fs/promises";
import path from "node:path";
import { Check } from "typebox/value";
import { compile } from "@earendil-works/pi-schema";
import { loadProfiles, parseProfile } from "../src/agents.ts";
import { DEFAULT_CONFIG, parseConfig } from "../src/config.ts";
import { parseCommand, resolveRun } from "../src/inspect.ts";
Expand All @@ -12,6 +12,8 @@ import type { RunRecord } from "../src/types.ts";
import { formatElapsed, nextLive, plain, rows } from "../src/ui.ts";
import { temp } from "./helpers.ts";

const checkOutput = compile(OutputSchema);

test("requests reject invalid action/field combinations", () => {
assert.deepEqual(parseRequest({ action: "run", tasks: [{ agent: "worker", task: "Implement" }] }), { action: "run", tasks: [{ agent: "worker", task: "Implement" }] });
assert.deepEqual(parseRequest({ action: "run", tasks: [{ agent: "worker", task: "Implement", thinking: "low" }] }), { action: "run", tasks: [{ agent: "worker", task: "Implement", thinking: "low" }] });
Expand Down Expand Up @@ -42,11 +44,11 @@ test("exact-ID status presents cached context and current tool inside the closed
const view = presentRun(base);
assert.equal(view.currentTool, "bash");
assert.deepEqual(view.contextUsage, { tokens: 10, contextWindow: 100, percent: 10 });
assert(Check(OutputSchema, view));
assert(checkOutput.check(view));
const unknown = presentRun({ ...base, currentTool: undefined, contextUsage: { tokens: null, percent: null, contextWindow: 64000 } });
assert(!("currentTool" in unknown));
assert.deepEqual(unknown.contextUsage, { tokens: null, percent: null, contextWindow: 64000 }, "unknown stays unknown, never zero");
assert(Check(OutputSchema, unknown));
assert(checkOutput.check(unknown));
});

test("config is bounded and rejects unknown or unsafe limits", () => {
Expand Down
Loading
Loading