diff --git a/.agents/skills/agent-core-dev/SKILL.md b/.agents/skills/agent-core-dev/SKILL.md index ade4ea78b..182f93677 100644 --- a/.agents/skills/agent-core-dev/SKILL.md +++ b/.agents/skills/agent-core-dev/SKILL.md @@ -1,6 +1,6 @@ --- name: agent-core-dev -description: Use when developing in packages/agent-core-v2 (the DI × Scope agent engine) — adding or modifying a domain Service, choosing a LifecycleScope, wiring DI dependencies, splitting a domain across scopes, owning or migrating a config section, gating behavior behind an experimental flag, raising coded errors, working on the permission system, writing DI/Scope tests, or porting business logic from agent-core (v1) to v2. Self-contained guide organized by development stage (orient → design → implement → test → verify) plus an align workflow for v1→v2 migration; each file carries the rules, examples, and red lines for its step. +description: Use when developing in packages/agent-core-v2 (the DI × Scope agent engine) — adding or modifying a domain Service, choosing a LifecycleScope, wiring DI dependencies, splitting a domain across scopes, owning or migrating a config section, gating behavior behind an experimental flag, raising coded errors, working on the permission system, writing DI/Scope tests, porting business logic from agent-core (v1) to v2, or exposing a v2 domain over server-v2 while keeping the wire contract compatible with packages/server. Self-contained guide organized by development stage (orient → design → implement → test → verify) plus align workflows for v1→v2 migration and server-v2 wire exposure; each file carries the rules, examples, and red lines for its step. --- # agent-core-dev @@ -28,6 +28,7 @@ Stages are ordered but not strictly linear: a test failure (stage 4) that reveal End-to-end procedures that span the stages. Reach for these before reading the stage files individually. - [Align (port `agent-core` → `agent-core-v2`)](align.md): split a v1 class into semantic units, fix each unit's domain / scope / Service / dependencies, then migrate the logic and tests. Use when the task is "move feature X from v1 to v2" or "port `IXxxService` to v2". +- [Server align (expose `agent-core-v2` over `server-v2`)](server-align.md): wire a v2 domain into `packages/server-v2` over `/api/v2` (native) and `/api/v1` (v1-compatible mirror), keep the wire schema byte-compatible with `packages/server` by sharing the `@moonshot-ai/protocol` schema, and isolate v1-only behavior in a `Legacy` edge adapter instead of distorting the native v2 Service. Use when the task is "expose the new v2 Service on the server", "port the v1 `/api/v1` routes to server-v2", or "keep server-v2 wire-compatible with `packages/server`". ## Stages diff --git a/.agents/skills/agent-core-dev/server-align.md b/.agents/skills/agent-core-dev/server-align.md new file mode 100644 index 000000000..2e4b1dc32 --- /dev/null +++ b/.agents/skills/agent-core-dev/server-align.md @@ -0,0 +1,250 @@ +# Subskill — Server align (expose `agent-core-v2` over `server-v2`) + +Wire a v2 domain into `packages/server-v2`, and — when the endpoint already exists in `packages/server` (v1) — keep the wire shape **byte-for-byte compatible**. This is the server-side counterpart of [align.md](align.md): `align.md` ports v1 *business logic* into v2; this file exposes the v2 result over HTTP / WS, reusing the v1 wire contract where it already exists. + +Use this when the task is "expose the new v2 Service on the server", "port the v1 `/sessions/:sid/...` routes to server-v2", or "make server-v2 speak the same `/api/v1` contract as `packages/server`". + +## The one-paragraph mental model + +`server-v2` serves **two HTTP surfaces** off the same `agent-core-v2` scope tree: + +- **`/api/v2/:sa`** — the native v2 RPC surface, driven by the `actionMap` allowlist (`packages/server-v2/src/transport/actionMap.ts`). One `resource:action` segment maps to one `Service.method`. New v2-native capabilities land here. See [edge-exposure.md](edge-exposure.md). +- **`/api/v1/...`** — the v1-compatible surface, hand-written routes in `packages/server-v2/src/routes/*.ts` that **mirror `packages/server/src/routes/*.ts` path-for-path and schema-for-schema**, mounted by `registerApiV1Routes.ts`. This exists so existing v1 clients keep working against server-v2 unchanged. + +The two surfaces can point at **different Services** for the same feature. v2's native `IPromptService` serves `/api/v2`; a v1-shaped `IPromptLegacyService` serves `/api/v1`. Keeping them separate is what lets v2's domain design stay clean while the wire stays compatible. + +## Decision: which surface? + +```text +Is there a matching endpoint in packages/server (v1)? +├─ YES → /api/v1 mirror route (this file, §schema-fidelity + §legacy-service). +│ Reuse the protocol schema; add a LegacyService if v2 semantics diverge. +└─ NO → /api/v2 native action (edge-exposure.md). + Add to actionMap, wrapping in a facade if the method fails §2 there. +``` + +A feature often needs **both**: the v1 mirror so old clients keep working, and the v2 action so new clients get the cleaner shape. Do them as two routes / two action-map entries over the same scope tree. + +## The server-align workflow + +```text +Pick surface → Read the v1 route (if any) → Reuse / add the protocol schema +→ Choose native Service vs LegacyService → Wire the route / actionMap entry +→ Map errors → Test against the v1 wire shape → Verify +``` + +### 1. Pick the surface + +Apply the decision above. For a v1-matched endpoint, open **both** files side by side: + +- `packages/server/src/routes/.ts` — the contract you must match. +- `packages/server-v2/src/routes/.ts` — the file you are writing (create it if missing). + +The v1 route file is the **spec**. Do not re-derive the wire shape from memory or from the v2 domain model. + +### 2. Reuse (or add) the protocol schema + +The wire schema lives in **`@moonshot-ai/protocol`** under `packages/protocol/src/rest/.ts` (e.g. `promptSubmissionSchema`, `promptListResponseSchema`, `configResponseSchema`). Both `packages/server` and `packages/server-v2` import from it — that single import is what guarantees the two servers speak the same shape. + +Actions: + +- **Schema already in protocol** → import it in the server-v2 route and use it in `defineRoute` (`body`, `success.data`, error `dataSchema` / `detailsSchema`). Do **not** re-declare the schema inline in server-v2. +- **Schema missing in protocol** → add it to `packages/protocol/src/rest/.ts` first, with a `rest-.test.ts`, then consume it from **both** servers. The protocol package is the source of truth; server-v2 never owns a v1 wire schema locally. +- **Schema exists but only v1 uses it** → move/keep it in protocol and import it into server-v2; do not fork a copy. + +#### Schema-fidelity rule (the hard rule) + +When the endpoint matches a `packages/server` endpoint, the request and response schemas **must be the same protocol schema** (or a strict superset): + +- ✅ **Adding** an optional field is allowed (`field: z.string().optional()`). Old clients ignore it; new clients may send it. +- ❌ **Renaming** a field, **changing** its type, **tightening** its validation, or **changing its meaning** is a wire break — do not do it in a mirror route. If the v2 domain genuinely needs a different shape, that shape belongs on `/api/v2`, not on the `/api/v1` mirror. +- ❌ Re-declaring the schema inline in server-v2 (even if it "looks identical") is forbidden — it drifts. One schema, one home: `packages/protocol`. + +Self-check: "would a client talking to `packages/server` get a byte-identical envelope from `packages/server-v2` for the same request?" If you cannot answer yes from the shared schema, the route is wrong. + +### 3. Choose native Service vs LegacyService + +Resolve the v2 Service that will back the route. Two cases: + +**Case A — the v2 native Service already matches the v1 contract.** Use it directly. Most data/command Services (`IConfigService`, `IWorkspaceRegistry`, `IApprovalService`, `IQuestionService`, `IFileStore`, …) land here: the route is a thin adapter that resolves the scope, calls the method, and wraps the result. Examples: `routes/config.ts`, `routes/messages.ts`, `routes/questions.ts`, `routes/files.ts`. + +**Case B — the v1 contract needs behavior that would distort the v2 domain.** Introduce a **`*LegacyService`** — an L7 edge adapter that implements the v1 contract **on top of** the v2 native Service, leaving the native Service untouched. The v2 native Service keeps serving `/api/v2`; the LegacyService serves `/api/v1`. + +Reach for a LegacyService when **any** hold: + +- The v1 endpoint carries state the v2 domain deliberately dropped (e.g. a FIFO queue, a `prompt_id`, idempotent `abort`/`steer`, auto-start-next). +- The v1 method returns a handle/stream that v2 wraps differently, and the v1 clients expect the old envelope shape. +- Matching v1 would force a `Map`-at-`Core` anti-pattern or a scope/domain-direction violation into the native Service (see [align.md](align.md) red lines). +- The native Service's error set / return type would have to grow v1-only branches. + +Do **not** put v1 quirks into the native v2 Service "to keep the route simple". That is the conflict this rule exists to prevent: the native Service serves the v2 architecture; the LegacyService serves the wire contract. + +#### LegacyService recipe + +A LegacyService is a normal v2 Service (service-authoring.md) with one extra convention: its contract is shaped by the **protocol** types, not by the v2 domain model. + +```text +packages/agent-core-v2/src/Legacy/ +├── Legacy.ts ← contract: protocol-typed interface + decorator +├── LegacyService.ts ← impl: delegates to the native v2 Service(s) +├── errors.ts ← v1-compatible error codes (KimiError codes) +└── index.ts ← barrel: import './errors'; export contract + impl +``` + +Skeleton (matches `promptLegacy/`): + +```ts +// promptLegacy.ts — contract shaped by @moonshot-ai/protocol +import type { PromptSubmitResult, PromptSubmission } from '@moonshot-ai/protocol'; +import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; + +export interface IPromptLegacyService { + readonly _serviceBrand: undefined; + submit(body: PromptSubmission): Promise; + // ...the rest of the v1 contract, typed by protocol +} +export const IPromptLegacyService: ServiceIdentifier = + createDecorator('promptLegacyService.agent'); +``` + +```ts +// promptLegacyService.ts — impl delegates to the native v2 Service +constructor(@IPromptService private readonly prompt: IPromptService /*, ... */) {} +// submit() builds v2-native input, calls the native Service, projects the result +// back into the protocol PromptSubmitResult. + +registerScopedService( + LifecycleScope.Agent, // scope = the lifetime of the legacy state + IPromptLegacyService, + PromptLegacyService, + InstantiationType.Delayed, + 'promptLegacy', +); +``` + +Conventions: + +- **Name** the domain `Legacy` and the interface `ILegacyService` (e.g. `promptLegacy` / `IPromptLegacyService`). +- **Header comment** must say it is an `L7 edge adapter` and name both the v1 contract it implements and the native v2 Service it leaves untouched (see `promptLegacy.ts`). +- **Scope** = the lifetime of the *legacy* state it holds (the `promptLegacy` queue is per-agent → `LifecycleScope.Agent`). Apply [orient.md](orient.md) / [design.md](design.md) normally — a LegacyService is not exempt from scope rules. +- **Delegate, do not duplicate** business logic. The LegacyService translates the v1 contract into native-Service calls and translates results back; the real work stays in the native Service. +- **Contract types come from `@moonshot-ai/protocol`**, so the interface cannot drift from the wire shape. + +### 4. Wire the route / actionMap entry + +**For `/api/v1` (mirror):** add a route file under `packages/server-v2/src/routes/.ts` using `defineRoute`, then register it in `registerApiV1Routes.ts`. Resolve the scope from the URL (`session_id` → Session scope, agent → Agent scope via `IAgentLifecycleService.getHandle`), then `accessor.get(IX)` the native or Legacy Service. Mirror the v1 file's verbs, paths (`:sid` / `{session_id}`), and `parseActionSuffix` actions (`:steer`, `:abort`) exactly. + +```ts +const route = defineRoute( + { + method: 'POST', + path: '/sessions/{session_id}/prompts', + body: promptSubmissionSchema, // ← from @moonshot-ai/protocol + params: sessionIdParamSchema, + success: { data: promptSubmitResultSchema }, // ← from @moonshot-ai/protocol + errors: { + [ErrorCode.SESSION_NOT_FOUND]: {}, + [ErrorCode.SESSION_BUSY]: {}, + [ErrorCode.PROMPT_ALREADY_COMPLETED]: { dataSchema: z.object({ aborted: z.literal(false) }) }, + }, + operationId: 'submitPrompt', + tags: ['prompts'], + }, + async (req, reply) => { + try { + const result = await resolveLegacy(core, req.params.session_id).submit(req.body); + reply.send(okEnvelope(result, req.id)); + } catch (error) { + sendMappedError(reply, req.id, error); + } + }, +); +app.post(route.path, route.options, route.handler); +``` + +**For `/api/v2` (native):** add a `resource:action` entry to `actionMap` ([edge-exposure.md](edge-exposure.md) §3). If the method fails the direct-exposure rules (returns a handle / stream / bytes, takes a live object), wrap it in a wire-shaped facade first (`IAgentRPCService` / `ISessionRPCService`) and map to the facade — as `prompts:*` does via `IAgentRPCService`. + +### 5. Map errors + +The route translates domain `KimiError` codes into protocol `ErrorCode` numbers. Two registries must stay in sync: + +- **Domain code** — register in `agent-core-v2/src/errors.ts` (`ErrorCodes`) and throw from the Service (errors.md). Co-located domain errors go in `Legacy/errors.ts` (e.g. `prompt.not_found`, `session.busy`). +- **Wire code** — register the matching number in `packages/protocol/src/error-codes.ts` and reference it in the route's `errors` map and `sendMappedError`. + +```ts +function sendMappedError(reply, requestId, err) { + if (isKimiError(err)) { + switch (err.code) { + case 'session.not_found': + case 'agent.not_found': + return reply.send(errEnvelope(ErrorCode.SESSION_NOT_FOUND, err.message, requestId)); + case 'prompt.not_found': + return reply.send(errEnvelope(ErrorCode.PROMPT_NOT_FOUND, err.message, requestId)); + // ... + } + } + return reply.send(errEnvelope(ErrorCode.INTERNAL_ERROR, String(err), requestId)); +} +``` + +Match the v1 route's status codes and idempotent-conflict envelopes (e.g. `prompt.already_completed` → `40903` with `{ data: { aborted: false } }`). The error envelope is part of the wire contract — it is covered by the same schema-fidelity rule. + +### 6. Test against the v1 wire shape + +Add a `packages/server-v2/test/.test.ts` that boots the server and hits the route. Assert on the **envelope + protocol shape**, not on the v2 domain internals: + +- success envelope `{ code: 0, data: , request_id }`; +- each declared error envelope `{ code: , msg, data, request_id }`; +- the fields v1 clients read are present with the same names/types. + +Where the route mirrors v1, the test is the regression guard for the schema-fidelity rule: if someone drifts the protocol schema or the projection, this test breaks. + +### 7. Verify + +- `pnpm -C packages/server-v2 test` — server routes green. +- `pnpm -C packages/protocol test` — schema tests green (incl. any new `rest-*.test.ts`). +- `pnpm -C packages/agent-core-v2 test` — native + Legacy Service tests green. +- `pnpm -C packages/agent-core-v2 run lint:domain` — a LegacyService is still inside the domain layers (edge adapter, L7); it must not pull business code into the edge or invert scope direction. +- `pnpm -C packages/server-e2e ...` when a v1 parity scenario exists. + +## Worked example — porting v1 `/sessions/:sid/prompts` + +This is the reference alignment (commits `feat(server-v2): port v1 /sessions/:sid/prompts routes`, `feat(server-v2): return turn ids for prompt actions`). It shows all three decisions at once. + +**The mismatch.** v1 `IPromptService` is a per-agent *scheduler*: it owns a FIFO queue, assigns `prompt_id`s, supports `steer`/`abort`, and auto-starts the next queued prompt when a turn settles. v2's native `IPromptService` is a *turn driver*: a submission *is* a turn, there is no queue and no `prompt_id`. Forcing the queue into the v2 native Service would distort the v2 domain. + +**The split.** + +- `/api/v2` keeps the native shape — `prompts:submit` / `steer` / `undo` / `clear` / `cancel` map to `IAgentRPCService` (a wire facade over the v2 turn driver) in `actionMap`. The native `IPromptService` is untouched. +- `/api/v1` gets a `PromptLegacyService` (`promptLegacy/`, `LifecycleScope.Agent`) that re-implements the v1 scheduler — queue, `prompt_id`, steer/abort, auto-start-next — **on top of** the native `IPromptService`. The `/api/v1` routes consume the LegacyService. + +**The schema.** Both servers import `promptSubmissionSchema` / `promptSubmitResultSchema` / `promptListResponseSchema` / `promptSteerRequestSchema` / `promptSteerResultSchema` / `promptAbortResponseSchema` from `@moonshot-ai/protocol`. The v1 and v2 route files are therefore byte-compatible by construction; the LegacyService projects v2 turn results back into those protocol shapes. + +**The errors.** v1 codes (`prompt.not_found`, `session.busy`, `prompt.already_completed`) are registered in `agent-core-v2` (`promptLegacy/errors.ts`) and in `packages/protocol` (`error-codes.ts`), then mapped in the route's `sendMappedError` — including the idempotent `prompt.already_completed` → `40903 { data: { aborted: false } }`. + +**The lesson.** When the v1 contract and the v2 domain disagree, add an adapter (LegacyService) at the edge; do not let the wire contract leak into the native domain. The two surfaces share the protocol schema but not the Service. + +## Migration checklist + +Before submitting a server-align change: + +- [ ] Surface chosen deliberately: `/api/v1` mirror for a v1-matched endpoint, `/api/v2` for a new native capability (both if needed). +- [ ] For a `/api/v1` mirror, the route file mirrors `packages/server/src/routes/.ts` path-for-path, verb-for-verb, action-for-action. +- [ ] Request and response schemas come from `@moonshot-ai/protocol` (`packages/protocol/src/rest/.ts`); no inline re-declaration in server-v2. +- [ ] Existing schema fields are unchanged in name, type, and semantics; only optional fields added (if any). +- [ ] Native v2 Service left clean; v1-only behavior isolated in a `Legacy` / `ILegacyService` edge adapter when the semantics diverge. +- [ ] LegacyService registered with the correct `LifecycleScope` and a header comment naming it an L7 edge adapter + the native Service it preserves. +- [ ] Domain error codes registered in `agent-core-v2`; wire codes registered in `packages/protocol`; route maps them in `sendMappedError`, matching v1's status codes and idempotent envelopes. +- [ ] Route resolves the scope from the URL by `accessor.get(IX)`; no cached scope; finishes before disposal. +- [ ] Tests assert the wire envelope + protocol shape; schema tests in `packages/protocol` added/updated. +- [ ] `lint:domain` passes; the LegacyService did not invert scope or domain direction. + +## Red lines (this subskill) + +- One wire schema, one home: `packages/protocol`. Never re-declare a v1 wire schema inline in server-v2. +- A `/api/v1` mirror route must keep every existing schema field's name, type, and semantics; only optional additions are allowed. A different shape belongs on `/api/v2`, not on the mirror. +- Do not distort the native v2 Service to satisfy a v1 quirk — add a `Legacy` edge adapter instead. The native Service serves the v2 architecture; the LegacyService serves the wire contract. +- A LegacyService is still a v2 Service: it follows scope, domain-direction, and DI rules. "Edge adapter" describes its role, not an exemption. +- The v1 route file (`packages/server/src/routes/.ts`) is the spec for a mirror — match it; do not re-derive the wire shape from the v2 domain model or from memory. +- Register every new error code in **both** `agent-core-v2` and `packages/protocol`; an unmapped code is a wire break. +- Events stream over WS (`listen`), never over the REST mirror; do not invent REST polling for something v1 pushed as an event.