docs(agent-core-dev): add server-align subskill guide

- add server-align.md covering server-v2 /api/v1 mirror and /api/v2 native surfaces
- document schema-fidelity rule reusing @moonshot-ai/protocol schemas
- describe the LegacyService edge-adapter pattern for v1-only behavior
- link the subskill from SKILL.md align workflows
This commit is contained in:
haozhe.yang 2026-06-30 10:32:07 +08:00
parent 053ee9a150
commit 4698ef36cc
2 changed files with 252 additions and 1 deletions

View file

@ -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 `<domain>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

View file

@ -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/<resource>.ts` — the contract you must match.
- `packages/server-v2/src/routes/<resource>.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/<resource>.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/<resource>.ts` first, with a `rest-<resource>.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<sessionId, …>`-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/<domain>Legacy/
├── <domain>Legacy.ts ← contract: protocol-typed interface + decorator
├── <domain>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<PromptSubmitResult>;
// ...the rest of the v1 contract, typed by protocol
}
export const IPromptLegacyService: ServiceIdentifier<IPromptLegacyService> =
createDecorator<IPromptLegacyService>('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 `<domain>Legacy` and the interface `I<Domain>LegacyService` (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/<resource>.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 `<domain>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/<resource>.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: <protocol shape>, request_id }`;
- each declared error envelope `{ code: <ErrorCode>, 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/<resource>.ts` path-for-path, verb-for-verb, action-for-action.
- [ ] Request and response schemas come from `@moonshot-ai/protocol` (`packages/protocol/src/rest/<resource>.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 `<domain>Legacy` / `I<Domain>LegacyService` 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 `<domain>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/<resource>.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.