diff --git a/CHANGELOG.md b/CHANGELOG.md index 8c3627c..1d7d79c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,8 +4,12 @@ All notable changes to this project will be documented in this file. ## Unreleased +## 1.9.1 - 2026-07-18 + - Added `runtime.ultraReasoningEffort` to override Ultra's default `max` inference effort with `low`, `medium`, `high`, or `xhigh`. - Aligned Ultra's multi-agent mode prompt and inherited child policy with official Codex Multi-Agent V2 behavior. +- Added a pinned-source Codex-to-OpenCode prompt transformation, build-generated Ultra overlays, source/output drift checks, and compiled package delivery. +- Corrected catalog documentation for live-entry precedence, official GitHub missing-model supplementation, and Ultra eligibility provenance. ## 1.9.0 - 2026-07-10 diff --git a/docs/README.md b/docs/README.md index d90ffe5..aa43573 100644 --- a/docs/README.md +++ b/docs/README.md @@ -42,6 +42,7 @@ This directory contains user and developer documentation for `opencode-codex-aut - `development/TESTING.md` - `development/UPSTREAM_SYNC.md` - `development/ULTRA.md` (WIP, default-off) +- `development/PROMPT_COMPATIBILITY.md` ## Local planning and research diff --git a/docs/configuration.md b/docs/configuration.md index 266df18..56f8ff4 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -142,15 +142,16 @@ Mode-derived runtime defaults when omitted: - `runtime.ultraReasoningEffort: "low" | "medium" | "high" | "xhigh" | "max"` - Selects the inference effort sent while logical Ultra mode is active. - Defaults to `"max"`, matching official Codex. Lower values retain Ultra's proactive multi-agent policy while reducing inference reasoning effort. + - OpenCode may log the logical picker value `ultra` before the plugin's last-mile request transform; the backend receives this configured wire effort, not literal `ultra`. ### Model behavior -- Model availability comes from the selected catalog source for the current account. -- When a live account-scoped `/backend-api/codex/models` fetch succeeds, the plugin uses that source alone after normalizing, deduplicating, and sorting the catalog response for provider shaping. -- When live catalog data is unavailable, the plugin falls back to the shared GitHub `models.json` snapshot, normalized through the same catalog parser. -- The plugin does not field-merge live catalog entries with GitHub fallback entries. -- Do not rely on a static model list: GPT-5.6-era models and variants are sourced from the authenticated account's live catalog when that fetch succeeds, or from the shared GitHub snapshot when live catalog data is unavailable. -- Actual availability still depends on the authenticated account's live catalog and plan entitlements. +- Model availability is assembled from the current account's live catalog and the version-matched official GitHub `models.json` cache. +- A successful live `/backend-api/codex/models` response wins for every matching slug. Official GitHub entries supply only slugs missing from that response. +- The plugin does not combine fields across matching live and GitHub entries. GitHub-supplied models retain `catalog_source: "github_fallback"` so their provenance remains observable. +- When live catalog data is unavailable, the normalized GitHub snapshot supplies the fallback catalog. +- Do not rely on a static model list: GPT-5.6-era models and variants can arrive from either source under those precedence rules. +- Catalog visibility is not an entitlement guarantee. Actual backend access still depends on the authenticated account and may require re-authentication after an account or model rollout. - `global.personality: string` - Personality key applied to all models unless overridden. @@ -160,9 +161,10 @@ Mode-derived runtime defaults when omitted: - User config can still override reasoning effort globally, per model, or per variant. - `ultra` reasoning variant - Work in progress and available only when `runtime.ultra=true` and the active model advertises `ultra` with `multi_agent_version: "v2"`. - - `codex` mode adds the official Codex proactive multi-agent mode guidance to eligible root and inherited Ultra child turns; `native` mode preserves OpenCode-native prompt identity. + - `codex` mode adds the generated OpenCode-compatible form of Codex's proactive multi-agent mode guidance to eligible root and inherited Ultra child turns; `native` mode preserves OpenCode-native prompt identity. - Correlated Ultra selections remain safe on unsupported or stale catalogs: the backend request sends the configured Ultra reasoning effort, defaulting to `max`, without proactive delegation. An uncorrelated literal `ultra` fails closed to wire `max`. - There is no public concurrency setting; OpenCode remains responsible for agent execution and lifecycle. + - The overlay is generated from pinned Codex source with exact, count-checked substitutions backed by pinned OpenCode task-tool evidence. See `docs/development/PROMPT_COMPATIBILITY.md`. - `global.reasoningMode: "standard" | "pro"` (optional) - GPT-5.6 reasoning mode, emitted as `reasoning.mode` independently of `reasoning.effort`. - An explicit request value is preserved. The same per-model and per-variant precedence applies. diff --git a/docs/development/OPENCODE_V1_17_18_SYNC.md b/docs/development/OPENCODE_V1_17_18_SYNC.md index 7b3a1e2..66e6e29 100644 --- a/docs/development/OPENCODE_V1_17_18_SYNC.md +++ b/docs/development/OPENCODE_V1_17_18_SYNC.md @@ -54,10 +54,10 @@ The full tagged comparison is [v1.3.0...v1.17.18](https://github.com/anomalyco/o | Request identity | **Aligned:** final hooks emit `session-id`; legacy `session_id` remains an inbound/redaction compatibility alias. Originator and native UA remain aligned. | | Endpoint routing | **Aligned for HTTP:** both Responses and Chat Completions are rewritten to the Codex Responses backend. | | Model catalog | **Mostly aligned and intentionally richer:** local live account-scoped Codex metadata plus tagged GitHub fallback is stronger than upstream's models.dev-based hook. Confirm hidden/API-support filtering and never clone metadata across slugs. | -| Model visibility | **Aligned by stronger authority:** local behavior follows account-scoped live catalog visibility/support fields and refuses to grant Ultra eligibility from GitHub fallback metadata. It intentionally does not copy OpenCode's version heuristic. | +| Model visibility | **Aligned by stronger authority:** live entries win for matching slugs, while version-matched official GitHub entries supply missing slugs and retain their provenance. Ultra eligibility requires explicit capability fields from either source; the plugin does not copy OpenCode's version heuristic. | | Refresh | **Aligned by stronger isolation:** catalog fetches are single-flight and account refresh/persistence remains lock-guarded by strict identity. Upstream's single-record global promise is not copied across rotating accounts. | | Retry/error | **Layering gap, not necessarily code gap:** local bounded 429 rotation is intentional; verify host 5xx and OpenAI `server_error`/`server_is_overloaded` semantics survive unchanged. | -| GPT-5.6 Ultra | **No OpenCode parity gap:** upstream has no literal Ultra contract. Local logical Ultra normalization is an extension and should remain isolated from native request identity and authorized only by live catalog metadata. | +| GPT-5.6 Ultra | **Intentional extension:** upstream has no literal Ultra contract. Local logical Ultra normalization remains isolated from native request identity and is authorized only by explicit live or version-matched official GitHub catalog metadata. Codex delegation-policy wording is transformed through pinned OpenCode task-tool evidence. | | WebSockets | **Optional gap:** v1.17.18 has experimental Responses WebSockets; local HTTP-only behavior remains valid unless feature parity is explicitly desired. | | Plugin lifecycle | **Aligned:** `dispose` stops the instance's proactive-refresh scheduler and composes any Codex-layer cleanup. | | Upstream watcher | **Aligned:** paths and hashes target v1.17.18, including the moved Codex plugin, models.dev core, and optional WebSocket transport files. Source-filtered checks allow OpenCode to advance independently of Codex path drift. | @@ -68,14 +68,14 @@ The full tagged comparison is [v1.3.0...v1.17.18](https://github.com/anomalyco/o 1. Changed outbound identity from `session_id` to `session-id`; affinity, redirect stripping, snapshots/redaction, and tests accept legacy input where needed while generated hooks emit only the canonical header. 2. Updated the upstream watch and sync guide to v1.17.18 paths/hashes, including `plugin/openai/codex.ts`, `packages/core/src/models-dev.ts`, `ws.ts`, and `ws-pool.ts`. -3. Preserved the account-scoped live Codex catalog as the stronger authority for visibility, defaults, and Ultra eligibility; GitHub fallback metadata remains fail-closed for Ultra. +3. Preserved live Codex entries as the stronger authority for matching slugs while allowing the version-matched official GitHub catalog to supply missing models and explicit Ultra eligibility metadata without cross-slug or cross-source field synthesis. 4. Upgraded `@opencode-ai/plugin` and `@opencode-ai/sdk` to `^1.17.18`, adapted the narrow declaration shim and config type boundary, and passed full type/test/build verification. 5. Composed the new plugin `dispose` hook to stop proactive-refresh timers without allowing disposal of an older instance to clear a newer instance's scheduler. ### Optional follow-up - Prototype experimental Responses WebSockets behind an explicit opt-in. Match upstream pooling/disposal, custom base URL, title HTTP fallback, internal-header stripping, stream retry, and idle handling before enabling it by default. -- Track upstream's models.dev/core catalog only for OpenCode host compatibility; keep the live account-scoped Codex catalog authoritative for Codex defaults and Ultra eligibility. +- Track upstream's models.dev/core catalog only for OpenCode host compatibility; keep live entries authoritative for matching Codex slugs and use only source-faithful, version-matched GitHub entries for missing slugs. - Add a documented ownership matrix: host retries transport/5xx errors; this plugin rotates accounts only for bounded 429/auth cases; neither layer silently multiplies attempts. ### No action diff --git a/docs/development/PROMPT_COMPATIBILITY.md b/docs/development/PROMPT_COMPATIBILITY.md new file mode 100644 index 0000000..95ab254 --- /dev/null +++ b/docs/development/PROMPT_COMPATIBILITY.md @@ -0,0 +1,88 @@ +# Codex-to-OpenCode Prompt Compatibility + +Codex prompt behavior cannot be copied into OpenCode safely by changing tool names in prose. The two harnesses differ in tool schemas, agent lifecycle, concurrency, steering, cancellation, injected context, and instruction precedence. This document defines the maintainable workflow for adapting a pinned Codex prompt surface to OpenCode. + +Ultra uses this workflow for its proactive and explicit-request-only mode overlays. The scope is deliberately narrow: it adapts the Codex delegation-policy wording to OpenCode's `task` tool without copying Codex-specific collaboration tools, limits, or lifecycle instructions. + +## Design goals + +- Preserve portable upstream wording exactly where the harnesses agree. +- Make every harness-specific difference explicit and reviewable. +- Fail closed when the pinned source or an expected replacement changes. +- Separate verified harness facts from policy choices made by this plugin. +- Generate runtime prompt artifacts deterministically instead of editing them by hand. +- Test the final composed OpenCode instructions, not only individual replacement rules. + +## Artifact model + +The implementation follows the same source-to-generated pattern used by Claudex: + +| Artifact | Responsibility | +| --- | --- | +| Pinned Codex source | Verbatim upstream prompt fragment plus source repository, tag or commit, path, and SHA-256. | +| Harness evidence manifest | Pinned Codex and OpenCode source files and the exact symbols that prove each mapped capability. | +| Transformation rules | Ordered declarative operations with exact expected match counts. | +| Generator | Validates the source hash, applies only supported operations, and writes atomically. | +| Generated OpenCode overlay | Runtime artifact; never edited directly. | +| Drift checker | Regenerates in memory, compares output byte-for-byte, and audits pinned source evidence. | + +The current scope is the Ultra multi-agent overlay only. It does not replace OpenCode's full base prompt or create a second general-purpose orchestrator. + +## Difference inventory + +Before adding a transformation, record the contract on both sides. The checked-in evidence and rules live under `prompts/`, the generator is `scripts/generate-prompt-compatibility.mjs`, and the runtime artifact is `lib/codex-native/generated/ultra-instructions.ts`. + +| Surface | Codex evidence | OpenCode evidence | Adaptation decision | +| --- | --- | --- | --- | +| Delegation tools | `spawn_agent`-based collaboration family | `task` with `description`, `prompt`, `subagent_type`, and optional `task_id` | Replace Codex's generic spawn/use wording with OpenCode's `task` tool; leave parameters to the host tool schema. | +| Child identity | Root/child role and inheritance rules | Session parentage, agent mode, and child model inheritance | Translate only states represented by OpenCode hooks. | +| Concurrency | Limit and scheduling semantics | Host task scheduling semantics | Do not copy Codex limits unless OpenCode enforces the same contract. | +| Steering and follow-up | Codex agent messaging lifecycle | OpenCode task/session lifecycle | Remove or replace unsupported actions explicitly. | +| Cancellation and failure | Codex failure propagation | OpenCode tool and session errors | Keep the root turn recoverable when the host contract allows it. | +| Instruction precedence | Codex developer-overlay ordering | OpenCode hook composition ordering | Verify the final composed prompt and idempotent replacement behavior. | +| Auxiliary turns | Codex title, summary, and compaction policy | OpenCode auxiliary request detection | Keep delegation overlays off auxiliary turns. | + +Each row must cite a pinned file and symbol in the evidence manifest. A statement observed only in a session log is a hypothesis until matched to source or a stable public contract. + +## Allowed transformations + +Keep the rule language intentionally small: + +- `replace_literal` with `expectedCount`. + +The generator rejects unknown operations, hash mismatches, missing constants, unexpected match counts, missing evidence symbols, and output drift. It does not use regular-expression substitutions for semantic rewrites or silently accept upstream changes. + +## Verification contract + +A prompt-compatibility change is complete only when all of these pass: + +1. The pinned Codex prompt hash matches. +2. Every Codex and OpenCode evidence file exists at its pinned revision and contains the declared symbols. +3. Generation is deterministic and `--check` reports a byte-for-byte match. +4. Tests cover root, child, auxiliary, native-mode, disabled, degraded, retry, and idempotent composition paths. +5. A captured final OpenCode request contains the generated overlay exactly once and retains unrelated host instructions. +6. A manual Ultra smoke records the selected logical effort, final wire effort, available host tools, actual delegation calls, and child-session lineage separately. +7. `npm run verify` passes. + +The smoke must not treat selecting `ultra` as proof that delegation occurred. OpenCode can log the logical `ultra` selection before the plugin's last-mile transform; the backend receives `runtime.ultraReasoningEffort`, which defaults to `max`. + +## Update workflow + +1. Pin the new Codex and OpenCode revisions. +2. Run the evidence audit before changing rules. +3. Review upstream prompt differences independently from harness API differences. +4. Update the difference inventory and transformation rules together. +5. Regenerate the runtime artifact. +6. Inspect the source-to-generated diff and final composed-prompt fixtures. +7. Run the verification contract and record any intentional compatibility gaps in `docs/development/ULTRA.md`. + +Do not update a source hash merely to make the checker pass. A changed hash is a review trigger: either the existing mapping is still valid and can be repinned with evidence, or the transformation and its tests must change. + +## Commands + +```bash +npm run generate:prompts +npm run check:prompts +``` + +`generate:prompts` validates every pinned source before atomically replacing the generated runtime module. `npm run build` runs generation before TypeScript compilation, producing `dist/lib/codex-native/generated/ultra-instructions.js`; `prepack` runs that build, so the completed overlay and the applicable upstream license and notice files are included in the published package. `check:prompts` performs the same validation in memory and fails if the checked-in module differs. The full `npm run verify` gate checks drift and then exercises the build and distribution smoke path. diff --git a/docs/development/README.md b/docs/development/README.md index 9bbf133..44c51b2 100644 --- a/docs/development/README.md +++ b/docs/development/README.md @@ -18,5 +18,6 @@ Use this section for implementation internals and maintenance workflows. - `docs/development/TESTING.md` - test strategy and verification commands. - `docs/development/UPSTREAM_SYNC.md` - how we track/sync upstream behavior. - `docs/development/ULTRA.md` - default-off GPT-5.6 Ultra WIP contract and rollout guidance. +- `docs/development/PROMPT_COMPATIBILITY.md` - pinned-source build workflow for adapting Codex prompt overlays to the OpenCode harness. - `docs/development/OPENCODE_V1_17_18_SYNC.md` - detailed v1.3.0 to v1.17.18 parity audit and disposition. - `docs/development/upstream-watch.json` - upstream watch state artifact. diff --git a/docs/development/TESTING.md b/docs/development/TESTING.md index 82fcb36..cd55869 100644 --- a/docs/development/TESTING.md +++ b/docs/development/TESTING.md @@ -18,6 +18,8 @@ npm run check:docs npm run verify ``` +`npm run build` first runs `npm run generate:prompts`, then compiles the generated Ultra instruction module into `dist`. Full verification also runs `npm run check:prompts` before tests and `npm run check:prompt-artifact` after the build to prove the checked-in source and compiled exports match the pinned transformation. + `npm run verify` is the required local gate before commits, pushes, and PR updates. `npm run verify:local` runs that gate with caching, and the installed git hooks enforce it automatically before `git commit` and `git push`. The commit hook accepts staged-only commit-ready changes; the push hook requires a clean tree and derives the touched-file set from the outgoing commits so the local regression-only ratchet matches the actual push surface instead of only `HEAD^`. GitHub Actions still adds extra platform and security jobs beyond the repo-local verify run. PR GitHub CI is intentionally slimmer than local `verify`: it keeps the clean-room Ubuntu verify job, Linux tarball smoke, Windows smoke, and secret scanning. The separate `npm audit` dependency audit remains GitHub-hosted, but it now runs on default-branch pushes instead of every PR. diff --git a/docs/development/ULTRA.md b/docs/development/ULTRA.md index b575ddd..4432228 100644 --- a/docs/development/ULTRA.md +++ b/docs/development/ULTRA.md @@ -13,7 +13,7 @@ Ultra is a work-in-progress feature behind `runtime.ultra`, which defaults to `f | Missing or stale metadata | Ultra is disabled when catalog metadata cannot prove eligibility. A correlated Ultra selection retains its configured wire effort without proactive instructions; an uncorrelated literal `ultra` fails closed to wire `max`. | | Failure | Missing task tools, spawn failure, cancellation, or partial completion do not fail the root turn. The agent continues locally and must not claim delegation that did not happen. | -The live account-scoped catalog is authoritative. GitHub fallback data is parsed through the same schema and remains usable for ordinary model defaults when the live source is unavailable, but it cannot prove Ultra eligibility. The plugin does not recreate account entitlement or minimum-client enforcement from catalog metadata. +Live account-scoped entries take precedence for matching model slugs. The version-matched official GitHub catalog can supply models missing from a successful live response, and those entries pass through the same schema and Ultra eligibility checks. The plugin never combines fields across matching slugs or treats catalog metadata as proof of account entitlement or minimum-client enforcement. ## State lifecycle @@ -25,7 +25,9 @@ The live account-scoped catalog is authoritative. GitHub fallback data is parsed ## Degradation and guardrails -Ultra supplies the Codex proactive mode hint available at the OpenCode plugin boundary. OpenCode remains the execution host for task tools, concurrency, steering, cancellation, and child lifecycle, so the plugin does not reproduce Codex's tool-specific V2 usage prompt or claim Codex's concurrency limits. A missing task tool or failed child spawn is observable in the host's normal tool/error path, but it is not a reason to reject the request. Unknown agents and failed lineage lookups are classified conservatively, but an eligible inherited Ultra effort remains proactive as it does in Codex. +Ultra derives its proactive mode overlay from pinned Codex source and transforms the Codex delegation wording to OpenCode's `task` tool. OpenCode remains the execution host for task tools, concurrency, steering, cancellation, and child lifecycle, so the overlay changes delegation policy but cannot guarantee that a child will be spawned. A missing task tool or failed child spawn is observable in the host's normal tool/error path, but it is not a reason to reject the request. Unknown agents and failed lineage lookups are classified conservatively, but an eligible inherited Ultra effort remains proactive as it does in Codex. + +Codex and OpenCode expose different harness contracts. Prompt compatibility is maintained as a pinned-source transformation rather than by appending guessed tool instructions to copied Codex text. The source, rules, generated artifact, and drift-check workflow are specified in `docs/development/PROMPT_COMPATIBILITY.md`. No public concurrency setting is exposed. `runtime.ultra` is the feature gate, `runtime.ultraReasoningEffort` only controls inference effort, and OpenCode remains authoritative for task tools and child lifecycle. The retired collaboration-profile/orchestrator WIP is not part of this flow. @@ -35,9 +37,10 @@ The minimum release evidence covers: - parser retention for effort descriptions, `multi_agent_version`, `minimal_client_version`, visibility, and API support; - default-off config, schema, environment override, provider picker hiding, and explicit opt-in behavior; -- eligible Sol/Terra variants, ineligible V1/hidden/non-API variants, fallback catalogs, custom aliases, and effort suffixes; +- eligible Sol/Terra variants from live and official GitHub catalog entries, ineligible V1/hidden/non-API variants, custom aliases, and effort suffixes; - session-lineage classification for root, child, custom `mode: all`, built-in agents, and fail-closed lookup errors; - root and child proactive plus auxiliary-disabled instruction composition, including idempotent merges and preserved host instructions; +- pinned Codex/OpenCode source hashes, evidence symbols, exact transformation match counts, and generated-artifact drift; - literal Ultra normalization to the configured wire effort, explicit non-Ultra efforts remaining non-Ultra, and normalization on retries/catalog-scope changes; - redacted snapshots for logical and wire state without internal headers reaching the backend; - compaction and auxiliary request paths remaining safe because their payloads pass through the same last-mile transform; @@ -45,6 +48,6 @@ The minimum release evidence covers: ## Rollout and rollback -Ultra follows the existing catalog-driven release path but remains marked WIP and default-off. It is visible only when the flag is enabled and authoritative metadata proves eligibility; there is no launch-time allowlist for Sol or Terra. Before publication or enabling it by default, run the full verification gate and a manual smoke using an eligible catalog response. +Ultra follows the existing catalog-driven release path but remains marked WIP and default-off. It is visible only when the flag is enabled and a live or version-matched official GitHub catalog entry proves eligibility; there is no launch-time allowlist for Sol or Terra. Catalog visibility does not guarantee account access, so a newly available model may require refreshed authentication before the backend accepts it. Before publication or enabling Ultra by default, run the full verification gate and a manual smoke using an eligible catalog response and authenticated request. Rollback is the smallest code/config rollback that removes the Ultra instruction and variant eligibility predicate while leaving account storage and catalog caches intact. Existing literal `reasoningEffort: "ultra"` values remain safe because an uncorrelated request transform falls back to wire `max`. Upstream changes are tracked through `docs/development/UPSTREAM_SYNC.md` and the repository's upstream-watch configuration; a changed Ultra contract requires a new compatibility decision before behavior is broadened. diff --git a/docs/index.md b/docs/index.md index 55813c1..bba8b6f 100644 --- a/docs/index.md +++ b/docs/index.md @@ -33,6 +33,7 @@ Use this page as the fast entrypoint for humans and agents. - `docs/development/TESTING.md` - `docs/development/UPSTREAM_SYNC.md` - `docs/development/ULTRA.md` (WIP, default-off) +- `docs/development/PROMPT_COMPATIBILITY.md` - `docs/DOCUMENTATION.md` ## Examples diff --git a/docs/releasing.md b/docs/releasing.md index 3dd0b6b..fabc893 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -53,6 +53,7 @@ npm run verify `verify` runs: - `npm run check:esm-imports` +- `npm run check:prompts` - `npm run lint` - `npm run format:check` - `npm run typecheck` @@ -62,9 +63,12 @@ npm run verify - `npm run check:coverage-ratchet` - `npm run check:docs` - `npm run build` +- `npm run check:prompt-artifact` - `npm run check:dist-esm-imports` - `npm run smoke:cli:dist` +The build regenerates the OpenCode-compatible Ultra overlays from pinned Codex/OpenCode evidence before TypeScript compilation. `check:prompt-artifact` then imports the compiled module and compares its exports exactly, ensuring the completed overlay is present in the distribution consumed by `prepack` and release publishing. + `npm run check:coverage-ratchet` is regression-only: it protects touched existing covered source files from coverage drops, while `npm run test:coverage` still records the full repo snapshot for visibility. Recommended additional checks: @@ -146,4 +150,5 @@ If release publish fails with `ENEEDAUTH`: - Do not run release commands unless publishing is intended. - Do not manually edit generated `dist/` files. +- Do not manually edit `lib/codex-native/generated/ultra-instructions.ts`; update the pinned prompt sources and rules, then run `npm run generate:prompts`. - Prefer script-driven release flow over manual git tagging. diff --git a/lib/codex-native/generated/ultra-instructions.ts b/lib/codex-native/generated/ultra-instructions.ts new file mode 100644 index 0000000..f83d935 --- /dev/null +++ b/lib/codex-native/generated/ultra-instructions.ts @@ -0,0 +1,9 @@ +// Generated by scripts/generate-prompt-compatibility.mjs. Do not edit directly. +// Derived from OpenAI Codex and modified for OpenCode compatibility under Apache-2.0. +// See prompts/upstream/licenses/openai-codex-LICENSE and openai-codex-NOTICE. + +export const ULTRA_PROACTIVE_INSTRUCTIONS = + "\nProactive multi-agent delegation is active. Any earlier instruction requiring an explicit user request before using the OpenCode `task` tool no longer applies. Use the OpenCode `task` tool when parallel work would materially improve speed or quality. This mode remains active until a later multi-agent mode developer message changes it.\n" + +export const ULTRA_EXPLICIT_ONLY_INSTRUCTIONS = + "\nAny earlier instruction enabling proactive multi-agent delegation no longer applies. Do not use the OpenCode `task` tool unless the user or applicable AGENTS.md/skill instructions explicitly ask for sub-agents, delegation, or parallel agent work.\n" diff --git a/lib/codex-native/ultra.ts b/lib/codex-native/ultra.ts index a183293..987eb3a 100644 --- a/lib/codex-native/ultra.ts +++ b/lib/codex-native/ultra.ts @@ -1,6 +1,8 @@ import type { CodexModelInfo } from "../model-catalog.js" import type { UltraReasoningEffort } from "../config.js" import type { AgentExecution, AgentExecutionReason, AgentExecutionRole } from "./agent-execution.js" +export { ULTRA_EXPLICIT_ONLY_INSTRUCTIONS, ULTRA_PROACTIVE_INSTRUCTIONS } from "./generated/ultra-instructions.js" +import { ULTRA_EXPLICIT_ONLY_INSTRUCTIONS, ULTRA_PROACTIVE_INSTRUCTIONS } from "./generated/ultra-instructions.js" const ULTRA_REASONING_EFFORT = "ultra" const DEFAULT_ULTRA_WIRE_REASONING_EFFORT: UltraReasoningEffort = "max" @@ -39,14 +41,6 @@ export type UltraResolution = { multiAgentVersion?: string } -export const ULTRA_PROACTIVE_INSTRUCTIONS = ` -Proactive multi-agent delegation is active. Any earlier instruction requiring an explicit user request before spawning sub-agents no longer applies. Use sub-agents when parallel work would materially improve speed or quality. This mode remains active until a later multi-agent mode developer message changes it. -` - -export const ULTRA_EXPLICIT_ONLY_INSTRUCTIONS = ` -Any earlier instruction enabling proactive multi-agent delegation no longer applies. Do not spawn sub-agents unless the user or applicable AGENTS.md/skill instructions explicitly ask for sub-agents, delegation, or parallel agent work. -` - function normalize(value: unknown): string | undefined { if (typeof value !== "string") return undefined const trimmed = value.trim().toLowerCase() diff --git a/package.json b/package.json index 0afd297..fed33f8 100644 --- a/package.json +++ b/package.json @@ -36,7 +36,7 @@ }, "scripts": { "clean:dist": "node scripts/clean-dist.js", - "build": "npm run patch:plugin-dts && npm run clean:dist && tsc", + "build": "npm run generate:prompts && npm run patch:plugin-dts && npm run clean:dist && tsc", "patch:plugin-dts": "node scripts/patch-opencode-plugin-dts.js", "typecheck": "npm run patch:plugin-dts && tsc --noEmit", "typecheck:test": "npm run patch:plugin-dts && tsc --noEmit -p tsconfig.test.json", @@ -47,7 +47,10 @@ "test:anti-mock": "node scripts/check-test-mocking.mjs", "check:coverage-ratchet": "node scripts/check-coverage-ratchet.mjs", "check:docs": "node scripts/check-docs-references.mjs", - "verify": "npm run check:esm-imports && npm run lint && npm run format:check && npm run typecheck && npm run typecheck:test && npm run test:anti-mock && npm run test:coverage && npm run check:coverage-ratchet && npm run check:docs && npm run build && npm run check:dist-esm-imports && npm run smoke:cli:dist", + "generate:prompts": "node scripts/generate-prompt-compatibility.mjs", + "check:prompts": "node scripts/generate-prompt-compatibility.mjs --check", + "check:prompt-artifact": "node scripts/generate-prompt-compatibility.mjs --check-dist", + "verify": "npm run check:esm-imports && npm run check:prompts && npm run lint && npm run format:check && npm run typecheck && npm run typecheck:test && npm run test:anti-mock && npm run test:coverage && npm run check:coverage-ratchet && npm run check:docs && npm run build && npm run check:prompt-artifact && npm run check:dist-esm-imports && npm run smoke:cli:dist", "check:esm-imports": "node scripts/check-esm-import-specifiers.mjs", "check:dist-esm-imports": "node scripts/check-esm-import-specifiers.mjs --dist", "smoke:cli": "npm run build && npm run smoke:cli:dist", @@ -75,6 +78,7 @@ "dist/index.js", "dist/index.js.map", "dist/lib/", + "prompts/upstream/licenses/", "schemas/", "README.md", "LICENSE" diff --git a/prompts/prompt-compatibility.rules.json b/prompts/prompt-compatibility.rules.json new file mode 100644 index 0000000..9e48fce --- /dev/null +++ b/prompts/prompt-compatibility.rules.json @@ -0,0 +1,74 @@ +{ + "$schema": "./prompt-compatibility.rules.schema.json", + "version": 1, + "description": "Exact, reviewable substitutions that adapt Codex multi-agent mode overlays to the OpenCode task harness.", + "sources": [ + { + "id": "codex_multi_agent_mode", + "repository": "https://github.com/openai/codex", + "revision": "82b294c73c902a4c51f789ba68bb599f0065616f", + "upstreamPath": "codex-rs/core/src/context/multi_agent_mode_instructions.rs", + "path": "prompts/upstream/codex-multi-agent-mode-instructions.rs", + "sha256": "86d3cd39999e02812e65224e85d7b1c9bc141fa439a8d84683e10d82a6a39870", + "requiredSymbols": [ + "EXPLICIT_REQUEST_ONLY_MULTI_AGENT_MODE_TEXT", + "PROACTIVE_MULTI_AGENT_MODE_TEXT", + "MULTI_AGENT_MODE_OPEN_TAG", + "MULTI_AGENT_MODE_CLOSE_TAG" + ] + }, + { + "id": "opencode_task_description", + "repository": "https://github.com/anomalyco/opencode", + "revision": "v1.17.18", + "upstreamPath": "packages/opencode/src/tool/task.txt", + "path": "prompts/upstream/opencode-task.txt", + "sha256": "220dcf4ad2582dbdaf2b0bbc8b7f5fa78172b1337539ac1c8912f45f2b9e5d46", + "requiredSymbols": ["Task tool", "subagent_type", "Launch multiple agents concurrently"] + }, + { + "id": "opencode_task_schema", + "repository": "https://github.com/anomalyco/opencode", + "revision": "v1.17.18", + "upstreamPath": "packages/opencode/src/tool/task.ts", + "path": "prompts/upstream/opencode-task-schema.ts", + "sha256": "319a06eb345c33f04e983ed31a84b6d0e2c1e0698f96835504774ca0918561a4", + "requiredSymbols": ["const id = \"task\"", "subagent_type", "task_id", "TaskTool"] + } + ], + "outputs": [ + { + "exportName": "ULTRA_PROACTIVE_INSTRUCTIONS", + "sourceId": "codex_multi_agent_mode", + "rustConstant": "PROACTIVE_MULTI_AGENT_MODE_TEXT", + "operations": [ + { + "type": "replace_literal", + "old": "spawning sub-agents", + "new": "using the OpenCode `task` tool", + "expectedCount": 1 + }, + { + "type": "replace_literal", + "old": "Use sub-agents", + "new": "Use the OpenCode `task` tool", + "expectedCount": 1 + } + ] + }, + { + "exportName": "ULTRA_EXPLICIT_ONLY_INSTRUCTIONS", + "sourceId": "codex_multi_agent_mode", + "rustConstant": "EXPLICIT_REQUEST_ONLY_MULTI_AGENT_MODE_TEXT", + "operations": [ + { + "type": "replace_literal", + "old": "Do not spawn sub-agents", + "new": "Do not use the OpenCode `task` tool", + "expectedCount": 1 + } + ] + } + ], + "output": "lib/codex-native/generated/ultra-instructions.ts" +} diff --git a/prompts/prompt-compatibility.rules.schema.json b/prompts/prompt-compatibility.rules.schema.json new file mode 100644 index 0000000..88a32fa --- /dev/null +++ b/prompts/prompt-compatibility.rules.schema.json @@ -0,0 +1,57 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "required": ["version", "sources", "outputs", "output"], + "properties": { + "version": { "const": 1 }, + "description": { "type": "string" }, + "sources": { + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "required": ["id", "repository", "revision", "upstreamPath", "path", "sha256", "requiredSymbols"], + "properties": { + "id": { "type": "string" }, + "repository": { "type": "string" }, + "revision": { "type": "string" }, + "upstreamPath": { "type": "string" }, + "path": { "type": "string" }, + "sha256": { "type": "string", "pattern": "^[a-f0-9]{64}$" }, + "requiredSymbols": { "type": "array", "minItems": 1, "items": { "type": "string" } } + }, + "additionalProperties": false + } + }, + "outputs": { + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "required": ["exportName", "sourceId", "rustConstant", "operations"], + "properties": { + "exportName": { "type": "string" }, + "sourceId": { "type": "string" }, + "rustConstant": { "type": "string" }, + "operations": { + "type": "array", + "items": { + "type": "object", + "required": ["type", "old", "new", "expectedCount"], + "properties": { + "type": { "const": "replace_literal" }, + "old": { "type": "string" }, + "new": { "type": "string" }, + "expectedCount": { "type": "integer", "minimum": 1 } + }, + "additionalProperties": false + } + } + }, + "additionalProperties": false + } + }, + "output": { "type": "string" } + }, + "additionalProperties": false +} diff --git a/prompts/upstream/README.md b/prompts/upstream/README.md new file mode 100644 index 0000000..0b3f6f9 --- /dev/null +++ b/prompts/upstream/README.md @@ -0,0 +1,8 @@ +# Upstream prompt evidence + +Files in this directory are verbatim snapshots pinned by `prompts/prompt-compatibility.rules.json`. + +- `codex-multi-agent-mode-instructions.rs` comes from OpenAI Codex and is distributed under the Apache License 2.0. See `licenses/openai-codex-LICENSE` and `licenses/openai-codex-NOTICE`. +- `opencode-task.txt` and `opencode-task-schema.ts` come from OpenCode and are distributed under the MIT License. See `licenses/opencode-LICENSE`. + +The manifest records each source repository, revision, upstream path, and content hash. diff --git a/prompts/upstream/codex-multi-agent-mode-instructions.rs b/prompts/upstream/codex-multi-agent-mode-instructions.rs new file mode 100644 index 0000000..635b65c --- /dev/null +++ b/prompts/upstream/codex-multi-agent-mode-instructions.rs @@ -0,0 +1,49 @@ +use super::ContextualUserFragment; +use codex_protocol::config_types::MultiAgentMode; +use codex_protocol::protocol::MULTI_AGENT_MODE_CLOSE_TAG; +use codex_protocol::protocol::MULTI_AGENT_MODE_OPEN_TAG; + +const EXPLICIT_REQUEST_ONLY_MULTI_AGENT_MODE_TEXT: &str = "Any earlier instruction enabling proactive multi-agent delegation no longer applies. Do not spawn sub-agents unless the user or applicable AGENTS.md/skill instructions explicitly ask for sub-agents, delegation, or parallel agent work."; +const PROACTIVE_MULTI_AGENT_MODE_TEXT: &str = "Proactive multi-agent delegation is active. Any earlier instruction requiring an explicit user request before spawning sub-agents no longer applies. Use sub-agents when parallel work would materially improve speed or quality. This mode remains active until a later multi-agent mode developer message changes it."; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct MultiAgentModeInstructions { + multi_agent_mode: MultiAgentMode, +} + +impl MultiAgentModeInstructions { + pub(crate) fn from_mode(multi_agent_mode: MultiAgentMode) -> Option { + if matches!( + &multi_agent_mode, + MultiAgentMode::Custom(hint_text) if hint_text.is_empty() + ) { + return None; + } + + Some(Self { multi_agent_mode }) + } +} + +impl ContextualUserFragment for MultiAgentModeInstructions { + fn role(&self) -> &'static str { + "developer" + } + + fn markers(&self) -> (&'static str, &'static str) { + Self::type_markers() + } + + fn type_markers() -> (&'static str, &'static str) { + (MULTI_AGENT_MODE_OPEN_TAG, MULTI_AGENT_MODE_CLOSE_TAG) + } + + fn body(&self) -> String { + match &self.multi_agent_mode { + MultiAgentMode::Custom(hint_text) => hint_text.clone(), + MultiAgentMode::ExplicitRequestOnly => { + EXPLICIT_REQUEST_ONLY_MULTI_AGENT_MODE_TEXT.to_string() + } + MultiAgentMode::Proactive => PROACTIVE_MULTI_AGENT_MODE_TEXT.to_string(), + } + } +} diff --git a/prompts/upstream/licenses/openai-codex-LICENSE b/prompts/upstream/licenses/openai-codex-LICENSE new file mode 100644 index 0000000..4606e72 --- /dev/null +++ b/prompts/upstream/licenses/openai-codex-LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + +1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + +2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + +3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + +4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + +5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + +6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + +7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + +8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + +9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + +END OF TERMS AND CONDITIONS + +APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + +Copyright 2025 OpenAI + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. diff --git a/prompts/upstream/licenses/openai-codex-NOTICE b/prompts/upstream/licenses/openai-codex-NOTICE new file mode 100644 index 0000000..2805899 --- /dev/null +++ b/prompts/upstream/licenses/openai-codex-NOTICE @@ -0,0 +1,6 @@ +OpenAI Codex +Copyright 2025 OpenAI + +This project includes code derived from [Ratatui](https://github.com/ratatui/ratatui), licensed under the MIT license. +Copyright (c) 2016-2022 Florian Dehau +Copyright (c) 2023-2025 The Ratatui Developers diff --git a/prompts/upstream/licenses/opencode-LICENSE b/prompts/upstream/licenses/opencode-LICENSE new file mode 100644 index 0000000..6439474 --- /dev/null +++ b/prompts/upstream/licenses/opencode-LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 opencode + +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/prompts/upstream/opencode-task-schema.ts b/prompts/upstream/opencode-task-schema.ts new file mode 100644 index 0000000..b0a866c --- /dev/null +++ b/prompts/upstream/opencode-task-schema.ts @@ -0,0 +1,346 @@ +import * as Tool from "./tool" +import DESCRIPTION from "./task.txt" +import { ToolJsonSchema } from "./json-schema" +import { SessionV1 } from "@opencode-ai/core/v1/session" +import { BackgroundJob } from "@/background/job" +import { Session } from "@/session/session" +import { SessionID, MessageID } from "../session/schema" +import { MessageV2 } from "../session/message-v2" +import { Agent } from "../agent/agent" +import { deriveSubagentSessionPermission } from "../agent/subagent-permissions" +import type { SessionPrompt } from "../session/prompt" +import { Config } from "@/config/config" +import { Effect, Exit, Schema, Scope } from "effect" +import { EffectBridge } from "@/effect/bridge" +import { RuntimeFlags } from "@/effect/runtime-flags" +import { Database } from "@opencode-ai/core/database/database" + +export interface TaskPromptOps { + cancel(sessionID: SessionID): Effect.Effect + resolvePromptParts(template: string): Effect.Effect + prompt(input: SessionPrompt.PromptInput): Effect.Effect +} + +const id = "task" +const BACKGROUND_DESCRIPTION = [ + "Background mode: background=true launches the subagent asynchronously and returns immediately.", + "Foreground is the default; use it when you need the result before continuing.", + "Use background only for independent work that can run while you continue elsewhere.", + "You will be notified automatically when it finishes.", +].join(" ") +const BACKGROUND_STARTED = [ + "The task is working in the background. You will be notified automatically when it finishes.", + "DO NOT sleep, poll for progress, ask the task for status, or duplicate this task's work — avoid working with the same files or topics it is using.", + "Work on non-overlapping tasks, or briefly tell the user what you launched and end your response.", +].join("\n") +const BACKGROUND_UPDATED = [ + "Additional context sent to the running background task.", + "The task is still working in the background. You will be notified automatically when it finishes.", + "DO NOT sleep, poll for progress, ask the task for status, or duplicate this task's work — avoid working with the same files or topics it is using.", + "Work on non-overlapping tasks, or briefly tell the user what you sent and end your response.", +].join("\n") + +const BaseParameterFields = { + description: Schema.String.annotate({ description: "A short (3-5 words) description of the task" }), + prompt: Schema.String.annotate({ description: "The task for the agent to perform" }), + subagent_type: Schema.String.annotate({ description: "The type of specialized agent to use for this task" }), + task_id: Schema.optional(Schema.String).annotate({ + description: + "This should only be set if you mean to resume a previous task (you can pass a prior task_id and the task will continue the same subagent session as before instead of creating a fresh one)", + }), + command: Schema.optional(Schema.String).annotate({ description: "The command that triggered this task" }), +} + +const BaseParameters = Schema.Struct(BaseParameterFields) + +export const Parameters = Schema.Struct({ + ...BaseParameterFields, + background: Schema.optional(Schema.Boolean).annotate({ + description: + "Run the agent in the background. You will be notified when it completes. DO NOT sleep, poll, or proactively check on its progress", + }), +}) + +function renderOutput(input: { + sessionID: SessionID + state: "running" | "completed" | "error" + summary?: string + text: string +}) { + const tag = input.state === "error" ? "task_error" : "task_result" + return [ + ``, + ...(input.summary ? [`${input.summary}`] : []), + `<${tag}>`, + input.text, + ``, + "", + ].join("\n") +} + +export const TaskTool = Tool.define( + id, + Effect.gen(function* () { + const agent = yield* Agent.Service + const background = yield* BackgroundJob.Service + const config = yield* Config.Service + const sessions = yield* Session.Service + const scope = yield* Scope.Scope + const flags = yield* RuntimeFlags.Service + const database = yield* Database.Service + + const run = Effect.fn("TaskTool.execute")(function* ( + params: Schema.Schema.Type, + ctx: Tool.Context, + ) { + const cfg = yield* config.get() + const runInBackground = params.background === true + if (runInBackground && !flags.experimentalBackgroundSubagents) { + return yield* Effect.fail( + new Error("Background subagents require OPENCODE_EXPERIMENTAL_BACKGROUND_SUBAGENTS=true"), + ) + } + + if (!ctx.extra?.bypassAgentCheck) { + yield* ctx.ask({ + permission: id, + patterns: [params.subagent_type], + always: ["*"], + metadata: { + description: params.description, + subagent_type: params.subagent_type, + }, + }) + } + + const next = yield* agent.get(params.subagent_type) + if (!next) { + return yield* Effect.fail(new Error(`Unknown agent type: ${params.subagent_type} is not a valid agent type`)) + } + + const session = params.task_id + ? yield* sessions.get(SessionID.make(params.task_id)).pipe(Effect.catchCause(() => Effect.succeed(undefined))) + : undefined + const parent = yield* sessions.get(ctx.sessionID) + const childPermission = deriveSubagentSessionPermission({ + parentSessionPermission: parent.permission ?? [], + subagent: next, + }) + const childToolDenies = [ + ...(next.permission.some((rule) => rule.permission === "todowrite") + ? [] + : [{ permission: "todowrite" as const, pattern: "*" as const, action: "deny" as const }]), + ...(next.permission.some((rule) => rule.permission === id) + ? [] + : [{ permission: id, pattern: "*" as const, action: "deny" as const }]), + ...(cfg.experimental?.primary_tools?.map((permission) => ({ + permission, + pattern: "*" as const, + action: "deny" as const, + })) ?? []), + ] + const nextSession = + session ?? + (yield* sessions.create({ + parentID: ctx.sessionID, + title: params.description + ` (@${next.name} subagent)`, + agent: next.name, + permission: [ + ...childPermission, + ...childToolDenies.filter( + (deny) => + !childPermission.some( + (rule) => + rule.permission === deny.permission && rule.pattern === deny.pattern && rule.action === deny.action, + ), + ), + ], + })) + + const msg = yield* MessageV2.get({ sessionID: ctx.sessionID, messageID: ctx.messageID }).pipe( + Effect.provideService(Database.Service, database), + Effect.orDie, + ) + if (msg.info.role !== "assistant") return yield* Effect.fail(new Error("Not an assistant message")) + const variant = msg.info.variant + + const model = next.model ?? { + modelID: msg.info.modelID, + providerID: msg.info.providerID, + } + const metadata = { + parentSessionId: ctx.sessionID, + sessionId: nextSession.id, + model, + ...(runInBackground ? { background: true } : {}), + } + + yield* ctx.metadata({ + title: params.description, + metadata, + }) + + const ops = ctx.extra?.promptOps as TaskPromptOps + if (!ops) return yield* Effect.fail(new Error("TaskTool requires promptOps in ctx.extra")) + + const runTask = Effect.fn("TaskTool.runTask")(function* () { + const parts = yield* ops.resolvePromptParts(params.prompt) + const result = yield* ops.prompt({ + messageID: MessageID.ascending(), + sessionID: nextSession.id, + model: { + modelID: model.modelID, + providerID: model.providerID, + }, + variant: next.model ? undefined : variant, + agent: next.name, + parts, + }) + return result.parts.findLast((item) => item.type === "text")?.text ?? "" + }) + + const inject = Effect.fn("TaskTool.injectBackgroundResult")(function* ( + state: "completed" | "error", + text: string, + ) { + const currentParent = yield* sessions.get(ctx.sessionID) + yield* ops + .prompt({ + sessionID: ctx.sessionID, + agent: currentParent.agent ?? ctx.agent, + variant, + parts: [ + { + type: "text", + synthetic: true, + text: renderOutput({ + sessionID: nextSession.id, + state, + summary: + state === "completed" + ? `Background task completed: ${params.description}` + : `Background task failed: ${params.description}`, + text, + }), + }, + ], + }) + .pipe(Effect.ignore, Effect.forkIn(scope, { startImmediately: true })) + }) + + const notify = Effect.fn("TaskTool.notifyBackgroundResult")(function* (jobID: string) { + yield* background.wait({ id: jobID }).pipe( + Effect.flatMap((result) => { + if (result.info?.status === "completed") return inject("completed", result.info.output ?? "") + if (result.info?.status === "error") return inject("error", result.info.error ?? "") + return Effect.void + }), + Effect.forkIn(scope, { startImmediately: true }), + ) + }) + + if (yield* background.extend({ id: nextSession.id, run: runTask() })) { + return { + title: params.description, + metadata: { + ...metadata, + background: true, + jobId: nextSession.id, + }, + output: renderOutput({ + sessionID: nextSession.id, + state: "running", + summary: "Background task updated", + text: BACKGROUND_UPDATED, + }), + } + } + + const info = yield* background.start({ + id: nextSession.id, + type: id, + title: params.description, + metadata, + onPromote: Effect.all([ + ctx.metadata({ + title: params.description, + metadata: { ...metadata, background: true, jobId: nextSession.id }, + }), + notify(nextSession.id), + ]), + run: runTask().pipe(Effect.onInterrupt(() => ops.cancel(nextSession.id))), + }) + + function backgroundResult() { + return { + title: params.description, + metadata: { + ...metadata, + background: true, + jobId: info.id, + }, + output: renderOutput({ + sessionID: nextSession.id, + state: "running", + summary: "Background task started", + text: BACKGROUND_STARTED, + }), + } + } + + if (runInBackground) { + yield* notify(info.id) + return backgroundResult() + } + + const runCancel = yield* EffectBridge.make() + const cancel = ops.cancel(nextSession.id) + + function onAbort() { + runCancel.fork(cancel) + } + + return yield* Effect.acquireUseRelease( + Effect.sync(() => { + ctx.abort.addEventListener("abort", onAbort) + }), + () => + Effect.gen(function* () { + const result = yield* Effect.raceFirst( + background.wait({ id: nextSession.id }).pipe(Effect.map((waited) => waited.info)), + background.waitForPromotion(nextSession.id), + ) + if (result?.metadata?.background === true) return backgroundResult() + if (result?.status === "error") return yield* Effect.fail(new Error(result.error ?? "Task failed")) + if (result?.status === "cancelled") return yield* Effect.fail(new Error("Task cancelled")) + return { + title: params.description, + metadata, + output: renderOutput({ sessionID: nextSession.id, state: "completed", text: result?.output ?? "" }), + } + }), + (_, exit) => + Effect.gen(function* () { + if (Exit.hasInterrupts(exit)) + yield* Effect.all([cancel, background.cancel(nextSession.id)], { discard: true }) + }).pipe( + Effect.ensuring( + Effect.sync(() => { + ctx.abort.removeEventListener("abort", onAbort) + }), + ), + ), + ) + }) + + return { + description: flags.experimentalBackgroundSubagents + ? [DESCRIPTION, BACKGROUND_DESCRIPTION].join("\n\n") + : DESCRIPTION, + parameters: Parameters, + jsonSchema: flags.experimentalBackgroundSubagents ? undefined : ToolJsonSchema.fromSchema(BaseParameters), + execute: (params: Schema.Schema.Type, ctx: Tool.Context) => + run(params, ctx).pipe(Effect.orDie), + } + }), +) diff --git a/prompts/upstream/opencode-task.txt b/prompts/upstream/opencode-task.txt new file mode 100644 index 0000000..c5e412f --- /dev/null +++ b/prompts/upstream/opencode-task.txt @@ -0,0 +1,19 @@ +Launch a new agent to handle complex, multistep tasks autonomously. + +When using the Task tool, you must specify a subagent_type parameter to select which agent type to use. + +When NOT to use the Task tool: +- If you want to read a specific file path, use the Read or Glob tool instead of the Task tool, to find the match more quickly +- If you are searching for a specific class definition like "class Foo", use the Grep tool instead, to find the match more quickly +- If you are searching for code within a specific file or set of 2-3 files, use the Read tool instead of the Task tool, to find the match more quickly +- If no available agent is a good fit for the task, use other tools directly + + +Usage notes: +1. Launch multiple agents concurrently whenever possible, to maximize performance; to do that, use a single message with multiple tool uses +2. Once you have delegated work to an agent, do not duplicate that work yourself. Continue with non-overlapping tasks, or wait for the result. For background tasks, you will be notified automatically when the result is ready. +3. When the agent is done, it will return a single message back to you. The result returned by the agent is not visible to the user. To show the user the result, you should send a text message back to the user with a concise summary of the result. The output includes a task_id you can reuse later to continue the same subagent session. +4. Each agent invocation starts with a fresh context unless you provide task_id to resume the same subagent session (which continues with its previous messages and tool outputs). When starting fresh, your prompt should contain a highly detailed task description for the agent to perform autonomously and you should specify exactly what information the agent should return back to you in its final and only message to you. +5. The agent's outputs should generally be trusted +6. Clearly tell the agent whether you expect it to write code or just to do research (search, file reads, web fetches, etc.), since it is not aware of the user's intent. Tell it how to verify its work if possible (e.g., relevant test commands). +7. If the agent description mentions that it should be used proactively, then you should try your best to use it without the user having to ask for it first. Use your judgement. diff --git a/scripts/generate-prompt-compatibility.mjs b/scripts/generate-prompt-compatibility.mjs new file mode 100644 index 0000000..187fc50 --- /dev/null +++ b/scripts/generate-prompt-compatibility.mjs @@ -0,0 +1,161 @@ +import { createHash } from "node:crypto" +import { mkdir, readFile, rename, writeFile } from "node:fs/promises" +import { dirname, isAbsolute, relative, resolve, sep } from "node:path" +import { fileURLToPath, pathToFileURL } from "node:url" + +const root = resolve(dirname(fileURLToPath(import.meta.url)), "..") +const rulesPath = resolve(root, "prompts/prompt-compatibility.rules.json") + +function sha256(value) { + return createHash("sha256").update(value).digest("hex") +} + +function resolveWithin(base, candidate, label) { + if (isAbsolute(candidate)) throw new Error(`${label} must be relative`) + const resolved = resolve(base, candidate) + const relativePath = relative(base, resolved) + if (relativePath === ".." || relativePath.startsWith(`..${sep}`) || isAbsolute(relativePath)) { + throw new Error(`${label} must stay within ${base}`) + } + return resolved +} + +function countOccurrences(value, search) { + if (!search) throw new Error("replacement literal must not be empty") + return value.split(search).length - 1 +} + +function extractRustStringConstant(source, constant) { + const marker = `const ${constant}: &str = ` + const start = source.indexOf(marker) + if (start < 0) throw new Error(`missing Rust string constant ${constant}`) + const valueStart = start + marker.length + const valueEnd = source.indexOf(";", valueStart) + if (valueEnd < 0) throw new Error(`unterminated Rust string constant ${constant}`) + const literal = source.slice(valueStart, valueEnd).trim() + try { + const parsed = JSON.parse(literal) + if (typeof parsed !== "string") throw new Error("constant is not a string") + return parsed + } catch (error) { + throw new Error(`invalid Rust string literal for ${constant}: ${error instanceof Error ? error.message : error}`) + } +} + +function applyOperations(value, operations, exportName) { + let rendered = value + for (const [index, operation] of operations.entries()) { + if (operation.type !== "replace_literal") { + throw new Error(`${exportName} operation ${index + 1}: unsupported type ${operation.type}`) + } + const actualCount = countOccurrences(rendered, operation.old) + if (actualCount !== operation.expectedCount) { + throw new Error( + `${exportName} operation ${index + 1}: expected ${operation.expectedCount} matches, found ${actualCount}` + ) + } + rendered = rendered.replaceAll(operation.old, operation.new) + } + return rendered +} + +async function loadRules() { + return JSON.parse(await readFile(rulesPath, "utf8")) +} + +async function validateSources(rules) { + const sources = new Map() + for (const source of rules.sources) { + if (sources.has(source.id)) throw new Error(`duplicate source id ${source.id}`) + const path = resolveWithin(root, source.path, `${source.id} path`) + const content = await readFile(path) + const actualHash = sha256(content) + if (actualHash !== source.sha256) { + throw new Error(`${source.id} hash mismatch: expected ${source.sha256}, got ${actualHash}`) + } + const text = content.toString("utf8") + for (const symbol of source.requiredSymbols) { + if (!text.includes(symbol)) throw new Error(`${source.id}: missing evidence symbol ${JSON.stringify(symbol)}`) + } + sources.set(source.id, text) + } + return sources +} + +function renderArtifacts(rules, sources) { + const artifacts = [] + for (const output of rules.outputs) { + const source = sources.get(output.sourceId) + if (!source) throw new Error(`${output.exportName}: unknown source ${output.sourceId}`) + const extracted = extractRustStringConstant(source, output.rustConstant) + const transformed = applyOperations(extracted, output.operations, output.exportName) + const wrapped = `\n${transformed}\n` + artifacts.push({ exportName: output.exportName, value: wrapped }) + } + return artifacts +} + +function renderModule(artifacts) { + const lines = [ + "// Generated by scripts/generate-prompt-compatibility.mjs. Do not edit directly.", + "// Derived from OpenAI Codex and modified for OpenCode compatibility under Apache-2.0.", + "// See prompts/upstream/licenses/openai-codex-LICENSE and openai-codex-NOTICE.", + "" + ] + for (const artifact of artifacts) { + lines.push(`export const ${artifact.exportName} =`, ` ${JSON.stringify(artifact.value)}`, "") + } + return `${lines.join("\n").trimEnd()}\n` +} + +async function main() { + const check = process.argv.includes("--check") + const checkDist = process.argv.includes("--check-dist") + const rules = await loadRules() + const sources = await validateSources(rules) + const artifacts = renderArtifacts(rules, sources) + const rendered = renderModule(artifacts) + const outputPath = resolveWithin(root, rules.output, "prompt output") + + if (checkDist) { + if (!rules.output.endsWith(".ts")) throw new Error("prompt output must be a TypeScript module") + const relativeDistPath = rules.output.replace(/\.ts$/, ".js") + const distRoot = resolve(root, "dist") + const compiledPath = resolveWithin(distRoot, relativeDistPath, "compiled prompt output") + const compiled = await import(`${pathToFileURL(compiledPath).href}?check=${Date.now()}`) + const actualExports = Object.keys(compiled).sort() + const expectedExports = artifacts.map((artifact) => artifact.exportName).sort() + if (JSON.stringify(actualExports) !== JSON.stringify(expectedExports)) { + throw new Error( + `compiled prompt artifact exports differ: expected ${expectedExports.join(", ")}, got ${actualExports.join(", ")}` + ) + } + for (const artifact of artifacts) { + if (compiled[artifact.exportName] !== artifact.value) { + throw new Error(`compiled prompt artifact is missing ${artifact.exportName}: ${relativeDistPath}`) + } + } + process.stdout.write(`Compiled prompt artifact contains ${artifacts.length} overlays.\n`) + return + } + + if (check) { + const current = await readFile(outputPath, "utf8").catch(() => undefined) + if (current !== rendered) { + throw new Error(`generated prompt is out of date: run npm run generate:prompts`) + } + process.stdout.write(`Prompt compatibility check passed for ${rules.outputs.length} overlays.\n`) + return + } + + await mkdir(dirname(outputPath), { recursive: true }) + const temporaryPath = `${outputPath}.tmp` + await writeFile(temporaryPath, rendered, "utf8") + await rename(temporaryPath, outputPath) + process.stderr.write(`Generated ${rules.output}.\n`) +} + +main().catch((error) => { + process.stderr.write(`Prompt compatibility error: ${error instanceof Error ? error.message : error}\n`) + process.exitCode = 1 +}) diff --git a/test/prompt-compatibility.test.ts b/test/prompt-compatibility.test.ts new file mode 100644 index 0000000..f8a8135 --- /dev/null +++ b/test/prompt-compatibility.test.ts @@ -0,0 +1,39 @@ +import { execFileSync } from "node:child_process" +import { createHash } from "node:crypto" +import { readFileSync } from "node:fs" +import { join } from "node:path" +import { describe, expect, it } from "vitest" + +type PromptRules = { + sources: Array<{ path: string; sha256: string; requiredSymbols: string[] }> + outputs: Array<{ exportName: string }> + output: string +} + +describe("prompt compatibility artifacts", () => { + it("matches pinned harness evidence and generated output", () => { + const root = process.cwd() + const rules = JSON.parse( + readFileSync(join(root, "prompts", "prompt-compatibility.rules.json"), "utf8") + ) as PromptRules + + for (const source of rules.sources) { + const content = readFileSync(join(root, source.path)) + expect(createHash("sha256").update(content).digest("hex")).toBe(source.sha256) + const text = content.toString("utf8") + for (const symbol of source.requiredSymbols) expect(text).toContain(symbol) + } + + expect(readFileSync(join(root, rules.output), "utf8")).toContain("OpenCode `task` tool") + expect(rules.outputs.map((output) => output.exportName)).toEqual([ + "ULTRA_PROACTIVE_INSTRUCTIONS", + "ULTRA_EXPLICIT_ONLY_INSTRUCTIONS" + ]) + expect(() => + execFileSync(process.execPath, ["scripts/generate-prompt-compatibility.mjs", "--check"], { + cwd: root, + stdio: "pipe" + }) + ).not.toThrow() + }) +}) diff --git a/test/release-hygiene.test.ts b/test/release-hygiene.test.ts index 57e82bc..14a2155 100644 --- a/test/release-hygiene.test.ts +++ b/test/release-hygiene.test.ts @@ -1,8 +1,7 @@ -import { describe, it, expect } from "vitest" - import { execFileSync } from "node:child_process" import { existsSync, readFileSync, statSync } from "node:fs" import { join } from "node:path" +import { describe, expect, it } from "vitest" const REQUIRED_RELEASE_RUNTIME_CI_JOBS = [ "Verify (Node.js 22.x)", @@ -23,6 +22,7 @@ describe("release hygiene", () => { expect(pkg.scripts?.["hooks:install"]).toBe("node scripts/install-git-hooks.mjs") const verifyOrder = [ "npm run check:esm-imports", + "npm run check:prompts", "npm run lint", "npm run format:check", "npm run typecheck", @@ -32,6 +32,7 @@ describe("release hygiene", () => { "npm run check:coverage-ratchet", "npm run check:docs", "npm run build", + "npm run check:prompt-artifact", "npm run check:dist-esm-imports", "npm run smoke:cli:dist" ] @@ -42,15 +43,19 @@ describe("release hygiene", () => { searchFrom = nextIndex + step.length } expect(pkg.scripts?.["check:esm-imports"]).toBe("node scripts/check-esm-import-specifiers.mjs") + expect(pkg.scripts?.["generate:prompts"]).toBe("node scripts/generate-prompt-compatibility.mjs") + expect(pkg.scripts?.["check:prompts"]).toBe("node scripts/generate-prompt-compatibility.mjs --check") + expect(pkg.scripts?.["check:prompt-artifact"]).toBe("node scripts/generate-prompt-compatibility.mjs --check-dist") expect(pkg.scripts?.["check:dist-esm-imports"]).toBe("node scripts/check-esm-import-specifiers.mjs --dist") expect(pkg.scripts?.["smoke:cli:dist"]).toBe("node ./dist/bin/opencode-codex-auth.js --help") + expect(pkg.scripts?.build).toBe("npm run generate:prompts && npm run patch:plugin-dts && npm run clean:dist && tsc") + expect(pkg.scripts?.prepack).toBe("npm run build") + expect(pkg.files).toContain("prompts/upstream/licenses/") expect(pkg.scripts?.["test:coverage"]).toBe("vitest run --coverage.enabled true --coverage.provider=v8") expect(pkg.scripts?.["patch:plugin-dts"]).toBe("node scripts/patch-opencode-plugin-dts.js") expect(pkg.scripts?.typecheck).toContain("npm run patch:plugin-dts") expect(pkg.scripts?.["typecheck:test"]).toContain("npm run patch:plugin-dts") expect(pkg.scripts?.["check:file-size"]).toBeUndefined() - expect(pkg.scripts?.prepack).toBe("npm run build") - expect(pkg.scripts?.build).toBe("npm run patch:plugin-dts && npm run clean:dist && tsc") expect(pkg.scripts?.["clean:dist"]).toBe("node scripts/clean-dist.js") expect(existsSync(join(process.cwd(), "scripts", "clean-dist.js"))).toBe(true) expect(existsSync(join(process.cwd(), "scripts", "enforce-local-verify.mjs"))).toBe(true) diff --git a/test/ultra.test.ts b/test/ultra.test.ts index 82d6d9c..0d83a10 100644 --- a/test/ultra.test.ts +++ b/test/ultra.test.ts @@ -31,12 +31,12 @@ function chatOutput(): { temperature: number; topP: number; topK: number; option } describe("GPT-5.6 Ultra contract", () => { - it("uses the official Codex multi-agent mode messages", () => { + it("uses the generated OpenCode-compatible multi-agent mode messages", () => { expect(ULTRA_PROACTIVE_INSTRUCTIONS).toBe(` -Proactive multi-agent delegation is active. Any earlier instruction requiring an explicit user request before spawning sub-agents no longer applies. Use sub-agents when parallel work would materially improve speed or quality. This mode remains active until a later multi-agent mode developer message changes it. +Proactive multi-agent delegation is active. Any earlier instruction requiring an explicit user request before using the OpenCode \`task\` tool no longer applies. Use the OpenCode \`task\` tool when parallel work would materially improve speed or quality. This mode remains active until a later multi-agent mode developer message changes it. `) expect(ULTRA_EXPLICIT_ONLY_INSTRUCTIONS).toBe(` -Any earlier instruction enabling proactive multi-agent delegation no longer applies. Do not spawn sub-agents unless the user or applicable AGENTS.md/skill instructions explicitly ask for sub-agents, delegation, or parallel agent work. +Any earlier instruction enabling proactive multi-agent delegation no longer applies. Do not use the OpenCode \`task\` tool unless the user or applicable AGENTS.md/skill instructions explicitly ask for sub-agents, delegation, or parallel agent work. `) })