Merge branch 'main' into feat/shared-checkout-parallel

This commit is contained in:
Bryan Helmkamp 2026-07-24 06:29:57 -04:00
commit 85f3286c66
No known key found for this signature in database
1335 changed files with 9245 additions and 2707 deletions

View file

@ -17,7 +17,7 @@ Output a report with all the bugs using this format:
<title>title of bug</title>
<description>brief description of bug</description>
<location>
<file>lib/crates/fabro-cli/src/commands/resume.rs</file>
<file>lib/apps/fabro-cli/src/commands/resume.rs</file>
<start_line>115</start_line>
<end_line>115</end_line>
</location>

View file

@ -19,7 +19,7 @@ Output a report with all the bugs using this format:
<severity>important OR nit</severity>
<pre_existing>yes OR no</pre_existing>
<location>
<file>lib/crates/fabro-cli/src/commands/resume.rs</file>
<file>lib/apps/fabro-cli/src/commands/resume.rs</file>
<start_line>115</start_line>
<end_line>115</end_line>
</location>
@ -51,7 +51,7 @@ Here is a real-world example:
<severity>important</severity>
<pre_existing>no</pre_existing>
<location>
<file>lib/crates/fabro-cli/src/commands/resume.rs</file>
<file>lib/apps/fabro-cli/src/commands/resume.rs</file>
<start_line>208</start_line>
<end_line>208</end_line>
</location>

View file

@ -4,32 +4,32 @@ Which source files affect which doc pages. Use this as guidance — also apply j
| Source | Docs |
|--------|------|
| `lib/crates/fabro-cli/src/main.rs`, `lib/crates/fabro-workflow/src/cli/mod.rs`, `lib/crates/fabro-workflow/src/cli/run.rs` | `docs/public/reference/cli.mdx` |
| `lib/crates/fabro-cli/src/cli_config.rs` | `docs/public/reference/cli-configuration.mdx` |
| `lib/crates/fabro-llm/src/cli.rs` | `docs/public/reference/cli.mdx` |
| `lib/crates/fabro-api/src/serve.rs` | `docs/public/reference/cli.mdx` |
| `lib/crates/fabro-workflow/src/parser/*.rs` | `docs/public/reference/dot-language.mdx` |
| `lib/crates/fabro-workflow/src/condition.rs` | `docs/public/reference/dot-language.mdx` |
| `lib/crates/fabro-workflow/src/cli/validate.rs` | `docs/public/reference/dot-language.mdx` |
| `lib/crates/fabro-workflow/src/stylesheet.rs` | `docs/public/workflows/stylesheets.mdx` |
| `lib/crates/fabro-workflow/src/transform.rs` | `docs/public/workflows/variables.mdx` |
| `lib/crates/fabro-workflow/src/handler/*.rs` | `docs/public/workflows/stages-and-nodes.mdx`, `docs/public/reference/dot-language.mdx` |
| `lib/crates/fabro-workflow/src/handler/human.rs` | `docs/public/workflows/human-in-the-loop.mdx` |
| `lib/crates/fabro-workflow/src/cli/run_config.rs` | `docs/public/execution/run-configuration.mdx` |
| `lib/crates/fabro-workflow/src/engine.rs` | `docs/public/core-concepts/how-arc-works.mdx` |
| `lib/crates/fabro-workflow/src/context/*.rs` | `docs/public/execution/context.mdx` |
| `lib/crates/fabro-workflow/src/checkpoint.rs` | `docs/public/execution/checkpoints.mdx` |
| `lib/crates/fabro-workflow/src/retro.rs`, `lib/crates/fabro-workflow/src/retro_agent.rs` | `docs/public/execution/retros.mdx` |
| `lib/crates/fabro-workflow/src/interviewer/*.rs` | `docs/public/execution/interviews.mdx` |
| `lib/crates/fabro-workflow/src/hook/*.rs` | `docs/public/agents/hooks.mdx` |
| `lib/crates/fabro-workflow/src/daytona_sandbox.rs` | `docs/public/integrations/daytona.mdx`, `docs/public/execution/environments.mdx` |
| `lib/crates/fabro-agent/src/tools.rs`, `lib/crates/fabro-agent/src/tool_registry.rs`, `lib/crates/fabro-agent/src/tool_execution.rs` | `docs/public/agents/tools.mdx` |
| `lib/crates/fabro-agent/src/v4a_patch.rs` | `docs/public/agents/tools.mdx` |
| `lib/crates/fabro-agent/src/cli.rs` | `docs/public/agents/permissions.mdx` |
| `lib/crates/fabro-agent/src/subagent.rs` | `docs/public/agents/subagents.mdx` |
| `lib/crates/fabro-agent/src/mcp_integration.rs` | `docs/public/agents/mcp.mdx` |
| `lib/crates/fabro-llm/src/catalog.rs`, `lib/crates/fabro-llm/src/providers/*.rs` | `docs/public/core-concepts/models.mdx` |
| `lib/crates/fabro-slack/src/*.rs` | `docs/public/integrations/slack.mdx` |
| `lib/crates/fabro-mcp/src/*.rs` | `docs/public/agents/mcp.mdx` |
| `lib/crates/fabro-api/src/*.rs` | `docs/public/api-reference/overview.mdx`, `docs/public/api-reference/demo-mode.mdx` |
| `lib/crates/fabro-api/src/server_config.rs` | `docs/public/administration/server-configuration.mdx` |
| `lib/apps/fabro-cli/src/main.rs`, `lib/components/fabro-workflow/src/cli/mod.rs`, `lib/components/fabro-workflow/src/cli/run.rs` | `docs/public/reference/cli.mdx` |
| `lib/apps/fabro-cli/src/cli_config.rs` | `docs/public/reference/cli-configuration.mdx` |
| `lib/components/fabro-llm/src/cli.rs` | `docs/public/reference/cli.mdx` |
| `lib/foundation/fabro-api/src/serve.rs` | `docs/public/reference/cli.mdx` |
| `lib/components/fabro-workflow/src/parser/*.rs` | `docs/public/reference/dot-language.mdx` |
| `lib/components/fabro-workflow/src/condition.rs` | `docs/public/reference/dot-language.mdx` |
| `lib/components/fabro-workflow/src/cli/validate.rs` | `docs/public/reference/dot-language.mdx` |
| `lib/components/fabro-workflow/src/stylesheet.rs` | `docs/public/workflows/stylesheets.mdx` |
| `lib/components/fabro-workflow/src/transform.rs` | `docs/public/workflows/variables.mdx` |
| `lib/components/fabro-workflow/src/handler/*.rs` | `docs/public/workflows/stages-and-nodes.mdx`, `docs/public/reference/dot-language.mdx` |
| `lib/components/fabro-workflow/src/handler/human.rs` | `docs/public/workflows/human-in-the-loop.mdx` |
| `lib/components/fabro-workflow/src/cli/run_config.rs` | `docs/public/execution/run-configuration.mdx` |
| `lib/components/fabro-workflow/src/engine.rs` | `docs/public/core-concepts/how-arc-works.mdx` |
| `lib/components/fabro-workflow/src/context/*.rs` | `docs/public/execution/context.mdx` |
| `lib/components/fabro-workflow/src/checkpoint.rs` | `docs/public/execution/checkpoints.mdx` |
| `lib/components/fabro-workflow/src/retro.rs`, `lib/components/fabro-workflow/src/retro_agent.rs` | `docs/public/execution/retros.mdx` |
| `lib/components/fabro-workflow/src/interviewer/*.rs` | `docs/public/execution/interviews.mdx` |
| `lib/components/fabro-workflow/src/hook/*.rs` | `docs/public/agents/hooks.mdx` |
| `lib/components/fabro-workflow/src/daytona_sandbox.rs` | `docs/public/integrations/daytona.mdx`, `docs/public/execution/environments.mdx` |
| `lib/components/fabro-agent/src/tools.rs`, `lib/components/fabro-agent/src/tool_registry.rs`, `lib/components/fabro-agent/src/tool_execution.rs` | `docs/public/agents/tools.mdx` |
| `lib/components/fabro-agent/src/v4a_patch.rs` | `docs/public/agents/tools.mdx` |
| `lib/components/fabro-agent/src/cli.rs` | `docs/public/agents/permissions.mdx` |
| `lib/components/fabro-agent/src/subagent.rs` | `docs/public/agents/subagents.mdx` |
| `lib/components/fabro-agent/src/mcp_integration.rs` | `docs/public/agents/mcp.mdx` |
| `lib/components/fabro-llm/src/catalog.rs`, `lib/components/fabro-llm/src/providers/*.rs` | `docs/public/core-concepts/models.mdx` |
| `lib/components/fabro-slack/src/*.rs` | `docs/public/integrations/slack.mdx` |
| `lib/components/fabro-mcp/src/*.rs` | `docs/public/agents/mcp.mdx` |
| `lib/foundation/fabro-api/src/*.rs` | `docs/public/api-reference/overview.mdx`, `docs/public/api-reference/demo-mode.mdx` |
| `lib/foundation/fabro-api/src/server_config.rs` | `docs/public/administration/server-configuration.mdx` |

View file

