@oh-my-pi/pi-coding-agent 3.24.0 → 3.25.0

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.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,21 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [3.25.0] - 2026-01-07
6
+ ### Added
7
+
8
+ - Added `complete` tool for structured subagent output with JSON schema validation
9
+ - Added `query` parameter to output tool for jq-like JSON querying
10
+ - Added `output_schema` parameter to task tool for structured subagent completion
11
+ - Added JTD (JSON Type Definition) to JSON Schema converter for schema flexibility
12
+ - Added memorable two-word task identifiers (e.g., SwiftFalcon) for better task tracking
13
+
14
+ ### Changed
15
+
16
+ - Changed task output IDs from `agent_index` format to memorable names for easier reference
17
+ - Changed subagent completion flow to require explicit `complete` tool call with retry reminders
18
+ - Simplified worker agent system prompt to be more concise and focused
19
+
5
20
  ## [3.24.0] - 2026-01-07
6
21
  ### Added
7
22
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oh-my-pi/pi-coding-agent",
3
- "version": "3.24.0",
3
+ "version": "3.25.0",
4
4
  "description": "Coding agent CLI with read, bash, edit, write tools and session management",
5
5
  "type": "module",
6
6
  "ompConfig": {
@@ -40,9 +40,9 @@
40
40
  },
41
41
  "dependencies": {
42
42
  "@mariozechner/pi-ai": "^0.37.4",
43
- "@oh-my-pi/pi-agent-core": "3.24.0",
44
- "@oh-my-pi/pi-git-tool": "3.24.0",
45
- "@oh-my-pi/pi-tui": "3.24.0",
43
+ "@oh-my-pi/pi-agent-core": "3.25.0",
44
+ "@oh-my-pi/pi-git-tool": "3.25.0",
45
+ "@oh-my-pi/pi-tui": "3.25.0",
46
46
  "@openai/agents": "^0.3.7",
47
47
  "@sinclair/typebox": "^0.34.46",
48
48
  "ajv": "^8.17.1",
@@ -9,6 +9,7 @@ import { formatStats, getStats } from "../../../../lib/worktree/stats";
9
9
  import type { HookCommandContext } from "../../../hooks/types";
10
10
  import { discoverAgents, getAgent } from "../../../tools/task/discovery";
11
11
  import { runSubprocess } from "../../../tools/task/executor";
12
+ import { generateTaskName } from "../../../tools/task/name-generator";
12
13
  import type { AgentDefinition } from "../../../tools/task/types";
13
14
  import type { CustomCommand, CustomCommandAPI } from "../../types";
14
15
 
@@ -265,6 +266,7 @@ async function handleSpawn(args: SpawnArgs, ctx: HookCommandContext): Promise<st
265
266
  agent,
266
267
  task: args.task,
267
268
  index: 0,
269
+ taskId: generateTaskName(),
268
270
  context,
269
271
  });
270
272
 
@@ -322,6 +324,7 @@ async function handleParallel(args: ParallelTask[], ctx: HookCommandContext): Pr
322
324
  agent,
323
325
  task: task.task,
324
326
  index,
327
+ taskId: generateTaskName(),
325
328
  context: `Scope: ${task.scope}`,
326
329
  });
