diff --git a/.github/workflows/cross-platform.yml b/.github/workflows/cross-platform.yml new file mode 100644 index 0000000..950f186 --- /dev/null +++ b/.github/workflows/cross-platform.yml @@ -0,0 +1,88 @@ +name: Cross-platform runtime + +on: + pull_request: + branches: [main, master] + push: + branches: [main, master] + workflow_dispatch: + +permissions: + contents: read + +jobs: + portable-runtime: + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + node-version: [20, 22] + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: ${{ matrix.node-version }} + cache: npm + - run: npm ci + - name: Validate routing policy + run: npm run validate:routing + - name: Validate budget policy + run: npm run validate:budget + - name: Validate feature contract + run: npm run validate:feature + - name: Validate documentation links + run: node scripts/check-doc-links.mjs + - name: Test v0.2 reliability features + run: node tests/reliability-v020-tests.mjs + - name: Test budget enforcement + run: node tests/budget-tests.mjs + - name: Test budget edge cases + run: node tests/budget-audit-tests.mjs + - name: Test routing + run: node tests/routing-tests.mjs + - name: Test runtime + shell: bash + run: | + set +e + node tests/runtime-tests.mjs 2>&1 | tee runtime-test.log + status=${PIPESTATUS[0]} + set -e + exit "$status" + - name: Upload runtime-test diagnostics + if: failure() + uses: actions/upload-artifact@v4 + with: + name: runtime-test-${{ matrix.os }}-node-${{ matrix.node-version }} + path: runtime-test.log + - name: Test plugin integration + run: node tests/tool-integration-tests.mjs + - name: Test bypass detection + run: node tests/bypass-detection.mjs + - name: Test installer in POSIX shell + shell: bash + run: | + set -euo pipefail + test_home="$PWD/.installer-test-home" + test_bin="$PWD/.installer-test-bin" + mkdir -p "$test_home" "$test_bin" + : > "$test_home/.bashrc" + cat > "$test_bin/opencode" <<'EOF' + #!/usr/bin/env bash + exit 0 + EOF + chmod +x "$test_bin/opencode" + HOME="$test_home" PATH="$test_bin:$PATH" bash scripts/install.sh + grep -F 'OPENCODE_CONFIG_DIR=' "$test_home/.bashrc" + rm -rf "$test_home" "$test_bin" + + bash-installation: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + - run: npm ci + - run: npm run validate diff --git a/.github/workflows/opencode-patch-compat.yml b/.github/workflows/opencode-patch-compat.yml new file mode 100644 index 0000000..583ae26 --- /dev/null +++ b/.github/workflows/opencode-patch-compat.yml @@ -0,0 +1,140 @@ +name: OpenCode patch compatibility + +on: + pull_request: + branches: [main, master] + paths: + - patches/opencode-subagent-failure-exposure.patch + - config/supported-opencode.json + - scripts/check-opencode-patch-contract.mjs + - .github/workflows/opencode-patch-compat.yml + workflow_dispatch: + inputs: + opencode_ref: + description: Optional OpenCode branch, tag, or commit. Empty uses the pinned supported revision. + required: false + default: '' + schedule: + - cron: '23 6 * * 1' + +permissions: + contents: read + +jobs: + supported-compatibility: + runs-on: ubuntu-latest + timeout-minutes: 45 + steps: + - name: Check out agent loop + uses: actions/checkout@v4 + + - name: Resolve supported revision + id: supported + shell: bash + env: + REQUESTED_REF: ${{ inputs.opencode_ref }} + run: | + set -euo pipefail + configured_ref=$(node -e "const fs=require('fs'); const value=JSON.parse(fs.readFileSync('config/supported-opencode.json','utf8')); process.stdout.write(value.ref)") + selected_ref=${REQUESTED_REF:-$configured_ref} + echo "ref=$selected_ref" >> "$GITHUB_OUTPUT" + echo "Testing OpenCode revision $selected_ref" + + - name: Check out supported OpenCode + uses: actions/checkout@v4 + with: + repository: anomalyco/opencode + ref: ${{ steps.supported.outputs.ref }} + path: upstream-opencode + fetch-depth: 1 + + - name: Verify patch applies + working-directory: upstream-opencode + run: git apply --check ../patches/opencode-subagent-failure-exposure.patch + + - name: Apply patch + working-directory: upstream-opencode + run: git apply ../patches/opencode-subagent-failure-exposure.patch + + - name: Set up Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: '1.3.14' + + - name: Install upstream dependencies + working-directory: upstream-opencode + run: bun install --frozen-lockfile + + - name: Build native standalone OpenCode + working-directory: upstream-opencode + shell: bash + run: | + set +e + bun ./packages/opencode/script/build.ts --single --skip-install --skip-embed-web-ui 2>&1 | tee ../opencode-build.log + status=${PIPESTATUS[0]} + set -e + exit "$status" + + - name: Verify patched source contracts + run: node scripts/check-opencode-patch-contract.mjs upstream-opencode + + - name: Record tested revision + if: always() + shell: bash + run: | + git -C upstream-opencode rev-parse HEAD > opencode-tested-revision.txt + printf 'requested_ref=%s\n' '${{ steps.supported.outputs.ref }}' >> opencode-tested-revision.txt + printf 'tested_at=%s\n' "$(date -u +%FT%TZ)" >> opencode-tested-revision.txt + + - name: Upload compatibility evidence + if: always() + uses: actions/upload-artifact@v4 + with: + name: opencode-supported-compatibility + path: | + opencode-tested-revision.txt + opencode-build.log + + upstream-dev-probe: + if: github.event_name != 'workflow_dispatch' || inputs.opencode_ref == '' + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v4 + - uses: actions/checkout@v4 + with: + repository: anomalyco/opencode + ref: dev + path: upstream-opencode-dev + fetch-depth: 1 + - name: Probe patch against moving dev branch + id: probe + working-directory: upstream-opencode-dev + shell: bash + run: | + set +e + git apply --check ../patches/opencode-subagent-failure-exposure.patch > ../opencode-dev-patch-check.txt 2>&1 + status=$? + set -e + if [ "$status" -eq 0 ]; then + echo "compatible=true" >> "$GITHUB_OUTPUT" + echo "### Upstream dev probe: compatible" >> "$GITHUB_STEP_SUMMARY" + else + echo "compatible=false" >> "$GITHUB_OUTPUT" + echo "### Upstream dev probe: patch rebase needed" >> "$GITHUB_STEP_SUMMARY" + echo "The pinned supported revision remains the release target. See the uploaded probe artifact for details." >> "$GITHUB_STEP_SUMMARY" + fi + exit 0 + - name: Record upstream revision + if: always() + shell: bash + run: | + git -C upstream-opencode-dev rev-parse HEAD > opencode-dev-probe.txt + printf 'patch_compatible=%s\n' '${{ steps.probe.outputs.compatible }}' >> opencode-dev-probe.txt + cat opencode-dev-patch-check.txt >> opencode-dev-probe.txt + - name: Upload upstream probe evidence + if: always() + uses: actions/upload-artifact@v4 + with: + name: opencode-dev-probe + path: opencode-dev-probe.txt diff --git a/.gitignore b/.gitignore index 38e31d9..e3908ac 100644 --- a/.gitignore +++ b/.gitignore @@ -55,6 +55,7 @@ Thumbs.db !.opencode/command/feature.md .opencode/node_modules/ .opencode/agent-loop-logs/ +.opencode/agent-loop-state/ *.bak # Benchmark output (gitignored temp location) diff --git a/.opencode/plugins/agent-loop.js b/.opencode/plugins/agent-loop.js index 7e89c8c..8b4e55d 100644 --- a/.opencode/plugins/agent-loop.js +++ b/.opencode/plugins/agent-loop.js @@ -40,6 +40,7 @@ function concise(result) { review: result.review, requiresUserInput: result.requiresUserInput === true, logPath: result.logPath, + eventLogPath: result.eventLogPath || undefined, budget: result.budget || undefined, steps }; @@ -52,10 +53,10 @@ export default async function AgentLoopPlugin() { description: 'Run the OpenCode agent-loop runtime for a development task. Use for non-trivial build/test/review work; do not use for simple questions.', args: { task: tool.schema.string().min(1).describe('The complete user request to run through the agent loop.'), - mode: tool.schema.enum(['build', 'test', 'review', 'smoke', 'escalate']).optional().describe('Which role to run: build, test, review, smoke (model test), or escalate (GPT-5.5 diagnosis)'), - maxRetries: tool.schema.number().int().min(0).max(5).optional().describe('Maximum task-level retry cycles. Provider failover is handled separately.'), + mode: tool.schema.enum(['build', 'test', 'review', 'smoke', 'escalate']).optional().describe('Which role to run: build, test, review, smoke (model test), or escalate (GPT-5.6 diagnosis)'), + maxRetries: tool.schema.number().int().min(0).max(5).optional().describe('Maximum same-model retries for transient failures. Provider failover begins after these retries are exhausted.'), models: tool.schema.array(tool.schema.string()).optional().describe('Pre-verified model IDs from a prior smoke call to restrict which models are used.'), - taskId: tool.schema.string().min(1).max(128).optional().describe('Stable task ID used to share token and cost budgets across build, test, and review calls.') + taskId: tool.schema.string().min(1).max(128).optional().describe('Stable task ID used to share token and cost budgets across smoke, build, test, review, fix, and escalation calls.') }, async execute(args, context) { if (process.env.AGENT_LOOP_CHILD === '1') { @@ -89,7 +90,7 @@ export default async function AgentLoopPlugin() { const result = await runAgentLoop({ task, mode: args.mode || 'build', - maxRetries: args.maxRetries || 0, + maxRetries: args.maxRetries, cwd: context.directory, parentSessionId: context.sessionID, signal: context.abort, diff --git a/CHANGELOG.md b/CHANGELOG.md index 7b67f03..afca94b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,18 +2,41 @@ ## [Unreleased] +## [0.2.0] - 2026-07-22 + ### Added -- Added configurable per-task token and cost budgets with live OpenCode usage tracking, model-registry pricing estimates, failover interruption, and structured budget output. Closes #4. -- Added the referenced free-first configuration schema and automated budget/feature contract validation. -- Added bounded in-memory budget-ledger retention with configurable TTL and task-count limits. +- Added configurable token, cost, and workflow-call budgets shared by one stable task ID. +- Added atomic budget persistence across OpenCode and plugin restarts with TTL and task-count retention limits. +- Added same-model transient retries with exponential backoff and jitter before provider failover. +- Added provider adapters for provider identity, aliases, timeout selection, local-model recognition, and error normalization. +- Added a custom provider-adapter example and extension documentation. +- Added a versioned structured event schema, append-only JSONL event stream, recursive redaction, and query utility. Closes #5. +- Added Linux, macOS, Windows, WSL, and Git Bash guidance plus a cross-platform Node CI matrix. Closes #6. +- Added scheduled and manually configurable patched-OpenCode compatibility builds. +- Added portable task-checkpoint paths. +- Added a workflow-call ceiling and reduced parent-orchestrator turn cap. +- Added v0.2 configuration, event, feature-contract, and reliability validation. + +### Changed + +- Routed every `/feature` worker stage through the budget-enforced `agent_loop` tool. +- Moved provider-specific runtime behavior behind the provider-adapter interface. Closes #7. +- Updated runtime state layout under `.opencode/agent-loop-state/`. +- Updated package and policy schema versions to 0.2.0 and 2.0.0 respectively. +- Updated architecture, configuration, platform, troubleshooting, event, and provider documentation. ### Fixed -- Treat local-unmetered models as zero-cost and account for a final usage event even when the JSON stream has no trailing newline. -- Route `/feature` delegation through the budget-enforced `agent_loop` tool and require one stable task ID across all stages. -- Continue recording usage emitted during worker shutdown after a budget-triggered termination. -- Mark budget totals explicitly as delegated-worker scope so parent-orchestrator usage is not misrepresented. +- Made the public `maxRetries` option perform actual same-model retries for transient failures. +- Corrected timeout selection for local/Ollama model IDs without a slash. +- Removed the hardcoded `/usr/local/bin/opencode` executable path so PATH resolution works across operating systems. +- Treat local-unmetered models as zero-cost. +- Account for final usage events emitted without a trailing newline or during budget-triggered shutdown. +- Stop retries, failover, and escalation immediately after `BUDGET_EXCEEDED`. +- Prevent replacement task IDs from being used to evade an exhausted feature budget. +- Strengthened recursive redaction for structured event and attempt logs. +- Clarified that exact parent-orchestrator token and dollar usage is unavailable, while parent workflow calls and turns are bounded. ## [0.1.1] - 2026-07-22 diff --git a/README.md b/README.md index 5703d9c..25b7826 100644 --- a/README.md +++ b/README.md @@ -1,54 +1,44 @@ # OpenCode Agent Loop -[![Project Status](https://img.shields.io/badge/status-pre--release-yellow?style=for-the-badge)](https://github.com/wryan2986/opencode-agent-loop) +[![Project Status](https://img.shields.io/badge/status-v0.2.0--pre--release-yellow?style=for-the-badge)](https://github.com/wryan2986/opencode-agent-loop) [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE) -A reusable OpenCode agent-loop package for structured feature development with specialized planning, building, testing, review, recovery, and local agents. +A reusable OpenCode agent-loop package for structured feature development with specialized planning, building, testing, independent review, recovery, and local agents. > **Independent project:** OpenCode Agent Loop is a community project. It is not built, maintained, or endorsed by the OpenCode team. -The workflow is: +`plan → approve → smoke → build → test → review → fix/escalate → commit` -`plan → approve → baseline test → build → verify → review → fix/escalate → commit` - -The orchestrator uses a paid DeepSeek model for reliable coordination. Subagent roles use free-first model pools with automatic provider failover and controlled paid fallback. +The parent orchestrator uses paid DeepSeek for coordination. Delegated workers use free-first model pools with same-model retries, provider failover, local-model support, persistent budgets, structured events, and controlled paid fallback. ## Important prerequisite -This pre-release currently requires a **patched OpenCode build**. Stock OpenCode does not expose every subagent failure signal required by the orchestrator's failover logic. +This pre-release requires a **patched OpenCode build**. Stock OpenCode does not expose every subagent failure signal required for reliable failover. -Read [Required OpenCode Fork](docs/opencode-fork.md) before installation. The loop may incorrectly treat failed subagent work as successful without the patch. +Read [Required OpenCode Patch](docs/opencode-fork.md) before installation. A scheduled compatibility workflow verifies that the patch applies, builds, and retains its required source contracts against the configured upstream revision. ## Quick start -### 1. Prepare the patched OpenCode build +### 1. Prepare patched OpenCode -Follow [docs/opencode-fork.md](docs/opencode-fork.md), place the resulting `opencode` executable in your `PATH`, and verify it: +Follow [docs/opencode-fork.md](docs/opencode-fork.md), place the resulting executable on `PATH`, and verify it: ```bash opencode --version ``` -### 2. Clone this repository +### 2. Install the agent loop ```bash git clone https://github.com/wryan2986/opencode-agent-loop.git cd opencode-agent-loop -``` - -### 3. Install the package - -```bash bash scripts/install.sh -``` - -### 4. Activate it in the current shell - -```bash source ~/.bashrc # or ~/.zshrc ``` -### 5. Initialize a project +Windows users should prefer WSL 2 or use Git Bash. See [Platform Support](docs/platforms.md). + +### 3. Initialize a project ```bash cd /path/to/your/project @@ -56,53 +46,76 @@ opencode /loop-init ``` -### 6. Run a feature +### 4. Run a feature ```text /feature Implement user authentication ``` -## Architecture +## Reliability architecture ```text User request | v +--------------------------------------+ -| Orchestrator | -| Paid DeepSeek V4 Flash | -| Plans, delegates, enforces stages | +| Parent orchestrator | +| Plans and reuses one stable task ID | ++--------------------------------------+ + | + v ++--------------------------------------+ +| agent_loop runtime | +| Retry | Route | Failover | Budget | +--------------------------------------+ | v +--------------------------------------+ -| Free-first role routing | -| Test, build, review, explore | -| Free models -> controlled fallback | +| Provider adapters and worker pools | +| Free/local models -> paid fallback | +--------------------------------------+ | v +--------------------------------------+ -| Model registry and role pools | -| Capability, privacy, cooldown state | +| Persistent state and events | +| Budget ledger | JSONL audit stream | +--------------------------------------+ ``` -The routing configuration lives in: +Stable configuration lives in: -- `config/free-first-config.json` — global routing and fallback policy +- `config/free-first-config.json` — routing, retry, timeout, budget, and event policy - `config/free-first-pools.json` — ordered model pools by role -- `config/model-registry.json` — capabilities and privacy classifications +- `config/model-registry.json` — capabilities, privacy, retirement, and pricing metadata +- `config/free-first-config-schema.json` — supported policy schema +- `config/agent-loop-event.schema.json` — versioned event schema -See [Architecture](docs/architecture.md) and [Configuration](docs/configuration.md) for details. +See [Architecture](docs/architecture.md) and [Configuration](docs/configuration.md). ## Commands | Command | Description | |---------|-------------| -| `/feature ` | Run the staged feature workflow through the orchestrator | -| `/loop ` | Run the `agent_loop` custom tool directly | -| `/loop-init` | Add the project-specific agent-loop files to a repository | +| `/feature ` | Run the complete approved workflow through the orchestrator | +| `/loop ` | Run one `agent_loop` role call directly | +| `/loop-init` | Install project-specific agent-loop files | +| `npm run events -- --task ` | Query the local structured event log | + +## v0.2 safeguards + +- one stable task ID across all feature stages +- persistent token, cost, and workflow-call budgets +- terminal budget exhaustion with no replacement-ID bypass +- bounded paid parent orchestration turns +- same-model transient retries with exponential backoff and jitter +- provider adapters for identity, timeout, and error normalization +- local/Ollama timeout handling even for model IDs without `/` +- append-only, versioned, recursively redacted event logs +- portable checkpoint paths and cross-platform Node CI +- scheduled patched-OpenCode compatibility builds +- independent test and review gates before the final local commit + +Budget dollar and token totals cover delegated workers. The parent orchestrator's precise token and dollar use is not available from OpenCode's plugin event surface, so results state that limitation explicitly. ## Project structure @@ -110,69 +123,58 @@ See [Architecture](docs/architecture.md) and [Configuration](docs/configuration. opencode-agent-loop/ ├── agents/ Agent definitions ├── commands/ OpenCode slash commands -├── config/ Routing and model configuration -├── lib/ Routing and failover modules -├── runtime/ Runtime controller and worker execution -├── .opencode/ Plugin and project-local command integration +├── config/ Policy, pools, registry, and schemas +├── lib/ Routing, adapters, budgets, events, and failover +├── runtime/ Controller and worker execution +├── .opencode/ Plugin and project-local commands ├── skills/ Reusable project-analysis skills ├── templates/ Files installed into target projects -├── docs/ Architecture, configuration, safety, and usage -├── tests/ Deterministic automated tests -├── scripts/ Install, validation, and maintenance utilities -├── opencode.json Package-level OpenCode configuration -├── CHANGELOG.md Release history -├── CONTRIBUTING.md Contribution guidelines -├── SECURITY.md Vulnerability-reporting policy -└── LICENSE MIT License +├── docs/ Architecture, operations, safety, and usage +├── examples/ Extension examples +├── tests/ Deterministic and integration tests +├── scripts/ Installation, validation, query, and maintenance tools +└── .github/workflows/ CI, release, compatibility, and platform matrices ``` ## Safety model -The package applies several layers of protection: +The package requires explicit approval before implementation, routes workers through a budget-enforced runtime, runs independent testing and review, blocks automatic pushes, denies destructive Git commands, guards against recursion, and filters providers by privacy policy. -- explicit approval before implementation -- baseline and post-change testing -- independent read-only review -- no automatic push -- explicit Bash denials for destructive Git commands -- recursion guards for worker agents -- privacy-aware model filtering -- state checkpoints for interrupted work - -Prompt and permission controls are not an OS sandbox. Use a container or VM for untrusted repositories. See [Safety Model](docs/safety-model.md). +Prompt permissions and redaction are not an operating-system sandbox. Use a container or VM for untrusted repositories. See [Safety Model](docs/safety-model.md). ## Validation ```bash npm ci -npm test -bash scripts/validate.sh -node scripts/check-doc-links.mjs +npm run validate +npm run validate:portable ``` +The full command includes Bash permission checks. The portable command is exercised on Linux, macOS, and Windows. + ## Documentation - [Architecture](docs/architecture.md) - [Agent Roles](docs/agent-roles.md) - [Configuration](docs/configuration.md) +- [Provider Adapters](docs/provider-adapters.md) +- [Structured Event Logging](docs/event-logging.md) +- [Platform Support](docs/platforms.md) +- [Troubleshooting](docs/troubleshooting.md) - [Model Providers](docs/providers.md) - [Safety Model](docs/safety-model.md) -- [Required OpenCode Fork](docs/opencode-fork.md) +- [Required OpenCode Patch](docs/opencode-fork.md) - [TUI Integration](docs/tui-agent-loop-integration.md) - [Roadmap](docs/roadmap.md) -## Contributing - -Contributions are welcome. Read [CONTRIBUTING.md](CONTRIBUTING.md) before opening an issue or pull request. - -## Security +## Contributing and security -Do not report vulnerabilities in public issues. Follow [SECURITY.md](SECURITY.md). +Read [CONTRIBUTING.md](CONTRIBUTING.md) before opening a pull request. Report vulnerabilities privately according to [SECURITY.md](SECURITY.md). ## License -Distributed under the MIT License. See [LICENSE](LICENSE). +MIT. See [LICENSE](LICENSE). --- -This is a pre-release project. Features and APIs may change before v1.0.0. \ No newline at end of file +This is a pre-release project. APIs and configuration may change before v1.0.0. diff --git a/agents/orchestrator.md b/agents/orchestrator.md index 6ae6392..5c5c1ff 100644 --- a/agents/orchestrator.md +++ b/agents/orchestrator.md @@ -3,7 +3,7 @@ mode: primary model: opencode-go/deepseek-v4-flash temperature: 0.1 reasoning_effort: medium -steps: 200 +steps: 100 description: > Orchestrates a complete feature lifecycle. It inspects and plans the work, obtains approval, then runs smoke, build, test, review, and escalation stages diff --git a/config/agent-loop-event.schema.json b/config/agent-loop-event.schema.json new file mode 100644 index 0000000..4a221f3 --- /dev/null +++ b/config/agent-loop-event.schema.json @@ -0,0 +1,22 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/wryan2986/opencode-agent-loop/schemas/agent-loop-event-1.0.0.json", + "title": "OpenCode Agent Loop Event", + "type": "object", + "additionalProperties": false, + "required": ["schemaVersion", "eventId", "timestamp", "type", "taskId", "data"], + "properties": { + "schemaVersion": { "const": "1.0.0" }, + "eventId": { "type": "string", "minLength": 8 }, + "timestamp": { "type": "string", "format": "date-time" }, + "type": { + "type": "string", + "pattern": "^[a-z][a-z0-9]*(?:\\.[a-z][a-z0-9-]*)+$" + }, + "taskId": { "type": "string", "minLength": 1, "maxLength": 128 }, + "stage": { "type": "string", "minLength": 1 }, + "role": { "type": "string", "minLength": 1 }, + "modelId": { "type": "string", "minLength": 1 }, + "data": { "type": "object" } + } +} diff --git a/config/free-first-config-schema.json b/config/free-first-config-schema.json index df7030f..1b64b04 100644 --- a/config/free-first-config-schema.json +++ b/config/free-first-config-schema.json @@ -1,117 +1,36 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", "$id": "https://github.com/wryan2986/opencode-agent-loop/config/free-first-config-schema.json", - "title": "OpenCode Agent Loop free-first configuration", + "title": "OpenCode Agent Loop configuration", "type": "object", - "required": [ - "version", - "general", - "retry", - "model_registry_path", - "pools_path", - "budgets" - ], + "required": ["version", "general", "retry", "model_registry_path", "pools_path", "budgets", "events"], "properties": { - "$schema": { - "type": "string" - }, - "version": { - "type": "string", - "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" - }, - "general": { - "type": "object" - }, - "cooldowns": { - "type": "object" - }, - "privacy": { - "type": "object" - }, + "$schema": { "type": "string" }, + "version": { "type": "string", "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" }, + "general": { "type": "object" }, + "cooldowns": { "type": "object" }, + "privacy": { "type": "object" }, "retry": { "type": "object", - "required": [ - "max_retries", - "retryable_http_codes", - "retryable_errors", - "non_retryable_errors" - ], + "required": ["max_retries", "retryable_http_codes", "retryable_errors", "non_retryable_errors"], "properties": { - "max_retries": { - "type": "integer", - "minimum": 0 - }, - "base_delay_ms": { - "type": "integer", - "minimum": 0 - }, - "max_delay_ms": { - "type": "integer", - "minimum": 0 - }, - "jitter_factor": { - "type": "number", - "minimum": 0 - }, - "retryable_http_codes": { - "type": "array", - "items": { - "type": "integer" - }, - "uniqueItems": true - }, - "retryable_errors": { - "type": "array", - "items": { - "type": "string" - }, - "uniqueItems": true - }, - "non_retryable_errors": { - "type": "array", - "items": { - "type": "string" - }, - "uniqueItems": true - } + "max_retries": { "type": "integer", "minimum": 0, "maximum": 5 }, + "base_delay_ms": { "type": "integer", "minimum": 0 }, + "max_delay_ms": { "type": "integer", "minimum": 0 }, + "jitter_factor": { "type": "number", "minimum": 0 }, + "retryable_http_codes": { "type": "array", "items": { "type": "integer" }, "uniqueItems": true }, + "retryable_errors": { "type": "array", "items": { "type": "string" }, "uniqueItems": true }, + "non_retryable_errors": { "type": "array", "items": { "type": "string" }, "uniqueItems": true } }, "additionalProperties": true }, - "model_registry_path": { - "type": "string", - "minLength": 1 - }, - "pools_path": { - "type": "string", - "minLength": 1 - }, - "provider_timeouts_ms": { - "type": "object", - "additionalProperties": { - "type": "integer", - "minimum": 1 - } - }, - "latency_timeout_mapping": { - "type": "object", - "additionalProperties": { - "type": "integer", - "minimum": 1 - } - }, - "smoke_test_provider_timeout_ms": { - "type": "object", - "additionalProperties": { - "type": "integer", - "minimum": 1 - } - }, - "task_classification": { - "type": "object" - }, - "lightweight_profiles": { - "type": "object" - }, + "model_registry_path": { "type": "string", "minLength": 1 }, + "pools_path": { "type": "string", "minLength": 1 }, + "provider_timeouts_ms": { "type": "object", "additionalProperties": { "type": "integer", "minimum": 1 } }, + "latency_timeout_mapping": { "type": "object", "additionalProperties": { "type": "integer", "minimum": 1 } }, + "smoke_test_provider_timeout_ms": { "type": "object", "additionalProperties": { "type": "integer", "minimum": 1 } }, + "task_classification": { "type": "object" }, + "lightweight_profiles": { "type": "object" }, "budgets": { "type": "object", "required": [ @@ -120,47 +39,52 @@ "max_input_tokens_per_task", "max_output_tokens_per_task", "max_cost_usd_per_task", + "max_workflow_calls_per_task", "fail_closed_on_unknown_pricing", + "persist_state", + "state_path", "ledger_ttl_minutes", "max_tracked_tasks", "unknown_paid_model_pricing" ], "properties": { - "enabled": { - "type": "boolean" - }, - "max_tokens_per_task": { - "type": ["integer", "null"], - "minimum": 0 - }, - "max_input_tokens_per_task": { - "type": ["integer", "null"], - "minimum": 0 - }, - "max_output_tokens_per_task": { - "type": ["integer", "null"], - "minimum": 0 - }, - "max_cost_usd_per_task": { - "type": ["number", "null"], - "minimum": 0 - }, - "fail_closed_on_unknown_pricing": { - "type": "boolean" - }, - "ledger_ttl_minutes": { - "type": "integer", - "minimum": 1 - }, - "max_tracked_tasks": { - "type": "integer", - "minimum": 1 - }, - "unknown_paid_model_pricing": { - "$ref": "#/$defs/pricing" - } + "enabled": { "type": "boolean" }, + "max_tokens_per_task": { "type": ["integer", "null"], "minimum": 0 }, + "max_input_tokens_per_task": { "type": ["integer", "null"], "minimum": 0 }, + "max_output_tokens_per_task": { "type": ["integer", "null"], "minimum": 0 }, + "max_cost_usd_per_task": { "type": ["number", "null"], "minimum": 0 }, + "max_workflow_calls_per_task": { "type": "integer", "minimum": 1 }, + "fail_closed_on_unknown_pricing": { "type": "boolean" }, + "persist_state": { "type": "boolean" }, + "state_path": { "type": "string", "minLength": 1 }, + "ledger_ttl_minutes": { "type": "integer", "minimum": 1 }, + "max_tracked_tasks": { "type": "integer", "minimum": 1 }, + "unknown_paid_model_pricing": { "$ref": "#/$defs/pricing" } }, "additionalProperties": false + }, + "events": { + "type": "object", + "required": ["enabled", "path", "schema_version"], + "properties": { + "enabled": { "type": "boolean" }, + "path": { "type": "string", "minLength": 1 }, + "schema_version": { "const": "1.0.0" } + }, + "additionalProperties": false + }, + "provider_retry": { + "type": "object", + "additionalProperties": { + "type": "object", + "properties": { + "max_retries": { "type": "integer", "minimum": 0, "maximum": 5 }, + "retryable_http_codes": { "type": "array", "items": { "type": "integer" }, "uniqueItems": true }, + "retryable_errors": { "type": "array", "items": { "type": "string" }, "uniqueItems": true }, + "non_retryable_errors": { "type": "array", "items": { "type": "string" }, "uniqueItems": true } + }, + "additionalProperties": false + } } }, "$defs": { @@ -174,26 +98,11 @@ "cache_write_per_million_usd" ], "properties": { - "input_per_million_usd": { - "type": "number", - "minimum": 0 - }, - "output_per_million_usd": { - "type": "number", - "minimum": 0 - }, - "reasoning_per_million_usd": { - "type": "number", - "minimum": 0 - }, - "cache_read_per_million_usd": { - "type": "number", - "minimum": 0 - }, - "cache_write_per_million_usd": { - "type": "number", - "minimum": 0 - } + "input_per_million_usd": { "type": "number", "minimum": 0 }, + "output_per_million_usd": { "type": "number", "minimum": 0 }, + "reasoning_per_million_usd": { "type": "number", "minimum": 0 }, + "cache_read_per_million_usd": { "type": "number", "minimum": 0 }, + "cache_write_per_million_usd": { "type": "number", "minimum": 0 } }, "additionalProperties": false } diff --git a/config/free-first-config.json b/config/free-first-config.json index f6136c6..d1a778f 100644 --- a/config/free-first-config.json +++ b/config/free-first-config.json @@ -1,6 +1,6 @@ { "$schema": "./free-first-config-schema.json", - "version": "1.2.0", + "version": "2.0.0", "general": { "free_first": true, "allow_paid_fallback": true, @@ -33,7 +33,11 @@ ], "local_only_task_classifications": [ "trusted-provider-only" - ] + ], + "paid_fallback_log_path": ".opencode/agent-loop-state/paid-escalations.jsonl", + "paid_fallback_state_path": ".opencode/agent-loop-state/paid-fallback-state.json", + "paid_fallback_global_window_minutes": 1440, + "paid_fallback_task_state_ttl_minutes": 1440 }, "cooldowns": { "single_rate_limit_minutes": 10, @@ -118,7 +122,10 @@ "groq": 30000, "cerebras": 60000, "opencode": 60000, + "opencode-go": 60000, "openrouter": 60000, + "openai": 300000, + "local": 420000, "default": 300000 }, "latency_timeout_mapping": { @@ -131,7 +138,10 @@ "groq": 15000, "cerebras": 15000, "opencode": 15000, + "opencode-go": 15000, "openrouter": 15000, + "openai": 15000, + "local": 30000, "default": 15000 }, "task_classification": { @@ -147,7 +157,10 @@ "max_input_tokens_per_task": 200000, "max_output_tokens_per_task": 50000, "max_cost_usd_per_task": 1, + "max_workflow_calls_per_task": 12, "fail_closed_on_unknown_pricing": false, + "persist_state": true, + "state_path": ".opencode/agent-loop-state/budgets.json", "ledger_ttl_minutes": 1440, "max_tracked_tasks": 1000, "unknown_paid_model_pricing": { @@ -157,5 +170,47 @@ "cache_read_per_million_usd": 1, "cache_write_per_million_usd": 5 } + }, + "events": { + "enabled": true, + "path": ".opencode/agent-loop-state/events.jsonl", + "schema_version": "1.0.0" + }, + "provider_retry": { + "local": { + "max_retries": 1, + "retryable_errors": [ + "ECONNRESET", + "ETIMEDOUT", + "ECONNREFUSED", + "EPIPE" + ], + "non_retryable_errors": [ + "UNAUTHORIZED", + "FORBIDDEN", + "BUDGET_EXCEEDED" + ] + }, + "nvidia": { + "max_retries": 2 + }, + "cerebras": { + "max_retries": 2 + }, + "groq": { + "max_retries": 1 + }, + "openrouter": { + "max_retries": 1 + }, + "opencode": { + "max_retries": 1 + }, + "opencode-go": { + "max_retries": 2 + }, + "openai": { + "max_retries": 2 + } } } diff --git a/config/supported-opencode.json b/config/supported-opencode.json new file mode 100644 index 0000000..d0521ca --- /dev/null +++ b/config/supported-opencode.json @@ -0,0 +1,7 @@ +{ + "repository": "anomalyco/opencode", + "ref": "14a5529793a91001ca81c80e96f39533eab79127", + "patch": "patches/opencode-subagent-failure-exposure.patch", + "verified_on": "2026-07-22", + "notes": "Latest upstream revision confirmed patch-compatible before the current patch series was created. Scheduled CI separately probes the moving dev branch for drift." +} diff --git a/docs/architecture.md b/docs/architecture.md index 12fb4b1..66fc48a 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -2,11 +2,11 @@ ## Overview -OpenCode Agent Loop coordinates specialized agents through a gated software-development lifecycle: +OpenCode Agent Loop coordinates a gated development lifecycle: -`plan → approve → baseline test → implement → verify and review → fix or escalate → commit` +`plan → approve → smoke → build → test → review → fix or escalate → commit` -The parent orchestrator remains responsible for inspection, planning, approval, stage order, and the final commit. Delegated model execution is centralized in the `agent_loop` custom tool so routing, failover, paid-fallback controls, and worker-budget enforcement cannot be bypassed. +The paid parent orchestrator handles inspection, planning, approval, stage order, and the final commit. Every delegated model call goes through `agent_loop`, which centralizes provider adapters, free-first routing, retries, failover, budget enforcement, and structured events. ```text User request @@ -14,118 +14,78 @@ The parent orchestrator remains responsible for inspection, planning, approval, v +--------------------------------------+ | Parent orchestrator | -| Inspects, plans, requests approval | -| Reuses one stable feature task ID | +| Plans and reuses one stable task ID | +| Turn cap + workflow-call budget | +--------------------------------------+ | v +--------------------------------------+ -| agent_loop custom tool | +| agent_loop runtime | | Smoke | Build | Test | Review | -| Escalate | Failover | Budget guard | +| Retry | Failover | Escalate | +--------------------------------------+ | v +--------------------------------------+ -| Specialized worker processes | -| Free-first pools and paid fallback | +| Reliability services | +| Provider adapters | Persistent budget| +| Structured events | Checkpoints | ++--------------------------------------+ + | + v ++--------------------------------------+ +| OpenCode worker processes | +| Free/local pools -> paid fallback | +--------------------------------------+ ``` -## Workflow state machine - -The normal path is: - -1. **PLANNING** — inspect the repository, discover commands, and define acceptance criteria -2. **AWAITING_APPROVAL** — present the plan and wait for explicit approval -3. **SMOKE_TESTING** — identify responsive worker models through `agent_loop` -4. **IMPLEMENTING** — run the approved build request with the stable task ID -5. **VERIFYING** — run post-change verification through the test role -6. **REVIEWING** — independently inspect the diff, tests, security, and acceptance criteria -7. **READY_TO_COMMIT** — confirm gates, inspect staged content, and create a focused commit -8. **COMPLETED** — report results and budget scope - -Conditional states are: +## Workflow contract -- **FIXING** — a bounded build call corrects failed test or review findings, then returns to verification and review -- **ESCALATING** — a stronger diagnostic role investigates repeated non-budget failures -- **BLOCKED** — progress requires user input, an unavailable dependency, or an exhausted budget +A feature uses one stable task ID across every stage. `BUDGET_EXCEEDED` is terminal: the orchestrator must not continue under a replacement ID or bypass `agent_loop` with direct task delegation. -`BUDGET_EXCEEDED` is terminal. The orchestrator must not continue under a fresh task ID or fall back to direct task delegation. +Test and review are independent gates. Every fix cycle returns through both. Only the parent orchestrator may create the final local commit, and it never pushes automatically. ## Agent roles -The repository contains cloud and local roles. The exact number may change as agents are added or retired, so documentation describes capabilities rather than relying on a fixed count. +The exact number of roles may change. Current capabilities include orchestration, standard and trivial building, testing, independent review, exploration, reconciliation, escalation, and local private or low-cost work. See [Agent Roles](agent-roles.md). -| Role | Responsibility | Typical access | -|------|----------------|----------------| -| Orchestrator | Inspection, planning, stage enforcement, final commit | Read, `agent_loop`, limited Git | -| Build worker | Approved implementation | Read, edit, bounded shell | -| Trivial builder | Small bounded changes | Read, edit, bounded shell | -| Test agent | Baseline and post-change verification | Read, test edits, bounded shell | -| Review agent | Independent review | Read-only | -| Explore agent | Codebase research | Read-only | -| Reconcile agent | Integrate overlapping work | Read, edit, bounded Git | -| Escalation agent | Diagnose repeated failures | Read, edit, optionally web | -| Local agents | Sensitive, offline, or low-cost bounded work | Role-specific, local model only | +## Routing and retries -See [Agent Roles](agent-roles.md). +Provider adapters identify models, select timeout keys, and normalize provider errors. Transient failures can retry the same model using exponential backoff and jitter. After retries are exhausted, failover moves to another eligible provider. Non-retryable task, auth, billing, safety, cancellation, and budget failures stop immediately. -## Model routing +Free-first worker pools may include local models and controlled paid fallback. GPT-5.6 Luna is reserved for explicit escalation. -The default design uses two routing policies: +## Budgets -- **Parent orchestrator:** paid DeepSeek V4 Flash for dependable coordination -- **Delegated workers:** free-first ordered pools, local models where appropriate, then controlled paid fallback +Budget state is persisted atomically per project under `.opencode/agent-loop-state/`. The ledger covers delegated tokens and cost plus the number of parent workflow calls. The parent orchestrator also has a bounded turn count. -GPT-5.6 Luna is reserved for explicit escalation and difficult diagnosis rather than routine work. +OpenCode does not currently expose parent-session token and cost events to this plugin, so snapshots state `parentModelUsageIncluded: false`. They must not be described as complete end-to-end dollar totals. -Provider failures are classified before failover. Rate limits and transient availability failures may trigger provider-wide cooldowns; isolated request, safety, or billing failures should only affect the relevant model or credential path. +## Structured events -## Runtime architecture +The runtime emits versioned JSON Lines events for workflow calls, stages, attempts, retries, model selection, cooldowns, budget changes, and completion. Events are recursively redacted and queryable with `scripts/query-events.mjs`. See [Structured Event Logging](event-logging.md). -```text -OpenCode TUI - | - +-- /feature --> orchestrator - | - stable taskId - | - v - agent_loop tool - | - v - agent-loop-controller.mjs - | - +----------+----------+ - | | | - failover paid guard budget guard - | | | - +----------+----------+ - | - v - OpenCode worker process -``` - -The plugin entry point is `.opencode/plugins/agent-loop.js`. Runtime execution is centralized under `runtime/`, with routing and budget helpers under `lib/`. +## State and recovery -The budget ledger covers delegated worker processes. The parent orchestrator model's own message usage is outside that ledger and must be reported separately as an untracked scope limitation. - -## Configuration and state - -Stable configuration: +Stable configuration lives in: - `config/free-first-config.json` - `config/free-first-config-schema.json` - `config/free-first-pools.json` - `config/model-registry.json` -Transient health data, cooldowns, failure counts, task checkpoints, and worker logs should be written to ignored runtime-state files rather than committed configuration. Budget ledgers are retained in memory only for the configured TTL and maximum task count. +Ignored runtime state includes: + +- persistent budgets +- structured events +- provider cooldowns +- portable task checkpoints +- attempt and progress logs -See [Configuration](configuration.md). +The v0.2 foundations make interrupted-state reconstruction possible; full automatic stage resume remains planned for v0.3. -## Trust boundaries +## Compatibility and trust boundaries -Agent permissions reduce accidental damage but are not an operating-system sandbox. Shell permissions must use explicit command patterns and deny destructive Git operations. Run untrusted work in a container or VM and review provider data policies before sending confidential code. +The portable Node runtime is tested on Linux, macOS, and Windows. Bash installation and permission validation require Linux, WSL, macOS, or Git Bash. A scheduled workflow builds the patched OpenCode revision and verifies required source contracts. -See [Safety Model](safety-model.md). \ No newline at end of file +Agent permissions and redaction reduce risk but are not an operating-system sandbox. Run untrusted repositories in a container or VM, keep credentials out of prompts, and review provider data policies. diff --git a/docs/configuration.md b/docs/configuration.md index 966254e..4fce312 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -1,114 +1,117 @@ # Configuration -OpenCode Agent Loop separates stable model metadata, role ordering, and runtime policy into three JSON files. +OpenCode Agent Loop separates stable model metadata, role ordering, runtime policy, and generated state. -## Files +## Stable configuration ### `config/free-first-config.json` -Controls global behavior such as: - -- whether free-first routing is enabled -- whether paid fallback is allowed -- retry and cooldown durations -- smoke-test behavior -- privacy classifications -- paid-call limits -- delegated-worker token and cost budgets - -The file references `config/free-first-config-schema.json` for editor validation. Treat it as policy. Do not store provider keys or other credentials in it. +Controls free-first and paid-fallback policy, retries, cooldowns, provider timeouts, privacy classifications, budgets, state retention, and structured event logging. Treat it as policy; never store credentials in it. ### `config/free-first-pools.json` -Defines the ordered models available to each role. Every pool uses ordered failover. A typical pool contains: - -1. one or more free models from different providers -2. optional local models -3. one or more paid fallbacks - -Runtime health data should not be committed to this file. Fields such as `cooldown_until`, `consecutive_failures`, and `last_failure_reason` belong in an ignored runtime-state file. +Defines ordered model candidates for every role. A pool normally contains free models from different providers, optional local models, and controlled paid fallbacks. Runtime cooldowns and failure counters do not belong in this committed file. ### `config/model-registry.json` -Stores model capabilities and data-handling metadata, including: +Stores stable model identity, capability, privacy, retirement, and pricing metadata. -- provider and model ID -- context and output limits -- tool and vision support -- coding, orchestration, review, and debugging scores -- privacy classification -- whether sensitive code is allowed -- paid equivalent or fallback information -- structured pricing where known +### `config/free-first-config-schema.json` -The registry is descriptive configuration, not a runtime log. Health-check timestamps and failure counters should be initialized to clean values in version control. +Defines the supported policy structure. CI also runs semantic checks that JSON Schema alone cannot express. ## Routing strategy -The intended default strategy is: +The default strategy is: - paid DeepSeek V4 Flash for the parent orchestrator -- free-first pools for delegated build, test, review, and related worker calls -- paid fallback only after suitable free providers are unavailable or fail +- free-first pools for delegated build, test, review, exploration, and reconciliation work +- local models for suitable low-cost or private tasks +- controlled paid fallback after suitable free/local choices fail or are unavailable - GPT-5.6 Luna for explicit escalation and difficult diagnosis -The `/feature` command must make delegated calls through the `agent_loop` custom tool. Direct use of OpenCode's built-in `task` tool bypasses this package's router, failover controller, and budget ledger. +The `/feature` command must make delegated calls through `agent_loop`. Direct use of OpenCode's built-in `task` tool bypasses this package's router and safety controls. + +Provider adapters normalize identity, timeout selection, and provider-specific errors. See [Provider Adapters](provider-adapters.md). + +## Retry policy + +`maxRetries` controls retries of the same model after transient provider or network failures. Retries use exponential backoff plus jitter and count toward the task budget. Provider failover begins only after same-model retries are exhausted. + +Authentication, billing, safety, invalid-request, task-quality, cancellation, and budget failures are terminal. ## Task budgets -The `budgets` section in `config/free-first-config.json` enforces a shared limit for one stable task ID across smoke, build, test, review, escalation, and provider-failover attempts. +One stable task ID shares a ledger across smoke, build, test, review, fixes, escalation, and provider failover. Default limits are: -- 250,000 total tokens +- 250,000 delegated-worker tokens - 200,000 input tokens - 50,000 output plus reasoning tokens -- $1.00 estimated or provider-reported cost +- $1.00 estimated or provider-reported delegated-worker cost +- 12 workflow calls + +The greater of estimated and provider-reported cost is charged. Unknown paid-model prices use `unknown_paid_model_pricing` unless fail-closed behavior is enabled. -OpenCode `step_finish` events provide input, output, reasoning, cache-read, cache-write, and provider-reported cost data. The runtime tracks those values by task step and model. Cost estimation uses structured `pricing` fields in `config/model-registry.json`; legacy price text is supported for compatibility. Unknown paid-model prices use the conservative fallback rates in `unknown_paid_model_pricing` unless `fail_closed_on_unknown_pricing` is enabled. +### Persistence -The reported `scope` is `delegated-workers`. The parent orchestrator model's own conversation usage is not included in this ledger, so the budget snapshot must not be presented as the complete cost of the entire OpenCode session. +With `persist_state: true`, ledgers are atomically stored at: -When any limit is crossed, the active worker is terminated, failover stops, and the structured result returns `code: "BUDGET_EXCEEDED"` with the complete worker-budget snapshot. The runtime continues recording any final usage events emitted while the process shuts down so the ledger does not understate the completed request. +```text +/.opencode/agent-loop-state/budgets.json +``` + +They survive OpenCode and plugin restarts. `ledger_ttl_minutes` and `max_tracked_tasks` bound retained state. `AGENT_LOOP_BUDGET_STATE_PATH` can override the path for testing or managed deployments. + +A snapshot reports limits, remaining allowance, token and cost usage, workflow calls by stage, per-stage and per-model totals, unknown-pricing models, and exceeded reasons. -Reuse the same optional `taskId` in every sequential `agent_loop` call belonging to one feature. Starting a new task ID after exhaustion would bypass the configured safety limit and is prohibited by the `/feature` contract. +`BUDGET_EXCEEDED` is terminal for the task ID. Starting the same work with a replacement ID is a budget bypass and is prohibited. -### Ledger retention +### Parent-orchestrator scope -Budget ledgers live in the long-running plugin process so separate stage calls can share totals. To prevent unbounded memory growth: +The runtime limits parent orchestration with a workflow-call ceiling and a reduced maximum turn count. OpenCode does not currently expose parent-session token and cost events to this plugin, so precise parent-model tokens and dollars are not included. Results state `parentModelUsageIncluded: false` rather than claiming a complete task-cost total. -- `ledger_ttl_minutes` expires inactive ledgers; default: 1,440 minutes -- `max_tracked_tasks` caps retained ledgers; default: 1,000 tasks +## Paid fallback audit state -Expired or oldest inactive ledgers are pruned when a new tracker is created. These values affect in-memory accounting only; they do not delete repository logs. +Paid fallback selections and call counters are persisted per project under `.opencode/agent-loop-state/`. The audit log contains only model, role, failure-code, and outcome metadata; it excludes prompts and credentials. Persistence prevents a process restart from resetting paid-call ceilings. -## Provider cooldowns +The global call limit uses a rolling window controlled by `paid_fallback_global_window_minutes`—24 hours by default—rather than becoming a permanent lifetime lockout. Per-task counters are pruned after `paid_fallback_task_state_ttl_minutes`, also 24 hours by default. -Rate-limit and transient availability failures may affect an entire provider. When a provider-wide failure is detected: +## Structured events -1. record the failure in runtime state -2. skip other models from that provider during the cooldown -3. select the next enabled model from a different provider -4. preserve task checkpoint context before retrying +The default append-only stream is: + +```text +/.opencode/agent-loop-state/events.jsonl +``` -Do not commit local cooldown state back into the repository. +Events conform to `config/agent-loop-event.schema.json` and cover stages, model attempts, retries, provider cooldowns, budget updates, and completion. See [Structured Event Logging](event-logging.md). + +## Provider timeouts + +Timeouts are selected through provider adapters. Local IDs such as `ollama-9b-local` resolve to the `local` timeout even without a slash. The configured local timeout matches `general.local_model_request_timeout_seconds`. ## Privacy classifications -Tasks are classified before routing. Sensitive tasks must not be sent to providers whose registry entry disallows sensitive code. Local-only tasks must remain on approved local models. +Sensitive tasks must not go to providers whose registry entries disallow sensitive code. Local-only work must remain on approved local models. Configuration cannot replace review of current provider data policies. -Configuration cannot replace provider-contract review. Verify each provider's current data policy before using it with confidential code. +## Runtime state -## Environment variables +Generated state is ignored by Git: -See `.env.example` for supported environment variables. Keep secrets in the environment or an external secret manager. Never add API keys to JSON configuration, agent Markdown, logs, or issue reports. +- `.opencode/agent-loop-state/` +- `.opencode/agent-loop-logs/` +- role-pool health state files -## Validation +Do not commit these files or share them without reviewing them for sensitive material. -Run: +## Validation ```bash +npm ci npm run validate +npm run validate:portable ``` -The validation command checks repository structure, agent permissions, routing defaults, the budget policy and referenced schema, `/feature` integration, documentation links, and the deterministic test suites. \ No newline at end of file +The portable command runs Node-based checks on Linux, macOS, and Windows. Full validation additionally runs Bash permission checks. diff --git a/docs/event-logging.md b/docs/event-logging.md new file mode 100644 index 0000000..550f891 --- /dev/null +++ b/docs/event-logging.md @@ -0,0 +1,44 @@ +# Structured event logging + +The runtime writes append-only JSON Lines events to: + +```text +/.opencode/agent-loop-state/events.jsonl +``` + +Each event conforms to `config/agent-loop-event.schema.json` and includes: + +- schema version and unique event ID +- timestamp and task ID +- event type +- optional stage, role, and model ID +- recursively redacted structured data + +Current event families include: + +- `workflow.call`, `workflow.call.completed`, and `workflow.call.failed` +- `stage.started`, `stage.completed`, `stage.failed`, and `stage.blocked` +- `smoke.completed` +- `model.attempt.*`, `model.invocation.*`, and `model.retry.scheduled` +- `provider.cooldown`, `provider.skipped`, and `provider.cooldown-reaped` +- `paid-fallback.denied` +- `budget.updated` and `budget.exceeded` +- `routing.event` for backward-compatible routing events that do not yet have a dedicated type + +## Querying + +```bash +node scripts/query-events.mjs --task feature-auth-20260722 --format summary +node scripts/query-events.mjs --type budget.exceeded --format json +node scripts/query-events.mjs --model opencode/deepseek-v4-flash-free --limit 50 +``` + +The stream supports debugging, audit history, budget recovery, performance analysis, and later dashboard integrations. It is local runtime state and must not be committed. + +## Backward compatibility + +The structured stream supplements rather than replaces existing attempt and progress logs under `.opencode/agent-loop-logs/`. Existing log consumers can continue reading those files. New integrations should prefer the versioned event stream and treat unknown event types as forward-compatible records. + +## Redaction + +The logger recursively redacts fields with credential-like names and common token, bearer, private-key, and environment-secret formats. Redaction is defense in depth, not permission to log secrets deliberately. Worker prompts, environment files, credentials, and production data should never be emitted. diff --git a/docs/opencode-fork.md b/docs/opencode-fork.md index bc31582..2647ad6 100644 --- a/docs/opencode-fork.md +++ b/docs/opencode-fork.md @@ -1,69 +1,74 @@ # Required OpenCode Patch -OpenCode Agent Loop currently requires a patched OpenCode build. The stock release does not expose all subagent failure information needed for reliable model failover. +OpenCode Agent Loop currently requires a patched OpenCode build. The stock release does not expose all subagent failure information needed for reliable failover. ## Why the patch is required -When a delegated agent fails because of a provider error, rate limit, timeout, quota problem, or non-retryable request error, the orchestrator must receive a structured failure. Without the patch, some failed task-tool calls can appear successful to the caller. +Delegated provider errors, rate limits, timeouts, quota failures, and non-retryable request failures must reach the parent as structured failures. Without the patch, a failed child call can appear successful and allow the workflow to advance incorrectly. -That can cause the workflow to: +## Supported revision -- advance after an implementation failed -- skip model failover -- misclassify transient and permanent provider errors -- verify or review an incomplete change +The known-good upstream repository and commit are recorded in: -## Patch contents +```text +config/supported-opencode.json +``` -The repository includes: +Use that pinned revision for normal installations. The moving upstream `dev` branch is probed separately because the patch is revision-sensitive and does not necessarily apply to current development head. + +## Patch contents ```text patches/opencode-subagent-failure-exposure.patch ``` -The patch adds or updates behavior for: - -- task-level model overrides -- provider-error classification -- subagent retry and failure metadata -- non-retryable free-usage-limit handling -- child-session failure propagation -- resilient session-title generation +The patch covers task-level model overrides, provider-error classification, retry metadata, free-usage-limit behavior, child-session failure propagation, and resilient session titles. ## Build process -OpenCode's source repository and build commands can change. Follow the current upstream development instructions for prerequisites and building, then apply this repository's patch before compiling. - -A typical flow is: - ```bash -# Clone the current official OpenCode source repository. git clone https://github.com/anomalyco/opencode.git cd opencode -# Apply the patch from this repository. +git checkout "$(node -e "const fs=require('fs'); const p=JSON.parse(fs.readFileSync('/absolute/path/to/opencode-agent-loop/config/supported-opencode.json','utf8')); process.stdout.write(p.ref)")" git apply /absolute/path/to/opencode-agent-loop/patches/opencode-subagent-failure-exposure.patch -# Install and build using the commands documented by upstream. +# Install and build using the checked-out revision's upstream instructions. ``` -Do not assume an old `npm run build` command remains valid. Use the package manager and build procedure specified by the checked-out OpenCode revision. +OpenCode's source layout and build procedure can change. Do not substitute the latest `dev` revision merely because cloning it succeeds, and do not assume an old npm build command remains valid. + +## Automated compatibility workflow + +`.github/workflows/opencode-patch-compat.yml` runs for compatibility-related pull requests, weekly, and on manual request. + +The required job: + +1. reads the pinned revision from `config/supported-opencode.json` +2. verifies and applies the patch +3. installs upstream Bun dependencies +4. builds a standalone OpenCode binary +5. verifies the patched source contracts +6. publishes the exact tested revision as a workflow artifact + +A separate non-blocking job probes the moving `dev` branch. Its purpose is early warning that the patch needs rebasing; failure of that probe does not invalidate the pinned supported build. -## Verify the patched behavior +## Verify behavior -At minimum, verify these cases before using the loop on important work: +Before important use, verify: -1. A subagent provider failure is surfaced as a structured failure. -2. A rate limit is classified separately from authentication or billing errors. -3. Non-retryable failures stop retrying promptly. -4. A free-usage-limit failure allows the orchestrator to select another model. -5. A task-level model override reaches the child session. -6. Cancelling or aborting a child task does not erase its failure metadata. +1. provider failures surface as structured failures +2. rate limits differ from authentication and billing failures +3. non-retryable failures stop promptly +4. free-usage exhaustion permits another eligible model +5. task-level model overrides reach the child session +6. cancellation preserves failure metadata +7. JSON usage and cost events remain available to budget enforcement -Run the relevant upstream task, retry, session, and provider tests for the exact revision you built. +Run the relevant upstream task, retry, session, provider, and CLI tests for the exact revision you built. ## Compatibility warning -This patch is revision-sensitive. It may fail to apply cleanly after upstream changes, or it may apply while no longer producing the intended behavior. Treat a clean `git apply` as necessary but not sufficient; run the verification tests. +The patch may fail to apply, fail to build, or apply while no longer producing the intended behavior. Treat `git apply` success as necessary but insufficient. -The long-term goal is to remove this requirement by relying on equivalent upstream behavior or a stable provider/task extension interface. +The long-term goal is to replace the patch with equivalent upstream behavior or a stable provider/task extension interface. diff --git a/docs/platforms.md b/docs/platforms.md new file mode 100644 index 0000000..d79cab6 --- /dev/null +++ b/docs/platforms.md @@ -0,0 +1,38 @@ +# Platform support + +The runtime is written in Node.js and is tested on Linux, macOS, and Windows. Shell-based installation and agent validation still require a POSIX-compatible shell. + +## Linux + +Linux is the primary supported environment. Run the standard installer: + +```bash +bash scripts/install.sh +``` + +## macOS + +Install Node.js 18 or newer and a patched OpenCode build, then run the standard installer from Terminal. Both Intel and Apple Silicon use the same package files; the OpenCode executable itself must match the machine architecture. + +## Windows + +Supported workflows are: + +1. **WSL 2** — recommended; follow the Linux instructions inside WSL. +2. **Git Bash** — supported for installation and shell validation when Node and the patched OpenCode executable are available on `PATH`. +3. **Native PowerShell/cmd** — the Node runtime and tests are portable, but `scripts/install.sh` and Bash validation scripts are not native PowerShell installers. + +Do not mix Windows and WSL configuration directories. Install and run OpenCode in the same environment where the agent loop is installed. + +## Verification + +On every platform: + +```bash +node --version +npm ci +npm test +npm run validate:portable +``` + +The cross-platform CI matrix runs the portable test suite on Ubuntu, macOS, and Windows. Linux CI additionally runs Bash permission and installation-oriented checks. diff --git a/docs/provider-adapters.md b/docs/provider-adapters.md new file mode 100644 index 0000000..ffaf16e --- /dev/null +++ b/docs/provider-adapters.md @@ -0,0 +1,43 @@ +# Provider adapters + +Provider-specific behavior is isolated behind `ProviderAdapter` implementations in `lib/provider-adapters.mjs`. + +An adapter provides: + +- provider identity and aliases +- local/unmetered classification +- timeout configuration key +- provider-error normalization +- model-ID matching + +Built-in adapters cover OpenCode, OpenCode Go, NVIDIA, Cerebras, Groq, OpenRouter, OpenAI, and local/Ollama model IDs. + +## Custom adapter + +Create a subclass and register it before running workers: + +```js +import { ProviderAdapter, registerProviderAdapter } from './lib/provider-adapters.mjs'; + +class AcmeAdapter extends ProviderAdapter { + constructor() { + super({ id: 'acme', aliases: ['acme-ai'], timeoutKey: 'acme' }); + } + + normalizeError(result) { + const normalized = super.normalizeError(result); + // Add provider-specific status and quota mappings here. + return normalized; + } +} + +registerProviderAdapter(new AcmeAdapter()); +``` + +See `examples/provider-adapter.mjs` for a complete example. + +Add the provider's timeout to `provider_timeouts_ms` and `smoke_test_provider_timeout_ms`. Model entries remain in `config/model-registry.json`; role order remains in `config/free-first-pools.json`. + +## Provider-specific retry policy + +Use the optional `provider_retry` map in `config/free-first-config.json` to cap retries or override retryable and non-retryable codes for one provider. The adapter combines that policy with the global retry defaults. A provider-specific cap can reduce, but cannot increase, the caller's `maxRetries` value. diff --git a/docs/roadmap.md b/docs/roadmap.md index 932b0c2..7ffa13a 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,72 +1,73 @@ # Roadmap ## Vision -A reliable, safe, and extensible open-source framework for autonomous software-development task orchestration that works with any model provider and any project. -## Version 0.1 — Initial Public Release (Current) -**Focus**: Sanitized, documented, testable foundation. +A reliable, safe, and extensible open-source framework for autonomous software-development task orchestration that works with multiple model providers and project types. + +## Version 0.1 — Public foundation + +**Focus:** Sanitized, documented, testable foundation. - [x] Sanitized public release with no private references - [x] Core orchestration loop with stage enforcement -- [x] 10 specialized agent roles -- [x] Paid-primary model routing with multi-provider failover -- [x] Configuration validation and schema -- [x] Deterministic test fixtures (routing, runtime, integration) -- [x] Comprehensive documentation (architecture, providers, safety, etc.) -- [x] CI workflow with GitHub Actions -- [x] Security policy and vulnerability reporting -- [x] Contribution guidelines and code of conduct -- [x] CLI commands: /feature, /loop, /loop-init -- [x] Example fixtures for common workflows - -## Version 0.2 — Provider Adapters and Improved Resilience -**Focus**: Provider abstraction, improved failover, structured events. - -- [ ] Provider adapter interface for easier third-party model integration -- [ ] Structured event log schema for audit and debugging -- [ ] Improved checkpointing with partial progress recovery -- [ ] Budget enforcement (token and cost limits per task) -- [ ] Better retry strategies with exponential backoff configuration -- [ ] Additional example workflows (database migration, refactoring) -- [ ] Windows compatibility testing and fixes -- [ ] macOS compatibility validation - -## Version 0.3 — Plugin System and Observability -**Focus**: Extensibility, monitoring, safety hardening. - -- [ ] Plugin system for custom agent roles and workflow hooks -- [ ] Reusable evaluation harness for benchmarking agent performance -- [ ] Optional web dashboard for task progress visualization -- [ ] Stronger sandboxing (container-based execution) -- [ ] Community-contributed role packs -- [ ] Expanded local-model support documentation -- [ ] i18n of documentation - -## Version 0.4 — Enterprise Readiness -**Focus**: Scale, audit, compliance. +- [x] Specialized cloud and local roles +- [x] Paid orchestrator with free-first delegated-role routing +- [x] Deterministic routing, runtime, integration, and security tests +- [x] CI, contribution, security, and release workflows +- [x] `/feature`, `/loop`, and `/loop-init` commands + +## Version 0.2 — Reliability, portability, and observability + +**Focus:** Durable budgets, provider abstraction, structured events, and cross-platform validation. + +- [x] Configurable token, cost, and workflow-call budgets +- [x] Persistent budget state across plugin and process restarts +- [x] Same-model transient retries with exponential backoff and jitter +- [x] Provider adapter interface and custom adapter example +- [x] Local/Ollama provider recognition and timeout handling +- [x] Versioned structured event schema and append-only event log +- [x] Event query utility and recursive log redaction +- [x] Portable checkpoint paths +- [x] Linux, macOS, Windows, WSL, and Git Bash guidance +- [x] Cross-platform Node runtime CI +- [x] Scheduled OpenCode patch compatibility workflow +- [x] Runtime configuration schema and integration-contract validation + +## Version 0.3 — Recovery and evaluation + +**Focus:** Deeper resilience, performance measurement, and operational visibility. + +- [ ] Resumable workflow controller with durable stage checkpoints +- [ ] Automatic recovery from interrupted build/test/review stages +- [ ] Reusable evaluation harness for agent quality, latency, and cost +- [ ] Optional task-progress dashboard backed by structured events +- [ ] Container-based execution profiles for stronger isolation +- [ ] Community-contributed role and provider packs +- [ ] Expanded local-model benchmarking and hardware guidance + +## Version 0.4 — Team and enterprise workflows + +**Focus:** Scale, audit, and administrative controls. - [ ] Multi-project orchestration - [ ] Persistent task queues and backlog processing -- [ ] Role-based access control for team use -- [ ] Audit trail with tamper-evident logging -- [ ] Compliance documentation (SOC2, GDPR) -- [ ] SLA monitoring for model providers +- [ ] Team role-based access control +- [ ] Tamper-evident audit storage +- [ ] Provider SLA and spend monitoring - [ ] SSO and team authentication integration +- [ ] Compliance deployment guidance ## Version 1.0 — Stable API -**Focus**: API stability, production hardening, ecosystem growth. - -- [ ] Stable, versioned configuration schema -- [ ] Backward-compatible migration paths -- [ ] Extensive integration testing across providers -- [ ] Performance benchmarks and optimization -- [ ] Official package registries (npm, Homebrew, Docker) -- [ ] Third-party provider certification program - -## How to Influence the Roadmap -- Open GitHub issues with the "enhancement" label -- Participate in GitHub Discussions -- Submit pull requests for planned features -- Share your use cases and requirements - -The roadmap is a living document. Priorities may shift based on community feedback, security requirements, and contributor availability. \ No newline at end of file + +**Focus:** Long-term compatibility and ecosystem growth. + +- [ ] Stable, versioned public APIs +- [ ] Backward-compatible configuration migrations +- [ ] Extensive live-provider integration testing +- [ ] Published performance and reliability benchmarks +- [ ] Official npm, Homebrew, and container distributions +- [ ] Upstream or extension-based replacement for the OpenCode source patch + +## How to influence the roadmap + +Open an issue describing the use case, safety constraints, expected behavior, and evidence that would prove completion. The roadmap is a living document; reliability and security issues take priority over adding more agent roles. diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index dcaed63..5420980 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -1,78 +1,74 @@ # Troubleshooting -## Common Issues +## All providers are unresponsive -### "All providers unresponsive" +1. Run `opencode models` and confirm the configured model IDs exist. +2. Verify network connectivity and provider credentials. +3. Check runtime cooldown state under `.opencode/agent-loop-state/`. +4. Query recent provider and model-attempt events. +5. Wait for the configured cooldown or select a different provider. -**Cause**: No model provider responded to the smoke test. +```bash +node scripts/query-events.mjs --task --format summary +``` -**Solutions**: -1. Check your OpenCode installation: `opencode models` -2. Verify network connectivity -3. Check if models are in cooldown: check `config/free-first-pools-state.json` -4. Wait for cooldown to expire (configurable in `free-first-config.json`) -5. Try a different model provider +## A task stops with `BUDGET_EXCEEDED` -### "/feature does nothing" +Do not restart the request under a new task ID. Inspect the returned budget snapshot and event stream: -**Cause**: The /feature command requires the TUI (interactive mode). +```bash +node scripts/query-events.mjs --task --type budget.exceeded --format json +``` -**Solutions**: -1. Run `opencode` (without arguments) to enter TUI mode -2. Type `/feature ` in the chat -3. Or use `/loop ` for the agent_loop tool path +Raise a budget only after reviewing why the task consumed it. Persistent budget state lives under `.opencode/agent-loop-state/` and survives plugin restarts. -### "Tests fail with no output" +## `/feature` does nothing -**Cause**: Missing test dependencies or incorrect test configuration. +The slash command requires OpenCode's interactive TUI. Start `opencode`, then enter `/feature `. Use `/loop ` for direct access to the custom tool. -**Solutions**: -1. Run `npm install` to ensure dependencies are installed -2. Check the project's `AGENTS.md` for test commands -3. Run `bash scripts/validate.sh` to check your setup +## Tests fail with no output -### "Model X not found" +1. Run `npm ci` in this package. +2. Read the target project's `AGENTS.md` for its exact commands. +3. Run `npm run validate` in the agent-loop package. +4. Inspect the attempt log and structured event log. -**Cause**: The model might not be available in your OpenCode installation. +## A model is not found -**Solutions**: -1. Run `opencode models` to list available models -2. Check `config/free-first-pools.json` for enabled models -3. Check `config/model-registry.json` for the model entry +1. Run `opencode models`. +2. Check `config/free-first-pools.json` for the role. +3. Check `config/model-registry.json` for retirement or disabled status. +4. Confirm the provider adapter recognizes the model prefix. -### "Commit blocked by pre-existing changes" +## A local model times out too quickly -**Cause**: There are unstaged or uncommitted changes in the working tree. +Local and Ollama IDs use the `local` timeout key, including IDs without a slash such as `ollama-9b-local`. Adjust `general.local_model_request_timeout_seconds` or `provider_timeouts_ms.local`. -**Solutions**: -1. Commit or stash your changes before running /feature -2. The orchestrator will not discard unrelated changes +## Retry behavior is unexpected -## Debugging +`maxRetries` controls retries of the same model for transient failures. Provider failover is separate. Authentication, billing, safety, invalid-request, task-quality, and budget failures are terminal and are not retried. -### Enable verbose logging +## A commit is blocked by pre-existing changes -Agent loop logs are written to `.opencode/agent-loop-logs/`. Check these files for detailed execution traces: +The orchestrator preserves unrelated changes. Commit or stash them yourself, or ensure the intended feature diff can be staged independently. The loop must not discard unrelated work. -```bash -ls .opencode/agent-loop-logs/ -``` +## The OpenCode patch no longer applies -### Run individual steps +Run the OpenCode compatibility workflow manually. It clones the configured upstream revision, checks the patch, installs Bun dependencies, builds a standalone binary, and runs the compatibility probe. A clean `git apply` is necessary but not sufficient; build and behavior checks must pass. -You can test individual agent roles with the agent_loop tool: +## Windows path or shell errors -```bash -# Test model availability -opencode run "smoke test" +Use WSL 2 or Git Bash for installation. The Node runtime is tested natively on Windows, but Bash installation and permission-validation scripts require a POSIX-compatible shell. See [Platform Support](platforms.md). -# Run just the test step -opencode run "test the implementation" -``` +## Logs and sensitive material + +Runtime logging recursively redacts credential-like fields and common token, bearer, environment-secret, and private-key formats. This is defense in depth. Never place credentials or production data in prompts, and review logs before sharing them. -### Validate configuration +Attempt logs are written under `.opencode/agent-loop-logs/`. Structured events and persistent budgets are under `.opencode/agent-loop-state/`. + +## Validate the installation ```bash -bash scripts/validate.sh -bash scripts/validate-agent-configs.sh -``` \ No newline at end of file +npm ci +npm run validate +``` diff --git a/docs/v0.2.0-release-notes.md b/docs/v0.2.0-release-notes.md new file mode 100644 index 0000000..a1db172 --- /dev/null +++ b/docs/v0.2.0-release-notes.md @@ -0,0 +1,16 @@ +# OpenCode Agent Loop v0.2.0 + +Version 0.2.0 focuses on reliability, portability, and observability. + +Highlights: + +- persistent token, cost, and workflow-call budgets +- same-model transient retries before provider failover +- provider adapter interface with local/Ollama recognition +- versioned structured event logging and a query utility +- stronger recursive log redaction +- portable checkpoint and executable handling +- Linux, macOS, and Windows runtime validation +- scheduled patched-OpenCode compatibility builds + +The parent orchestrator remains paid and bounded by a workflow-call ceiling and a lower maximum turn count. Exact parent-session token and dollar use is not available to the plugin, so budget output identifies that limitation rather than presenting worker totals as complete task cost. diff --git a/examples/database-migration-workflow.md b/examples/database-migration-workflow.md new file mode 100644 index 0000000..a346bd5 --- /dev/null +++ b/examples/database-migration-workflow.md @@ -0,0 +1,15 @@ +# Database migration workflow example + +Use one stable task ID for every stage. + +1. Inspect the schema, migration framework, backup procedure, and rollback path. +2. Define acceptance criteria for forward migration, rollback, data preservation, and application compatibility. +3. Run `agent_loop` in `smoke` mode. +4. Run baseline tests before editing. +5. Run `build` with the migration request and responsive model IDs. +6. Run migration-specific tests against disposable data. +7. Run the normal `test` and independent `review` stages. +8. Fix findings with the same task ID, then repeat both gates. +9. Commit only the intended migration, application changes, and tests. + +Never run an autonomous migration against production data. Use an isolated database, verified backup, and explicit human deployment approval. diff --git a/examples/provider-adapter.mjs b/examples/provider-adapter.mjs new file mode 100644 index 0000000..2e6642b --- /dev/null +++ b/examples/provider-adapter.mjs @@ -0,0 +1,18 @@ +import { ProviderAdapter, registerProviderAdapter } from '../lib/provider-adapters.mjs'; + +class ExampleProviderAdapter extends ProviderAdapter { + constructor() { + super({ id: 'example', aliases: ['example-cloud'], timeoutKey: 'example' }); + } + + normalizeError(result) { + const normalized = super.normalizeError(result); + if (/daily allowance/i.test(normalized.message)) { + normalized.statusCode = 429; + normalized.code = 'DAILY_QUOTA_EXHAUSTED'; + } + return normalized; + } +} + +registerProviderAdapter(new ExampleProviderAdapter()); diff --git a/lib/budget-manager.mjs b/lib/budget-manager.mjs index 569b12a..3f5d403 100644 --- a/lib/budget-manager.mjs +++ b/lib/budget-manager.mjs @@ -1,10 +1,12 @@ import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { defaultBudgetStatePath, loadBudgetState, saveBudgetState } from './budget-store.mjs'; -const ledgers = new Map(); - +const stores = new Map(); const TOKEN_FIELDS = ['input', 'output', 'reasoning', 'cacheRead', 'cacheWrite']; const DEFAULT_LEDGER_TTL_MINUTES = 24 * 60; const DEFAULT_MAX_TRACKED_TASKS = 1000; +const DEFAULT_MAX_WORKFLOW_CALLS = 12; function finiteNonNegative(value, fallback = 0) { const number = Number(value); @@ -36,6 +38,10 @@ function emptyUsage() { }; } +function normalizeStoredUsage(value = {}) { + return { ...emptyUsage(), ...Object.fromEntries(Object.entries(value).map(([key, item]) => [key, finiteNonNegative(item)])) }; +} + export function normalizeTokenUsage(raw = {}) { const cache = raw.cache || {}; const usage = { @@ -50,11 +56,8 @@ export function normalizeTokenUsage(raw = {}) { } function loadJson(path, fallback) { - try { - return JSON.parse(readFileSync(path, 'utf8')); - } catch { - return fallback; - } + try { return JSON.parse(readFileSync(path, 'utf8')); } + catch { return fallback; } } function modelMap(registry = {}) { @@ -123,103 +126,111 @@ export function estimateUsageCost({ modelId, usage, registry = {}, unknownPricin const normalized = normalizeTokenUsage(usage); const model = modelMap(registry).get(modelId); const pricing = normalizedPricing(model, unknownPricing); - const perMillion = 1_000_000; const estimatedCostUsd = ( normalized.input * pricing.input_per_million_usd + normalized.output * pricing.output_per_million_usd + normalized.reasoning * pricing.reasoning_per_million_usd + normalized.cacheRead * pricing.cache_read_per_million_usd + normalized.cacheWrite * pricing.cache_write_per_million_usd - ) / perMillion; - - return { - estimatedCostUsd, - pricing, - pricingKnown: pricing.source !== 'unknown-model-fallback', - modelFound: Boolean(model) - }; + ) / 1_000_000; + return { estimatedCostUsd, pricing, pricingKnown: pricing.source !== 'unknown-model-fallback', modelFound: Boolean(model) }; } -function budgetSettings(config = {}) { +function budgetSettings(config = {}, cwd = process.cwd()) { const raw = config.budgets || {}; - const ttlMinutes = positiveInteger(raw.ledger_ttl_minutes, DEFAULT_LEDGER_TTL_MINUTES); + const configuredPath = raw.state_path || '.opencode/agent-loop-state/budgets.json'; return { enabled: raw.enabled !== false, maxTokens: optionalLimit(raw.max_tokens_per_task), maxInputTokens: optionalLimit(raw.max_input_tokens_per_task), maxOutputTokens: optionalLimit(raw.max_output_tokens_per_task), maxCostUsd: optionalLimit(raw.max_cost_usd_per_task), + maxWorkflowCalls: optionalLimit(raw.max_workflow_calls_per_task) ?? DEFAULT_MAX_WORKFLOW_CALLS, unknownPricing: raw.unknown_paid_model_pricing || {}, failClosedOnUnknownPricing: raw.fail_closed_on_unknown_pricing === true, - ledgerTtlMs: ttlMinutes * 60 * 1000, - maxTrackedTasks: positiveInteger(raw.max_tracked_tasks, DEFAULT_MAX_TRACKED_TASKS) + ledgerTtlMs: positiveInteger(raw.ledger_ttl_minutes, DEFAULT_LEDGER_TTL_MINUTES) * 60 * 1000, + maxTrackedTasks: positiveInteger(raw.max_tracked_tasks, DEFAULT_MAX_TRACKED_TASKS), + persistenceEnabled: raw.persist_state === true, + statePath: process.env.AGENT_LOOP_BUDGET_STATE_PATH || resolve(cwd, configuredPath) + }; +} + +function normalizeLedger(taskId, ledger = {}) { + const normalizeGroups = groups => Object.fromEntries(Object.entries(groups || {}).map(([key, usage]) => [key, normalizeStoredUsage(usage)])); + return { + taskId, + createdAtMs: Number(ledger.createdAtMs) || Date.now(), + updatedAtMs: Number(ledger.updatedAtMs) || Date.now(), + used: normalizeStoredUsage(ledger.used), + steps: normalizeGroups(ledger.steps), + models: normalizeGroups(ledger.models), + workflowCalls: finiteNonNegative(ledger.workflowCalls), + workflowStages: Object.fromEntries(Object.entries(ledger.workflowStages || {}).map(([key, value]) => [key, finiteNonNegative(value)])), + exceeded: ledger.exceeded === true, + exceededReasons: Array.isArray(ledger.exceededReasons) ? ledger.exceededReasons : [], + unknownPricingModels: Array.isArray(ledger.unknownPricingModels) ? ledger.unknownPricingModels : [] }; } +function getStore(settings) { + const key = settings.persistenceEnabled ? settings.statePath : ':memory:'; + if (!stores.has(key)) { + const loaded = settings.persistenceEnabled ? loadBudgetState(settings.statePath) : new Map(); + stores.set(key, new Map([...loaded].map(([taskId, ledger]) => [taskId, normalizeLedger(taskId, ledger)]))); + } + return stores.get(key); +} + +function persist(settings, store) { + if (settings.persistenceEnabled) saveBudgetState(store, settings.statePath); +} + export function pruneBudgetLedgers({ now = Date.now(), maxAgeMs = DEFAULT_LEDGER_TTL_MINUTES * 60 * 1000, maxEntries = DEFAULT_MAX_TRACKED_TASKS, - preserveTaskId = null + preserveTaskId = null, + statePath = null } = {}) { - const effectiveAge = Number.isFinite(maxAgeMs) && maxAgeMs >= 0 ? maxAgeMs : DEFAULT_LEDGER_TTL_MINUTES * 60 * 1000; - const effectiveMax = positiveInteger(maxEntries, DEFAULT_MAX_TRACKED_TASKS); + const selected = statePath ? [[statePath, stores.get(statePath)]] : [...stores.entries()]; let expired = 0; let overflow = 0; - - for (const [taskId, ledger] of ledgers) { - if (taskId === preserveTaskId) continue; - if (now - ledger.updatedAtMs > effectiveAge) { - ledgers.delete(taskId); - expired += 1; + let remaining = 0; + for (const [, store] of selected) { + if (!store) continue; + for (const [taskId, ledger] of store) { + if (taskId !== preserveTaskId && now - ledger.updatedAtMs > maxAgeMs) { + store.delete(taskId); + expired += 1; + } } - } - - if (ledgers.size > effectiveMax) { - const oldest = [...ledgers.entries()] - .filter(([taskId]) => taskId !== preserveTaskId) - .sort((a, b) => a[1].updatedAtMs - b[1].updatedAtMs); - while (ledgers.size > effectiveMax && oldest.length > 0) { - const [taskId] = oldest.shift(); - if (ledgers.delete(taskId)) overflow += 1; + if (store.size > maxEntries) { + const oldest = [...store.entries()].filter(([taskId]) => taskId !== preserveTaskId).sort((a, b) => a[1].updatedAtMs - b[1].updatedAtMs); + while (store.size > maxEntries && oldest.length) { + const [taskId] = oldest.shift(); + if (store.delete(taskId)) overflow += 1; + } } + remaining += store.size; } - - return { expired, overflow, remaining: ledgers.size }; + return { expired, overflow, remaining }; } function ensureLedger(taskId, settings) { + const store = getStore(settings); const now = Date.now(); - pruneBudgetLedgers({ - now, - maxAgeMs: settings.ledgerTtlMs, - maxEntries: settings.maxTrackedTasks, - preserveTaskId: taskId - }); - - if (!ledgers.has(taskId)) { - ledgers.set(taskId, { - taskId, - createdAtMs: now, - updatedAtMs: now, - used: emptyUsage(), - steps: {}, - models: {}, - exceeded: false, - exceededReasons: [], - unknownPricingModels: [] - }); + for (const [id, ledger] of store) { + if (id !== taskId && now - ledger.updatedAtMs > settings.ledgerTtlMs) store.delete(id); } - - const ledger = ledgers.get(taskId); + if (!store.has(taskId)) store.set(taskId, normalizeLedger(taskId)); + const ledger = store.get(taskId); ledger.updatedAtMs = now; - pruneBudgetLedgers({ - now, - maxAgeMs: settings.ledgerTtlMs, - maxEntries: settings.maxTrackedTasks, - preserveTaskId: taskId - }); - return ledger; + if (store.size > settings.maxTrackedTasks) { + const oldest = [...store.entries()].filter(([id]) => id !== taskId).sort((a, b) => a[1].updatedAtMs - b[1].updatedAtMs); + while (store.size > settings.maxTrackedTasks && oldest.length) store.delete(oldest.shift()[0]); + } + persist(settings, store); + return { ledger, store }; } function addUsage(target, usage, costs) { @@ -235,43 +246,46 @@ function remaining(limit, used) { } export class BudgetTracker { - constructor({ taskId, step = 'unknown', config = {}, registry = {} } = {}) { + constructor({ taskId, step = 'unknown', config = {}, registry = {}, cwd = process.cwd() } = {}) { if (!taskId) throw new Error('BudgetTracker requires taskId'); this.taskId = taskId; this.step = step; - this.settings = budgetSettings(config); + this.settings = budgetSettings(config, cwd); this.registry = registry; - this.ledger = ensureLedger(taskId, this.settings); + const ensured = ensureLedger(taskId, this.settings); + this.ledger = ensured.ledger; + this.store = ensured.store; } - static fromFiles({ taskId, step, configPath, registryPath } = {}) { - const config = loadJson(configPath, {}); - const registry = loadJson(registryPath, { models: [] }); - return new BudgetTracker({ taskId, step, config, registry }); + static fromFiles({ taskId, step, configPath, registryPath, cwd = process.cwd() } = {}) { + return new BudgetTracker({ + taskId, + step, + config: loadJson(configPath, {}), + registry: loadJson(registryPath, { models: [] }), + cwd + }); } canContinue() { return !this.settings.enabled || !this.ledger.exceeded; } + recordWorkflowCall(stage = this.step) { + this.ledger.workflowCalls += 1; + this.ledger.workflowStages[stage || 'unknown'] = (this.ledger.workflowStages[stage || 'unknown'] || 0) + 1; + this.ledger.updatedAtMs = Date.now(); + this.evaluate(); + persist(this.settings, this.store); + return this.snapshot(); + } + recordUsage({ modelId, usage, reportedCostUsd = 0, step = this.step } = {}) { const normalized = normalizeTokenUsage(usage); if (normalized.total === 0 && finiteNonNegative(reportedCostUsd) === 0) return this.snapshot(); - - const estimate = estimateUsageCost({ - modelId, - usage: normalized, - registry: this.registry, - unknownPricing: this.settings.unknownPricing - }); + const estimate = estimateUsageCost({ modelId, usage: normalized, registry: this.registry, unknownPricing: this.settings.unknownPricing }); const reported = finiteNonNegative(reportedCostUsd); - const billable = Math.max(estimate.estimatedCostUsd, reported); - const costs = { - estimatedCostUsd: estimate.estimatedCostUsd, - reportedCostUsd: reported, - billableCostUsd: billable - }; - + const costs = { estimatedCostUsd: estimate.estimatedCostUsd, reportedCostUsd: reported, billableCostUsd: Math.max(estimate.estimatedCostUsd, reported) }; const stepKey = step || 'unknown'; const modelKey = modelId || 'unknown'; this.ledger.steps[stepKey] ||= emptyUsage(); @@ -280,12 +294,9 @@ export class BudgetTracker { addUsage(this.ledger.steps[stepKey], normalized, costs); addUsage(this.ledger.models[modelKey], normalized, costs); this.ledger.updatedAtMs = Date.now(); - - if (!estimate.pricingKnown && !this.ledger.unknownPricingModels.includes(modelKey)) { - this.ledger.unknownPricingModels.push(modelKey); - } - + if (!estimate.pricingKnown && !this.ledger.unknownPricingModels.includes(modelKey)) this.ledger.unknownPricingModels.push(modelKey); this.evaluate(); + persist(this.settings, this.store); return this.snapshot(); } @@ -293,22 +304,13 @@ export class BudgetTracker { if (!this.settings.enabled) return; const reasons = []; const used = this.ledger.used; - if (this.settings.maxTokens !== null && used.total > this.settings.maxTokens) { - reasons.push(`total token budget exceeded (${used.total} > ${this.settings.maxTokens})`); - } - if (this.settings.maxInputTokens !== null && used.input > this.settings.maxInputTokens) { - reasons.push(`input token budget exceeded (${used.input} > ${this.settings.maxInputTokens})`); - } - if (this.settings.maxOutputTokens !== null && used.output + used.reasoning > this.settings.maxOutputTokens) { - reasons.push(`output token budget exceeded (${used.output + used.reasoning} > ${this.settings.maxOutputTokens})`); - } - if (this.settings.maxCostUsd !== null && used.billableCostUsd > this.settings.maxCostUsd) { - reasons.push(`cost budget exceeded ($${used.billableCostUsd.toFixed(6)} > $${this.settings.maxCostUsd.toFixed(6)})`); - } - if (this.settings.failClosedOnUnknownPricing && this.ledger.unknownPricingModels.length > 0) { - reasons.push(`pricing unavailable for: ${this.ledger.unknownPricingModels.join(', ')}`); - } - if (reasons.length > 0) { + if (this.settings.maxTokens !== null && used.total > this.settings.maxTokens) reasons.push(`total token budget exceeded (${used.total} > ${this.settings.maxTokens})`); + if (this.settings.maxInputTokens !== null && used.input > this.settings.maxInputTokens) reasons.push(`input token budget exceeded (${used.input} > ${this.settings.maxInputTokens})`); + if (this.settings.maxOutputTokens !== null && used.output + used.reasoning > this.settings.maxOutputTokens) reasons.push(`output token budget exceeded (${used.output + used.reasoning} > ${this.settings.maxOutputTokens})`); + if (this.settings.maxCostUsd !== null && used.billableCostUsd > this.settings.maxCostUsd) reasons.push(`cost budget exceeded ($${used.billableCostUsd.toFixed(6)} > $${this.settings.maxCostUsd.toFixed(6)})`); + if (this.settings.maxWorkflowCalls !== null && this.ledger.workflowCalls > this.settings.maxWorkflowCalls) reasons.push(`workflow call budget exceeded (${this.ledger.workflowCalls} > ${this.settings.maxWorkflowCalls})`); + if (this.settings.failClosedOnUnknownPricing && this.ledger.unknownPricingModels.length) reasons.push(`pricing unavailable for: ${this.ledger.unknownPricingModels.join(', ')}`); + if (reasons.length) { this.ledger.exceeded = true; this.ledger.exceededReasons = [...new Set([...this.ledger.exceededReasons, ...reasons])]; } @@ -320,11 +322,14 @@ export class BudgetTracker { maxTokens: this.settings.maxTokens, maxInputTokens: this.settings.maxInputTokens, maxOutputTokens: this.settings.maxOutputTokens, - maxCostUsd: this.settings.maxCostUsd + maxCostUsd: this.settings.maxCostUsd, + maxWorkflowCalls: this.settings.maxWorkflowCalls }; return { enabled: this.settings.enabled, - scope: 'delegated-workers', + scope: 'workflow-calls-and-delegated-workers', + parentModelUsageIncluded: false, + persistent: this.settings.persistenceEnabled, taskId: this.taskId, createdAt: new Date(this.ledger.createdAtMs).toISOString(), updatedAt: new Date(this.ledger.updatedAtMs).toISOString(), @@ -332,11 +337,13 @@ export class BudgetTracker { exceededReasons: [...this.ledger.exceededReasons], limits, used, + workflow: { calls: this.ledger.workflowCalls, stages: structuredClone(this.ledger.workflowStages) }, remaining: { tokens: remaining(limits.maxTokens, used.total), inputTokens: remaining(limits.maxInputTokens, used.input), outputTokens: remaining(limits.maxOutputTokens, used.output + used.reasoning), - costUsd: remaining(limits.maxCostUsd, used.billableCostUsd) + costUsd: remaining(limits.maxCostUsd, used.billableCostUsd), + workflowCalls: remaining(limits.maxWorkflowCalls, this.ledger.workflowCalls) }, steps: structuredClone(this.ledger.steps), models: structuredClone(this.ledger.models), @@ -346,10 +353,13 @@ export class BudgetTracker { } export function getBudgetLedgerCount() { - return ledgers.size; + return [...stores.values()].reduce((sum, store) => sum + store.size, 0); } export function resetBudgetLedger(taskId) { - if (taskId) ledgers.delete(taskId); - else ledgers.clear(); + for (const [key, store] of stores) { + if (taskId) store.delete(taskId); + else store.clear(); + if (key !== ':memory:') saveBudgetState(store, key); + } } diff --git a/lib/budget-store.mjs b/lib/budget-store.mjs new file mode 100644 index 0000000..9f0c7ad --- /dev/null +++ b/lib/budget-store.mjs @@ -0,0 +1,53 @@ +import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs'; +import { dirname, resolve } from 'node:path'; + +export const BUDGET_STORE_VERSION = 1; + +export function defaultBudgetStatePath(cwd = process.cwd()) { + return resolve(cwd, '.opencode', 'agent-loop-state', 'budgets.json'); +} + +function sanitizeLedger(taskId, ledger) { + if (!taskId || !ledger || typeof ledger !== 'object') return null; + return { + taskId, + createdAtMs: Number(ledger.createdAtMs) || Date.now(), + updatedAtMs: Number(ledger.updatedAtMs) || Date.now(), + used: ledger.used || {}, + steps: ledger.steps || {}, + models: ledger.models || {}, + workflowCalls: Number(ledger.workflowCalls) || 0, + workflowStages: ledger.workflowStages || {}, + exceeded: ledger.exceeded === true, + exceededReasons: Array.isArray(ledger.exceededReasons) ? ledger.exceededReasons : [], + unknownPricingModels: Array.isArray(ledger.unknownPricingModels) ? ledger.unknownPricingModels : [] + }; +} + +export function loadBudgetState(path = defaultBudgetStatePath()) { + if (!existsSync(path)) return new Map(); + try { + const parsed = JSON.parse(readFileSync(path, 'utf8')); + if (parsed.version !== BUDGET_STORE_VERSION || typeof parsed.ledgers !== 'object') return new Map(); + const entries = []; + for (const [taskId, ledger] of Object.entries(parsed.ledgers)) { + const sanitized = sanitizeLedger(taskId, ledger); + if (sanitized) entries.push([taskId, sanitized]); + } + return new Map(entries); + } catch { + return new Map(); + } +} + +export function saveBudgetState(ledgers, path = defaultBudgetStatePath()) { + mkdirSync(dirname(path), { recursive: true }); + const payload = { + version: BUDGET_STORE_VERSION, + savedAt: new Date().toISOString(), + ledgers: Object.fromEntries([...ledgers.entries()].map(([taskId, ledger]) => [taskId, sanitizeLedger(taskId, ledger)])) + }; + const temporary = `${path}.${process.pid}.${Date.now()}.tmp`; + writeFileSync(temporary, `${JSON.stringify(payload, null, 2)}\n`, { encoding: 'utf8', mode: 0o600 }); + renameSync(temporary, path); +} diff --git a/lib/event-log.mjs b/lib/event-log.mjs new file mode 100644 index 0000000..0c59b56 --- /dev/null +++ b/lib/event-log.mjs @@ -0,0 +1,91 @@ +import { appendFileSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs'; +import { dirname, resolve } from 'node:path'; +import { randomUUID } from 'node:crypto'; + +export const EVENT_SCHEMA_VERSION = '1.0.0'; + +const SECRET_KEY = /(token|secret|password|passwd|api[_-]?key|private[_-]?key|authorization|cookie|credential)/i; +const VALUE_PATTERNS = [ + /\bgh[pousr]_[A-Za-z0-9]{20,}\b/g, + /\bgithub_pat_[A-Za-z0-9_]{20,}\b/g, + /\bsk-[A-Za-z0-9_-]{20,}\b/g, + /\bBearer\s+[A-Za-z0-9._~+\/-]+=*\b/gi, + /-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----/g, + /([A-Z0-9_]*(?:TOKEN|SECRET|PASSWORD|PASSWD|API_KEY|PRIVATE_KEY)[A-Z0-9_]*\s*=\s*)[^\s]+/gi +]; + +export function redactSensitive(value, key = '') { + if (SECRET_KEY.test(String(key))) return '[REDACTED]'; + if (typeof value === 'string') { + let output = value; + for (const pattern of VALUE_PATTERNS) output = output.replace(pattern, match => match.includes('=') ? `${match.split('=')[0]}=[REDACTED]` : '[REDACTED]'); + return output.length > 16000 ? `${output.slice(0, 16000)}…[TRUNCATED]` : output; + } + if (Array.isArray(value)) return value.map(item => redactSensitive(item)); + if (value && typeof value === 'object') { + return Object.fromEntries(Object.entries(value).map(([childKey, child]) => [childKey, redactSensitive(child, childKey)])); + } + return value; +} + +export function createAgentLoopEvent({ type, taskId, stage, role, modelId, data = {}, timestamp = new Date().toISOString() } = {}) { + if (!type) throw new Error('Event type is required'); + if (!taskId) throw new Error('Event taskId is required'); + return { + schemaVersion: EVENT_SCHEMA_VERSION, + eventId: randomUUID(), + timestamp, + type, + taskId, + ...(stage ? { stage } : {}), + ...(role ? { role } : {}), + ...(modelId ? { modelId } : {}), + data: redactSensitive(data) + }; +} + +export function defaultEventLogPath(cwd = process.cwd(), configuredPath = '.opencode/agent-loop-state/events.jsonl') { + return process.env.AGENT_LOOP_EVENT_LOG_PATH || resolve(cwd, configuredPath); +} + +export class AgentLoopEventLogger { + constructor({ taskId, cwd = process.cwd(), path = defaultEventLogPath(cwd), enabled = true } = {}) { + if (!taskId) throw new Error('AgentLoopEventLogger requires taskId'); + this.taskId = taskId; + this.path = path; + this.enabled = enabled !== false; + if (this.enabled) mkdirSync(dirname(path), { recursive: true }); + } + + emit(type, details = {}) { + const event = createAgentLoopEvent({ type, taskId: this.taskId, ...details }); + if (this.enabled) appendFileSync(this.path, `${JSON.stringify(event)}\n`, { encoding: 'utf8', mode: 0o600 }); + return event; + } + + query({ type, stage, role, modelId, limit = 200 } = {}) { + if (!this.enabled) return []; + let lines = []; + try { lines = readFileSync(this.path, 'utf8').split(/\r?\n/).filter(Boolean); } catch { return []; } + const events = []; + for (const line of lines) { + try { + const event = JSON.parse(line); + if (event.taskId !== this.taskId) continue; + if (type && event.type !== type) continue; + if (stage && event.stage !== stage) continue; + if (role && event.role !== role) continue; + if (modelId && event.modelId !== modelId) continue; + events.push(event); + } catch {} + } + return events.slice(-Math.max(1, Math.min(limit, 5000))); + } +} + +export function writeJsonAtomic(path, value) { + mkdirSync(dirname(path), { recursive: true }); + const temporary = `${path}.${process.pid}.${Date.now()}.tmp`; + writeFileSync(temporary, `${JSON.stringify(value, null, 2)}\n`, { encoding: 'utf8', mode: 0o600 }); + renameSync(temporary, path); +} diff --git a/lib/failover-handler.mjs b/lib/failover-handler.mjs index 77e61e9..de1583f 100644 --- a/lib/failover-handler.mjs +++ b/lib/failover-handler.mjs @@ -11,6 +11,8 @@ import { readFileSync, writeFileSync, existsSync, unlinkSync } from 'node:fs'; import { resolve } from 'node:path'; +import { tmpdir } from 'node:os'; +import { deriveProviderFromModel } from './provider-adapters.mjs'; import { reapExpiredCooldowns, applyCooldown, @@ -35,6 +37,11 @@ import { * @property {string} exactNextAction */ +function checkpointPath(taskId) { + const safe = String(taskId || 'task').replace(/[^a-zA-Z0-9._-]/g, '_').slice(0, 128); + return resolve(tmpdir(), `task-checkpoint-${safe}.json`); +} + const RETRYABLE_SYSTEM_ERRORS = ['ECONNRESET', 'ETIMEDOUT', 'ECONNREFUSED', 'EPIPE']; const RETRYABLE_MESSAGE_PATTERNS = [ /rate\s*limit/i, @@ -48,7 +55,6 @@ const RETRYABLE_MESSAGE_PATTERNS = [ /timeout/i, /model\s+(not\s+found|unavailable|removed|invalid|end\s+of\s+life|retired|deprecated)/i, /(^|\W)gone(\W|$)/i, - /authentication|unauthorized|forbidden/i, /empty\s+(response|provider response|result|envelope)/i, /no\s+content/i, /interrupted/i, @@ -58,7 +64,6 @@ const RETRYABLE_MESSAGE_PATTERNS = [ const PROVIDER_WIDE_PATTERNS = [ /provider.*(outage|unavailable|downtime)/i, - /authentication.*(nvidia|provider|failed)/i, /all.*(models|providers).*(unavailable|failed|down)/i, /provider.*5\d{2}/i, /provider.*quota.*exhausted/i, @@ -203,6 +208,16 @@ export class FailoverHandler { const retryableErrors = this.config?.retry?.retryable_errors || RETRYABLE_SYSTEM_ERRORS; const nonRetryableErrors = this.config?.retry?.non_retryable_errors || []; + if (/authentication|unauthorized|forbidden|invalid api key|invalid token/i.test(message)) { + return { retryable: false, reason: 'UNAUTHORIZED', category: 'configuration' }; + } + if (/billing|payment required|insufficient (credit|balance)/i.test(message)) { + return { retryable: false, reason: 'BILLING_DISABLED', category: 'configuration' }; + } + if (/safety rejection|content[- ]filter|content policy/i.test(message)) { + return { retryable: false, reason: 'SAFETY_REJECTION', category: 'task' }; + } + if (nonRetryableErrors.includes(code) && !/unauthorized|forbidden|auth/i.test(message)) { return { retryable: false, reason: code, category: 'configuration' }; } @@ -246,7 +261,7 @@ export class FailoverHandler { const tier = model.tier || model.poolTier || 'freeCloud'; // Skip if this model's provider is in cooldown (provider-wide failure) - const provider = modelId.split('/')[0]; + const provider = deriveProviderFromModel(modelId); if (providerCooldowns[provider] && Date.now() < providerCooldowns[provider]) { log({ type: 'skip-provider-cooldown', taskId, role, modelId, provider, cooldownUntil: new Date(providerCooldowns[provider]).toISOString() }); continue; @@ -360,7 +375,7 @@ export class FailoverHandler { // If provider-wide failure, mark provider cooldown if (classification.providerScope === 'provider-wide') { - const provider = modelId.split('/')[0]; + const provider = deriveProviderFromModel(modelId); const cooldownMs = this.config?.cooldowns?.provider_wide_failure_minutes || 10; providerCooldowns[provider] = Date.now() + cooldownMs * 60 * 1000; log({ type: 'provider-wide-failure', taskId, role, modelId, provider, cooldownMinutes: cooldownMs }); @@ -556,7 +571,7 @@ export class FailoverHandler { * @returns {Promise} */ async saveCheckpoint(taskId, data) { - const filePath = `/tmp/task-checkpoint-${taskId}.json`; + const filePath = checkpointPath(taskId); const payload = { savedAt: new Date().toISOString(), data @@ -571,7 +586,7 @@ export class FailoverHandler { * @returns {Promise} The saved data, or null if no checkpoint exists */ async loadCheckpoint(taskId) { - const filePath = `/tmp/task-checkpoint-${taskId}.json`; + const filePath = checkpointPath(taskId); if (!existsSync(filePath)) return null; const raw = readFileSync(filePath, 'utf-8'); try { @@ -589,7 +604,7 @@ export class FailoverHandler { * @returns {Promise} */ async clearCheckpoint(taskId) { - const filePath = `/tmp/task-checkpoint-${taskId}.json`; + const filePath = checkpointPath(taskId); if (existsSync(filePath)) { unlinkSync(filePath); } diff --git a/lib/paid-fallback.mjs b/lib/paid-fallback.mjs index f61bc8e..68a7250 100644 --- a/lib/paid-fallback.mjs +++ b/lib/paid-fallback.mjs @@ -1,202 +1,241 @@ -/** - * paid-fallback.mjs - * - * Controls and audits paid-model fallback from free-tier providers. - * Guards against exceeding call limits and logs all escalations - * without leaking secrets, prompts, or credentials. - * - * @module paid-fallback - */ - -import { readFileSync, appendFileSync, existsSync } from 'node:fs'; -import { resolve } from 'node:path'; - -/** - * @typedef {Object} EscalationLogEntry - * @property {string} timestamp - ISO-8601 - * @property {string} taskId - * @property {string} role - * @property {number} freeModelsAttempted - * @property {string} failuresSummary - * @property {string} paidModelSelected - * @property {string} result - */ - -const ESCALATION_LOG_PATH = '/tmp/paid-escalation-log.jsonl'; +import { + appendFileSync, + existsSync, + mkdirSync, + readFileSync, + renameSync, + writeFileSync +} from 'node:fs'; +import { dirname, resolve } from 'node:path'; + +const PAID_STATE_VERSION = 2; +const DEFAULT_GLOBAL_WINDOW_MINUTES = 24 * 60; +const DEFAULT_TASK_STATE_TTL_MINUTES = 24 * 60; + +function safeInteger(value, fallback = 0) { + return Number.isInteger(value) && value >= 0 ? value : fallback; +} + +function positiveInteger(value, fallback) { + return Number.isInteger(value) && value > 0 ? value : fallback; +} + +function emptyState(now = Date.now()) { + return { + globalCallCount: 0, + globalWindowStartedAtMs: now, + taskCallState: new Map() + }; +} + +function atomicWriteJson(path, value) { + mkdirSync(dirname(path), { recursive: true }); + const temporary = `${path}.${process.pid}.${Date.now()}.tmp`; + writeFileSync(temporary, `${JSON.stringify(value, null, 2)}\n`, { encoding: 'utf8', mode: 0o600 }); + renameSync(temporary, path); +} + +function normalizeTaskState(value, now) { + if (typeof value === 'number') { + return { count: safeInteger(value), updatedAtMs: now }; + } + return { + count: safeInteger(value?.count), + updatedAtMs: safeInteger(value?.updatedAtMs, now) + }; +} + +function readState(path, now = Date.now()) { + if (!existsSync(path)) return emptyState(now); + try { + const parsed = JSON.parse(readFileSync(path, 'utf8')); + if (parsed.version !== 1 && parsed.version !== PAID_STATE_VERSION) return emptyState(now); + const rawTasks = parsed.taskCallState || parsed.taskCallCounts || {}; + return { + globalCallCount: safeInteger(parsed.globalCallCount), + globalWindowStartedAtMs: safeInteger(parsed.globalWindowStartedAtMs, now), + taskCallState: new Map( + Object.entries(rawTasks).map(([taskId, value]) => [taskId, normalizeTaskState(value, now)]) + ) + }; + } catch { + return emptyState(now); + } +} + +function sanitizeFailureSummary(failures = []) { + return failures.map((failure, index) => { + const parts = []; + if (failure.modelId) parts.push(`model:${failure.modelId}`); + if (failure.errorCode) parts.push(`code:${failure.errorCode}`); + if (failure.errorName) parts.push(`error:${failure.errorName}`); + if (failure.statusCode) parts.push(`http:${failure.statusCode}`); + return `[${index + 1}] ${parts.join(' ')}`; + }).join('; ') || 'none'; +} export class PaidFallbackController { - /** - * @param {string} configPath - Path to free-first-config.json - */ - constructor(configPath = './config/free-first-config.json') { + constructor(configPath = './config/free-first-config.json', { + cwd = process.cwd(), + logPath, + statePath, + now = () => Date.now() + } = {}) { this.configPath = resolve(configPath); - /** @type {Object|null} */ + this.cwd = cwd; this.config = null; - /** - * Volatile per-task call counter (reset on restart — acceptable per spec). - * @type {Map} - */ - this._callCounts = new Map(); + this.logPathOverride = logPath; + this.statePathOverride = statePath; + this.logPath = null; + this.statePath = null; this._globalCallCount = 0; + this._globalWindowStartedAtMs = 0; + this._taskCallState = new Map(); + this._globalWindowMs = DEFAULT_GLOBAL_WINDOW_MINUTES * 60 * 1000; + this._taskStateTtlMs = DEFAULT_TASK_STATE_TTL_MINUTES * 60 * 1000; + this._stateLoaded = false; + this._now = now; } - /** - * Load configuration. - * @returns {Promise} - */ async _loadConfig() { if (this.config) return this.config; - const raw = readFileSync(this.configPath, 'utf-8'); - this.config = JSON.parse(raw); + this.config = JSON.parse(readFileSync(this.configPath, 'utf8')); + const general = this.config.general || {}; + this.logPath = process.env.AGENT_LOOP_PAID_FALLBACK_LOG_PATH || this.logPathOverride || resolve( + this.cwd, + general.paid_fallback_log_path || '.opencode/agent-loop-state/paid-escalations.jsonl' + ); + this.statePath = process.env.AGENT_LOOP_PAID_FALLBACK_STATE_PATH || this.statePathOverride || resolve( + this.cwd, + general.paid_fallback_state_path || '.opencode/agent-loop-state/paid-fallback-state.json' + ); + this._globalWindowMs = positiveInteger( + general.paid_fallback_global_window_minutes, + DEFAULT_GLOBAL_WINDOW_MINUTES + ) * 60 * 1000; + this._taskStateTtlMs = positiveInteger( + general.paid_fallback_task_state_ttl_minutes, + DEFAULT_TASK_STATE_TTL_MINUTES + ) * 60 * 1000; + this._loadState(); return this.config; } - /** - * Check whether paid fallback is allowed for a given role. - * - * Verifies: - * 1. `allow_paid_fallback` is true in config - * 2. The role is in `paid_fallback_allowed_roles` - * - * @param {string} role - e.g. "orchestrator", "builder", "reviewer" - * @returns {Promise} - */ - async isPaidFallbackAllowed(role) { - const cfg = await this._loadConfig(); - const general = cfg.general; + _loadState() { + if (this._stateLoaded || !this.statePath) return; + const now = this._now(); + const state = readState(this.statePath, now); + this._globalCallCount = state.globalCallCount; + this._globalWindowStartedAtMs = state.globalWindowStartedAtMs; + this._taskCallState = state.taskCallState; + this._stateLoaded = true; + if (this._refreshWindows(now)) this._saveState(); + } - if (!general.allow_paid_fallback) { - return false; + _refreshWindows(now = this._now()) { + let changed = false; + if (now - this._globalWindowStartedAtMs >= this._globalWindowMs) { + this._globalCallCount = 0; + this._globalWindowStartedAtMs = now; + changed = true; } - - if (!general.paid_fallback_allowed_roles || !Array.isArray(general.paid_fallback_allowed_roles)) { - return false; + for (const [taskId, state] of this._taskCallState) { + if (now - state.updatedAtMs >= this._taskStateTtlMs) { + this._taskCallState.delete(taskId); + changed = true; + } } + return changed; + } - return general.paid_fallback_allowed_roles.includes(role); + _saveState() { + if (!this.statePath) return; + atomicWriteJson(this.statePath, { + version: PAID_STATE_VERSION, + savedAt: new Date(this._now()).toISOString(), + globalCallCount: this._globalCallCount, + globalWindowStartedAtMs: this._globalWindowStartedAtMs, + taskCallState: Object.fromEntries(this._taskCallState) + }); } - /** - * Log a paid escalation event. - * - * The log entry MUST NOT contain prompts, secrets, or credentials. - * Only metadata: timestamp, taskId, role, freeModelsAttempted count, - * summary of failures, paidModelSelected, and result. - * - * @param {Object} opts - * @param {string} opts.taskId - * @param {string} opts.role - * @param {number} opts.freeModelsAttempted - Number of free models tried - * @param {Array} opts.failures - List of failure objects (only summary used) - * @param {string} opts.paidModelSelected - The model_id chosen for paid fallback - * @param {string} opts.result - Outcome description (no secrets) - * @returns {Promise} - */ - async logEscalation({ taskId, role, freeModelsAttempted, failures, paidModelSelected, result }) { - const cfg = await this._loadConfig(); - - // Build a safe summary of failures — no prompts, secrets, or credentials - const failuresSummary = (failures || []) - .map((f, i) => { - // Only include safe metadata - const parts = []; - if (f.modelId) parts.push(`model:${f.modelId}`); - if (f.errorCode) parts.push(`code:${f.errorCode}`); - if (f.errorName) parts.push(`error:${f.errorName}`); - if (f.statusCode) parts.push(`http:${f.statusCode}`); - // Omit 'message', 'prompt', 'response', 'credentials', 'secrets' - return `[${i + 1}] ${parts.join(' ')}`; - }) - .join('; ') || 'none'; + async isPaidFallbackAllowed(role) { + const config = await this._loadConfig(); + const general = config.general || {}; + return general.allow_paid_fallback === true && + Array.isArray(general.paid_fallback_allowed_roles) && + general.paid_fallback_allowed_roles.includes(role); + } + async logEscalation({ taskId, role, freeModelsAttempted, failures, paidModelSelected, result }) { + await this._loadConfig(); const entry = { - timestamp: new Date().toISOString(), + timestamp: new Date(this._now()).toISOString(), taskId, role, freeModelsAttempted, - failuresSummary, + failuresSummary: sanitizeFailureSummary(failures), paidModelSelected, result }; - - // Append to JSONL file - appendFileSync(ESCALATION_LOG_PATH, JSON.stringify(entry) + '\n', 'utf-8'); - - // Increment paid counters only after an authorized paid invocation is selected. + mkdirSync(dirname(this.logPath), { recursive: true }); + appendFileSync(this.logPath, `${JSON.stringify(entry)}\n`, { encoding: 'utf8', mode: 0o600 }); await this.recordPaidCall(taskId); } - /** - * Get the number of paid calls made for a given task. - * - * @param {string} taskId - * @returns {Promise} - */ async getCallCount(taskId) { - return this._callCounts.get(taskId) || 0; + await this._loadConfig(); + if (this._refreshWindows()) this._saveState(); + return this._taskCallState.get(taskId)?.count || 0; } - /** - * Increment the paid call counter for a task. - * - * @param {string} taskId - * @returns {Promise} The new count - */ async incrementCallCount(taskId) { - const current = this._callCounts.get(taskId) || 0; - const next = current + 1; - this._callCounts.set(taskId, next); + await this._loadConfig(); + const now = this._now(); + this._refreshWindows(now); + const next = (this._taskCallState.get(taskId)?.count || 0) + 1; + this._taskCallState.set(taskId, { count: next, updatedAtMs: now }); + this._saveState(); return next; } - /** - * Check if a paid fallback call is allowed for the given task and role, - * enforcing the max-calls-per-task limit. - * - * @param {string} taskId - * @param {string} role - * @returns {Promise<{allowed: boolean, reason?: string}>} - */ async isCallAllowed(taskId, role, options = {}) { - const cfg = await this._loadConfig(); - const general = cfg.general; - - // 1. Must be globally allowed - const roleAllowed = await this.isPaidFallbackAllowed(role); - if (!roleAllowed) { + const config = await this._loadConfig(); + const general = config.general || {}; + if (this._refreshWindows()) this._saveState(); + if (!await this.isPaidFallbackAllowed(role)) { return { allowed: false, reason: `Paid fallback not allowed for role "${role}"` }; } - // 2. Must not exceed max calls per task const maxCalls = general.paid_fallback_max_calls_per_task ?? 1; const currentCount = await this.getCallCount(taskId); if (currentCount >= maxCalls) { - return { - allowed: false, - reason: `Paid fallback call limit (${maxCalls}) reached for task "${taskId}"` - }; + return { allowed: false, reason: `Paid fallback call limit (${maxCalls}) reached for task "${taskId}"` }; } const globalMaxCalls = general.paid_fallback_max_calls_global; if (Number.isInteger(globalMaxCalls) && this._globalCallCount >= globalMaxCalls) { return { allowed: false, - reason: `Global paid fallback call limit (${globalMaxCalls}) reached` + reason: `Global paid fallback call limit (${globalMaxCalls}) reached for the current ${Math.round(this._globalWindowMs / 60000)}-minute window` }; } if (general.paid_fallback_requires_approval === true && options.approved !== true) { - return { - allowed: false, - reason: 'Paid fallback requires explicit approval' - }; + return { allowed: false, reason: 'Paid fallback requires explicit approval' }; } return { allowed: true }; } async recordPaidCall(taskId) { + await this._loadConfig(); + const now = this._now(); + this._refreshWindows(now); this._globalCallCount += 1; - return this.incrementCallCount(taskId); + const next = (this._taskCallState.get(taskId)?.count || 0) + 1; + this._taskCallState.set(taskId, { count: next, updatedAtMs: now }); + this._saveState(); + return next; } } diff --git a/lib/provider-adapters.mjs b/lib/provider-adapters.mjs new file mode 100644 index 0000000..8d03e69 --- /dev/null +++ b/lib/provider-adapters.mjs @@ -0,0 +1,115 @@ +const LOCAL_MODEL_PATTERNS = [/^ollama(?:-|\/)/i, /^local(?:-|\/)/i, /-local$/i]; + +function normalizedRetryPolicy(base = {}, override = {}) { + return { + maxRetries: Number.isInteger(override.max_retries) ? override.max_retries : Number.isInteger(base.max_retries) ? base.max_retries : null, + retryableHttpCodes: override.retryable_http_codes || base.retryable_http_codes || [], + retryableErrors: override.retryable_errors || base.retryable_errors || [], + nonRetryableErrors: override.non_retryable_errors || base.non_retryable_errors || [] + }; +} + +export class ProviderAdapter { + constructor({ id, aliases = [], local = false, timeoutKey = id, retry = {} } = {}) { + if (!id) throw new Error('ProviderAdapter requires id'); + this.id = id; + this.aliases = aliases.map(alias => String(alias).toLowerCase()); + this.local = local; + this.timeoutKey = timeoutKey; + this.retry = retry; + } + + matches(modelId, explicitProvider) { + const provider = String(explicitProvider || '').toLowerCase(); + if (provider === this.id || this.aliases.includes(provider)) return true; + return this.modelProvider(modelId) === this.id; + } + + modelProvider(modelId) { + const value = String(modelId || ''); + if (!value) return 'unknown'; + if (LOCAL_MODEL_PATTERNS.some(pattern => pattern.test(value))) return 'local'; + const slash = value.indexOf('/'); + return slash > 0 ? value.slice(0, slash).toLowerCase() : 'unknown'; + } + + retryPolicy(globalPolicy = {}, providerPolicy = {}) { + return normalizedRetryPolicy(globalPolicy, { ...this.retry, ...providerPolicy }); + } + + shouldRetry(error, globalPolicy = {}, providerPolicy = {}) { + const policy = this.retryPolicy(globalPolicy, providerPolicy); + const code = error?.code || error?.error?.code || ''; + const status = Number(error?.statusCode || error?.status || error?.error?.statusCode || 0); + if (policy.nonRetryableErrors.includes(code)) return false; + if (policy.retryableErrors.includes(code)) return true; + if (policy.retryableHttpCodes.includes(status)) return true; + return null; + } + + normalizeError({ stdout = '', stderr = '', exitCode = 0 } = {}) { + const combined = `${stderr}\n${stdout}`; + const statusMatch = combined.match(/\b(400|401|402|403|404|408|409|410|422|429|500|502|503|504)\b/); + const codeMatch = combined.match(/\b(ECONNRESET|ETIMEDOUT|ECONNREFUSED|EPIPE|UNAUTHORIZED|FORBIDDEN|BILLING_DISABLED|SAFETY_REJECTION)\b/); + const sessionMatch = combined.match(/session(?:ID|Id| id)?[\s:=]+([a-zA-Z0-9_-]+)/); + return { + statusCode: statusMatch ? Number(statusMatch[1]) : undefined, + code: codeMatch ? codeMatch[1] : undefined, + provider: this.id, + sessionId: sessionMatch ? sessionMatch[1] : undefined, + message: combined.slice(0, 2000), + exitCode + }; + } +} + +export class LocalProviderAdapter extends ProviderAdapter { + constructor() { + super({ + id: 'local', + aliases: ['ollama', 'rx580-llama'], + local: true, + timeoutKey: 'local', + retry: { + max_retries: 1, + retryable_errors: ['ECONNRESET', 'ETIMEDOUT', 'ECONNREFUSED', 'EPIPE'] + } + }); + } +} + +const adapters = new Map(); + +export function registerProviderAdapter(adapter) { + if (!(adapter instanceof ProviderAdapter)) throw new TypeError('registerProviderAdapter expects ProviderAdapter'); + adapters.set(adapter.id, adapter); + for (const alias of adapter.aliases) adapters.set(alias, adapter); + return adapter; +} + +export function listProviderAdapters() { + return [...new Set(adapters.values())]; +} + +export function resolveProviderAdapter(modelId, explicitProvider) { + const explicit = String(explicitProvider || '').toLowerCase(); + if (explicit && adapters.has(explicit)) return adapters.get(explicit); + for (const adapter of listProviderAdapters()) { + if (adapter.matches(modelId, explicitProvider)) return adapter; + } + const prefix = String(modelId || '').split('/')[0].toLowerCase(); + return adapters.get(prefix) || new ProviderAdapter({ id: prefix && prefix !== modelId ? prefix : 'unknown' }); +} + +export function deriveProviderFromModel(modelId, explicitProvider) { + return resolveProviderAdapter(modelId, explicitProvider).id; +} + +export function providerTimeoutKey(modelId, explicitProvider) { + return resolveProviderAdapter(modelId, explicitProvider).timeoutKey; +} + +for (const id of ['opencode', 'opencode-go', 'nvidia', 'cerebras', 'groq', 'openrouter', 'openai']) { + registerProviderAdapter(new ProviderAdapter({ id })); +} +registerProviderAdapter(new LocalProviderAdapter()); diff --git a/package-lock.json b/package-lock.json index b2d6b69..eb46b2b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,7 +8,7 @@ "@opencode-ai/plugin": "1.18.4" }, "name": "opencode-agent-loop", - "version": "0.1.1" + "version": "0.2.0" }, "node_modules/@ai-sdk/provider": { "version": "3.0.8", diff --git a/package.json b/package.json index bc264c4..7361216 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "opencode-agent-loop", - "version": "0.1.1", + "version": "0.2.0", "description": "A structured multi-agent feature-development loop for OpenCode with free-first subagent routing and controlled paid fallback.", "type": "module", "private": true, @@ -17,12 +17,14 @@ "node": ">=18" }, "scripts": { - "test": "node tests/budget-tests.mjs && node tests/budget-audit-tests.mjs && node tests/routing-tests.mjs && node tests/runtime-tests.mjs && node tests/tool-integration-tests.mjs && node tests/bypass-detection.mjs", + "test": "node tests/reliability-v020-tests.mjs && node tests/paid-fallback-state-tests.mjs && node tests/budget-tests.mjs && node tests/budget-audit-tests.mjs && node tests/routing-tests.mjs && node tests/runtime-tests.mjs && node tests/tool-integration-tests.mjs && node tests/bypass-detection.mjs", "validate:agents": "bash scripts/validate-agent-configs.sh", "validate:routing": "node scripts/check-routing-defaults.mjs", "validate:budget": "node scripts/check-budget-config.mjs", "validate:feature": "node scripts/check-feature-contract.mjs", - "validate": "npm run validate:agents && npm run validate:routing && npm run validate:budget && npm run validate:feature && node scripts/check-doc-links.mjs && npm test" + "validate": "npm run validate:agents && npm run validate:portable", + "events": "node scripts/query-events.mjs", + "validate:portable": "npm run validate:routing && npm run validate:budget && npm run validate:feature && node scripts/check-doc-links.mjs && npm test" }, "dependencies": { "@opencode-ai/plugin": "1.18.4" diff --git a/runtime/agent-loop-controller.mjs b/runtime/agent-loop-controller.mjs index 1bb6f8f..f90e0a5 100644 --- a/runtime/agent-loop-controller.mjs +++ b/runtime/agent-loop-controller.mjs @@ -1,9 +1,10 @@ import { executeAgentTask, resolvePoolName } from './execute-agent-task.mjs'; import { assertCanStartLoop, getCurrentDepth } from './recursion-guard.mjs'; import { loadPoolConfig, normalizePoolConfig, isModelCoolingDown } from '../lib/pool-normalizer.mjs'; -import { runSmokeTest, filterResponsiveModels } from '../lib/smoke-test.mjs'; +import { runSmokeTest } from '../lib/smoke-test.mjs'; import { reapExpiredCooldowns, filterRetiredModels, loadRegistry } from '../lib/cooldown-manager.mjs'; import { BudgetTracker } from '../lib/budget-manager.mjs'; +import { AgentLoopEventLogger, defaultEventLogPath } from '../lib/event-log.mjs'; import { readFileSync } from 'node:fs'; import { resolve, dirname } from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -15,25 +16,26 @@ const DEFAULT_POOLS_PATH = resolve(PACKAGE_ROOT, 'config/free-first-pools.json') const DEFAULT_REGISTRY_PATH = resolve(PACKAGE_ROOT, 'config/model-registry.json'); function loadConfig(path) { - try { return JSON.parse(readFileSync(path, 'utf-8')); } catch { return {}; } + try { return JSON.parse(readFileSync(path, 'utf-8')); } + catch { return {}; } } function yieldToEventLoop() { - return new Promise(resolve => setImmediate(resolve)); + return new Promise(resolvePromise => setImmediate(resolvePromise)); } -async function updateProgress(pc, title, metadata) { - if (!pc) return; +async function updateProgress(callback, title, metadata) { + if (!callback) return; try { - pc({ title, metadata: { ...metadata, _t: Date.now() } }); + callback({ title, metadata: { ...metadata, _t: Date.now() } }); await yieldToEventLoop(); } catch {} } const MODE_STEPS = { - smoke: { role: 'builder', agent: 'build-worker', label: 'smoke' }, - build: { role: 'builder', agent: 'build-worker', label: 'build' }, - test: { role: 'test', agent: 'test', label: 'test' }, + smoke: { role: 'builder', agent: 'build-worker', label: 'smoke' }, + build: { role: 'builder', agent: 'build-worker', label: 'build' }, + test: { role: 'test', agent: 'test', label: 'test' }, review: { role: 'reviewer', agent: 'review', label: 'review' }, escalate: { role: 'escalation', agent: 'escalation', label: 'escalate' } }; @@ -52,10 +54,30 @@ function classifyStep(label, result) { return 'passed'; } +function budgetFailure({ taskId, budget, smokeResults = null, eventLogPath = '' }) { + return { + status: 'failed', + code: 'BUDGET_EXCEEDED', + taskId, + summary: budget.exceededReasons.join('; ') || 'Task budget exceeded.', + successfulModel: null, + attemptedModels: [], + changedFiles: [], + smokeResults, + budget, + eventLogPath, + tests: { status: 'not-run', commands: [] }, + review: { status: 'not-run' }, + requiresUserInput: false, + logPath: '', + steps: [{ step: 'budget', status: 'failed', code: 'BUDGET_EXCEEDED', budget }] + }; +} + export async function runAgentLoop({ task, mode = 'build', - maxRetries = 0, + maxRetries, cwd = process.cwd(), parentSessionId, metadata = {}, @@ -80,25 +102,43 @@ export async function runAgentLoop({ } const config = loadConfig(DEFAULT_CONFIG_PATH); + const configuredRetryLimit = Number.isInteger(config?.retry?.max_retries) ? config.retry.max_retries : 0; + const effectiveMaxRetries = Number.isInteger(maxRetries) + ? Math.max(0, Math.min(maxRetries, 5)) + : Math.max(0, Math.min(configuredRetryLimit, 5)); const smokeTestEnabled = config?.general?.smoke_test_enabled !== false; + const events = new AgentLoopEventLogger({ + taskId, + cwd, + enabled: config?.events?.enabled !== false, + path: defaultEventLogPath(cwd, config?.events?.path || '.opencode/agent-loop-state/events.jsonl') + }); const budgetTracker = BudgetTracker.fromFiles({ taskId, - step: 'smoke', + step: mode, configPath: DEFAULT_CONFIG_PATH, - registryPath: DEFAULT_REGISTRY_PATH - }); - const onSmokeUsage = ({ modelId, usage, reportedCostUsd }) => budgetTracker.recordUsage({ - modelId, - usage, - reportedCostUsd, - step: 'smoke' + registryPath: DEFAULT_REGISTRY_PATH, + cwd }); + const callBudget = budgetTracker.recordWorkflowCall(mode); + events.emit('workflow.call', { stage: mode, data: { mode, maxRetries: effectiveMaxRetries, budget: callBudget } }); + if (!budgetTracker.canContinue()) { + events.emit('budget.exceeded', { stage: mode, data: { budget: callBudget } }); + return budgetFailure({ taskId, budget: callBudget, eventLogPath: events.path }); + } + + const onSmokeUsage = ({ modelId, usage, reportedCostUsd }) => { + const budget = budgetTracker.recordUsage({ modelId, usage, reportedCostUsd, step: 'smoke' }); + events.emit(budget.exceeded ? 'budget.exceeded' : 'budget.updated', { stage: 'smoke', modelId, data: { usage, budget } }); + return budget; + }; + + events.emit('stage.started', { stage: mode, data: { mode, taskLength: task.length } }); - // --- Smoke test mode: test models and return results --- if (mode === 'smoke') { reapExpiredCooldowns(); - const poolConf = loadPoolConfig(DEFAULT_POOLS_PATH); - const normalized = normalizePoolConfig(poolConf, { role: resolvePoolName('builder') }); + const poolConfig = loadPoolConfig(DEFAULT_POOLS_PATH); + const normalized = normalizePoolConfig(poolConfig, { role: resolvePoolName('builder') }); const registry = loadRegistry(); const freeModels = []; for (const tier of ['freeCloud', 'openRouterFree', 'trialCreditModels']) { @@ -106,9 +146,8 @@ export async function runAgentLoop({ if (!isModelCoolingDown(normalized, modelId)) freeModels.push({ modelId, tier }); } } - // Filter out retired models so smoke tests don't waste call on them - const activeModels = filterRetiredModels(freeModels.map(m => m.modelId), registry); - const filteredModels = freeModels.filter(m => activeModels.includes(m.modelId)); + const activeModels = filterRetiredModels(freeModels.map(item => item.modelId), registry); + const filteredModels = freeModels.filter(item => activeModels.includes(item.modelId)); let smokeResults = null; if (filteredModels.length > 0) { await updateProgress(progressCallback, 'Smoke testing free models...', { models: filteredModels.length, status: 'smoke-test' }); @@ -117,44 +156,48 @@ export async function runAgentLoop({ providerTimeouts: config?.provider_timeouts_ms || {}, smokeTestTimeoutMs: config?.general?.smoke_test_default_timeout_ms || 30000, smokeTestProviderTimeouts: config?.smoke_test_provider_timeout_ms || {}, - cwd, env: { ...env, AGENT_LOOP_DEPTH: String(getCurrentDepth() + 1) }, signal, progressCallback, onUsage: onSmokeUsage + cwd, + env: { ...env, AGENT_LOOP_DEPTH: String(getCurrentDepth() + 1) }, + signal, + progressCallback, + onUsage: onSmokeUsage }); + events.emit('smoke.completed', { stage: 'smoke', data: { responsive: smokeResults.responsive, unresponsive: smokeResults.unresponsive } }); await updateProgress(progressCallback, `Smoke test: ${smokeResults.responsive.length}/${smokeResults.responsive.length + smokeResults.unresponsive.length} responsive`, { smoke: { responsive: smokeResults.responsive, unresponsive: smokeResults.unresponsive }, status: 'smoke-done' }); } if (smokeResults?.budgetExceeded || !budgetTracker.canContinue()) { const budget = budgetTracker.snapshot(); - return { - status: 'failed', code: 'BUDGET_EXCEEDED', taskId, - summary: budget.exceededReasons.join('; ') || 'Task budget exceeded during smoke test.', - successfulModel: null, attemptedModels: [], changedFiles: [], - smokeResults, budget, - tests: { status: 'not-run', commands: [] }, review: { status: 'not-run' }, - requiresUserInput: false, logPath: '', steps: [{ step: 'smoke', status: 'failed', code: 'BUDGET_EXCEEDED', budget }] - }; + events.emit('budget.exceeded', { stage: 'smoke', data: { budget } }); + return budgetFailure({ taskId, budget, smokeResults, eventLogPath: events.path }); } - const hasResponsive = smokeResults && smokeResults.responsive.length > 0; + const hasResponsive = Boolean(smokeResults?.responsive.length); + events.emit(hasResponsive ? 'stage.completed' : 'stage.failed', { stage: 'smoke', data: { responsive: smokeResults?.responsive || [], unresponsive: smokeResults?.unresponsive || [] } }); return { status: hasResponsive ? 'completed' : 'failed', - taskId, summary: hasResponsive ? `Smoke test: ${smokeResults.responsive.length} responsive models` : 'All providers unresponsive', - successfulModel: null, attemptedModels: [], changedFiles: [], + taskId, + summary: hasResponsive ? `Smoke test: ${smokeResults.responsive.length} responsive models` : 'All providers unresponsive', + successfulModel: null, + attemptedModels: [], + changedFiles: [], smokeResults: smokeResults ? { responsive: smokeResults.responsive, unresponsive: smokeResults.unresponsive } : null, - tests: { status: 'not-run', commands: [] }, review: { status: 'not-run' }, - requiresUserInput: !hasResponsive, logPath: '', + tests: { status: 'not-run', commands: [] }, + review: { status: 'not-run' }, + requiresUserInput: !hasResponsive, + logPath: '', + eventLogPath: events.path, budget: budgetTracker.snapshot(), - steps: [{ step: 'smoke', status: hasResponsive ? 'completed' : 'failed', attemptedModels: (smokeResults?.responsive || []).map(r => r.modelId), smokeResults: { responsive: smokeResults?.responsive || [], unresponsive: smokeResults?.unresponsive || [] }, budget: budgetTracker.snapshot() }] + steps: [{ step: 'smoke', status: hasResponsive ? 'completed' : 'failed', attemptedModels: (smokeResults?.responsive || []).map(item => item.modelId), smokeResults: { responsive: smokeResults?.responsive || [], unresponsive: smokeResults?.unresponsive || [] }, budget: budgetTracker.snapshot() }] }; } - // --- Regular mode: run smoke test if no forceModels provided --- let smokeResults = null; if (Array.isArray(forceModels) && forceModels.length > 0) { - // forceModels from the caller are pre-filtered; trust caller - smokeResults = { responsive: forceModels.map(id => ({ modelId: id })), unresponsive: [] }; + smokeResults = { responsive: forceModels.map(modelId => ({ modelId })), unresponsive: [] }; } else if (smokeTestEnabled) { reapExpiredCooldowns(); - const poolConf = loadPoolConfig(DEFAULT_POOLS_PATH); + const poolConfig = loadPoolConfig(DEFAULT_POOLS_PATH); const firstPool = resolvePoolName(MODE_STEPS[mode].role); - const normalized = normalizePoolConfig(poolConf, { role: firstPool }); + const normalized = normalizePoolConfig(poolConfig, { role: firstPool }); const registry = loadRegistry(); const freeModels = []; for (const tier of ['freeCloud', 'openRouterFree', 'trialCreditModels']) { @@ -162,67 +205,93 @@ export async function runAgentLoop({ if (!isModelCoolingDown(normalized, modelId)) freeModels.push({ modelId, tier }); } } - // Filter out retired models - const activeIds = filterRetiredModels(freeModels.map(m => m.modelId), registry); - const filteredModels = freeModels.filter(m => activeIds.includes(m.modelId)); + const activeIds = filterRetiredModels(freeModels.map(item => item.modelId), registry); + const filteredModels = freeModels.filter(item => activeIds.includes(item.modelId)); if (filteredModels.length > 0) { await updateProgress(progressCallback, 'Smoke testing free models...', { models: filteredModels.length, status: 'smoke-test' }); smokeResults = await runSmokeTest({ - models: filteredModels, providerTimeouts: config?.provider_timeouts_ms || {}, + models: filteredModels, + providerTimeouts: config?.provider_timeouts_ms || {}, smokeTestTimeoutMs: config?.general?.smoke_test_default_timeout_ms || 30000, smokeTestProviderTimeouts: config?.smoke_test_provider_timeout_ms || {}, - cwd, env: { ...env, AGENT_LOOP_DEPTH: String(getCurrentDepth() + 1) }, signal, progressCallback, onUsage: onSmokeUsage + cwd, + env: { ...env, AGENT_LOOP_DEPTH: String(getCurrentDepth() + 1) }, + signal, + progressCallback, + onUsage: onSmokeUsage }); + events.emit('smoke.completed', { stage: mode, data: { responsive: smokeResults.responsive, unresponsive: smokeResults.unresponsive } }); await updateProgress(progressCallback, `Smoke test: ${smokeResults.responsive.length}/${smokeResults.responsive.length + smokeResults.unresponsive.length} responsive`, { smoke: { responsive: smokeResults.responsive, unresponsive: smokeResults.unresponsive }, status: 'smoke-done' }); } if (smokeResults?.budgetExceeded || !budgetTracker.canContinue()) { const budget = budgetTracker.snapshot(); - return { status: 'failed', code: 'BUDGET_EXCEEDED', taskId, summary: budget.exceededReasons.join('; ') || 'Task budget exceeded during smoke test.', successfulModel: null, attemptedModels: [], changedFiles: [], smokeResults, budget, tests: { status: 'not-run', commands: [] }, review: { status: 'not-run' }, requiresUserInput: false, logPath: '', steps: [{ step: 'smoke', status: 'failed', code: 'BUDGET_EXCEEDED', budget }] }; + events.emit('budget.exceeded', { stage: mode, data: { budget } }); + return budgetFailure({ taskId, budget, smokeResults, eventLogPath: events.path }); } if (smokeResults && smokeResults.responsive.length === 0 && smokeResults.unresponsive.length > 0) { await updateProgress(progressCallback, 'All providers unresponsive', { status: 'blocked' }); - return { status: 'failed', taskId, summary: 'All free providers are unresponsive.', successfulModel: null, attemptedModels: [], changedFiles: [], smokeResults: { responsive: [], unresponsive: smokeResults.unresponsive }, tests: { status: 'not-run', commands: [] }, review: { status: 'not-run' }, requiresUserInput: false, logPath: '', steps: [] }; + events.emit('stage.blocked', { stage: mode, data: { code: 'ALL_PROVIDERS_UNRESPONSIVE', smokeResults } }); + return { + status: 'failed', taskId, summary: 'All free providers are unresponsive.', + successfulModel: null, attemptedModels: [], changedFiles: [], + smokeResults: { responsive: [], unresponsive: smokeResults.unresponsive }, + tests: { status: 'not-run', commands: [] }, review: { status: 'not-run' }, + requiresUserInput: false, logPath: '', eventLogPath: events.path, + budget: budgetTracker.snapshot(), steps: [] + }; } } - // --- Execute single step --- const step = MODE_STEPS[mode]; const prompt = [ - `User request:\n${task}`, '', `Mode: ${mode}`, `Step: ${step.label}`, `Task ID: ${taskId}`, '', + `User request:\n${task}`, + '', + `Mode: ${mode}`, + `Step: ${step.label}`, + `Task ID: ${taskId}`, + '', 'Inspect the repository state before changing anything. Return a concise structured result with evidence. Do not call the agent_loop tool.' ].join('\n'); - await updateProgress(progressCallback, `${step.label} step...`, { step: step.label, status: 'running' }); const executeOptions = { - taskId: `${taskId}-${step.label}`, role: step.role, agent: step.agent, prompt, - cwd, parentSessionId, metadata, timeoutMs, signal, workerAdapter, + taskId: `${taskId}-${step.label}`, + role: step.role, + agent: step.agent, + prompt, + cwd, + parentSessionId, + metadata, + timeoutMs, + signal, + workerAdapter, env: { ...env, AGENT_LOOP_DEPTH: String(getCurrentDepth() + 1) }, progressCallback, budgetTaskId: taskId, budgetStep: step.label, - registryPath: DEFAULT_REGISTRY_PATH + registryPath: DEFAULT_REGISTRY_PATH, + maxRetries: effectiveMaxRetries, + eventLogger: events }; - if (smokeResults && smokeResults.responsive.length > 0) { - // Filter smoke-responsive models through registry retirement check so - // a model that is retired or disabled does not get passed as a candidate. + if (smokeResults?.responsive.length) { const registry = loadRegistry(); - const responsiveIds = smokeResults.responsive.map(r => r.modelId); + const responsiveIds = smokeResults.responsive.map(item => item.modelId); const activeIds = filterRetiredModels(responsiveIds, registry); executeOptions.forceModels = activeIds.length > 0 ? activeIds : responsiveIds; } const result = await executeAgentTask(executeOptions); const stepStatus = classifyStep(step.label, result); - await updateProgress(progressCallback, `${step.label}: ${stepStatus}`, { step: step.label, status: stepStatus, attemptedModels: result.attemptedModels, successfulModel: result.successfulModel }); + const finalBudget = result.budget || budgetTracker.snapshot(); + events.emit(stepStatus === 'passed' ? 'workflow.call.completed' : 'workflow.call.failed', { stage: step.label, role: step.role, modelId: result.successfulModel, data: { code: result.code || null, budget: finalBudget } }); return { status: stepStatus === 'passed' ? 'completed' : 'failed', code: result.code || null, taskId, summary: result.code === 'BUDGET_EXCEEDED' - ? (result.budget?.exceededReasons?.join('; ') || 'Task budget exceeded.') + ? (finalBudget.exceededReasons?.join('; ') || 'Task budget exceeded.') : stepStatus === 'passed' ? `Agent loop completed (${step.label}).` : `${step.label} step did not pass.`, successfulModel: result.successfulModel || null, attemptedModels: result.attemptedModels || [], @@ -232,14 +301,18 @@ export async function runAgentLoop({ review: { status: step.label === 'review' ? stepStatus : 'not-run' }, requiresUserInput: result.code === 'FREE_MODELS_EXHAUSTED', logPath: result.logPath || '', - budget: result.budget || budgetTracker.snapshot(), + eventLogPath: events.path, + budget: finalBudget, steps: [{ - step: step.label, status: stepStatus, + step: step.label, + status: stepStatus, successfulModel: result.successfulModel, attemptedModels: result.attemptedModels, - code: result.code, logPath: result.logPath, + code: result.code, + logPath: result.logPath, + eventLogPath: events.path, attemptDetails: result.attemptDetails || null, - budget: result.budget || budgetTracker.snapshot(), + budget: finalBudget, smokeResults: smokeResults ? { responsive: smokeResults.responsive, unresponsive: smokeResults.unresponsive } : null, stepStartedAt: new Date().toISOString() }] diff --git a/runtime/execute-agent-task.mjs b/runtime/execute-agent-task.mjs index 1ea7622..992b940 100644 --- a/runtime/execute-agent-task.mjs +++ b/runtime/execute-agent-task.mjs @@ -7,6 +7,8 @@ import { loadPoolConfig, normalizePoolConfig, isModelCoolingDown } from '../lib/ import { runOpenCodeWorker, resolveTimeoutMs } from './opencode-worker-runner.mjs'; import { reapExpiredCooldowns } from '../lib/cooldown-manager.mjs'; import { BudgetTracker } from '../lib/budget-manager.mjs'; +import { AgentLoopEventLogger, redactSensitive } from '../lib/event-log.mjs'; +import { resolveProviderAdapter } from '../lib/provider-adapters.mjs'; const __dirname = dirname(fileURLToPath(import.meta.url)); const PACKAGE_ROOT = resolve(__dirname, '..'); @@ -14,17 +16,15 @@ const DEFAULT_LOG_DIR = resolve(PACKAGE_ROOT, '.opencode', 'agent-loop-logs'); const DEFAULT_REGISTRY_PATH = resolve(PACKAGE_ROOT, 'config/model-registry.json'); function loadFreeFirstConfig(configPath) { - try { - return JSON.parse(readFileSync(configPath, 'utf-8')); - } catch { - return {}; - } + try { return JSON.parse(readFileSync(configPath, 'utf-8')); } + catch { return {}; } } function extractProviderTimeoutConfig(config) { - const providerTimeouts = config?.provider_timeouts_ms || {}; - const latencyTimeoutMapping = config?.latency_timeout_mapping || {}; - return { providerTimeouts, latencyTimeoutMapping }; + return { + providerTimeouts: config?.provider_timeouts_ms || {}, + latencyTimeoutMapping: config?.latency_timeout_mapping || {} + }; } const ROLE_POOL = { @@ -64,14 +64,44 @@ function tieredModels(normalized) { } function redact(text) { - return String(text || '') - .replace(/([A-Z0-9_]*(?:TOKEN|SECRET|PASSWORD|KEY)[A-Z0-9_]*=)[^\s]+/gi, '$1[REDACTED]') - .replace(/(Bearer\s+)[A-Za-z0-9._-]+/g, '$1[REDACTED]') - .slice(0, 4000); + return String(redactSensitive(String(text || ''))).slice(0, 4000); } function writeAttemptLog(logPath, event) { - appendFileSync(logPath, `${JSON.stringify({ timestamp: new Date().toISOString(), ...event })}\n`, 'utf8'); + appendFileSync(logPath, `${JSON.stringify({ timestamp: new Date().toISOString(), ...redactSensitive(event) })}\n`, 'utf8'); +} + +function eventTypeForRouting(type) { + const map = { + 'attempt-start': 'model.attempt.started', + 'attempt-success': 'model.attempt.completed', + 'attempt-retryable-failure': 'model.attempt.failed', + 'attempt-task-failure': 'stage.task-failed', + 'provider-wide-failure': 'provider.cooldown', + 'skip-provider-cooldown': 'provider.skipped', + 'budget-denied': 'budget.exceeded', + 'budget-exceeded': 'budget.exceeded', + 'paid-denied': 'paid-fallback.denied', + 'cooldown-reap': 'provider.cooldown-reaped' + }; + return map[type] || 'routing.event'; +} + +function sleep(ms, signal) { + if (!ms || ms <= 0) return Promise.resolve(); + return new Promise((resolvePromise, reject) => { + const timer = setTimeout(resolvePromise, ms); + if (signal) { + const abort = () => { + clearTimeout(timer); + const error = new Error('Retry wait cancelled'); + error.code = 'USER_CANCELLED'; + reject(error); + }; + if (signal.aborted) abort(); + else signal.addEventListener('abort', abort, { once: true }); + } + }); } export async function executeAgentTask({ @@ -93,20 +123,20 @@ export async function executeAgentTask({ forceModels, progressCallback, budgetTaskId = taskId, - budgetStep = role + budgetStep = role, + maxRetries = 0, + eventLogger }) { if (!role) throw new Error('executeAgentTask requires role'); if (!agent) throw new Error('executeAgentTask requires agent'); if (!prompt) throw new Error('executeAgentTask requires prompt'); + const events = eventLogger || new AgentLoopEventLogger({ taskId: budgetTaskId, cwd }); mkdirSync(logDir, { recursive: true }); const logPath = resolve(logDir, `${taskId}-${role}.jsonl`); writeFileSync(logPath, '', 'utf8'); - // Reap expired cooldowns before selecting models — ensures recently-expired - // models are available again. reapExpiredCooldowns(); - const poolName = resolvePoolName(role); const poolConfig = loadPoolConfig(poolsPath); const normalized = normalizePoolConfig(poolConfig, { role: poolName }); @@ -114,38 +144,41 @@ export async function executeAgentTask({ if (Array.isArray(forceModels) && forceModels.length > 0) { const forceSet = new Set(forceModels); - models = models.filter(m => forceSet.has(m.modelId)); + models = models.filter(model => forceSet.has(model.modelId)); if (models.length === 0) { + events.emit('stage.blocked', { stage: budgetStep, role, data: { code: 'ALL_MODELS_FAILED', reason: 'Smoke-responsive models were filtered out.' } }); return { - status: 'failed', - code: 'ALL_MODELS_FAILED', - taskId, role, agent, poolName, - attemptedModels: [], - successfulModel: null, - attemptDetails: [], - stepStartedAt: new Date().toISOString(), - logPath, + status: 'failed', code: 'ALL_MODELS_FAILED', taskId, role, agent, poolName, + attemptedModels: [], successfulModel: null, attemptDetails: [], + stepStartedAt: new Date().toISOString(), logPath, summary: 'All responsive models from smoke test were filtered out before work began.' }; } } const freeFirstConfig = loadFreeFirstConfig(configPath); + const configuredRetryLimit = Number.isInteger(freeFirstConfig?.retry?.max_retries) + ? freeFirstConfig.retry.max_retries + : 0; + const retryLimit = Number.isInteger(maxRetries) + ? Math.max(0, Math.min(maxRetries, 5)) + : Math.max(0, Math.min(configuredRetryLimit, 5)); const { providerTimeouts, latencyTimeoutMapping } = extractProviderTimeoutConfig(freeFirstConfig); const budgetTracker = BudgetTracker.fromFiles({ taskId: budgetTaskId, step: budgetStep, configPath, - registryPath + registryPath, + cwd }); const failover = new FailoverHandler(configPath, poolsPath); await failover.loadConfig(); await failover.loadPools(); - const paidFallback = new PaidFallbackController(configPath); - + const paidFallback = new PaidFallbackController(configPath, { cwd }); const attemptDetails = []; - let stepStartedAt = new Date().toISOString(); + const stepStartedAt = new Date().toISOString(); + events.emit('stage.started', { stage: budgetStep, role, data: { agent, poolName, retryLimit, candidateModels: models.map(model => model.modelId) } }); const result = await failover.runWithFailover({ taskId, @@ -154,7 +187,15 @@ export async function executeAgentTask({ models, paidFallback, paidApproved: metadata.paidApproved === true || process.env.AGENT_LOOP_PAID_APPROVED === '1', - log: event => writeAttemptLog(logPath, event), + log: event => { + writeAttemptLog(logPath, event); + events.emit(eventTypeForRouting(event.type), { + stage: budgetStep, + role, + modelId: event.modelId, + data: { ...event, type: undefined } + }); + }, canAttempt: async () => { const budget = budgetTracker.snapshot(); return budgetTracker.canContinue() @@ -163,112 +204,135 @@ export async function executeAgentTask({ }, invoke: async ({ modelId, tier, attemptIndex, attempts }) => { const checkpoint = attempts.length > 0 - ? `\n\nPrevious provider attempts failed. Before editing, inspect repository state and git diff. Attempted models: ${attempts.map(a => a.modelId).join(', ')}.` + ? `\n\nPrevious provider attempts failed. Before editing, inspect repository state and git diff. Attempted models: ${attempts.map(item => item.modelId).join(', ')}.` : ''; const workerPrompt = `${prompt}${checkpoint}`; - const attemptLog = resolve(logDir, `${taskId}-${role}-attempt-${attemptIndex + 1}.log`); - const progressLogPath = resolve(logDir, `${taskId}-${role}-attempt-${attemptIndex + 1}-progress.jsonl`); - const attemptStartedAt = new Date().toISOString(); - const effectiveTimeoutMs = resolveTimeoutMs({ model: modelId, timeoutMs, providerTimeouts, latencyTimeoutMapping }); - if (progressCallback) { - progressCallback({ title: `Trying ${modelId.split('/').pop()}...`, metadata: { model: modelId, tier, action: 'starting', attempt: attemptIndex + 1, timeoutMs: effectiveTimeoutMs } }); - await new Promise(r => setImmediate(r)); - } const modelLabel = modelId.split('/').pop(); - const stdoutTailCallback = (tail) => { + let finalInvocation = null; + const providerAdapter = resolveProviderAdapter(modelId); + const providerPolicy = freeFirstConfig?.provider_retry?.[providerAdapter.id] || {}; + const adapterRetryPolicy = providerAdapter.retryPolicy(freeFirstConfig.retry || {}, providerPolicy); + const modelRetryLimit = Number.isInteger(adapterRetryPolicy.maxRetries) + ? Math.min(retryLimit, Math.max(0, adapterRetryPolicy.maxRetries)) + : retryLimit; + + for (let retryIndex = 0; retryIndex <= modelRetryLimit; retryIndex += 1) { + if (!budgetTracker.canContinue()) { + const budget = budgetTracker.snapshot(); + return { success: false, code: 'BUDGET_EXCEEDED', budgetExceeded: true, budget }; + } + + const suffix = retryIndex > 0 ? `-retry-${retryIndex}` : ''; + const attemptLog = resolve(logDir, `${taskId}-${role}-attempt-${attemptIndex + 1}${suffix}.log`); + const progressLogPath = resolve(logDir, `${taskId}-${role}-attempt-${attemptIndex + 1}${suffix}-progress.jsonl`); + const attemptStartedAt = new Date().toISOString(); + const effectiveTimeoutMs = resolveTimeoutMs({ model: modelId, timeoutMs, providerTimeouts, latencyTimeoutMapping }); + events.emit('model.invocation.started', { stage: budgetStep, role, modelId, data: { tier, attemptIndex, retryIndex, timeoutMs: effectiveTimeoutMs } }); + if (progressCallback) { - progressCallback({ title: modelLabel, metadata: { model: modelId, tail, action: 'progress', attempt: attemptIndex + 1 } }); + progressCallback({ title: `Trying ${modelLabel}${retryIndex ? ` retry ${retryIndex}` : ''}...`, metadata: { model: modelId, tier, action: 'starting', attempt: attemptIndex + 1, retry: retryIndex, timeoutMs: effectiveTimeoutMs } }); + await new Promise(resolvePromise => setImmediate(resolvePromise)); } - }; - let usageRecordedIncrementally = false; - const invocation = await workerAdapter({ - cwd, - agent, - model: modelId, - prompt: workerPrompt, - timeoutMs: effectiveTimeoutMs, - env: { - ...env, - AGENT_LOOP_PARENT_SESSION: parentSessionId || '', - AGENT_LOOP_TASK_ID: taskId, - AGENT_LOOP_ROLE: role, - AGENT_LOOP_MODEL: modelId, - AGENT_LOOP_WORKER_EXECUTABLE: process.env.AGENT_LOOP_WORKER_EXECUTABLE || 'opencode' - }, - signal, - title: taskId, - progressLogPath, - providerTimeouts, - latencyTimeoutMapping, - stdoutTailCallback, - onUsage: ({ usage, reportedCostUsd }) => { - usageRecordedIncrementally = true; - const budget = budgetTracker.recordUsage({ modelId, usage, reportedCostUsd, step: budgetStep }); - writeAttemptLog(logPath, { type: 'budget-usage', taskId: budgetTaskId, step: budgetStep, modelId, usage, budget }); - if (progressCallback) { - progressCallback({ title: `Budget: ${budget.used.total} tokens / ${budget.used.billableCostUsd.toFixed(4)}`, metadata: { action: 'budget', budget } }); + + const stdoutTailCallback = tail => { + if (progressCallback) progressCallback({ title: modelLabel, metadata: { model: modelId, tail, action: 'progress', attempt: attemptIndex + 1, retry: retryIndex } }); + }; + let usageRecordedIncrementally = false; + const invocation = await workerAdapter({ + cwd, + agent, + model: modelId, + prompt: workerPrompt, + timeoutMs: effectiveTimeoutMs, + env: { + ...env, + AGENT_LOOP_PARENT_SESSION: parentSessionId || '', + AGENT_LOOP_TASK_ID: budgetTaskId, + AGENT_LOOP_ROLE: role, + AGENT_LOOP_MODEL: modelId, + AGENT_LOOP_WORKER_EXECUTABLE: process.env.AGENT_LOOP_WORKER_EXECUTABLE || 'opencode' + }, + signal, + title: taskId, + progressLogPath, + providerTimeouts, + latencyTimeoutMapping, + stdoutTailCallback, + onUsage: ({ usage, reportedCostUsd }) => { + usageRecordedIncrementally = true; + const budget = budgetTracker.recordUsage({ modelId, usage, reportedCostUsd, step: budgetStep }); + writeAttemptLog(logPath, { type: 'budget-usage', taskId: budgetTaskId, step: budgetStep, modelId, usage, budget }); + events.emit(budget.exceeded ? 'budget.exceeded' : 'budget.updated', { stage: budgetStep, role, modelId, data: { usage, budget } }); + if (progressCallback) progressCallback({ title: `Budget: ${budget.used.total} tokens / $${budget.used.billableCostUsd.toFixed(4)}`, metadata: { action: 'budget', budget } }); + return budget; } - return budget; - } - }); - if (!usageRecordedIncrementally && invocation?.usage) { - invocation.budget = budgetTracker.recordUsage({ - modelId, - usage: invocation.usage, - reportedCostUsd: invocation.reportedCostUsd, - step: budgetStep }); + + if (!usageRecordedIncrementally && invocation?.usage) { + invocation.budget = budgetTracker.recordUsage({ modelId, usage: invocation.usage, reportedCostUsd: invocation.reportedCostUsd, step: budgetStep }); + } + const budget = budgetTracker.snapshot(); + if (budget.exceeded) { + invocation.success = false; + invocation.code = 'BUDGET_EXCEEDED'; + invocation.budgetExceeded = true; + invocation.budget = budget; + } + + writeFileSync(attemptLog, [ + `model=${modelId}`, + `tier=${tier}`, + `retryIndex=${retryIndex}`, + `timeoutMs=${effectiveTimeoutMs}`, + `exitCode=${invocation.exitCode ?? ''}`, + `usage=${JSON.stringify(invocation.usage || {})}`, + `reportedCostUsd=${invocation.reportedCostUsd || 0}`, + `budgetExceeded=${invocation.budgetExceeded === true}`, + '--- stdout ---', redact(invocation.stdout), + '--- stderr ---', redact(invocation.stderr) + ].join('\n'), 'utf8'); + + const detail = { + modelId, tier, attemptIndex: attemptIndex + 1, retryIndex, + startedAt: attemptStartedAt, finishedAt: new Date().toISOString(), + timedOut: Boolean(invocation.timedOut), + lastActivityAt: invocation.lastActivityAt ? new Date(invocation.lastActivityAt).toISOString() : null, + exitCode: invocation.exitCode, success: Boolean(invocation.success), code: invocation.code || null, + usage: invocation.usage || null, reportedCostUsd: invocation.reportedCostUsd || 0, + budgetExceeded: invocation.budgetExceeded === true, + budget: invocation.budget || budgetTracker.snapshot(), attemptLog, progressLogPath + }; + attemptDetails.push(detail); + events.emit(invocation.success ? 'model.invocation.completed' : 'model.invocation.failed', { stage: budgetStep, role, modelId, data: detail }); + finalInvocation = invocation; + + if (invocation.success || invocation.budgetExceeded || invocation.code === 'BUDGET_EXCEEDED') break; + const classification = failover.classifyFailure(invocation); + const adapterDecision = providerAdapter.shouldRetry(invocation, freeFirstConfig.retry || {}, providerPolicy); + const retryable = adapterDecision ?? classification.retryable; + if (!retryable || retryIndex >= modelRetryLimit) break; + const delayMs = failover.getBackoffDelay(retryIndex); + events.emit('model.retry.scheduled', { stage: budgetStep, role, modelId, data: { retryIndex: retryIndex + 1, delayMs, reason: classification.reason } }); + await sleep(delayMs, signal); } - const budget = budgetTracker.snapshot(); - if (budget.exceeded) { - invocation.success = false; - invocation.code = 'BUDGET_EXCEEDED'; - invocation.budgetExceeded = true; - invocation.budget = budget; - } - writeFileSync(attemptLog, [ - `model=${modelId}`, - `tier=${tier}`, - `timeoutMs=${effectiveTimeoutMs}`, - `exitCode=${invocation.exitCode ?? ''}`, - `usage=${JSON.stringify(invocation.usage || {})}`, - `reportedCostUsd=${invocation.reportedCostUsd || 0}`, - `budgetExceeded=${invocation.budgetExceeded === true}`, - '--- stdout ---', - redact(invocation.stdout), - '--- stderr ---', - redact(invocation.stderr) - ].join('\n'), 'utf8'); - writeAttemptLog(logPath, { type: 'attempt-output', modelId, tier, attemptLog, progressLogPath, timedOut: invocation.timedOut }); - const detail = { - modelId, - tier, - attemptIndex: attemptIndex + 1, - startedAt: attemptStartedAt, - finishedAt: new Date().toISOString(), - timedOut: !!invocation.timedOut, - lastActivityAt: invocation.lastActivityAt ? new Date(invocation.lastActivityAt).toISOString() : null, - exitCode: invocation.exitCode, - success: !!invocation.success, - code: invocation.code || null, - usage: invocation.usage || null, - reportedCostUsd: invocation.reportedCostUsd || 0, - budgetExceeded: invocation.budgetExceeded === true, - budget: invocation.budget || budgetTracker.snapshot(), - logPath: attemptLog, - progressLogPath - }; - attemptDetails.push(detail); - const status = invocation.success ? 'ok' : invocation.timedOut ? 'timeout' : 'failed'; - const reason = invocation.success ? '' : invocation.code || 'error'; + + const status = finalInvocation?.success ? 'ok' : finalInvocation?.timedOut ? 'timeout' : 'failed'; if (progressCallback) { - progressCallback({ title: `${modelId.split('/').pop()}: ${status}${reason ? ' (' + reason + ')' : ''}`, metadata: { model: modelId, action: 'result', status, code: invocation.code || null, timedOut: !!invocation.timedOut, elapsed: new Date() - new Date(attemptStartedAt) } }); - await new Promise(r => setImmediate(r)); + progressCallback({ title: `${modelLabel}: ${status}${finalInvocation?.code ? ` (${finalInvocation.code})` : ''}`, metadata: { model: modelId, action: 'result', status, code: finalInvocation?.code || null, timedOut: Boolean(finalInvocation?.timedOut) } }); + await new Promise(resolvePromise => setImmediate(resolvePromise)); } - return invocation; + return finalInvocation; } }); + const finalBudget = budgetTracker.snapshot(); + events.emit(result.status === 'completed' ? 'stage.completed' : 'stage.failed', { + stage: budgetStep, + role, + modelId: result.successfulModel, + data: { status: result.status, code: result.code || null, attemptedModels: result.attemptedModels || [], budget: finalBudget } + }); + return { ...result, taskId, @@ -276,10 +340,11 @@ export async function executeAgentTask({ agent, poolName, logPath, + eventLogPath: events.path, stepStartedAt, attemptDetails, - attemptedModels: result.attemptedModels || result.attempts?.map(a => a.modelId) || [], + attemptedModels: result.attemptedModels || result.attempts?.map(item => item.modelId) || [], successfulModel: result.successfulModel || null, - budget: budgetTracker.snapshot() + budget: finalBudget }; } diff --git a/runtime/opencode-worker-runner.mjs b/runtime/opencode-worker-runner.mjs index b73cbbd..abfbe52 100644 --- a/runtime/opencode-worker-runner.mjs +++ b/runtime/opencode-worker-runner.mjs @@ -1,7 +1,11 @@ import { spawn } from 'node:child_process'; import { appendFileSync } from 'node:fs'; -import { resolve } from 'node:path'; import { normalizeTokenUsage } from '../lib/budget-manager.mjs'; +import { + deriveProviderFromModel as deriveProvider, + providerTimeoutKey, + resolveProviderAdapter +} from '../lib/provider-adapters.mjs'; const activeChildren = new Set(); @@ -15,34 +19,40 @@ export function terminateActiveWorkers(signal = 'SIGTERM') { } } -export function extractProviderError({ stdout = '', stderr = '', exitCode = 0 } = {}) { +export function extractProviderError({ stdout = '', stderr = '', exitCode = 0, model } = {}) { const combined = `${stderr}\n${stdout}`; - const statusMatch = combined.match(/\b(410|429|500|502|503|504|401|403|404)\b/); - const codeMatch = combined.match(/\b(ECONNRESET|ETIMEDOUT|ECONNREFUSED|EPIPE|UNAUTHORIZED|FORBIDDEN)\b/); - const providerMatch = combined.match(/provider[\s:]+([a-z0-9._-]+)/i); - const modelMatch = combined.match(/model[\s:]+([a-z0-9._/-]+(?::free)?)/i); - const sessionMatch = combined.match(/session(?:ID|Id| id)?[\s:=]+([a-zA-Z0-9_-]+)/); + const providerMatch = combined.match(/provider(?:ID|Id| id)?[\s:=]+([a-z0-9._-]+)/i); + const modelMatch = combined.match(/model(?:ID|Id| id)?[\s:=]+([a-z0-9._/-]+(?::free)?)/i); + const adapter = resolveProviderAdapter(model || modelMatch?.[1], providerMatch?.[1]); + const normalized = adapter.normalizeError({ stdout, stderr, exitCode }); return { - statusCode: statusMatch ? Number(statusMatch[1]) : undefined, - code: codeMatch ? codeMatch[1] : undefined, - provider: providerMatch ? providerMatch[1] : undefined, - model: modelMatch ? modelMatch[1] : undefined, - sessionId: sessionMatch ? sessionMatch[1] : undefined, + ...normalized, + provider: providerMatch?.[1] || normalized.provider, + model: modelMatch?.[1] || model, message: combined.slice(0, 2000), exitCode }; } export function deriveProviderFromModel(model) { - if (!model || typeof model !== 'string') return 'unknown'; - const parts = model.split('/'); - return parts.length >= 2 ? parts[0] : 'unknown'; + return deriveProvider(model); +} + +export function parseExecutableArgs(value = process.env.AGENT_LOOP_WORKER_EXECUTABLE_ARGS) { + if (Array.isArray(value)) return value.map(String); + if (!value) return []; + try { + const parsed = JSON.parse(value); + return Array.isArray(parsed) ? parsed.map(String) : []; + } catch { + throw new Error('AGENT_LOOP_WORKER_EXECUTABLE_ARGS must be a JSON array of strings'); + } } export function resolveTimeoutMs({ model, timeoutMs, providerTimeouts = {}, latencyTimeoutMapping = {} }) { if (timeoutMs != null && timeoutMs > 0) return timeoutMs; - const provider = deriveProviderFromModel(model); - const providerTier = providerTimeouts[provider]; + const timeoutKey = providerTimeoutKey(model); + const providerTier = providerTimeouts[timeoutKey]; if (providerTier != null && providerTier > 0) return providerTier; const fallback = providerTimeouts.default; if (fallback != null && fallback > 0) return fallback; @@ -83,12 +93,12 @@ function addUsage(target, usage) { target.total += usage.total; } -function extractSessionId(stdout, stderr) { +function extractSessionId(stdout, stderr, model) { for (const event of parseJsonEvents(stdout)) { const id = event.sessionID || event.sessionId || event.session?.id || event.properties?.sessionID; if (id) return id; } - return extractProviderError({ stdout, stderr }).sessionId; + return extractProviderError({ stdout, stderr, model }).sessionId; } export async function runOpenCodeWorker({ @@ -100,6 +110,7 @@ export async function runOpenCodeWorker({ env = {}, signal, executable = process.env.AGENT_LOOP_WORKER_EXECUTABLE || 'opencode', + executableArgs, sessionId, continueSession = false, title, @@ -115,8 +126,9 @@ export async function runOpenCodeWorker({ if (!prompt) throw new Error('runOpenCodeWorker requires prompt'); const effectiveTimeoutMs = resolveTimeoutMs({ model, timeoutMs, providerTimeouts, latencyTimeoutMapping }); - + const prefixArgs = parseExecutableArgs(executableArgs); const args = [ + ...prefixArgs, 'run', '--agent', agent, '--model', model, @@ -135,13 +147,7 @@ export async function runOpenCodeWorker({ AGENT_LOOP_CHILD: '1' }; - // Ensure executable is absolute - if (executable === 'opencode' && !process.env.AGENT_LOOP_WORKER_EXECUTABLE) { - executable = process.env.AGENT_LOOP_WORKER_EXECUTABLE || '/usr/local/bin/opencode'; - } - if (!cwd || cwd === '') { - cwd = process.env.AGENT_LOOP_PROJECT_DIR || process.cwd(); - } + if (!cwd) cwd = process.env.AGENT_LOOP_PROJECT_DIR || process.cwd(); return await new Promise((resolve) => { const child = spawn(executable, args, { @@ -196,16 +202,12 @@ export async function runOpenCodeWorker({ for (const line of lines) { const trimmed = line.trim(); if (!trimmed.startsWith('{')) continue; - try { - recordUsageEvent(JSON.parse(trimmed)); - } catch { - // Ignore non-JSON output mixed into the stream. - } + try { recordUsageEvent(JSON.parse(trimmed)); } + catch { /* Ignore non-JSON output mixed into the stream. */ } } if (flush && tail.trim().startsWith('{')) { - try { - recordUsageEvent(JSON.parse(tail.trim())); - } catch {} + try { recordUsageEvent(JSON.parse(tail.trim())); } + catch {} } } @@ -250,7 +252,6 @@ export async function runOpenCodeWorker({ stdout += text; consumeJsonOutput(text); lastActivityAt = Date.now(); - // Throttled tail callback for TUI metadata if (stdoutTailCallback) { const now = Date.now(); if (now - lastTailTs >= TAIL_THROTTLE_MS) { @@ -264,16 +265,16 @@ export async function runOpenCodeWorker({ lastActivityAt = Date.now(); }); - // Periodic progress flush const progressInterval = setInterval(flushProgress, 5000); child.on('error', error => { clearTimeout(timeout); clearInterval(progressInterval); activeChildren.delete(child); + if (signal) signal.removeEventListener?.('abort', abort); flushProgress(); - const providerError = extractProviderError({ stdout, stderr: `${stderr}\n${error.message}`, exitCode: -1 }); consumeJsonOutput('', true); + const providerError = extractProviderError({ stdout, stderr: `${stderr}\n${error.message}`, exitCode: -1, model }); resolve({ success: false, stdout, stderr, exitCode: -1, signal: null, error, timedOut: false, lastActivityAt, @@ -290,8 +291,8 @@ export async function runOpenCodeWorker({ if (signal) signal.removeEventListener?.('abort', abort); flushProgress(); consumeJsonOutput('', true); - const providerError = extractProviderError({ stdout, stderr, exitCode }); - const session = extractSessionId(stdout, stderr); + const providerError = extractProviderError({ stdout, stderr, exitCode, model }); + const session = extractSessionId(stdout, stderr, model); resolve({ success: exitCode === 0 && !timedOut && !budgetExceeded, stdout, diff --git a/scripts/check-budget-config.mjs b/scripts/check-budget-config.mjs index 0c84654..29e67c4 100644 --- a/scripts/check-budget-config.mjs +++ b/scripts/check-budget-config.mjs @@ -39,6 +39,10 @@ if (!budgets || typeof budgets !== 'object' || Array.isArray(budgets)) { nonNegativeNumber(budgets.max_input_tokens_per_task, 'budgets.max_input_tokens_per_task', { integer: true, nullable: true }); nonNegativeNumber(budgets.max_output_tokens_per_task, 'budgets.max_output_tokens_per_task', { integer: true, nullable: true }); nonNegativeNumber(budgets.max_cost_usd_per_task, 'budgets.max_cost_usd_per_task', { nullable: true }); + nonNegativeNumber(budgets.max_workflow_calls_per_task, 'budgets.max_workflow_calls_per_task', { integer: true }); + if (budgets.max_workflow_calls_per_task < 1) fail('budgets.max_workflow_calls_per_task must be at least 1'); + if (typeof budgets.persist_state !== 'boolean') fail('budgets.persist_state must be boolean'); + if (typeof budgets.state_path !== 'string' || !budgets.state_path) fail('budgets.state_path must be a non-empty string'); nonNegativeNumber(budgets.ledger_ttl_minutes, 'budgets.ledger_ttl_minutes', { integer: true }); nonNegativeNumber(budgets.max_tracked_tasks, 'budgets.max_tracked_tasks', { integer: true }); if (budgets.ledger_ttl_minutes < 1) fail('budgets.ledger_ttl_minutes must be at least 1'); @@ -50,12 +54,8 @@ if (!budgets || typeof budgets !== 'object' || Array.isArray(budgets)) { const total = budgets.max_tokens_per_task; const input = budgets.max_input_tokens_per_task; const output = budgets.max_output_tokens_per_task; - if (total !== null && input !== null && input > total) { - fail('input token limit cannot exceed total token limit'); - } - if (total !== null && output !== null && output > total) { - fail('output token limit cannot exceed total token limit'); - } + if (total !== null && input !== null && input > total) fail('input token limit cannot exceed total token limit'); + if (total !== null && output !== null && output > total) fail('output token limit cannot exceed total token limit'); if (total !== null && input !== null && output !== null && input + output > total) { fail('input plus output token limits cannot exceed total token limit'); } @@ -76,6 +76,20 @@ if (!budgets || typeof budgets !== 'object' || Array.isArray(budgets)) { } } +const general = config.general || {}; +for (const key of ['paid_fallback_global_window_minutes', 'paid_fallback_task_state_ttl_minutes']) { + nonNegativeNumber(general[key], `general.${key}`, { integer: true }); + if (general[key] < 1) fail(`general.${key} must be at least 1`); +} +for (const key of ['paid_fallback_log_path', 'paid_fallback_state_path']) { + if (typeof general[key] !== 'string' || !general[key]) fail(`general.${key} must be a non-empty string`); +} + +if (config.provider_timeouts_ms?.local !== general.local_model_request_timeout_seconds * 1000) { + fail('provider_timeouts_ms.local must match general.local_model_request_timeout_seconds'); +} +if (!config.events || config.events.schema_version !== '1.0.0') fail('events.schema_version must be 1.0.0'); + if (!config.retry?.non_retryable_errors?.includes('BUDGET_EXCEEDED')) { fail('retry.non_retryable_errors must include BUDGET_EXCEEDED'); } diff --git a/scripts/check-feature-contract.mjs b/scripts/check-feature-contract.mjs index 28dff11..8044557 100644 --- a/scripts/check-feature-contract.mjs +++ b/scripts/check-feature-contract.mjs @@ -14,6 +14,7 @@ function requireMatch(text, pattern, message) { if (!pattern.test(text)) failures.push(message); } +requireMatch(orchestrator, /^steps:\s*(?:[1-9][0-9]?|100)\s*$/m, 'orchestrator steps must be capped at 100'); requireMatch(orchestrator, /^\s*agent_loop:\s*allow\s*$/m, 'orchestrator must allow agent_loop'); requireMatch(orchestrator, /^\s*task:\s*deny\s*$/m, 'orchestrator must deny direct task delegation'); requireMatch(orchestrator, /same stable task ID|same `taskId`/i, 'orchestrator must require one stable task ID'); diff --git a/scripts/check-opencode-patch-contract.mjs b/scripts/check-opencode-patch-contract.mjs new file mode 100644 index 0000000..8d9db39 --- /dev/null +++ b/scripts/check-opencode-patch-contract.mjs @@ -0,0 +1,68 @@ +#!/usr/bin/env node + +import { existsSync, readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; + +const root = resolve(process.argv[2] || 'upstream-opencode'); +const checks = [ + { + path: 'packages/opencode/src/cli/cmd/run.ts', + markers: [ + 'function enhanceErrorForJson', + 'classification: classifyError', + 'result.retryAfterMs', + 'result.providerID', + 'result.modelID', + 'args.format === "json"' + ] + }, + { + path: 'packages/opencode/src/session/message-v2.ts', + markers: [ + 'modelID?: ModelV2.ID', + 'providerID: ctx.providerID', + 'ctx.modelID ? { modelID: ctx.modelID }' + ] + }, + { + path: 'packages/opencode/src/session/processor.ts', + markers: ['modelID: input.model.id'] + }, + { + path: 'packages/opencode/src/session/prompt.ts', + markers: ['modelID: msg.modelID'] + }, + { + path: 'packages/opencode/test/cli/run/error-metadata.test.ts', + markers: [ + 'adds rate-limit metadata without removing the raw error', + 'expect(event?.classification).toBe("rate-limit")', + 'expect(event?.providerID).toBe("test")', + 'expect(event?.modelID).toBe("test-model")' + ] + } +]; + +let failed = false; +for (const check of checks) { + const path = resolve(root, check.path); + if (!existsSync(path)) { + console.error(`FAIL: missing patched file ${check.path}`); + failed = true; + continue; + } + + const content = readFileSync(path, 'utf8'); + let fileFailed = false; + for (const marker of check.markers) { + if (!content.includes(marker)) { + console.error(`FAIL: ${check.path} missing marker ${marker}`); + failed = true; + fileFailed = true; + } + } + if (!fileFailed) console.log(`OK: ${check.path}`); +} + +if (failed) process.exit(1); +console.log('OpenCode patch contract verified.'); diff --git a/scripts/query-events.mjs b/scripts/query-events.mjs new file mode 100644 index 0000000..b4c9cc4 --- /dev/null +++ b/scripts/query-events.mjs @@ -0,0 +1,46 @@ +#!/usr/bin/env node + +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { defaultEventLogPath } from '../lib/event-log.mjs'; + +const args = process.argv.slice(2); +const options = {}; +for (let index = 0; index < args.length; index += 1) { + const arg = args[index]; + if (arg === '--help' || arg === '-h') options.help = true; + else if (arg.startsWith('--')) options[arg.slice(2)] = args[index + 1], index += 1; +} + +if (options.help) { + console.log(`Usage: node scripts/query-events.mjs [options]\n\nOptions:\n --file PATH\n --task TASK_ID\n --type EVENT_TYPE\n --stage STAGE\n --role ROLE\n --model MODEL_ID\n --limit N\n --format json|jsonl|summary`); + process.exit(0); +} + +const path = resolve(options.file || defaultEventLogPath()); +let lines; +try { lines = readFileSync(path, 'utf8').split(/\r?\n/).filter(Boolean); } +catch (error) { + console.error(`Unable to read event log ${path}: ${error.message}`); + process.exit(1); +} + +const events = []; +for (const line of lines) { + try { + const event = JSON.parse(line); + if (options.task && event.taskId !== options.task) continue; + if (options.type && event.type !== options.type) continue; + if (options.stage && event.stage !== options.stage) continue; + if (options.role && event.role !== options.role) continue; + if (options.model && event.modelId !== options.model) continue; + events.push(event); + } catch {} +} + +const limit = Math.max(1, Math.min(Number(options.limit) || 200, 5000)); +const selected = events.slice(-limit); +const format = options.format || 'summary'; +if (format === 'json') console.log(JSON.stringify(selected, null, 2)); +else if (format === 'jsonl') for (const event of selected) console.log(JSON.stringify(event)); +else for (const event of selected) console.log(`${event.timestamp} ${event.taskId} ${event.type}${event.stage ? ` stage=${event.stage}` : ''}${event.modelId ? ` model=${event.modelId}` : ''}`); diff --git a/tests/budget-audit-tests.mjs b/tests/budget-audit-tests.mjs index 552a429..9e021b0 100644 --- a/tests/budget-audit-tests.mjs +++ b/tests/budget-audit-tests.mjs @@ -1,5 +1,5 @@ import { strict as assert } from 'node:assert'; -import { chmodSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { resolve } from 'node:path'; import { @@ -69,10 +69,14 @@ function testLedgerRetentionIsBounded() { } async function testUsageAfterTerminationRemainsAccounted() { + if (process.platform === 'win32') { + console.log('SKIP: Windows terminates the child immediately and cannot emit a post-SIGTERM usage event'); + return; + } + const dir = mkdtempSync(resolve(tmpdir(), 'agent-loop-budget-stream-')); - const executable = resolve(dir, 'fake-worker.cjs'); - writeFileSync(executable, `#!/usr/bin/env node -process.on('SIGTERM', () => {}); + const script = resolve(dir, 'fake-worker.cjs'); + writeFileSync(script, `process.on('SIGTERM', () => {}); const first = { type: 'step_finish', part: { type: 'step-finish', tokens: { input: 10, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, cost: 0 } }; const second = { type: 'step_finish', part: { type: 'step-finish', tokens: { input: 20, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, cost: 0 } }; process.stdout.write(JSON.stringify(first) + '\\n'); @@ -81,7 +85,6 @@ setTimeout(() => { setTimeout(() => process.exit(0), 20); }, 20); `, 'utf8'); - chmodSync(executable, 0o755); let callbackCalls = 0; let callbackTokens = 0; @@ -90,7 +93,8 @@ setTimeout(() => { agent: 'build-worker', model: 'free/model', prompt: 'test', - executable, + executable: process.execPath, + executableArgs: [script], timeoutMs: 2000, onUsage: ({ usage }) => { callbackCalls += 1; diff --git a/tests/bypass-detection.mjs b/tests/bypass-detection.mjs index b33c4a8..9f2de7b 100644 --- a/tests/bypass-detection.mjs +++ b/tests/bypass-detection.mjs @@ -1,33 +1,38 @@ import { strict as assert } from 'node:assert'; import { readdirSync, readFileSync, statSync } from 'node:fs'; -import { resolve, relative } from 'node:path'; +import { resolve, relative, sep } from 'node:path'; +import { fileURLToPath } from 'node:url'; -const root = resolve(new URL('..', import.meta.url).pathname); +const root = fileURLToPath(new URL('..', import.meta.url)); const allowed = new Set([ 'runtime/opencode-worker-runner.mjs', 'tests/bypass-detection.mjs', 'scripts/watch-agent-configs.sh' ]); -const excludedDirs = new Set(['.git', 'node_modules', 'tests', 'docs', 'templates', 'skills', '.opencode/agent-loop-logs']); +const excludedDirs = new Set(['.git', 'node_modules', 'tests', 'docs', 'templates', 'skills', '.opencode/agent-loop-logs', '.opencode/agent-loop-state']); const excludedFiles = [/README\.md$/, /CHANGELOG\.md$/, /package-lock\.json$/]; const patterns = [/opencode\s+run/, /\bspawn\s*\(/, /\bexecFile\s*\(/, /\bexec\s*\(/, /child_process/, /createOpencodeClient/, /createOpencode\s*\(/]; +function portableRelative(path) { + return relative(root, path).split(sep).join('/'); +} + function walk(dir, out = []) { for (const entry of readdirSync(dir)) { const abs = resolve(dir, entry); - const rel = relative(root, abs); + const rel = portableRelative(abs); if (rel.split('/').includes('node_modules')) continue; - if ([...excludedDirs].some(ex => rel === ex || rel.startsWith(`${ex}/`))) continue; + if ([...excludedDirs].some(excluded => rel === excluded || rel.startsWith(`${excluded}/`))) continue; const stat = statSync(abs); if (stat.isDirectory()) walk(abs, out); - else if (/\.(mjs|js|ts|sh|json)$/.test(entry) && !excludedFiles.some(rx => rx.test(rel))) out.push(abs); + else if (/\.(mjs|js|ts|sh|json)$/.test(entry) && !excludedFiles.some(pattern => pattern.test(rel))) out.push(abs); } return out; } const offenders = []; for (const file of walk(root)) { - const rel = relative(root, file); + const rel = portableRelative(file); if (allowed.has(rel)) continue; const text = readFileSync(file, 'utf8'); for (const pattern of patterns) { diff --git a/tests/paid-fallback-state-tests.mjs b/tests/paid-fallback-state-tests.mjs new file mode 100644 index 0000000..74c0878 --- /dev/null +++ b/tests/paid-fallback-state-tests.mjs @@ -0,0 +1,44 @@ +import { strict as assert } from 'node:assert'; +import { existsSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { resolve } from 'node:path'; +import { PaidFallbackController } from '../lib/paid-fallback.mjs'; + +const dir = mkdtempSync(resolve(tmpdir(), 'agent-loop-paid-state-')); +const configPath = resolve(dir, 'config.json'); +writeFileSync(configPath, JSON.stringify({ + general: { + allow_paid_fallback: true, + paid_fallback_allowed_roles: ['reviewer'], + paid_fallback_max_calls_per_task: 2, + paid_fallback_max_calls_global: 2, + paid_fallback_requires_approval: false, + paid_fallback_global_window_minutes: 1, + paid_fallback_task_state_ttl_minutes: 1, + paid_fallback_log_path: '.opencode/agent-loop-state/paid-escalations.jsonl', + paid_fallback_state_path: '.opencode/agent-loop-state/paid-fallback-state.json' + } +}), 'utf8'); + +let now = Date.parse('2026-07-22T00:00:00Z'); +const first = new PaidFallbackController(configPath, { cwd: dir, now: () => now }); +assert.equal((await first.isCallAllowed('task-a', 'reviewer')).allowed, true); +await first.recordPaidCall('task-a'); +await first.recordPaidCall('task-a'); +assert.equal((await first.isCallAllowed('task-a', 'reviewer')).allowed, false); +assert.equal((await first.isCallAllowed('task-b', 'reviewer')).allowed, false, 'global limit should survive within the active window'); + +const statePath = resolve(dir, '.opencode/agent-loop-state/paid-fallback-state.json'); +assert.equal(existsSync(statePath), true); + +const restarted = new PaidFallbackController(configPath, { cwd: dir, now: () => now }); +assert.equal(await restarted.getCallCount('task-a'), 2, 'per-task counter should survive restart'); +assert.equal((await restarted.isCallAllowed('task-b', 'reviewer')).allowed, false, 'global counter should survive restart'); + +now += 61_000; +const nextWindow = new PaidFallbackController(configPath, { cwd: dir, now: () => now }); +assert.equal(await nextWindow.getCallCount('task-a'), 0, 'expired task state should be pruned'); +assert.equal((await nextWindow.isCallAllowed('task-b', 'reviewer')).allowed, true, 'global window should reset'); + +rmSync(dir, { recursive: true, force: true }); +console.log('paid-fallback-state-tests: passed'); diff --git a/tests/reliability-v020-tests.mjs b/tests/reliability-v020-tests.mjs new file mode 100644 index 0000000..560ac09 --- /dev/null +++ b/tests/reliability-v020-tests.mjs @@ -0,0 +1,149 @@ +import { strict as assert } from 'node:assert'; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { resolve } from 'node:path'; +import { deriveProviderFromModel, providerTimeoutKey, ProviderAdapter, registerProviderAdapter, resolveProviderAdapter } from '../lib/provider-adapters.mjs'; +import { AgentLoopEventLogger, redactSensitive } from '../lib/event-log.mjs'; +import { loadBudgetState, saveBudgetState } from '../lib/budget-store.mjs'; +import { resolveTimeoutMs } from '../runtime/opencode-worker-runner.mjs'; +import { executeAgentTask } from '../runtime/execute-agent-task.mjs'; + +function testLocalProviderResolution() { + assert.equal(deriveProviderFromModel('ollama-9b-local'), 'local'); + assert.equal(providerTimeoutKey('ollama-9b-local'), 'local'); + assert.equal(resolveTimeoutMs({ model: 'ollama-9b-local', providerTimeouts: { local: 420000, default: 30000 } }), 420000); +} + +function testCustomProviderAdapter() { + registerProviderAdapter(new ProviderAdapter({ id: 'acme', aliases: ['acme-cloud'], timeoutKey: 'acme-slow' })); + assert.equal(resolveProviderAdapter('acme/model').id, 'acme'); + assert.equal(resolveProviderAdapter('anything/model', 'acme-cloud').timeoutKey, 'acme-slow'); +} + +function testProviderRetryPolicy() { + const local = resolveProviderAdapter('ollama-9b-local'); + const global = { + max_retries: 4, + retryable_http_codes: [429, 500], + retryable_errors: ['ETIMEDOUT'], + non_retryable_errors: ['BUDGET_EXCEEDED'] + }; + const policy = local.retryPolicy(global, {}); + assert.equal(policy.maxRetries, 1); + assert.equal(local.shouldRetry({ code: 'ETIMEDOUT' }, global), true); + assert.equal(local.shouldRetry({ code: 'BUDGET_EXCEEDED' }, global), false); + assert.equal(local.shouldRetry({ statusCode: 418 }, global), null); +} + +function testRecursiveRedaction() { + const simulatedGithubToken = ['github', 'pat', 'abcdefghijklmnopqrstuvwxyz123456'].join('_'); + const redacted = redactSensitive({ + authorization: 'Bearer secret-value', + nested: { apiKey: 'abc123', harmless: 'ok', output: 'TOKEN=do-not-log' }, + values: [simulatedGithubToken] + }); + assert.equal(redacted.authorization, '[REDACTED]'); + assert.equal(redacted.nested.apiKey, '[REDACTED]'); + assert.equal(redacted.nested.harmless, 'ok'); + assert.doesNotMatch(redacted.nested.output, /do-not-log/); + assert.equal(redacted.values[0], '[REDACTED]'); +} + +function testStructuredEvents() { + const dir = mkdtempSync(resolve(tmpdir(), 'agent-loop-events-')); + const path = resolve(dir, 'events.jsonl'); + const logger = new AgentLoopEventLogger({ taskId: 'task-a', path }); + logger.emit('stage.started', { stage: 'build', data: { token: 'secret', ok: true } }); + const events = logger.query({ stage: 'build' }); + assert.equal(events.length, 1); + assert.equal(events[0].schemaVersion, '1.0.0'); + assert.equal(events[0].data.token, '[REDACTED]'); + assert.equal(JSON.parse(readFileSync(path, 'utf8').trim()).type, 'stage.started'); + rmSync(dir, { recursive: true, force: true }); +} + +function testPersistentBudgetStore() { + const dir = mkdtempSync(resolve(tmpdir(), 'agent-loop-budget-store-')); + const path = resolve(dir, 'budgets.json'); + const ledgers = new Map([['task-a', { + taskId: 'task-a', createdAtMs: 1, updatedAtMs: 2, + used: { total: 25 }, steps: {}, models: {}, workflowCalls: 3, + workflowStages: { build: 1, test: 1, review: 1 }, exceeded: false, + exceededReasons: [], unknownPricingModels: [] + }]]); + saveBudgetState(ledgers, path); + const loaded = loadBudgetState(path); + assert.equal(loaded.get('task-a').used.total, 25); + assert.equal(loaded.get('task-a').workflowCalls, 3); + rmSync(dir, { recursive: true, force: true }); +} + +async function testSameModelRetryBeforeFailover() { + const dir = mkdtempSync(resolve(tmpdir(), 'agent-loop-retry-')); + const configPath = resolve(dir, 'config.json'); + const poolsPath = resolve(dir, 'pools.json'); + const registryPath = resolve(dir, 'registry.json'); + writeFileSync(configPath, JSON.stringify({ + general: { allow_paid_fallback: false, paid_fallback_allowed_roles: [], paid_fallback_max_calls_per_task: 0 }, + cooldowns: { default_cooldown_minutes: 1 }, + provider_timeouts_ms: { default: 1000 }, + retry: { + max_retries: 1, base_delay_ms: 0, max_delay_ms: 0, jitter_factor: 0, + retryable_http_codes: [429, 500, 502, 503, 504], + retryable_errors: ['ETIMEDOUT'], + non_retryable_errors: ['BUDGET_EXCEEDED'] + }, + budgets: { enabled: false, persist_state: false } + }), 'utf8'); + writeFileSync(poolsPath, JSON.stringify({ pools: { 'routine-builder': { models: [ + { model_id: 'free/first', role: 'primary', enabled: true }, + { model_id: 'free/second', role: 'free-fallback', enabled: true } + ] } } }), 'utf8'); + writeFileSync(registryPath, JSON.stringify({ models: [ + { model_id: 'free/first', classification: 'recurring free tier' }, + { model_id: 'free/second', classification: 'recurring free tier' } + ] }), 'utf8'); + + const calls = []; + const result = await executeAgentTask({ + taskId: 'retry-step', + budgetTaskId: 'retry-feature', + budgetStep: 'build', + role: 'builder', + agent: 'build-worker', + prompt: 'retry test', + cwd: dir, + configPath, + poolsPath, + registryPath, + logDir: dir, + maxRetries: 1, + workerAdapter: async ({ model }) => { + calls.push(model); + if (calls.length === 1) return { success: false, code: 'ETIMEDOUT', timedOut: true, exitCode: 1, stdout: '', stderr: 'timeout' }; + return { success: true, exitCode: 0, stdout: 'ok', stderr: '' }; + } + }); + + assert.equal(result.status, 'completed'); + assert.deepEqual(calls, ['free/first', 'free/first']); + assert.equal(result.attemptDetails.length, 2); + assert.equal(result.attemptDetails[1].retryIndex, 1); + rmSync(dir, { recursive: true, force: true }); +} + +const tests = [ + testLocalProviderResolution, + testCustomProviderAdapter, + testProviderRetryPolicy, + testRecursiveRedaction, + testStructuredEvents, + testPersistentBudgetStore, + testSameModelRetryBeforeFailover +]; + +for (const test of tests) { + await test(); + console.log(`OK: ${test.name}`); +} +console.log('reliability-v020-tests: passed'); diff --git a/tests/runtime-tests.mjs b/tests/runtime-tests.mjs index a949c3f..cf64987 100644 --- a/tests/runtime-tests.mjs +++ b/tests/runtime-tests.mjs @@ -2,9 +2,12 @@ import { strict as assert } from 'node:assert'; import { mkdtempSync, writeFileSync, readFileSync, rmSync, mkdirSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { resolve, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; import { normalizePoolConfig, PoolConfigError, retiredModelSet } from '../lib/pool-normalizer.mjs'; import { executeAgentTask } from '../runtime/execute-agent-task.mjs'; import { assertCanStartLoop } from '../runtime/recursion-guard.mjs'; +const PACKAGE_ROOT = fileURLToPath(new URL('..', import.meta.url)); + import { loadState, saveState, @@ -715,14 +718,14 @@ async function testTaskFailureDoesNotTriggerCooldown() { async function testPluginRegistrationInConfig() { // Verify that opencode.json contains the agent-loop.js plugin reference - const configText = readFileSync(resolve(new URL('..', import.meta.url).pathname, 'opencode.json'), 'utf-8'); + const configText = readFileSync(resolve(PACKAGE_ROOT, 'opencode.json'), 'utf-8'); assert.ok(configText.includes('agent-loop.js'), 'opencode.json should reference agent-loop.js plugin'); return { name: 'Plugin registration present in opencode.json', passed: true }; } async function testReviewPermissionsValidFormat() { // Verify review.md has edit: deny in any valid format (indented or top-level) - const reviewText = readFileSync(resolve(new URL('..', import.meta.url).pathname, 'agents/review.md'), 'utf-8'); + const reviewText = readFileSync(resolve(PACKAGE_ROOT, 'agents/review.md'), 'utf-8'); const hasIndented = /\s+edit: deny/.test(reviewText); assert.ok(hasIndented, 'review.md should contain edit: deny (indented or top-level)'); return { name: 'Review permissions in valid nested format', passed: true }; diff --git a/tests/tool-integration-tests.mjs b/tests/tool-integration-tests.mjs index 6425da7..4ddd5cd 100644 --- a/tests/tool-integration-tests.mjs +++ b/tests/tool-integration-tests.mjs @@ -1,27 +1,27 @@ import { strict as assert } from 'node:assert'; -import { chmodSync, mkdtempSync, readFileSync, writeFileSync, rmSync } from 'node:fs'; +import { mkdtempSync, readFileSync, writeFileSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { resolve } from 'node:path'; import AgentLoopPlugin from '../.opencode/plugins/agent-loop.js'; const dir = mkdtempSync(resolve(tmpdir(), 'agent-loop-tool-')); -const fakeOpencode = resolve(dir, 'fake-opencode.js'); +const fakeScript = resolve(dir, 'fake-opencode.cjs'); const fakeLog = resolve(dir, 'fake-opencode.jsonl'); -writeFileSync(fakeOpencode, `#!/usr/bin/env node -const fs = require('fs'); +writeFileSync(fakeScript, `const fs = require('fs'); fs.appendFileSync(process.env.AGENT_LOOP_FAKE_LOG, JSON.stringify({ args: process.argv.slice(2), child: process.env.AGENT_LOOP_CHILD, taskId: process.env.AGENT_LOOP_TASK_ID, smokeTest: process.env.AGENT_LOOP_SMOKE_TEST || '' }) + '\\n'); console.log(JSON.stringify({ type: 'step_start', timestamp: Date.now(), sessionID: 'fake-session', part: { id: 'p1', type: 'step-start' } })); console.log(JSON.stringify({ type: 'text', timestamp: Date.now(), sessionID: 'fake-session', part: { id: 'p2', type: 'text', text: 'ok' } })); -console.log(JSON.stringify({ type: 'step_finish', timestamp: Date.now(), sessionID: 'fake-session', part: { id: 'p3', reason: 'stop' } })); +console.log(JSON.stringify({ type: 'step_finish', timestamp: Date.now(), sessionID: 'fake-session', part: { id: 'p3', type: 'step-finish', reason: 'stop', tokens: { input: 1, output: 1 } } })); console.log('RESULT: PASS'); process.exit(0); `, 'utf8'); -chmodSync(fakeOpencode, 0o755); -process.env.AGENT_LOOP_WORKER_EXECUTABLE = fakeOpencode; +process.env.AGENT_LOOP_WORKER_EXECUTABLE = process.execPath; +process.env.AGENT_LOOP_WORKER_EXECUTABLE_ARGS = JSON.stringify([fakeScript]); process.env.AGENT_LOOP_FAKE_LOG = fakeLog; -// Run directly (no session forking) in test environment process.env.AGENT_LOOP_FORK_DISABLED = '1'; +process.env.AGENT_LOOP_BUDGET_STATE_PATH = resolve(dir, 'budgets.json'); +process.env.AGENT_LOOP_EVENT_LOG_PATH = resolve(dir, 'events.jsonl'); const plugin = await AgentLoopPlugin(); assert.ok(plugin.tool.agent_loop, 'agent_loop tool should be registered'); @@ -38,9 +38,8 @@ const result = await plugin.tool.agent_loop.execute({ task: 'harmless smoke task const parsed = JSON.parse(result.output); assert.equal(parsed.status, 'completed'); assert.match(parsed.successfulModel, /.+/); -const calls = readFileSync(fakeLog, 'utf8').trim().split('\n').map(line => JSON.parse(line)); -// Filter out smoke test calls (they run before the main build step) -const buildCalls = calls.filter(c => !c.smokeTest); +const calls = readFileSync(fakeLog, 'utf8').trim().split(/\r?\n/).map(line => JSON.parse(line)); +const buildCalls = calls.filter(call => !call.smokeTest); assert.ok(buildCalls.length >= 1, `Expected at least 1 build call, got ${buildCalls.length}`); const mainCall = buildCalls[buildCalls.length - 1]; assert.equal(mainCall.child, '1'); @@ -48,6 +47,8 @@ assert.ok(mainCall.args.includes('--agent')); assert.ok(mainCall.args.includes('build-worker')); assert.ok(mainCall.args.includes('--model')); assert.equal(mainCall.args[mainCall.args.indexOf('--model') + 1], parsed.successfulModel); +assert.equal(parsed.budget.persistent, true); +assert.match(parsed.eventLogPath, /events\.jsonl$/); process.env.AGENT_LOOP_CHILD = '1'; const blocked = await plugin.tool.agent_loop.execute({ task: 'nested', mode: 'build' }, { @@ -62,5 +63,6 @@ const blocked = await plugin.tool.agent_loop.execute({ task: 'nested', mode: 'bu delete process.env.AGENT_LOOP_CHILD; assert.equal(JSON.parse(blocked.output).code, 'AGENT_LOOP_RECURSION_BLOCKED'); +for (const key of ['AGENT_LOOP_WORKER_EXECUTABLE', 'AGENT_LOOP_WORKER_EXECUTABLE_ARGS', 'AGENT_LOOP_FAKE_LOG', 'AGENT_LOOP_FORK_DISABLED', 'AGENT_LOOP_BUDGET_STATE_PATH', 'AGENT_LOOP_EVENT_LOG_PATH']) delete process.env[key]; rmSync(dir, { recursive: true, force: true }); console.log('tool-integration-tests: passed');