diff --git a/README.md b/README.md index 04c2f8c..5195cec 100644 --- a/README.md +++ b/README.md @@ -122,6 +122,7 @@ Prompt intents for shell and file operations are optimized for sandboxed agentic p.bash("git status --short") p.bash("npm test") p.bashRaw`grep -rn 'app\.get\|app\.post' src/` // tagged template: no TypeScript escape needed +p.bashEach("curl -s -o /dev/null -w '%{http_code}' {} --max-time 5", "endpoints") // run once per element in input.endpoints p.read("README.md") p.readOptional("Dockerfile") // returns "" if file is absent p.readOptional(".eslintrc.json", "{}") // returns "{}" if file is absent @@ -130,6 +131,7 @@ p.readInput("path") // reads the file at the single path held p.readAllInput("files") // reads all files at the paths in input.files (array field) 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.writeInput("outputPath", "rendered") // after generation, write output field "rendered" to the path in input.outputPath 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 @@ -143,9 +145,13 @@ const reviewWorkspace = agent({ `p.bash(cmd)` requires backslashes to be escaped as in any TypeScript string literal. When a command contains regex patterns (e.g. `grep 'foo\|bar'`), use `p.bashRaw\`...\`` instead — it takes the command verbatim with no TypeScript escaping. +`p.bashEach(template, inputArrayField)` runs the template once per element in a caller-supplied string array, substituting `{}` with each element. Use it instead of prose-based iteration when the same command applies to every element. + `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. +`p.writeOutput(field, path)` instructs the harness to write the value of output field `field` to the **static** file path `path` after the agent generates its response. Use this instead of `p.write` when the content to be written is LLM-generated. + +`p.writeInput(inputPathField, contentOutputField)` is like `p.writeOutput` but accepts a **dynamic destination path** from an input field. Use it when the caller supplies the path to write to — e.g. `p.writeInput("outputPath", "rendered")` writes the `rendered` output field to the path held in `input.outputPath`. `p.readAll(paths)` reads all files in the array and concatenates their contents into a single block. Use it instead of repeated `p.read(...)` calls or `p.bash("cat ...")` when you need the full contents of a known set of files as one context block. diff --git a/skills/rig/SKILL.md b/skills/rig/SKILL.md index 2dba3bf..61f3034 100644 --- a/skills/rig/SKILL.md +++ b/skills/rig/SKILL.md @@ -75,6 +75,7 @@ Descriptions go first for scalar helpers, second after the shape for containers, |------------------|-----| | Shell command | `p.bash(command)` | | Shell command with literal backslashes | ``p.bashRaw`command` `` | +| Same command run once per element in an input array | `p.bashEach(template, inputArrayField)` — use `{}` as the element placeholder | | Known required/optional file | `p.read(path)` / `p.readOptional(path, fallback?)` | | Several known files | `p.readAll(paths)` | | Single path supplied in an input field | `p.readInput(field)` | @@ -83,7 +84,8 @@ Descriptions go first for scalar helpers, second after the shape for containers, | Environment or structured inline data | `p.env(name, fallback?)` / `p.json(value)` | | Reference an input field value in prose | `p.inputField(field)` | | Write content known while building the prompt | `p.write(path, content)` | -| Write an LLM-generated output field | `p.writeOutput(field, path)` | +| Write an LLM-generated output field to a static path | `p.writeOutput(field, path)` | +| Write an LLM-generated output field to a caller-supplied path | `p.writeInput(inputPathField, contentOutputField)` | Prefer: @@ -91,7 +93,7 @@ Prefer: instructions: p`Review ${p.read("README.md")} against ${p.bash("git status --short")}.` ``` -Do not replace file intents with `cat` commands or large in-memory strings. `p.readOptional(path, fallback?)` injects the fallback text directly into prompt context when the file is absent. `p.write` does not return a path; `p.writeOutput` requires a matching output-schema field. `p.readAll(paths)` accepts a known path list, not a glob pattern. `p.readInput(field)` reads the file at a **single** path held in the named input field; `p.readAllInput(field)` reads all files at the paths in an input array field and concatenates their contents — use it instead of prose-based iteration instructions when the input is a `s.array(s.path)` field. `p.bash` and `p.bashRaw` accept only static strings; to run a command that depends on a caller-supplied value, describe it in the instructions prose and reference `input.` by name — use `p.inputField(field)` to reference a non-path input value explicitly in prose instead of the opaque `${"input.field"}` literal. +Do not replace file intents with `cat` commands or large in-memory strings. `p.readOptional(path, fallback?)` injects the fallback text directly into prompt context when the file is absent. `p.write` does not return a path; `p.writeOutput` requires a matching output-schema field and a static path; `p.writeInput(inputPathField, contentOutputField)` is the alternative when the destination path comes from an input field. `p.readAll(paths)` accepts a known path list, not a glob pattern. `p.readInput(field)` reads the file at a **single** path held in the named input field; `p.readAllInput(field)` reads all files at the paths in an input array field and concatenates their contents — use it instead of prose-based iteration instructions when the input is a `s.array(s.path)` field. `p.bash` and `p.bashRaw` accept only static strings; when the **same command must run once per element** in a caller-supplied array, use `p.bashEach(template, field)` with `{}` as the element placeholder; for commands that depend on multiple fields or require branching, describe the iteration in prose and reference `input.` by name — use `p.inputField(field)` to reference a non-path input value explicitly in prose instead of the opaque `${"input.field"}` literal. ## Tools, composition, and reliability diff --git a/skills/rig/references/prompt-intents.md b/skills/rig/references/prompt-intents.md index ab7b4b6..3bf8c5d 100644 --- a/skills/rig/references/prompt-intents.md +++ b/skills/rig/references/prompt-intents.md @@ -32,13 +32,15 @@ Intent values are also accepted in agent inputs, but prefer template expressions |--------|-----| | `p.bash(command)` | Shell command written as a normal TypeScript string | | ``p.bashRaw`command` `` | Verbatim shell command with no TypeScript backslash escaping | +| `p.bashEach(template, inputArrayField)` | Run `template` once per element in `input.`, substituting `{}` with each element | | `p.read(path)` | Required file at a literal path | | `p.readOptional(path, fallback?)` | Literal path that may be absent; default fallback is `""` and is injected as prompt text | | `p.readAll(paths)` | Concatenated contents of several known files (path list only; no glob overload) | | `p.readInput(field)` | File contents at the **single** path held in `input.` at runtime | | `p.readAllInput(field)` | Concatenated contents of all files at the paths in `input.` (array) at runtime | | `p.write(path, content)` | Prompt instruction to write content already known | -| `p.writeOutput(field, path)` | Post-generation write of an output field | +| `p.writeOutput(field, path)` | Post-generation write of an output field to a **static** path | +| `p.writeInput(inputPathField, contentOutputField)` | Post-generation write of an output field to a **dynamic** path from an input field | | `p.glob(pattern)` | Runtime workspace path discovery | | `p.env(name, fallback?)` | Environment variable; default fallback is `""` | | `p.json(value)` | Immediate pretty-printed JSON for inline structured context | @@ -50,6 +52,7 @@ Examples: p.bash("git diff -- .") p.bash("npm test") p.bashRaw`grep -rn 'app\.get\|app\.post' src/` +p.bashEach("curl -s -o /dev/null -w '%{http_code}' {} --max-time 5", "endpoints") p.read("README.md") p.readOptional("Dockerfile") p.readOptional(".eslintrc.json", "{}") @@ -58,6 +61,7 @@ p.readInput("path") p.readAllInput("files") // reads all files at the paths in input.files (array) p.write("README.md", "# Hello\n") p.writeOutput("report", "todo-report.md") +p.writeInput("outputPath", "rendered") // writes output field "rendered" to path in input.outputPath p.glob("src/**/*.ts") p.env("GITHUB_TOKEN") p.env("GITHUB_TOKEN", "unset") @@ -83,17 +87,18 @@ Do not: - shell out through `cat` when `p.read` or `p.readAll` expresses the intent - construct large in-memory strings when the context already lives in files -## `p.write` versus `p.writeOutput` +## `p.write`, `p.writeOutput`, and `p.writeInput` | Situation | Use | |-----------|-----| | Content is known while building the prompt | `p.write(path, content)` | -| Content is generated into an output field | `p.writeOutput(field, path)` | -| TypeScript needs the written path as a value | Use the literal path; neither helper returns it | +| Content is generated into an output field; path is static | `p.writeOutput(field, path)` | +| Content is generated into an output field; path comes from an input field | `p.writeInput(inputPathField, contentOutputField)` | +| TypeScript needs the written path as a value | Use the literal path; no helper returns it | `p.write` is an instruction embedded in the prompt. It does not return the path, contents, or subsequently written file. Use a separate `p.read(path)` expression if later prompt context must read that file. -`p.writeOutput` runs after valid structured output is produced. Its field must exist in the output schema: +`p.writeOutput` runs after valid structured output is produced. Its field must exist in the output schema. The `path` argument is a **static string** fixed at definition time: ```ts import { agent, p, s } from "rig"; @@ -110,6 +115,22 @@ export default report; Do not rely on `p.writeOutput` to create an output field. +`p.writeInput` is like `p.writeOutput` but accepts a **dynamic destination path** from a caller-supplied input field. Use it when the path to write to is not known at definition time: + +```ts +import { agent, p, s } from "rig"; + +// Agent role: render a changelog and write it to the caller-supplied path. +const renderer = agent({ + model: "mini", + input: s.object({ outputPath: s.path }), + instructions: p`Generate a changelog entry. ${p.writeInput("outputPath", "rendered")}`, + output: s.object({ rendered: s.string }), +}); + +export default renderer; +``` + ## Dynamic path reads `p.read(path)` requires a literal path known at definition time. When a subagent receives the path in its input, defer the read with `p.readInput(field)`: @@ -221,6 +242,24 @@ export default diffAnalyzer; Do not use `p.bash("git diff " + input.base)` — `input` is not in scope at definition time. The correct pattern is prose instructions that reference `input.` by name. +When the **same command must run once per element** in a caller-supplied array, use `p.bashEach(template, inputArrayField)`. Write `{}` in the template as the element placeholder: + +```ts +import { agent, p, s } from "rig"; + +// Agent role: probe each caller-supplied URL and report its HTTP status. +const healthProbe = agent({ + model: "mini", + input: s.object({ endpoints: s.array(s.url) }), + instructions: p`${p.bashEach("curl -s -o /dev/null -w '%{http_code}' {} --max-time 5", "endpoints")}`, + output: s.object({ results: s.array(s.object({ url: s.url, status: s.string })) }), +}); + +export default healthProbe; +``` + +`p.bashEach` is the correct choice when every element receives the same command template. For commands that depend on multiple input fields or require branching logic, describe the full iteration strategy in prose instead. + ## Failures Shell and dynamic-read intents are instructions to the runtime/model. If a command exits non-zero or a dynamic file cannot be read, the resulting stderr or error message enters prompt context so the model can surface or recover from it. diff --git a/skills/rig/rig.ts b/skills/rig/rig.ts index a68d650..517f943 100644 --- a/skills/rig/rig.ts +++ b/skills/rig/rig.ts @@ -19,7 +19,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'|'readAll'|'write'|'writeOutput'|'glob'|'env',…} resolved into prompt text + * T:PromptIntent type declarative placeholder {kind:'bash'|'bashEach'|'read'|'readAll'|'write'|'writeOutput'|'writeInput'|'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 type {__rig:'prompt.var';name:string;value:T} named prompt variable [NEW] @@ -45,10 +45,12 @@ * 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.writeInput(inputPathField,contentOutputField,opts?) PromptIntent post-generation write declaration; writes output field value to the path given by input. [NEW] * p.glob(pattern,opts?) PromptIntent glob file-list declaration (not run in-process) * p.readAll(paths,opts?) PromptIntent multi-file read declaration; reads all listed paths and concatenates their contents * p.readInput(field,opts?) PromptIntent file read declaration using a runtime input field as the path; reads the file at the single path given by input. [NEW] * p.readAllInput(field,opts?) PromptIntent multi-file read declaration using a runtime input array field; reads all paths in input. and concatenates their contents [NEW] + * p.bashEach(template,inputArrayField,opts?) PromptIntent bash-per-element declaration; runs template once per element in input., substituting {} with each element [NEW] * 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 * p.inputField(field) string returns "input." for explicit, documented reference to a caller-supplied input field in prompt prose [NEW] @@ -658,9 +660,10 @@ export type PromptIntentOptions = { export type PromptIntent = { __rig: "prompt"; id: string; - mode: "prompt.text" | "prompt.read" | "prompt.write" | "prompt.glob" | "prompt.readOptional" | "prompt.env" | "prompt.writeOutput" | "prompt.readAll" | "prompt.readInput" | "prompt.readAllInput"; + mode: "prompt.text" | "prompt.read" | "prompt.write" | "prompt.glob" | "prompt.readOptional" | "prompt.env" | "prompt.writeOutput" | "prompt.writeInput" | "prompt.readAll" | "prompt.readInput" | "prompt.readAllInput" | "prompt.bashEach"; command?: string; path?: string; + pathField?: string; paths?: string[]; contents?: string; pattern?: string; @@ -825,6 +828,42 @@ type PromptHelpers = { * }); */ readAllInput(field: string, options?: PromptIntentOptions): PromptIntent; + /** + * Declarative intent that instructs the LLM to run `template` once per + * element in the array at input field `inputArrayField`, substituting `{}` + * with each element, and to collect all results. Neither the template nor + * the iteration is executed in-process; the harness expands this into a + * natural-language instruction resolved by the Copilot runtime. + * + * Use this when a uniform shell command must be run for every element in a + * caller-supplied string array — for example, probing each URL in a list. + * Use `{}` as the element placeholder inside the template string. + * + * @example + * // Run curl once per URL in input.endpoints: + * const healthProbe = agent({ + * input: s.object({ endpoints: s.array(s.url) }), + * instructions: p`Probe each endpoint: ${p.bashEach("curl -s -o /dev/null -w '%{http_code}' {} --max-time 5", "endpoints")}`, + * output: s.object({ results: s.array(s.object({ url: s.url, status: s.string })) }), + * }); + */ + bashEach(template: string, inputArrayField: string, options?: PromptIntentOptions): PromptIntent; + /** + * Declarative intent that instructs the LLM to write the value of output + * field `contentOutputField` to the file at the path provided by input field + * `inputPathField` after generating the response. Use this instead of + * `p.writeOutput(field, path)` when the destination path is caller-supplied + * rather than a static string known at definition time. + * + * @example + * // Write the generated report to the path supplied by the caller: + * const renderer = agent({ + * input: s.object({ outputPath: s.path }), + * instructions: p`Render the changelog. ${p.writeInput("outputPath", "rendered")}`, + * output: s.object({ rendered: s.string }), + * }); + */ + writeInput(inputPathField: string, contentOutputField: string, options?: PromptIntentOptions): PromptIntent; /** * Serializes `value` to a pretty-printed JSON string for inline use inside * prompt templates. Equivalent to `JSON.stringify(value, null, 2)`. @@ -991,6 +1030,12 @@ export const p: PromptHelpers = Object.assign( readAllInput(field: string, options?: PromptIntentOptions): PromptIntent { return createPromptIntent("prompt.readAllInput", withOptions({ field }, options)); }, + bashEach(template: string, inputArrayField: string, options?: PromptIntentOptions): PromptIntent { + return createPromptIntent("prompt.bashEach", withOptions({ command: template, field: inputArrayField }, options)); + }, + writeInput(inputPathField: string, contentOutputField: string, options?: PromptIntentOptions): PromptIntent { + return createPromptIntent("prompt.writeInput", withOptions({ pathField: inputPathField, field: contentOutputField }, options)); + }, json(value: unknown): string { return json(value); }, @@ -1976,6 +2021,10 @@ function renderPromptIntentInstruction(intent: PromptIntent): string { return `Read the file at the path provided by input field ${JSON.stringify(intent.field ?? "")} and return its contents as text${promptExecutionContext()}${options}`; case "prompt.readAllInput": return `Read all files at the paths provided by input field ${JSON.stringify(intent.field ?? "")} and concatenate their contents in order${promptExecutionContext()}${options}`; + case "prompt.bashEach": + return `For each element in the array at input field ${JSON.stringify(intent.field ?? "")}, run the command ${JSON.stringify(intent.command ?? "")} with {} replaced by the element, and collect all results${promptExecutionContext()}${options}`; + case "prompt.writeInput": + return `After generating the response, write the value of output field ${JSON.stringify(intent.field ?? "")} to the file at the path provided by input field ${JSON.stringify(intent.pathField ?? "")}${promptExecutionContext()}${options}`; default: throw new Error(`Unsupported prompt intent mode: ${(intent as { mode?: string }).mode ?? "unknown"}`); } diff --git a/src/rig.test.ts b/src/rig.test.ts index 720f437..85960f7 100644 --- a/src/rig.test.ts +++ b/src/rig.test.ts @@ -1027,6 +1027,40 @@ describe("prompt intents", () => { expect(intent.field).toBe("sources"); expect(intent.options).toEqual({ cwd: "/workspace" }); }); + + it("p.bashEach stores template and input array field with mode prompt.bashEach", () => { + const intent = p.bashEach("curl -s -o /dev/null -w '%{http_code}' {} --max-time 5", "endpoints"); + + expect(intent.mode).toBe("prompt.bashEach"); + expect(intent.command).toBe("curl -s -o /dev/null -w '%{http_code}' {} --max-time 5"); + expect(intent.field).toBe("endpoints"); + }); + + it("p.bashEach supports options", () => { + const intent = p.bashEach("ping -c 1 {}", "hosts", { cwd: "/workspace" }); + + expect(intent.mode).toBe("prompt.bashEach"); + expect(intent.command).toBe("ping -c 1 {}"); + expect(intent.field).toBe("hosts"); + expect(intent.options).toEqual({ cwd: "/workspace" }); + }); + + it("p.writeInput stores inputPathField and contentOutputField with mode prompt.writeInput", () => { + const intent = p.writeInput("outputPath", "rendered"); + + expect(intent.mode).toBe("prompt.writeInput"); + expect(intent.pathField).toBe("outputPath"); + expect(intent.field).toBe("rendered"); + }); + + it("p.writeInput supports options", () => { + const intent = p.writeInput("destPath", "content", { cwd: "/workspace" }); + + expect(intent.mode).toBe("prompt.writeInput"); + expect(intent.pathField).toBe("destPath"); + expect(intent.field).toBe("content"); + expect(intent.options).toEqual({ cwd: "/workspace" }); + }); }); describe("prompt builder", () => {