327
330
  await updateSession(session.id, {
package/src/core/sdk.ts CHANGED
@@ -149,6 +149,11 @@ export interface CreateAgentSessionOptions {
149
149
  /** Tool names explicitly requested (enables disabled-by-default tools) */
150
150
  toolNames?: string[];
151
151
 
152
+ /** Output schema for structured completion (subagents) */
153
+ outputSchema?: unknown;
154
+ /** Whether to include the complete tool by default */
155
+ requireCompleteTool?: boolean;
156
+
152
157
  /** Session manager. Default: SessionManager.create(cwd) */
153
158
  sessionManager?: SessionManager;
154
159
 
@@ -608,6 +613,8 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
608
613
  hasUI: options.hasUI ?? false,
609
614
  rulebookRules,
610
615
  eventBus,
616
+ outputSchema: options.outputSchema,
617
+ requireCompleteTool: options.requireCompleteTool,
611
618
  getSessionFile: () => sessionManager.getSessionFile() ?? null,
612
619
  getSessionSpawns: () => options.spawns ?? "*",
613
620
  settings: settingsManager,
@@ -0,0 +1,131 @@
1
+ /**
2
+ * Complete tool for structured subagent output.
3
+ *
4
+ * Subagents must call this tool to finish and return structured JSON output.
5
+ */
6
+
7
+ import type { AgentTool } from "@oh-my-pi/pi-agent-core";
8
+ import { Type } from "@sinclair/typebox";
9
+ import Ajv, { type ErrorObject, type ValidateFunction } from "ajv";
10
+ import type { ToolSession } from "./index";
11
+ import { jtdToJsonSchema } from "./jtd-to-json-schema";
12
+ import { subprocessToolRegistry } from "./task/subprocess-tool-registry";
13
+
14
+ export interface CompleteDetails {
15
+ data: unknown;
16
+ status: "success" | "aborted";
17
+ error?: string;
18
+ }
19
+
20
+ const ajv = new Ajv({ allErrors: true, strict: false });
21
+
22
+ function normalizeSchema(schema: unknown): { normalized?: unknown; error?: string } {
23
+ if (schema === undefined || schema === null) return {};
24
+ if (typeof schema === "string") {
25
+ try {
26
+ return { normalized: JSON.parse(schema) };
27
+ } catch (err) {
28
+ return { error: err instanceof Error ? err.message : String(err) };
29
+ }
30
+ }
31
+ return { normalized: schema };
32
+ }
33
+
34
+ function formatSchema(schema: unknown): string {
35
+ if (schema === undefined) return "No schema provided.";
36
+ if (typeof schema === "string") return schema;
37
+ try {
38
+ return JSON.stringify(schema, null, 2);
39
+ } catch {
40
+ return "[unserializable schema]";
41
+ }
42
+ }
43
+
44
+ function formatAjvErrors(errors: ErrorObject[] | null | undefined): string {
45
+ if (!errors || errors.length === 0) return "Unknown schema validation error.";
46
+ return errors
47
+ .map((err) => {
48
+ const path = err.instancePath ? `${err.instancePath}: ` : "";
49
+ return `${path}${err.message ?? "invalid"}`;
50
+ })
51
+ .join("; ");
52
+ }
53
+
54
+ export function createCompleteTool(session: ToolSession) {
55
+ const schemaResult = normalizeSchema(session.outputSchema);
56
+ // Convert JTD to JSON Schema if needed (auto-detected)
57
+ const normalizedSchema =
58
+ schemaResult.normalized !== undefined ? jtdToJsonSchema(schemaResult.normalized) : undefined;
59
+ let validate: ValidateFunction | undefined;
60
+ let schemaError = schemaResult.error;
61
+
62
+ if (normalizedSchema !== undefined && !schemaError) {
63
+ try {
64
+ validate = ajv.compile(normalizedSchema as any);
65
+ } catch (err) {
66
+ schemaError = err instanceof Error ? err.message : String(err);
67
+ }
68
+ }
69
+
70
+ const schemaHint = formatSchema(normalizedSchema ?? session.outputSchema);
71
+
72
+ // Use actual schema if provided, otherwise fall back to Type.Any
73
+ // Merge description into the JSON schema for better tool documentation
74
+ const dataSchema = normalizedSchema
75
+ ? Type.Unsafe({
76
+ ...(normalizedSchema as object),
77
+ description: "Structured output matching the schema:\n" + schemaHint,
78
+ })
79
+ : Type.Any({ description: "Structured JSON output (no schema specified)" });
80
+
81
+ const completeParams = Type.Object({
82
+ data: dataSchema,
83
+ status: Type.Optional(
84
+ Type.Union([Type.Literal("success"), Type.Literal("aborted")], {
85
+ default: "success",
86
+ description: "Use 'aborted' if the task cannot be completed",
87
+ }),
88
+ ),
89
+ error: Type.Optional(Type.String({ description: "Error message when status is 'aborted'" })),
90
+ });
91
+
92
+ const tool: AgentTool<typeof completeParams, CompleteDetails> = {
93
+ name: "complete",
94
+ label: "Complete",
95
+ description:
96
+ "Finish the task with structured JSON output. Call exactly once at the end of the task.\n\n" +
97
+ "If you cannot complete the task, call with status='aborted' and an error message.",
98
+ parameters: completeParams,
99
+ execute: async (_toolCallId, params) => {
100
+ const status = params.status ?? "success";
101
+
102
+ // Skip schema validation when aborting - the agent is giving up
103
+ if (status === "success") {
104
+ if (schemaError) {
105
+ throw new Error(`Invalid output schema: ${schemaError}`);
106
+ }
107
+ if (validate && !validate(params.data)) {
108
+ throw new Error(`Output does not match schema: ${formatAjvErrors(validate.errors)}`);
109
+ }
110
+ }
111
+
112
+ const responseText =
113
+ status === "aborted"
114
+ ? `Task aborted: ${params.error || "No reason provided"}`
115
+ : "Completion recorded.";
116
+
117
+ return {
118
+ content: [{ type: "text", text: responseText }],
119
+ details: { data: params.data, status, error: params.error },
120
+ };
121
+ },
122
+ };
123
+
124
+ return tool;
125
+ }
126
+
127
+ // Register subprocess tool handler for extraction + termination.
128
+ subprocessToolRegistry.register<CompleteDetails>("complete", {
129
+ extractData: (event) => event.result?.details as CompleteDetails | undefined,
130
+ shouldTerminate: () => true,
131
+ });
@@ -50,6 +50,14 @@ describe("createTools", () => {
50
50
  expect(names).toEqual(["report_finding"]);
51
51
  });
52
52
 
53
+ it("includes complete tool when required", async () => {
54
+ const session = createTestSession({ requireCompleteTool: true });
55
+ const tools = await createTools(session);
56
+ const names = tools.map((t) => t.name);
57
+
58
+ expect(names).toContain("complete");
59
+ });
60
+
53
61
  it("excludes ask tool when hasUI is false", async () => {
54
62
  const session = createTestSession({ hasUI: false });
55
63
  const tools = await createTools(session);
@@ -175,6 +183,6 @@ describe("createTools", () => {
175
183
  });
176
184
 
177
185
  it("HIDDEN_TOOLS contains review tools", () => {
178
- expect(Object.keys(HIDDEN_TOOLS).sort()).toEqual(["report_finding", "submit_review"]);
186
+ expect(Object.keys(HIDDEN_TOOLS).sort()).toEqual(["complete", "report_finding", "submit_review"]);
179
187
  });
180
188
  });
@@ -1,5 +1,6 @@
1
1
  export { type AskToolDetails, askTool, createAskTool } from "./ask";
2
2
  export { type BashToolDetails, createBashTool } from "./bash";
3
+ export { createCompleteTool } from "./complete";
3
4
  export { createEditTool } from "./edit";
4
5
  // Exa MCP tools (22 tools)
5
6
  export { exaTools } from "./exa/index";
@@ -54,6 +55,7 @@ import type { Rule } from "../../capability/rule";
54
55
  import type { EventBus } from "../event-bus";
55
56
  import { createAskTool } from "./ask";
56
57
  import { createBashTool } from "./bash";
58
+ import { createCompleteTool } from "./complete";
57
59
  import { createEditTool } from "./edit";
58
60
  import { createFindTool } from "./find";
59
61
  import { createGitTool } from "./git";
@@ -83,6 +85,10 @@ export interface ToolSession {
83
85
  rulebookRules: Rule[];
84
86
  /** Event bus for tool/extension communication */
85
87
  eventBus?: EventBus;
88
+ /** Output schema for structured completion (subagents) */
89
+ outputSchema?: unknown;
90
+ /** Whether to include the complete tool by default */
91
+ requireCompleteTool?: boolean;
86
92
  /** Get session file */
87
93
  getSessionFile: () => string | null;
88
94
  /** Get session spawns */
@@ -121,6 +127,7 @@ export const BUILTIN_TOOLS: Record<string, ToolFactory> = {
121
127
  };
122
128
 
123
129
  export const HIDDEN_TOOLS: Record<string, ToolFactory> = {
130
+ complete: createCompleteTool,
124
131
  report_finding: () => reportFindingTool,
125
132
  submit_review: () => submitReviewTool,
126
133
  };
@@ -131,13 +138,19 @@ export type ToolName = keyof typeof BUILTIN_TOOLS;
131
138
  * Create tools from BUILTIN_TOOLS registry.
132
139
  */
133
140
  export async function createTools(session: ToolSession, toolNames?: string[]): Promise<Tool[]> {
134
- const requestedTools = toolNames && toolNames.length > 0 ? toolNames : undefined;
141
+ const includeComplete = session.requireCompleteTool === true;
142
+ const requestedTools = toolNames && toolNames.length > 0 ? [...new Set(toolNames)] : undefined;
135
143
  const allTools: Record<string, ToolFactory> = { ...BUILTIN_TOOLS, ...HIDDEN_TOOLS };
144
+ if (includeComplete && requestedTools && !requestedTools.includes("complete")) {
145
+ requestedTools.push("complete");
146
+ }
147
+
136
148
  const entries = requestedTools
137
- ? requestedTools
138
- .filter((name, index) => requestedTools.indexOf(name) === index && name in allTools)
139
- .map((name) => [name, allTools[name]] as const)
140
- : Object.entries(BUILTIN_TOOLS);
149
+ ? requestedTools.filter((name) => name in allTools).map((name) => [name, allTools[name]] as const)
150
+ : [
151
+ ...Object.entries(BUILTIN_TOOLS),
152
+ ...(includeComplete ? ([["complete", HIDDEN_TOOLS.complete]] as const) : []),
153
+ ];
141
154
  const results = await Promise.all(entries.map(([, factory]) => factory(session)));
142
155
  const tools = results.filter((t): t is Tool => t !== null);
143
156
 
@@ -0,0 +1,274 @@
1
+ /**
2
+ * Convert JSON Type Definition (JTD) to JSON Schema.
3
+ *
4
+ * JTD (RFC 8927) is a simpler schema format. This converter allows users to
5
+ * write schemas in JTD and have them converted to JSON Schema for model APIs.
6
+ *
7
+ * @see https://jsontypedef.com/
8
+ * @see https://datatracker.ietf.org/doc/html/rfc8927
9
+ */
10
+
11
+ type JTDPrimitive =
12
+ | "boolean"
13
+ | "string"
14
+ | "timestamp"
15
+ | "float32"
16
+ | "float64"
17
+ | "int8"
18
+ | "uint8"
19
+ | "int16"
20
+ | "uint16"
21
+ | "int32"
22
+ | "uint32";
23
+
24
+ interface JTDType {
25
+ type: JTDPrimitive;
26
+ }
27
+
28
+ interface JTDEnum {
29
+ enum: string[];
30
+ }
31
+
32
+ interface JTDElements {
33
+ elements: JTDSchema;
34
+ }
35
+
36
+ interface JTDValues {
37
+ values: JTDSchema;
38
+ }
39
+
40
+ interface JTDProperties {
41
+ properties?: Record<string, JTDSchema>;
42
+ optionalProperties?: Record<string, JTDSchema>;
43
+ }
44
+
45
+ interface JTDDiscriminator {
46
+ discriminator: string;
47
+ mapping: Record<string, JTDProperties>;
48
+ }
49
+
50
+ interface JTDRef {
51
+ ref: string;
52
+ }
53
+
54
+ interface JTDEmpty {}
55
+
56
+ type JTDSchema =
57
+ | JTDType
58
+ | JTDEnum
59
+ | JTDElements
60
+ | JTDValues
61
+ | JTDProperties
62
+ | JTDDiscriminator
63
+ | JTDRef
64
+ | JTDEmpty;
65
+
66
+ const primitiveMap: Record<JTDPrimitive, string> = {
67
+ boolean: "boolean",
68
+ string: "string",
69
+ timestamp: "string", // ISO 8601
70
+ float32: "number",
71
+ float64: "number",
72
+ int8: "integer",
73
+ uint8: "integer",
74
+ int16: "integer",
75
+ uint16: "integer",
76
+ int32: "integer",
77
+ uint32: "integer",
78
+ };
79
+
80
+ function isJTDType(schema: unknown): schema is JTDType {
81
+ return typeof schema === "object" && schema !== null && "type" in schema;
82
+ }
83
+
84
+ function isJTDEnum(schema: unknown): schema is JTDEnum {
85
+ return typeof schema === "object" && schema !== null && "enum" in schema;
86
+ }
87
+
88
+ function isJTDElements(schema: unknown): schema is JTDElements {
89
+ return typeof schema === "object" && schema !== null && "elements" in schema;
90
+ }
91
+
92
+ function isJTDValues(schema: unknown): schema is JTDValues {
93
+ return typeof schema === "object" && schema !== null && "values" in schema;
94
+ }
95
+
96
+ function isJTDProperties(schema: unknown): schema is JTDProperties {
97
+ return (
98
+ typeof schema === "object" &&
99
+ schema !== null &&
100
+ ("properties" in schema || "optionalProperties" in schema)
101
+ );
102
+ }
103
+
104
+ function isJTDDiscriminator(schema: unknown): schema is JTDDiscriminator {
105
+ return typeof schema === "object" && schema !== null && "discriminator" in schema;
106
+ }
107
+
108
+ function isJTDRef(schema: unknown): schema is JTDRef {
109
+ return typeof schema === "object" && schema !== null && "ref" in schema;
110
+ }
111
+
112
+ function convertSchema(schema: unknown): unknown {
113
+ if (schema === null || typeof schema !== "object") {
114
+ return {};
115
+ }
116
+
117
+ // Type form: { type: "string" } → { type: "string" }
118
+ if (isJTDType(schema)) {
119
+ const jsonType = primitiveMap[schema.type as JTDPrimitive];
120
+ if (!jsonType) {
121
+ return { type: schema.type };
122
+ }
123
+ const result: Record<string, unknown> = { type: jsonType };
124
+ // Add format for timestamp
125
+ if (schema.type === "timestamp") {
126
+ result.format = "date-time";
127
+ }
128
+ return result;
129
+ }
130
+
131
+ // Enum form: { enum: ["a", "b"] } → { enum: ["a", "b"] }
132
+ if (isJTDEnum(schema)) {
133
+ return { enum: schema.enum };
134
+ }
135
+
136
+ // Elements form: { elements: { type: "string" } } → { type: "array", items: ... }
137
+ if (isJTDElements(schema)) {
138
+ return {
139
+ type: "array",
140
+ items: convertSchema(schema.elements),
141
+ };
142
+ }
143
+
144
+ // Values form: { values: { type: "string" } } → { type: "object", additionalProperties: ... }
145
+ if (isJTDValues(schema)) {
146
+ return {
147
+ type: "object",
148
+ additionalProperties: convertSchema(schema.values),
149
+ };
150
+ }
151
+
152
+ // Properties form: { properties: {...}, optionalProperties: {...} }
153
+ if (isJTDProperties(schema)) {
154
+ const properties: Record<string, unknown> = {};
155
+ const required: string[] = [];
156
+
157
+ // Required properties
158
+ if (schema.properties) {
159
+ for (const [key, value] of Object.entries(schema.properties)) {
160
+ properties[key] = convertSchema(value);
161
+ required.push(key);
162
+ }
163
+ }
164
+
165
+ // Optional properties
166
+ if (schema.optionalProperties) {
167
+ for (const [key, value] of Object.entries(schema.optionalProperties)) {
168
+ properties[key] = convertSchema(value);
169
+ }
170
+ }
171
+
172
+ const result: Record<string, unknown> = {
173
+ type: "object",
174
+ properties,
175
+ additionalProperties: false,
176
+ };
177
+
178
+ if (required.length > 0) {
179
+ result.required = required;
180
+ }
181
+
182
+ return result;
183
+ }
184
+
185
+ // Discriminator form: { discriminator: "type", mapping: { ... } }
186
+ if (isJTDDiscriminator(schema)) {
187
+ const oneOf: unknown[] = [];
188
+
189
+ for (const [tag, props] of Object.entries(schema.mapping)) {
190
+ const converted = convertSchema(props) as Record<string, unknown>;
191
+ // Add the discriminator property
192
+ const properties = (converted.properties || {}) as Record<string, unknown>;
193
+ properties[schema.discriminator] = { const: tag };
194
+
195
+ const required = ((converted.required as string[]) || []).slice();
196
+ if (!required.includes(schema.discriminator)) {
197
+ required.push(schema.discriminator);
198
+ }
199
+
200
+ oneOf.push({
201
+ ...converted,
202
+ properties,
203
+ required,
204
+ });
205
+ }
206
+
207
+ return { oneOf };
208
+ }
209
+
210
+ // Ref form: { ref: "MyType" } → { $ref: "#/$defs/MyType" }
211
+ if (isJTDRef(schema)) {
212
+ return { $ref: `#/$defs/${schema.ref}` };
213
+ }
214
+
215
+ // Empty form: {} → {} (accepts anything)
216
+ return {};
217
+ }
218
+
219
+ /**
220
+ * Detect if a schema is JTD format (vs JSON Schema).
221
+ *
222
+ * JTD schemas use: type (primitives), properties, optionalProperties, elements, values, enum, discriminator, ref
223
+ * JSON Schema uses: type: "object", type: "array", items, additionalProperties, etc.
224
+ */
225
+ export function isJTDSchema(schema: unknown): boolean {
226
+ if (schema === null || typeof schema !== "object") {
227
+ return false;
228
+ }
229
+
230
+ const obj = schema as Record<string, unknown>;
231
+
232
+ // JTD-specific keywords
233
+ if ("elements" in obj) return true;
234
+ if ("values" in obj) return true;
235
+ if ("optionalProperties" in obj) return true;
236
+ if ("discriminator" in obj) return true;
237
+ if ("ref" in obj) return true;
238
+
239
+ // JTD type primitives (JSON Schema doesn't have int32, float64, etc.)
240
+ if ("type" in obj) {
241
+ const jtdPrimitives = [
242
+ "timestamp",
243
+ "float32",
244
+ "float64",
245
+ "int8",
246
+ "uint8",
247
+ "int16",
248
+ "uint16",
249
+ "int32",
250
+ "uint32",
251
+ ];
252
+ if (jtdPrimitives.includes(obj.type as string)) {
253
+ return true;
254
+ }
255
+ }
256
+
257
+ // JTD properties form without type: "object" (JSON Schema requires it)
258
+ if ("properties" in obj && !("type" in obj)) {
259
+ return true;
260
+ }
261
+
262
+ return false;
263
+ }
264
+
265
+ /**
266
+ * Convert JTD schema to JSON Schema.
267
+ * If already JSON Schema, returns as-is.
268
+ */
269
+ export function jtdToJsonSchema(schema: unknown): unknown {
270
+ if (!isJTDSchema(schema)) {
271
+ return schema;
272
+ }
273
+ return convertSchema(schema);
274
+ }