diff --git a/.agents/skills/agent-core-dev/config.md b/.agents/skills/agent-core-dev/config.md index 316f307f1..ac00d1313 100644 --- a/.agents/skills/agent-core-dev/config.md +++ b/.agents/skills/agent-core-dev/config.md @@ -242,20 +242,12 @@ When `KIMI_MODEL_NAME` is set, the `kosongConfig` wrapper's `kimiModelEnvOverlay `config` holds no monolithic config schema and no whole-config object. Every section is owned by the domain that consumes it: the schema (and any `fromToml` / `toToml` normalization and `stripEnv`) lives in that domain's `configSection.ts`, and the domain registers it via `IConfigRegistry.registerSection`. Cross-section env behavior (e.g. `KIMI_MODEL_*`) lives in an owner-registered `ConfigEffectiveOverlay`. To add a section, follow "Add a config section" above in the owning domain — never add schema or normalization to `config` itself. -## Ownership map (current) +## Ownership map (generated) -| Section | Owner | Layer | Status | -|---|---|---|---| -| `providers` / `defaultProvider` | `kosongConfig` (types: `kosong/provider`) | L3/L2 | owner-owned (`IProviderService` CRUD) | -| `experimental` | `flag` | L3 | owner-owned | -| `thinking` | `kosongConfig` (type: `kosong/model/thinking`) | L3/L2 | owner-owned | -| `loopControl` | `loop` | L4 | owner-owned (read by `loop` + `profile`) | -| `McpServerConfig` (type) | `mcp` | L5 | owner-owned (type only; not a registered section) | -| `session` | `config` | L2 | in config | -| `models` / `defaultModel` | `kosongConfig` (types: `kosong/model`) | L3/L2 | owner-owned (`IModelService` CRUD) | -| `hooks` | `externalHooks` | L4 | owner-owned | -| `permission` | `permissionRules` | L3 | owner-owned | -| `background` | `background` | L5 | owner-owned | +The authoritative, always-current list of registered sections — rendered in the on-disk `config.toml` shape, with owner file, scope, defaults, env bindings, and schema fields — is generated from the live registry: + +- `packages/agent-core-v2/docs/config-manifest.toml` (checked in; do not edit by hand). +- Regenerate with `pnpm --filter @moonshot-ai/agent-core-v2 gen:config-manifest` (add `--check` for a freshness check; `test/app/config/configManifest.test.ts` enforces it in CI). `config` must not import from any of these owner domains; that is the whole reason the schemas, TOML normalization, and env overlays live with their owners. diff --git a/packages/agent-core-v2/AGENTS.md b/packages/agent-core-v2/AGENTS.md index 30debc889..8f494134d 100644 --- a/packages/agent-core-v2/AGENTS.md +++ b/packages/agent-core-v2/AGENTS.md @@ -62,3 +62,5 @@ Per-domain references live in `docs/`. - [`docs/flag.md`](docs/flag.md) — Read **before gating behavior behind a feature flag**: declaring a flag in its owning domain and registering it at import time via `registerFlagDefinition`, checking `IFlagService.enabled(id)`, wiring the `[experimental]` config section, or deciding whether a flag is App-scope vs. per-session. - [`docs/errors.md`](docs/errors.md) — Read **before raising errors from a domain**: defining a co-located `XxxError`, registering a code in `ErrorCodes`/`ERROR_INFO`, translating external errors (provider/HTTP, fs, MCP) at the boundary, or (de)serializing errors across RPC/SDK with `toErrorPayload`/`fromErrorPayload`. - [`docs/di-testing.md`](docs/di-testing.md) — Read **before writing or touching any DI/Scope test**: picking the right harness (`InstantiationService` vs `TestInstantiationService` vs `createScopedTestHost`), declaring deps with `@IService`, stubbing collaborators, and teardown via `DisposableStore`. +- [`docs/config-manifest.toml`](docs/config-manifest.toml) — Generated list of every registered config section, in the on-disk `config.toml` shape (owner, scope, defaults, env bindings, schema fields). Do not edit by hand; regenerate with `pnpm gen:config-manifest` after adding or removing a `registerConfigSection` call — `test/app/config/configManifest.test.ts` enforces freshness. +- [`docs/wire-manifest.d.ts`](docs/wire-manifest.d.ts) — Generated declaration file listing every registered wire record type as a payload interface (model, persist policy, `toEvent`, cross-reducers in the doc comment; payload fields in real TS type syntax), plus a `WirePayloadMap`. Do not edit by hand; regenerate with `pnpm gen:wire-manifest` after adding or removing a `defineOp` call — `test/wire/wireManifest.test.ts` enforces freshness and checks the file parses. diff --git a/packages/agent-core-v2/docs/config-manifest.toml b/packages/agent-core-v2/docs/config-manifest.toml new file mode 100644 index 000000000..e0b9ddc1f --- /dev/null +++ b/packages/agent-core-v2/docs/config-manifest.toml @@ -0,0 +1,352 @@ +# Config Section Manifest +# +# Generated by scripts/gen-config-manifest.mts — do not edit by hand. +# Regenerate with: pnpm --filter @moonshot-ai/agent-core-v2 gen:config-manifest +# +# One [table] per registered config section, in the on-disk config.toml shape +# (snake_case keys). Un-commented assignments are registered defaults; +# commented "# field: type" lines describe the remaining schema fields. +# Values resolve as: default -> config.toml -> env overlay -> memory. + +# Index (20 sections · 1 overlay(s)) +# background src/agent/task/configSection.ts +# cron src/app/cron/configSection.ts +# defaultPermissionMode src/agent/permissionMode/configSection.ts +# defaultPlanMode src/agent/plan/configSection.ts +# experimental src/app/flag/flag.ts +# extraAgentDirs src/app/agentFileCatalog/configSection.ts +# extraSkillDirs src/app/skillCatalog/configSection.ts +# hooks src/agent/externalHooks/configSection.ts +# image src/agent/media/configSection.ts +# loopControl src/agent/loop/configSection.ts +# mergeAllAvailableSkills src/app/skillCatalog/configSection.ts +# modelCatalog src/app/kosongConfig/configSection.ts +# models src/app/kosongConfig/configSection.ts +# permission src/agent/permissionRules/configSection.ts +# providers src/app/kosongConfig/configSection.ts +# services src/app/auth/configSection.ts +# subagent src/session/subagent/configSection.ts +# task src/agent/task/configSection.ts +# thinking src/app/kosongConfig/configSection.ts +# tools src/agent/toolPolicy/configSection.ts +# (overlay) kimiModelEnvOverlay src/app/kosongConfig/envOverlay.ts + +# ########################################################################## +# background +# owner: src/agent/task/configSection.ts +# scope: core +# hooks: stripEnv +# env: +# keep_alive_on_exit <- KIMI_CODE_BACKGROUND_KEEP_ALIVE_ON_EXIT (custom parse) +# max_running_tasks <- KIMI_CODE_BACKGROUND_MAX_RUNNING_TASKS (custom parse) +# ########################################################################## + +[background] +# max_running_tasks: integer +# keep_alive_on_exit: boolean +# bash_auto_background_on_timeout: boolean +# bash_task_timeout_s: integer +# kill_grace_period_ms: integer +# print_wait_ceiling_s: integer +# print_background_mode: "exit" | "drain" | "steer" +# print_max_turns: integer + +# ########################################################################## +# cron +# owner: src/app/cron/configSection.ts +# scope: core +# hooks: stripEnv +# env: +# debug <- KIMI_CRON_DEBUG (custom parse) +# no_jitter <- KIMI_CRON_NO_JITTER (custom parse) +# no_stale <- KIMI_CRON_NO_STALE (custom parse) +# disabled <- KIMI_DISABLE_CRON (custom parse) +# manual_tick <- KIMI_CRON_MANUAL_TICK (custom parse) +# clock <- KIMI_CRON_CLOCK +# poll_interval_ms <- KIMI_CRON_POLL_INTERVAL_MS (custom parse) +# ########################################################################## + +[cron] +# (schema uses transforms; fields below come from the registered default) +debug = false +no_jitter = false +no_stale = false +disabled = false +manual_tick = false + +# ########################################################################## +# defaultPermissionMode (config.toml: default_permission_mode) +# owner: src/agent/permissionMode/configSection.ts +# scope: core +# ########################################################################## + +# default_permission_mode: "manual" | "auto" | "yolo" + +# ########################################################################## +# defaultPlanMode (config.toml: default_plan_mode) +# owner: src/agent/plan/configSection.ts +# scope: core +# ########################################################################## + +default_plan_mode = false + +# ########################################################################## +# experimental +# owner: src/app/flag/flag.ts +# scope: core +# hooks: custom fromToml · custom toToml +# ########################################################################## + +[experimental] +# : boolean + +# ########################################################################## +# extraAgentDirs (config.toml: extra_agent_dirs) +# owner: src/app/agentFileCatalog/configSection.ts +# scope: core +# ########################################################################## + +extra_agent_dirs = [] + +# ########################################################################## +# extraSkillDirs (config.toml: extra_skill_dirs) +# owner: src/app/skillCatalog/configSection.ts +# scope: core +# ########################################################################## + +extra_skill_dirs = [] + +# ########################################################################## +# hooks +# owner: src/agent/externalHooks/configSection.ts +# scope: core +# hooks: custom fromToml · custom toToml +# ########################################################################## + +# one [[hooks]] table per entry: +# [[hooks]] + # event: "PreToolUse" | "PostToolUse" | "PostToolUseFailure" | "PermissionRequest" | "PermissionResult" | "UserPromptSubmit" | "Stop" | "StopFailure" | "Interrupt" | "SessionStart" | "SessionEnd" | "SubagentStart" | "SubagentStop" | "PreCompact" | "PostCompact" | "Notification" + # matcher: string + # command: string + # timeout: integer + +# ########################################################################## +# image +# owner: src/agent/media/configSection.ts +# scope: core +# hooks: stripEnv +# env: +# max_edge_px <- KIMI_IMAGE_MAX_EDGE_PX (custom parse) +# read_byte_budget <- KIMI_IMAGE_READ_BYTE_BUDGET (custom parse) +# ########################################################################## + +[image] +# max_edge_px: integer +# read_byte_budget: integer + +# ########################################################################## +# loopControl (config.toml: loop_control) +# owner: src/agent/loop/configSection.ts +# scope: core +# hooks: custom fromToml · custom toToml · stripEnv +# env: +# max_steps_per_turn <- KIMI_LOOP_MAX_STEPS_PER_TURN (custom parse) +# max_retries_per_step <- KIMI_LOOP_MAX_RETRIES_PER_STEP (custom parse) +# ########################################################################## + +[loop_control] +# max_steps_per_turn: integer +# max_retries_per_step: integer +# max_ralph_iterations: integer +# reserved_context_size: integer +# compaction_trigger_ratio: number + +# ########################################################################## +# mergeAllAvailableSkills (config.toml: merge_all_available_skills) +# owner: src/app/skillCatalog/configSection.ts +# scope: core +# ########################################################################## + +merge_all_available_skills = true + +# ########################################################################## +# modelCatalog (config.toml: model_catalog) +# owner: src/app/kosongConfig/configSection.ts +# scope: core +# ########################################################################## + +[model_catalog] +# refresh_interval_ms: integer +# refresh_on_start: boolean + +# ########################################################################## +# models +# owner: src/app/kosongConfig/configSection.ts +# scope: core +# hooks: custom fromToml · custom toToml +# ########################################################################## + +[models] + +# one [models.""] table per entry: +# [models.""] + # provider_id: string + # base_url: string + # api_key: string + # oauth: object + # storage: "file" | "keyring" + # key: string + # oauth_host: string + # protocol: "anthropic" | "openai" | "openai_responses" | "google-genai" + # name: string + # aliases: string[] + # provider: string + # model: string + # max_context_size: integer + # max_input_size: integer + # max_output_size: integer + # capabilities: string[] + # display_name: string + # reasoning_key: string + # adaptive_thinking: boolean + # beta_api: boolean + # support_efforts: string[] + # default_effort: string + # off_effort: string + # overrides: object + # max_context_size: integer + # max_input_size: integer + # max_output_size: integer + # capabilities: string[] + # display_name: string + # reasoning_key: string + # adaptive_thinking: boolean + # support_efforts: string[] + # default_effort: string + # off_effort: string + +# ########################################################################## +# permission +# owner: src/agent/permissionRules/configSection.ts +# scope: core +# hooks: custom fromToml · custom toToml +# ########################################################################## + +[permission] +# rules: object[] — one entry per item: + # decision: "allow" | "deny" | "ask" + # scope: "turn-override" | "session-runtime" | "project" | "user" (default: "user") + # pattern: string + # reason: string + +# ########################################################################## +# providers +# owner: src/app/kosongConfig/configSection.ts +# scope: core +# hooks: custom fromToml · custom toToml · stripEnv +# env: +# __kimi_env__.api_key <- KIMI_MODEL_API_KEY +# __kimi_env__.type <- KIMI_MODEL_PROVIDER_TYPE +# __kimi_env__.base_url <- KIMI_MODEL_BASE_URL +# ########################################################################## + +[providers] + +# one [providers.""] table per entry: +# [providers.""] + # model_source: "static" | "discover" | "oauth-catalog" + # base_url: string + # custom_headers: record + # default_model: string + # type: string + # api_key: string + # oauth: object + # storage: "file" | "keyring" + # key: string + # oauth_host: string + # env: record + # source: record + +# ########################################################################## +# services +# owner: src/app/auth/configSection.ts +# scope: core +# hooks: custom fromToml · custom toToml +# ########################################################################## + +[services] + +# [services.moonshot_search] + # base_url: string + # api_key: string + # oauth: object + # storage: "file" | "keyring" + # key: string + # oauth_host: string + # custom_headers: record + +# [services.moonshot_fetch] + # base_url: string + # api_key: string + # oauth: object + # storage: "file" | "keyring" + # key: string + # oauth_host: string + # custom_headers: record + +# ########################################################################## +# subagent +# owner: src/session/subagent/configSection.ts +# scope: core +# hooks: stripEnv +# env: +# timeout_ms <- KIMI_SUBAGENT_TIMEOUT_MS (custom parse) +# ########################################################################## + +[subagent] +timeout_ms = 7200000 + +# ########################################################################## +# task +# owner: src/agent/task/configSection.ts +# scope: core +# hooks: stripEnv +# env: +# keep_alive_on_exit <- KIMI_CODE_BACKGROUND_KEEP_ALIVE_ON_EXIT (custom parse) +# max_running_tasks <- KIMI_CODE_BACKGROUND_MAX_RUNNING_TASKS (custom parse) +# ########################################################################## + +[task] +# max_running_tasks: integer +# keep_alive_on_exit: boolean +# bash_auto_background_on_timeout: boolean +# bash_task_timeout_s: integer +# kill_grace_period_ms: integer +# print_wait_ceiling_s: integer +# print_background_mode: "exit" | "drain" | "steer" +# print_max_turns: integer + +# ########################################################################## +# thinking +# owner: src/app/kosongConfig/configSection.ts +# scope: core +# hooks: stripEnv +# env: +# forced_effort <- KIMI_MODEL_THINKING_EFFORT +# ########################################################################## + +[thinking] +# enabled: boolean +# effort: string +# forced_effort: string +# keep: string + +# ########################################################################## +# tools +# owner: src/agent/toolPolicy/configSection.ts +# scope: core +# ########################################################################## + +[tools] +# enabled: string[] +# disabled: string[] diff --git a/packages/agent-core-v2/docs/wire-manifest.d.ts b/packages/agent-core-v2/docs/wire-manifest.d.ts new file mode 100644 index 000000000..77c7053b6 --- /dev/null +++ b/packages/agent-core-v2/docs/wire-manifest.d.ts @@ -0,0 +1,660 @@ +// Wire Protocol Manifest +// +// Generated by scripts/gen-wire-manifest.mts — do not edit by hand. +// Regenerate with: pnpm --filter @moonshot-ai/agent-core-v2 gen:wire-manifest +// +// protocol_version: "1.5" (migrations: 1.0 -> 1.1 -> 1.2 -> 1.3 -> 1.4 -> 1.5) +// +// One declaration per record type registered via defineOp(...) and drained from +// the runtime OP_REGISTRY. Every payload declaration carries its record type in +// a `_name` field. Payload sketches use TypeScript type syntax; when a +// named type is expanded inline, its name appears as a doc comment +// (`/** ContextMessage */`). Bare type names (ContentPart, ContextMessage, …) +// refer to the real types in src/ — they are intentionally not resolved here. +// `// …` marks a capped field list. On disk (wire.jsonl) the journal opens with +// a metadata line {"type": "metadata", "protocol_version", "created_at"}; each +// op record is {"type", ...payload, "time"} — object payloads spread at the +// top level, scalar payloads nest under a "payload" key. +// +// Declaration flags: persisted (written to the journal; absent = transient), +// toEvent (also publishes an IEventBus fact on live dispatch), blobs (the +// owning model offloads inline media to blob storage), cross-reducers +// (foreign models that also reduce this record on dispatch and replay). + +// Index (44 record types) +// config.update profile persisted src/agent/profile/profileOps.ts +// context_size.measured contextSize transient src/agent/contextSize/contextSizeOps.ts +// context.append_loop_event contextMemory persisted src/agent/contextMemory/contextOps.ts +// context.append_message contextMemory persisted src/agent/contextMemory/contextOps.ts +// context.apply_compaction contextMemory persisted src/agent/contextMemory/contextOps.ts +// context.clear contextMemory persisted src/agent/contextMemory/contextOps.ts +// context.undo contextMemory persisted src/agent/contextMemory/contextOps.ts +// cron.add cron transient src/session/cron/cronOps.ts +// cron.cursor cron transient src/session/cron/cronOps.ts +// cron.delete cron transient src/session/cron/cronOps.ts +// forked goal persisted src/agent/goal/goalOps.ts +// full_compaction.begin fullCompaction persisted src/agent/fullCompaction/compactionOps.ts +// full_compaction.cancel fullCompaction persisted src/agent/fullCompaction/compactionOps.ts +// full_compaction.complete fullCompaction persisted src/agent/fullCompaction/compactionOps.ts +// goal.clear goal persisted src/agent/goal/goalOps.ts +// goal.create goal persisted src/agent/goal/goalOps.ts +// goal.update goal persisted src/agent/goal/goalOps.ts +// interaction.request interaction persisted src/session/interaction/interactionOps.ts +// interaction.resolved interaction persisted src/session/interaction/interactionOps.ts +// llm.request llm.requestTrace persisted src/agent/llmRequester/llmRequestOps.ts +// llm.tools_snapshot llm.requestTrace persisted src/agent/llmRequester/llmRequestOps.ts +// mcp.tools_discovered mcp.discovery persisted src/agent/mcp/mcpDiscoveryOps.ts +// permission.record_approval_result permissionRules persisted src/agent/permissionRules/permissionRulesOps.ts +// permission.rules.add permissionRules transient src/agent/permissionRules/permissionRulesOps.ts +// permission.set_mode permissionMode persisted src/agent/permissionMode/permissionModeOps.ts +// plan_mode.cancel plan persisted src/agent/plan/planOps.ts +// plan_mode.enter plan persisted src/agent/plan/planOps.ts +// plan_mode.exit plan persisted src/agent/plan/planOps.ts +// plan.revision plan persisted src/agent/plan/planOps.ts +// profile.bind profile persisted src/agent/profile/profileOps.ts +// skill.activate skill transient src/agent/skill/skillOps.ts +// swarm_mode.enter swarm persisted src/agent/swarm/swarmOps.ts +// swarm_mode.exit swarm persisted src/agent/swarm/swarmOps.ts +// task.started task persisted src/agent/task/taskOps.ts +// task.terminated task persisted src/agent/task/taskOps.ts +// tools.register_user_tool userTool persisted src/agent/userTool/userToolOps.ts +// tools.reset_active_tools profile.activeTools persisted src/agent/profile/profileOps.ts +// tools.set_active_tools profile.activeTools persisted src/agent/profile/profileOps.ts +// tools.unregister_user_tool userTool persisted src/agent/userTool/userToolOps.ts +// tools.update_store todo persisted src/session/todo/todoOps.ts +// turn.cancel turn persisted src/agent/loop/turnOps.ts +// turn.prompt turn persisted src/agent/loop/turnOps.ts +// turn.steer turn persisted src/agent/loop/turnOps.ts +// usage.record usage persisted src/agent/usage/usageOps.ts + +/** + * model: profile · persisted + * owner: src/agent/profile/profileOps.ts + */ +interface ConfigUpdatePayload { + _name: 'config.update'; + cwd?: string; + modelAlias?: string; + profileName?: string; + /** ThinkingEffort */ + thinkingEffort?: 'off' | 'on' | (string & {}); + /** ThinkingEffort */ + thinkingLevel?: 'off' | 'on' | (string & {}); + systemPrompt?: string; + disallowedTools?: string[]; +} + +/** + * model: contextSize · toEvent + * owner: src/agent/contextSize/contextSizeOps.ts + */ +interface ContextSizeMeasuredPayload { + _name: 'context_size.measured'; + length: number; + tokens: number; +} + +/** + * model: contextMemory · persisted · blobs · cross-reducers: turn + * owner: src/agent/contextMemory/contextOps.ts + */ +interface ContextAppendLoopEventPayload { + _name: 'context.append_loop_event'; + /** LoopRecordedEvent */ + event: 'step.begin' | 'step.end' | 'content.part' | 'tool.call' | 'tool.result'; +} + +/** + * model: contextMemory · persisted · blobs · cross-reducers: goalForkNotice, task.notificationDelivery + * owner: src/agent/contextMemory/contextOps.ts + */ +interface ContextAppendMessagePayload { + _name: 'context.append_message'; + /** ContextMessage */ + message: { + role: 'system' | 'user' | 'assistant' | 'tool'; + name?: string; + content: ('text' | 'think' | 'image_url' | 'audio_url' | 'video_url')[]; + toolCalls: { + type: 'function'; + id: string; + name: string; + arguments: string | null; + extras?: Record; + _streamIndex?: number | string; + }[]; + toolCallId?: string; + partial?: boolean; + tools?: { + name: string; + description: string; + parameters: Record; + deferred?: true; + }[]; + id?: string; + providerMessageId?: string; + origin?: 'user' | 'skill_activation' | 'plugin_command' | 'injection' | 'shell_command' | 'compaction_summary' | 'system_trigger' | 'task' | 'cron_job' | 'cron_missed' | 'hook_result' | 'retry' | undefined; + isError?: boolean; + note?: string; + }; +} + +/** + * model: contextMemory · persisted · blobs + * owner: src/agent/contextMemory/contextOps.ts + * shared base: ...contextCompactionBaseShape + */ +type ContextApplyCompactionPayload = { _name: 'context.apply_compaction'; } & ({ summary: string, compactedCount: number, contextSummary?: string } | { contextSummary: string, compactedCount: number, summary?: string } | { summary: ContextMessage, count: number, compactedCount?: number }); + +/** + * model: contextMemory · persisted · blobs + * owner: src/agent/contextMemory/contextOps.ts + */ +interface ContextClearPayload { + _name: 'context.clear'; +} + +/** + * model: contextMemory · persisted · blobs + * owner: src/agent/contextMemory/contextOps.ts + */ +interface ContextUndoPayload { + _name: 'context.undo'; + count: number; +} + +/** + * model: cron + * owner: src/session/cron/cronOps.ts + */ +interface CronAddPayload { + _name: 'cron.add'; + /** CronTask */ + task: { + id: string; + cron: string; + prompt: string; + createdAt: number; + recurring?: boolean; + lastFiredAt?: number; + tags?: Readonly>; + }; +} + +/** + * model: cron + * owner: src/session/cron/cronOps.ts + */ +interface CronCursorPayload { + _name: 'cron.cursor'; + id: string; + lastFiredAt: number; +} + +/** + * model: cron + * owner: src/session/cron/cronOps.ts + */ +interface CronDeletePayload { + _name: 'cron.delete'; + ids: string[]; +} + +/** + * model: goal · persisted · cross-reducers: goalForkNotice + * owner: src/agent/goal/goalOps.ts + */ +interface ForkedPayload { + _name: 'forked'; +} + +/** + * model: fullCompaction · persisted · toEvent + * owner: src/agent/fullCompaction/compactionOps.ts + * payload type: CompactionBeginData + */ +interface FullCompactionBeginPayload { + _name: 'full_compaction.begin'; + instruction?: string; + source: 'manual' | 'auto'; +} + +/** + * model: fullCompaction · persisted + * owner: src/agent/fullCompaction/compactionOps.ts + */ +interface FullCompactionCancelPayload { + _name: 'full_compaction.cancel'; +} + +/** + * model: fullCompaction · persisted + * owner: src/agent/fullCompaction/compactionOps.ts + */ +interface FullCompactionCompletePayload { + _name: 'full_compaction.complete'; +} + +/** + * model: goal · persisted · cross-reducers: goalForkNotice + * owner: src/agent/goal/goalOps.ts + */ +interface GoalClearPayload { + _name: 'goal.clear'; +} + +/** + * model: goal · persisted · cross-reducers: goalForkNotice + * owner: src/agent/goal/goalOps.ts + */ +interface GoalCreatePayload { + _name: 'goal.create'; + goalId: string; + objective: string; + completionCriterion?: string; + wallClockResumedAt?: number; + status?: 'active' | 'paused' | 'blocked' | 'complete'; + actor?: 'user' | 'model' | 'runtime' | 'system'; + budgetLimits?: { + tokenBudget?: number; + turnBudget?: number; + wallClockBudgetMs?: number; + }; +} + +/** + * model: goal · persisted + * owner: src/agent/goal/goalOps.ts + */ +interface GoalUpdatePayload { + _name: 'goal.update'; + goalId?: string; + status?: 'active' | 'paused' | 'blocked' | 'complete'; + reason?: string; + turnsUsed?: number; + tokensUsed?: number; + wallClockMs?: number; + wallClockResumedAt?: number; + budgetLimits?: { + tokenBudget?: number; + turnBudget?: number; + wallClockBudgetMs?: number; + }; + actor?: 'user' | 'model' | 'runtime' | 'system'; +} + +/** + * model: interaction · persisted + * owner: src/session/interaction/interactionOps.ts + */ +interface InteractionRequestPayload { + _name: 'interaction.request'; + id: string; + kind: 'approval' | 'question' | 'user_tool'; + toolCallId?: string; + agentId?: string; + request: any; +} + +/** + * model: interaction · persisted + * owner: src/session/interaction/interactionOps.ts + */ +interface InteractionResolvedPayload { + _name: 'interaction.resolved'; + id: string; + response: any; +} + +/** + * model: llm.requestTrace · persisted + * owner: src/agent/llmRequester/llmRequestOps.ts + */ +interface LlmRequestPayload { + _name: 'llm.request'; + kind: 'loop' | 'compaction'; + provider: string; + model: string; + modelAlias?: string; + /** ThinkingEffort */ + thinkingEffort?: 'off' | 'on' | (string & {}); + thinkingKeep?: string; + temperature?: number; + topP?: number; + maxTokens?: number; + betaApi?: boolean; + toolSelect: boolean; + systemPromptHash: string; + systemPrompt?: string; + toolsHash: string; + messageCount: number; + turnStep?: string; + attempt?: string; + projection?: 'strict' | 'media-degraded' | 'media-stripped'; + droppedCount?: number; +} + +/** + * model: llm.requestTrace · persisted + * owner: src/agent/llmRequester/llmRequestOps.ts + */ +interface LlmToolsSnapshotPayload { + _name: 'llm.tools_snapshot'; + hash: string; + tools: { + name: string; + description: string; + parameters: Record; + }[]; +} + +/** + * model: mcp.discovery · persisted + * owner: src/agent/mcp/mcpDiscoveryOps.ts + */ +interface McpToolsDiscoveredPayload { + _name: 'mcp.tools_discovered'; + serverName: string; + hash: string; + tools: readonly MCPToolDefinition[]; + enabledNames: string[]; + collisions?: { + qualified: string; + toolName: string; + collidesWith: { kind: 'same_server', toolName: string } | { kind: 'other_server', serverName: string }; + }[]; +} + +/** + * model: permissionRules · persisted + * owner: src/agent/permissionRules/permissionRulesOps.ts + * payload type: PermissionApprovalResultRecord + */ +interface PermissionRecordApprovalResultPayload { + _name: 'permission.record_approval_result'; + turnId: number; + toolCallId: string; + toolName: string; + action: string; + sessionApprovalRule?: string; + result: ApprovalResponse; +} + +/** + * model: permissionRules + * owner: src/agent/permissionRules/permissionRulesOps.ts + */ +interface PermissionRulesAddPayload { + _name: 'permission.rules.add'; + rules: readonly PermissionRule[]; +} + +/** + * model: permissionMode · persisted · cross-reducers: permissionMode.configured + * owner: src/agent/permissionMode/permissionModeOps.ts + */ +interface PermissionSetModePayload { + _name: 'permission.set_mode'; + /** PermissionMode */ + mode: 'manual' | 'yolo' | 'auto'; +} + +/** + * model: plan · persisted · toEvent + * owner: src/agent/plan/planOps.ts + */ +interface PlanModeCancelPayload { + _name: 'plan_mode.cancel'; + id?: string; +} + +/** + * model: plan · persisted · toEvent + * owner: src/agent/plan/planOps.ts + */ +interface PlanModeEnterPayload { + _name: 'plan_mode.enter'; + id: string; +} + +/** + * model: plan · persisted · toEvent + * owner: src/agent/plan/planOps.ts + */ +interface PlanModeExitPayload { + _name: 'plan_mode.exit'; + id?: string; +} + +/** + * model: plan · persisted · toEvent + * owner: src/agent/plan/planOps.ts + */ +interface PlanRevisionPayload { + _name: 'plan.revision'; + id: string; + version: number; + path: string; + sha256: string; + bytes: number; +} + +/** + * model: profile · persisted · cross-reducers: profile.activeTools + * owner: src/agent/profile/profileOps.ts + */ +interface ProfileBindPayload { + _name: 'profile.bind'; + cwd?: string; + modelAlias?: string; + profileName?: string; + /** ThinkingEffort */ + thinkingEffort: 'off' | 'on' | (string & {}); + systemPrompt: string; + activeToolNames?: string[]; + disallowedTools: string[]; + subagents?: string[]; +} + +/** + * model: skill · toEvent + * owner: src/agent/skill/skillOps.ts + */ +interface SkillActivatePayload { + _name: 'skill.activate'; + /** SkillActivationOrigin */ + origin: { + kind: 'skill_activation'; + activationId: string; + skillName: string; + skillArgs?: string | undefined; + trigger: 'user-slash' | 'model-tool' | 'nested-skill'; + skillType?: string | undefined; + skillPath?: string | undefined; + skillSource?: 'project' | 'user' | 'extra' | 'builtin' | undefined; + }; +} + +/** + * model: swarm · persisted · toEvent + * owner: src/agent/swarm/swarmOps.ts + */ +interface SwarmModeEnterPayload { + _name: 'swarm_mode.enter'; + /** SwarmModeTrigger */ + trigger: 'manual' | 'task' | 'tool'; +} + +/** + * model: swarm · persisted · toEvent · cross-reducers: contextMemory + * owner: src/agent/swarm/swarmOps.ts + */ +interface SwarmModeExitPayload { + _name: 'swarm_mode.exit'; +} + +/** + * model: task · persisted · toEvent + * owner: src/agent/task/taskOps.ts + */ +interface TaskStartedPayload { + _name: 'task.started'; + /** AgentTaskInfo */ + info: AgentTaskInfoByKind[AgentTaskKind]; +} + +/** + * model: task · persisted · toEvent + * owner: src/agent/task/taskOps.ts + */ +interface TaskTerminatedPayload { + _name: 'task.terminated'; + /** AgentTaskInfo */ + info: AgentTaskInfoByKind[AgentTaskKind]; + outputTail?: string; +} + +/** + * model: userTool · persisted + * owner: src/agent/userTool/userToolOps.ts + * payload type: UserToolRegistration + */ +interface ToolsRegisterUserToolPayload { + _name: 'tools.register_user_tool'; + name: string; + description: string; + parameters: Record; +} + +/** + * model: profile.activeTools · persisted + * owner: src/agent/profile/profileOps.ts + */ +interface ToolsResetActiveToolsPayload { + _name: 'tools.reset_active_tools'; +} + +/** + * model: profile.activeTools · persisted + * owner: src/agent/profile/profileOps.ts + */ +interface ToolsSetActiveToolsPayload { + _name: 'tools.set_active_tools'; + names: string[]; +} + +/** + * model: userTool · persisted + * owner: src/agent/userTool/userToolOps.ts + */ +interface ToolsUnregisterUserToolPayload { + _name: 'tools.unregister_user_tool'; + name: string; +} + +/** + * model: todo · persisted + * owner: src/session/todo/todoOps.ts + */ +interface ToolsUpdateStorePayload { + _name: 'tools.update_store'; + key: string; + value: any; +} + +/** + * model: turn · persisted + * owner: src/agent/loop/turnOps.ts + */ +interface TurnCancelPayload { + _name: 'turn.cancel'; + turnId?: number; +} + +/** + * model: turn · persisted + * owner: src/agent/loop/turnOps.ts + */ +interface TurnPromptPayload { + _name: 'turn.prompt'; + input: readonly ContentPart[]; + /** PromptOrigin */ + origin: 'user' | 'skill_activation' | 'plugin_command' | 'injection' | 'shell_command' | 'compaction_summary' | 'system_trigger' | 'task' | 'cron_job' | 'cron_missed' | 'hook_result' | 'retry'; +} + +/** + * model: turn · persisted + * owner: src/agent/loop/turnOps.ts + */ +interface TurnSteerPayload { + _name: 'turn.steer'; + input: readonly ContentPart[]; + /** PromptOrigin */ + origin: 'user' | 'skill_activation' | 'plugin_command' | 'injection' | 'shell_command' | 'compaction_summary' | 'system_trigger' | 'task' | 'cron_job' | 'cron_missed' | 'hook_result' | 'retry'; +} + +/** + * model: usage · persisted + * owner: src/agent/usage/usageOps.ts + */ +interface UsageRecordPayload { + _name: 'usage.record'; + model: string; + /** TokenUsage */ + usage: { + inputOther: number; + output: number; + inputCacheRead: number; + inputCacheCreation: number; + }; + /** UsageRecordScope */ + usageScope?: 'session' | 'turn'; +} + +/** Record type → payload sketch. */ +interface WirePayloadMap { + "config.update": ConfigUpdatePayload; + "context_size.measured": ContextSizeMeasuredPayload; + "context.append_loop_event": ContextAppendLoopEventPayload; + "context.append_message": ContextAppendMessagePayload; + "context.apply_compaction": ContextApplyCompactionPayload; + "context.clear": ContextClearPayload; + "context.undo": ContextUndoPayload; + "cron.add": CronAddPayload; + "cron.cursor": CronCursorPayload; + "cron.delete": CronDeletePayload; + "forked": ForkedPayload; + "full_compaction.begin": FullCompactionBeginPayload; + "full_compaction.cancel": FullCompactionCancelPayload; + "full_compaction.complete": FullCompactionCompletePayload; + "goal.clear": GoalClearPayload; + "goal.create": GoalCreatePayload; + "goal.update": GoalUpdatePayload; + "interaction.request": InteractionRequestPayload; + "interaction.resolved": InteractionResolvedPayload; + "llm.request": LlmRequestPayload; + "llm.tools_snapshot": LlmToolsSnapshotPayload; + "mcp.tools_discovered": McpToolsDiscoveredPayload; + "permission.record_approval_result": PermissionRecordApprovalResultPayload; + "permission.rules.add": PermissionRulesAddPayload; + "permission.set_mode": PermissionSetModePayload; + "plan_mode.cancel": PlanModeCancelPayload; + "plan_mode.enter": PlanModeEnterPayload; + "plan_mode.exit": PlanModeExitPayload; + "plan.revision": PlanRevisionPayload; + "profile.bind": ProfileBindPayload; + "skill.activate": SkillActivatePayload; + "swarm_mode.enter": SwarmModeEnterPayload; + "swarm_mode.exit": SwarmModeExitPayload; + "task.started": TaskStartedPayload; + "task.terminated": TaskTerminatedPayload; + "tools.register_user_tool": ToolsRegisterUserToolPayload; + "tools.reset_active_tools": ToolsResetActiveToolsPayload; + "tools.set_active_tools": ToolsSetActiveToolsPayload; + "tools.unregister_user_tool": ToolsUnregisterUserToolPayload; + "tools.update_store": ToolsUpdateStorePayload; + "turn.cancel": TurnCancelPayload; + "turn.prompt": TurnPromptPayload; + "turn.steer": TurnSteerPayload; + "usage.record": UsageRecordPayload; +} diff --git a/packages/agent-core-v2/package.json b/packages/agent-core-v2/package.json index 2825a4be1..1b14da36c 100644 --- a/packages/agent-core-v2/package.json +++ b/packages/agent-core-v2/package.json @@ -48,6 +48,8 @@ "test": "vitest run", "typecheck": "tsc -p tsconfig.json --noEmit", "gen:contract-types": "node scripts/gen-contract-types.mjs", + "gen:config-manifest": "tsx --import ../../build/register-raw-text-loader.mjs scripts/gen-config-manifest.mts", + "gen:wire-manifest": "tsx --import ../../build/register-raw-text-loader.mjs scripts/gen-wire-manifest.mts", "lint:domain": "node scripts/check-domain-layers.mjs", "clean": "rm -rf dist", "dep-graph:analyze": "tsx scripts/dep-graph/cli.ts", diff --git a/packages/agent-core-v2/scripts/gen-config-manifest.mts b/packages/agent-core-v2/scripts/gen-config-manifest.mts new file mode 100644 index 000000000..01662774d --- /dev/null +++ b/packages/agent-core-v2/scripts/gen-config-manifest.mts @@ -0,0 +1,367 @@ +/** + * Generates `docs/config-manifest.toml` — the single place to see every config + * section registered via `registerConfigSection(...)` plus every effective + * overlay registered via `registerConfigOverlay(...)`. + * + * Two passes: + * 1. Static scan of `src/**` maps each registered section domain (and each + * overlay) to the source file that registers it — the "owner". + * 2. Runtime pass imports `src/index.ts` ("import = register") and drains the + * module-level contributions, capturing defaults, env bindings, and the + * registered hooks exactly as the running process sees them. + * + * The output is TOML in the on-disk shape (snake_case keys): one `[table]` per + * section, uncommented assignments for registered defaults, and commented + * `# field: type` lines for the remaining schema fields. + * + * Usage: + * pnpm --filter @moonshot-ai/agent-core-v2 gen:config-manifest # write the file + * pnpm --filter @moonshot-ai/agent-core-v2 gen:config-manifest --check # freshness check (CI-style) + * + * Freshness is also enforced by `test/app/config/configManifest.test.ts`. + */ + +import { readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs'; +import { join, relative } from 'node:path'; +import { pathToFileURL } from 'node:url'; + +import { getConfigOverlayContributions } from '#/app/config/configOverlayContributions'; +import type { ConfigSectionContribution } from '#/app/config/configSectionContributions'; +import { getConfigSectionContributions } from '#/app/config/configSectionContributions'; +import { camelToSnake } from '#/app/config/toml'; + +import { + asJsonSchema, + describeType, + isRecord, + resolveRef, + toJsonSchema, + truncate, + type JsonSchema, +} from './lib/jsonSchema.mts'; + +const PKG = join(import.meta.dirname, '..'); +const SRC = join(PKG, 'src'); +export const MANIFEST_PATH = join(PKG, 'docs', 'config-manifest.toml'); + +// --------------------------------------------------------------------------- +// Static pass — domain/overlay → owner file +// --------------------------------------------------------------------------- + +function walk(dir: string, out: string[] = []): string[] { + for (const entry of readdirSync(dir)) { + const p = join(dir, entry); + if (statSync(p).isDirectory()) walk(p, out); + else if (entry.endsWith('.ts')) out.push(p); + } + return out; +} + +function constStringValue(source: string, ident: string): string | undefined { + const re = new RegExp(`(?:export\\s+)?const\\s+${ident}\\s*(?::[^=;]+)?=\\s*'([^']+)'`); + return re.exec(source)?.[1]; +} + +/** domain key → owner file (relative to the package root). */ +function scanSectionOwners(): Map { + const owners = new Map(); + for (const file of walk(SRC)) { + const source = readFileSync(file, 'utf-8'); + if (!source.includes('registerConfigSection(')) continue; + for (const match of source.matchAll(/registerConfigSection\(\s*(?:'([^']+)'|([A-Za-z0-9_$]+))/g)) { + const ident = match[2]; + const domain = match[1] ?? (ident === undefined ? undefined : constStringValue(source, ident)); + if (domain !== undefined) owners.set(domain, relative(PKG, file)); + } + } + return owners; +} + +/** overlay variable name → owner file (relative to the package root). */ +function scanOverlayOwners(): Map { + const owners = new Map(); + for (const file of walk(SRC)) { + // Skip the collector module itself — its `registerConfigOverlay(overlay)` + // function signature is not a registration. + if (file.endsWith('configOverlayContributions.ts')) continue; + const source = readFileSync(file, 'utf-8'); + if (!source.includes('registerConfigOverlay(')) continue; + for (const match of source.matchAll(/registerConfigOverlay\(\s*([A-Za-z0-9_$]+)/g)) { + const ident = match[1]; + if (ident !== undefined) owners.set(ident, relative(PKG, file)); + } + } + return owners; +} + +// --------------------------------------------------------------------------- +// TOML-like rendering helpers +// --------------------------------------------------------------------------- + +/** Serialize a small JSON value as an inline TOML value. */ +function toTomlValue(value: unknown): string { + if (typeof value === 'string') return JSON.stringify(value); + if (typeof value === 'number' || typeof value === 'boolean') return String(value); + if (Array.isArray(value)) return `[${value.map(toTomlValue).join(', ')}]`; + if (isRecord(value)) { + const entries = Object.entries(value).map(([k, v]) => `${camelToSnake(k)} = ${toTomlValue(v)}`); + return `{ ${entries.join(', ')} }`; + } + return '""'; +} + +interface EnvRow { + readonly field: string; + readonly env: string; + readonly detail: string; +} + +/** Property access shape of an `EnvBinding` object (avoids index-signature access). */ +interface EnvBindingFields { + readonly env?: unknown; + readonly parse?: unknown; + readonly default?: unknown; +} + +function flattenEnvBindings(bindings: unknown, path: string[] = []): EnvRow[] { + if (typeof bindings === 'string') { + return [{ field: path.join('.'), env: bindings, detail: '' }]; + } + if (!isRecord(bindings)) return []; + const binding = bindings as EnvBindingFields; + if (typeof binding.env === 'string') { + const detail: string[] = []; + if (binding.parse !== undefined) detail.push('custom parse'); + if (binding.default !== undefined) detail.push(`default ${JSON.stringify(binding.default)}`); + return [{ field: path.join('.'), env: binding.env, detail: detail.join('; ') }]; + } + return Object.entries(bindings).flatMap(([key, value]) => flattenEnvBindings(value, [...path, key])); +} + +function snakePath(field: string): string { + return field.split('.').map(camelToSnake).join('.'); +} + +const RULE = `# ${'#'.repeat(74)}`; + +// --------------------------------------------------------------------------- +// Section rendering +// --------------------------------------------------------------------------- + +/** `# field: type (default: x)` comment lines for an object schema's properties. */ +function renderFieldComments( + properties: Record, + root: JsonSchema, + indent: string, + depth = 0, +): string[] { + const lines: string[] = []; + for (const [name, prop] of Object.entries(properties)) { + const resolved = resolveRef(prop, root); + const propDefault = asJsonSchema(resolved)?.default; + const defNote = propDefault !== undefined ? ` (default: ${JSON.stringify(propDefault)})` : ''; + lines.push(`${indent}# ${camelToSnake(name)}: ${describeType(resolved)}${defNote}`); + // Expand nested object fields one level at a time (depth-capped so a + // recursive $ref cannot loop). + const subProps = asJsonSchema(resolved)?.properties; + if (depth < 3 && isRecord(subProps) && Object.keys(subProps).length > 0) { + lines.push(...renderFieldComments(subProps, root, `${indent} `, depth + 1)); + } + } + return lines; +} + +function renderBody(section: ConfigSectionContribution): string[] { + const { domain, schema, options } = section; + const key = camelToSnake(domain); + const jsonSchema = schema === undefined ? undefined : toJsonSchema(schema); + + if (jsonSchema === undefined) { + // No schema (passthrough) or a schema that JSON Schema cannot represent. + if (isRecord(options.defaultValue)) { + return [ + `[${key}]`, + `# (${schema === undefined ? 'no schema — passthrough' : 'schema uses transforms'}; fields below come from the registered default)`, + ...Object.entries(options.defaultValue).map( + ([k, v]) => `${camelToSnake(k)} = ${truncate(toTomlValue(v))}`, + ), + ]; + } + if (options.defaultValue !== undefined) { + return [`${key} = ${truncate(toTomlValue(options.defaultValue))}`]; + } + return [`[${key}]`, `# (${schema === undefined ? 'no schema — passthrough' : 'schema uses transforms; see the owner file'})`]; + } + + // Object with named fields. + if (isRecord(jsonSchema.properties) && Object.keys(jsonSchema.properties).length > 0) { + const defaults = isRecord(options.defaultValue) ? options.defaultValue : {}; + const lines = [`[${key}]`]; + for (const [name, prop] of Object.entries(jsonSchema.properties)) { + const fieldKey = camelToSnake(name); + if (defaults[name] !== undefined) { + lines.push(`${fieldKey} = ${truncate(toTomlValue(defaults[name]))}`); + continue; + } + // A nested object field is an on-disk sub-table (`[section.field]`) — + // render its own fields instead of a flat `field: object` comment. + const resolved = resolveRef(prop, jsonSchema); + const subProps = asJsonSchema(resolved)?.properties; + if (isRecord(subProps) && Object.keys(subProps).length > 0) { + lines.push(''); + lines.push(`# [${key}.${fieldKey}]`); + lines.push(...renderFieldComments(subProps, jsonSchema, ' ')); + continue; + } + // An array-of-objects field carries its element fields inline. + const itemProps = asJsonSchema( + resolveRef(asJsonSchema(resolved)?.items, jsonSchema), + )?.properties; + if (isRecord(itemProps) && Object.keys(itemProps).length > 0) { + lines.push(`# ${fieldKey}: object[] — one entry per item:`); + lines.push(...renderFieldComments(itemProps, jsonSchema, ' ')); + continue; + } + lines.push(...renderFieldComments({ [name]: prop }, jsonSchema, '')); + } + return lines; + } + + // Record section — one sub-table per entry. + if (jsonSchema.additionalProperties !== undefined) { + const valueSchema = resolveRef(jsonSchema.additionalProperties, jsonSchema); + const valueProps = asJsonSchema(valueSchema)?.properties; + const lines = [`[${key}]`]; + if (isRecord(valueProps) && Object.keys(valueProps).length > 0) { + lines.push(''); + lines.push(`# one [${key}.""] table per entry:`); + lines.push(`# [${key}.""]`); + lines.push(...renderFieldComments(valueProps, jsonSchema, ' ')); + } else { + lines.push(`# : ${describeType(valueSchema)}`); + } + return lines; + } + + // Array-of-tables section — one `[[section]]` entry per element. There is + // no `[section]` parent table in TOML, so the whole shape stays commented; + // emitting a bare `[${key}]` header would parse as a plain table, which + // array sections (e.g. `hooks`) reject on load. + if (jsonSchema.type === 'array') { + const itemProps = asJsonSchema(resolveRef(jsonSchema.items, jsonSchema))?.properties; + if (isRecord(itemProps) && Object.keys(itemProps).length > 0) { + return [ + `# one [[${key}]] table per entry:`, + `# [[${key}]]`, + ...renderFieldComments(itemProps, jsonSchema, ' '), + ]; + } + } + + // Scalar / array section — a plain top-level key. + if (options.defaultValue !== undefined) { + return [`${key} = ${truncate(toTomlValue(options.defaultValue))}`]; + } + return [`# ${key}: ${describeType(jsonSchema)}`]; +} + +function renderSection(section: ConfigSectionContribution, owner: string | undefined): string[] { + const { domain, options } = section; + const key = camelToSnake(domain); + const lines: string[] = [RULE]; + lines.push(`# ${domain}${key === domain ? '' : ` (config.toml: ${key})`}`); + lines.push(`# owner: ${owner ?? '(unresolved)'}`); + lines.push(`# scope: ${options.scope ?? 'core'}`); + const hooks: string[] = []; + if (options.merge !== undefined) hooks.push('custom merge'); + if (options.fromToml !== undefined) hooks.push('custom fromToml'); + if (options.toToml !== undefined) hooks.push('custom toToml'); + if (options.stripEnv !== undefined) hooks.push('stripEnv'); + if (hooks.length > 0) lines.push(`# hooks: ${hooks.join(' · ')}`); + const envRows = flattenEnvBindings(options.env); + if (envRows.length > 0) { + lines.push('# env:'); + for (const row of envRows) { + lines.push(`# ${snakePath(row.field)} <- ${row.env}${row.detail === '' ? '' : ` (${row.detail})`}`); + } + } + lines.push(RULE); + lines.push(''); + lines.push(...renderBody(section)); + return lines; +} + +// --------------------------------------------------------------------------- +// Manifest rendering +// --------------------------------------------------------------------------- + +export async function buildConfigManifest(): Promise { + // "import = register": loading the package root fills the contribution bags. + await import('../src/index.ts'); + const sections = getConfigSectionContributions().toSorted((a, b) => + a.domain.localeCompare(b.domain), + ); + const overlays = getConfigOverlayContributions(); + const sectionOwners = scanSectionOwners(); + const overlayOwners = scanOverlayOwners(); + + const out: string[] = [ + '# Config Section Manifest', + '#', + '# Generated by scripts/gen-config-manifest.mts — do not edit by hand.', + '# Regenerate with: pnpm --filter @moonshot-ai/agent-core-v2 gen:config-manifest', + '#', + '# One [table] per registered config section, in the on-disk config.toml shape', + '# (snake_case keys). Un-commented assignments are registered defaults;', + '# commented "# field: type" lines describe the remaining schema fields.', + '# Values resolve as: default -> config.toml -> env overlay -> memory.', + '', + `# Index (${sections.length} sections · ${overlays.length} overlay(s))`, + ]; + const width = Math.max(...sections.map((s) => s.domain.length)); + for (const { domain } of sections) { + out.push(`# ${domain.padEnd(width)} ${sectionOwners.get(domain) ?? '(unresolved)'}`); + } + for (const [ident, file] of overlayOwners) { + out.push(`# ${'(overlay) ' + ident} ${file}`); + } + out.push(''); + + for (const section of sections) { + out.push(...renderSection(section, sectionOwners.get(section.domain))); + out.push(''); + } + return out.join('\n'); +} + +// --------------------------------------------------------------------------- +// CLI +// --------------------------------------------------------------------------- + +async function main(): Promise { + const check = process.argv.includes('--check'); + const manifest = await buildConfigManifest(); + if (check) { + let current: string | undefined; + try { + current = readFileSync(MANIFEST_PATH, 'utf-8'); + } catch { + current = undefined; + } + if (current !== manifest) { + console.error( + `[gen-config-manifest] ${relative(process.cwd(), MANIFEST_PATH)} is stale. ` + + 'Regenerate with `pnpm --filter @moonshot-ai/agent-core-v2 gen:config-manifest`.', + ); + process.exit(1); + } + console.log('[gen-config-manifest] up to date'); + return; + } + writeFileSync(MANIFEST_PATH, manifest); + console.log(`[gen-config-manifest] wrote ${relative(process.cwd(), MANIFEST_PATH)}`); +} + +if (process.argv[1] !== undefined && import.meta.url === pathToFileURL(process.argv[1]).href) { + await main(); +} diff --git a/packages/agent-core-v2/scripts/gen-wire-manifest.mts b/packages/agent-core-v2/scripts/gen-wire-manifest.mts new file mode 100644 index 000000000..a747a064c --- /dev/null +++ b/packages/agent-core-v2/scripts/gen-wire-manifest.mts @@ -0,0 +1,973 @@ +/** + * Generates `docs/wire-manifest.d.ts` — the single place to see every wire + * record type registered via `defineOp(...)`. + * + * Two passes: + * 1. Static scan of `src/**` maps each op type to the source file that + * defines it — the "owner" — and collects the migration chain from + * `src/wire/migration/v*.ts`. + * 2. Runtime pass imports `src/index.ts` plus every op module found in the + * static pass ("import = register") and drains `OP_REGISTRY`, capturing + * the owning model, the persist policy, `toEvent`, and the payload schema + * exactly as the running process sees them. + * + * The output is a `.d.ts` — one payload declaration per record type, with a + * `WirePayloadMap` from record type to declaration — using real TypeScript + * type syntax for the sketches. + * + * Usage: + * pnpm --filter @moonshot-ai/agent-core-v2 gen:wire-manifest # write the file + * pnpm --filter @moonshot-ai/agent-core-v2 gen:wire-manifest --check # freshness check (CI-style) + * + * Freshness is also enforced by `test/wire/wireManifest.test.ts`. + */ + +import { existsSync, readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs'; +import { dirname, join, relative } from 'node:path'; +import { pathToFileURL } from 'node:url'; + +import { MODEL_CROSS_REDUCERS } from '#/wire/model'; +import { OP_REGISTRY } from '#/wire/op'; + +import { + asJsonSchema, + describeType, + isRecord, + resolveRef, + toJsonSchema, + truncate, + type JsonSchema, +} from './lib/jsonSchema.mts'; + +const PKG = join(import.meta.dirname, '..'); +const SRC = join(PKG, 'src'); +export const MANIFEST_PATH = join(PKG, 'docs', 'wire-manifest.d.ts'); + +// --------------------------------------------------------------------------- +// Static pass — op type → owner file; migration chain +// --------------------------------------------------------------------------- + +function walk(dir: string, out: string[] = []): string[] { + for (const entry of readdirSync(dir)) { + const p = join(dir, entry); + if (statSync(p).isDirectory()) walk(p, out); + else if (entry.endsWith('.ts')) out.push(p); + } + return out; +} + +/** op type → owner file (relative to the package root). */ +function scanOpOwners(): { owners: Map; opFiles: string[] } { + const owners = new Map(); + const opFiles: string[] = []; + for (const file of walk(SRC)) { + const source = readFileSync(file, 'utf-8'); + if (!source.includes('defineOp(')) continue; + const matches = [...source.matchAll(/defineOp\(\s*'([^']+)'/g)]; + if (matches.length === 0) continue; + opFiles.push(file); + for (const match of matches) { + const type = match[1]; + if (type !== undefined) owners.set(type, relative(PKG, file)); + } + } + return { owners, opFiles }; +} + +/** `1.0 -> 1.1 -> ...` chain read from the `src/wire/migration/v*.ts` files. */ +function scanMigrationChain(): string { + const dir = join(SRC, 'wire', 'migration'); + const pairs: { source: string; target: string }[] = []; + for (const entry of readdirSync(dir)) { + if (!/^v[\d.]+\.ts$/.test(entry)) continue; + const source = readFileSync(join(dir, entry), 'utf-8'); + const sourceVersion = /sourceVersion:\s*'([^']+)'/.exec(source)?.[1]; + const targetVersion = /targetVersion:\s*'([^']+)'/.exec(source)?.[1]; + if (sourceVersion !== undefined && targetVersion !== undefined) { + pairs.push({ source: sourceVersion, target: targetVersion }); + } + } + pairs.sort((a, b) => a.source.localeCompare(b.source, undefined, { numeric: true })); + const chain = pairs.flatMap((p, i) => (i === 0 ? [p.source, p.target] : [p.target])); + return chain.join(' -> '); +} + +// --------------------------------------------------------------------------- +// Payload sketch +// +// A Sketch is a small tree: strings are one-line type annotations, dicts are +// object shapes, and a one-element array marks an array-of shape. The d.ts +// renderer below turns the tree into real TypeScript syntax. +// --------------------------------------------------------------------------- + +type SketchDict = { [key: string]: Sketch }; +type Sketch = string | SketchDict | [Sketch]; + +/** First key of a dict produced by expanding a named type. */ +const TYPE_KEY = '_type'; +/** Marker key rendered as a `// …` comment when a field list is capped. */ +const MORE_KEY = '…'; + +/** Compact one-line rendering of a Sketch (used inside unions/intersections). */ +function stringifySketch(sketch: Sketch): string { + if (typeof sketch === 'string') return sketch; + if (Array.isArray(sketch)) { + const inner = stringifySketch(sketch[0]); + return inner.includes('|') ? `(${inner})[]` : `${inner}[]`; + } + return `{ ${Object.entries(sketch) + .map(([k, v]) => `${k}: ${stringifySketch(v)}`) + .join(', ')} }`; +} + +/** Build a Sketch tree from a zod JSON-schema projection. */ +function sketchFromJsonSchema(schema: unknown, root: JsonSchema, depth: number): Sketch { + const resolved = resolveRef(schema, root); + const s = asJsonSchema(resolved); + if (s !== undefined && depth < 4) { + if (isRecord(s.properties) && Object.keys(s.properties).length > 0) { + const required = new Set(Array.isArray(s.required) ? s.required : []); + const dict: SketchDict = {}; + for (const [name, prop] of Object.entries(s.properties)) { + dict[required.has(name) ? name : `${name}?`] = sketchFromJsonSchema(prop, root, depth + 1); + } + return dict; + } + if (s.type === 'array' && s.items !== undefined) { + const inner = sketchFromJsonSchema(s.items, root, depth + 1); + if (typeof inner !== 'string') return [inner]; + } + } + return describeType(resolved, tsQuote); +} + +/** Build the payload Sketch tree for one op (all three data paths converge). */ +function buildPayloadSketch( + schema: unknown, + staticSketch?: string | Map, +): Sketch { + const jsonSchema = toJsonSchema(schema); + if (jsonSchema === undefined) { + if (typeof staticSketch === 'string') return staticSketch; + if (staticSketch !== undefined && staticSketch.size > 0) { + return Object.fromEntries(staticSketch); + } + return '(schema uses transforms; see the owner file)'; + } + if (isRecord(jsonSchema.properties) && Object.keys(jsonSchema.properties).length > 0) { + const required = new Set(Array.isArray(jsonSchema.required) ? jsonSchema.required : []); + const dict: SketchDict = {}; + for (const [name, prop] of Object.entries(jsonSchema.properties)) { + dict[required.has(name) ? name : `${name}?`] = sketchFromJsonSchema(prop, jsonSchema, 0); + } + return dict; + } + // An empty object schema (`z.object({})`) is a payload-less record. + if ( + jsonSchema.type === 'object' && + (jsonSchema.additionalProperties === undefined || jsonSchema.additionalProperties === false) + ) { + return {}; + } + return describeType(jsonSchema, tsQuote); +} + +// --------------------------------------------------------------------------- +// d.ts rendering — Sketch tree → TypeScript declarations +// --------------------------------------------------------------------------- + +function pascalCase(name: string): string { + return name + .split(/[^A-Za-z0-9]+/) + .filter(Boolean) + .map((part) => (part[0] ?? '').toUpperCase() + part.slice(1)) + .join(''); +} + +function tsFieldKey(key: string): string { + return /^[$A-Z_a-z][$\w]*$/.test(key) ? key : JSON.stringify(key); +} + +/** + * Convert a one-line sketch annotation into a valid TS type expression. + * Returns the type plus an optional doc note (the expanded type's name, or a + * hoisted shared spread that cannot be expressed inline). + */ +function sketchStringToTs(text: string): { type: string; doc?: string } { + let t = text.trim(); + const docs: string[] = []; + const named = /^([A-Z][$\w]*) = ([\s\S]+)$/.exec(t); + if (named?.[1] !== undefined && named[2] !== undefined) { + docs.push(named[1]); + t = named[2].trim(); + } + // A hoisted shared spread (`...base & A | B`) becomes a doc note + variants. + const spread = /^((?:\.\.\.[$\w]+(?: \+ )?)+) & ([\s\S]+)$/.exec(t); + if (spread?.[1] !== undefined && spread[2] !== undefined) { + docs.push(`shared base: ${spread[1]}`); + t = spread[2].trim(); + } + t = t.replaceAll(/union on [$\w]+: /g, ''); + t = t.replaceAll(/\brecord 0 ? docs.join(' · ') : undefined }; +} + +/** + * Render a Sketch as TS type-expression lines. The first line continues after + * the field's `key: `; subsequent lines carry `indent`. + */ +function renderTsType(sketch: Sketch, indent: string): { doc?: string; lines: string[] } { + if (typeof sketch === 'string') { + const { type, doc } = sketchStringToTs(sketch); + return { doc, lines: [type] }; + } + if (Array.isArray(sketch)) { + const inner = renderTsType(sketch[0], indent); + const lines = [...inner.lines]; + lines[lines.length - 1] += '[]'; + return { doc: inner.doc, lines }; + } + const doc = typeof sketch[TYPE_KEY] === 'string' ? sketch[TYPE_KEY] : undefined; + const lines = ['{']; + emitTsDict(lines, sketch, indent + ' '); + lines.push(`${indent}}`); + return { doc, lines }; +} + +function emitTsDict(lines: string[], dict: SketchDict, indent: string): void { + for (const [key, sketch] of Object.entries(dict)) { + if (key === MORE_KEY) { + lines.push(`${indent}// …`); + continue; + } + if (key === TYPE_KEY) continue; // surfaces as the field's doc comment + if (key.startsWith('...')) { + lines.push(`${indent}// spread: ${key}`); + continue; + } + const optional = key.endsWith('?'); + const fieldKey = tsFieldKey(optional ? key.slice(0, -1) : key); + const { doc, lines: typeLines } = renderTsType(sketch, indent); + if (doc !== undefined) lines.push(`${indent}/** ${doc} */`); + lines.push(`${indent}${fieldKey}${optional ? '?' : ''}: ${typeLines[0]}${typeLines.length === 1 ? ';' : ''}`); + if (typeLines.length > 1) { + lines.push(...typeLines.slice(1, -1)); + lines.push(`${typeLines[typeLines.length - 1]};`); + } + } +} + +/** One record type's payload declaration (`interface` for objects, `type` otherwise). */ +function renderPayloadDecl( + entry: { type: string; model: { name: string }; persist?: boolean; toEvent?: unknown }, + owner: string | undefined, + flags: string[], + sketch: Sketch, +): string[] { + const name = `${pascalCase(entry.type)}Payload`; + const nameField = `_name: '${entry.type}';`; + const header = [ + '/**', + ` * model: ${entry.model.name}${flags.length > 0 ? ` · ${flags.join(' · ')}` : ''}`, + ` * owner: ${owner ?? '(unresolved)'}`, + ]; + if (typeof sketch === 'string') { + const { type, doc } = sketchStringToTs(sketch); + if (type.startsWith('(')) { + // Unrepresentable schema note — keep the declaration parseable. + header.push(` * ${type.slice(1, -1)}`); + header.push(' */'); + return [...header, `interface ${name} {\n ${nameField}\n}`, '']; + } + if (doc !== undefined) header.push(` * ${doc}`); + header.push(' */'); + return [...header, `type ${name} = { ${nameField} } & (${type});`, '']; + } + if (Array.isArray(sketch)) { + const inner = renderTsType(sketch[0], ' '); + const lines = [...inner.lines]; + lines[lines.length - 1] += '[]'; + header.push(' */'); + if (lines.length === 1) { + return [...header, `type ${name} = { ${nameField} } & (${lines[0]});`, '']; + } + return [ + ...header, + `type ${name} = { ${nameField} } & (${lines[0]}`, + ...lines.slice(1, -1), + `${lines[lines.length - 1]});`, + '', + ]; + } + const payloadType = typeof sketch[TYPE_KEY] === 'string' ? sketch[TYPE_KEY] : undefined; + if (payloadType !== undefined) header.push(` * payload type: ${payloadType}`); + header.push(' */'); + const lines = [...header, `interface ${name} {`, ` ${nameField}`]; + emitTsDict(lines, sketch, ' '); + lines.push('}', ''); + return lines; +} + +// --------------------------------------------------------------------------- +// Static payload fallback — sketch fields from source when the zod schema +// cannot be projected to JSON Schema (payloads using `z.custom()`) +// --------------------------------------------------------------------------- + +/** Find the index of the closer matching the opener at `start` (quotes-aware). */ +function matchDelimiter(source: string, start: number, open: string, close: string): number { + let depth = 0; + for (let i = start; i < source.length; i++) { + const ch = source[i]; + if (ch === '/' && source[i + 1] === '/') { + i = source.indexOf('\n', i); + if (i === -1) return -1; + continue; + } + if (ch === '/' && source[i + 1] === '*') { + i = source.indexOf('*/', i); + if (i === -1) return -1; + i += 1; + continue; + } + if (ch === "'" || ch === '"' || ch === '`') { + const quote = ch; + i += 1; + while (i < source.length && source[i] !== quote) { + if (source[i] === '\\') i += 1; + i += 1; + } + continue; + } + if (ch === open) depth += 1; + else if (ch === close) { + depth -= 1; + if (depth === 0) return i; + } + } + return -1; +} + +/** Split `body` into top-level parts on any of `separators` (quotes/nesting-aware). */ +function splitTopLevel(body: string, separators: readonly string[] = [',']): string[] { + const parts: string[] = []; + let depth = 0; + let partStart = 0; + const n = body.length; + for (let i = 0; i < n; i++) { + const ch = body[i]; + if (ch === "'" || ch === '"' || ch === '`') { + const quote = ch; + i += 1; + while (i < n && body[i] !== quote) { + if (body[i] === '\\') i += 1; + i += 1; + } + continue; + } + if (ch === '{' || ch === '(' || ch === '[' || ch === '<') depth += 1; + else if (ch === '}' || ch === ')' || ch === ']' || ch === '>') depth = Math.max(0, depth - 1); + else if (ch !== undefined && depth === 0 && separators.includes(ch)) { + parts.push(body.slice(partStart, i).trim()); + partStart = i + 1; + } + } + parts.push(body.slice(partStart).trim()); + return parts.filter((p) => p !== ''); +} + +/** Split an object literal's body into top-level `key: expr` fields. */ +function splitObjectFields(body: string): Map { + const fields = new Map(); + for (const part of splitTopLevel(body)) { + const keyMatch = /^([$\w]+|'[^']+'|"[^"]+")\s*:/.exec(part); + if (keyMatch?.[1] !== undefined) { + const key = keyMatch[1].replace(/^['"]|['"]$/g, ''); + fields.set(key, part.slice(keyMatch[0].length).trim()); + } else if (part.startsWith('...')) { + fields.set(part, ''); + } + } + return fields; +} + +/** Extract the body of the first balanced `{...}` in `text` starting at `braceIndex`. */ +function objectBody(text: string, braceIndex: number): string | undefined { + const end = matchDelimiter(text, braceIndex, '{', '}'); + return end === -1 ? undefined : text.slice(braceIndex + 1, end); +} + +/** Read one expression from `start` up to the top-level `;` that ends the statement. */ +function readExpression(source: string, start: number): string { + let depth = 0; + const n = source.length; + for (let i = start; i < n; i++) { + const ch = source[i]; + if (ch === "'" || ch === '"' || ch === '`') { + const quote = ch; + i += 1; + while (i < n && source[i] !== quote) { + if (source[i] === '\\') i += 1; + i += 1; + } + continue; + } + if (ch === '{' || ch === '(' || ch === '[' || ch === '<') depth += 1; + else if (ch === '}' || ch === ')' || ch === ']' || ch === '>') depth = Math.max(0, depth - 1); + else if (ch === ';' && depth === 0) return source.slice(start, i); + } + return source.slice(start); +} + +/** Quote a string literal TS-style (single quotes) so sketches need no JSON escapes. */ +function tsQuote(raw: string): string { + return raw.includes("'") ? JSON.stringify(raw) : `'${raw}'`; +} + +/** Resolve a `schema:` expression to an object-literal body, following local consts. */ +function resolveSchemaLiteral(expr: string, source: string, depth = 0): string | undefined { + if (depth > 2) return undefined; + // z.object({ ... }) / z.strictObject({ ... }) — inline literal. + const inline = /^z\.\w*[oO]bject\s*\(/.exec(expr); + if (inline !== null) { + const rest = expr.slice(inline[0].length).trimStart(); + if (rest.startsWith('{')) return objectBody(rest, 0); + // z.object(SHAPE_CONST) — look up the local shape const. + const shapeName = /^([$\w]+)/.exec(rest)?.[1]; + if (shapeName !== undefined) { + const constRe = new RegExp(`const\\s+${shapeName}\\s*(?::[^=;]+)?=\\s*\\{`); + const m = constRe.exec(source); + if (m !== null) return objectBody(source, m.index + m[0].length - 1); + } + return undefined; + } + // schema: SOME_CONST — follow `const X = z.object(...)` in the same file. + const ident = /^([$\w]+)$/.exec(expr.trim())?.[1]; + if (ident !== undefined) { + const constRe = new RegExp(`const\\s+${ident}\\s*(?::[^=;]+)?=\\s*`); + const m = constRe.exec(source); + if (m !== null) { + const rhs = readExpression(source, m.index + m[0].length).trim(); + return resolveSchemaLiteral(rhs, source, depth + 1); + } + } + return undefined; +} + +// --------------------------------------------------------------------------- +// TS type summarizer — expand `z.custom()` type names into readable sketches +// by resolving the alias (or interface) across local definitions, imports, and +// re-exports. Discriminated unions collapse to `union on type: "a" | "b"`. +// Resolution work is bounded by a per-expansion step budget. +// --------------------------------------------------------------------------- + +interface Budget { + remaining: number; +} + +function spend(budget: Budget): boolean { + if (budget.remaining <= 0) return false; + budget.remaining -= 1; + return true; +} + +const TS_BUDGET = (): Budget => ({ remaining: 24 }); + +const _fileCache = new Map(); + +function readCached(file: string): string { + let text = _fileCache.get(file); + if (text === undefined) { + text = readFileSync(file, 'utf-8'); + _fileCache.set(file, text); + } + return text; +} + +interface TsField { + readonly type: string; + readonly optional: boolean; +} + +/** Split a TS object type literal body into fields (separators: `;` / `,`). */ +function splitTsTypeFields(body: string): Map { + const fields = new Map(); + for (const part of splitTopLevel(body, [';', ','])) { + const m = /^(?:readonly\s+)?([$\w]+|'[^']+'|"[^"]+")\s*(\?)?\s*:\s*(.+)$/.exec(part); + if (m?.[1] !== undefined && m[3] !== undefined) { + fields.set(m[1].replace(/^['"]|['"]$/g, ''), { + type: m[3].trim(), + optional: m[2] !== undefined, + }); + } + } + return fields; +} + +const TS_PRIMITIVES = new Set(['string', 'number', 'boolean', 'unknown', 'any', 'null', 'undefined', 'void']); + +function renderTsFields( + fields: Map, + file: string, + budget: Budget, + charBudget: number, + depth: number, +): SketchDict { + const dict: SketchDict = {}; + let count = 0; + for (const [name, f] of fields) { + if (count >= 8) { + dict[MORE_KEY] = '…'; + break; + } + count += 1; + dict[`${name}${f.optional ? '?' : ''}`] = summarizeTsTypeExpr( + f.type, + file, + budget, + Math.max(120, Math.floor(charBudget / 2)), + depth + 1, + ); + } + return dict; +} + +/** Find a local `type X = ...` / `interface X {...}` definition's RHS text. */ +function findTsTypeDef(name: string, file: string): string | undefined { + const source = readCached(file); + const typeRe = new RegExp(`(?:export\\s+)?type\\s+${name}(?:<[^>;=]*>)?\\s*=\\s*`); + const m = typeRe.exec(source); + if (m !== null) return readExpression(source, m.index + m[0].length).trim(); + const ifaceRe = new RegExp(`(?:export\\s+)?interface\\s+${name}(?:<[^>]*>)?(?:\\s+extends[^{]+)?\\s*\\{`); + const im = ifaceRe.exec(source); + if (im !== null) { + const body = objectBody(source, im.index + im[0].length - 1); + if (body !== undefined) return `{ ${body} }`; + } + return undefined; +} + +/** Find the module specifier a name is imported (or named-re-exported) from. */ +function findImportSource(file: string, name: string): string | undefined { + const source = readCached(file); + const re = /(?:import|export)\s+(?:type\s+)?\{([^}]+)\}\s*from\s*'([^']+)'/g; + for (const m of source.matchAll(re)) { + for (const part of m[1]!.split(',')) { + const named = /^(?:type\s+)?([\w$]+)(?:\s+as\s+([\w$]+))?$/.exec(part.trim()); + if (named === null) continue; + if ((named[2] ?? named[1]) === name) return m[2]; + } + } + return undefined; +} + +function resolveModuleFile(fromFile: string, specifier: string): string | undefined { + let base: string; + if (specifier.startsWith('#/')) base = join(SRC, specifier.slice(2)); + else if (specifier.startsWith('.')) base = join(dirname(fromFile), specifier); + else return undefined; + for (const candidate of [`${base}.ts`, join(base, 'index.ts')]) { + if (existsSync(candidate)) return candidate; + } + return undefined; +} + +function summarizeTsUnion( + members: string[], + file: string, + budget: Budget, + charBudget: number, + depth: number, +): string { + // Resolve member idents one level so alias unions (ContextMessage = A | B | C) + // still expose their object shapes. + const resolved = members.map((m) => { + const t = m.trim(); + if (/^[$\w]+$/.test(t)) { + const def = findTsTypeDef(t, file); + if (def !== undefined) return def; + } + return t; + }); + const bodies = resolved.map((m) => (m.trim().startsWith('{') ? objectBody(m.trim(), 0) : undefined)); + if (bodies.length > 0 && bodies.every((b) => b !== undefined)) { + const fieldMaps = bodies.map((b) => splitTsTypeFields(b!)); + // Discriminated union: one field is a string literal in every member. + for (const [name, info] of fieldMaps[0]!) { + if ( + /^'[^']*'$/.test(info.type) && + fieldMaps.every((fm) => /^'[^']*'$/.test(fm.get(name)?.type ?? '')) + ) { + const values = fieldMaps.map((fm) => tsQuote(fm.get(name)!.type.slice(1, -1))); + return truncate(`union on ${name}: ${values.join(' | ')}`, charBudget); + } + } + // Unions stay one-line strings; object members use the compact renderer. + return truncate( + fieldMaps + .map((fm) => stringifySketch(renderTsFields(fm, file, budget, charBudget, depth + 1))) + .join(' | '), + charBudget * 2, + ); + } + return truncate( + members + .map((m) => stringifySketch(summarizeTsTypeExpr(m, file, budget, charBudget, depth + 1))) + .join(' | '), + charBudget * 2, + ); +} + +function summarizeTsTypeExpr( + rhs: string, + file: string, + budget: Budget, + charBudget = 480, + depth = 0, +): Sketch { + let text = rhs.replaceAll(/\s+/g, ' ').trim(); + if (text.startsWith('readonly ')) text = text.slice('readonly '.length).trim(); + const literal = /^'([^']*)'$/.exec(text); + if (literal?.[1] !== undefined) return tsQuote(literal[1]); + if (TS_PRIMITIVES.has(text)) return text; + if (text.endsWith('[]')) { + const inner = summarizeTsTypeExpr(text.slice(0, -2), file, budget, charBudget, depth + 1); + if (typeof inner !== 'string') return [inner]; + return inner.includes('|') ? `(${inner})[]` : `${inner}[]`; + } + const members = splitTopLevel(text, ['|']); + if (members.length > 1) { + return spend(budget) + ? summarizeTsUnion(members, file, budget, charBudget, depth) + : truncate(text, 80); + } + const intersections = splitTopLevel(text, ['&']); + if (intersections.length > 1) { + if (!spend(budget)) return truncate(text, 80); + const sides = intersections.map((m) => summarizeTsTypeExpr(m, file, budget, charBudget, depth + 1)); + // An intersection of object shapes merges into one dictionary. + if (sides.every((side) => typeof side !== 'string' && !Array.isArray(side))) { + return Object.assign({}, ...sides) as SketchDict; + } + return truncate(sides.map(stringifySketch).join(' & '), charBudget * 2); + } + if (text.startsWith('{')) { + if (!spend(budget) || depth >= 4) return 'object'; + const body = objectBody(text, 0); + if (body !== undefined) { + return renderTsFields(splitTsTypeFields(body), file, budget, charBudget, depth); + } + } + if (/^[$\w]+$/.test(text)) { + const summary = summarizeTsType(text, file, budget); + if (summary !== undefined) return summary; + } + return truncate(text, 80); +} + +/** Resolve a type name to a readable summary across aliases, imports, re-exports. */ +function summarizeTsType(name: string, fromFile: string, budget: Budget): Sketch | undefined { + if (!spend(budget)) return undefined; + const def = findTsTypeDef(name, fromFile); + if (def !== undefined) return summarizeTsTypeExpr(def, fromFile, budget); + const specifier = findImportSource(fromFile, name); + if (specifier !== undefined) { + const target = resolveModuleFile(fromFile, specifier); + if (target !== undefined) return summarizeTsType(name, target, budget); + } + for (const m of readCached(fromFile).matchAll(/export\s+\*\s+from\s*'([^']+)'/g)) { + const target = m[1] === undefined ? undefined : resolveModuleFile(fromFile, m[1]); + if (target === undefined) continue; + const summary = summarizeTsType(name, target, budget); + if (summary !== undefined) return summary; + } + return undefined; +} + +/** + * Render a zod field expression as a Sketch, in the same notation the + * JSON-Schema path produces (`string`, `'a' | 'b'`, `Foo[]`). `z.custom()` + * and bare type idents expand through the TS type summarizer — object shapes + * become nested dicts (keyed with the type name under `_type`), everything + * else stays a one-line string. + */ +function friendlyZodExpr(expr: string, ownerFile: string, depth = 0): Sketch { + let text = expr.replaceAll(/\s+/g, ' ').trim(); + // Strip trailing modifiers the sketch does not mark. + let stripped = true; + while (stripped) { + stripped = false; + for (const suffix of ['.optional()', '.nullable()', '.nullish()', '.readonly()']) { + if (text.endsWith(suffix)) { + text = text.slice(0, -suffix.length).trim(); + stripped = true; + } + } + } + const stringLiteral = /^'([^']*)'$/.exec(text); + if (stringLiteral?.[1] !== undefined) return tsQuote(stringLiteral[1]); + const custom = /^z\.custom<(.+)>\(\)$/.exec(text); + if (custom?.[1] !== undefined) { + const typeName = custom[1].trim(); + // Expand the TS type only at the top levels — nested fields keep the bare + // type name so long union member sketches stay readable. + if (depth > 1) return typeName; + const summary = summarizeTsType(typeName, ownerFile, TS_BUDGET()); + if (summary === undefined) return typeName; + if (typeof summary !== 'string' && !Array.isArray(summary)) { + return { [TYPE_KEY]: typeName, ...summary }; + } + return truncate(`${typeName} = ${stringifySketch(summary)}`, 1024); + } + if (/^z\.string\(\)$/.test(text)) return 'string'; + if (/^z\.number\(\)$/.test(text)) return 'number'; + if (/^z\.boolean\(\)$/.test(text)) return 'boolean'; + if (/^z\.(?:int|integer)\(\)$/.test(text)) return 'integer'; + const array = /^z\.array\((.+)\)$/.exec(text); + if (array?.[1] !== undefined) { + const inner = friendlyZodExpr(array[1], ownerFile, depth + 1); + if (typeof inner !== 'string') return [inner]; + return `${inner}[]`; + } + const literal = /^z\.literal\((.+)\)$/.exec(text); + if (literal?.[1] !== undefined) return friendlyZodExpr(literal[1], ownerFile, depth + 1); + const union = /^z\.union\((.+)\)$/.exec(text); + if (union?.[1] !== undefined) return friendlyZodUnion(union[1], ownerFile, depth); + const enumMatch = /^z\.enum\((.+)\)$/.exec(text); + if (enumMatch?.[1] !== undefined) { + const body = enumMatch[1].trim().replace(/^\[/, '').replace(/\]$/, ''); + return splitTopLevel(body) + .map((member) => stringifySketch(friendlyZodExpr(member, ownerFile, depth + 1))) + .join(' | '); + } + const record = /^z\.record\((.+)\)$/.exec(text); + if (record?.[1] !== undefined) { + const parts = splitTopLevel(record[1]); + if (parts.length === 2) { + return `record`; + } + } + if (/^z\.\w*[oO]bject\(/.test(text)) { + if (depth >= 3) return 'object'; + const body = resolveSchemaLiteral(text, readCached(ownerFile)); + if (body === undefined) return 'object'; + const dict: SketchDict = {}; + for (const [key, fieldExpr] of splitObjectFields(body)) { + if (fieldExpr === '') { + dict[key] = '(spread)'; + continue; + } + const optional = /\.(?:optional|nullish)\(\)$/.test(fieldExpr); + dict[`${key}${optional ? '?' : ''}`] = friendlyZodExpr(fieldExpr, ownerFile, depth + 1); + } + return dict; + } + const ident = /^([$\w]+)$/.exec(text)?.[1]; + if (ident !== undefined && depth < 4) { + const source = readCached(ownerFile); + const constRe = new RegExp(`const\\s+${ident}\\s*(?::[^=;]+)?=\\s*`); + const m = constRe.exec(source); + if (m !== null) { + const rhs = readExpression(source, m.index + m[0].length).trim(); + return friendlyZodExpr(rhs, ownerFile, depth + 1); + } + if (depth <= 1) { + const summary = summarizeTsType(ident, ownerFile, TS_BUDGET()); + if (summary !== undefined) { + if (typeof summary !== 'string' && !Array.isArray(summary)) { + return { [TYPE_KEY]: ident, ...summary }; + } + return truncate(`${ident} = ${stringifySketch(summary)}`, 320); + } + } + } + return truncate(text, 80); +} + +/** Sketch a `z.union([...])` body (one-line string); object members get field sketches. */ +function friendlyZodUnion(body: string, ownerFile: string, depth: number): string { + const members = splitTopLevel(body.trim().replace(/^\[/, '').replace(/\]$/, '')); + const source = readCached(ownerFile); + const bodies = members.map((m) => resolveSchemaLiteral(m, source)); + if (members.length > 0 && bodies.every((b) => b !== undefined)) { + const fieldMaps = bodies.map((b) => splitObjectFields(b!)); + // Hoist spreads shared by every member (`...base & { … } | { … }`). + const spreadSets = fieldMaps.map((fm) => [...fm.keys()].filter((k) => fm.get(k) === '')); + const commonSpreads = (spreadSets[0] ?? []).filter((s) => + spreadSets.every((set) => set.includes(s)), + ); + const sketches = fieldMaps.map((fm) => { + const dict: SketchDict = {}; + for (const [key, expr] of fm) { + if (expr === '') continue; + const optional = /\.(?:optional|nullish)\(\)$/.test(expr); + dict[`${key}${optional ? '?' : ''}`] = friendlyZodExpr(expr, ownerFile, depth + 1); + } + return stringifySketch(dict); + }); + const prefix = commonSpreads.length > 0 ? `${commonSpreads.join(' + ')} & ` : ''; + return truncate(`${prefix}${sketches.join(' | ')}`, 320); + } + return truncate( + members.map((m) => stringifySketch(friendlyZodExpr(m, ownerFile, depth + 1))).join(' | '), + 320, + ); +} + +/** + * Best-effort payload sketch from the owner source for schemas that use + * `z.custom` (not representable as JSON Schema). Returns a field map for + * object payloads, a type string for whole-payload custom schemas, or + * `undefined` when the source shape is not recognized. + */ +function sketchPayloadFromSource( + ownerFile: string, + type: string, +): string | Map | undefined { + const absFile = join(PKG, ownerFile); + const source = readCached(absFile); + const callRe = new RegExp(`defineOp\\(\\s*'${type.replaceAll('.', '\\.')}'\\s*,\\s*\\{`); + const call = callRe.exec(source); + if (call === null) return undefined; + const optionsBody = objectBody(source, call.index + call[0].length - 1); + if (optionsBody === undefined) return undefined; + const schemaField = /(?:^|[,\n])\s*schema\s*:/.exec(optionsBody); + if (schemaField === null) return undefined; + const afterSchema = optionsBody.slice(schemaField.index + schemaField[0].length).trimStart(); + // The schema expression ends at the next top-level comma. + const exprFields = splitObjectFields(`schema: ${afterSchema}`); + const schemaExpr = exprFields.get('schema'); + if (schemaExpr === undefined) return undefined; + const literal = resolveSchemaLiteral(schemaExpr, source); + if (literal === undefined) { + const sketch = friendlyZodExpr(schemaExpr, absFile); + if (typeof sketch === 'string') return sketch; + if (!Array.isArray(sketch)) return new Map(Object.entries(sketch)); + return stringifySketch(sketch); + } + const sketch = new Map(); + for (const [key, expr] of splitObjectFields(literal)) { + if (expr === '') { + sketch.set(key, '(spread)'); + continue; + } + const optional = /\.(?:optional|nullish)\(\)$/.test(expr); + sketch.set(`${key}${optional ? '?' : ''}`, friendlyZodExpr(expr, absFile)); + } + return sketch; +} + +// --------------------------------------------------------------------------- +// Manifest rendering +// --------------------------------------------------------------------------- + +export async function buildWireManifest(): Promise { + const { owners, opFiles } = scanOpOwners(); + // "import = register": loading the package root plus every op module found in + // the static pass fills OP_REGISTRY, even for modules index.ts does not load. + await import('../src/index.ts'); + for (const file of opFiles) { + await import(relative(join(PKG, 'scripts'), file)); + } + const { WIRE_PROTOCOL_VERSION } = (await import('#/wire/migration/migration')) as { + WIRE_PROTOCOL_VERSION: string; + }; + + const entries = [...OP_REGISTRY.values()].toSorted((a, b) => a.type.localeCompare(b.type)); + const migrationChain = scanMigrationChain(); + + const out: string[] = [ + '// Wire Protocol Manifest', + '//', + '// Generated by scripts/gen-wire-manifest.mts — do not edit by hand.', + '// Regenerate with: pnpm --filter @moonshot-ai/agent-core-v2 gen:wire-manifest', + '//', + `// protocol_version: "${WIRE_PROTOCOL_VERSION}" (migrations: ${migrationChain})`, + '//', + '// One declaration per record type registered via defineOp(...) and drained from', + '// the runtime OP_REGISTRY. Every payload declaration carries its record type in', + '// a `_name` field. Payload sketches use TypeScript type syntax; when a', + '// named type is expanded inline, its name appears as a doc comment', + '// (`/** ContextMessage */`). Bare type names (ContentPart, ContextMessage, …)', + '// refer to the real types in src/ — they are intentionally not resolved here.', + '// `// …` marks a capped field list. On disk (wire.jsonl) the journal opens with', + '// a metadata line {"type": "metadata", "protocol_version", "created_at"}; each', + '// op record is {"type", ...payload, "time"} — object payloads spread at the', + '// top level, scalar payloads nest under a "payload" key.', + '//', + '// Declaration flags: persisted (written to the journal; absent = transient),', + '// toEvent (also publishes an IEventBus fact on live dispatch), blobs (the', + '// owning model offloads inline media to blob storage), cross-reducers', + '// (foreign models that also reduce this record on dispatch and replay).', + '', + `// Index (${entries.length} record types)`, + ]; + const width = Math.max(...entries.map((e) => e.type.length)); + const modelWidth = Math.max(...entries.map((e) => e.model.name.length)); + for (const entry of entries) { + const flags = entry.persist === false ? 'transient' : 'persisted'; + out.push( + `// ${entry.type.padEnd(width)} ${entry.model.name.padEnd(modelWidth)} ${flags} ${owners.get(entry.type) ?? '(unresolved)'}`, + ); + } + out.push(''); + const declNames: [string, string][] = []; + for (const entry of entries) { + const flags: string[] = []; + if (entry.persist !== false) flags.push('persisted'); + if (entry.toEvent !== undefined) flags.push('toEvent'); + if (entry.model.blobs !== undefined) flags.push('blobs'); + const crossReducers = (MODEL_CROSS_REDUCERS.get(entry.type) ?? []) + .map((r) => (r.model as { name: string }).name) + .filter((name) => name !== entry.model.name); + if (crossReducers.length > 0) flags.push(`cross-reducers: ${crossReducers.join(', ')}`); + const owner = owners.get(entry.type); + const staticSketch = + owner === undefined ? undefined : sketchPayloadFromSource(owner, entry.type); + const sketch = buildPayloadSketch(entry.schema as unknown, staticSketch); + out.push(...renderPayloadDecl(entry, owner, flags, sketch)); + declNames.push([entry.type, `${pascalCase(entry.type)}Payload`]); + } + + // Record type → payload declaration map. + out.push('/** Record type → payload sketch. */'); + out.push('interface WirePayloadMap {'); + for (const [type, declName] of declNames) { + out.push(` ${JSON.stringify(type)}: ${declName};`); + } + out.push('}'); + out.push(''); + return out.join('\n'); +} + +// --------------------------------------------------------------------------- +// CLI +// --------------------------------------------------------------------------- + +async function main(): Promise { + const check = process.argv.includes('--check'); + const manifest = await buildWireManifest(); + if (check) { + let current: string | undefined; + try { + current = readFileSync(MANIFEST_PATH, 'utf-8'); + } catch { + current = undefined; + } + if (current !== manifest) { + console.error( + `[gen-wire-manifest] ${relative(process.cwd(), MANIFEST_PATH)} is stale. ` + + 'Regenerate with `pnpm --filter @moonshot-ai/agent-core-v2 gen:wire-manifest`.', + ); + process.exit(1); + } + console.log('[gen-wire-manifest] up to date'); + return; + } + writeFileSync(MANIFEST_PATH, manifest); + console.log(`[gen-wire-manifest] wrote ${relative(process.cwd(), MANIFEST_PATH)}`); +} + +if (process.argv[1] !== undefined && import.meta.url === pathToFileURL(process.argv[1]).href) { + await main(); +} diff --git a/packages/agent-core-v2/scripts/lib/jsonSchema.mts b/packages/agent-core-v2/scripts/lib/jsonSchema.mts new file mode 100644 index 000000000..4e2f9c793 --- /dev/null +++ b/packages/agent-core-v2/scripts/lib/jsonSchema.mts @@ -0,0 +1,99 @@ +/** + * Shared JSON-schema helpers for the manifest generators + * (`gen-config-manifest.mts`, `gen-wire-manifest.mts`). + * + * Both generators drain runtime registries that carry zod schemas and render + * field/type sketches from their JSON Schema projection. + */ + +import { z } from 'zod'; + +export function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +export function truncate(text: string, max = 100): string { + return text.length > max ? `${text.slice(0, max - 1)}…` : text; +} + +/** Property access shape of a JSON Schema node (avoids index-signature access). */ +export interface JsonSchema { + readonly $ref?: unknown; + readonly $defs?: unknown; + readonly const?: unknown; + readonly enum?: unknown; + readonly anyOf?: unknown; + readonly oneOf?: unknown; + readonly type?: unknown; + readonly items?: unknown; + readonly properties?: unknown; + readonly required?: unknown; + readonly additionalProperties?: unknown; + readonly default?: unknown; +} + +export function asJsonSchema(value: unknown): JsonSchema | undefined { + return isRecord(value) ? (value as JsonSchema) : undefined; +} + +/** Resolve a `#/$defs/` reference against the root schema. */ +export function resolveRef(schema: unknown, root: JsonSchema): unknown { + const s = asJsonSchema(schema); + if (typeof s?.$ref === 'string' && s.$ref.startsWith('#/$defs/')) { + const defs = asJsonSchema(root.$defs); + const name = s.$ref.slice('#/$defs/'.length); + if (defs !== undefined && isRecord(defs) && name in defs) { + return (defs as Record)[name]; + } + } + return schema; +} + +/** One-line type description of a JSON Schema node (`"a" | "b"`, `Foo[]`, …). */ +export function describeType( + schema: unknown, + quoteString: (raw: string) => string = (s) => JSON.stringify(s), +): string { + const s = asJsonSchema(schema); + if (s === undefined) return 'any'; + if (s.$ref !== undefined) { + return typeof s.$ref === 'string' ? (s.$ref.split('/').pop() ?? 'any') : 'any'; + } + if (s.const !== undefined) { + return truncate( + typeof s.const === 'string' ? quoteString(s.const) : JSON.stringify(s.const), + 40, + ); + } + if (Array.isArray(s.enum)) { + return s.enum + .map((v) => (typeof v === 'string' ? quoteString(v) : JSON.stringify(v))) + .join(' | '); + } + for (const combiner of ['anyOf', 'oneOf'] as const) { + const subs = s[combiner]; + if (Array.isArray(subs)) return subs.map((sub) => describeType(sub, quoteString)).join(' | '); + } + if (s.type === 'array') return `${describeType(s.items, quoteString)}[]`; + if (s.type === 'object') { + // Named sub-tables (zod objects emit `additionalProperties: false`) are + // rendered by the caller; only a schema-valued additionalProperties marks + // a true record. + if (isRecord(s.properties)) return 'object'; + if (isRecord(s.additionalProperties)) { + return `record`; + } + return 'object'; + } + if (typeof s.type === 'string') return s.type; + return 'any'; +} + +/** Project a zod schema to JSON Schema; `undefined` when it uses transforms. */ +export function toJsonSchema(schema: unknown): JsonSchema | undefined { + try { + return z.toJSONSchema(schema as never) as JsonSchema; + } catch { + return undefined; + } +} diff --git a/packages/agent-core-v2/test/app/config/configManifest.test.ts b/packages/agent-core-v2/test/app/config/configManifest.test.ts new file mode 100644 index 000000000..72f798725 --- /dev/null +++ b/packages/agent-core-v2/test/app/config/configManifest.test.ts @@ -0,0 +1,20 @@ +/** + * Scenario: the checked-in config-section manifest matches the live registry. + * + * Rebuilds `docs/config-manifest.toml` from the actual `registerConfigSection` / + * `registerConfigOverlay` contributions and fails when the file is stale. + * Regenerate with `pnpm --filter @moonshot-ai/agent-core-v2 gen:config-manifest`. + */ + +import { readFileSync } from 'node:fs'; +import { describe, expect, it } from 'vitest'; + +import { buildConfigManifest, MANIFEST_PATH } from '../../../scripts/gen-config-manifest.mts'; + +describe('config manifest', () => { + it('docs/config-manifest.toml is up to date', async () => { + const expected = await buildConfigManifest(); + const actual = readFileSync(MANIFEST_PATH, 'utf-8'); + expect(actual).toBe(expected); + }, 60_000); +}); diff --git a/packages/agent-core-v2/test/wire/wireManifest.test.ts b/packages/agent-core-v2/test/wire/wireManifest.test.ts new file mode 100644 index 000000000..b5e76220b --- /dev/null +++ b/packages/agent-core-v2/test/wire/wireManifest.test.ts @@ -0,0 +1,34 @@ +/** + * Scenario: the checked-in wire-protocol manifest matches the live OP_REGISTRY + * and parses as a valid TypeScript declaration file. + * + * Rebuilds `docs/wire-manifest.d.ts` from the actual `defineOp` registrations + * and fails when the file is stale. Regenerate with + * `pnpm --filter @moonshot-ai/agent-core-v2 gen:wire-manifest`. + */ + +import { readFileSync } from 'node:fs'; +import { Project } from 'ts-morph'; +import { describe, expect, it } from 'vitest'; + +import { buildWireManifest, MANIFEST_PATH } from '../../scripts/gen-wire-manifest.mts'; + +describe('wire manifest', () => { + it('docs/wire-manifest.d.ts is up to date', async () => { + const expected = await buildWireManifest(); + const actual = readFileSync(MANIFEST_PATH, 'utf-8'); + expect(actual).toBe(expected); + }, 60_000); + + it('docs/wire-manifest.d.ts parses as TypeScript', () => { + const project = new Project({ useInMemoryFileSystem: true }); + const sourceFile = project.createSourceFile( + 'wire-manifest.d.ts', + readFileSync(MANIFEST_PATH, 'utf-8'), + ); + // `parseDiagnostics` is internal in the compiler typings but populated at runtime. + const diagnostics = (sourceFile.compilerNode as { parseDiagnostics?: readonly unknown[] }) + .parseDiagnostics; + expect(diagnostics ?? []).toEqual([]); + }); +});