diff --git a/docs/.vitepress/config.ts b/docs/.vitepress/config.ts index 4628b19..f658834 100644 --- a/docs/.vitepress/config.ts +++ b/docs/.vitepress/config.ts @@ -63,12 +63,40 @@ export default defineConfig({ { text: "Orchestration", link: "/concepts/orchestration" }, ], }, + { + text: "Tools", + collapsed: false, + items: [ + { text: "Overview", link: "/tools/index" }, + { text: "Bash", link: "/tools/bash" }, + { text: "Read", link: "/tools/read" }, + { text: "Write", link: "/tools/write" }, + { text: "Edit", link: "/tools/edit" }, + { text: "Grep", link: "/tools/grep" }, + { text: "Glob", link: "/tools/glob" }, + { text: "WebFetch", link: "/tools/web-fetch" }, + { text: "WebSearch", link: "/tools/web-search" }, + { text: "AskUserQuestion", link: "/tools/ask-user-question" }, + { text: "TodoWrite", link: "/tools/todo-write" }, + { text: "Sleep", link: "/tools/sleep" }, + { text: "Python", link: "/tools/python" }, + { text: "BrowserNavigate", link: "/tools/browser-navigate" }, + { text: "BrowserContent", link: "/tools/browser-content" }, + { text: "BrowserScroll", link: "/tools/browser-scroll" }, + { text: "BrowserAction", link: "/tools/browser-action" }, + { text: "spawn_agent", link: "/tools/spawn-agent" }, + { text: "close_agent", link: "/tools/close-agent" }, + { text: "send_input", link: "/tools/send-input" }, + { text: "wait_agent", link: "/tools/wait-agent" }, + ], + }, { text: "Guides", items: [ { text: "Build a Profile", link: "/guides/build-a-profile" }, { text: "Manage Prompts", link: "/guides/manage-prompts" }, { text: "Add Tools", link: "/guides/add-tools" }, + { text: "Tool Permissions", link: "/guides/tool-permissions" }, { text: "Add Context", link: "/guides/add-context" }, { text: "Configure Sessions", link: "/guides/configure-sessions" }, { text: "Consume Stream (SSE)", link: "/guides/consume-stream" }, diff --git a/docs/examples/deep-research.md b/docs/examples/deep-research.md index 63d65f7..ad38cc3 100644 --- a/docs/examples/deep-research.md +++ b/docs/examples/deep-research.md @@ -32,16 +32,16 @@ That separation is one of the main design goals of Agentrail. Start with these files: - example route wiring: - - [examples/deep-research/src/routes/run.ts](../../examples/deep-research/src/routes/run.ts) - - [examples/deep-research/src/routes/deep-research.ts](../../examples/deep-research/src/routes/deep-research.ts) + - [examples/deep-research/src/routes/run.ts](https://github.com/yai-dev/agentrail/blob/main/examples/deep-research/src/routes/run.ts) + - [examples/deep-research/src/routes/deep-research.ts](https://github.com/yai-dev/agentrail/blob/main/examples/deep-research/src/routes/deep-research.ts) - example runtime config: - - [examples/deep-research/src/config.ts](../../examples/deep-research/src/config.ts) + - [examples/deep-research/src/config.ts](https://github.com/yai-dev/agentrail/blob/main/examples/deep-research/src/config.ts) - workflow package entrypoint: - - [packages/deep-research/src/index.ts](../../packages/deep-research/src/index.ts) + - [packages/deep-research/src/index.ts](https://github.com/yai-dev/agentrail/blob/main/packages/deep-research/src/index.ts) - workflow coordinator: - - [packages/deep-research/src/coordinator.ts](../../packages/deep-research/src/coordinator.ts) + - [packages/deep-research/src/coordinator.ts](https://github.com/yai-dev/agentrail/blob/main/packages/deep-research/src/coordinator.ts) - workflow prompt assembly: - - [packages/deep-research/src/prompts.ts](../../packages/deep-research/src/prompts.ts) + - [packages/deep-research/src/prompts.ts](https://github.com/yai-dev/agentrail/blob/main/packages/deep-research/src/prompts.ts) ## Execution Shape diff --git a/docs/examples/playground-server.md b/docs/examples/playground-server.md index 0a0a3cb..7a791bc 100644 --- a/docs/examples/playground-server.md +++ b/docs/examples/playground-server.md @@ -191,13 +191,13 @@ Both routes share the same surrounding infrastructure. The stream route adds: ## Source Files To Read -| File | What it shows | -| ----------------------------------------------------------------------------------------------- | ---------------------------------------- | -| [routes/stream.ts](../../examples/playground-server/src/routes/stream.ts) | Full `createStreamRoute` options | -| [profiles/default-profile.ts](../../examples/playground-server/src/profiles/default-profile.ts) | `defineProfile` + capability descriptors | -| [prompts/index.ts](../../examples/playground-server/src/prompts/index.ts) | Fragment + bundle composition | -| [plugins/index.ts](../../examples/playground-server/src/plugins/index.ts) | Plugin registration | -| [context/index.ts](../../examples/playground-server/src/context/index.ts) | Singleton managers | +| File | What it shows | +| ---------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------- | +| [routes/stream.ts](https://github.com/yai-dev/agentrail/blob/main/examples/playground-server/src/routes/stream.ts) | Full `createStreamRoute` options | +| [profiles/default-profile.ts](https://github.com/yai-dev/agentrail/blob/main/examples/playground-server/src/profiles/default-profile.ts) | `defineProfile` + capability descriptors | +| [prompts/index.ts](https://github.com/yai-dev/agentrail/blob/main/examples/playground-server/src/prompts/index.ts) | Fragment + bundle composition | +| [plugins/index.ts](https://github.com/yai-dev/agentrail/blob/main/examples/playground-server/src/plugins/index.ts) | Plugin registration | +| [context/index.ts](https://github.com/yai-dev/agentrail/blob/main/examples/playground-server/src/context/index.ts) | Singleton managers | ## What Is Framework-Level vs Example-Level diff --git a/docs/examples/playground-ui.md b/docs/examples/playground-ui.md index ac74dcd..0ee0317 100644 --- a/docs/examples/playground-ui.md +++ b/docs/examples/playground-ui.md @@ -254,8 +254,8 @@ async function chatNonStream(message: string): Promise { Read in this order to understand the full client-server loop: 1. [Examples: Playground Server](playground-server.md) — how the server produces events -2. [examples/playground-ui/src/api.ts](../../examples/playground-ui/src/api.ts) — how the UI calls the API -3. [examples/playground-ui/src/App.tsx](../../examples/playground-ui/src/App.tsx) — top-level state and routing +2. [examples/playground-ui/src/api.ts](https://github.com/yai-dev/agentrail/blob/main/examples/playground-ui/src/api.ts) — how the UI calls the API +3. [examples/playground-ui/src/App.tsx](https://github.com/yai-dev/agentrail/blob/main/examples/playground-ui/src/App.tsx) — top-level state and routing 4. [Reference: Events](../reference/events.md) — full event type reference ## Related Docs diff --git a/docs/guides/add-context.md b/docs/guides/add-context.md index 11079fd..7a5774a 100644 --- a/docs/guides/add-context.md +++ b/docs/guides/add-context.md @@ -76,13 +76,13 @@ That path is especially useful when you want to compose: The current implementation lives in: -- [packages/capabilities/src/memory/context.ts](../../packages/capabilities/src/memory/context.ts) +- [packages/capabilities/src/memory/context.ts](https://github.com/yai-dev/agentrail/blob/main/packages/capabilities/src/memory/context.ts) ## Repository Example The playground example builds request context here: -- [examples/playground-server/src/context/index.ts](../../examples/playground-server/src/context/index.ts) +- [examples/playground-server/src/context/index.ts](https://github.com/yai-dev/agentrail/blob/main/examples/playground-server/src/context/index.ts) That file is a good example of how to: diff --git a/docs/guides/build-a-profile.md b/docs/guides/build-a-profile.md index 0b34a0b..3994b00 100644 --- a/docs/guides/build-a-profile.md +++ b/docs/guides/build-a-profile.md @@ -77,7 +77,7 @@ const app = createAgentApp({ The playground example follows this pattern in: -- [examples/playground-server/src/profiles/default-profile.ts](../../examples/playground-server/src/profiles/default-profile.ts) +- [examples/playground-server/src/profiles/default-profile.ts](https://github.com/yai-dev/agentrail/blob/main/examples/playground-server/src/profiles/default-profile.ts) That profile: diff --git a/docs/guides/manage-prompts.md b/docs/guides/manage-prompts.md index 9251162..3a0614e 100644 --- a/docs/guides/manage-prompts.md +++ b/docs/guides/manage-prompts.md @@ -93,7 +93,7 @@ const systemPromptBuilder = createPromptBuilder( This is the same style used by the playground example in: -- [examples/playground-server/src/prompts/index.ts](../../examples/playground-server/src/prompts/index.ts) +- [examples/playground-server/src/prompts/index.ts](https://github.com/yai-dev/agentrail/blob/main/examples/playground-server/src/prompts/index.ts) ## Layering Strategy @@ -148,9 +148,9 @@ For example: Current examples in this repository: - playground hosted system prompt: - - [examples/playground-server/src/prompts/index.ts](../../examples/playground-server/src/prompts/index.ts) + - [examples/playground-server/src/prompts/index.ts](https://github.com/yai-dev/agentrail/blob/main/examples/playground-server/src/prompts/index.ts) - deep-research workflow prompts: - - [packages/deep-research/src/prompts.ts](../../packages/deep-research/src/prompts.ts) + - [packages/deep-research/src/prompts.ts](https://github.com/yai-dev/agentrail/blob/main/packages/deep-research/src/prompts.ts) These show two useful patterns: diff --git a/docs/guides/tool-permissions.md b/docs/guides/tool-permissions.md new file mode 100644 index 0000000..bc3d8b5 --- /dev/null +++ b/docs/guides/tool-permissions.md @@ -0,0 +1,275 @@ +# Tool Permissions + +Control which tool calls an agent is allowed to execute, and how unapproved calls are handled. + +## Prerequisites + +Read this guide after: + +- [Quickstart](quickstart.md) +- [Add Tools](add-tools.md) + +## How It Works + +Every tool call passes through a permission check before execution. The check returns one of three decisions: + +| Decision | Meaning | +| --------- | ------------------------------------------------------------------- | +| `"allow"` | Execution proceeds immediately. | +| `"deny"` | Execution is blocked; the model receives an error result. | +| `"ask"` | Execution is suspended until the host approves or rejects the call. | + +The `ToolPermissionPolicy` you provide to `createAgentApp` drives this check. + +## Defining a Policy + +### Inline policy + +Build a policy directly using `parseRules` from `@agentrail/capabilities`: + +```ts +import { createAgentApp } from "@agentrail/app"; +import { parseRules } from "@agentrail/capabilities"; + +const app = createAgentApp({ + permissionPolicy: { + mode: "default", + allow: [], + deny: parseRules(["Bash(rm:*)"]), // block destructive shell commands + ask: parseRules(["Bash", "Write"]), // require approval for everything else + }, +}); +``` + +### Loading from `agentrail.yaml` + +Use `configPermissionsToPolicy` to convert the YAML config block into a runtime policy: + +```ts +import { createAgentApp, loadAgentrailConfig, configPermissionsToPolicy } from "@agentrail/app"; + +const config = loadAgentrailConfig(); +const app = createAgentApp({ + permissionPolicy: config.permissions ? configPermissionsToPolicy(config.permissions) : undefined, +}); +``` + +The corresponding YAML block: + +```yaml +permissions: + mode: default + ask: + - "Bash" + - "Write" + deny: + - "Bash(rm:*)" +``` + +## Rule DSL + +Rules are strings of the form `ToolName` or `ToolName(content-pattern)`. + +| Rule | Matches | +| ------------------------ | --------------------------------------------------------- | +| `"Bash"` | Any Bash call | +| `"Bash(git:*)"` | Bash calls whose command starts with `git:` | +| `"Bash(git:**)"` | Same — `**` spans `/` too, useful for multi-segment paths | +| `"Write"` | Any file write | +| `"Write(/workspace/*)"` | Writes to files directly under `/workspace/` | +| `"Write(/workspace/**)"` | Writes to any path under `/workspace/` | + +Rules are **prefix-anchored**: `Bash(git:*)` matches any command beginning with `git:`, including chained forms like `git:status; rm -rf /`. For strong shell isolation use the sandboxed environment. + +## Evaluation Order + +Rules are evaluated as: **deny → ask → allow → default**. + +The first matching rule in the highest-priority list wins. When no rule matches, the `mode` field determines the outcome. + +## Modes + +| Mode | Effect | +| --------------------- | ------------------------------------------------------------------- | +| `"default"` | Unmatched calls are allowed; `"ask"` triggers interactive approval. | +| `"strict"` | Unmatched calls are **denied**. Use to build an explicit allowlist. | +| `"acceptEdits"` | `"ask"` decisions on `Write` and `Edit` are auto-approved. | +| `"dontAsk"` | `"ask"` decisions are demoted to `"deny"` (headless environments). | +| `"bypassPermissions"` | All calls are unconditionally allowed (trusted automation only). | + +### Strict allowlist example + +```ts +{ + mode: "strict", + allow: parseRules([ + "Bash(git:*)", + "Bash(npm:*)", + "Read", + ]), + deny: [], + ask: [], +} +``` + +Everything not listed here is denied without any user prompt. + +## Interactive Approval (`"ask"`) + +When a tool call matches an `ask` rule and the host provides a `PermissionApprovalHandler`, the runtime: + +1. Emits a `permission_request` event on the SSE stream. +2. **Suspends** the tool call. +3. Waits for the handler to resolve. +4. Emits a `permission_resolved` event and either continues or returns an error result to the model. + +Wire up the handler via `createPermissionApprovalHandler` in `createStreamRoute`: + +```ts +import { createStreamRoute } from "@agentrail/app/advanced"; + +const stream = createStreamRoute({ + // ... + createPermissionApprovalHandler: (sessionId) => ({ + async requestApproval({ toolCallId, toolName, reason, signal }) { + // suspend until the user responds — resolve "approved" or "rejected" + return waitForUserDecision(sessionId, toolCallId, { signal }); + }, + }), +}); +``` + +When no handler is registered, `"ask"` behaves like `"deny"` (backward-compatible fallback). + +## UI Integration + +The SSE stream emits `permission_request` and `permission_resolved` events. A client can listen for these to show an approval prompt and POST the user's decision back to the server. + +### 1. Consume events from the stream + +```ts +for await (const event of streamChat(sessionId, message)) { + if (event.type === "permission_request") { + // { type, toolCallId, toolName, reason? } + setPendingPermission({ + toolCallId: event.toolCallId, + toolName: event.toolName, + reason: event.reason, + }); + } else if (event.type === "permission_resolved" || event.type === "turn.complete") { + setPendingPermission(null); + } +} +``` + +### 2. Send the user's decision to the server + +POST to `/api/sessions/:sessionId/respond` with `kind: "permission"`: + +```ts +async function respondToPermission( + sessionId: string, + decision: "approved" | "rejected", +): Promise { + const res = await fetch(`/api/sessions/${sessionId}/respond`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ kind: "permission", decision }), + }); + return res.ok; +} +``` + +The server resolves the suspended wait handle and the agent run continues (or receives a rejection error result). + +### 3. Render an approval prompt + +Only show the prompt when a `permission_request` is pending. Dismiss it only **after** the POST succeeds to avoid hiding the prompt on network failure: + +```tsx +function PermissionPrompt({ sessionId, pending, onDismiss }) { + const [submitting, setSubmitting] = useState(false); + const [error, setError] = useState(null); + + // Reset state when a new request arrives (consecutive approvals). + useEffect(() => { + setSubmitting(false); + setError(null); + }, [pending?.toolCallId]); + + if (!pending) return null; + + const respond = async (decision) => { + setSubmitting(true); + const ok = await respondToPermission(sessionId, decision); + if (ok) { + onDismiss(); + } else { + setError("Failed — please try again."); + setSubmitting(false); + } + }; + + return ( +
+

+ Allow {pending.toolName} to run? +

+ {pending.reason &&

{pending.reason}

} + {error &&

{error}

} + + +
+ ); +} +``` + +Key points: + +- **Reset state on new request** — `useEffect` keyed on `toolCallId` prevents button state leaking across consecutive approval prompts. +- **Dismiss after success** — call `onDismiss` only when `respondToPermission` returns `true`; on failure restore the buttons so the user can retry. +- **Disable during POST** — prevent double-submission while the request is in flight. + +## Per-Tool `checkPermissions` + +Individual tools can also enforce their own permission logic independently of the global policy. Define `checkPermissions` on `defineTool`: + +```ts +import { defineTool } from "@agentrail/core"; + +export const sensitiveOp = defineTool({ + name: "sensitive_op", + description: "An operation that requires explicit approval.", + parameters: Type.Object({ target: Type.String() }), + checkPermissions({ params }) { + if (params.target.startsWith("/etc/")) { + return { decision: "ask", reason: "Modifying system files requires approval." }; + } + return "allow"; + }, + async execute(params) { + /* ... */ + }, +}); +``` + +The global policy and per-tool `checkPermissions` are both evaluated; the stricter result wins. + +## Runtime Events + +| Event type | When emitted | +| --------------------- | --------------------------------------------------------------------------- | +| `permission_request` | A tool call is suspended waiting for approval. | +| `permission_resolved` | The approval decision has been made (`decision: "approved" \| "rejected"`). | + +Subscribe to these on your SSE stream to build custom approval UIs or audit logs. + +## Related Docs + +- [Add Tools](add-tools.md) +- [`createAgentApp` Reference](../reference/create-agent-app.md) +- `createStreamRoute` in `@agentrail/app/advanced` diff --git a/docs/reference/create-agent-app.md b/docs/reference/create-agent-app.md index 6792cdb..5672844 100644 --- a/docs/reference/create-agent-app.md +++ b/docs/reference/create-agent-app.md @@ -281,13 +281,13 @@ matches across `/` so path arguments in commands are matched correctly. **Permission modes:** -| `mode` | Default outcome | Description | -|--------|----------------|-------------| -| `"default"` | `allow` | Opt-in deny/ask. Only rules explicitly listed in `deny`/`ask` block tool calls. | -| `"strict"` | `deny` | Deny-by-default. Only operations listed in `allow` are permitted; everything else is blocked. Use for minimal-privilege configurations. | -| `"acceptEdits"` | `allow` | Like `default`, but `ask` decisions for `Write`/`Edit` tools are auto-approved. | -| `"dontAsk"` | `allow` | Like `default`, but `ask` decisions are demoted to `deny` (headless environments). | -| `"bypassPermissions"` | `allow` | All checks skipped (trusted automation only). | +| `mode` | Default outcome | Description | +| --------------------- | --------------- | --------------------------------------------------------------------------------------------------------------------------------------- | +| `"default"` | `allow` | Opt-in deny/ask. Only rules explicitly listed in `deny`/`ask` block tool calls. | +| `"strict"` | `deny` | Deny-by-default. Only operations listed in `allow` are permitted; everything else is blocked. Use for minimal-privilege configurations. | +| `"acceptEdits"` | `allow` | Like `default`, but `ask` decisions for `Write`/`Edit` tools are auto-approved. | +| `"dontAsk"` | `allow` | Like `default`, but `ask` decisions are demoted to `deny` (headless environments). | +| `"bypassPermissions"` | `allow` | All checks skipped (trusted automation only). | **Strict (deny-by-default) allowlist example:** @@ -296,11 +296,11 @@ import { parseRules } from "@agentrail/capabilities"; const app = createAgentApp({ permissionPolicy: { - mode: "strict", // deny anything not explicitly allowed + mode: "strict", // deny anything not explicitly allowed allow: parseRules([ - "Bash(git:*)", // prefix-match: any content starting with "git:" - "Bash(npm:*)", // prefix-match: any content starting with "npm:" - "Read", // permit all file reads + "Bash(git:*)", // prefix-match: any content starting with "git:" + "Bash(npm:*)", // prefix-match: any content starting with "npm:" + "Read", // permit all file reads ]), deny: [], ask: [], @@ -310,10 +310,10 @@ const app = createAgentApp({ > **Bash rule caveat:** Bash patterns are **prefix-anchored** (no trailing > `$`). `Bash(git:*)` matches any normalised command whose first word is -> `git`, but it also matches shell strings that merely *start* with `git:` -> — including chained forms like `git:status; curl evil.com`. Bash rules +> `git`, but it also matches shell strings that merely _start_ with `git:` +> — including chained forms like `git:status; curl evil.com`. Bash rules > are useful for coarse-grained allow/deny (e.g. block all `rm` calls), but -> they cannot provide strict command confinement. For strong shell +> they cannot provide strict command confinement. For strong shell > isolation, run agents in the sandboxed environment. **Loading from `agentrail.yaml`:** @@ -328,13 +328,11 @@ const config = loadAgentrailConfig(); const app = createAgentApp({ dataDir: "./data", profiles: [myProfile], - permissionPolicy: config.permissions - ? configPermissionsToPolicy(config.permissions) - : undefined, + permissionPolicy: config.permissions ? configPermissionsToPolicy(config.permissions) : undefined, }); ``` -See the [Permissions Guide](/guides/tool-permissions) for the full DSL reference. +See the [Tool Permissions Guide](/guides/tool-permissions) for the full DSL reference, mode descriptions, and interactive approval wiring. --- diff --git a/docs/reference/host-primitives.md b/docs/reference/host-primitives.md index d8dde5d..8fef308 100644 --- a/docs/reference/host-primitives.md +++ b/docs/reference/host-primitives.md @@ -46,7 +46,7 @@ import { createChatRoute, createStreamRoute } from "@agentrail/app/advanced"; Defined in: -- [packages/app/src/routes/chat-route.ts](../../packages/app/src/routes/chat-route.ts) +- [packages/app/src/routes/chat-route.ts](https://github.com/yai-dev/agentrail/blob/main/packages/app/src/routes/chat-route.ts) This is the non-streaming host entry point. @@ -152,7 +152,7 @@ app.route( Defined in: -- [packages/app/src/routes/stream-route.ts](../../packages/app/src/routes/stream-route.ts) +- [packages/app/src/routes/stream-route.ts](https://github.com/yai-dev/agentrail/blob/main/packages/app/src/routes/stream-route.ts) This is the streaming host entry point. @@ -288,7 +288,7 @@ Composes multiple rewrite transforms left-to-right before provider injection. Us Defined in: -- [packages/app/src/host/context-pipeline.ts](../../packages/app/src/host/context-pipeline.ts) +- [packages/app/src/host/context-pipeline.ts](https://github.com/yai-dev/agentrail/blob/main/packages/app/src/host/context-pipeline.ts) Converts an ordered list of `ContextProvider`s into the runtime `transformContext` function shape. It can also accept a base rewrite transform; in that case, providers run against the rewritten history and their messages are prepended afterward. @@ -296,7 +296,7 @@ Converts an ordered list of `ContextProvider`s into the runtime `transformContex Also defined in: -- [packages/app/src/host/context-pipeline.ts](../../packages/app/src/host/context-pipeline.ts) +- [packages/app/src/host/context-pipeline.ts](https://github.com/yai-dev/agentrail/blob/main/packages/app/src/host/context-pipeline.ts) Adapts legacy or runtime-style transform logic back into provider form. @@ -308,7 +308,7 @@ This helper is now legacy-only. It should be used only for prepend-only adapters Defined in: -- [packages/app/src/host/orchestration-registry.ts](../../packages/app/src/host/orchestration-registry.ts) +- [packages/app/src/host/orchestration-registry.ts](https://github.com/yai-dev/agentrail/blob/main/packages/app/src/host/orchestration-registry.ts) Manages per-session orchestration managers and lazy run initialization. diff --git a/docs/reference/plugin-contract.md b/docs/reference/plugin-contract.md index 916d9f1..8e6be51 100644 --- a/docs/reference/plugin-contract.md +++ b/docs/reference/plugin-contract.md @@ -34,7 +34,7 @@ It is not the right place for core agent reasoning behavior. That belongs in pro The current plugin contract is defined by `AgentrailPlugin` in: -- [packages/app/src/host/types.ts](../../packages/app/src/host/types.ts) +- [packages/app/src/host/types.ts](https://github.com/yai-dev/agentrail/blob/main/packages/app/src/host/types.ts) ### `AgentrailPlugin` interface @@ -455,7 +455,7 @@ When `onPluginError` is omitted, the default behavior is to log to `console.warn The current plugin runtime helpers live in: -- [packages/app/src/host/plugins.ts](../../packages/app/src/host/plugins.ts) +- [packages/app/src/host/plugins.ts](https://github.com/yai-dev/agentrail/blob/main/packages/app/src/host/plugins.ts) Important characteristics of the current model: @@ -469,7 +469,7 @@ Important characteristics of the current model: The playground example assembles plugins here: -- [examples/playground-server/src/plugins/index.ts](../../examples/playground-server/src/plugins/index.ts) +- [examples/playground-server/src/plugins/index.ts](https://github.com/yai-dev/agentrail/blob/main/examples/playground-server/src/plugins/index.ts) Current example plugins include: diff --git a/docs/reference/profile-contract.md b/docs/reference/profile-contract.md index 878bc29..528e150 100644 --- a/docs/reference/profile-contract.md +++ b/docs/reference/profile-contract.md @@ -71,7 +71,7 @@ export const analyticsProfile = defineProfile({ The low-level profile contract is defined by `AgentrailProfile` in: -- [packages/app/src/host/types.ts](../../packages/app/src/host/types.ts) +- [packages/app/src/host/types.ts](https://github.com/yai-dev/agentrail/blob/main/packages/app/src/host/types.ts) ```ts interface AgentrailProfileContext { @@ -179,7 +179,7 @@ A well-shaped profile usually does **not** own: The current recommended example is: -- [examples/playground-server/src/profiles/default-profile.ts](../../examples/playground-server/src/profiles/default-profile.ts) +- [examples/playground-server/src/profiles/default-profile.ts](https://github.com/yai-dev/agentrail/blob/main/examples/playground-server/src/profiles/default-profile.ts) That file shows: diff --git a/docs/tools/ask-user-question.md b/docs/tools/ask-user-question.md new file mode 100644 index 0000000..5038e5f --- /dev/null +++ b/docs/tools/ask-user-question.md @@ -0,0 +1,155 @@ +# `AskUserQuestion` + +Pause agent execution and ask the user a question, optionally presenting predefined choices. + +**Package:** `@agentrail/capabilities` + +## Parameters + +| Name | Type | Required | Description | +| ---------- | ---------- | -------- | ---------------------------------------------------------------------------------------------------------------- | +| `question` | `string` | Yes | The question to present to the user. Be specific and concise. | +| `hint` | `string` | No | Optional hint shown below the question (e.g. expected format, constraints). Omit when `options` are provided. | +| `options` | `string[]` | No | Predefined choices rendered as clickable buttons. Do not include an "Other" option — use `custom: true` instead. | +| `multiple` | `boolean` | No | Allow the user to select more than one option. Defaults to `false`. | +| `custom` | `boolean` | No | Add a "Type your own answer" entry automatically. Defaults to `true`. | + +## Result + +```ts +{ + question: string; + answer: string; +} +``` + +When `multiple` is `true`, selected options are returned as a comma-separated string. + +## How it works + +When called, the tool: + +1. Emits a `waiting_for_user_input` event on the runtime stream with the question and options. +2. Suspends execution and waits for the host to provide an answer via the wait-handle registry. +3. Resumes and returns the user's answer to the model. + +The host layer (e.g. the playground server) receives the `waiting_for_user_input` event and must POST the user's answer back to resume the run. + +## UI Integration + +`AskUserQuestion` is not just a tool parameter contract. It is a suspend-and-resume flow between: + +1. the runtime event stream +2. your UI +3. your host's wait-handle registry + +### 1. Runtime event sent to the UI + +The runtime emits a `waiting_for_user_input` event with the question payload: + +```ts +{ + type: "waiting_for_user_input"; + toolCallId: string; + question: string; + hint?: string; + options?: string[]; + multiple?: boolean; + custom?: boolean; +} +``` + +Your UI should listen for this event and render an input surface for the user. + +### 2. Recommended UI behavior + +- No `options`: show a text input and a submit button. +- `options` + `multiple: false`: show single-choice buttons. +- `options` + `multiple: true`: allow multi-select, then submit the combined answer. +- `custom: true`: allow a free-form answer in addition to predefined options. + +The reference implementation is: + +- [WaitingQuestionPrompt.tsx](https://github.com/yai-dev/agentrail/blob/main/examples/playground-ui/src/components/WaitingQuestionPrompt.tsx#L1) +- [App.tsx](https://github.com/yai-dev/agentrail/blob/main/examples/playground-ui/src/App.tsx#L551) + +### 3. Submit the user's answer back to the host + +Once the user answers, POST it back to the host so the suspended tool call can resume. + +Playground uses: + +```http +POST /api/sessions/:sessionId/respond +Content-Type: application/json +``` + +```json +{ + "kind": "question", + "answer": "Vitest" +} +``` + +Reference implementation: + +- [api.ts](https://github.com/yai-dev/agentrail/blob/main/examples/playground-ui/src/api.ts#L236) +- [sessions.ts](https://github.com/yai-dev/agentrail/blob/main/examples/playground-server/src/routes/sessions.ts#L196) + +### 4. How execution resumes + +After the host receives the answer: + +1. it resolves the pending wait handle for that session +2. the suspended `AskUserQuestion` tool call resumes +3. the tool returns `{ question, answer }` +4. the agent continues the same turn with the user's response now available as tool output + +In the playground server, this is handled by the wait-handle registry: + +- [wait-handle-registry.ts](https://github.com/yai-dev/agentrail/blob/main/examples/playground-server/src/wait-handle-registry.ts#L1) + +### Minimal client example + +```ts +if (event.type === "waiting_for_user_input") { + showQuestionPrompt(event); +} + +async function submitAnswer(sessionId: string, answer: string) { + await fetch(`/api/sessions/${sessionId}/respond`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ kind: "question", answer }), + }); +} +``` + +## Usage notes + +- When recommending a specific option, put it first and append `" (Recommended)"` to its label. +- Omit `options` for open-ended questions that require free-form text. +- When `custom: true` (default), a "Type your own answer" entry is added automatically — do not include an "Other" catch-all in `options`. +- The agent is blocked until the user responds. Use this tool sparingly; batch questions when possible. + +## Example + +```ts +// Open-ended question +{ + question: "What is the target deployment environment?", + hint: "e.g. AWS, GCP, Azure, or on-premises" +} + +// Multiple-choice question with a recommendation +{ + question: "Which testing framework should I use?", + options: ["Vitest (Recommended)", "Jest", "Mocha"], + custom: false +} +``` + +## Related + +- [TodoWrite](todo-write.md) +- [Consume Stream (SSE)](../guides/consume-stream.md) diff --git a/docs/tools/bash.md b/docs/tools/bash.md new file mode 100644 index 0000000..c451d66 --- /dev/null +++ b/docs/tools/bash.md @@ -0,0 +1,108 @@ +# `Bash` + +Execute a shell command and return its output. + +**Package:** `@agentrail/capabilities` + +## Parameters + +| Name | Type | Required | Description | +| ------------------- | --------- | -------- | ------------------------------------------------------------------------------------------------------------------------ | +| `command` | `string` | Yes | The shell command to execute. | +| `working_directory` | `string` | No | Absolute path to the working directory. Defaults to the agent process's current working directory. | +| `timeout` | `integer` | No | Milliseconds to wait before moving the process to background. Defaults to `30000`. Set to `0` to immediately background. | + +## Result + +The tool returns combined stdout and stderr as text. The `details` field is a discriminated union: + +**Foreground** (completed within `timeout`): + +```ts +{ + mode: "foreground"; + exit_code: number; + stdout: string; + stderr: string; +} +``` + +**Background** (still running after `timeout`): + +```ts +{ + mode: "background"; + pid?: number; + stdout: string; // partial output captured before timeout + stderr: string; +} +``` + +Non-zero exit codes are not treated as tool errors — always check `exit_code` in the result. + +## Permissions + +`Bash` has a built-in `checkPermissions` hook. Before executing, it: + +1. Normalizes the command string. +2. Checks whether the command is considered dangerous (e.g. `rm -rf /`). +3. Evaluates the active `ToolPermissionPolicy` (if one is configured) against the normalized command using `"command"` content mode. + +To require approval before every shell call: + +```yaml +# agentrail.yaml +permissions: + mode: default + ask: + - "Bash" +``` + +To block specific commands while allowing others: + +```yaml +permissions: + mode: default + deny: + - "Bash(rm:*)" + allow: + - "Bash(git:*)" + - "Bash(npm:*)" +``` + +See [Tool Permissions](../guides/tool-permissions.md) for the full DSL reference. + +## Usage notes + +- Do not use `Bash` for file reads or writes — use `Read`, `Write`, or `Edit` instead. +- Do not use `Bash` for file search — use `Grep` instead. +- Set `timeout: 0` for long-running processes (dev servers, watchers) that should run in the background. +- Output is truncated at 1 MB. + +## Example + +```ts +// In a tool call the agent would emit: +{ + command: "git log --oneline -5", + working_directory: "/workspace/myproject" +} +``` + +## Sandbox variant + +When the sandbox capability is active, a sandboxed `Bash` tool replaces the host-side variant. Key differences: + +- Commands run inside an isolated Docker container. +- The working directory must be inside `/workspace` (defaults to `/workspace`). +- Session memo files are at `/workspace/memo/session/`; user profile at `/workspace/memo/user/USER.md`. +- No `isDangerousCommand` check — the sandbox provides isolation instead. +- Background mode does not return a `pid` (the process runs inside the container). + +## Related + +- [Read](read.md) +- [Write](write.md) +- [Edit](edit.md) +- [Grep](grep.md) +- [Tool Permissions](../guides/tool-permissions.md) diff --git a/docs/tools/browser-action.md b/docs/tools/browser-action.md new file mode 100644 index 0000000..fbbcf5e --- /dev/null +++ b/docs/tools/browser-action.md @@ -0,0 +1,64 @@ +# `BrowserAction` + +Perform an interaction on the current browser page. + +**Package:** `@agentrail/capabilities` (sandbox-only) + +> This tool is available only when the sandboxed capability set is active. + +## Parameters + +| Name | Type | Required | Description | +| ---------- | ----------------------------------------------------------------------------- | -------- | ------------------------------------------------------------------------------------------------------------------------ | +| `type` | `"click"` \| `"fill"` \| `"select"` \| `"hover"` \| `"press"` \| `"evaluate"` | Yes | The type of action to perform. | +| `selector` | `string` | No | CSS selector of the target element. Required for `click`, `fill`, `select`, `hover`, and `press`. | +| `value` | `string` | No | Value to fill/select, or key to press (e.g. `"Enter"`, `"Escape"`, `"Tab"`). Required for `fill`, `select`, and `press`. | +| `script` | `string` | No | JavaScript to evaluate. Required for the `evaluate` action. | + +### Action types + +| Type | Description | +| ---------- | ------------------------------------------------------------ | +| `click` | Click the element identified by `selector`. | +| `fill` | Clear and set the value of an input or textarea. | +| `select` | Select an option in a `