mirror of
https://github.com/MoonshotAI/kimi-code.git
synced 2026-08-23 07:37:18 +00:00
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:
parent
053ee9a150
commit
4698ef36cc
2 changed files with 252 additions and 1 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
250
.agents/skills/agent-core-dev/server-align.md
Normal file
250
.agents/skills/agent-core-dev/server-align.md
Normal 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.
|
||||
Loading…
Add table
Add a link
Reference in a new issue