@ -12,7 +12,7 @@ digraph ImplementPlan {
implement [label="Implement", prompt="Read the plan file referenced in the goal and implement every step. Make all the code changes described in the plan. Use red/green TDD.", model="openai/gpt-5.6-sol", provider="openrouter", reasoning_effort="xhigh"]
simplify_fable [label="Simplify (Claude Fable 5)", prompt="@prompts/simplify.md", model="anthropic/claude-fable-5", provider="openrouter", reasoning_effort="xhigh"]
simplify_sol [label="Simplify (GPT-5.6 Sol)", prompt="@prompts/simplify.md", model="openai/gpt-5.6-sol", provider="openrouter", reasoning_effort="max"]
verify [label="Verify", shape=parallelogram, script="git fetch origin main 2>&1 && git merge --no-edit --no-stat origin/main 2>&1 && cargo +nightly-2026-04-14 fmt --all 2>&1 && cargo dev docs refresh 2>&1 && cargo +nightly-2026-04-14 fmt --check --all 2>&1 && { command -v rg >/dev/null 2>&1 || { echo 'rg is required for verify'; exit 127; }; } && ! rg -n 'AuthMode::Disabled|RunAuthMethod|RunSubjectProvenance|\bActorRef\b|\bActorKind\b|AuthenticatedSubject|AuthenticatedService|AuthorizeRunScoped|AuthorizeRunBlob|AuthorizeStageArtifact|AuthorizeCommandLog|auth_method\s*==\s*\"disabled\"' lib/crates apps lib/packages docs/public/api-reference/fabro-api.yaml 2>&1 && cargo +nightly-2026-04-14 clippy --workspace --all-targets -- -D warnings 2>&1 && cargo nextest run --workspace --status-level slow --profile ci 2>&1 && cargo dev docs check 2>&1 && bun install --frozen-lockfile 2>&1 && (cd apps/fabro-web && bun run typecheck) 2>&1 && (cd apps/fabro-web && bun run test) 2>&1 && (cd lib/packages/fabro-api-client && bun run typecheck) 2>&1 && cargo dev build -- -p fabro-cli --release 2>&1", goal_gate=true, retry_target="fixup"]
verify [label="Verify", shape=parallelogram, script="git fetch origin main 2>&1 && git merge --no-edit --no-stat origin/main 2>&1 && cargo +nightly-2026-04-14 fmt --all 2>&1 && cargo dev docs refresh 2>&1 && cargo +nightly-2026-04-14 fmt --check --all 2>&1 && { command -v rg >/dev/null 2>&1 || { echo 'rg is required for verify'; exit 127; }; } && ! rg -n 'AuthMode::Disabled|RunAuthMethod|RunSubjectProvenance|\bActorRef\b|\bActorKind\b|AuthenticatedSubject|AuthenticatedService|AuthorizeRunScoped|AuthorizeRunBlob|AuthorizeStageArtifact|AuthorizeCommandLog|auth_method\s*==\s*\"disabled\"' lib/crates apps lib/packages docs/public/api-reference/fabro-api.yaml 2>&1 && cargo +nightly-2026-04-14 clippy --workspace --all-targets -- -D warnings 2>&1 && cargo nextest run --workspace --status-level slow --profile ci 2>&1 && cargo dev docs check 2>&1 && bun install --frozen-lockfile 2>&1 && (cd apps/fabro-web && bun run typecheck) 2>&1 && (cd apps/fabro-web && bun run test) 2>&1 && (cd lib/packages/fabro-api-client && bun run typecheck) 2>&1 && cargo dev build -- -p fabro-cli --release 2>&1", timeout="20m", goal_gate=true, retry_target="fixup"]
fixup [label="Fixup", prompt="The verify step failed. Read the build output from context and fix all format, clippy, Rust test, docs, TypeScript typecheck/test, and build failures.", model="anthropic/claude-fable-5", provider="openrouter", reasoning_effort="xhigh", max_visits=3]
start -> toolchain

2
.gitattributes vendored
View file

@ -1,2 +1,2 @@
lib/crates/fabro-spa/assets/** linguist-generated=true -diff
lib/apps/fabro-spa/assets/** linguist-generated=true -diff
lib/packages/fabro-api-client/src/** linguist-generated=true

View file

@ -4,7 +4,9 @@ on:
push:
branches: [main]
paths:
- "lib/crates/**"
- "lib/apps/**"
- "lib/components/**"
- "lib/foundation/**"
- "test/**"
- "Cargo.toml"
- "Cargo.lock"
@ -18,7 +20,9 @@ on:
pull_request:
branches: [main]
paths:
- "lib/crates/**"
- "lib/apps/**"
- "lib/components/**"
- "lib/foundation/**"
- "test/**"
- "Cargo.toml"
- "Cargo.lock"
@ -76,7 +80,7 @@ jobs:
- name: Verify legacy auth identity removal
run: |
if git grep -nE 'AuthMode::Disabled|RunAuthMethod|RunSubjectProvenance|\bActorRef\b|\bActorKind\b|AuthenticatedSubject|AuthenticatedService|AuthorizeRunScoped|AuthorizeRunBlob|AuthorizeStageArtifact|AuthorizeCommandLog|auth_method\s*==\s*"disabled"' \
-- lib/crates apps lib/packages docs/public/api-reference/fabro-api.yaml; then
-- lib/apps lib/components lib/foundation apps lib/packages docs/public/api-reference/fabro-api.yaml; then
echo "::error::Legacy auth identities remain in the repository"
exit 1
else

View file

@ -5,7 +5,7 @@ on:
branches: [main]
paths:
- "apps/**"
- "lib/crates/fabro-spa/**"
- "lib/apps/fabro-spa/**"
- "lib/packages/**"
- "package.json"
- "bun.lock"
@ -17,7 +17,7 @@ on:
branches: [main]
paths:
- "apps/**"
- "lib/crates/fabro-spa/**"
- "lib/apps/fabro-spa/**"
- "lib/packages/**"
- "package.json"
- "bun.lock"

4
.gitignore vendored
View file

@ -4,8 +4,8 @@ target
node_modules
apps/fabro-web/dist
apps/fabro-web/.dist-builds/
lib/crates/fabro-spa/assets/*
!lib/crates/fabro-spa/assets/.gitkeep
lib/apps/fabro-spa/assets/*
!lib/apps/fabro-spa/assets/.gitkeep
tmp
evals/swe-bench/repos/
evals/swe-bench/results/

View file

@ -56,7 +56,7 @@ The OpenAPI spec at `docs/public/api-reference/fabro-api.yaml` is the source of
1. Edit `docs/public/api-reference/fabro-api.yaml`
2. `cargo build -p fabro-api` — build.rs regenerates Rust types and client via progenitor
3. Write/update handler in `lib/crates/fabro-server/src/server.rs`, add route to `build_router()`
3. Write/update handler in `lib/apps/fabro-server/src/server.rs`, add route to `build_router()`
4. `cargo nextest run -p fabro-server` — conformance test catches spec/router drift
5. `cd lib/packages/fabro-api-client && bun run generate` — regenerates TypeScript Axios client
@ -64,7 +64,7 @@ The OpenAPI spec at `docs/public/api-reference/fabro-api.yaml` is the source of
- Treat OpenAPI as the source of truth for the wire contract, not as the automatic owner of Rust types.
- Before adding or keeping a generated schema type, search the workspace for an existing hand-written Rust type with the same product meaning.
- If the schema and an existing Rust type have the same semantics and serde shape, reuse the existing type via `lib/crates/fabro-api/build.rs` `with_replacement(...)` instead of generating a parallel API type.
- If the schema and an existing Rust type have the same semantics and serde shape, reuse the existing type via `lib/foundation/fabro-api/build.rs` `with_replacement(...)` instead of generating a parallel API type.
- If two types are close but not identical, prefer proposing changes that align them into one canonical type rather than accepting small drift. It is usually better to iterate the API now than to create permanently split Rust/API types.
- Keep a separate API DTO only when the API is intentionally a projection, summary, or presentation-specific view of internal state. In that case, give it a distinct API-facing name instead of reusing the internal concept name.
- Treat `ApiFoo` aliases and `foo_to_api` / `foo_from_api` adapters as a smell unless they represent a real semantic boundary. They should not exist only to bridge accidental duplicate types.
@ -107,7 +107,7 @@ Before merging changes that add or move shared test helpers, verify:
Fabro is an AI-powered workflow orchestration platform. Workflows are defined as Graphviz graphs, where each node is a stage (agent, prompt, command, conditional, human, parallel, etc.) executed by the workflow engine.
### Rust crates (`lib/crates/`)
### Rust crates (`lib/apps/`, `lib/components/`, and `lib/foundation/`)
- **fabro-cli** — CLI entry point. Commands: `run`, `exec`, `serve`, `validate`, `parse`, `cp`, `model`, `doctor`, `install`, `ps`, `system prune`
- **fabro-workflow** — Core workflow engine. Parses Graphviz graphs, runs stages, manages checkpoints/resume, hooks, and human-in-the-loop interactions
- **fabro-agent** — AI coding agent with tool use (Bash, Read, Write, Edit, Glob, Grep, WebFetch). `Sandbox` trait abstracts execution environments

104
Cargo.lock generated
View file

@ -2239,7 +2239,7 @@ dependencies = [
[[package]]
name = "fabro-acp"
version = "0.303.0-nightly.2"
version = "0.303.0-nightly.4"
dependencies = [
"agent-client-protocol",
"agent-client-protocol-tokio",
@ -2258,7 +2258,7 @@ dependencies = [
[[package]]
name = "fabro-agent"
version = "0.303.0-nightly.2"
version = "0.303.0-nightly.4"
dependencies = [
"anyhow",
"async-trait",
@ -2300,7 +2300,7 @@ dependencies = [
[[package]]
name = "fabro-api"
version = "0.303.0-nightly.2"
version = "0.303.0-nightly.4"
dependencies = [
"chrono",
"fabro-automation",
@ -2323,7 +2323,7 @@ dependencies = [
[[package]]
name = "fabro-auth"
version = "0.303.0-nightly.2"
version = "0.303.0-nightly.4"
dependencies = [
"anyhow",
"async-trait",
@ -2348,7 +2348,7 @@ dependencies = [
[[package]]
name = "fabro-automation"
version = "0.303.0-nightly.2"
version = "0.303.0-nightly.4"
dependencies = [
"anyhow",
"chrono",
@ -2367,11 +2367,11 @@ dependencies = [
[[package]]
name = "fabro-build-support"
version = "0.303.0-nightly.2"
version = "0.303.0-nightly.4"
[[package]]
name = "fabro-checkpoint"
version = "0.303.0-nightly.2"
version = "0.303.0-nightly.4"
dependencies = [
"chrono",
"fabro-config",
@ -2387,7 +2387,7 @@ dependencies = [
[[package]]
name = "fabro-cli"
version = "0.303.0-nightly.2"
version = "0.303.0-nightly.4"
dependencies = [
"anyhow",
"assert_cmd",
@ -2489,7 +2489,7 @@ dependencies = [
[[package]]
name = "fabro-client"
version = "0.303.0-nightly.2"
version = "0.303.0-nightly.4"
dependencies = [
"anyhow",
"bytes",
@ -2518,7 +2518,7 @@ dependencies = [
[[package]]
name = "fabro-config"
version = "0.303.0-nightly.2"
version = "0.303.0-nightly.4"
dependencies = [
"anyhow",
"chrono",
@ -2547,7 +2547,7 @@ dependencies = [
[[package]]
name = "fabro-core"
version = "0.303.0-nightly.2"
version = "0.303.0-nightly.4"
dependencies = [
"async-trait",
"fabro-types",
@ -2562,7 +2562,7 @@ dependencies = [
[[package]]
name = "fabro-db"
version = "0.303.0-nightly.2"
version = "0.303.0-nightly.4"
dependencies = [
"anyhow",
"chrono",
@ -2574,7 +2574,7 @@ dependencies = [
[[package]]
name = "fabro-dev"
version = "0.303.0-nightly.2"
version = "0.303.0-nightly.4"
dependencies = [
"anyhow",
"assert_cmd",
@ -2593,7 +2593,7 @@ dependencies = [
[[package]]
name = "fabro-dump"
version = "0.303.0-nightly.2"
version = "0.303.0-nightly.4"
dependencies = [
"anyhow",
"bytes",
@ -2607,7 +2607,7 @@ dependencies = [
[[package]]
name = "fabro-environment"
version = "0.303.0-nightly.2"
version = "0.303.0-nightly.4"
dependencies = [
"anyhow",
"chrono",
@ -2629,7 +2629,7 @@ dependencies = [
[[package]]
name = "fabro-github"
version = "0.303.0-nightly.2"
version = "0.303.0-nightly.4"
dependencies = [
"anyhow",
"base64",
@ -2651,7 +2651,7 @@ dependencies = [
[[package]]
name = "fabro-graphviz"
version = "0.303.0-nightly.2"
version = "0.303.0-nightly.4"
dependencies = [
"anyhow",
"fabro-types",
@ -2659,13 +2659,14 @@ dependencies = [
"nom",
"regex",
"serde",
"serde_json",
"strum 0.28.0",
"thiserror 2.0.18",
]
[[package]]
name = "fabro-hooks"
version = "0.303.0-nightly.2"
version = "0.303.0-nightly.4"
dependencies = [
"async-trait",
"fabro-agent",
@ -2688,7 +2689,7 @@ dependencies = [
[[package]]
name = "fabro-http"
version = "0.303.0-nightly.2"
version = "0.303.0-nightly.4"
dependencies = [
"fabro-static",
"http 1.4.0",
@ -2698,7 +2699,7 @@ dependencies = [
[[package]]
name = "fabro-install"
version = "0.303.0-nightly.2"
version = "0.303.0-nightly.4"
dependencies = [
"anyhow",
"base64",
@ -2717,7 +2718,7 @@ dependencies = [
[[package]]
name = "fabro-interview"
version = "0.303.0-nightly.2"
version = "0.303.0-nightly.4"
dependencies = [
"async-trait",
"dialoguer",
@ -2732,7 +2733,7 @@ dependencies = [
[[package]]
name = "fabro-llm"
version = "0.303.0-nightly.2"
version = "0.303.0-nightly.4"
dependencies = [
"anyhow",
"async-trait",
@ -2773,7 +2774,7 @@ dependencies = [
[[package]]
name = "fabro-macros"
version = "0.303.0-nightly.2"
version = "0.303.0-nightly.4"
dependencies = [
"clap",
"fabro-options-metadata",
@ -2784,7 +2785,7 @@ dependencies = [
[[package]]
name = "fabro-manifest"
version = "0.303.0-nightly.2"
version = "0.303.0-nightly.4"
dependencies = [
"anyhow",
"fabro-api",
@ -2802,7 +2803,7 @@ dependencies = [
[[package]]
name = "fabro-mcp"
version = "0.303.0-nightly.2"
version = "0.303.0-nightly.4"
dependencies = [
"anyhow",
"axum",
@ -2822,7 +2823,7 @@ dependencies = [
[[package]]
name = "fabro-mcp-server"
version = "0.303.0-nightly.2"
version = "0.303.0-nightly.4"
dependencies = [
"anyhow",
"chrono",
@ -2849,7 +2850,7 @@ dependencies = [
[[package]]
name = "fabro-mcp-store"
version = "0.303.0-nightly.2"
version = "0.303.0-nightly.4"
dependencies = [
"chrono",
"fabro-db",
@ -2867,7 +2868,7 @@ dependencies = [
[[package]]
name = "fabro-model"
version = "0.303.0-nightly.2"
version = "0.303.0-nightly.4"
dependencies = [
"fabro-static",
"http 1.4.0",
@ -2883,7 +2884,7 @@ dependencies = [
[[package]]
name = "fabro-oauth"
version = "0.303.0-nightly.2"
version = "0.303.0-nightly.4"
dependencies = [
"anyhow",
"axum",
@ -2905,7 +2906,7 @@ dependencies = [
[[package]]
name = "fabro-options-metadata"
version = "0.303.0-nightly.2"
version = "0.303.0-nightly.4"
dependencies = [
"serde",
"serde_json",
@ -2913,7 +2914,7 @@ dependencies = [
[[package]]
name = "fabro-proc"
version = "0.303.0-nightly.2"
version = "0.303.0-nightly.4"
dependencies = [
"cc",
"libc",
@ -2922,7 +2923,7 @@ dependencies = [
[[package]]
name = "fabro-redact"
version = "0.303.0-nightly.2"
version = "0.303.0-nightly.4"
dependencies = [
"aho-corasick",
"ref-cast",
@ -2938,7 +2939,7 @@ dependencies = [
[[package]]
name = "fabro-sandbox"
version = "0.303.0-nightly.2"
version = "0.303.0-nightly.4"
dependencies = [
"anyhow",
"async-trait",
@ -2983,7 +2984,7 @@ dependencies = [
[[package]]
name = "fabro-server"
version = "0.303.0-nightly.2"
version = "0.303.0-nightly.4"
dependencies = [
"anyhow",
"async-trait",
@ -3075,7 +3076,7 @@ dependencies = [
[[package]]
name = "fabro-slack"
version = "0.303.0-nightly.2"
version = "0.303.0-nightly.4"
dependencies = [
"fabro-http",
"fabro-interview",
@ -3097,18 +3098,18 @@ dependencies = [
[[package]]
name = "fabro-spa"
version = "0.303.0-nightly.2"
version = "0.303.0-nightly.4"
dependencies = [
"rust-embed",
]
[[package]]
name = "fabro-static"
version = "0.303.0-nightly.2"
version = "0.303.0-nightly.4"
[[package]]
name = "fabro-store"
version = "0.303.0-nightly.2"
version = "0.303.0-nightly.4"
dependencies = [
"async-trait",
"bytes",
@ -3138,7 +3139,7 @@ dependencies = [
[[package]]
name = "fabro-telemetry"
version = "0.303.0-nightly.2"
version = "0.303.0-nightly.4"
dependencies = [
"anyhow",
"base64",
@ -3164,7 +3165,7 @@ dependencies = [
[[package]]
name = "fabro-template"
version = "0.303.0-nightly.2"
version = "0.303.0-nightly.4"
dependencies = [
"anyhow",
"fabro-types",
@ -3178,7 +3179,7 @@ dependencies = [
[[package]]
name = "fabro-test"
version = "0.303.0-nightly.2"
version = "0.303.0-nightly.4"
dependencies = [
"anyhow",
"assert_cmd",
@ -3203,7 +3204,7 @@ dependencies = [
[[package]]
name = "fabro-tool"
version = "0.303.0-nightly.2"
version = "0.303.0-nightly.4"
dependencies = [
"anyhow",
"async-trait",
@ -3224,7 +3225,7 @@ dependencies = [
[[package]]
name = "fabro-tracker"
version = "0.303.0-nightly.2"
version = "0.303.0-nightly.4"
dependencies = [
"anyhow",
"async-trait",
@ -3238,7 +3239,7 @@ dependencies = [
[[package]]
name = "fabro-types"
version = "0.303.0-nightly.2"
version = "0.303.0-nightly.4"
dependencies = [
"chrono",
"clap",
@ -3260,7 +3261,7 @@ dependencies = [
[[package]]
name = "fabro-util"
version = "0.303.0-nightly.2"
version = "0.303.0-nightly.4"
dependencies = [
"anyhow",
"console 0.15.11",
@ -3281,7 +3282,7 @@ dependencies = [
[[package]]
name = "fabro-validate"
version = "0.303.0-nightly.2"
version = "0.303.0-nightly.4"
dependencies = [
"fabro-acp",
"fabro-graphviz",
@ -3294,7 +3295,7 @@ dependencies = [
[[package]]
name = "fabro-variable"
version = "0.303.0-nightly.2"
version = "0.303.0-nightly.4"
dependencies = [
"anyhow",
"chrono",
@ -3311,7 +3312,7 @@ dependencies = [
[[package]]
name = "fabro-vault"
version = "0.303.0-nightly.2"
version = "0.303.0-nightly.4"
dependencies = [
"anyhow",
"chrono",
@ -3330,7 +3331,7 @@ dependencies = [
[[package]]
name = "fabro-workflow"
version = "0.303.0-nightly.2"
version = "0.303.0-nightly.4"
dependencies = [
"anyhow",
"assert_cmd",
@ -3368,6 +3369,7 @@ dependencies = [
"fabro-util",
"fabro-validate",
"fabro-vault",
"fabro-workflow",
"futures",
"git2",
"hex",
@ -8493,7 +8495,7 @@ dependencies = [
[[package]]
name = "twin-github"
version = "0.303.0-nightly.2"
version = "0.303.0-nightly.4"
dependencies = [
"axum",
"base64",
@ -8512,7 +8514,7 @@ dependencies = [
[[package]]
name = "twin-openai"
version = "0.303.0-nightly.2"
version = "0.303.0-nightly.4"
dependencies = [
"anyhow",
"async-stream",

View file

@ -1,11 +1,17 @@
[workspace]
members = ["lib/crates/*", "test/twin/openai", "test/twin/github"]
default-members = ["lib/crates/fabro-cli"]
members = [
"lib/apps/*",
"lib/components/*",
"lib/foundation/*",
"test/twin/openai",
"test/twin/github",
]
default-members = ["lib/apps/fabro-cli"]
resolver = "2"
[workspace.package]
edition = "2021"
version = "0.303.0-nightly.2"
version = "0.303.0-nightly.4"
license = "MIT"
[workspace.dependencies]
@ -84,7 +90,7 @@ hmac = "0.12"
sha2 = "0.10"
hex = "0.4"
insta = "1"
fabro-test = { path = "lib/crates/fabro-test" }
fabro-test = { path = "lib/foundation/fabro-test" }
twin-openai = { path = "test/twin/openai" }
twin-github = { path = "test/twin/github" }
tokio-tungstenite = { version = "0.26", features = ["rustls-tls-webpki-roots"] }
@ -100,11 +106,11 @@ rust-embed = "8"
percent-encoding = "2"
minijinja = "=2.19.0"
miette = { version = "7.6", features = ["fancy"] }
fabro-http = { path = "lib/crates/fabro-http" }
fabro-environment = { path = "lib/crates/fabro-environment" }
fabro-options-metadata = { path = "lib/crates/fabro-options-metadata" }
fabro-redact = { path = "lib/crates/fabro-redact" }
fabro-static = { path = "lib/crates/fabro-static" }
fabro-http = { path = "lib/foundation/fabro-http" }
fabro-environment = { path = "lib/components/fabro-environment" }
fabro-options-metadata = { path = "lib/foundation/fabro-options-metadata" }
fabro-redact = { path = "lib/foundation/fabro-redact" }
fabro-static = { path = "lib/foundation/fabro-static" }
graphviz-sys = { git = "https://github.com/fabro-sh/graphviz-sys" }
ref-cast = "1"
strum = { version = "0.28", features = ["derive"] }

View file

@ -0,0 +1,32 @@
import { describe, expect, test } from "bun:test";
import {
modelOfferingKey,
modelOfferingTestArgs,
} from "./model-offerings";
describe("model offering identity", () => {
const openai = { id: "portable-model", provider: "openai" };
const openrouter = { id: "portable-model", provider: "openrouter" };
test("keeps duplicate model IDs in independent row state", () => {
expect(modelOfferingKey(openai)).not.toBe(modelOfferingKey(openrouter));
const state = new Map([
[modelOfferingKey(openai), "ok"],
[modelOfferingKey(openrouter), "error"],
]);
expect(state.get(modelOfferingKey(openai))).toBe("ok");
expect(state.get(modelOfferingKey(openrouter))).toBe("error");
});
test("includes the row provider in model-test request arguments", () => {
expect(modelOfferingTestArgs(openai)).toEqual([
"portable-model",
"openai",
]);
expect(modelOfferingTestArgs(openrouter)).toEqual([
"portable-model",
"openrouter",
]);
});
});

View file

@ -0,0 +1,13 @@
import type { Model } from "@qltysh/fabro-api-client";
type ModelOfferingIdentity = Pick<Model, "provider" | "id">;
export function modelOfferingKey(model: ModelOfferingIdentity): string {
return `${model.provider}\u0000${model.id}`;
}
export function modelOfferingTestArgs(
model: ModelOfferingIdentity,
): [id: string, provider: string] {
return [model.id, model.provider];
}

View file

@ -23,7 +23,7 @@ export const handle = { wide: true, fullHeight: true };
type Direction = "LR" | "TB";
// Mirrors fabro-graphviz's RANKDIR_RE (lib/crates/fabro-graphviz/src/render.rs) —
// Mirrors fabro-graphviz's RANKDIR_RE (lib/components/fabro-graphviz/src/render.rs) —
// keep the accepted `rankdir=` syntax in sync with that regex.
const RANKDIR_RE = /rankdir\s*=\s*(\w+)/;

View file

@ -28,6 +28,10 @@ import {
} from "../components/runs-list/sort-header";
import { Tooltip } from "../components/ui";
import { formatContextWindow, formatTokensPerSecond } from "../lib/format";
import {
modelOfferingKey,
modelOfferingTestArgs,
} from "../lib/model-offerings";
import { useDebouncedValue } from "../hooks/effects";
export function meta() {
@ -277,30 +281,46 @@ function ModelsSection({ providers }: { providers: Provider[] }) {
const runSweep = useCallback(async () => {
if (running) return;
const ids = rows.map((r) => r.id);
if (ids.length === 0) return;
const offerings = rows.map((model) => ({
id: model.id,
provider: model.provider,
key: modelOfferingKey(model),
}));
if (offerings.length === 0) return;
const seed = new Map<string, RowState>();
for (const id of ids) seed.set(id, { phase: "queued" });
for (const offering of offerings) {
seed.set(offering.key, { phase: "queued" });
}
setResults(seed);
setSweep({ done: 0, total: ids.length, ok: 0, failed: 0 });
setSweep({ done: 0, total: offerings.length, ok: 0, failed: 0 });
let cursor = 0;
const worker = async () => {
while (cursor < ids.length) {
while (cursor < offerings.length) {
const i = cursor;
cursor += 1;
const id = ids[i];
const offering = offerings[i];
setResults((prev) => {
const next = new Map(prev);
next.set(id, { phase: "running" });
next.set(offering.key, { phase: "running" });
return next;
});
let outcome: RowState;
try {
const result = await apiData(() => modelsApi.testModel(id));
if (result.status === "ok") {
const result = await apiData(() =>
modelsApi.testModel(...modelOfferingTestArgs(offering)),
);
if (
result.provider !== offering.provider ||
result.model_id !== offering.id
) {
outcome = {
phase: "error",
message: `Server tested unexpected offering ${result.provider}/${result.model_id}`,
};
} else if (result.status === "ok") {
outcome = { phase: "ok" };
} else if (result.status === "error") {
outcome = {
@ -319,7 +339,7 @@ function ModelsSection({ providers }: { providers: Provider[] }) {
setResults((prev) => {
const next = new Map(prev);
next.set(id, outcome);
next.set(offering.key, outcome);
return next;
});
setSweep((prev) =>
@ -336,7 +356,7 @@ function ModelsSection({ providers }: { providers: Provider[] }) {
};
await Promise.all(
Array.from({ length: Math.min(TEST_CONCURRENCY, ids.length) }, () =>
Array.from({ length: Math.min(TEST_CONCURRENCY, offerings.length) }, () =>
worker(),
),
);
@ -435,12 +455,12 @@ function ModelsSection({ providers }: { providers: Provider[] }) {
<tbody>
{rows.map((model) => (
<ModelTableRow
key={model.id}
key={modelOfferingKey(model)}
model={model}
providerLabel={
providerNameById.get(model.provider) ?? model.provider
}
state={results.get(model.id)}
state={results.get(modelOfferingKey(model))}
/>
))}
</tbody>
@ -468,7 +488,7 @@ function ModelTableRow({
state: RowState | undefined;
}) {
return (
<tr className="border-b border-line transition-colors last:border-b-0 hover:bg-overlay/40">
<tr className="border-b border-line last:border-b-0">
<td className="whitespace-nowrap px-3 py-2.5 text-fg-3">
{providerLabel}
</td>

View file

@ -2,7 +2,7 @@
## Scope
- Production imports under `lib/crates/fabro-cli/src/**` that still reference `fabro_workflow::*` after the server-owned selector/export refactor.
- Production imports under `lib/apps/fabro-cli/src/**` that still reference `fabro_workflow::*` after the server-owned selector/export refactor.
- Test-only imports are listed separately so the remaining architectural debt is explicit.
## Completed In This Change
@ -16,30 +16,30 @@
| Path | Direct dependency | Why it still exists | Required remediation track |
| --- | --- | --- | --- |
| `lib/crates/fabro-cli/src/commands/pr/create.rs` | `StageOutcome`, `pull_request::maybe_open_pull_request` | CLI still reconstructs store state and runs PR creation logic from the workflow pipeline directly. | Replace with a server API, or extract PR orchestration into a non-engine shared service crate plus API. |
| `lib/crates/fabro-cli/src/commands/run/runner.rs` | `artifact_snapshot::CapturedArtifactInfo`, `artifact_upload::{ArtifactSink, StageArtifactUploader}`, `event::{Emitter, RunEventSink}`, `operations::{self, StartServices}`, `run_control::RunControlState`, `runtime_store::{RunStoreBackend, RunStoreHandle}` | Hidden worker subprocess path still lives inside the CLI crate and embeds the workflow engine directly. | Re-home worker/runtime code outside the user CLI surface, ideally into a dedicated worker crate or binary. |
| `lib/crates/fabro-cli/src/manifest_builder.rs` | `git::{GitSyncStatus, head_sha, sync_status}` | Manifest submission still relies on git helper logic that happens to live in `fabro_workflow`. | Extract git-sync inspection helpers into a non-workflow shared crate/module. |
| `lib/crates/fabro-cli/src/server_client.rs` | `artifact_snapshot::CapturedArtifactInfo` | The upload client reuses a workflow-owned artifact snapshot DTO. | Extract shared artifact snapshot DTOs into `fabro-store`, `fabro-types`, or a dedicated shared crate. |
| `lib/crates/fabro-cli/src/commands/runs/inspect.rs` | `run_status::RunStatus` | CLI output types still depend on engine-owned run status enums. | Extract shared status types into `fabro-types` or switch to API-generated/public store types. |
| `lib/crates/fabro-cli/src/commands/runs/list.rs` | `run_status::RunStatus` | List rendering still depends on engine-owned run status enums. | Extract shared status types into `fabro-types` or switch to API-generated/public store types. |
| `lib/crates/fabro-cli/src/commands/run/attach.rs` | `StageOutcome`, `run_status::RunStatus` | Attach/replay logic still formats engine-owned terminal status types directly. | Extract shared run/conclusion status types into `fabro-types`. |
| `lib/crates/fabro-cli/src/commands/run/output.rs` | `StageOutcome`, `records::Conclusion` | Human-readable completion output still consumes workflow-owned conclusion/status records. | Extract shared conclusion/status DTOs into `fabro-types` or `fabro-store`. |
| `lib/crates/fabro-cli/src/commands/run/wait.rs` | `records::Conclusion`, `run_status::RunStatus` | Wait output still depends on workflow-owned status/conclusion records. | Extract shared conclusion/status DTOs into `fabro-types` or `fabro-store`. |
| `lib/crates/fabro-cli/src/commands/run/run_progress/stage_display.rs` | `StageOutcome`, `format_cost` | Progress UI still depends on shared stage outcome and workflow-owned cost-formatting helper code. | Move formatting helpers into `fabro-util`. |
| `lib/crates/fabro-cli/src/commands/run/run_progress/info_display.rs` | `event::RunNoticeLevel` | Progress UI still formats workflow-owned notice levels directly. | Extract shared notice/event enums into `fabro-types`. |
| `lib/crates/fabro-cli/src/commands/run/run_progress/event.rs` | `event::RunNoticeLevel` | Progress event translation still depends on workflow-owned notice levels. | Extract shared notice/event enums into `fabro-types`. |
| `lib/apps/fabro-cli/src/commands/pr/create.rs` | `StageOutcome`, `pull_request::maybe_open_pull_request` | CLI still reconstructs store state and runs PR creation logic from the workflow pipeline directly. | Replace with a server API, or extract PR orchestration into a non-engine shared service crate plus API. |
| `lib/apps/fabro-cli/src/commands/run/runner.rs` | `artifact_snapshot::CapturedArtifactInfo`, `artifact_upload::{ArtifactSink, StageArtifactUploader}`, `event::{Emitter, RunEventSink}`, `operations::{self, StartServices}`, `run_control::RunControlState`, `runtime_store::{RunStoreBackend, RunStoreHandle}` | Hidden worker subprocess path still lives inside the CLI crate and embeds the workflow engine directly. | Re-home worker/runtime code outside the user CLI surface, ideally into a dedicated worker crate or binary. |
| `lib/apps/fabro-cli/src/manifest_builder.rs` | `git::{GitSyncStatus, head_sha, sync_status}` | Manifest submission still relies on git helper logic that happens to live in `fabro_workflow`. | Extract git-sync inspection helpers into a non-workflow shared crate/module. |
| `lib/apps/fabro-cli/src/server_client.rs` | `artifact_snapshot::CapturedArtifactInfo` | The upload client reuses a workflow-owned artifact snapshot DTO. | Extract shared artifact snapshot DTOs into `fabro-store`, `fabro-types`, or a dedicated shared crate. |
| `lib/apps/fabro-cli/src/commands/runs/inspect.rs` | `run_status::RunStatus` | CLI output types still depend on engine-owned run status enums. | Extract shared status types into `fabro-types` or switch to API-generated/public store types. |
| `lib/apps/fabro-cli/src/commands/runs/list.rs` | `run_status::RunStatus` | List rendering still depends on engine-owned run status enums. | Extract shared status types into `fabro-types` or switch to API-generated/public store types. |
| `lib/apps/fabro-cli/src/commands/run/attach.rs` | `StageOutcome`, `run_status::RunStatus` | Attach/replay logic still formats engine-owned terminal status types directly. | Extract shared run/conclusion status types into `fabro-types`. |
| `lib/apps/fabro-cli/src/commands/run/output.rs` | `StageOutcome`, `records::Conclusion` | Human-readable completion output still consumes workflow-owned conclusion/status records. | Extract shared conclusion/status DTOs into `fabro-types` or `fabro-store`. |
| `lib/apps/fabro-cli/src/commands/run/wait.rs` | `records::Conclusion`, `run_status::RunStatus` | Wait output still depends on workflow-owned status/conclusion records. | Extract shared conclusion/status DTOs into `fabro-types` or `fabro-store`. |
| `lib/apps/fabro-cli/src/commands/run/run_progress/stage_display.rs` | `StageOutcome`, `format_cost` | Progress UI still depends on shared stage outcome and workflow-owned cost-formatting helper code. | Move formatting helpers into `fabro-util`. |
| `lib/apps/fabro-cli/src/commands/run/run_progress/info_display.rs` | `event::RunNoticeLevel` | Progress UI still formats workflow-owned notice levels directly. | Extract shared notice/event enums into `fabro-types`. |
| `lib/apps/fabro-cli/src/commands/run/run_progress/event.rs` | `event::RunNoticeLevel` | Progress event translation still depends on workflow-owned notice levels. | Extract shared notice/event enums into `fabro-types`. |
## Test-Only Couplings
| Path | Direct dependency | Why it still exists | Suggested handling |
| --- | --- | --- | --- |
| `lib/crates/fabro-cli/src/commands/dump.rs` test module | `event::{Event, append_event}` | Unit tests synthesize workflow events directly. | Low priority; keep until a lighter-weight event fixture helper exists. |
| `lib/crates/fabro-cli/src/commands/run/wait.rs` test module | `StageOutcome`, `records::Conclusion`, `run_status::RunStatusRecord` | Output tests construct workflow-owned records directly. | Replace with shared fixture builders once conclusion DTOs move out. |
| `lib/crates/fabro-cli/src/commands/run/run_progress/mod.rs` test module | `event::{Event, RunNoticeLevel, to_run_event, to_run_event_at}`, `outcome::billed_model_usage_from_llm` | Progress tests build engine events directly. | Replace with shared event fixture helpers after event DTO extraction. |
| `lib/crates/fabro-cli/src/commands/run/run_progress/event.rs` test module | `event::{Event, to_run_event}` | Event rendering tests depend on engine event constructors. | Replace with shared event fixture helpers after event DTO extraction. |
| `lib/crates/fabro-cli/src/commands/run/runner.rs` test module | `artifact_upload::StageArtifactUploader` | Worker tests still reach into workflow upload internals. | Keep with worker re-home work; not worth separating first. |
| `lib/crates/fabro-cli/tests/it/workflow/real_cli.rs` | `context::Context`, `event::Emitter`, `handler::agent::{CodergenBackend, CodergenResult}`, `handler::llm::cli::AgentCliBackend` | Integration test exercises the real workflow engine directly through CLI harnesses. | Accept as engine integration coverage or move under workflow-owned test support later. |
| `lib/crates/fabro-cli/tests/it/scenario/recovery.rs` | `operations::{RunTimeline, build_timeline}` | Scenario test inspects rewind timeline internals directly. | Replace after server-owned rewind/timeline APIs exist. |
| `lib/apps/fabro-cli/src/commands/dump.rs` test module | `event::{Event, append_event}` | Unit tests synthesize workflow events directly. | Low priority; keep until a lighter-weight event fixture helper exists. |
| `lib/apps/fabro-cli/src/commands/run/wait.rs` test module | `StageOutcome`, `records::Conclusion`, `run_status::RunStatusRecord` | Output tests construct workflow-owned records directly. | Replace with shared fixture builders once conclusion DTOs move out. |
| `lib/apps/fabro-cli/src/commands/run/run_progress/mod.rs` test module | `event::{Event, RunNoticeLevel, to_run_event, to_run_event_at}`, `outcome::billed_model_usage_from_llm` | Progress tests build engine events directly. | Replace with shared event fixture helpers after event DTO extraction. |
| `lib/apps/fabro-cli/src/commands/run/run_progress/event.rs` test module | `event::{Event, to_run_event}` | Event rendering tests depend on engine event constructors. | Replace with shared event fixture helpers after event DTO extraction. |
| `lib/apps/fabro-cli/src/commands/run/runner.rs` test module | `artifact_upload::StageArtifactUploader` | Worker tests still reach into workflow upload internals. | Keep with worker re-home work; not worth separating first. |
| `lib/apps/fabro-cli/tests/it/workflow/real_cli.rs` | `context::Context`, `event::Emitter`, `handler::agent::{CodergenBackend, CodergenResult}`, `handler::llm::cli::AgentCliBackend` | Integration test exercises the real workflow engine directly through CLI harnesses. | Accept as engine integration coverage or move under workflow-owned test support later. |
| `lib/apps/fabro-cli/tests/it/scenario/recovery.rs` | `operations::{RunTimeline, build_timeline}` | Scenario test inspects rewind timeline internals directly. | Replace after server-owned rewind/timeline APIs exist. |
## Follow-Up Order

View file

@ -48,9 +48,9 @@ That envelope is stronger than most comparator systems. It gives Fabro stable to
Relevant current Fabro sources:
- `docs-internal/events-strategy.md`
- `lib/crates/fabro-workflow/src/event.rs`
- `lib/crates/fabro-types/src/run_event/mod.rs`
- `lib/crates/fabro-agent/src/types.rs`
- `lib/components/fabro-workflow/src/event.rs`
- `lib/foundation/fabro-types/src/run_event/mod.rs`
- `lib/components/fabro-agent/src/types.rs`
## Comparison Matrix

View file

@ -1215,26 +1215,6 @@ Emitted when the agent detects a tool-use loop.
No properties.
### `agent.turn.limit`
Emitted when the agent reaches its maximum turn count.
```json
{
"id": "...", "ts": "...", "run_id": "...",
"event": "agent.turn.limit",
"node_id": "code", "node_label": "code",
"session_id": "ses_abc",
"properties": {
"max_turns": 25
}
}
```
| Property | Type | Description |
|----------|------|-------------|
| `max_turns` | number | Maximum turns allowed |
### `agent.skill.expanded`
```json

View file

@ -269,21 +269,21 @@ The durable model remains simple: replay ordered events, no duplicate truth laye
An engineer implementing this proposal should make only these structural changes unless a later section explicitly says otherwise.
1. Update [`RunEvent`](/Users/bhelmkamp/p/fabro-sh/fabro/lib/crates/fabro-types/src/run_event/mod.rs) to add:
1. Update [`RunEvent`](/Users/bhelmkamp/p/fabro-sh/fabro/lib/foundation/fabro-types/src/run_event/mod.rs) to add:
- `stage_id`
- `parallel_group_id`
- `parallel_branch_id`
- `tool_call_id`
- `actor`
2. Update `RunEvent::to_value()` and `RunEvent` parsing in [`run_event/mod.rs`](/Users/bhelmkamp/p/fabro-sh/fabro/lib/crates/fabro-types/src/run_event/mod.rs) so the new envelope fields serialize and deserialize.
3. Extend `StoredEventFields` and `stored_event_fields()` in [`event.rs`](/Users/bhelmkamp/p/fabro-sh/fabro/lib/crates/fabro-workflow/src/event.rs) to populate:
2. Update `RunEvent::to_value()` and `RunEvent` parsing in [`run_event/mod.rs`](/Users/bhelmkamp/p/fabro-sh/fabro/lib/foundation/fabro-types/src/run_event/mod.rs) so the new envelope fields serialize and deserialize.
3. Extend `StoredEventFields` and `stored_event_fields()` in [`event.rs`](/Users/bhelmkamp/p/fabro-sh/fabro/lib/components/fabro-workflow/src/event.rs) to populate:
- `stage_id`
- `parallel_group_id`
- `parallel_branch_id`
- `tool_call_id` on tool-lifecycle events
- `actor` when there is a clear primary actor
These values should come from the emitter's current execution context for stage and parallel scope, and from event-specific payloads for `tool_call_id`.
4. Leave [`EventEnvelope`](/Users/bhelmkamp/p/fabro-sh/fabro/lib/crates/fabro-store/src/types.rs) structurally unchanged:
4. Leave [`EventEnvelope`](/Users/bhelmkamp/p/fabro-sh/fabro/lib/components/fabro-store/src/types.rs) structurally unchanged:
- `seq: u32`
- `payload: EventPayload`
5. Update API/SSE envelope serialization so wire JSON is flattened:
@ -304,11 +304,11 @@ An engineer implementing this proposal should make only these structural changes
V2 should keep the current hand-coded domain split for prop structs:
- run props in [`run.rs`](/Users/bhelmkamp/p/fabro-sh/fabro/lib/crates/fabro-types/src/run_event/run.rs)
- stage and checkpoint props in [`stage.rs`](/Users/bhelmkamp/p/fabro-sh/fabro/lib/crates/fabro-types/src/run_event/stage.rs)
- agent props in [`agent.rs`](/Users/bhelmkamp/p/fabro-sh/fabro/lib/crates/fabro-types/src/run_event/agent.rs)
- infra/setup props in [`infra.rs`](/Users/bhelmkamp/p/fabro-sh/fabro/lib/crates/fabro-types/src/run_event/infra.rs)
- parallel/interview/git/misc props in [`misc.rs`](/Users/bhelmkamp/p/fabro-sh/fabro/lib/crates/fabro-types/src/run_event/misc.rs)
- run props in [`run.rs`](/Users/bhelmkamp/p/fabro-sh/fabro/lib/foundation/fabro-types/src/run_event/run.rs)
- stage and checkpoint props in [`stage.rs`](/Users/bhelmkamp/p/fabro-sh/fabro/lib/foundation/fabro-types/src/run_event/stage.rs)
- agent props in [`agent.rs`](/Users/bhelmkamp/p/fabro-sh/fabro/lib/foundation/fabro-types/src/run_event/agent.rs)
- infra/setup props in [`infra.rs`](/Users/bhelmkamp/p/fabro-sh/fabro/lib/foundation/fabro-types/src/run_event/infra.rs)
- parallel/interview/git/misc props in [`misc.rs`](/Users/bhelmkamp/p/fabro-sh/fabro/lib/foundation/fabro-types/src/run_event/misc.rs)
That split is part of the design quality. V2 should keep adding hand-coded prop structs, not collapse everything into generic maps.
@ -374,7 +374,6 @@ V2 keeps the current durable family surface broadly intact.
- `agent.error`
- `agent.warning`
- `agent.loop.detected`
- `agent.turn.limit`
- `agent.steering.injected`
- `agent.compaction.started`
- `agent.compaction.completed`

View file

@ -1,6 +1,6 @@
# Fabro MCP Server — QA Test Plan
One-time manual QA pass for the 5 tools exposed by `fabro-mcp-server`. Source of truth: `lib/crates/fabro-mcp-server/src/run_tools/`.
One-time manual QA pass for the 5 tools exposed by `fabro-mcp-server`. Source of truth: `lib/apps/fabro-mcp-server/src/run_tools/`.
This plan is **not** a template for adding automated test coverage — it exists to drive a single hands-on sweep against a real running server. Tick boxes as scenarios pass; add notes inline for failures or surprising behavior. Open bugs/PRs for issues found; do not port these scenarios into the Rust test suite.

View file

@ -9,7 +9,7 @@ Migrations are product-facing compatibility code. Treat them like startup and st
Each crate owns the migrations for the data it owns.
```text
lib/crates/<crate>/
lib/<layer>/<crate>/
migrations/
YYYYMMDDSS_descriptive_name.rs
src/migrations.rs

View file

@ -170,14 +170,14 @@ Required payload fields:
Emit from the same places that currently call `put_status`:
- `lib/crates/fabro-workflow/src/operations/create.rs`
- `lib/crates/fabro-workflow/src/operations/start.rs`
- `lib/crates/fabro-workflow/src/operations/resume.rs`
- `lib/crates/fabro-workflow/src/pipeline/finalize.rs`
- `lib/crates/fabro-workflow/src/lifecycle/disk.rs`
- `lib/components/fabro-workflow/src/operations/create.rs`
- `lib/components/fabro-workflow/src/operations/start.rs`
- `lib/components/fabro-workflow/src/operations/resume.rs`
- `lib/components/fabro-workflow/src/pipeline/finalize.rs`
- `lib/components/fabro-workflow/src/lifecycle/disk.rs`
- CLI administrative flows that directly mutate status:
- `lib/crates/fabro-cli/src/commands/runs/rm.rs`
- `lib/crates/fabro-cli/src/commands/run/rewind.rs`
- `lib/apps/fabro-cli/src/commands/runs/rm.rs`
- `lib/apps/fabro-cli/src/commands/run/rewind.rs`
### 2. Enrich `checkpoint.completed` to carry a full checkpoint snapshot
@ -200,11 +200,11 @@ Add fields covering:
Emitter seam:
- `lib/crates/fabro-workflow/src/lifecycle/event.rs`
- `lib/components/fabro-workflow/src/lifecycle/event.rs`
Producer seam for the source checkpoint object:
- `lib/crates/fabro-workflow/src/lifecycle/disk.rs`
- `lib/components/fabro-workflow/src/lifecycle/disk.rs`
Design rule:
@ -231,7 +231,7 @@ Add:
Producer seam:
- `lib/crates/fabro-workflow/src/pipeline/pull_request.rs`
- `lib/components/fabro-workflow/src/pipeline/pull_request.rs`
After this lands, `put_pull_request` should become removable during the later memoized-state cutover.
@ -245,7 +245,7 @@ Do not add a separate storage-shaped event. The final patch is run-level termina
Enrich `run.completed`, using the patch already computed from:
- `lib/crates/fabro-workflow/src/lifecycle/git.rs`
- `lib/components/fabro-workflow/src/lifecycle/git.rs`
Required payload:
@ -287,9 +287,9 @@ Required projected output:
Likely seams:
- `lib/crates/fabro-workflow/src/handler/agent.rs`
- `lib/crates/fabro-workflow/src/handler/llm/api.rs`
- `lib/crates/fabro-workflow/src/pipeline/retro.rs` if retro uses the same forwarded agent session path
- `lib/components/fabro-workflow/src/handler/agent.rs`
- `lib/components/fabro-workflow/src/handler/llm/api.rs`
- `lib/components/fabro-workflow/src/pipeline/retro.rs` if retro uses the same forwarded agent session path
- any CLI-backed LLM path if it still produces `provider_used.json`
Do not keep the current “read JSON sidecar, then `put_node_provider_used`” pattern once this event exists.
@ -304,7 +304,7 @@ Use the existing terminal parallel event rather than adding a storage-shaped eve
Add to `parallel.completed`, emitted from:
- `lib/crates/fabro-workflow/src/handler/parallel.rs`
- `lib/components/fabro-workflow/src/handler/parallel.rs`
Required new payload:
@ -346,19 +346,19 @@ Likely files to touch:
- `docs-internal/events.md`
- `docs-internal/run-directory-keys.md`
- `docs-internal/events-strategy.md`
- `lib/crates/fabro-workflow/src/event.rs`
- `lib/crates/fabro-workflow/src/lifecycle/event.rs`
- `lib/crates/fabro-workflow/src/lifecycle/disk.rs`
- `lib/crates/fabro-workflow/src/lifecycle/git.rs`
- `lib/crates/fabro-workflow/src/operations/create.rs`
- `lib/crates/fabro-workflow/src/operations/start.rs`
- `lib/crates/fabro-workflow/src/operations/resume.rs`
- `lib/crates/fabro-workflow/src/pipeline/finalize.rs`
- `lib/crates/fabro-workflow/src/pipeline/pull_request.rs`
- `lib/crates/fabro-workflow/src/handler/agent.rs`
- `lib/crates/fabro-workflow/src/handler/parallel.rs`
- `lib/crates/fabro-cli/src/commands/runs/rm.rs`
- `lib/crates/fabro-cli/src/commands/run/rewind.rs`
- `lib/components/fabro-workflow/src/event.rs`
- `lib/components/fabro-workflow/src/lifecycle/event.rs`
- `lib/components/fabro-workflow/src/lifecycle/disk.rs`
- `lib/components/fabro-workflow/src/lifecycle/git.rs`
- `lib/components/fabro-workflow/src/operations/create.rs`
- `lib/components/fabro-workflow/src/operations/start.rs`
- `lib/components/fabro-workflow/src/operations/resume.rs`
- `lib/components/fabro-workflow/src/pipeline/finalize.rs`
- `lib/components/fabro-workflow/src/pipeline/pull_request.rs`
- `lib/components/fabro-workflow/src/handler/agent.rs`
- `lib/components/fabro-workflow/src/handler/parallel.rs`
- `lib/apps/fabro-cli/src/commands/runs/rm.rs`
- `lib/apps/fabro-cli/src/commands/run/rewind.rs`
- tests in `fabro-workflow`, `fabro-cli`, and `fabro-store`
## Phases

View file

@ -5,8 +5,8 @@ This document maps the files that still live under a run scratch directory. Dura
Scope:
- Scratch root: `~/.fabro/scratch/YYYYMMDD-{run_id}/`
- This covers local run files only
- Persistent store keys live in `lib/crates/fabro-store/src/keys.rs`
- Artifact object-store keys live in `lib/crates/fabro-store/src/artifact_store.rs`
- Persistent store keys live in `lib/components/fabro-store/src/keys.rs`
- Artifact object-store keys live in `lib/components/fabro-store/src/artifact_store.rs`
There is no `_init.json` anymore. Run existence in the database is determined by stored run events, and local scratch directories are managed separately under `scratch/`.

View file

@ -22,7 +22,7 @@ Method:
### [x] 1. Change two slow `exec` mock responses from retriable `500` to non-retriable `400`
Files:
- `lib/crates/fabro-cli/tests/it/cmd/exec.rs`
- `lib/apps/fabro-cli/tests/it/cmd/exec.rs`
Measured evidence:
- `fabro-cli::it::cmd::exec::exec_cli_server_target_overrides_configured_server_target`: `6.858s` median
@ -32,7 +32,7 @@ Measured evidence:
- mocked `400`: `0.053s` median
Implementation status:
- Implemented in `lib/crates/fabro-cli/tests/it/cmd/exec.rs`
- Implemented in `lib/apps/fabro-cli/tests/it/cmd/exec.rs`
- Verified with `ulimit -n 4096` via 5 targeted nextest runs per test
- Post-change nextest exec-time medians:
- `exec_server_target_uses_remote_transport_instead_of_local_api_key_resolution`: `1.580s`
@ -57,7 +57,7 @@ Cons:
### 2. Short-circuit delete-path worker grace for already-terminal runs
Files:
- `lib/crates/fabro-server/src/server.rs`
- `lib/apps/fabro-server/src/server.rs`
Measured evidence:
- `fabro-cli::it::cmd::system_prune::system_prune_yes_deletes_matching_runs`: `10.477s`
@ -87,11 +87,11 @@ Cons:
### [x] 3. Collapse the five abnormally slow `help` integration tests into one smoke test or a lighter harness
Files:
- `lib/crates/fabro-cli/tests/it/cmd/artifact.rs`
- `lib/crates/fabro-cli/tests/it/cmd/artifact_list.rs`
- `lib/crates/fabro-cli/tests/it/cmd/artifact_cp.rs`
- `lib/crates/fabro-cli/tests/it/cmd/config.rs`
- `lib/crates/fabro-cli/tests/it/cmd/attach.rs`
- `lib/apps/fabro-cli/tests/it/cmd/artifact.rs`
- `lib/apps/fabro-cli/tests/it/cmd/artifact_list.rs`
- `lib/apps/fabro-cli/tests/it/cmd/artifact_cp.rs`
- `lib/apps/fabro-cli/tests/it/cmd/config.rs`
- `lib/apps/fabro-cli/tests/it/cmd/attach.rs`
Measured evidence:
- Slow `help` tests:
@ -131,8 +131,8 @@ Cons:
### [x] 4. Replace `doctor_no_color_when_no_color_set` with a render-path assertion
Files:
- `lib/crates/fabro-cli/tests/it/cmd/doctor.rs`
- `lib/crates/fabro-util/src/check_report.rs`
- `lib/apps/fabro-cli/tests/it/cmd/doctor.rs`
- `lib/foundation/fabro-util/src/check_report.rs`
Measured evidence:
- `fabro-cli::it::cmd::doctor::doctor_no_color_when_no_color_set`: `5.131s`
@ -144,7 +144,7 @@ Estimated impact:
Implementation status:
- Implemented by deleting `fabro-cli::it::cmd::doctor::doctor_no_color_when_no_color_set`
- Added a unit-level render assertion in `lib/crates/fabro-cli/src/commands/doctor.rs`:
- Added a unit-level render assertion in `lib/apps/fabro-cli/src/commands/doctor.rs`:
- `render_report_text_without_color_has_no_ansi`
- Verified with `ulimit -n 4096; cargo nextest run -p fabro-cli render_report_text_without_color_has_no_ansi --status-level fail --final-status-level fail --show-progress none`
- Verification result: `1 passed`
@ -166,8 +166,8 @@ Cons:
### [x] 5. Fix local Unix-socket autostart so it doesn't burn the full 5s readiness wait
Files:
- `lib/crates/fabro-cli/src/server_client.rs`
- `lib/crates/fabro-cli/tests/it/cmd/server_start.rs`
- `lib/apps/fabro-cli/src/server_client.rs`
- `lib/apps/fabro-cli/tests/it/cmd/server_start.rs`
Measured evidence:
- Pre-fix 5-run timing for `fabro-cli::it::cmd::server_start::concurrent_autostart_converges_on_one_shared_daemon_and_cleans_up`:
@ -197,7 +197,7 @@ Implementation status:
- Implemented by splitting the Unix-socket connection path into:
- a single immediate health probe before autostart
- the existing retrying readiness wait after autostart
- Kept the original integration test coverage in `lib/crates/fabro-cli/tests/it/cmd/server_start.rs`
- Kept the original integration test coverage in `lib/apps/fabro-cli/tests/it/cmd/server_start.rs`
- Verified with `ulimit -n 4096; cargo nextest run -p fabro-cli concurrent_autostart_converges_on_one_shared_daemon_and_cleans_up --status-level fail --final-status-level fail --show-progress none`
- Verification result: `1 passed`
- Post-change 5-run timing for `fabro-cli::it::cmd::server_start::concurrent_autostart_converges_on_one_shared_daemon_and_cleans_up`:
@ -217,7 +217,7 @@ Implementation status:
### [x] 6. Collapse three lightweight `attach` smoke tests into one scenario-style test
Files:
- `lib/crates/fabro-cli/tests/it/cmd/attach.rs`
- `lib/apps/fabro-cli/tests/it/cmd/attach.rs`
Measured evidence:
- `attach_requires_run_arg`: `1.595s`
@ -230,7 +230,7 @@ Estimated impact:
- Conservative recoverable time: about `3.15s`
Implementation status:
- Implemented by removing the 3 command-owned smoke tests from `lib/crates/fabro-cli/tests/it/cmd/attach.rs`
- Implemented by removing the 3 command-owned smoke tests from `lib/apps/fabro-cli/tests/it/cmd/attach.rs`
- Added `fabro-cli::it::scenario::smoke::attach_smoke_covers_arg_validation_and_remote_server_behaviors`
- Verified with `ulimit -n 4096; cargo nextest run -p fabro-cli attach_smoke_covers_arg_validation_and_remote_server_behaviors --status-level fail --final-status-level fail --show-progress none`
- Verification result: `1 passed`
@ -256,7 +256,7 @@ Cons:
### [x] 7. Collapse the three `completion` tests
Files:
- `lib/crates/fabro-cli/tests/it/cmd/completion.rs`
- `lib/apps/fabro-cli/tests/it/cmd/completion.rs`
Measured evidence:
- `completion::generates_zsh_completions`: `1.567s`
@ -295,7 +295,7 @@ Cons:
### [x] 8. Remove or merge the duplicate attach replay test
Files:
- `lib/crates/fabro-cli/tests/it/cmd/attach.rs`
- `lib/apps/fabro-cli/tests/it/cmd/attach.rs`
Measured evidence:
- `attach_replays_completed_detached_run`: `2.696s`
@ -303,7 +303,7 @@ Measured evidence:
- The two tests are currently identical in code and assertions
Implementation status:
- Implemented by removing the duplicate test from `lib/crates/fabro-cli/tests/it/cmd/attach.rs`
- Implemented by removing the duplicate test from `lib/apps/fabro-cli/tests/it/cmd/attach.rs`
- Verified with `ulimit -n 4096; cargo nextest run -p fabro-cli attach_replays_completed_detached_run --status-level fail --final-status-level fail --show-progress none`
- Verification result: `1 passed`
@ -325,7 +325,7 @@ Cons:
### [x] 9. Make `attach_before_completion_streams_to_finished_state` event-driven instead of sleep-driven
Files:
- `lib/crates/fabro-cli/tests/it/cmd/attach.rs`
- `lib/apps/fabro-cli/tests/it/cmd/attach.rs`
Measured evidence:
- `attach_before_completion_streams_to_finished_state`: `3.043s`
@ -333,7 +333,7 @@ Measured evidence:
- `write_gated_workflow()` adds another fixed `sleep 0.2`
Implementation status:
- Implemented by replacing the fixed 1-second gate-release sleep with a real attach-output signal in `lib/crates/fabro-cli/tests/it/cmd/attach.rs`
- Implemented by replacing the fixed 1-second gate-release sleep with a real attach-output signal in `lib/apps/fabro-cli/tests/it/cmd/attach.rs`
- The test now spawns `fabro attach`, waits for replayed stderr output (`✓ start`), then releases the workflow gate
- Verified with `ulimit -n 4096; cargo nextest run -p fabro-cli attach_before_completion_streams_to_finished_state --status-level fail --final-status-level fail --show-progress none`
- Verification result: `1 passed`
@ -361,14 +361,14 @@ Cons:
### [x] 10. Remove or parameterize the fixed `sleep 0.2` in `write_gated_workflow()`
Files:
- `lib/crates/fabro-cli/tests/it/cmd/support.rs`
- `lib/apps/fabro-cli/tests/it/cmd/support.rs`
Measured evidence:
- `write_gated_workflow()` hardcodes `sleep 0.2`
- The helper is used in 6 cmd tests
Implementation status:
- Implemented by deleting the fixed `sleep 0.2` from `write_gated_workflow()` in `lib/crates/fabro-cli/tests/it/cmd/support.rs`
- Implemented by deleting the fixed `sleep 0.2` from `write_gated_workflow()` in `lib/apps/fabro-cli/tests/it/cmd/support.rs`
- Verified with `ulimit -n 4096; cargo nextest run -p fabro-cli -E 'test(attach_before_completion_streams_to_finished_state) | test(ctrl_c_cancels_active_run_via_server) | test(rm_force_terminates_active_run_worker) | test(start_rejects_already_active_or_completed_run) | test(start_runs_under_server_ownership_without_launcher_record)' --status-level fail --final-status-level fail --show-progress none`
- Verification result: `5 passed`
- Targeted 5-pass benchmark with `ulimit -n 4096` over the 5 tests that currently use the helper:

View file

@ -37,7 +37,7 @@ This is the right place for:
If the setup requires direct writes to internal run files or runtime directories, prefer this layer over `fabro-cli/tests/it`.
### `lib/crates/fabro-cli/tests/it/cmd/*.rs`
### `lib/apps/fabro-cli/tests/it/cmd/*.rs`
`cmd/*` tests are command-owned tests.
@ -64,7 +64,7 @@ Bad command-test assertions:
- behavior primarily owned by another command
- runtime internals that only exist because the test planted them by hand
### `lib/crates/fabro-cli/tests/it/workflow/*.rs`
### `lib/apps/fabro-cli/tests/it/workflow/*.rs`
`workflow/*` tests are black-box workflow-behavior tests.
@ -79,7 +79,7 @@ Examples:
These tests should focus on the workflow's observed behavior, not on CLI help text or command argument validation.
### `lib/crates/fabro-cli/tests/it/scenario/*.rs`
### `lib/apps/fabro-cli/tests/it/scenario/*.rs`
`scenario/*` tests are cross-command lifecycle tests.

View file

@ -66,7 +66,7 @@ JSON objects without recognized fields are ignored.
### Validated routing output
Set `output_schema="routing"` on an agent or prompt node to require Fabro's built-in routing directive schema:
Set `output_schema="routing"` on an agent, prompt, or command node to require Fabro's built-in routing directive schema:
```dot
review [
@ -81,7 +81,9 @@ With `output_schema="routing"`, the routing JSON must be an object with at least
Validated routing uses the same reverse scan as normal routing extraction: Fabro validates the last parsable JSON object that contains a recognized routing field. If a routing object is present but malformed or has invalid field types, Fabro repairs that response instead of falling through to a file fallback.
Fabro repairs invalid structured output inside the same LLM context before failing the node. For prompt nodes, Fabro appends the invalid assistant response and a corrective user message to the same message list. For agent nodes using the API backend, Fabro sends the corrective message to the same live agent session. `output_retries` controls these repair turns and defaults to `2`; `output_retries=0` validates once and fails without a repair turn. Negative values are treated as `0`. These repair turns are separate from workflow `max_retries` and do not consume node retry attempts.
Fabro repairs invalid structured output inside the same LLM context before failing an agent or prompt node. For prompt nodes, Fabro appends the invalid assistant response and a corrective user message to the same message list. For agent nodes using the API backend, Fabro sends the corrective message to the same live agent session. `output_retries` controls these repair turns and defaults to `2`; `output_retries=0` validates once and fails without a repair turn. Negative values are treated as `0`. These repair turns are separate from workflow `max_retries` and do not consume node retry attempts.
Command nodes instead validate only after an exit-code-`0` script, applying the same reverse scan to merged stdout and stderr. Print the intended JSON object last. Invalid output fails deterministically without a repair turn, command retry, or `status.json` fallback; `output_retries`, `retry_policy`, and `max_retries` do not retry the validation failure. Nonzero exits keep their normal command failure behavior without schema validation.
### Routing fallback sources
@ -95,7 +97,9 @@ Agent nodes can provide routing directives through fallback files. Fabro checks
This fallback chain applies to normal routing extraction and to `output_schema="routing"`. For validated routing, Fabro only advances to the next source when the current source has no JSON object or no object with recognized routing fields. If the current source contains malformed routing JSON or valid JSON with wrong routing field types, validation fails and Fabro starts the repair loop instead.
Prompt nodes do not use file fallbacks; they validate or extract routing directives from the response text only.
The last-file fallback only reads `.json` and `.md` files (case-insensitive), and the routing JSON must be the final JSON object in the file, with only whitespace after it. Fabro ignores other file types and routing JSON followed by any other content. These restrictions do not apply to the dedicated `status.json` fallback.
Prompt nodes do not use file fallbacks; they validate or extract routing directives from the response text only. Command nodes likewise have no file fallback and validate only their merged stdout and stderr.
If no source provides routing directives, the transition falls through to condition matching, unconditional edges, or weight-based tiebreaking as described in [Transitions](/workflows/transitions).
@ -119,7 +123,7 @@ review -> approve [label="Approve"]
## Custom structured outputs
Agent and prompt nodes can also validate their final JSON object against a JSON Schema file:
Agent, prompt, and command nodes can also validate their final JSON object against a JSON Schema file:
```dot
audit [
@ -130,9 +134,9 @@ audit [
]
```
`output_schema="@path/to/schema.json"` uses the same workflow file-reference rules as prompt files: the schema is loaded relative to the workflow file and inlined before execution. The final JSON object in the LLM response is validated with `jsonschema`.
`output_schema="@path/to/schema.json"` uses the same workflow file-reference rules as prompt files: the schema is loaded relative to the workflow file and inlined before execution. The final JSON object in the LLM response, or in a successful command's merged stdout and stderr, is validated with `jsonschema`.
Custom schema validation only reads the response text. It does not fall back to `status.json` or the last file touched by the agent.
Custom schema validation only reads response or command output text. It does not fall back to `status.json` or the last file touched by the agent.
When custom schema validation succeeds, Fabro stores the parsed JSON value in context at:
@ -140,12 +144,12 @@ When custom schema validation succeeds, Fabro stores the parsed JSON value in co
|---|---|
| `output.{node_id}` | The parsed JSON object that matched the custom schema |
For example, node `audit` writes its parsed custom output to `output.audit`. Fabro still stores the raw response text at `response.audit`.
For example, node `audit` writes its parsed custom output to `output.audit`. Fabro still stores raw LLM response text at `response.audit`; command output remains available through `command.output`.
If custom schema validation fails, Fabro sends concise validation feedback to the same prompt conversation or agent session and asks for corrected JSON. After `output_retries` repair turns are exhausted, the node fails terminally with `output schema validation failed after N repair attempt(s)`.
If custom schema validation fails for an agent or prompt node, Fabro sends concise validation feedback to the same prompt conversation or agent session and asks for corrected JSON. After `output_retries` repair turns are exhausted, the node fails terminally with `output schema validation failed after N repair attempt(s)`. A command node instead fails deterministically after its first validation, without a repair turn or command retry.
<Note>
Structured output validation currently applies to agent and prompt nodes. `backend="acp"` does not support `output_schema` in this release. Custom schemas update `output.{node_id}`; routing schemas update routing fields and `context_updates` instead.
Structured output validation applies to agent, prompt, and command nodes. `backend="acp"` does not support `output_schema` in this release. Custom schemas update `output.{node_id}`; routing schemas update routing fields and merge `context_updates` as flat context keys that edge conditions can read. Edge conditions cannot traverse the custom `output.{node_id}` object.
</Note>
## Output logging

View file

@ -44,7 +44,6 @@ Sub-agent failures do not automatically fail the parent stage. The parent receiv
Common cases:
- **Hits `max_turns`** -- returns normally with its last output
- **Panics or errors** -- returned as a failed `wait` result
- **`spawn_agent` fails** -- returned immediately as a tool result
@ -97,5 +96,5 @@ Sub-agents are most useful for:
Use [child runs](/execution/child-runs) instead when the delegated work should be a separate Fabro run with its own workflow, lifecycle, sandbox, checkpoints, and outputs.
<Note>
Sub-agents run with no turn limit by default. Pass `max_turns` when you want predictable cost or time bounds. All active sub-agents are cleaned up automatically when the parent session closes.
Sub-agents run until they complete, fail, are cancelled, or hit the session's wall-clock timeout. All active sub-agents are cleaned up automatically when the parent session closes.
</Note>

View file

@ -43,7 +43,7 @@ The `fabro-api` crate generates Rust structs, enums, and a `reqwest`-based HTTP
A `build.rs` script reads `docs/public/api-reference/fabro-api.yaml`, patches it from OpenAPI 3.1 to 3.0 for progenitor compatibility, and generates both types and a client. The generated code is written to `OUT_DIR` and included via:
```rust
// lib/crates/fabro-api/src/lib.rs
// lib/foundation/fabro-api/src/lib.rs
include!(concat!(env!("OUT_DIR"), "/codegen.rs"));
```

View file

@ -5452,7 +5452,9 @@ paths:
operationId: listModels
tags: [Models]
summary: List Models
description: Returns a paginated list of available LLM models from the built-in catalog.
description: |
Returns one row per provider/model offering from the catalog. Model IDs
are unique within a provider; `(provider, id)` is the resource identity.
parameters:
- $ref: "#/components/parameters/ModelProviderFilter"
- $ref: "#/components/parameters/ModelQueryFilter"
@ -5487,7 +5489,8 @@ paths:
required: true
schema:
type: string
description: The model identifier.
description: The canonical model ID or an alias.
- $ref: "#/components/parameters/ModelTestProviderParam"
- $ref: "#/components/parameters/ModelTestModeParam"
responses:
"200":
@ -5996,6 +5999,17 @@ components:
$ref: "#/components/schemas/ModelTestMode"
example: basic
ModelTestProviderParam:
name: provider
in: query
required: false
description: |
Pin the test to this provider's offering. When omitted, the server
selects among ready providers by catalog priority.
schema:
$ref: "#/components/schemas/ProviderId"
example: openrouter
headers:
XRequestId:
description: >
@ -7766,6 +7780,12 @@ components:
$ref: "#/components/schemas/SessionStatus"
model:
type: ["string", "null"]
description: Canonical model ID selected when the session was created.
provider:
oneOf:
- $ref: "#/components/schemas/ProviderId"
- type: "null"
description: Provider selected when the session was created.
active_turn:
oneOf:
- $ref: "#/components/schemas/SessionTurn"
@ -7798,6 +7818,12 @@ components:
$ref: "#/components/schemas/SessionStatus"
model:
type: ["string", "null"]
description: Canonical model ID selected when the session was created.
provider:
oneOf:
- $ref: "#/components/schemas/ProviderId"
- type: "null"
description: Provider selected when the session was created.
active_turn:
oneOf:
- $ref: "#/components/schemas/SessionTurn"
@ -7832,6 +7858,12 @@ components:
$ref: "#/components/schemas/SessionStatus"
model:
type: ["string", "null"]
description: Canonical model ID selected when the session was created.
provider:
oneOf:
- $ref: "#/components/schemas/ProviderId"
- type: "null"
description: Provider selected when the session was created.
active_turn:
oneOf:
- $ref: "#/components/schemas/SessionTurn"
@ -7857,7 +7889,12 @@ components:
type: string
model:
type: string
description: Catalog model ID or alias, or provider-qualified provider/model reference. Stored as the canonical catalog model ID.
description: |
Catalog model ID or alias. The server selects among ready
providers and stores the canonical model ID.
provider:
$ref: "#/components/schemas/ProviderId"
description: Optional provider pin. Provider-qualified model references remain accepted for compatibility.
SubmitTurnRequest:
type: object
@ -8190,6 +8227,7 @@ components:
- reasoning
- reasoning_effort
- prompt_cache
- cache_control_breakpoints
- sampling_params
properties:
tools:
@ -8206,6 +8244,12 @@ components:
prompt_cache:
type: boolean
description: Whether the model endpoint supports prompt caching.
cache_control_breakpoints:
type: boolean
description: >-
Whether the endpoint only caches when the request marks the
cacheable prefix with Anthropic-style cache_control breakpoints
(e.g. Claude via OpenRouter).
sampling_params:
type: boolean
description: Whether the model accepts classic sampling parameters (temperature, top_p).
@ -8235,7 +8279,9 @@ components:
example: 1.50
Model:
description: An available LLM model from the built-in catalog.
description: |
One provider's offering of an LLM model. The `id` is unique within
`provider`; `(provider, id)` is the stable resource identity.
type: object
required:
- id
@ -8255,7 +8301,7 @@ components:
properties:
id:
type: string
description: Unique model identifier.
description: Canonical human-facing model ID, unique within the provider.
example: "claude-opus-4-6"
provider:
$ref: "#/components/schemas/ProviderId"
@ -8310,12 +8356,15 @@ components:
type: object
required:
- model_id
- provider
- status
properties:
model_id:
type: string
description: The model identifier that was tested.
description: The canonical model ID that was tested.
example: "claude-opus-4-6"
provider:
$ref: "#/components/schemas/ProviderId"
status:
type: string
enum:
@ -8406,7 +8455,7 @@ components:
$ref: "#/components/schemas/CompletionMessage"
model:
type: string
description: Model ID or alias. Server picks default if omitted.
description: Model ID or alias. Server picks a ready-provider default if omitted.
system:
type: string
description: System prompt (convenience; prepended as a system message).
@ -8442,7 +8491,7 @@ components:
description: Reasoning effort level.
provider:
type: string
description: Provider to route to.
description: Optional provider pin.
provider_options:
description: Provider-specific options.
@ -8459,12 +8508,15 @@ components:
CompletionResponse:
type: object
required: [id, model, message, stop_reason, usage]
required: [id, model, provider, message, stop_reason, usage]
properties:
id:
type: string
model:
type: string
description: Canonical model ID selected for the request.
provider:
$ref: "#/components/schemas/ProviderId"
message:
$ref: "#/components/schemas/CompletionMessage"
stop_reason:
@ -8518,7 +8570,10 @@ components:
same format the model writes back via `write_workflow_file`.
model:
type: string
description: Model id or alias. Server picks the default if omitted.
description: Model ID or alias. Server picks a ready-provider default if omitted.
provider:
$ref: "#/components/schemas/ProviderId"
description: Optional provider pin.
PaginatedSavedQueryList:
description: Paginated list of saved queries.
@ -10497,7 +10552,11 @@ components:
oneOf:
- $ref: "#/components/schemas/TodoListProjection"
- type: "null"
description: Projected todo / task list for this stage.
description: |
Todo / task list owned by this stage's root agent session. OpenAI
child sessions have separate per-session plans that do not appear
here. Anthropic task lists are root-scoped and shared with child
sessions, so child mutations of that shared list do appear here.
subagents:
type: array
description: Subagents spawned by this stage, in replay/insertion order.

View file

@ -5,7 +5,7 @@ date: "2026-03-23"
## Unlimited agent tool rounds
Agent stages no longer have an artificial cap on tool rounds per input or subagent turns. Previously, the default limits could cause agents to stop mid-task on complex operations. Both `max_tool_rounds_per_input` and subagent `max_turns` now default to unlimited, so agents run until they complete their work or hit the context window.
Agent stages no longer have an artificial cap on tool rounds per input or subagent turns, so agents run until they complete their work or are interrupted.
## More

View file

@ -24,7 +24,7 @@ This is useful for workflows that prepare their own workspace, run against alrea
<Accordion title="Workflows">
- Added `[run.clone]`, `[run.run_branch]`, and `[run.meta_branch]` settings for clone, branch, and metadata branch behavior
- Added `[llm.providers]` and `[llm.models]` settings foundations for configurable provider and model catalog data
- Added provider-scoped `[llm.providers.<id>.models.<slug>]` settings foundations for configurable provider and model catalog data
- Added typed provider `extra_headers` settings for gateway-backed LLM providers
</Accordion>

View file

@ -28,8 +28,7 @@ credentials = ["env:ACME_GATEWAY_API_KEY"]
x-portkey-api-key = { env = "PORTKEY_API_KEY" }
x-portkey-config = { literal = "@bedrock-prod" }
[llm.models."team-code-large"]
provider = "proxy"
[llm.providers.proxy.models."team-code-large"]
api_id = "provider-wire-model-name"
default = true
```

View file

@ -11,6 +11,29 @@ No single model is best at everything. Fabro lets you assign the right model to
## Model catalog
Fabro keeps five related concepts separate:
- A **provider** serves requests, such as `openai` or `openrouter`.
- A **model slug** is Fabro's canonical, human-facing model ID, such as `gpt-5.6-sol`.
- An **alias** is another user-facing selector, such as `gpt-56-sol`.
- A **family** is display and matching metadata. It does not affect routing identity.
- An **API ID** is the opaque model string sent on the provider wire. Workflows should never reference it.
One provider's route to one model slug is an **offering**, identified by `(provider, model slug)`. The same slug and alias may appear on several providers. Within one provider, however, every slug or alias must identify exactly one offering.
For an unqualified selector, Fabro checks canonical slugs before aliases, filters the candidates to providers whose adapters are ready, and then chooses the highest provider priority. Equal priorities use canonical provider ID order. An explicit provider restricts selection to that provider and is a pin: if it is unavailable, Fabro reports the error instead of silently switching.
For example, the shared `gpt-56-sol` alias can be portable across direct OpenAI and OpenRouter offerings:
| Ready providers | Selector | Selected offering |
|---|---|---|
| OpenAI only | `gpt-56-sol` | `openai/gpt-5.6-sol` |
| OpenRouter only | `gpt-56-sol` | `openrouter/gpt-5.6-sol` |
| OpenAI and OpenRouter | `gpt-56-sol` | OpenAI, because it has higher priority |
| Both, with `provider = "openrouter"` | `gpt-56-sol` | OpenRouter, because the provider is pinned |
Fabro performs this selection once when creating a run and persists the chosen provider and canonical slug in the run settings and graph. Resuming that run does not reconsider provider priority when credentials change. Runtime [model fallbacks](/execution/failures#model-fallbacks) are the separate mechanism for handling a later provider failure.
| Model | Provider | Aliases | Context | Cost (in/out per Mtok) | Speed |
|---|---|---|---|---|---|
| `claude-fable-5` | anthropic | `fable`, `claude-fable` | 1M | $10.00 / $50.00 | n/a |
@ -46,7 +69,7 @@ Claude Fable 5 is available as an explicit model but is not the default Anthropi
## Configuring providers and models
Fabro's catalog starts with the built-in providers and models, then merges any `[llm]` entries from settings. Provider and model IDs are strings, so a server or project can add an OpenAI-compatible provider without a Fabro release.
Fabro's catalog starts with the built-in providers and models, then merges any `[llm]` entries from settings. Models are nested under their provider, so two providers can expose the same model slug without overwriting each other.
```toml title="settings.toml"
[llm.providers.proxy]
@ -62,8 +85,7 @@ credentials = ["env:ACME_GATEWAY_API_KEY", "vault:ACME_GATEWAY_API_KEY"]
x-portkey-api-key = "{{ env.PORTKEY_API_KEY }}"
x-portkey-config = "@bedrock-prod"
[llm.models."team-code-large"]
provider = "proxy"
[llm.providers.proxy.models."team-code-large"]
api_id = "provider-wire-model-name"
agent_profile = "anthropic"
display_name = "Team Code Large"
@ -73,27 +95,26 @@ small_default = true
aliases = ["team-code"]
estimated_output_tps = 80
[llm.models."team-code-large".limits]
[llm.providers.proxy.models."team-code-large".limits]
context_window = 200000
max_output = 32000
[llm.models."team-code-large".features]
[llm.providers.proxy.models."team-code-large".features]
tools = true
reasoning = true
reasoning_effort = "levels"
prompt_cache = true
effort = true
[llm.models."team-code-large".controls]
[llm.providers.proxy.models."team-code-large".controls]
reasoning_effort = ["low", "medium", "high"]
speed = ["fast"]
[llm.models."team-code-large".costs]
[llm.providers.proxy.models."team-code-large".costs]
input_cost_per_mtok = 1.50
output_cost_per_mtok = 8.00
cache_input_cost_per_mtok = 0.30
[llm.models."team-code-large".costs.speed.fast]
[llm.providers.proxy.models."team-code-large".costs.speed.fast]
input_cost_per_mtok = 3.00
output_cost_per_mtok = 16.00
cache_input_cost_per_mtok = 0.60
@ -106,24 +127,27 @@ For [LiteLLM](/integrations/litellm), Fabro ships a disabled provider entry. Ena
enabled = true
base_url = "http://localhost:4000/v1"
[llm.models."litellm-gpt-5"]
provider = "litellm"
[llm.providers.litellm.models."litellm-gpt-5"]
api_id = "gpt-5"
display_name = "LiteLLM GPT-5"
family = "litellm"
default = true
[llm.models."litellm-gpt-5".limits]
[llm.providers.litellm.models."litellm-gpt-5".limits]
context_window = 128000
max_output = 8192
[llm.models."litellm-gpt-5".features]
[llm.providers.litellm.models."litellm-gpt-5".features]
tools = true
vision = false
reasoning = false
```
`api_id` is the model name sent to the provider API. Omit it when the Fabro model ID and provider model ID are the same.
`api_id` is the opaque model name sent to that provider's API. It defaults to the exact model slug, so omit it when the two strings match. Fabro does not infer vendor prefixes or rewrite the value.
<Note>
Historical built-in catalog keys that exposed provider API IDs remain accepted as compatibility selectors. Fabro normalizes a primary or node selector such as `openai/gpt-5.6-sol` to the canonical `gpt-5.6-sol` slug before normal provider-aware selection. With no provider pin, the highest-priority ready offering wins; a separate `provider = "openrouter"` pin selects the OpenRouter offering. Fabro also normalizes these keys in legacy top-level `[llm.models]` rows without rewriting the settings file.
</Note>
Model roles are separate: `default = true` controls normal model selection for workflow execution, while `small_default = true` marks the provider's small/cheap utility model for metadata tasks such as generated run titles. If a provider has no small default, Fabro falls back to that provider's normal default.
@ -139,7 +163,7 @@ Provider fields in configuration, APIs, and model routing are provider ID string
### Poolside
Fabro ships a built-in [Poolside](/integrations/poolside) provider for Laguna S 2.1 and Laguna XS 2.1 over Poolside's OpenAI-compatible API. Store a direct API key with `fabro provider login --provider poolside`. The same models are also available through the opt-in OpenRouter provider under vendor-namespaced IDs.
Fabro ships a built-in [Poolside](/integrations/poolside) provider for Laguna S 2.1 and Laguna XS 2.1 over Poolside's OpenAI-compatible API. Store a direct API key with `fabro provider login --provider poolside`. The same model slugs are also available through the opt-in OpenRouter provider; its vendor-namespaced strings remain provider-only `api_id` values.
### OpenRouter
@ -169,11 +193,11 @@ Fabro ships an Ollama provider definition that is disabled by default. Enable it
enabled = true
```
Enabling the provider alone does not expose any models — until #267 adds auto-discovery, add explicit `[llm.models.<id>]` blocks for each Ollama model you have pulled locally. Ollama's OpenAI-compatible endpoint accepts any bearer token, so local users can set `OLLAMA_API_KEY=ollama`.
Enabling the provider alone does not expose any models — until #267 adds auto-discovery, add explicit `[llm.providers.ollama.models."<model-slug>"]` blocks for each Ollama model you have pulled locally. Ollama's OpenAI-compatible endpoint accepts any bearer token, so local users can set `OLLAMA_API_KEY=ollama`.
## Default models
When no model or provider is specified, Fabro checks configured provider credentials and chooses the first configured provider by catalog priority. If no provider credentials are configured, it uses the catalog's global default model. Each provider has a default model:
When no model or provider is specified, Fabro chooses the default offering on the highest-priority ready provider. If no provider adapter is ready, run creation reports that no eligible offering is available. Each provider has its own default model:
| Provider | Default model |
|---|---|
@ -184,7 +208,7 @@ When no model or provider is specified, Fabro checks configured provider credent
| `poolside` | `laguna-s-2.1` |
| `zai` | `glm-4.7` |
| `minimax` | `minimax-m2.5` |
| `inception` | `mercury` |
| `inception` | `mercury-2` |
## Using models in workflows
@ -221,7 +245,7 @@ fabro run docs/internal/demo/01-hello.fabro --model claude-opus-4-6
fabro run docs/internal/demo/04-pipeline.fabro --model gemini-3.1-pro-preview
```
These flags set the default model for all nodes that don't have an explicit model assigned via a stylesheet. The provider is automatically inferred from the model catalog — you only need `--provider` for models not in the catalog or to force a specific provider.
These flags set the default model for all nodes that don't have an explicit model assigned via a stylesheet. Without `--provider`, Fabro selects among ready offerings by priority. Add `--provider` to pin an exact provider, including for an uncatalogued provider model string.
### Run config TOML
@ -247,7 +271,7 @@ Then launch with:
fabro run run.toml
```
The `fallbacks` array is optional. Each entry may be a bare provider token (like `"gemini"`), a bare model alias (like `"gpt-5.4"`), or a qualified `"provider/model"` reference. Fabro tries them in order when the primary provider is unavailable.
The `fallbacks` array is optional. Each entry may be a bare provider token (like `"gemini"`), a bare model alias (like `"gpt-5.4"`), or a qualified `"provider/model"` reference. Fabro tries them in order when the primary provider is unavailable. In this field, qualified references keep their established provider-pin meaning: `"openai/gpt-5.6-sol"` selects the direct OpenAI offering.
<Note>
The precedence order is: node-level stylesheet > run config TOML > CLI flags > server defaults. More specific settings always win.

View file

@ -73,7 +73,7 @@ Use Claude Code's plan mode to collaborate on an implementation plan. Go back an
> /plan Add retry logic to the webhook delivery system
Planning...
1. Add RetryPolicy struct to lib/crates/fabro-webhooks/src/policy.rs
1. Add RetryPolicy struct to lib/components/fabro-webhooks/src/policy.rs
2. Implement exponential backoff with jitter
3. Add max_retries field to WebhookConfig
4. Write tests for retry timing, max attempts, and jitter bounds

View file

@ -140,6 +140,16 @@ Fidelity can be set at three levels. The first match wins:
If none of these are set, fidelity defaults to `compact`.
### Parallel branch fidelity
The first node in each parallel branch uses this precedence:
1. `fidelity` on the fork-to-branch edge
2. `fidelity` on the branch node
3. Otherwise, inherit the fork's preamble unchanged
Fabro renders any branch-specific preambles before fan-out from the fork's context snapshot, then places them into the isolated branch contexts. An explicit branch-level `full` degrades to `summary:high` because concurrent branches cannot share conversation sessions. `thread_id` on a branch node or fork-to-branch edge is inert.
### Full fidelity and threads
`full` fidelity is typically used with `thread_id` to create a shared conversation across multiple nodes. Nodes with the same `thread_id` share a single LLM session, preserving full context continuity:

View file

@ -121,7 +121,15 @@ provider = "anthropic"
fallbacks = ["gemini", "openai"]
```
When Anthropic is unavailable, Fabro tries Gemini first, then OpenAI. Each fallback entry may be a bare provider token (like `"gemini"`), a bare model alias (like `"gpt-5.4"`), or a qualified `"provider/model"` reference. For each fallback provider, Fabro selects the closest model by matching required capabilities (tool use, vision, reasoning) and minimizing cost difference.
When Anthropic fails, Fabro tries Gemini first, then OpenAI. Fallback resolution is provider-aware:
- A bare provider token such as `"gemini"` selects that provider's closest compatible model.
- A qualified selector such as `"openrouter/gpt-56-sol"` resolves only within that provider.
- A bare model slug or alias considers ready providers and uses provider priority.
Qualified fallback references always remain provider pins, including strings that were historical built-in API IDs. For example, `"openai/gpt-5.6-sol"` pins the direct OpenAI offering.
The primary provider and model were already resolved and persisted when the run was created; resuming does not re-run primary selection. Fallbacks are only considered after an eligible runtime failure.
### What triggers failover

View file

@ -138,12 +138,16 @@ name = "claude-sonnet-4-5"
| Field | Description |
|---|---|
| `name` | Model ID or alias (e.g. `claude-sonnet-4-5`, `opus`, `gemini-pro`). See [Models](/core-concepts/models). |
| `provider` | Provider name (optional — auto-inferred from the model catalog). Only needed for models not in the catalog or to force a specific provider. |
| `name` | Canonical model slug or alias (e.g. `claude-sonnet-4-5`, `opus`, `gemini-pro`). See [Models](/core-concepts/models). |
| `provider` | Optional provider pin. When omitted, Fabro selects among ready offerings by provider priority. When present, an unavailable provider is an error rather than permission to switch. |
| `fallbacks` | Ordered list of model references to try when the primary is unavailable. Entries can be bare provider tokens (`"openai"`), bare model aliases, or qualified `"provider/model"` references. |
Provider values are catalog provider ID strings. Built-in IDs like `anthropic` and `openai` work, and settings-defined IDs like `proxy` work after they are added under `[llm.providers.<id>]`.
At run creation, Fabro resolves the primary selector and every node selector against the ready-provider snapshot. It persists the selected canonical model slug and provider, so resuming the run does not choose a different provider just because credentials or priorities changed. The configured fallback chain remains available for failures that occur while the materialized run is executing.
Historical built-in provider API IDs are accepted for compatibility and normalize before this selection. For example, `name = "openai/gpt-5.6-sol"` is treated as the canonical `gpt-5.6-sol` selector; omit `provider` to use readiness and priority, or set `provider` separately to pin an offering.
#### `[run.model.controls]`
Set default model controls for all nodes that do not override them in the workflow stylesheet:

View file

@ -157,6 +157,6 @@ Bedrock-specific request fields pass through verbatim via `provider_options.bedr
How Fabro routes model IDs, providers, and fallbacks.
</Card>
<Card title="Settings Configuration" icon="gear" href="/reference/user-configuration">
Full reference for `[llm.providers.<id>]` and `[llm.models.<id>]`.
Full reference for provider settings and provider-scoped model offerings.
</Card>
</Columns>

View file

@ -24,18 +24,17 @@ _version = 1
enabled = true
base_url = "http://localhost:4000/v1"
[llm.models."litellm-gpt-5"]
provider = "litellm"
[llm.providers.litellm.models."litellm-gpt-5"]
api_id = "gpt-5"
display_name = "LiteLLM GPT-5"
family = "litellm"
default = true
[llm.models."litellm-gpt-5".limits]
[llm.providers.litellm.models."litellm-gpt-5".limits]
context_window = 128000
max_output = 8192
[llm.models."litellm-gpt-5".features]
[llm.providers.litellm.models."litellm-gpt-5".features]
tools = true
vision = false
reasoning = false
@ -94,18 +93,17 @@ digraph Example {
Declare each LiteLLM-routed model explicitly so Fabro knows its provider, context window, tool support, and routing defaults:
```toml title="settings.toml"
[llm.models."litellm-fast"]
provider = "litellm"
[llm.providers.litellm.models."litellm-fast"]
api_id = "fast-model"
display_name = "LiteLLM Fast"
family = "litellm"
aliases = ["fast"]
[llm.models."litellm-fast".limits]
[llm.providers.litellm.models."litellm-fast".limits]
context_window = 64000
max_output = 4096
[llm.models."litellm-fast".features]
[llm.providers.litellm.models."litellm-fast".features]
tools = true
vision = false
reasoning = false
@ -128,6 +126,6 @@ Only one model for a provider should set `default = true`. You may also mark one
How Fabro routes model IDs, providers, and fallbacks.
</Card>
<Card title="Settings Configuration" icon="gear" href="/reference/user-configuration">
Full reference for `[llm.providers.<id>]` and `[llm.models.<id>]`.
Full reference for provider settings and provider-scoped model offerings.
</Card>
</Columns>

View file

@ -44,34 +44,33 @@ export OPENROUTER_API_KEY=sk-or-v1-...
## Included models
The built-in catalog curates frontier and open-weights models under vendor-namespaced IDs:
The built-in catalog gives OpenRouter offerings the same human-facing model slugs used by direct providers. Vendor-namespaced OpenRouter IDs remain opaque `api_id` values:
| Fabro model ID | Notes |
| Fabro model slug | OpenRouter API ID / notes |
| --- | --- |
| `anthropic/claude-opus-4-7` | Claude via OpenRouter, Anthropic-style cache billing |
| `anthropic/claude-sonnet-4-6` | Provider default |
| `anthropic/claude-haiku-4-5` | Provider small default |
| `openai/gpt-5.4`, `openai/gpt-5.5` | |
| `google/gemini-3.1-pro-preview`, `google/gemini-3.5-flash` | |
| `deepseek/deepseek-v4-pro`, `deepseek/deepseek-v4-flash` | |
| `moonshotai/kimi-k2.6`, `qwen/qwen3-coder`, `qwen/qwen3.6-flash` | |
| `poolside/laguna-s-2.1`, `poolside/laguna-xs-2.1` | Poolside Laguna coding models with native reasoning and tool use |
| `z-ai/glm-4.6`, `minimax/minimax-m2.7`, `xiaomi/mimo-v2.5-pro` | |
| `nvidia/nemotron-3-super-120b-a12b`, `mistralai/devstral-2512` | |
| `claude-opus-4-7` | `anthropic/claude-opus-4.7`; Anthropic-style cache billing |
| `claude-sonnet-4-6` | `anthropic/claude-sonnet-4.6`; provider default |
| `claude-haiku-4-5` | `anthropic/claude-haiku-4.5`; provider small default |
| `gpt-5.4`, `gpt-5.5` | `openai/gpt-5.4`, `openai/gpt-5.5` |
| `gemini-3.1-pro-preview`, `gemini-3.5-flash` | `google/...` API IDs |
| `deepseek-v4-pro`, `deepseek-v4-flash` | `deepseek/...` API IDs |
| `kimi-k2.6`, `qwen3-coder`, `qwen3.6-flash` | Vendor-prefixed API IDs |
| `laguna-s-2.1`, `laguna-xs-2.1` | `poolside/...`; native reasoning and tool use |
| `glm-4.6`, `minimax-m2.7`, `mimo-v2.5-pro` | Vendor-prefixed API IDs |
| `nemotron-3-super-120b-a12b`, `devstral-2512` | Vendor-prefixed API IDs |
Any other OpenRouter model can be added as a settings model entry with `provider = "openrouter"` and the OpenRouter slug as `api_id`:
Any other OpenRouter model can be added under the provider. Choose a stable Fabro model slug as the table key and put OpenRouter's exact vendor/model string in `api_id`:
```toml title="settings.toml"
[llm.models."meta-llama/llama-4-maverick"]
provider = "openrouter"
[llm.providers.openrouter.models."llama-4-maverick"]
api_id = "meta-llama/llama-4-maverick"
display_name = "Llama 4 Maverick"
family = "llama-4"
[llm.models."meta-llama/llama-4-maverick".limits]
[llm.providers.openrouter.models."llama-4-maverick".limits]
context_window = 1000000
[llm.models."meta-llama/llama-4-maverick".features]
[llm.providers.openrouter.models."llama-4-maverick".features]
tools = true
vision = false
reasoning = false
@ -81,8 +80,8 @@ reasoning = false
```bash
fabro model list --provider openrouter
fabro model test --model anthropic/claude-sonnet-4-6
fabro run workflow.fabro --model deepseek/deepseek-v4-flash
fabro model test --provider openrouter --model claude-sonnet-4-6
fabro run workflow.fabro --provider openrouter --model deepseek-v4-flash
```
When targeting a non-default remote server, pass the same `--server` value to verification commands:
@ -159,6 +158,6 @@ Fabro does not send OpenRouter's optional attribution headers (`HTTP-Referer`, `
How Fabro routes model IDs, providers, and fallbacks.
</Card>
<Card title="Settings Configuration" icon="gear" href="/reference/user-configuration">
Full reference for `[llm.providers.<id>]` and `[llm.models.<id>]`.
Full reference for provider settings and provider-scoped model offerings.
</Card>
</Columns>

View file

@ -201,12 +201,12 @@ Start nodes can also be identified by ID (`start` or `Start`). Exit nodes can be
| `prompt` | String | Task instructions for the LLM. Supports file references with `@path/to/file.md` |
| `reasoning_effort` | String | `low`, `medium`, or `high` (default: `high`) |
| `max_tokens` | Integer | Maximum output tokens |
| `fidelity` | String | How much prior context is passed: `compact`, `full`, `summary:high`, `summary:medium`, `summary:low`, `truncate` |
| `thread_id` | String | Groups nodes into a shared conversation thread |
| `fidelity` | String | How much prior context is passed: `compact`, `full`, `summary:high`, `summary:medium`, `summary:low`, `truncate`. On a node entered directly from a parallel fork, this is overridden by the fork-to-branch edge; explicit `full` degrades to `summary:high`. |
| `thread_id` | String | Groups nodes into a shared conversation thread. Inert when the node is entered directly from a parallel fork. |
| `model` | String | Explicit model ID (overrides stylesheet) |
| `provider` | String | Explicit provider name (overrides stylesheet). Auto-inferred from the model catalog when omitted. |
| `project_memory` | Boolean | When `true` (default), prompt nodes discover and include project docs (`AGENTS.md`, `CLAUDE.md`, etc.) as a system prompt. Set to `false` to disable. |
| `output_schema` | String | Optional structured output validation. Use `routing` for Fabro's built-in routing directive schema, `@path/to/schema.json` for a JSON Schema file, or an inline JSON Schema object string. Supported on agent and prompt nodes. |
| `output_schema` | String | Optional structured output validation. Use `routing` for Fabro's built-in routing directive schema, `@path/to/schema.json` for a JSON Schema file, or an inline JSON Schema object string. Supported on agent, prompt, and command nodes. |
| `output_retries` | Integer | Corrective structured-output turns inside the same prompt conversation or agent session. Default `2`; `0` validates once and fails without repair; negative values are treated as `0`. Separate from `max_retries`. |
| `backend` | String | Agent execution backend: `api` (default) or `acp`. `api` runs Fabro's tool loop through provider APIs; `acp` runs an Agent Client Protocol stdio agent inside the active sandbox. Prompt nodes are API-only. See [Agents — Backends](/core-concepts/agents#backends). |
| `acp.command` | String | Shell command for nodes with `backend="acp"`. Mutually exclusive with `acp.config`. The value is always parsed as a command string, not JSON. |
@ -214,7 +214,7 @@ Start nodes can also be identified by ID (`start` or `Start`). Exit nodes can be
#### Structured output validation
`output_schema` opts an agent or prompt node into strict JSON validation:
`output_schema` opts an agent, prompt, or command node into strict JSON validation:
```dot
review [
@ -235,7 +235,9 @@ audit [
- On validation failure, Fabro sends validation feedback to the same active context before failing: prompt nodes keep the prior assistant response in the message list, and API-backed agent nodes repair in the same live session.
- `output_retries` defaults to `2` and controls only these corrective structured-output turns. Negative values are treated as `0`. It is not the same as `max_retries` and does not consume workflow retry attempts.
- Custom schema output is stored in context at `output.{node_id}`. Routing schema output updates routing fields and any `context_updates`.
- Agent routing fallbacks still apply to `output_schema="routing"`: response text first, then `status.json`, then the last file touched by the agent. Custom schemas and prompt nodes validate response text only.
- Agent routing fallbacks still apply to `output_schema="routing"`: response text first, then `status.json`, then the last file touched by the agent. The last-file fallback only accepts `.json` and `.md` files (case-insensitive) whose final JSON object contains the routing directive; only whitespace may follow it. Custom schemas and prompt nodes validate response text only.
- Command nodes validate merged stdout and stderr only after the script exits with code `0`, using the same object selection as agents and prompts: custom schemas validate the last JSON object, and `routing` validates the last JSON object containing a recognized routing field. Print the intended JSON object last. A validation error is a deterministic, non-retryable failure with no repair turn or `status.json` fallback. `output_retries`, `retry_policy`, and `max_retries` do not retry it. Nonzero exits retain normal command failure behavior without schema validation.
- For commands, custom schema output is stored at `output.{node_id}`; edge conditions cannot traverse into its fields. The `routing` schema applies routing fields and merges `context_updates` into flat context keys, which conditions can read (for example, `context.kept_count`).
- `backend="acp"` with `output_schema` is unsupported in this release.
### Command nodes
@ -244,6 +246,7 @@ audit [
|---|---|---|
| `script` | String | Shell command to execute |
| `language` | String | `"shell"` (default) or `"python"` |
| `output_schema` | String | Optional structured output validation. Accepts `routing`, `@path/to/schema.json`, or an inline JSON Schema object string. See [Structured output validation](#structured-output-validation). |
### Parallel (fan-out) nodes
@ -251,6 +254,8 @@ audit [
|---|---|---|
| `max_parallel` | Integer | Maximum concurrent branches (default: 4). The node always waits for every branch. |
For the first node in each branch, `fidelity` resolves from the fork-to-branch edge, then the branch node; without either, the fork preamble is inherited unchanged. Branch-specific preambles are rendered before fan-out from the fork's context snapshot. Concurrent branches cannot share sessions, so explicit branch `full` becomes `summary:high`, and branch-level `thread_id` is inert.
### Wait nodes
| Attribute | Type | Description |
@ -281,8 +286,8 @@ audit [
| `label` | String | Display text; also used for human gate option matching |
| `condition` | String | Boolean expression for conditional routing (see below) |
| `weight` | Integer | Priority for tiebreaking (higher wins, default: 0) |
| `fidelity` | String | Override fidelity level for this transition |
| `thread_id` | String | Override thread ID for this transition |
| `fidelity` | String | Override fidelity level for this transition. On a fork-to-branch edge, takes precedence over the branch node; explicit `full` degrades to `summary:high`. |
| `thread_id` | String | Override thread ID for this transition. Inert on fork-to-branch edges. |
| `loop_restart` | Boolean | Restart the workflow from this edge's target when taken: stage history and retry counts clear and the context resets to empty (visit counts are kept). Failed outcomes may only take it for `transient_infra` failures — see [Failures](/execution/failures#loop-restart-edges) |
| `freeform` | Boolean | When `true` on a human-gate edge, accept free-text input instead of fixed choices |

View file

@ -84,7 +84,7 @@ pub fn new(
| Method | Description |
|---|---|
| `initialize().await` | Discovers project docs, skills, and MCP servers. Call before `process_input`. |
| `process_input(input).await` | Sends user input and runs the agent loop until the model stops or a limit is hit. |
| `process_input(input).await` | Sends user input and runs the agent loop until the model stops, the session is interrupted, or an error occurs. |
| `close()` | Ends the session and emits `SessionEnded`. |
| `interrupt()` | Cancels the current `process_input` call. |
| `cancel_token()` | Returns a `CancellationToken` for external cancellation. |
@ -110,8 +110,6 @@ All fields are public. Key settings with their defaults:
| Field | Default | Description |
|---|---|---|
| `max_turns` | `0` (unlimited) | Maximum conversation turns before stopping. |
| `max_tool_rounds_per_input` | `200` | Maximum tool execution rounds per `process_input` call. |
| `default_command_timeout_ms` | `10,000` | Default timeout for Bash tool commands. |
| `max_command_timeout_ms` | `600,000` | Maximum allowed timeout for Bash tool commands. |
| `enable_loop_detection` | `true` | Detect and break out of repetitive tool call patterns. |
@ -221,7 +219,6 @@ Key `AgentEvent` variants:
| `ToolCallCompleted { tool_name, tool_call_id, output, is_error }` | A tool call finished. |
| `Error { error }` | An `AgentError` occurred. |
| `LoopDetected` | The agent is repeating itself. |
| `TurnLimitReached { max_turns }` | Turn limit hit. |
| `CompactionStarted` / `CompactionCompleted` | Context window compaction. |
| `SubAgentSpawned` / `SubAgentCompleted` | Sub-agent lifecycle. |
| `McpServerReady` / `McpServerFailed` | MCP server connection status. |
@ -296,7 +293,7 @@ All fallible `Session` methods return `Result<T, AgentError>`:
| `SessionClosed` | `process_input` was called on a closed session. |
| `InvalidState(String)` | The session is in an unexpected state. |
| `ToolExecution(String)` | A tool execution failed. |
| `Interrupted(InterruptReason)` | The session was cancelled (`Cancelled`) or timed out (`WallClockTimeout`). |
| `Interrupted(InterruptReason)` | The session was cancelled or timed out. |
---

View file

@ -35,7 +35,7 @@ Files that omit `_version` are treated as version `1`. The legacy top-level `ver
|---|---|
| CLI-only | `[cli.target]`, `[cli.auth]`, `[cli.exec]`, `[cli.output]`, `[cli.updates]`, `[cli.logging]` |
| Shared run defaults | `[run.model]`, `[run.environment]`, `[environments.<slug>]`, `[run.checkpoint]`, `[run.inputs]`, `[run.prepare]`, `[run.pull_request]`, `[run.integrations.github.permissions]`, `[run.hooks]`, `[run.agent.mcps]` |
| Shared LLM catalog | `[llm.providers.<id>]`, `[llm.models.<id>]`, model limits, features, controls, and costs |
| Shared LLM catalog | `[llm.providers.<id>]`, provider-scoped `[llm.providers.<id>.models.<slug>]` offerings, limits, features, controls, and costs |
| Server-only | `[server.listen]`, `[server.api]`, `[server.web]`, `[server.auth]`, `[server.storage]`, `[server.artifacts]`, `[server.slatedb]`, `[server.scheduler]`, `[server.logging]`, `[server.integrations]` |
`[cli.*]` and `[server.*]` stanzas are owner-specific: they are only consumed from `~/.fabro/settings.toml` (plus process-local flags and env overrides). The same stanzas in `.fabro/project.toml` or `workflow.toml` remain schema-valid but runtime-inert.
@ -97,23 +97,22 @@ credentials = ["env:ACME_GATEWAY_API_KEY", "vault:ACME_GATEWAY_API_KEY"]
x-portkey-api-key = "{{ env.PORTKEY_API_KEY }}"
x-portkey-config = "@bedrock-prod"
[llm.models."team-code-large"]
provider = "proxy"
[llm.providers.proxy.models."team-code-large"]
api_id = "provider-wire-model-name"
agent_profile = "anthropic"
display_name = "Team Code Large"
default = true
aliases = ["team-code"]
[llm.models."team-code-large".controls]
[llm.providers.proxy.models."team-code-large".controls]
reasoning_effort = ["low", "medium", "high"]
speed = ["fast"]
[llm.models."team-code-large".costs]
[llm.providers.proxy.models."team-code-large".costs]
input_cost_per_mtok = 1.50
output_cost_per_mtok = 8.00
[llm.models."team-code-large".costs.speed.fast]
[llm.providers.proxy.models."team-code-large".costs.speed.fast]
input_cost_per_mtok = 3.00
output_cost_per_mtok = 16.00
@ -201,19 +200,20 @@ x-team-secret = "{{ secrets.gateway_team_secret }}"
| `auth.credentials` | array<string> | required when `auth` present | Ordered credential refs. Accepted forms are `vault:<NAME>`, `env:<NAME>`, and `aws_sigv4` (sign requests from the AWS default credential chain — Bedrock). Literal secret strings are rejected. |
| `auth.header` | `"bearer"` or `{ custom = "Header-Name" }` | `"bearer"` | Primary API-key header policy. Omit when the provider uses a standard bearer token. |
| `extra_headers` | table | `{}` | Additional headers attached to provider requests. Values are interpolation strings: literal text, an `{{ env.NAME }}` token, or a `{{ secrets.NAME }}` token. Put credentials in a secret and reference them with a `{{ secrets.NAME }}` token, not a bare literal. |
| `priority` | integer | `0` | Higher-priority configured providers win default selection; ties use canonical provider ID. |
| `priority` | integer | `0` | Higher-priority ready providers win unqualified model and default selection; ties use canonical provider ID. |
| `enabled` | boolean | `true` | Set `false` to disable a provider after lower-precedence layers define it. |
| `aliases` | array<string> | `[]` | Additional provider names accepted by model routing and fallback config. |
## `[llm.models.<id>]`
## `[llm.providers.<provider>.models.<model-slug>]`
Define or override a model in the catalog. The table key is the canonical
model ID Fabro users reference; `api_id` is the model string sent to the
provider API.
Define or override one provider's offering of a model. The table key is the
canonical model slug Fabro users reference. An offering's identity is the
pair `(provider, model slug)`, so different providers may use the same slug
and aliases. `api_id` is the opaque model string sent to this provider's API
and defaults to the exact model slug.
```toml title="settings.toml"
[llm.models."team-code-large"]
provider = "proxy"
[llm.providers.proxy.models."team-code-large"]
api_id = "provider-wire-model-name"
agent_profile = "anthropic"
display_name = "Team Code Large"
@ -224,27 +224,27 @@ enabled = true
aliases = ["team-code"]
estimated_output_tps = 80
[llm.models."team-code-large".limits]
[llm.providers.proxy.models."team-code-large".limits]
context_window = 200000
max_output = 32000
[llm.models."team-code-large".features]
[llm.providers.proxy.models."team-code-large".features]
tools = true
vision = false
reasoning = true
reasoning_effort = "levels"
prompt_cache = true
[llm.models."team-code-large".controls]
[llm.providers.proxy.models."team-code-large".controls]
reasoning_effort = ["low", "medium", "high"]
speed = ["fast"]
[llm.models."team-code-large".costs]
[llm.providers.proxy.models."team-code-large".costs]
input_cost_per_mtok = 1.50
output_cost_per_mtok = 8.00
cache_input_cost_per_mtok = 0.30
[llm.models."team-code-large".costs.speed.fast]
[llm.providers.proxy.models."team-code-large".costs.speed.fast]
input_cost_per_mtok = 3.00
output_cost_per_mtok = 16.00
cache_input_cost_per_mtok = 0.60
@ -252,8 +252,7 @@ cache_input_cost_per_mtok = 0.60
| Key | Type / values | Default | Description |
|---|---|---|---|
| `provider` | string | None | Provider ID this model belongs to. |
| `api_id` | string | model ID | Identifier sent to the provider API. |
| `api_id` | string | model slug | Opaque identifier sent to this provider's API. An explicitly empty value is invalid. |
| `agent_profile` | `"anthropic"` \| `"openai"` \| `"gemini"` | provider profile | Agent profile override for this model. Model overrides take precedence over provider overrides. |
| `billing_policy` | `"openai"` \| `"anthropic"` \| `"gemini"` \| `"none"` | provider policy | Billing algorithm override for this model — for models whose billing family differs from their provider's (e.g. Claude served through OpenRouter bills Anthropic-style cache reads/writes). |
| `display_name` | string | model ID | Human-readable model name. |
@ -263,17 +262,17 @@ cache_input_cost_per_mtok = 0.60
| `default` | boolean | `false` | Whether this is the provider default model. |
| `probe` | boolean | `false` | Whether this model should be preferred for provider connectivity probes. Set `false` in a higher-precedence layer to clear an inherited probe marker. |
| `enabled` | boolean | `true` | Set `false` to disable a model after lower-precedence layers define it. |
| `aliases` | array<string> | `[]` | Additional model names accepted by routing and fallback config. |
| `aliases` | array<string> | `[]` | Additional model selectors accepted by routing and fallback config. Aliases may repeat across providers, but one selector cannot identify two models within the same provider. |
| `estimated_output_tps` | number | None | Estimated output tokens per second for catalog display and planning. |
## `[llm.models.<id>.limits]`
## `[llm.providers.<provider>.models.<model-slug>.limits]`
| Key | Type / values | Default | Description |
|---|---|---|---|
| `context_window` | integer | None | Maximum context window size in tokens. |
| `max_output` | integer | None | Maximum output tokens, if known. |
## `[llm.models.<id>.features]`
## `[llm.providers.<provider>.models.<model-slug>.features]`
| Key | Type / values | Default | Description |
|---|---|---|---|
@ -284,14 +283,14 @@ cache_input_cost_per_mtok = 0.60
| `prompt_cache` | boolean | `false` | Whether prompt cache pricing/usage applies. |
| `sampling_params` | boolean | `true` | Whether the model accepts classic sampling parameters (`temperature`, `top_p`). |
## `[llm.models.<id>.controls]`
## `[llm.providers.<provider>.models.<model-slug>.controls]`
| Key | Type / values | Default | Description |
|---|---|---|---|
| `reasoning_effort` | array<string> | all standard levels when feature is `"levels"` or `"always_adaptive"` | User-facing reasoning effort values Fabro may send for this model. Can be set explicitly for reasoning models whose provider adapter maps effort to a non-native API shape. |
| `speed` | array<string> | `[]` | Additional speeds beyond implicit `standard`; do not list `standard`. |
## `[llm.models.<id>.costs]`
## `[llm.providers.<provider>.models.<model-slug>.costs]`
| Key | Type / values | Default | Description |
|---|---|---|---|
@ -299,10 +298,12 @@ cache_input_cost_per_mtok = 0.60
| `output_cost_per_mtok` | number | None | Output cost in USD per million tokens. |
| `cache_input_cost_per_mtok` | number | None | Cached input/read cost in USD per million tokens. |
## `[llm.models.<id>.costs.speed.<speed>]`
## `[llm.providers.<provider>.models.<model-slug>.costs.speed.<speed>]`
Per-speed cost overrides use the same keys as `[llm.models.<id>.costs]`.
Each `<speed>` key must be declared in `[llm.models.<id>.controls].speed`.
Per-speed cost overrides use the same keys as
`[llm.providers.<provider>.models.<model-slug>.costs]`. Each `<speed>` key
must be declared in
`[llm.providers.<provider>.models.<model-slug>.controls].speed`.
The `standard` speed is implicit and always uses the base cost table.
## `[cli.updates]`

View file

@ -171,6 +171,8 @@ fork -> quality
Because the checkout is shared, file changes from one branch are immediately visible to the others. Concurrent writes can race or overwrite each other. Fabro does not isolate branch files, lock paths, detect conflicts, or warn about overlapping writes. Design branches to be read-only or assign each branch disjoint files and directories when deterministic workspace changes matter.
For each branch's first node, fidelity resolves from the fork-to-branch edge, then the branch node; otherwise it inherits the fork preamble unchanged. Fabro renders branch-specific preambles before fan-out from the fork snapshot. Branch-level `full` degrades to `summary:high` because concurrent branches cannot share sessions, and `thread_id` on a branch node or fork-to-branch edge is inert.
### Merge (fan-in)
**Shape:** `tripleoctagon`

View file

@ -18,40 +18,40 @@ sleep_inhibitor = ["dep:core-foundation"]
workspace = true
[dependencies]
fabro-auth = { path = "../fabro-auth" }
fabro-config = { path = "../fabro-config" }
fabro-environment = { path = "../fabro-environment" }
fabro-llm = { path = "../fabro-llm" }
fabro-model = { path = "../fabro-model" }
fabro-oauth = { path = "../fabro-oauth" }
fabro-github = { path = "../fabro-github" }
fabro-agent = { path = "../fabro-agent" }
fabro-dump = { path = "../fabro-dump" }
fabro-hooks = { path = "../fabro-hooks" }
fabro-install = { path = "../fabro-install" }
fabro-interview = { path = "../fabro-interview" }
fabro-mcp = { path = "../fabro-mcp" }
fabro-auth = { path = "../../foundation/fabro-auth" }
fabro-config = { path = "../../foundation/fabro-config" }
fabro-environment = { path = "../../components/fabro-environment" }
fabro-llm = { path = "../../components/fabro-llm" }
fabro-model = { path = "../../foundation/fabro-model" }
fabro-oauth = { path = "../../foundation/fabro-oauth" }
fabro-github = { path = "../../components/fabro-github" }
fabro-agent = { path = "../../components/fabro-agent" }
fabro-dump = { path = "../../components/fabro-dump" }
fabro-hooks = { path = "../../components/fabro-hooks" }
fabro-install = { path = "../../components/fabro-install" }
fabro-interview = { path = "../../components/fabro-interview" }
fabro-mcp = { path = "../../components/fabro-mcp" }
fabro-mcp-server = { path = "../fabro-mcp-server" }
fabro-manifest = { path = "../fabro-manifest" }
fabro-proc = { path = "../fabro-proc" }
fabro-sandbox = { path = "../fabro-sandbox", features = ["daytona"] }
fabro-checkpoint = { path = "../fabro-checkpoint" }
fabro-graphviz = { path = "../fabro-graphviz" }
fabro-validate = { path = "../fabro-validate" }
fabro-workflow = { path = "../fabro-workflow" }
fabro-manifest = { path = "../../components/fabro-manifest" }
fabro-proc = { path = "../../foundation/fabro-proc" }
fabro-sandbox = { path = "../../components/fabro-sandbox", features = ["daytona"] }
fabro-checkpoint = { path = "../../components/fabro-checkpoint" }
fabro-graphviz = { path = "../../components/fabro-graphviz" }
fabro-validate = { path = "../../components/fabro-validate" }
fabro-workflow = { path = "../../components/fabro-workflow" }
fabro-server = { path = "../fabro-server" }
fabro-client = { path = "../fabro-client" }
fabro-api = { path = "../fabro-api" }
fabro-telemetry = { path = "../fabro-telemetry" }
fabro-store = { path = "../fabro-store" }
fabro-vault = { path = "../fabro-vault" }
fabro-types = { path = "../fabro-types", features = ["clap"] }
fabro-client = { path = "../../foundation/fabro-client" }
fabro-api = { path = "../../foundation/fabro-api" }
fabro-telemetry = { path = "../../foundation/fabro-telemetry" }
fabro-store = { path = "../../components/fabro-store" }
fabro-vault = { path = "../../foundation/fabro-vault" }
fabro-types = { path = "../../foundation/fabro-types", features = ["clap"] }
fabro-redact.workspace = true
fabro-util = { path = "../fabro-util" }
fabro-util = { path = "../../foundation/fabro-util" }
fabro-http.workspace = true
fabro-static.workspace = true
fabro-template = { path = "../fabro-template" }
fabro-tool = { path = "../fabro-tool" }
fabro-template = { path = "../../foundation/fabro-template" }
fabro-tool = { path = "../../components/fabro-tool" }
clap.workspace = true
clap_complete.workspace = true
cli-table.workspace = true
@ -111,15 +111,16 @@ core-foundation = { version = "0.9", optional = true }
openssl = { version = "0.10", features = ["vendored"] }
[build-dependencies]
fabro-build-support = { path = "../build-support" }
fabro-build-support = { path = "../../foundation/build-support" }
chrono = { workspace = true }
[dev-dependencies]
assert_cmd = "2"
fabro-acp = { path = "../fabro-acp", features = ["test-support"] }
fabro-build-support = { path = "../build-support" }
fabro-acp = { path = "../../components/fabro-acp", features = ["test-support"] }
fabro-build-support = { path = "../../foundation/build-support" }
fabro-server = { path = "../fabro-server", features = ["test-support"] }
fabro-types = { path = "../fabro-types", features = ["clap", "test-support"] }
fabro-workflow = { path = "../../components/fabro-workflow", features = ["test-support"] }
fabro-types = { path = "../../foundation/fabro-types", features = ["clap", "test-support"] }
insta = { workspace = true, features = ["filters"] }
paste = "1"
predicates = "3"
@ -128,7 +129,7 @@ tempfile = "3"
temp-env = "0.3"
httpmock = "0.8"
fabro-test = { workspace = true }
fabro-macros = { path = "../fabro-macros" }
fabro-macros = { path = "../../foundation/fabro-macros" }
hkdf.workspace = true
reqwest = { workspace = true, features = ["cookies"] }
tokio = { workspace = true, features = ["test-util", "macros"] }

View file

@ -45,10 +45,21 @@ struct CompletedModelTest {
status: String,
}
fn find_model_by_id_or_alias(models: &[Model], id: &str) -> Option<Model> {
fn model_matches_selector(model: &Model, selector: &str) -> bool {
model.id == selector || model.aliases.iter().any(|alias| alias == selector)
}
fn find_model_by_id_or_alias(
models: &[Model],
id: &str,
provider: Option<&ProviderId>,
) -> Option<Model> {
models
.iter()
.find(|model| model.id == id || model.aliases.iter().any(|alias| alias == id))
.find(|model| {
provider.is_none_or(|provider| &model.provider == provider)
&& model_matches_selector(model, id)
})
.cloned()
}
@ -116,7 +127,7 @@ fn model_row(model: &Model, use_color: bool) -> Vec<CellStruct> {
format_cost(model.costs.output_cost_per_mtok),
);
vec![
model.id.clone().cell().bold(use_color),
model.id.as_str().cell().bold(use_color),
model
.provider
.as_str()
@ -172,9 +183,20 @@ fn print_models_table(models: &[Model], styles: &Styles) {
}
fn configured_model_test_status(
expected: &Model,
result: Result<api_types::ModelTestResult>,
) -> (Color, String, bool) {
match result {
Ok(resp) if resp.provider != expected.provider || resp.model_id != expected.id.as_str() => {
(
Color::Red,
format!(
"error: server tested unexpected offering {}/{}",
resp.provider, resp.model_id
),
true,
)
}
Ok(resp) if resp.status == api_types::ModelTestResultStatus::Ok => {
(Color::Green, "ok".to_string(), false)
}
@ -197,21 +219,21 @@ fn model_test_row_from_status(model: &Model, status: &str, result_color: Color)
let trimmed = status.trim();
match result_color {
Color::Green => ModelTestRow {
model: model.id.clone(),
model: model.id.to_string(),
provider: model.provider.clone(),
result: ModelTestResultKind::Pass,
detail: None,
error: None,
},
Color::Yellow => ModelTestRow {
model: model.id.clone(),
model: model.id.to_string(),
provider: model.provider.clone(),
result: ModelTestResultKind::Skip,
detail: Some(trimmed.to_string()),
error: None,
},
_ => ModelTestRow {
model: model.id.clone(),
model: model.id.to_string(),
provider: model.provider.clone(),
result: ModelTestResultKind::Fail,
detail: None,
@ -251,21 +273,45 @@ async fn test_models_via_server(
let mut skipped = 0u32;
let mut skipped_providers: Vec<String> = Vec::new();
if let Some(model_id) = model {
let listed_models = client.list_models(None, Some(model_id)).await?;
let listed_info = find_model_by_id_or_alias(&listed_models, model_id);
let requested_provider = provider.map(ProviderId::new);
let listed_models = client.list_models(provider, Some(model_id)).await?;
let listed_info = find_model_by_id_or_alias(&listed_models, model_id, None);
if !json_output {
eprint!("Testing {model_id}...");
}
let result = client.test_model(model_id, request_mode).await;
let has_configured_match = listed_models
.iter()
.any(|model| model.configured && model_matches_selector(model, model_id));
let result =
if requested_provider.is_none() && listed_info.is_some() && !has_configured_match {
None
} else {
Some(
client
.test_model(model_id, requested_provider.as_ref(), request_mode)
.await,
)
};
if !json_output {
eprintln!(" done");
}
let (info, result_color, status) = match result {
Ok(resp) => {
let info = find_model_by_id_or_alias(&listed_models, &resp.model_id).with_context(
|| format!("Unknown model returned by server: {}", resp.model_id),
)?;
None => {
let info = listed_info.with_context(|| format!("Unknown model: {model_id}"))?;
failures += 1;
skipped += 1;
(info, Color::Yellow, "not configured".to_string())
}
Some(Ok(resp)) => {
let info =
find_model_by_id_or_alias(&listed_models, &resp.model_id, Some(&resp.provider))
.with_context(|| {
format!(
"Unknown model returned by server: {}/{}",
resp.provider, resp.model_id
)
})?;
if resp.status == api_types::ModelTestResultStatus::Ok {
(info, Color::Green, "ok".to_string())
} else if resp.status == api_types::ModelTestResultStatus::Skip {
@ -280,10 +326,10 @@ async fn test_models_via_server(
(info, Color::Red, format!("error: {message}"))
}
}
Err(err) if err.to_string().contains("Model not found") => {
Some(Err(err)) if err.to_string().contains("Model not found") => {
bail!("Unknown model: {model_id}");
}
Err(err) => {
Some(Err(err)) => {
let info = listed_info.with_context(|| format!("Unknown model: {model_id}"))?;
failures += 1;
(info, Color::Red, format!("error: {err}"))
@ -328,11 +374,14 @@ async fn test_models_via_server(
.map(|(index, info)| {
let client = client.clone();
async move {
let result = client.test_model(&info.id, request_mode).await;
let result = client
.test_model(info.id.as_str(), Some(&info.provider), request_mode)
.await;
if !json_output {
eprintln!("Testing {}... done", info.id);
}
let (result_color, status, failed) = configured_model_test_status(result);
let (result_color, status, failed) =
configured_model_test_status(&info, result);
(
CompletedModelTest {
index,
@ -478,7 +527,7 @@ mod tests {
fn test_model_json(id: &str, provider: ProviderId) -> serde_json::Value {
serde_json::to_value(Model {
id: id.to_string(),
id: id.into(),
provider,
family: "test".to_string(),
display_name: format!("{id} display"),
@ -489,12 +538,13 @@ mod tests {
training: None,
knowledge_cutoff: None,
features: ModelFeatures {
tools: true,
vision: false,
reasoning: false,
reasoning_effort: ReasoningEffortFeature::None,
prompt_cache: false,
sampling_params: true,
tools: true,
vision: false,
reasoning: false,
reasoning_effort: ReasoningEffortFeature::None,
prompt_cache: false,
cache_control_breakpoints: false,
sampling_params: true,
},
costs: ModelCosts {
input_cost_per_mtok: Some(1.0),
@ -512,7 +562,7 @@ mod tests {
fn custom_model_json(id: &str, provider: &str) -> serde_json::Value {
serde_json::to_value(Model {
id: id.to_string(),
id: id.into(),
provider: ProviderId::new(provider),
family: "test".to_string(),
display_name: format!("{id} display"),
@ -523,12 +573,13 @@ mod tests {
training: None,
knowledge_cutoff: None,
features: ModelFeatures {
tools: true,
vision: false,
reasoning: false,
reasoning_effort: ReasoningEffortFeature::None,
prompt_cache: false,
sampling_params: true,
tools: true,
vision: false,
reasoning: false,
reasoning_effort: ReasoningEffortFeature::None,
prompt_cache: false,
cache_control_breakpoints: false,
sampling_params: true,
},
costs: ModelCosts {
input_cost_per_mtok: Some(1.0),
@ -610,6 +661,7 @@ mod tests {
.body(
serde_json::json!({
"model_id": "test-model",
"provider": "anthropic",
"status": "ok"
})
.to_string(),
@ -618,7 +670,7 @@ mod tests {
.await;
let client = test_client(&server.url(""));
let response = client.test_model("test-model", None).await.unwrap();
let response = client.test_model("test-model", None, None).await.unwrap();
assert_eq!(response.status, api_types::ModelTestResultStatus::Ok);
assert!(response.error_message.is_none());
@ -637,6 +689,7 @@ mod tests {
.body(
serde_json::json!({
"model_id": "test-model",
"provider": "anthropic",
"status": "error",
"error_message": "timeout"
})
@ -647,7 +700,7 @@ mod tests {
let client = test_client(&server.url(""));
let response = client
.test_model("test-model", Some(ModelTestMode::Deep))
.test_model("test-model", None, Some(ModelTestMode::Deep))
.await
.unwrap();
@ -666,6 +719,7 @@ mod tests {
.body(
serde_json::json!({
"model_id": "kimi-k2.5",
"provider": "kimi",
"status": "skip"
})
.to_string(),
@ -674,7 +728,7 @@ mod tests {
.await;
let client = test_client(&server.url(""));
let response = client.test_model("kimi-k2.5", None).await.unwrap();
let response = client.test_model("kimi-k2.5", None, None).await.unwrap();
assert_eq!(response.status, api_types::ModelTestResultStatus::Skip);
assert!(response.error_message.is_none());
@ -698,7 +752,7 @@ mod tests {
.await;
let client = test_client(&server.url(""));
let result = client.test_model("bad-model", None).await;
let result = client.test_model("bad-model", None, None).await;
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("Model not found"));
}
@ -732,6 +786,7 @@ mod tests {
.body(
serde_json::json!({
"model_id": "venice-large",
"provider": "venice",
"status": "ok"
})
.to_string(),
@ -754,6 +809,80 @@ mod tests {
.unwrap();
}
#[tokio::test]
async fn bulk_model_test_keeps_duplicate_ids_scoped_by_provider() {
let server = httpmock::MockServer::start_async().await;
server
.mock_async(|when, then| {
when.method("GET")
.path("/api/v1/models")
.query_param("page[limit]", "100")
.query_param("page[offset]", "0");
then.status(200)
.header("Content-Type", "application/json")
.body(
serde_json::json!({
"data": [
custom_model_json("portable-model", "openai"),
custom_model_json("portable-model", "openrouter")
],
"meta": { "has_more": false }
})
.to_string(),
);
})
.await;
let openai = server
.mock_async(|when, then| {
when.method("POST")
.path("/api/v1/models/portable-model/test")
.query_param("provider", "openai");
then.status(200)
.header("Content-Type", "application/json")
.body(
serde_json::json!({
"model_id": "portable-model",
"provider": "openai",
"status": "ok"
})
.to_string(),
);
})
.await;
let openrouter = server
.mock_async(|when, then| {
when.method("POST")
.path("/api/v1/models/portable-model/test")
.query_param("provider", "openrouter");
then.status(200)
.header("Content-Type", "application/json")
.body(
serde_json::json!({
"model_id": "portable-model",
"provider": "openrouter",
"status": "ok"
})
.to_string(),
);
})
.await;
test_models_via_server(
&test_client(&server.url("")),
None,
None,
false,
2,
&Styles::new(false),
true,
)
.await
.unwrap();
openai.assert_async().await;
openrouter.assert_async().await;
}
#[tokio::test]
async fn fetch_models_from_server_parses_response() {
let server = httpmock::MockServer::start_async().await;

View file

@ -11,8 +11,9 @@ pub(crate) async fn run(args: AskArgs, base_ctx: &CommandContext) -> Result<()>
let run_id = client.resolve_run(&args.run).await?.id;
let session = client
.create_run_session(run_id, CreateRunSessionRequest {
title: Some(session_title(&args.prompt)),
model: args.model,
title: Some(session_title(&args.prompt)),
model: args.model,
provider: None,
})
.await?;
let mut stream = client

View file

@ -323,7 +323,7 @@ pub(super) fn from_run_event(stored: &RunEvent) -> Option<ProgressEvent> {
EventBody::ParallelCompleted(_) => Some(ProgressEvent::ParallelCompleted),
EventBody::AgentMessage(props) => Some(ProgressEvent::AssistantMessage {
stage_node_id: node_id,
model: props.model.model_id.clone(),
model: props.model.model_id.to_string(),
}),
EventBody::AgentToolStarted(props) => Some(ProgressEvent::ToolCallStarted {
stage_node_id: node_id,

Some files were not shown because too many files have changed in this diff Show more