Skip to content
Merged
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
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, V> with minProperties: 1
s.nonEmptyObject(value, "description")
s.enum(...values)
s.enum(values, "description")
s.optional(shape)
Expand Down Expand Up @@ -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
Expand All @@ -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");
Expand Down
7 changes: 6 additions & 1 deletion skills/rig/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,8 @@ s.object(fields)
s.object(fields, "description")
s.record(value)
s.record(value, "description")
s.nonEmptyObject(value) // Record<string, V> with minProperties: 1
s.nonEmptyObject(value, "description")
s.enum(...values)
s.enum(values, "description")
s.optional(shape)
Expand All @@ -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<string, string> with at least one key
```

## Tools
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down
53 changes: 49 additions & 4 deletions skills/rig/rig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@
* T:AgentError class error carrying turn,agentName,rawOutput,parseError fields
* T:Tool<TArgs> type ToolConfig+name; created by defineTool
* T:ToolConfig<TArgs> type {description,parameters,handler}
* T:PromptIntent type declarative placeholder {kind:'bash'|'read'|'write'|'glob'|'env',…} resolved into prompt text
* T:PromptIntent type declarative placeholder {kind:'bash'|'read'|'write'|'writeOutput'|'glob'|'env',…} 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:PromptVariable<T> type {__rig:'prompt.var';name:string;value:T} named prompt variable [NEW]
Expand All @@ -34,6 +34,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")
Expand All @@ -43,6 +44,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; does NOT expand to path in template — hard-code path in output schema
* 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
Expand Down Expand Up @@ -89,7 +91,7 @@ export type ObjectSchema<Fields extends Record<string, Schema> = Record<string,
properties: Fields;
description?: string;
};
export type RecordSchema<Value extends Schema = Schema> = { type: "object"; additionalProperties: Value; description?: string };
export type RecordSchema<Value extends Schema = Schema> = { type: "object"; additionalProperties: Value; description?: string; minProperties?: number };
export type EnumSchema<Values extends readonly Json[] = readonly Json[]> = { enum: Values; description?: string };
const OPTIONAL_SYMBOL: unique symbol = Symbol("rig.optional");
type OptionalMarker = { readonly [OPTIONAL_SYMBOL]: true };
Expand Down Expand Up @@ -282,6 +284,19 @@ export const s = {
record<Value extends Schema>(additionalProperties: Value, description?: string): RecordSchema<Value> {
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<string, string> with at least one key
* s.nonEmptyObject(s.number, "scores by name") // Record<string, number> with at least one key and description
*/
nonEmptyObject<Value extends Schema>(additionalProperties: Value, description?: string): RecordSchema<Value> {
return description === undefined
? markAsSchema({ type: "object", additionalProperties, minProperties: 1 })
: markAsSchema({ type: "object", additionalProperties, minProperties: 1, description });
},
/**
* Schema for a closed set of literal values.
*
Expand Down Expand Up @@ -356,7 +371,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<string, JsonSchemaObject> = {};
Expand Down Expand Up @@ -634,12 +652,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<PromptIntentOptions, "signal">;
};

Expand Down Expand Up @@ -725,6 +744,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
Expand Down Expand Up @@ -870,6 +905,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));
},
Expand Down Expand Up @@ -1716,6 +1754,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) {
Expand Down Expand Up @@ -1834,6 +1877,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:
Expand Down
74 changes: 74 additions & 0 deletions src/rig.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down Expand Up @@ -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", () => {
Expand Down Expand Up @@ -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 });
Expand Down