From d54e7dc467da1fe7299c8845aee443a4b7875218 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 23 Jul 2026 20:39:13 +0000 Subject: [PATCH 1/2] Initial plan From fab863a71a0449a00fa5341111c4a28b467c14f0 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 23 Jul 2026 20:47:03 +0000 Subject: [PATCH 2/2] feat: add s.nonEmptyObject schema helper and p.writeOutput prompt helper - Add s.nonEmptyObject(valueSchema, description?) as a constrained RecordSchema with minProperties:1; validates that the record has at least one key at runtime - Add p.writeOutput(field, path, options?) prompt intent that instructs the harness to write the value of a named output field to a file after generation, solving the p.write ambiguity for LLM-generated content - Update RecordSchema type to include optional minProperties field - Update serializeSchema to emit minProperties in JSON Schema output - Update validateSchema to enforce minProperties on record schemas - Add 9 new tests covering both features - Update SKILL.md and README.md with new helpers and clarifications - Update file-header comment in rig.ts Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- README.md | 5 +++ skills/rig/SKILL.md | 7 ++++- skills/rig/rig.ts | 53 +++++++++++++++++++++++++++++--- src/rig.test.ts | 74 +++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 134 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index ac474e9..3fbddaa 100644 --- a/README.md +++ b/README.md @@ -95,6 +95,8 @@ s.nonEmptyArray(item) // array with minItems: 1 s.nonEmptyArray(item, "description") s.object(fields, "description") s.record(value, "description") +s.nonEmptyObject(value) // Record with minProperties: 1 +s.nonEmptyObject(value, "description") s.enum(...values) s.enum(values, "description") s.optional(shape) @@ -122,6 +124,7 @@ p.read("README.md") p.readOptional("Dockerfile") // returns "" if file is absent p.readOptional(".eslintrc.json", "{}") // returns "{}" if file is absent p.write("README.md", "# Updated\n") // write-file instruction; does NOT return the path +p.writeOutput("report", "todo-report.md") // after generation, write output field "report" to file p.glob("src/**/*.ts") p.env("GITHUB_TOKEN") // returns "" if variable is not set p.env("GITHUB_TOKEN", "unset") // returns "unset" if variable is not set @@ -136,6 +139,8 @@ const reviewWorkspace = agent({ `p.write(path, contents)` contributes a write-file instruction to the prompt; it does **not** return the file path or contents as a string. When used in a template expression the result is a prompt instruction, not the path. If the output schema includes the written file path, hard-code the path string in the agent's output. +`p.writeOutput(field, path)` instructs the harness to write the value of output field `field` to the file at `path` after the agent generates its response. Use this instead of `p.write` when the content to be written is LLM-generated — e.g. `p.writeOutput("report", "todo-report.md")` wires the `report` output field to `todo-report.md` automatically. + ```ts const b = p(); const repo = b.var("repo", "rig"); diff --git a/skills/rig/SKILL.md b/skills/rig/SKILL.md index a515c28..356dcde 100644 --- a/skills/rig/SKILL.md +++ b/skills/rig/SKILL.md @@ -153,6 +153,8 @@ s.object(fields) s.object(fields, "description") s.record(value) s.record(value, "description") +s.nonEmptyObject(value) // Record with minProperties: 1 +s.nonEmptyObject(value, "description") s.enum(...values) s.enum(values, "description") s.optional(shape) @@ -177,6 +179,7 @@ s.nonEmptyString // non-empty string required s.url // valid URL string s.int // integer number (no floats); prefer over s.number for counts, line numbers, etc. s.nonEmptyArray(s.string) // string[] with at least one element +s.nonEmptyObject(s.string) // Record with at least one key ``` ## Tools @@ -219,6 +222,7 @@ p.read("README.md") p.readOptional("Dockerfile") // returns "" if file is absent p.readOptional(".eslintrc.json", "{}") // returns "{}" if file is absent p.write("README.md", "# Hello\n") // write-file instruction; does NOT return the path +p.writeOutput("report", "todo-report.md") // after generation, write output field "report" to file p.glob("src/**/*.ts") p.env("GITHUB_TOKEN") // returns "" if variable is not set p.env("GITHUB_TOKEN", "unset") // returns "unset" if variable is not set @@ -249,11 +253,12 @@ const reviewAgent = agent({ }); ``` -- ``p`...` `` accepts `${p.bash(...)}`, `${p.bashRaw\`...\`}`, `${p.read(...)}`, `${p.readOptional(...)}`, `${p.write(...)}`, `${p.glob(...)}`, `${p.env(...)}`, and `${p.json(...)}` expressions. +- ``p`...` `` accepts `${p.bash(...)}`, `${p.bashRaw\`...\`}`, `${p.read(...)}`, `${p.readOptional(...)}`, `${p.write(...)}`, `${p.writeOutput(...)}`, `${p.glob(...)}`, `${p.env(...)}`, and `${p.json(...)}` expressions. - Multiple `p.*` calls in the same template are resolved independently in order; each contributes its own instruction line. - Nested `PromptBuilder` values used as interpolations are inlined as plain text. - The rendered `PromptBuilder` replaces the instructions string when the agent prompt is assembled. - `p.write(path, contents)` contributes a write-file instruction to the prompt; it does **not** return the file path or contents as text. If the output schema needs to reference the written path, hard-code the path string in the agent's output — it cannot be read from the `p.write(...)` expression. Use `p.read(path)` in a **separate** expression to read back written content. +- `p.writeOutput(field, path)` instructs the harness to write the value of output field `field` to the file at `path` after the agent generates its response. Use this instead of `p.write` when the content to be written is LLM-generated output — e.g. `p.writeOutput("report", "todo-report.md")` wires the `report` output field to `todo-report.md` automatically. - `p.bash(cmd)` accepts a regular TypeScript string; backslashes and special characters must be escaped as in any TypeScript string literal. Use `p.bashRaw\`cmd\`` (tagged template) to avoid escaping — the command is taken verbatim from the template. When a grep or regex pattern contains `\.`, `\|`, or other backslash sequences, prefer `p.bashRaw`. - `p.glob(pattern)` resolves to a list of matching paths at runtime; it is resolved by the Copilot runtime, not in-process. Brace expansion (`{ts,js}`) and negation patterns are resolved by the runtime and are not guaranteed to work identically across all environments; prefer simple glob wildcards when portability matters. - `p.readOptional(path, fallback?)` reads a file if it exists; returns the fallback string (default `""`) if the file is absent. Use this instead of `p.read` when the file may not exist. diff --git a/skills/rig/rig.ts b/skills/rig/rig.ts index ff038d2..685d2e9 100644 --- a/skills/rig/rig.ts +++ b/skills/rig/rig.ts @@ -18,7 +18,7 @@ * T:AgentError class error carrying turn,agentName,rawOutput,parseError fields * T:Tool type ToolConfig+name; created by defineTool * T:ToolConfig type {description,parameters,handler} - * T:PromptIntent type declarative placeholder {kind:'bash'|'read'|'write'|'glob',…} resolved into prompt text + * T:PromptIntent type declarative placeholder {kind:'bash'|'read'|'write'|'writeOutput'|'glob',…} resolved into prompt text * T:PromptBuilder class template-tag result; composes intents+strings into a prompt fragment * T:PromptHelpers type shape of exported p object * T:ResponseAnalysisResult type {ok:true;output}|{ok:false;error:AgentError} @@ -32,6 +32,7 @@ * s.nonEmptyArray(items,desc?) ArraySchema with minItems:1; validates array has at least one element * s.object(props,desc?) ObjectSchema; s.optional(inner) marks field optional; s.nullable(inner) accepts inner|null; use for fixed-key shapes * s.record(valSchema,desc?) RecordSchema keyed by string; use for open-ended key→value maps + * s.nonEmptyObject(valSchema,desc?) RecordSchema with minProperties:1; validates record has at least one key * s.enum(...values|values,desc) EnumSchema * s.literal(value,desc?) EnumSchema with a single value; clearer than s.enum for single-value constraints * s.unknown unconstrained JSON; call as value or s.unknown("description") @@ -41,6 +42,7 @@ * p.read(path,opts?) PromptIntent file read declaration * p.readOptional(path,fallback?,opts?) PromptIntent file read declaration; returns fallback (default "") if file absent * p.write(path,content,opts?) PromptIntent file write declaration + * p.writeOutput(field,path,opts?) PromptIntent post-generation write declaration; writes output field value to path * p.glob(pattern,opts?) PromptIntent glob file-list declaration (not run in-process) * p.env(name,fallback?,opts?) PromptIntent env var read declaration; returns fallback (default "") if not set * p.json(value) string JSON.stringify helper for inlining structured values in prompt templates @@ -84,7 +86,7 @@ export type ObjectSchema = Record = { type: "object"; additionalProperties: Value; description?: string }; +export type RecordSchema = { type: "object"; additionalProperties: Value; description?: string; minProperties?: number }; export type EnumSchema = { enum: Values; description?: string }; const OPTIONAL_SYMBOL: unique symbol = Symbol("rig.optional"); type OptionalMarker = { readonly [OPTIONAL_SYMBOL]: true }; @@ -277,6 +279,19 @@ export const s = { record(additionalProperties: Value, description?: string): RecordSchema { return description === undefined ? markAsSchema({ type: "object", additionalProperties }) : markAsSchema({ type: "object", additionalProperties, description }); }, + /** + * Schema for a non-empty string-keyed map (minProperties: 1). Validates that the record has at least one key. + * Use this instead of `s.record` when the map must not be empty. + * + * @example + * s.nonEmptyObject(s.string) // Record with at least one key + * s.nonEmptyObject(s.number, "scores by name") // Record with at least one key and description + */ + nonEmptyObject(additionalProperties: Value, description?: string): RecordSchema { + return description === undefined + ? markAsSchema({ type: "object", additionalProperties, minProperties: 1 }) + : markAsSchema({ type: "object", additionalProperties, minProperties: 1, description }); + }, /** * Schema for a closed set of literal values. * @@ -351,7 +366,10 @@ function serializeSchema(schema: Schema): JsonSchemaObject { return withDescription(obj); } if ("additionalProperties" in schema) { - return withDescription({ type: "object", additionalProperties: serializeSchema(schema.additionalProperties) }); + const rec = schema as RecordSchema; + const obj: JsonSchemaObject = { type: "object", additionalProperties: serializeSchema(rec.additionalProperties) }; + if (rec.minProperties !== undefined) obj["minProperties"] = rec.minProperties; + return withDescription(obj); } if ("properties" in schema) { const properties: Record = {}; @@ -629,12 +647,13 @@ export type PromptIntentOptions = { export type PromptIntent = { __rig: "prompt"; id: string; - mode: "prompt.text" | "prompt.read" | "prompt.write" | "prompt.glob" | "prompt.readOptional" | "prompt.env"; + mode: "prompt.text" | "prompt.read" | "prompt.write" | "prompt.glob" | "prompt.readOptional" | "prompt.env" | "prompt.writeOutput"; command?: string; path?: string; contents?: string; pattern?: string; fallback?: string; + field?: string; options?: Omit; }; @@ -720,6 +739,22 @@ type PromptHelpers = { * output: s.object({ writtenTo: s.string }) // agent infers "README.md" */ write(path: string, contents: string, options?: PromptIntentOptions): PromptIntent; + /** + * Declarative intent that instructs the LLM to write the value of output field + * `field` to the file at `path` after generating the response. The write is + * **not** performed in-process; it is resolved by the Copilot runtime after the + * agent produces its structured output. + * + * Use this instead of `p.write` when the content to be written is the + * LLM-generated value of an output field — it wires the output field directly + * to the target file path so the harness can perform the write automatically. + * + * @example + * // Writes the "report" output field to "todo-report.md" after generation: + * instructions: p`Scan for TODO comments. ${p.writeOutput("report", "todo-report.md")}` + * output: s.object({ report: s.string }) + */ + writeOutput(field: string, path: string, options?: PromptIntentOptions): PromptIntent; /** * Declarative intent that instructs the LLM to list files matching `pattern` * and substitute the results into the prompt. Resolution happens inside the @@ -865,6 +900,9 @@ export const p: PromptHelpers = Object.assign( write(path: string, contents: string, options?: PromptIntentOptions): PromptIntent { return createPromptIntent("prompt.write", withOptions({ path, contents }, options)); }, + writeOutput(field: string, path: string, options?: PromptIntentOptions): PromptIntent { + return createPromptIntent("prompt.writeOutput", withOptions({ field, path }, options)); + }, glob(pattern: string, options?: PromptIntentOptions): PromptIntent { return createPromptIntent("prompt.glob", withOptions({ pattern }, options)); }, @@ -1711,6 +1749,11 @@ function validateSchema(value: unknown, schema: Schema, path: string, optional: if (!value || typeof value !== "object" || Array.isArray(value)) { return bad(path, "object", value); } + const minProperties = (schema as RecordSchema).minProperties; + const keyCount = Object.keys(value as object).length; + if (minProperties !== undefined && keyCount < minProperties) { + return { ok: false, error: `${path}: expected object with at least ${minProperties} key(s), got empty object` }; + } for (const [key, item] of Object.entries(value as object)) { const result = validateSchema(item, schema.additionalProperties, `${path}.${key}`, false); if (!result.ok) { @@ -1829,6 +1872,8 @@ function renderPromptIntentInstruction(intent: PromptIntent): string { return `Read environment variable ${JSON.stringify(intent.command ?? "")} and return its value as text. If the variable is not set, return ${JSON.stringify(intent.fallback ?? "")} instead${promptExecutionContext()}${options}`; case "prompt.write": return `Write file at path ${JSON.stringify(requiredPath(intent))} with contents:\n${intent.contents ?? ""}${promptExecutionContext()}${options}`; + case "prompt.writeOutput": + return `After generating the response, write the value of output field ${JSON.stringify(intent.field ?? "")} to the file at path ${JSON.stringify(requiredPath(intent))}${promptExecutionContext()}${options}`; case "prompt.glob": return `List files matching glob pattern ${JSON.stringify(intent.pattern ?? "")} and return the list of matching paths${promptExecutionContext()}${options}`; default: diff --git a/src/rig.test.ts b/src/rig.test.ts index b302ad7..8c77624 100644 --- a/src/rig.test.ts +++ b/src/rig.test.ts @@ -954,6 +954,23 @@ describe("prompt intents", () => { expect(intent.mode).toBe("prompt.text"); expect(intent.command).toBe("find src -name '*.ts'"); }); + + it("p.writeOutput stores field and path with mode prompt.writeOutput", () => { + const intent = p.writeOutput("report", "todo-report.md"); + + expect(intent.mode).toBe("prompt.writeOutput"); + expect(intent.field).toBe("report"); + expect(intent.path).toBe("todo-report.md"); + }); + + it("p.writeOutput supports options", () => { + const intent = p.writeOutput("summary", "output.md", { cwd: "/workspace" }); + + expect(intent.mode).toBe("prompt.writeOutput"); + expect(intent.field).toBe("summary"); + expect(intent.path).toBe("output.md"); + expect(intent.options).toEqual({ cwd: "/workspace" }); + }); }); describe("prompt builder", () => { @@ -1036,6 +1053,14 @@ describe("prompt builder", () => { expect(String(builder)).toContain('Read environment variable "GITHUB_TOKEN" and return its value as text. If the variable is not set, return "unset" instead'); expect(String(builder)).toContain("sandboxed agentic workflow"); }); + + it("renders p.writeOutput as a post-generation write instruction", () => { + const builder = p`Scan for TODOs. ${p.writeOutput("report", "todo-report.md")}`; + + expect(String(builder)).toContain('output field "report"'); + expect(String(builder)).toContain('"todo-report.md"'); + expect(String(builder)).toContain("sandboxed agentic workflow"); + }); }); describe("p template literal for instructions", () => { @@ -1426,6 +1451,55 @@ describe("s.nonEmptyArray", () => { }); }); +describe("s.nonEmptyObject", () => { + it("serializes to {type:'object', additionalProperties:..., minProperties:1}", () => { + expect(toJsonSchema(s.nonEmptyObject(s.string))).toEqual({ type: "object", additionalProperties: { type: "string" }, minProperties: 1 }); + expect(toJsonSchema(s.nonEmptyObject(s.number, "scores by name"))).toEqual({ + type: "object", + additionalProperties: { type: "number" }, + minProperties: 1, + description: "scores by name", + }); + }); + + it("accepts objects with at least one key", () => { + const result = analyzeResponse(JSON.stringify({ a: "hello" }), s.nonEmptyObject(s.string), "test", 1); + expect(result.ok).toBe(true); + }); + + it("accepts objects with multiple keys", () => { + const result = analyzeResponse(JSON.stringify({ a: "x", b: "y", c: "z" }), s.nonEmptyObject(s.string), "test", 1); + expect(result.ok).toBe(true); + }); + + it("rejects empty objects", () => { + const result = analyzeResponse(JSON.stringify({}), s.nonEmptyObject(s.string), "test", 1); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error.message).toContain("at least 1 key"); + } + }); + + it("still validates value types in a non-empty object", () => { + const result = analyzeResponse(JSON.stringify({ a: 42 }), s.nonEmptyObject(s.string), "test", 1); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error.message).toContain("string"); + } + }); + + it("works as an object field", () => { + const schema = s.object({ labels: s.nonEmptyObject(s.string) }); + expect(toJsonSchema(schema)).toEqual({ + type: "object", + properties: { labels: { type: "object", additionalProperties: { type: "string" }, minProperties: 1 } }, + required: ["labels"], + }); + const result = analyzeResponse(JSON.stringify({ labels: { env: "prod" } }), schema, "test", 1); + expect(result.ok).toBe(true); + }); +}); + describe("missing required field error message", () => { it("reports a missing required string field clearly", () => { const schema = s.object({ name: s.string, age: s.number });