mirror of
https://github.com/MoonshotAI/kimi-code.git
synced 2026-08-16 12:15:29 +00:00
Merge branch 'kimi-code-v2' of https://github.com/MoonshotAI/kimi-code into kimi-code-v2
This commit is contained in:
commit
8ba75bbc02
179 changed files with 2690 additions and 6158 deletions
|
|
@ -12,8 +12,6 @@
|
|||
"dev:cli:marketplace": "KIMI_CODE_DEV_MARKETPLACE_URL=https://code.kimi.com/kimi-code/plugins/marketplace.json pnpm -C apps/kimi-code run dev",
|
||||
"dev:web": "pnpm -C apps/kimi-web run dev",
|
||||
"dev:server": "pnpm -C packages/server-v2 run dev",
|
||||
"dev:core-example": "pnpm -C packages/agent-core-v2 run example",
|
||||
"dev:core-example:list": "pnpm -C packages/agent-core-v2 exec vitest list --config vitest.examples.config.ts",
|
||||
"build:plugin-marketplace": "pnpm -C apps/kimi-code run build:plugin-marketplace",
|
||||
"vis": "pnpm -C apps/vis run dev",
|
||||
"dev:docs": "pnpm -C docs install --ignore-workspace && pnpm -C docs run dev",
|
||||
|
|
|
|||
|
|
@ -4,9 +4,9 @@
|
|||
|
||||
## Examples
|
||||
|
||||
`examples/` holds runnable **domain-slice scenarios**: each `examples/<name>.example.ts` is a vitest test that exercises one subset of domains end-to-end, so a single file teaches a single capability. Each example builds its **own** container (the `createServices` flat harness for App-scope slices, or `bootstrap` + child scopes for tree-spanning slices), runs its slice's services for real, and stubs the collaborators outside the slice — so examples never need the full engine, and different examples stub different subsets. Tree-spanning examples redirect `KIMI_CODE_HOME` to a single `.vitest-results/kimi-code-{timestamp}/` per run (set once in `examples/_globalSetup.ts` and shared across every file in the invocation) and seed a file-backed `IAtomicDocumentStorage`, so persisted state is written to disk for inspection.
|
||||
> The runnable examples have moved to the standalone `kimi-code-mini-bench` package at `../kimi-code-mini-bench`. They are wired to `agent-core-v2` through a pnpm `link:` dependency and run as a separate Vitest project.
|
||||
|
||||
Run one from the repo root with `pnpm dev:core-example <name>` (a filename filter; omit `<name>` to run them all). Examples use their own vitest project (`agent-core-v2-examples`, via `vitest.examples.config.ts`), so they are separate from the real `test/` suite and their console output is shown per scenario. Add a new example by adding an `examples/<name>.example.ts` that imports only its slice's domains.
|
||||
Domain-slice scenarios that used to live in `examples/<name>.example.ts` are now maintained there. Each `*.example.ts` exercises one subset of domains end-to-end, builds its own container, runs its slice's services for real, and stubs collaborators outside the slice. See `../kimi-code-mini-bench/README.md` for how to run them.
|
||||
|
||||
## Comment conventions
|
||||
|
||||
|
|
|
|||
|
|
@ -57,7 +57,8 @@ package "Session scope (per session)" #EAFAF1 {
|
|||
rectangle "<b>approval</b>\n<size:9><i>Session</i></size>\n IApprovalService" as approval #D5F5E3
|
||||
rectangle "<b>question</b>\n<size:9><i>Session</i></size>\n IQuestionService" as question #D5F5E3
|
||||
rectangle "<b>process</b>\n<size:9><i>Session</i></size>\n IProcessRunner\n IProcess" as process #D5F5E3
|
||||
rectangle "<b>terminal</b>\n<size:9><i>Session</i></size>\n ITerminalService\n ITerminalBackend" as terminal #D5F5E3
|
||||
rectangle "<b>terminal</b>\n<size:9><i>App</i></size>\n IHostTerminalService" as terminal_app #D6EAF8
|
||||
rectangle "<b>sessionTerminal</b>\n<size:9><i>Session</i></size>\n ISessionTerminalService" as terminal_session #D5F5E3
|
||||
rectangle "<b>modelProvider</b>\n<size:9><i>Session</i></size>\n IModelProvider (seed)" as modelProvider #D5F5E3
|
||||
}
|
||||
|
||||
|
|
@ -141,8 +142,9 @@ agentFs --> workspaceContext #34495E
|
|||
agentFs --> execContext #34495E
|
||||
agentFs --> process #34495E
|
||||
process --> execContext #34495E
|
||||
terminal --> workspaceContext #34495E
|
||||
terminal --> session_context #34495E
|
||||
terminal_session --> terminal_app #34495E
|
||||
terminal_session --> workspaceContext #34495E
|
||||
terminal_session --> session_context #34495E
|
||||
approval --> interaction #34495E
|
||||
question --> interaction #34495E
|
||||
modelProvider --> config #34495E
|
||||
|
|
|
|||
|
|
@ -1,117 +0,0 @@
|
|||
# `agent-core-v2` DI × Scope examples
|
||||
|
||||
Runnable examples for the `agent-core-v2` engine. Each `*.example.ts` wires one
|
||||
**vertical functional slice** and teaches one DI × Scope concept. Read in the
|
||||
order below, they form a learning path from the container itself up to the
|
||||
edge-exposure layer — together they touch every registered service in the
|
||||
package.
|
||||
|
||||
## Run
|
||||
|
||||
```bash
|
||||
# every example (separate vitest project with its own config + globalSetup)
|
||||
pnpm --filter @moonshot-ai/agent-core-v2 example
|
||||
|
||||
# one example
|
||||
pnpm --filter @moonshot-ai/agent-core-v2 example -- examples/file-tools.example.ts
|
||||
```
|
||||
|
||||
Examples run under `vitest.examples.config.ts` (project `agent-core-v2-examples`),
|
||||
which sets up a shared `KIMI_CODE_HOME` via `_globalSetup.ts`. Each file gets an
|
||||
isolated module registry, so examples that clear and re-populate the scoped
|
||||
registry do not leak into those that rely on import-time registrations.
|
||||
|
||||
## Three styles, all legitimate
|
||||
|
||||
- **`_harness` composition root (preferred for real slices)** —
|
||||
`createSliceHost({ homeDir })` boots the **real** composition root (every
|
||||
domain barrel + `bootstrap` + the seeded `IExecContext` / `ISessionContext` /
|
||||
`IAgentScopeContext` values) and returns `{ app, session, agent }`. You resolve
|
||||
the subject by interface and spy on real collaborators, so the example does
|
||||
not hard-code a stub list and does not rot when a service gains a dependency
|
||||
(`agentLifecycle`, `goals-plans-todos`, `async-tasks`, `usage-replay`,
|
||||
`context`, `turn-loop`, `shell-web-tools`, `model-provider`, `extensions`,
|
||||
`edge-gateway-rpc`, `config`, `session`, `oauth`, `scope`, `session-skill`).
|
||||
- **`bootstrap` + real services + `console.log`** — boots the production
|
||||
composition root and shows real behaviour against real files under
|
||||
`KIMI_CODE_HOME`. Best for slices where the on-disk result is the point
|
||||
(`persistence`, `wire-record`, `observability`).
|
||||
- **`createScopedTestHost` + explicit re-registration + stubs** — builds a
|
||||
minimal scope tree, registers only the slice's services, and stubs the
|
||||
collaborators outside it (`stubPair`). Best for isolating one wiring concept
|
||||
with no I/O (`di-container`, `file-tools`, `interaction`, `feature-flags`,
|
||||
`events`, `host`, `tool-framework`, `permission`, `compaction`).
|
||||
|
||||
All three styles resolve the subject under test **by interface** through the
|
||||
scope tree — never `new`.
|
||||
|
||||
## Learning path
|
||||
|
||||
```text
|
||||
L0 di-container · scope
|
||||
└─ L1 observability · config · feature-flags · persistence · events · host
|
||||
└─ L2 wire-record · session · sessionIndex · agentLifecycle
|
||||
· tool-framework · context · turn-loop
|
||||
└─ L3 file-tools · shell-web · permission · goals · async
|
||||
· model · compaction · extensions · oauth · interaction
|
||||
· edge · usage · replay
|
||||
```
|
||||
|
||||
## Roadmap
|
||||
|
||||
Status: ✅ exists · ⬜ planned.
|
||||
|
||||
### L0 — the framework
|
||||
|
||||
| file | status | scope | concept |
|
||||
|---|---|---|---|
|
||||
| `di-container` | ✅ | A/S/Ag | toy mechanics: `createDecorator`, `registerScopedService`, three `LifecycleScope` tiers, child→parent injection, eager vs delayed, disposal order |
|
||||
| `scope` | ✅ | A/S | real services: App singletons vs per-Session instances (`ILogService` shared, `ISessionMetadata` per session) |
|
||||
|
||||
### L1 — foundational services
|
||||
|
||||
| file | status | scope | concept |
|
||||
|---|---|---|---|
|
||||
| `observability` | ✅ | A | `log` + `telemetry`, child logger and context-scoped telemetry |
|
||||
| `config` | ✅ | A | every `registerSection` owner populating one shared `IConfigService` |
|
||||
| `feature-flags` | ✅ | A | `flag` real, `config` stubbed; env → config → default resolution |
|
||||
| `persistence` | ✅ | A | Store → Storage → backend; atomic doc / append-log / blob against real `~/.kimi-code` files |
|
||||
| `events` | ✅ | A/Ag | soft coupling via `publish`/`subscribe`/`emit`/`on` edges |
|
||||
| `host` | ✅ | A/S | host abstraction, the kaos `IExecContext` boundary |
|
||||
|
||||
### L2 — business foundations (patterns)
|
||||
|
||||
| file | status | scope | concept |
|
||||
|---|---|---|---|
|
||||
| `wire-record` | ✅ | Ag | append-log primitive; `append` + `restore` replay chain |
|
||||
| `session` | ✅ | A/S | `sessionLifecycle` + `sessionMetadata`; session as a durable, tracked entity |
|
||||
| `sessionIndex` | ✅ | A | business-specific Store building a query read-model |
|
||||
| `session-skill` | ✅ | A/S | session skill catalog: load skills from the current `workDir` and inspect each skill's `source` provenance |
|
||||
| `agentLifecycle` | ✅ | S | Agent-scope creation, parent/child |
|
||||
| `tool-framework` | ✅ | Ag | registry pattern, runtime state |
|
||||
| `context` | ✅ | Ag | event-sourced context, projection |
|
||||
| `turn-loop` | ✅ | Ag | turn lifecycle, hooks, step loop |
|
||||
|
||||
### L3 — complete features (slices)
|
||||
|
||||
| file | status | scope | concept |
|
||||
|---|---|---|---|
|
||||
| `file-tools` | ✅ | A/S/Ag | the smallest real 3-tier slice: Agent service injecting Session + App ancestors + an Agent peer; marker-interface service registered `Eager` |
|
||||
| `shell-web-tools` | ✅ | Ag | tool implementations (bash / web / ask) |
|
||||
| `permission` | ✅ | Ag | chain-of-responsibility, policy registry |
|
||||
| `goals-plans-todos` | ✅ | Ag | append-log CRUD domains |
|
||||
| `async-tasks` | ✅ | Ag | long-running tasks, child scopes (background / cron / swarm) |
|
||||
| `model-provider` | ✅ | A/S/Ag | provider abstraction, the kosong boundary |
|
||||
| `compaction` | ✅ | Ag | context-management strategy |
|
||||
| `extensions` | ✅ | A/S/Ag | plugin / mcp / skill extension points |
|
||||
| `oauth` | ✅ | A | device-code login + managed `/models` refresh, config-driven |
|
||||
| `interaction` | ✅ | S | `interaction` kernel + `approval` / `question` facades through the Session scope |
|
||||
| `edge-gateway-rpc` | ✅ | A/Ag | `resource:action`, WS events, edge exposure |
|
||||
| `usage-replay` | ✅ | Ag | usage metering, replay, system reminder, external hooks |
|
||||
|
||||
## Coverage
|
||||
|
||||
The existing examples plus the planned ones cover the ~134 registered services in
|
||||
`agent-core-v2`. The 29 `unresolved` tokens in the dep-graph are external
|
||||
boundaries (kaos / kosong / storage / vscode DI) and appear as `stubPair(...)`
|
||||
seeds, not as real implementations.
|
||||
|
|
@ -1,27 +0,0 @@
|
|||
/**
|
||||
* Vitest global setup for the agent-core-v2 examples.
|
||||
*
|
||||
* Picks a single `KIMI_CODE_HOME` for the whole run (one
|
||||
* `.vitest-results/kimi-code-{timestamp}/` directory) and publishes it through
|
||||
* the environment so every example file in the invocation writes into the same
|
||||
* directory. The previous value is restored in the teardown.
|
||||
*/
|
||||
|
||||
import { mkdirSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
|
||||
export default function setup(): () => void {
|
||||
const previous = process.env['KIMI_CODE_HOME'];
|
||||
const ts = new Date().toISOString().replaceAll(/[-:.Z]/g, '');
|
||||
const homeDir = join(import.meta.dirname, '..', '.vitest-results', `kimi-code-${ts}`);
|
||||
mkdirSync(homeDir, { recursive: true });
|
||||
process.env['KIMI_CODE_HOME'] = homeDir;
|
||||
|
||||
return () => {
|
||||
if (previous === undefined) {
|
||||
delete process.env['KIMI_CODE_HOME'];
|
||||
} else {
|
||||
process.env['KIMI_CODE_HOME'] = previous;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
|
@ -1,164 +0,0 @@
|
|||
/**
|
||||
* Shared harness for the `agent-core-v2` examples.
|
||||
*
|
||||
* Boots the **real** composition root so examples resolve services through the
|
||||
* same wiring production uses, and only the genuine external boundaries (the
|
||||
* seeded `IExecContext` value, plus anything a specific example wants to
|
||||
* control) are supplied as seeds. This keeps examples from rotting when a
|
||||
* service gains a constructor dependency: the dependency is already registered
|
||||
* by its domain barrel, so the example does not hard-code a stub list.
|
||||
*
|
||||
* How it works:
|
||||
* 1. `import '#/index'` loads every domain barrel as a side effect, which
|
||||
* populates the scoped registry with all real `registerScopedService`
|
||||
* descriptors.
|
||||
* 2. `bootstrap(...)` builds the real App scope (storage roles, bootstrap
|
||||
* snapshot, skill store) and picks up every App-scope descriptor.
|
||||
* 3. `createChild(Session, …)` / `createChild(Agent, …)` pick up the Session
|
||||
* and Agent descriptors. The seeded *values* (`IExecContext`,
|
||||
* `ISessionContext`, `IAgentScopeContext`) — which are not constructed
|
||||
* services and so absent from the registry — are provided here, mirroring
|
||||
* what `sessionLifecycle` / `agentLifecycle` seed when they open scopes.
|
||||
* 4. Per-example `sessionSeeds` / `agentSeeds` override any registration, so
|
||||
* an example can substitute a capturing fake for the one collaborator it
|
||||
* wants to assert on (for example `IAgentRecordService`).
|
||||
*
|
||||
* Examples using this harness must NOT call `_clearScopedRegistryForTests()`:
|
||||
* the registry populated by step 1 is what makes resolution work.
|
||||
*/
|
||||
|
||||
import '#/index';
|
||||
|
||||
import { LifecycleScope, type Scope, type ScopeSeed } from '#/_base/di/scope';
|
||||
import {
|
||||
bootstrap,
|
||||
IBootstrapService,
|
||||
type BootstrapInput,
|
||||
type IBootstrapService as IBootstrapServiceType,
|
||||
} from '#/app/bootstrap';
|
||||
import {
|
||||
ILogOptions,
|
||||
resolveLoggingConfig,
|
||||
} from '#/app/log/logConfig';
|
||||
import {
|
||||
IAgentScopeContext,
|
||||
makeAgentScopeContext,
|
||||
} from '#/agent/scopeContext';
|
||||
import { createExecContext, execContextSeed } from '#/os/interface/execContext';
|
||||
import {
|
||||
makeSessionContext,
|
||||
sessionContextSeed,
|
||||
} from '#/session/sessionContext';
|
||||
|
||||
export interface SliceHost {
|
||||
readonly app: Scope;
|
||||
/** The default Session scope created by the harness (`sessionId`, default `s1`). */
|
||||
readonly session: Scope;
|
||||
/** The default Agent scope under `session` (`agentId`, default `main`). */
|
||||
readonly agent: Scope;
|
||||
/** Create an additional seeded Session scope under the App root (for
|
||||
* multi-session examples). Shares the App scope and `KIMI_CODE_HOME`. */
|
||||
newSession(id: string, overrides?: { cwd?: string; seeds?: ScopeSeed }): Scope;
|
||||
/** Create an additional seeded Agent scope under the default Session. */
|
||||
newAgent(id: string, overrides?: { seeds?: ScopeSeed }): Scope;
|
||||
dispose(): void;
|
||||
}
|
||||
|
||||
export interface SliceHostOptions {
|
||||
/** Root directory for the real file-backed services (storage, config, logs). */
|
||||
readonly homeDir: string;
|
||||
/** Working directory seeded into `IExecContext`. Defaults to `homeDir`. */
|
||||
readonly cwd?: string;
|
||||
/** Extra App-scope seeds (rarely needed; the composition root is complete). */
|
||||
readonly appSeeds?: ScopeSeed;
|
||||
/** Extra Session-scope seeds (overrides for the slice under test). */
|
||||
readonly sessionSeeds?: ScopeSeed;
|
||||
/** Extra Agent-scope seeds (overrides for the slice under test). */
|
||||
readonly agentSeeds?: ScopeSeed;
|
||||
/** Session / Agent ids. */
|
||||
readonly sessionId?: string;
|
||||
readonly agentId?: string;
|
||||
/** Workspace id used to derive the agent persistence scope. */
|
||||
readonly workspaceId?: string;
|
||||
}
|
||||
|
||||
function sessionSeeds(
|
||||
boot: IBootstrapServiceType,
|
||||
workspaceId: string,
|
||||
sessionId: string,
|
||||
cwd: string,
|
||||
extra: ScopeSeed,
|
||||
): ScopeSeed {
|
||||
return [
|
||||
...execContextSeed(createExecContext(cwd)),
|
||||
...sessionContextSeed(
|
||||
makeSessionContext({
|
||||
sessionId,
|
||||
workspaceId,
|
||||
sessionDir: boot.sessionDir(workspaceId, sessionId),
|
||||
sessionScope: boot.sessionScope(workspaceId, sessionId),
|
||||
}),
|
||||
),
|
||||
...extra,
|
||||
];
|
||||
}
|
||||
|
||||
function agentSeeds(
|
||||
boot: IBootstrapServiceType,
|
||||
workspaceId: string,
|
||||
sessionId: string,
|
||||
agentId: string,
|
||||
extra: ScopeSeed,
|
||||
): ScopeSeed {
|
||||
return [
|
||||
[
|
||||
IAgentScopeContext,
|
||||
makeAgentScopeContext({
|
||||
agentId,
|
||||
agentScope: boot.agentScope(workspaceId, sessionId, agentId),
|
||||
}),
|
||||
],
|
||||
...extra,
|
||||
];
|
||||
}
|
||||
|
||||
export function createSliceHost(options: SliceHostOptions): SliceHost {
|
||||
const input: BootstrapInput = { homeDir: options.homeDir };
|
||||
// `ILogOptions` is an App-scope seeded value (built from env + homeDir); the
|
||||
// real startup seeds it before any log writer is constructed.
|
||||
const logSeed: ScopeSeed = [
|
||||
[ILogOptions, resolveLoggingConfig({ homeDir: options.homeDir, env: process.env })],
|
||||
];
|
||||
const { app } = bootstrap(input, [...logSeed, ...(options.appSeeds ?? [])]);
|
||||
|
||||
const sessionId = options.sessionId ?? 's1';
|
||||
const agentId = options.agentId ?? 'main';
|
||||
const workspaceId = options.workspaceId ?? 'ws_example';
|
||||
const cwd = options.cwd ?? options.homeDir;
|
||||
|
||||
const boot = app.accessor.get(IBootstrapService);
|
||||
|
||||
const session = app.createChild(LifecycleScope.Session, sessionId, {
|
||||
extra: sessionSeeds(boot, workspaceId, sessionId, cwd, options.sessionSeeds ?? []),
|
||||
});
|
||||
const agent = session.createChild(LifecycleScope.Agent, agentId, {
|
||||
extra: agentSeeds(boot, workspaceId, sessionId, agentId, options.agentSeeds ?? []),
|
||||
});
|
||||
|
||||
return {
|
||||
app,
|
||||
session,
|
||||
agent,
|
||||
newSession(id, overrides) {
|
||||
return app.createChild(LifecycleScope.Session, id, {
|
||||
extra: sessionSeeds(boot, workspaceId, id, overrides?.cwd ?? cwd, overrides?.seeds ?? []),
|
||||
});
|
||||
},
|
||||
newAgent(id, overrides) {
|
||||
return session.createChild(LifecycleScope.Agent, id, {
|
||||
extra: agentSeeds(boot, workspaceId, sessionId, id, overrides?.seeds ?? []),
|
||||
});
|
||||
},
|
||||
dispose: () => app.dispose(),
|
||||
};
|
||||
}
|
||||
|
|
@ -1,114 +0,0 @@
|
|||
/**
|
||||
* Scenario: the **agentLifecycle** slice — creating Agent scopes under a
|
||||
* Session and the parent/child agent relationship the session tracks.
|
||||
*
|
||||
* Concept taught: a Session owns a set of Agents. `IAgentLifecycleService`
|
||||
* (Session scope) is the factory — every `create(...)` builds a new child
|
||||
* **Agent** scope beneath the session, seeds its identity
|
||||
* (`IAgentScopeContext.agentId`) plus per-agent services (wire record, blob
|
||||
* store, MCP), and registers it in the session's agent set. The session then
|
||||
* tracks its agents through `list` / `getHandle` and broadcasts `onDidCreate` /
|
||||
* `onDidDispose` as the set changes. Because each Agent scope is a *child* of
|
||||
* the Session scope, an agent resolves its own Agent-scope seeds and also
|
||||
* inherits Session/App ancestors upward through the scope tree.
|
||||
*
|
||||
* Wiring: the real composition root (`_harness`) provides every collaborator
|
||||
* (`ISessionMetadata`, `IAgentMcpService` and its peers, …), so the slice runs
|
||||
* for real with no hand-rolled stub list. We spy on `ISessionMetadata` only to
|
||||
* observe the `registerAgent` call.
|
||||
*
|
||||
* Prerequisites: example 01 (container & scope tree), example 13 (file-tools slice).
|
||||
*
|
||||
* Run:
|
||||
* pnpm --filter @moonshot-ai/agent-core-v2 example -- examples/agentLifecycle.example.ts
|
||||
*/
|
||||
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { IAgentScopeContext } from '#/agent/scopeContext';
|
||||
import { IHostEnvironment } from '#/os/interface/hostEnvironment';
|
||||
import {
|
||||
IAgentLifecycleService,
|
||||
} from '#/session/agentLifecycle';
|
||||
import { ISessionMetadata } from '#/session/sessionMetadata';
|
||||
import { ISessionWorkspaceContext } from '#/session/workspaceContext';
|
||||
|
||||
import { createSliceHost, type SliceHost } from './_harness';
|
||||
|
||||
describe('agentLifecycle slice (Agent scopes under a Session)', () => {
|
||||
let host: SliceHost;
|
||||
afterEach(() => host?.dispose());
|
||||
|
||||
async function setUp() {
|
||||
host = createSliceHost({ homeDir: process.env['KIMI_CODE_HOME']! });
|
||||
// The host environment probes the OS asynchronously; the real composition
|
||||
// root awaits this before opening a Session scope, so Agent-scope services
|
||||
// (which read `osKind`/`pathClass` at construction) see a ready snapshot.
|
||||
await host.app.accessor.get(IHostEnvironment).ready;
|
||||
return host.session.accessor.get(IAgentLifecycleService);
|
||||
}
|
||||
|
||||
it('creates an agent under the session and tracks it in list/getHandle', async () => {
|
||||
const lifecycle = await setUp();
|
||||
|
||||
const agent = await lifecycle.create({ agentId: 'main' });
|
||||
|
||||
expect(agent.id).toBe('main');
|
||||
expect(lifecycle.getHandle('main')).toBe(agent);
|
||||
expect(lifecycle.list().map((h) => h.id)).toEqual(['main']);
|
||||
});
|
||||
|
||||
it('tracks multiple agents and assigns distinct ids', async () => {
|
||||
const lifecycle = await setUp();
|
||||
|
||||
const a = await lifecycle.create({});
|
||||
const b = await lifecycle.create({});
|
||||
|
||||
expect(a.id).not.toBe(b.id);
|
||||
expect(lifecycle.list().map((h) => h.id)).toEqual(expect.arrayContaining([a.id, b.id]));
|
||||
});
|
||||
|
||||
it('persists each created agent into the session metadata registry', async () => {
|
||||
const lifecycle = await setUp();
|
||||
const metadata = host.session.accessor.get(ISessionMetadata);
|
||||
const registerAgent = vi.spyOn(metadata, 'registerAgent').mockResolvedValue();
|
||||
|
||||
await lifecycle.create({ agentId: 'child', forkedFrom: 'main', swarmItem: 'swarm-1' });
|
||||
|
||||
expect(registerAgent).toHaveBeenCalledWith(
|
||||
'child',
|
||||
expect.objectContaining({ forkedFrom: 'main', swarmItem: 'swarm-1' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('fires onDidCreate on create and onDidDispose on remove', async () => {
|
||||
const lifecycle = await setUp();
|
||||
|
||||
const created: string[] = [];
|
||||
const disposed: string[] = [];
|
||||
const subCreate = lifecycle.onDidCreate((h) => created.push(h.id));
|
||||
const subDispose = lifecycle.onDidDispose((id) => disposed.push(id));
|
||||
|
||||
const agent = await lifecycle.create({});
|
||||
expect(created).toEqual([agent.id]);
|
||||
|
||||
await lifecycle.remove(agent.id);
|
||||
expect(disposed).toEqual([agent.id]);
|
||||
expect(lifecycle.getHandle(agent.id)).toBeUndefined();
|
||||
|
||||
subCreate.dispose();
|
||||
subDispose.dispose();
|
||||
});
|
||||
|
||||
it('builds each agent as a child scope that inherits Session ancestors', async () => {
|
||||
const lifecycle = await setUp();
|
||||
|
||||
const agent = await lifecycle.create({ agentId: 'main' });
|
||||
|
||||
// Own Agent-scope seed: the identity the lifecycle stamped on creation.
|
||||
expect(agent.accessor.get(IAgentScopeContext).agentId).toBe('main');
|
||||
// Upward resolution to the Session parent: a Session-scope service the agent
|
||||
// never registered itself is still visible through the scope tree.
|
||||
expect(agent.accessor.get(ISessionWorkspaceContext).workDir).toBe(process.env['KIMI_CODE_HOME']);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,111 +0,0 @@
|
|||
/**
|
||||
* Scenario: the **async-tasks** slice — long-running work owned by an Agent-scope service.
|
||||
*
|
||||
* Concept taught: background tasks, cron tasks, and swarm (multi-agent) runs
|
||||
* all share one shape — an *asynchronous task whose state and output are owned
|
||||
* by an Agent-scope service*, decoupled from whoever triggered it. The caller
|
||||
* fires and forgets; the service retains the task, drives its lifecycle, and
|
||||
* records the outcome.
|
||||
*
|
||||
* - `IAgentBackgroundService` — owns running/restored background tasks and a
|
||||
* bounded output ring.
|
||||
* - `IAgentCronService` — owns the scheduled cron task set and its fire loop.
|
||||
* - `IAgentSwarmService` — owns swarm-mode state for multi-agent runs and
|
||||
* auto-exits when the turn ends.
|
||||
*
|
||||
* All three are bound at Agent scope, but background and cron each inject ~9
|
||||
* collaborators. We demonstrate the shared shape with `IAgentSwarmService`
|
||||
* because it is the lightest of the three. Its auto-exit is driven by the real
|
||||
* `IAgentTurnService` `onEnded` hook — the same path the agent loop uses.
|
||||
*
|
||||
* Wiring: the real composition root (`_harness`) provides every collaborator;
|
||||
* we spy on the real `IAgentRecordService` only to observe the task records.
|
||||
*
|
||||
* Prerequisites: example 01 (container & scope tree).
|
||||
*
|
||||
* Run:
|
||||
* pnpm --filter @moonshot-ai/agent-core-v2 example -- examples/async-tasks.example.ts
|
||||
*/
|
||||
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { IAgentRecordService } from '#/agent/record';
|
||||
import { IAgentSwarmService } from '#/agent/swarm';
|
||||
import {
|
||||
IAgentTurnService,
|
||||
type Turn,
|
||||
type TurnResult,
|
||||
} from '#/agent/turn';
|
||||
|
||||
import { createSliceHost, type SliceHost } from './_harness';
|
||||
|
||||
function fakeTurn(id = 1): Turn {
|
||||
return {
|
||||
id,
|
||||
abortController: new AbortController(),
|
||||
ready: Promise.resolve(),
|
||||
result: Promise.resolve<TurnResult>({ reason: 'completed' }),
|
||||
} as Turn;
|
||||
}
|
||||
|
||||
describe('async-tasks slice (Agent-scope swarm task)', () => {
|
||||
let host: SliceHost;
|
||||
afterEach(() => host?.dispose());
|
||||
|
||||
function setUp() {
|
||||
host = createSliceHost({ homeDir: process.env['KIMI_CODE_HOME']! });
|
||||
const records = host.agent.accessor.get(IAgentRecordService);
|
||||
const appended: Array<{ type: string }> = [];
|
||||
vi.spyOn(records, 'append').mockImplementation((r) => {
|
||||
appended.push(r as { type: string });
|
||||
});
|
||||
const swarm = host.agent.accessor.get(IAgentSwarmService);
|
||||
const turn = host.agent.accessor.get(IAgentTurnService);
|
||||
const types = () => appended.map((r) => r.type).filter((t) => t.startsWith('swarm_mode'));
|
||||
return { swarm, turn, types };
|
||||
}
|
||||
|
||||
it('owns the swarm task state and records enter/exit', () => {
|
||||
const { swarm, types } = setUp();
|
||||
|
||||
expect(swarm.isActive).toBe(false);
|
||||
swarm.enter('manual');
|
||||
expect(swarm.isActive).toBe(true);
|
||||
swarm.exit();
|
||||
expect(swarm.isActive).toBe(false);
|
||||
|
||||
expect(types()).toEqual(['swarm_mode.enter', 'swarm_mode.exit']);
|
||||
});
|
||||
|
||||
it('treats a duplicate enter as a no-op (guards task state)', () => {
|
||||
const { swarm, types } = setUp();
|
||||
|
||||
swarm.enter('manual');
|
||||
swarm.enter('task');
|
||||
|
||||
expect(swarm.isActive).toBe(true);
|
||||
expect(types()).toEqual(['swarm_mode.enter']);
|
||||
});
|
||||
|
||||
it('auto-exits a task-triggered swarm run when the turn ends', async () => {
|
||||
const { swarm, turn, types } = setUp();
|
||||
|
||||
swarm.enter('task');
|
||||
expect(swarm.isActive).toBe(true);
|
||||
|
||||
await turn.hooks.onEnded.run({ turn: fakeTurn(), result: { reason: 'completed' } });
|
||||
|
||||
expect(swarm.isActive).toBe(false);
|
||||
expect(types()).toEqual(['swarm_mode.enter', 'swarm_mode.exit']);
|
||||
});
|
||||
|
||||
it('keeps a manual swarm run active across turn end (rule flips with trigger)', async () => {
|
||||
const { swarm, turn, types } = setUp();
|
||||
|
||||
swarm.enter('manual');
|
||||
await turn.hooks.onEnded.run({ turn: fakeTurn(), result: { reason: 'completed' } });
|
||||
|
||||
expect(swarm.isActive).toBe(true);
|
||||
expect(types()).toEqual(['swarm_mode.enter']);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,240 +0,0 @@
|
|||
/**
|
||||
* Scenario: the **compaction** slice — the context-size signal that chooses
|
||||
* between micro and full compaction.
|
||||
*
|
||||
* Concept taught: context management is driven by a single *reading* — the
|
||||
* Agent-scope `IAgentContextSizeService` reports how large the conversation has
|
||||
* grown (`getStatus().contextTokensWithPending`). Two distinct Agent-scope
|
||||
* strategies consume that same reading and fire at different thresholds:
|
||||
*
|
||||
* - **micro compaction** (`IAgentMicroCompactionService`) — cheap; clears the
|
||||
* bodies of old tool results. It triggers when the reading reaches
|
||||
* `minContextUsageRatio` (0.5) of the model window.
|
||||
* - **full compaction** (`IAgentFullCompactionService`) — expensive; asks the
|
||||
* LLM to summarize the prefix. It triggers when the reading reaches the
|
||||
* model window's `triggerRatio` (0.85), via
|
||||
* `DefaultCompactionStrategy.shouldCompact`.
|
||||
*
|
||||
* We deliberately do NOT wire the two compaction services end-to-end here:
|
||||
* each injects roughly 8–11 heavy collaborators (context memory, wire record,
|
||||
* profile, loop, turn, LLM requester, …). Instead we demonstrate the smallest
|
||||
* true thing: the real `AgentContextSizeService` is resolved through the scope
|
||||
* tree with only its two genuine collaborators stubbed. We assert its real
|
||||
* behavior — a measurement updates the reading, splicing messages into context
|
||||
* memory raises the pending estimate through the real `onSpliced` hook, and a
|
||||
* change emits the live `agent.status.updated` signal — and then show that
|
||||
* this real reading flips a faithful micro/full decision as it crosses the
|
||||
* 0.5 and 0.85 thresholds.
|
||||
*
|
||||
* Real: `AgentContextSizeService`. Stubbed: `IAgentContextMemoryService`
|
||||
* (in-memory fake carrying a real `onSpliced` hook) and `IAgentRecordService`
|
||||
* (append / signal / define doubles). No App- or Session-scope seeds are
|
||||
* required, because the size service injects only those two Agent-scope peers.
|
||||
*
|
||||
* Prerequisites: example 01 (container & scope tree).
|
||||
*
|
||||
* Run:
|
||||
* pnpm --filter @moonshot-ai/agent-core-v2 example -- examples/compaction.example.ts
|
||||
*/
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import {
|
||||
LifecycleScope,
|
||||
_clearScopedRegistryForTests,
|
||||
registerScopedService,
|
||||
} from '#/_base/di/scope';
|
||||
import { createScopedTestHost, stubPair } from '#/_base/di/test';
|
||||
import { estimateTokensForMessages } from '#/_base/utils/tokens';
|
||||
|
||||
import {
|
||||
AgentContextSizeService,
|
||||
type ContextSizeStatus,
|
||||
IAgentContextSizeService,
|
||||
} from '#/agent/contextSize';
|
||||
import {
|
||||
type ContextMessage,
|
||||
IAgentContextMemoryService,
|
||||
} from '#/agent/contextMemory';
|
||||
import { IAgentRecordService } from '#/agent/record';
|
||||
import { createHooks } from '#/hooks';
|
||||
|
||||
/**
|
||||
* In-memory `IAgentContextMemoryService` with a real `onSpliced` hook. The real
|
||||
* `AgentContextSizeService` registers a handler on this hook in its
|
||||
* constructor, so splicing here drives the size service exactly as the real
|
||||
* context memory would.
|
||||
*/
|
||||
function fakeContextMemory(): IAgentContextMemoryService {
|
||||
const messages: ContextMessage[] = [];
|
||||
const hooks = createHooks<{
|
||||
onSpliced: {
|
||||
start: number;
|
||||
deleteCount: number;
|
||||
messages: ContextMessage[];
|
||||
tokens?: number;
|
||||
};
|
||||
}>(['onSpliced']);
|
||||
return {
|
||||
_serviceBrand: undefined,
|
||||
hooks,
|
||||
get: () => [...messages],
|
||||
splice: (start, deleteCount, inserted, tokens) => {
|
||||
const added = [...inserted];
|
||||
messages.splice(start, deleteCount, ...added);
|
||||
void hooks.onSpliced.run({
|
||||
start,
|
||||
deleteCount,
|
||||
messages: added,
|
||||
tokens,
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** `IAgentRecordService` double — exposes the `signal` spy so tests can assert the live size signal. */
|
||||
function fakeRecordService() {
|
||||
const signal = vi.fn();
|
||||
const service = {
|
||||
_serviceBrand: undefined,
|
||||
append: vi.fn(),
|
||||
signal,
|
||||
define: () => ({ dispose: () => {} }),
|
||||
restoring: null,
|
||||
} as unknown as IAgentRecordService;
|
||||
return { service, signal };
|
||||
}
|
||||
|
||||
type CompactionDecision = 'none' | 'micro' | 'full';
|
||||
|
||||
/**
|
||||
* Faithful mirror of the two real thresholds, both consuming the same real
|
||||
* reading (`status.contextTokensWithPending`):
|
||||
* - micro compaction fires at `minContextUsageRatio` (0.5) of the window
|
||||
* (see `AgentMicroCompactionService.contextSizeRatio` / `detect`);
|
||||
* - full compaction fires at the model window `triggerRatio` (0.85)
|
||||
* (see `DefaultCompactionStrategy.shouldCompact`).
|
||||
*/
|
||||
function decideCompaction(
|
||||
status: ContextSizeStatus,
|
||||
maxContextTokens: number,
|
||||
): CompactionDecision {
|
||||
if (maxContextTokens <= 0) return 'none';
|
||||
const ratio = status.contextTokensWithPending / maxContextTokens;
|
||||
if (ratio >= 0.85) return 'full';
|
||||
if (ratio >= 0.5) return 'micro';
|
||||
return 'none';
|
||||
}
|
||||
|
||||
describe('compaction slice (context-size signal → micro vs full decision)', () => {
|
||||
beforeEach(() => {
|
||||
_clearScopedRegistryForTests();
|
||||
// Register the one real Agent-scope service of the slice. Its two
|
||||
// collaborators are supplied as stubPair seeds on the Agent scope below.
|
||||
registerScopedService(
|
||||
LifecycleScope.Agent,
|
||||
IAgentContextSizeService,
|
||||
AgentContextSizeService,
|
||||
);
|
||||
});
|
||||
|
||||
it('reports a zero reading, then reflects a real measurement', () => {
|
||||
const host = createScopedTestHost();
|
||||
const session = host.child(LifecycleScope.Session, 's1');
|
||||
const agent = host.childOf(session, LifecycleScope.Agent, 'main', [
|
||||
stubPair(IAgentContextMemoryService, fakeContextMemory()),
|
||||
stubPair(IAgentRecordService, fakeRecordService().service),
|
||||
]);
|
||||
|
||||
const size = agent.accessor.get(IAgentContextSizeService);
|
||||
expect(size.getStatus()).toEqual({
|
||||
contextTokens: 0,
|
||||
contextTokensWithPending: 0,
|
||||
});
|
||||
|
||||
size.measured(0, 42_000);
|
||||
expect(size.getStatus()).toEqual({
|
||||
contextTokens: 42_000,
|
||||
contextTokensWithPending: 42_000,
|
||||
});
|
||||
|
||||
host.dispose();
|
||||
});
|
||||
|
||||
it('emits agent.status.updated when the measured size changes', () => {
|
||||
const record = fakeRecordService();
|
||||
const host = createScopedTestHost();
|
||||
const session = host.child(LifecycleScope.Session, 's1');
|
||||
const agent = host.childOf(session, LifecycleScope.Agent, 'main', [
|
||||
stubPair(IAgentContextMemoryService, fakeContextMemory()),
|
||||
stubPair(IAgentRecordService, record.service),
|
||||
]);
|
||||
|
||||
const size = agent.accessor.get(IAgentContextSizeService);
|
||||
size.measured(0, 80_000);
|
||||
|
||||
// The live "context-size signal" the rest of the agent reacts to.
|
||||
expect(record.signal).toHaveBeenCalledWith({
|
||||
type: 'agent.status.updated',
|
||||
contextTokens: 80_000,
|
||||
});
|
||||
|
||||
host.dispose();
|
||||
});
|
||||
|
||||
it('tracks pending tokens as messages are spliced into context memory', () => {
|
||||
const context = fakeContextMemory();
|
||||
const host = createScopedTestHost();
|
||||
const session = host.child(LifecycleScope.Session, 's1');
|
||||
const agent = host.childOf(session, LifecycleScope.Agent, 'main', [
|
||||
stubPair(IAgentContextMemoryService, context),
|
||||
stubPair(IAgentRecordService, fakeRecordService().service),
|
||||
]);
|
||||
|
||||
const size = agent.accessor.get(IAgentContextSizeService);
|
||||
// Wake the delayed proxy so its constructor runs and registers the
|
||||
// `onSpliced` handler before we splice.
|
||||
size.getStatus();
|
||||
|
||||
const messages: ContextMessage[] = [
|
||||
{ role: 'user', content: [{ type: 'text', text: 'hello world' }] },
|
||||
{
|
||||
role: 'assistant',
|
||||
content: [{ type: 'text', text: 'hi there, how can I help?' }],
|
||||
toolCalls: [],
|
||||
},
|
||||
];
|
||||
context.splice(0, 0, messages);
|
||||
|
||||
// No measurement yet, so the whole estimate is "pending".
|
||||
expect(size.getStatus().contextTokens).toBe(0);
|
||||
expect(size.getStatus().contextTokensWithPending).toBe(
|
||||
estimateTokensForMessages(messages),
|
||||
);
|
||||
|
||||
host.dispose();
|
||||
});
|
||||
|
||||
it('drives a micro vs full compaction decision from the size reading', () => {
|
||||
const host = createScopedTestHost();
|
||||
const session = host.child(LifecycleScope.Session, 's1');
|
||||
const agent = host.childOf(session, LifecycleScope.Agent, 'main', [
|
||||
stubPair(IAgentContextMemoryService, fakeContextMemory()),
|
||||
stubPair(IAgentRecordService, fakeRecordService().service),
|
||||
]);
|
||||
|
||||
const size = agent.accessor.get(IAgentContextSizeService);
|
||||
const window = 200_000; // model max_context_tokens
|
||||
|
||||
size.measured(0, 40_000); // 0.20 of the window
|
||||
expect(decideCompaction(size.getStatus(), window)).toBe('none');
|
||||
|
||||
size.measured(0, 120_000); // 0.60 of the window → micro (>= 0.5)
|
||||
expect(decideCompaction(size.getStatus(), window)).toBe('micro');
|
||||
|
||||
size.measured(0, 180_000); // 0.90 of the window → full (>= 0.85)
|
||||
expect(decideCompaction(size.getStatus(), window)).toBe('full');
|
||||
|
||||
host.dispose();
|
||||
});
|
||||
});
|
||||
|
|
@ -1,201 +0,0 @@
|
|||
/**
|
||||
* Scenario: the **config** slice — every Service that registers a config
|
||||
* section, shown against one shared, file-backed `IConfigService`.
|
||||
*
|
||||
* `config` holds no schema of its own; each domain that consumes a config owns
|
||||
* its section and registers it from its Service constructor. This example
|
||||
* resolves **every** current section owner so its `registerSection` runs, then
|
||||
* reads the single `IConfigRegistry` / `IConfigService` they all populated:
|
||||
*
|
||||
* App-scope owners:
|
||||
* - `IModelService` → `models` (+ the `KIMI_MODEL_*` overlay)
|
||||
* - `IProviderService` → `providers`
|
||||
* - `IFlagService` → `experimental`
|
||||
*
|
||||
* Agent-scope owners:
|
||||
* - `IAgentBackgroundService` → `background`
|
||||
* - `IAgentCronService` → `cron`
|
||||
* - `IAgentPermissionRulesService`→ `permission`
|
||||
* - `IAgentProfileService` → `thinking`, `defaultThinking`
|
||||
* - `IAgentLoopService` → `loopControl`
|
||||
* - `IAgentExternalHooksService` → `hooks`
|
||||
*
|
||||
* Wiring: the real composition root (`_harness`) provides every collaborator,
|
||||
* so each owner is resolved for real — no hand-rolled stub list. The only
|
||||
* override is `IAgentCronService`, seeded with `{ isSubagent: true }` so its
|
||||
* runtime scheduler does not start (only its `cron` section registration is
|
||||
* relevant here).
|
||||
*
|
||||
* Two scenarios are shown:
|
||||
* 1. **register + inspect** — every owner registers its section into the one
|
||||
* registry; `inspect` reports each section's default layer.
|
||||
* 2. **write + round-trip** — a schema-valid value for every *persistable*
|
||||
* section is written through `IConfigService.set`; each is validated,
|
||||
* env-stripped, and persisted, then `reload()` parses the file back.
|
||||
*
|
||||
* All Services come from `src/`; nothing here defines a new Service.
|
||||
*/
|
||||
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
|
||||
import { afterEach, describe, expect, test } from 'vitest';
|
||||
|
||||
import { SyncDescriptor } from '#/_base/di/descriptors';
|
||||
import { type ServiceIdentifier } from '#/_base/di/instantiation';
|
||||
import { AgentCronService, IAgentCronService } from '#/agent/cron';
|
||||
import {
|
||||
type ConfigInspectValue,
|
||||
IConfigRegistry,
|
||||
IConfigService,
|
||||
} from '#/app/config/config';
|
||||
import { IFlagService } from '#/app/flag';
|
||||
import { IModelService } from '#/app/model';
|
||||
import { IProviderService } from '#/app/provider';
|
||||
import { IAgentBackgroundService } from '#/agent/background';
|
||||
import { IAgentExternalHooksService } from '#/agent/externalHooks';
|
||||
import { IAgentLoopService } from '#/agent/loop';
|
||||
import { IAgentPermissionRulesService } from '#/agent/permissionRules';
|
||||
import { IAgentProfileService } from '#/agent/profile';
|
||||
|
||||
import { createSliceHost, type SliceHost } from './_harness';
|
||||
|
||||
/**
|
||||
* One schema-valid sample value per **persistable** section, written through
|
||||
* `IConfigService.set` so each owner's write path round-trips to `config.toml`.
|
||||
* `cron` is intentionally absent: it is operational / env-only, so it is never
|
||||
* persisted to `config.toml` by design.
|
||||
*/
|
||||
const SECTION_VALUES: Record<string, unknown> = {
|
||||
models: {
|
||||
'kimi-k2': { provider: 'moonshot', model: 'kimi-k2-0905-preview', maxContextSize: 262_144 },
|
||||
},
|
||||
providers: {
|
||||
moonshot: { type: 'kimi', apiKey: 'YOUR_API_KEY' },
|
||||
},
|
||||
experimental: { demo_feature: true },
|
||||
background: { maxRunningTasks: 4, keepAliveOnExit: true },
|
||||
permission: {
|
||||
rules: [{ decision: 'allow', scope: 'user', pattern: 'bash(git status)' }],
|
||||
},
|
||||
thinking: { mode: 'auto', effort: 'medium' },
|
||||
defaultThinking: true,
|
||||
loopControl: { maxStepsPerTurn: 50, maxRetriesPerStep: 3 },
|
||||
hooks: [{ event: 'PreToolUse', matcher: 'bash', command: 'echo demo' }],
|
||||
};
|
||||
|
||||
/** Domains every current section owner registers, in registration order. */
|
||||
const EXPECTED_SECTIONS = [
|
||||
'models',
|
||||
'providers',
|
||||
'experimental',
|
||||
'background',
|
||||
'cron',
|
||||
'permission',
|
||||
'thinking',
|
||||
'defaultThinking',
|
||||
'loopControl',
|
||||
'hooks',
|
||||
] as const;
|
||||
|
||||
describe('config slice (every section owner against one shared registry)', () => {
|
||||
let host: SliceHost;
|
||||
let configPath: string;
|
||||
|
||||
function setUp() {
|
||||
const homeDir = process.env['KIMI_CODE_HOME'];
|
||||
if (homeDir === undefined) {
|
||||
throw new Error('KIMI_CODE_HOME is not set; globalSetup should have initialized it');
|
||||
}
|
||||
configPath = join(homeDir, 'config.toml');
|
||||
host = createSliceHost({
|
||||
homeDir,
|
||||
// Seed cron as a subagent so its scheduler/tool registration stays idle.
|
||||
agentSeeds: [
|
||||
[
|
||||
IAgentCronService as unknown as ServiceIdentifier<unknown>,
|
||||
new SyncDescriptor(AgentCronService, [{ isSubagent: true }], true),
|
||||
],
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
/** Resolve every section owner so its constructor registers its section. */
|
||||
function resolveOwners(): void {
|
||||
host.app.accessor.get(IModelService).list();
|
||||
host.app.accessor.get(IProviderService).list();
|
||||
host.app.accessor.get(IFlagService).snapshot();
|
||||
host.agent.accessor.get(IAgentBackgroundService);
|
||||
host.agent.accessor.get(IAgentPermissionRulesService);
|
||||
host.agent.accessor.get(IAgentProfileService);
|
||||
host.agent.accessor.get(IAgentExternalHooksService);
|
||||
host.agent.accessor.get(IAgentLoopService);
|
||||
host.agent.accessor.get(IAgentCronService);
|
||||
}
|
||||
|
||||
afterEach(() => host?.dispose());
|
||||
|
||||
test('every section owner registers its section into the shared registry', async () => {
|
||||
setUp();
|
||||
const registry = host.app.accessor.get(IConfigRegistry);
|
||||
const config = host.app.accessor.get(IConfigService);
|
||||
await config.ready;
|
||||
|
||||
resolveOwners();
|
||||
|
||||
const registered = registry
|
||||
.listSections()
|
||||
.map((s) => s.domain)
|
||||
.toSorted();
|
||||
console.log('registered sections:', registered);
|
||||
|
||||
// Every known owner registers its section. The real composition root may
|
||||
// register additional sections as the system grows, so assert inclusion
|
||||
// rather than an exact list (which would rot on the next new section).
|
||||
expect(registered).toEqual(expect.arrayContaining([...EXPECTED_SECTIONS]));
|
||||
|
||||
console.log('\ninspect (default layer) per section:');
|
||||
for (const domain of EXPECTED_SECTIONS) {
|
||||
console.log(` ${domain}:`, summarizeInspect(config.inspect(domain)));
|
||||
}
|
||||
});
|
||||
|
||||
test('writes every persistable section through config and round-trips the file', async () => {
|
||||
setUp();
|
||||
const config = host.app.accessor.get(IConfigService);
|
||||
await config.ready;
|
||||
|
||||
resolveOwners();
|
||||
|
||||
let changes = 0;
|
||||
const sub = config.onDidChangeConfiguration(() => changes++);
|
||||
for (const [domain, value] of Object.entries(SECTION_VALUES)) {
|
||||
await config.set(domain, value);
|
||||
}
|
||||
sub.dispose();
|
||||
|
||||
const onDisk = readFileSync(configPath, 'utf8').trim();
|
||||
console.log('config.toml after writing every section:');
|
||||
for (const line of onDisk.split('\n')) {
|
||||
console.log(' ', line);
|
||||
}
|
||||
console.log(
|
||||
`\n${Object.keys(SECTION_VALUES).length} sections written; onDidChangeConfiguration fired ${changes} times.`,
|
||||
);
|
||||
|
||||
await config.reload();
|
||||
console.log('\ninspect after reload (round-trip) per section:');
|
||||
for (const domain of Object.keys(SECTION_VALUES)) {
|
||||
console.log(` ${domain}:`, config.inspect(domain).value);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
function summarizeInspect(inspect: ConfigInspectValue<unknown>): Record<string, unknown> {
|
||||
return {
|
||||
hasDefaultValue: inspect.defaultValue !== undefined,
|
||||
hasUserValue: inspect.userValue !== undefined,
|
||||
hasMemoryValue: inspect.memoryValue !== undefined,
|
||||
keys: inspect.value !== null && typeof inspect.value === 'object' ? Object.keys(inspect.value) : [],
|
||||
};
|
||||
}
|
||||
|
|
@ -1,158 +0,0 @@
|
|||
/**
|
||||
* Scenario: the **context** slice — event-sourced conversation memory.
|
||||
*
|
||||
* Concept taught: `IAgentContextMemoryService` is *not* a private array of
|
||||
* messages. It is an event-sourced projection over the append-log
|
||||
* (`IAgentRecordService`, backed by `IAgentWireRecordService`). Every mutation
|
||||
* goes through `splice(start, deleteCount, messages)`, which (1) stamps each
|
||||
* message with a stable local id, (2) appends a durable `context.splice` record
|
||||
* to the append-log, and (3) applies the same splice to its in-memory history.
|
||||
* Because the durable record is the source of truth, the history can be
|
||||
* rebuilt by replaying the records — the `get()` view is a projection, not the
|
||||
* state itself.
|
||||
*
|
||||
* Wiring: the real composition root (`_harness`) provides every collaborator,
|
||||
* including the real `IAgentContextMemoryService`, `IAgentRecordService`, and
|
||||
* `IAgentWireRecordService`. We do not stub context memory. We spy on the real
|
||||
* `IAgentRecordService.append` only to capture the `context.splice` records so
|
||||
* we can show the projection is reproducible from the append-log alone.
|
||||
*
|
||||
* Prerequisites: example 01 (container & scope tree),
|
||||
* example `goals-plans-todos` (append-log CRUD + spying on the record service).
|
||||
*
|
||||
* Run:
|
||||
* pnpm --filter @moonshot-ai/agent-core-v2 example -- examples/context.example.ts
|
||||
*/
|
||||
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import {
|
||||
type ContextMessage,
|
||||
IAgentContextMemoryService,
|
||||
} from '#/agent/contextMemory';
|
||||
import {
|
||||
type AgentRecord,
|
||||
IAgentRecordService,
|
||||
} from '#/agent/record';
|
||||
import {
|
||||
IAgentWireRecordService,
|
||||
type PersistedWireRecord,
|
||||
} from '#/agent/wireRecord';
|
||||
|
||||
import { createSliceHost, type SliceHost } from './_harness';
|
||||
|
||||
function userMessage(text: string): ContextMessage {
|
||||
return { role: 'user', content: [{ type: 'text', text }], toolCalls: [] };
|
||||
}
|
||||
|
||||
function assistantMessage(text: string): ContextMessage {
|
||||
return { role: 'assistant', content: [{ type: 'text', text }], toolCalls: [] };
|
||||
}
|
||||
|
||||
describe('context slice (event-sourced conversation memory)', () => {
|
||||
let host: SliceHost;
|
||||
afterEach(() => host?.dispose());
|
||||
|
||||
function setUp() {
|
||||
host = createSliceHost({ homeDir: process.env['KIMI_CODE_HOME']! });
|
||||
// Resolve the real append-log first and instrument it; the context memory
|
||||
// service is constructed lazily and shares the same singletons.
|
||||
const records = host.agent.accessor.get(IAgentRecordService);
|
||||
const wireRecords = host.agent.accessor.get(IAgentWireRecordService);
|
||||
const appended: AgentRecord[] = [];
|
||||
const originalAppend = records.append.bind(records);
|
||||
vi.spyOn(records, 'append').mockImplementation((record) => {
|
||||
appended.push(record as AgentRecord);
|
||||
return originalAppend(record);
|
||||
});
|
||||
const context = host.agent.accessor.get(IAgentContextMemoryService);
|
||||
const spliceRecords = () =>
|
||||
appended.filter((r): r is AgentRecord<'context.splice'> => r.type === 'context.splice');
|
||||
return { context, records, wireRecords, appended, spliceRecords };
|
||||
}
|
||||
|
||||
it('splices a user message into context and records a context.splice on the append-log', () => {
|
||||
const { context, wireRecords, spliceRecords } = setUp();
|
||||
|
||||
context.splice(0, 0, [userMessage('hello')]);
|
||||
|
||||
// The projection reflects the splice: the message is readable back, stamped
|
||||
// with a stable local id assigned on entry.
|
||||
const history = context.get();
|
||||
expect(history).toHaveLength(1);
|
||||
expect(history[0]).toMatchObject({ role: 'user', content: [{ type: 'text', text: 'hello' }] });
|
||||
expect(history[0]?.id).toMatch(/^msg_/);
|
||||
|
||||
// The mutation is durable: one context.splice record landed on the
|
||||
// append-log (the record facade and its wire-record backing agree).
|
||||
expect(spliceRecords()).toHaveLength(1);
|
||||
expect(spliceRecords()[0]).toMatchObject({ start: 0, deleteCount: 0 });
|
||||
expect(wireRecords.getRecords().some((r) => r.type === 'context.splice')).toBe(true);
|
||||
});
|
||||
|
||||
it('preserves message order across a user/assistant turn', () => {
|
||||
const { context } = setUp();
|
||||
|
||||
context.splice(0, 0, [userMessage('hi'), assistantMessage('hello, how can I help?')]);
|
||||
|
||||
const history = context.get();
|
||||
expect(history.map((m) => m.role)).toEqual(['user', 'assistant']);
|
||||
expect(history.map((m) => m.content[0])).toEqual([
|
||||
{ type: 'text', text: 'hi' },
|
||||
{ type: 'text', text: 'hello, how can I help?' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('replaces a message in place when splicing with a deleteCount', () => {
|
||||
const { context, spliceRecords } = setUp();
|
||||
|
||||
context.splice(0, 0, [userMessage('first'), userMessage('second')]);
|
||||
expect(context.get().map((m) => m.content[0])).toEqual([
|
||||
{ type: 'text', text: 'first' },
|
||||
{ type: 'text', text: 'second' },
|
||||
]);
|
||||
|
||||
// Replace the message at index 1 with a new one.
|
||||
context.splice(1, 1, [assistantMessage('replacement')]);
|
||||
|
||||
const history = context.get();
|
||||
expect(history).toHaveLength(2);
|
||||
expect(history.map((m) => m.role)).toEqual(['user', 'assistant']);
|
||||
expect(history.map((m) => m.content[0])).toEqual([
|
||||
{ type: 'text', text: 'first' },
|
||||
{ type: 'text', text: 'replacement' },
|
||||
]);
|
||||
|
||||
// Both splices were recorded; the second carries the deletion.
|
||||
const records = spliceRecords();
|
||||
expect(records).toHaveLength(2);
|
||||
expect(records[1]).toMatchObject({ start: 1, deleteCount: 1 });
|
||||
});
|
||||
|
||||
it('rebuilds the same history in a fresh agent by replaying the append-log records', async () => {
|
||||
const { context, wireRecords } = setUp();
|
||||
|
||||
context.splice(0, 0, [userMessage('remember this')]);
|
||||
context.splice(1, 0, [assistantMessage('noted')]);
|
||||
const original = context.get();
|
||||
expect(original).toHaveLength(2);
|
||||
|
||||
// The append-log's durable records are the source of truth. Capture them in
|
||||
// the wire-record format the restore path expects.
|
||||
const persisted = wireRecords.getRecords();
|
||||
|
||||
// A brand-new agent scope has an empty context. Resolve its context memory
|
||||
// first so its constructor registers the context.splice resumer, then replay
|
||||
// the persisted records — no private array is shared between the two agents.
|
||||
const freshAgent = host.newAgent('fresh');
|
||||
const freshContext = freshAgent.accessor.get(IAgentContextMemoryService);
|
||||
const freshWireRecords = freshAgent.accessor.get(IAgentWireRecordService);
|
||||
expect(freshContext.get()).toHaveLength(0);
|
||||
|
||||
await freshWireRecords.restore(persisted);
|
||||
|
||||
// The projection is reproducible from the append-log alone: the fresh
|
||||
// agent reconstructs the same messages, including their stable ids.
|
||||
expect(freshContext.get()).toEqual(original);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,159 +0,0 @@
|
|||
/**
|
||||
* Example 01 — the DI container and the `App → Session → Agent` scope tree.
|
||||
*
|
||||
* Concept taught: a service declares an identity (`createDecorator`), its
|
||||
* dependencies (`@IToken`), and a lifetime (`registerScopedService`); the
|
||||
* container decides construction, singleton-per-scope, ordering, and disposal.
|
||||
*
|
||||
* It also shows `InstantiationType`: a `Delayed` service hands back a proxy
|
||||
* that is only constructed when a method is first called, while an `Eager`
|
||||
* service is constructed immediately on `accessor.get(...)`.
|
||||
*
|
||||
* Scope tiers: App (process-wide) → Session (one session) → Agent (one agent).
|
||||
* Short-lived may inject long-lived; never the reverse. Disposal is
|
||||
* deterministic: child scopes die before parents.
|
||||
*
|
||||
* Prerequisites: none (this is the entry point). Uses only in-file fixtures.
|
||||
*
|
||||
* Run:
|
||||
* pnpm --filter @moonshot-ai/agent-core-v2 example -- examples/di-container.example.ts
|
||||
*/
|
||||
|
||||
import { beforeEach, describe, expect, it } from 'vitest';
|
||||
|
||||
import { type IDisposable } from '#/_base/di';
|
||||
import { InstantiationType } from '#/_base/di/extensions';
|
||||
import { createDecorator } from '#/_base/di/instantiation';
|
||||
import {
|
||||
LifecycleScope,
|
||||
_clearScopedRegistryForTests,
|
||||
registerScopedService,
|
||||
} from '#/_base/di/scope';
|
||||
import { createScopedTestHost, stubPair } from '#/_base/di/test';
|
||||
|
||||
interface IGreeter {
|
||||
greet(): string;
|
||||
}
|
||||
interface IConsumer {
|
||||
label(): string;
|
||||
}
|
||||
|
||||
const IGreeter = createDecorator<IGreeter>('ex01-greeter');
|
||||
const IConsumer = createDecorator<IConsumer>('ex01-consumer');
|
||||
|
||||
/** A Session-scope service that depends on an App-scope `IGreeter`. */
|
||||
class Consumer implements IConsumer {
|
||||
constructor(@IGreeter private readonly greeter: IGreeter) {}
|
||||
label(): string {
|
||||
return `consumed:${this.greeter.greet()}`;
|
||||
}
|
||||
}
|
||||
|
||||
/** Disposable fixtures used only by the disposal-order test. */
|
||||
const disposalLog: string[] = [];
|
||||
|
||||
class AppThing implements IDisposable {
|
||||
dispose(): void {
|
||||
disposalLog.push('app');
|
||||
}
|
||||
}
|
||||
class SessionThing implements IDisposable {
|
||||
dispose(): void {
|
||||
disposalLog.push('session');
|
||||
}
|
||||
}
|
||||
class AgentThing implements IDisposable {
|
||||
dispose(): void {
|
||||
disposalLog.push('agent');
|
||||
}
|
||||
}
|
||||
|
||||
const IAppThing = createDecorator<AppThing>('ex01-app-thing');
|
||||
const ISessionThing = createDecorator<SessionThing>('ex01-session-thing');
|
||||
const IAgentThing = createDecorator<AgentThing>('ex01-agent-thing');
|
||||
|
||||
describe('example 01 — di container & scope tree', () => {
|
||||
beforeEach(() => {
|
||||
disposalLog.length = 0;
|
||||
_clearScopedRegistryForTests();
|
||||
registerScopedService(LifecycleScope.Session, IConsumer, Consumer);
|
||||
// Eager so `accessor.get(...)` returns the real instance immediately rather
|
||||
// than a delayed proxy — the disposal-order test needs the instances to
|
||||
// actually be constructed so the scope has something to dispose.
|
||||
registerScopedService(
|
||||
LifecycleScope.App,
|
||||
IAppThing,
|
||||
AppThing,
|
||||
InstantiationType.Eager,
|
||||
);
|
||||
registerScopedService(
|
||||
LifecycleScope.Session,
|
||||
ISessionThing,
|
||||
SessionThing,
|
||||
InstantiationType.Eager,
|
||||
);
|
||||
registerScopedService(
|
||||
LifecycleScope.Agent,
|
||||
IAgentThing,
|
||||
AgentThing,
|
||||
InstantiationType.Eager,
|
||||
);
|
||||
});
|
||||
|
||||
it('injects an App-scope ancestor into a Session-scope child', () => {
|
||||
const host = createScopedTestHost([
|
||||
stubPair<IGreeter>(IGreeter, { greet: () => 'hello-from-app' }),
|
||||
]);
|
||||
const session = host.child(LifecycleScope.Session, 's1');
|
||||
|
||||
const consumer = session.accessor.get(IConsumer);
|
||||
expect(consumer.label()).toBe('consumed:hello-from-app');
|
||||
|
||||
host.dispose();
|
||||
});
|
||||
|
||||
it('isolates stubs between sibling Session scopes', () => {
|
||||
const host = createScopedTestHost();
|
||||
const s1 = host.child(LifecycleScope.Session, 's1', [
|
||||
stubPair<IGreeter>(IGreeter, { greet: () => 'one' }),
|
||||
]);
|
||||
const s2 = host.child(LifecycleScope.Session, 's2', [
|
||||
stubPair<IGreeter>(IGreeter, { greet: () => 'two' }),
|
||||
]);
|
||||
|
||||
expect(s1.accessor.get(IConsumer).label()).toBe('consumed:one');
|
||||
expect(s2.accessor.get(IConsumer).label()).toBe('consumed:two');
|
||||
|
||||
host.dispose();
|
||||
});
|
||||
|
||||
it('builds an Agent scope under a Session and resolves upward', () => {
|
||||
const host = createScopedTestHost([
|
||||
stubPair<IGreeter>(IGreeter, { greet: () => 'from-app' }),
|
||||
]);
|
||||
const session = host.child(LifecycleScope.Session, 's1');
|
||||
const agent = host.childOf(session, LifecycleScope.Agent, 'main');
|
||||
|
||||
// The Agent scope has no IGreeter seed, so resolution walks up to App.
|
||||
expect(agent.accessor.get(IGreeter).greet()).toBe('from-app');
|
||||
// IConsumer is registered at Session scope; the Agent scope finds it on the ancestor.
|
||||
expect(agent.accessor.get(IConsumer).label()).toBe('consumed:from-app');
|
||||
|
||||
host.dispose();
|
||||
});
|
||||
|
||||
it('disposes child scopes before parent scopes', () => {
|
||||
const host = createScopedTestHost();
|
||||
const session = host.child(LifecycleScope.Session, 's1');
|
||||
const agent = host.childOf(session, LifecycleScope.Agent, 'main');
|
||||
|
||||
// Force construction of each scoped instance.
|
||||
host.app.accessor.get(IAppThing);
|
||||
session.accessor.get(ISessionThing);
|
||||
agent.accessor.get(IAgentThing);
|
||||
|
||||
host.dispose();
|
||||
|
||||
expect(disposalLog).toEqual(['agent', 'session', 'app']);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,182 +0,0 @@
|
|||
/**
|
||||
* Scenario: the **edge-gateway-rpc** slice — the edge-exposure layer where the
|
||||
* agent's `resource:action` RPC surface meets the REST/WS transport edge.
|
||||
*
|
||||
* Concept taught: the agent is not reached directly. `IAgentRPCService`
|
||||
* (Agent scope) is the typed `resource:action` RPC surface — one method per
|
||||
* action (`prompt`, `registerTool`, `getTools`, …) — that edge transports call
|
||||
* into. The `gateway` domain (App scope) is the transport edge itself:
|
||||
* `IRestGateway` drives request/response actions, while the WS side owns the
|
||||
* streaming connections. The WS fan-out is backed by a process-wide event
|
||||
* sink, `IEventService` (App scope) — a minimal type-tagged pub/sub bus that
|
||||
* the edge package subscribes to and republishes over sockets. So the data
|
||||
* path is: transport (gateway) → RPC action (agent) → domain fact → event
|
||||
* sink (`IEventService`) → WS connections.
|
||||
*
|
||||
* We keep this example read-only and deterministic: no sockets are opened and
|
||||
* no servers listen. We only resolve the real services and exercise safe,
|
||||
* synchronous-ish methods — `registerTool` / `getTools` on the RPC, a
|
||||
* session-status probe on the REST gateway, and a `publish` / `subscribe`
|
||||
* round-trip on the event sink.
|
||||
*
|
||||
* Wiring: the real composition root (`_harness`) provides every collaborator
|
||||
* (the tool registry, the session lifecycle the gateway resolves through, the
|
||||
* record log, …) so the slice runs for real with no hand-rolled stub list. We
|
||||
* spy on `IAgentRecordService.append` only to observe the
|
||||
* `tools.register_user_tool` record the RPC writes when an action is
|
||||
* registered.
|
||||
*
|
||||
* Note on the WS gateway: the App-scope `IWSGateway` binding in this package
|
||||
* still carries an Agent-scope `IAgentRecordService` dependency that the real
|
||||
* composition root does not satisfy at App scope, so it is intentionally not
|
||||
* instantiated here (WS sequencing / journaling / replay is completed in the
|
||||
* edge `server` package on top of `IEventService` + `IAgentRecordService`).
|
||||
* We therefore exercise the WS *backing* — the `IEventService` event sink —
|
||||
* directly, which is the part this package owns and wires for real.
|
||||
*
|
||||
* Prerequisites: example 01 (container & scope tree).
|
||||
*
|
||||
* Run:
|
||||
* pnpm --filter @moonshot-ai/agent-core-v2 example -- examples/edge-gateway-rpc.example.ts
|
||||
*/
|
||||
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { mkdirSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { IAgentRecordService } from '#/agent/record';
|
||||
import { IAgentRPCService } from '#/agent/rpc';
|
||||
import { type DomainEvent, IEventService } from '#/app/event';
|
||||
import { IRestGateway } from '#/app/gateway';
|
||||
|
||||
import { createSliceHost, type SliceHost } from './_harness';
|
||||
|
||||
describe('edge-gateway-rpc slice (resource:action RPC over the gateway/event edge)', () => {
|
||||
let host: SliceHost;
|
||||
afterEach(() => host?.dispose());
|
||||
|
||||
function newHomeDir(): string {
|
||||
const root = process.env['KIMI_CODE_HOME'];
|
||||
if (root === undefined) {
|
||||
throw new Error('KIMI_CODE_HOME is not set; globalSetup should have initialized it');
|
||||
}
|
||||
// Per-test isolated home so this example never writes into the shared
|
||||
// run-wide home (which other examples share) — keeps the slice hermetic.
|
||||
const dir = join(root, randomUUID());
|
||||
mkdirSync(dir, { recursive: true });
|
||||
return dir;
|
||||
}
|
||||
|
||||
function setUp() {
|
||||
host = createSliceHost({ homeDir: newHomeDir() });
|
||||
return {
|
||||
rpc: host.agent.accessor.get(IAgentRPCService),
|
||||
rest: host.app.accessor.get(IRestGateway),
|
||||
events: host.app.accessor.get(IEventService),
|
||||
};
|
||||
}
|
||||
|
||||
it('resolves the edge services and exposes the resource:action transport surface', async () => {
|
||||
const { rpc, rest, events } = setUp();
|
||||
|
||||
// The agent RPC surface exposes typed `resource:action` methods the edge
|
||||
// calls into — tool actions here are the clearest example.
|
||||
expect(typeof rpc.registerTool).toBe('function');
|
||||
expect(typeof rpc.getTools).toBe('function');
|
||||
expect(typeof rpc.prompt).toBe('function');
|
||||
|
||||
// The App-scope REST gateway is the request/response transport edge.
|
||||
expect(typeof rest.prompt).toBe('function');
|
||||
expect(typeof rest.getStatus).toBe('function');
|
||||
|
||||
// The WS fan-out is backed by the App-scope event sink.
|
||||
expect(typeof events.publish).toBe('function');
|
||||
expect(typeof events.subscribe).toBe('function');
|
||||
expect(typeof events.onDidPublish).toBe('function');
|
||||
|
||||
// The REST gateway resolves sessions through the session lifecycle; an
|
||||
// unknown session reports status `false` rather than throwing. This is a
|
||||
// safe, read-only probe — no socket, no session created.
|
||||
expect(await rest.getStatus('does-not-exist')).toBe(false);
|
||||
});
|
||||
|
||||
it('registers an action (tool) through the RPC and lists it back', async () => {
|
||||
host = createSliceHost({ homeDir: newHomeDir() });
|
||||
|
||||
// Instrument the real record service first; the RPC's user-tool registrar
|
||||
// is constructed against the same singleton and writes a
|
||||
// `tools.register_user_tool` record when an action is registered.
|
||||
const records = host.agent.accessor.get(IAgentRecordService);
|
||||
const appended: Array<{ type: string; name?: string }> = [];
|
||||
vi.spyOn(records, 'append').mockImplementation((r) => {
|
||||
appended.push(r as { type: string; name?: string });
|
||||
});
|
||||
|
||||
const rpc = host.agent.accessor.get(IAgentRPCService);
|
||||
|
||||
const before = await rpc.getTools({});
|
||||
expect(before.some((tool) => tool.name === 'echo-example')).toBe(false);
|
||||
|
||||
rpc.registerTool({
|
||||
name: 'echo-example',
|
||||
description: 'Echoes its input — example edge action.',
|
||||
parameters: { type: 'object', properties: { text: { type: 'string' } } },
|
||||
});
|
||||
|
||||
// The action is now part of the agent's RPC-listed tool set, sourced as a
|
||||
// user-registered tool and auto-activated by the profile.
|
||||
const after = await rpc.getTools({});
|
||||
const registered = after.find((tool) => tool.name === 'echo-example');
|
||||
expect(registered).toMatchObject({
|
||||
name: 'echo-example',
|
||||
description: 'Echoes its input — example edge action.',
|
||||
source: 'user',
|
||||
active: true,
|
||||
});
|
||||
|
||||
// Registering the action is itself a recorded domain fact.
|
||||
expect(appended).toContainEqual(
|
||||
expect.objectContaining({ type: 'tools.register_user_tool', name: 'echo-example' }),
|
||||
);
|
||||
|
||||
// Unregistering removes the action from the listed set (and records the
|
||||
// unregister fact), leaving the agent the way we found it.
|
||||
rpc.unregisterTool({ name: 'echo-example' });
|
||||
const final = await rpc.getTools({});
|
||||
expect(final.some((tool) => tool.name === 'echo-example')).toBe(false);
|
||||
expect(appended).toContainEqual(
|
||||
expect.objectContaining({ type: 'tools.unregister_user_tool', name: 'echo-example' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('streams a domain event over the event sink that backs the WS fan-out', () => {
|
||||
const { events } = setUp();
|
||||
|
||||
const viaSubscribe: DomainEvent[] = [];
|
||||
const viaOnDidPublish: DomainEvent[] = [];
|
||||
const subA = events.subscribe((event) => viaSubscribe.push(event));
|
||||
const subB = events.onDidPublish((event) => viaOnDidPublish.push(event));
|
||||
|
||||
const domainEvent: DomainEvent = {
|
||||
type: 'session.edgeExample',
|
||||
payload: { sessionId: 's1', kind: 'demo' },
|
||||
};
|
||||
events.publish(domainEvent);
|
||||
|
||||
// Both subscription paths on the real bus receive the published fact —
|
||||
// this is the event the edge package would republish over the WS
|
||||
// connections tracked by the gateway.
|
||||
expect(viaSubscribe).toEqual([domainEvent]);
|
||||
expect(viaOnDidPublish).toEqual([domainEvent]);
|
||||
|
||||
subA.dispose();
|
||||
subB.dispose();
|
||||
|
||||
// After disposal the sink no longer delivers to the removed handlers.
|
||||
events.publish({ type: 'session.edgeExample.afterDispose', payload: null });
|
||||
expect(viaSubscribe).toHaveLength(1);
|
||||
expect(viaOnDidPublish).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,110 +0,0 @@
|
|||
/**
|
||||
* Scenario: the **event bus** slice — soft coupling through `IEventService`.
|
||||
*
|
||||
* Concept taught: not every dependency is a constructor injection. When a
|
||||
* domain wants to broadcast a fact to an *unknown* set of consumers, it
|
||||
* publishes a typed `DomainEvent` to the App-scope `IEventService` instead of
|
||||
* importing and calling each consumer. The dep-graph records these as `publish`
|
||||
* / `subscribe` / `emit` / `on` edges — softer than `ctor` edges because the
|
||||
* publisher holds no reference to its consumers.
|
||||
*
|
||||
* Real publishers in the graph include `ISessionLifecycleService`,
|
||||
* `IModelCatalogService`, and `IOAuthService`; here we use a tiny in-file publisher to isolate the
|
||||
* wiring without pulling in those domains.
|
||||
*
|
||||
* Prerequisites: example 01 (container & scope tree).
|
||||
*
|
||||
* Run:
|
||||
* pnpm --filter @moonshot-ai/agent-core-v2 example -- examples/events.example.ts
|
||||
*/
|
||||
|
||||
import { beforeEach, describe, expect, it } from 'vitest';
|
||||
|
||||
import { type IDisposable } from '#/_base/di';
|
||||
import { createDecorator } from '#/_base/di/instantiation';
|
||||
import {
|
||||
LifecycleScope,
|
||||
_clearScopedRegistryForTests,
|
||||
registerScopedService,
|
||||
} from '#/_base/di/scope';
|
||||
import { createScopedTestHost } from '#/_base/di/test';
|
||||
|
||||
import {
|
||||
type DomainEvent,
|
||||
EventService,
|
||||
IEventService,
|
||||
} from '#/app/event';
|
||||
|
||||
interface IPublisher {
|
||||
announce(kind: string, detail: string): void;
|
||||
}
|
||||
|
||||
/** An Agent-scope publisher that broadcasts through the App-scope bus. */
|
||||
class Publisher implements IPublisher {
|
||||
constructor(@IEventService private readonly events: IEventService) {}
|
||||
announce(kind: string, detail: string): void {
|
||||
this.events.publish({ type: kind, payload: { detail } });
|
||||
}
|
||||
}
|
||||
|
||||
const IPublisher = createDecorator<IPublisher>('ex-events-publisher');
|
||||
|
||||
describe('events slice (soft coupling via IEventService)', () => {
|
||||
beforeEach(() => {
|
||||
_clearScopedRegistryForTests();
|
||||
registerScopedService(LifecycleScope.App, IEventService, EventService, undefined, 'event');
|
||||
registerScopedService(LifecycleScope.Agent, IPublisher, Publisher);
|
||||
});
|
||||
|
||||
it('delivers a published DomainEvent to a subscriber', () => {
|
||||
const host = createScopedTestHost();
|
||||
const bus = host.app.accessor.get(IEventService);
|
||||
|
||||
const received: DomainEvent[] = [];
|
||||
const sub = bus.subscribe((e) => received.push(e));
|
||||
|
||||
bus.publish({ type: 'session.archived', payload: { sessionId: 's1' } });
|
||||
|
||||
expect(received).toEqual([
|
||||
{ type: 'session.archived', payload: { sessionId: 's1' } },
|
||||
]);
|
||||
|
||||
sub.dispose();
|
||||
host.dispose();
|
||||
});
|
||||
|
||||
it('decouples an Agent-scope publisher from its consumers', () => {
|
||||
const host = createScopedTestHost();
|
||||
const session = host.child(LifecycleScope.Session, 's1');
|
||||
const agent = host.childOf(session, LifecycleScope.Agent, 'main');
|
||||
|
||||
// The consumer subscribes through the same App-scope bus the publisher
|
||||
// resolves upward to — neither side imports the other.
|
||||
const received: DomainEvent[] = [];
|
||||
host.app.accessor.get(IEventService).subscribe((e) => received.push(e));
|
||||
|
||||
agent.accessor.get(IPublisher).announce('turn.completed', 'turn-42');
|
||||
|
||||
expect(received).toEqual([
|
||||
{ type: 'turn.completed', payload: { detail: 'turn-42' } },
|
||||
]);
|
||||
|
||||
host.dispose();
|
||||
});
|
||||
|
||||
it('stops delivering after the subscription is disposed', () => {
|
||||
const host = createScopedTestHost();
|
||||
const bus = host.app.accessor.get(IEventService);
|
||||
|
||||
const received: DomainEvent[] = [];
|
||||
const sub: IDisposable = bus.subscribe((e) => received.push(e));
|
||||
|
||||
bus.publish({ type: 'first', payload: null });
|
||||
sub.dispose();
|
||||
bus.publish({ type: 'second', payload: null });
|
||||
|
||||
expect(received.map((e) => e.type)).toEqual(['first']);
|
||||
|
||||
host.dispose();
|
||||
});
|
||||
});
|
||||
|
|
@ -1,174 +0,0 @@
|
|||
/**
|
||||
* Scenario: the **extensions** slice — the plugin, MCP, and skill-catalog
|
||||
* surfaces through which the agent is extended without touching its core.
|
||||
*
|
||||
* Concept taught: three scoped services form the extension plane, each owning
|
||||
* a different lifetime and contribution channel.
|
||||
*
|
||||
* - `IPluginService` (App) discovers installed plugins and exposes their
|
||||
* *consumption plane*: skill roots, MCP servers, hooks, and session-start
|
||||
* reminders that other domains fold in. With no plugins installed, every
|
||||
* collection is empty — but the surface still resolves and reports shape.
|
||||
* - `IAgentMcpService` (Agent) manages the per-agent MCP server connections:
|
||||
* it lists configured servers, surfaces their status, and lets callers
|
||||
* subscribe to `onStatusChange`. With no connection manager seeded, it
|
||||
* resolves as a quiet shell — no servers, no network, an already-settled
|
||||
* initial load.
|
||||
* - `IGlobalSkillCatalog` (App) merges the code-defined builtin skills with
|
||||
* user / brand skills discovered from the home directories, loading once
|
||||
* and sharing the result with every Session catalog. Each `SkillDefinition`
|
||||
* carries a `source` tag so the catalog reports provenance, not just names.
|
||||
*
|
||||
* Wiring: the real composition root (`_harness`) provides every collaborator
|
||||
* (the file-backed plugin store, the agent's MCP service shell, the filesystem
|
||||
* `ISkillCatalogStore`, …) so each surface resolves for real with no
|
||||
* hand-rolled stub list. The isolated `KIMI_CODE_HOME` has no plugins
|
||||
* installed and no MCP servers configured, so every surface reports empty
|
||||
* contents and nothing connects over the network or loads a real plugin.
|
||||
*
|
||||
* Prerequisites: example 01 (container & scope tree).
|
||||
*
|
||||
* Run:
|
||||
* pnpm --filter @moonshot-ai/agent-core-v2 example -- examples/extensions.example.ts
|
||||
*/
|
||||
|
||||
import { afterEach, describe, expect, test } from 'vitest';
|
||||
|
||||
import { IAgentMcpService } from '#/agent/mcp';
|
||||
import { IGlobalSkillCatalog } from '#/app/globalSkillCatalog';
|
||||
import { IPluginService } from '#/app/plugin';
|
||||
|
||||
import { createSliceHost, type SliceHost } from './_harness';
|
||||
|
||||
describe('extensions slice (plugins × MCP × skill catalog)', () => {
|
||||
let host: SliceHost;
|
||||
afterEach(() => host?.dispose());
|
||||
|
||||
function setUp(): SliceHost {
|
||||
if (process.env['KIMI_CODE_HOME'] === undefined) {
|
||||
throw new Error('KIMI_CODE_HOME is not set; globalSetup should have initialized it');
|
||||
}
|
||||
host = createSliceHost({ homeDir: process.env['KIMI_CODE_HOME'] });
|
||||
return host;
|
||||
}
|
||||
|
||||
test('plugin service resolves and reports no contributed skills/MCP/hooks when no plugins are installed', async () => {
|
||||
const h = setUp();
|
||||
const plugins = h.app.accessor.get(IPluginService);
|
||||
|
||||
const summaries = await plugins.listPlugins();
|
||||
const mcpServers = await plugins.enabledMcpServers();
|
||||
const skillRoots = await plugins.pluginSkillRoots();
|
||||
const hooks = await plugins.enabledHooks();
|
||||
const sessionStarts = await plugins.enabledSessionStarts();
|
||||
|
||||
console.log('plugins:', {
|
||||
installed: summaries.length,
|
||||
mcpServers: Object.keys(mcpServers).length,
|
||||
skillRoots: skillRoots.length,
|
||||
hooks: hooks.length,
|
||||
sessionStarts: sessionStarts.length,
|
||||
});
|
||||
|
||||
// The consumption plane is present but empty: no plugins are installed.
|
||||
expect(summaries).toEqual([]);
|
||||
expect(mcpServers).toEqual({});
|
||||
expect(skillRoots).toEqual([]);
|
||||
expect(hooks).toEqual([]);
|
||||
expect(sessionStarts).toEqual([]);
|
||||
|
||||
// `onDidReload` is an Event — subscribing yields a disposable and, with no
|
||||
// reload triggered, the listener never fires.
|
||||
let reloads = 0;
|
||||
const sub = plugins.onDidReload(() => {
|
||||
reloads++;
|
||||
});
|
||||
expect(typeof sub.dispose).toBe('function');
|
||||
sub.dispose();
|
||||
expect(reloads).toBe(0);
|
||||
});
|
||||
|
||||
test('agent MCP service resolves with no servers and exposes a status subscription', async () => {
|
||||
const h = setUp();
|
||||
const mcp = h.agent.accessor.get(IAgentMcpService);
|
||||
|
||||
const entries = mcp.list();
|
||||
console.log('agent MCP servers:', entries.length);
|
||||
|
||||
// No connection manager is seeded, so no servers are configured or connected.
|
||||
expect(Array.isArray(entries)).toBe(true);
|
||||
expect(entries).toEqual([]);
|
||||
expect(mcp.resolved('does-not-exist')).toBeUndefined();
|
||||
expect(mcp.getRemoteServerUrl('does-not-exist')).toBeUndefined();
|
||||
|
||||
// The initial load is already settled — nothing ever connected.
|
||||
await expect(mcp.waitForInitialLoad()).resolves.toBeUndefined();
|
||||
expect(mcp.initialLoadDurationMs()).toBe(0);
|
||||
expect(mcp.oauthService).toBeUndefined();
|
||||
|
||||
// `onStatusChange` yields a disposable; with no servers the listener is
|
||||
// never invoked.
|
||||
let statusChanges = 0;
|
||||
const sub = mcp.onStatusChange(() => {
|
||||
statusChanges++;
|
||||
});
|
||||
expect(typeof sub.dispose).toBe('function');
|
||||
sub.dispose();
|
||||
expect(statusChanges).toBe(0);
|
||||
});
|
||||
|
||||
test('global skill catalog loads builtin skills with provenance', async () => {
|
||||
const h = setUp();
|
||||
const globalCatalog = h.app.accessor.get(IGlobalSkillCatalog);
|
||||
|
||||
await globalCatalog.load();
|
||||
|
||||
const skills = globalCatalog.catalog.listSkills();
|
||||
const builtins = skills.filter((skill) => skill.source === 'builtin');
|
||||
console.log('skills:', { total: skills.length, builtin: builtins.length });
|
||||
|
||||
// The code-defined builtins are always present after load.
|
||||
expect(skills.length).toBeGreaterThan(0);
|
||||
expect(builtins.length).toBeGreaterThan(0);
|
||||
for (const skill of skills) {
|
||||
expect(['builtin', 'user', 'extra', 'project']).toContain(skill.source);
|
||||
expect(skill.name.length).toBeGreaterThan(0);
|
||||
}
|
||||
|
||||
// `getSkill` round-trips a builtin by name and preserves its provenance.
|
||||
const first = builtins[0];
|
||||
expect(first).toBeDefined();
|
||||
if (first === undefined) return;
|
||||
const inspected = globalCatalog.catalog.getSkill(first.name);
|
||||
expect(inspected).toBeDefined();
|
||||
if (inspected === undefined) return;
|
||||
expect(inspected.name).toBe(first.name);
|
||||
expect(inspected.source).toBe('builtin');
|
||||
});
|
||||
|
||||
test('global skill catalog derives its model listing from invocable skills', async () => {
|
||||
const h = setUp();
|
||||
const globalCatalog = h.app.accessor.get(IGlobalSkillCatalog);
|
||||
await globalCatalog.load();
|
||||
|
||||
const all = globalCatalog.catalog.listSkills();
|
||||
const invocable = globalCatalog.catalog.listInvocableSkills();
|
||||
const listing = globalCatalog.catalog.getModelSkillListing();
|
||||
|
||||
console.log('catalog:', {
|
||||
total: all.length,
|
||||
invocable: invocable.length,
|
||||
listingChars: listing.length,
|
||||
});
|
||||
|
||||
// Invocable skills are a filtered subset of the full catalog.
|
||||
expect(invocable.length).toBeLessThanOrEqual(all.length);
|
||||
const allNames = all.map((skill) => skill.name);
|
||||
for (const skill of invocable) {
|
||||
expect(allNames).toContain(skill.name);
|
||||
}
|
||||
|
||||
// The model-facing listing is derived from the catalog (never hand-rolled).
|
||||
expect(typeof listing).toBe('string');
|
||||
});
|
||||
});
|
||||
|
|
@ -1,77 +0,0 @@
|
|||
/**
|
||||
* Scenario: the **feature-flags** slice — `flag` for real, `config` stubbed.
|
||||
*
|
||||
* Demonstrates running a slice's real services while stubbing the
|
||||
* collaborators outside it. `IFlagService` and `IFlagRegistry` are real, so
|
||||
* flag resolution (env → config → default) and `setConfigOverrides` behave
|
||||
* exactly as in production; the `config` registry/service and `bootstrap` env
|
||||
* lookup are stubbed, because the scenario does not need a real config file or
|
||||
* process environment. A flag is contributed inline so the slice is
|
||||
* self-contained.
|
||||
*/
|
||||
|
||||
import { afterEach, beforeEach, describe, test } from 'vitest';
|
||||
|
||||
import { DisposableStore, toDisposable } from '#/_base/di/lifecycle';
|
||||
import { createServices, type TestInstantiationService } from '#/_base/di/test';
|
||||
import { IBootstrapService } from '#/app/bootstrap/bootstrap';
|
||||
import { IConfigRegistry, IConfigService } from '#/app/config/config';
|
||||
import { FlagService } from '#/app/flag/flagService';
|
||||
import { type ExperimentalFlagConfig, IFlagService } from '#/app/flag/flag';
|
||||
import { IFlagRegistry, registerFlagDefinition } from '#/app/flag/flagRegistry';
|
||||
import { FlagRegistryService } from '#/app/flag/flagRegistryService';
|
||||
|
||||
registerFlagDefinition({
|
||||
id: 'demo_flag',
|
||||
title: 'Demo flag',
|
||||
description: 'An example-only experimental flag.',
|
||||
env: 'KIMI_CODE_EXPERIMENTAL_DEMO_FLAG',
|
||||
default: false,
|
||||
surface: 'core',
|
||||
});
|
||||
|
||||
describe('feature-flags slice (flag, with config stubbed)', () => {
|
||||
let disposables: DisposableStore;
|
||||
let ix: TestInstantiationService;
|
||||
let configValue: ExperimentalFlagConfig;
|
||||
|
||||
beforeEach(() => {
|
||||
disposables = new DisposableStore();
|
||||
configValue = {};
|
||||
ix = createServices(disposables, {
|
||||
additionalServices: (reg) => {
|
||||
reg.definePartialInstance(IConfigRegistry, { registerSection: () => {} });
|
||||
reg.definePartialInstance(IConfigService, {
|
||||
ready: Promise.resolve(),
|
||||
get: () => configValue,
|
||||
onDidChangeConfiguration: () => toDisposable(() => {}),
|
||||
});
|
||||
reg.definePartialInstance(IBootstrapService, { getEnv: () => undefined });
|
||||
reg.define(IFlagRegistry, FlagRegistryService);
|
||||
reg.define(IFlagService, FlagService);
|
||||
},
|
||||
});
|
||||
});
|
||||
afterEach(() => {
|
||||
disposables.dispose();
|
||||
});
|
||||
|
||||
test('resolves a flag from its default, then from a config override', () => {
|
||||
const flags = ix.get(IFlagService);
|
||||
|
||||
const initial = flags.explain('demo_flag');
|
||||
console.log('initial:', {
|
||||
enabled: initial?.enabled,
|
||||
source: initial?.source,
|
||||
default: initial?.defaultEnabled,
|
||||
});
|
||||
|
||||
configValue = { demo_flag: true };
|
||||
flags.setConfigOverrides(configValue);
|
||||
const overridden = flags.explain('demo_flag');
|
||||
console.log('after setConfigOverrides({ demo_flag: true }):', {
|
||||
enabled: overridden?.enabled,
|
||||
source: overridden?.source,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -1,116 +0,0 @@
|
|||
/**
|
||||
* Example 13 — the `fileTools` slice across all three scope tiers.
|
||||
*
|
||||
* Concept taught: a real feature is a *vertical slice*. Each built-in tool is
|
||||
* a DI class (constructor injects its dependencies with `@IX`) that
|
||||
* self-registers via `registerTool(ReadTool)` at module load. The Agent-scope
|
||||
* `IAgentToolRegistryService` consumes every module-level contribution when it
|
||||
* is constructed and stores the resulting tool instances in the per-agent
|
||||
* runtime table. The tool ctors themselves inject Session-scope peers
|
||||
* (`ISessionAgentFileSystem`, `ISessionFsService`,
|
||||
* `ISessionWorkspaceContext`) and App-scope peers (`IHostEnvironment`,
|
||||
* `ITelemetryService`) — the same "short-lived injects long-lived" rule made
|
||||
* concrete.
|
||||
*
|
||||
* We stub the leaf dependencies with minimal fakes instead of constructing
|
||||
* their real implementations, so the example needs no kaos and stays focused
|
||||
* on the wiring.
|
||||
*
|
||||
* Prerequisites: example 01 (container & scope tree).
|
||||
*
|
||||
* Run:
|
||||
* pnpm --filter @moonshot-ai/agent-core-v2 example -- examples/file-tools.example.ts
|
||||
*/
|
||||
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { LifecycleScope } from '#/_base/di/scope';
|
||||
import { createScopedTestHost, stubPair } from '#/_base/di/test';
|
||||
|
||||
// Side-effect import: each tool file calls `registerTool(SomeTool)` at module
|
||||
// load; the barrel re-exports them so this one import is enough to add
|
||||
// Read/Write/Edit/Grep/Glob to the contribution list.
|
||||
import '#/agent/fileTools';
|
||||
import {
|
||||
IAgentBuiltinToolsRegistrar,
|
||||
IAgentToolRegistryService,
|
||||
} from '#/agent/toolRegistry';
|
||||
|
||||
import { IHostEnvironment } from '#/os/interface/hostEnvironment';
|
||||
import { ITelemetryService, noopTelemetryService } from '#/app/telemetry';
|
||||
import {
|
||||
ISessionAgentFileSystem,
|
||||
ISessionFsService,
|
||||
} from '#/session/agentFs';
|
||||
import { ISessionProcessRunner } from '#/os/interface/process';
|
||||
import { ISessionWorkspaceContext } from '#/session/workspaceContext';
|
||||
|
||||
// Minimal leaf fakes. The real tool constructors only read these surfaces
|
||||
// during construction.
|
||||
const fakeEnv: IHostEnvironment = {
|
||||
_serviceBrand: undefined,
|
||||
osKind: 'Linux',
|
||||
osArch: 'x86_64',
|
||||
osVersion: 'test',
|
||||
shellName: 'bash',
|
||||
shellPath: '/bin/bash',
|
||||
pathClass: 'posix',
|
||||
homeDir: '/home',
|
||||
ready: Promise.resolve(),
|
||||
};
|
||||
const fakeFs = { cwd: '/workspace' } as unknown as ISessionAgentFileSystem;
|
||||
const fakeFsService = {} as unknown as ISessionFsService;
|
||||
const fakeRunner = {
|
||||
_serviceBrand: undefined,
|
||||
exec: vi.fn(),
|
||||
} as unknown as ISessionProcessRunner;
|
||||
const fakeWorkspace = {
|
||||
workDir: '/workspace',
|
||||
additionalDirs: [],
|
||||
} as unknown as ISessionWorkspaceContext;
|
||||
|
||||
describe('example 13 — file-tools slice (App + Session + Agent)', () => {
|
||||
it('registers the five built-in file tools through the scope tree', () => {
|
||||
const host = createScopedTestHost([
|
||||
stubPair(IHostEnvironment, fakeEnv),
|
||||
stubPair(ITelemetryService, noopTelemetryService),
|
||||
]);
|
||||
const session = host.child(LifecycleScope.Session, 's1', [
|
||||
stubPair(ISessionAgentFileSystem, fakeFs),
|
||||
stubPair(ISessionFsService, fakeFsService),
|
||||
stubPair(ISessionProcessRunner, fakeRunner),
|
||||
stubPair(ISessionWorkspaceContext, fakeWorkspace),
|
||||
]);
|
||||
const agent = host.childOf(session, LifecycleScope.Agent, 'main');
|
||||
|
||||
// Force-instantiate the Eager builtin-tools registrar: its constructor
|
||||
// consumes every registered tool contribution and builds each tool
|
||||
// instance against this Agent scope.
|
||||
agent.accessor.get(IAgentBuiltinToolsRegistrar);
|
||||
const tools = agent.accessor.get(IAgentToolRegistryService).list();
|
||||
const names = tools.map((t) => t.name);
|
||||
expect(names).toEqual(expect.arrayContaining(['Edit', 'Glob', 'Grep', 'Read', 'Write']));
|
||||
|
||||
host.dispose();
|
||||
});
|
||||
|
||||
it('resolves the same Agent-scope registry on repeated access (singleton per scope)', () => {
|
||||
const host = createScopedTestHost([
|
||||
stubPair(IHostEnvironment, fakeEnv),
|
||||
stubPair(ITelemetryService, noopTelemetryService),
|
||||
]);
|
||||
const session = host.child(LifecycleScope.Session, 's1', [
|
||||
stubPair(ISessionAgentFileSystem, fakeFs),
|
||||
stubPair(ISessionFsService, fakeFsService),
|
||||
stubPair(ISessionProcessRunner, fakeRunner),
|
||||
stubPair(ISessionWorkspaceContext, fakeWorkspace),
|
||||
]);
|
||||
const agent = host.childOf(session, LifecycleScope.Agent, 'main');
|
||||
|
||||
const a = agent.accessor.get(IAgentToolRegistryService);
|
||||
const b = agent.accessor.get(IAgentToolRegistryService);
|
||||
expect(a).toBe(b);
|
||||
|
||||
host.dispose();
|
||||
});
|
||||
});
|
||||
|
|
@ -1,101 +0,0 @@
|
|||
/**
|
||||
* Scenario: the **plan** slice — an entity-like domain backed by the append-log
|
||||
* record layer.
|
||||
*
|
||||
* Concept taught: `goal`, `plan`, and `todoList` are entity-like domains whose
|
||||
* durable state is carried by records on the append-log (`IAgentRecordService`),
|
||||
* not by private fields alone. Each lifecycle change is persisted with
|
||||
* `record.append({ type: '...' })`, and the same record both broadcasts the
|
||||
* change live and rebuilds the entity on resume through its `resume` facet.
|
||||
*
|
||||
* We demonstrate the pattern on `plan` because it is the lightest domain that
|
||||
* actually emits to the record log: `enter` appends a `plan_mode.enter` record,
|
||||
* `status` reads the entity, and `exit` appends a `plan_mode.exit` record.
|
||||
* `goal` follows the same append-log pattern (`goal.create` / `goal.update` /
|
||||
* `goal.clear`); `todoList` stores its items in the tool store rather than the
|
||||
* record log, so it is not re-wired here.
|
||||
*
|
||||
* Wiring: the real composition root (`_harness`) provides every collaborator,
|
||||
* including the real `IAgentRecordService`. We spy on the record service's
|
||||
* `append` / `define` to observe the records and to capture the `resume` facet,
|
||||
* so the slice runs end-to-end for real with no hand-rolled stub list.
|
||||
*
|
||||
* Prerequisites: example 01 (container & scope tree).
|
||||
*
|
||||
* Run:
|
||||
* pnpm --filter @moonshot-ai/agent-core-v2 example -- examples/goals-plans-todos.example.ts
|
||||
*/
|
||||
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { IAgentPlanService } from '#/agent/plan';
|
||||
import { IAgentRecordService } from '#/agent/record';
|
||||
|
||||
import { createSliceHost, type SliceHost } from './_harness';
|
||||
|
||||
describe('goals-plans-todos slice (append-log CRUD via plan)', () => {
|
||||
let host: SliceHost;
|
||||
afterEach(() => host?.dispose());
|
||||
|
||||
function setUp() {
|
||||
host = createSliceHost({ homeDir: process.env['KIMI_CODE_HOME']! });
|
||||
// Resolve the real record service first and instrument it; the plan service
|
||||
// is constructed lazily and will pick up the same singleton.
|
||||
const records = host.agent.accessor.get(IAgentRecordService);
|
||||
const appended: Array<{ type: string; id?: string }> = [];
|
||||
const facets = new Map<string, { resume?: (r: { type: string; id?: string }) => unknown }>();
|
||||
vi.spyOn(records, 'append').mockImplementation((r) => {
|
||||
appended.push(r as { type: string; id?: string });
|
||||
});
|
||||
vi.spyOn(records, 'define').mockImplementation((type, facet) => {
|
||||
facets.set(type as string, facet as { resume?: (r: { type: string; id?: string }) => unknown });
|
||||
return { dispose: () => facets.delete(type as string) };
|
||||
});
|
||||
const plan = host.agent.accessor.get(IAgentPlanService);
|
||||
return { plan, appended, facets };
|
||||
}
|
||||
|
||||
it('entering plan mode activates the plan and appends a plan_mode.enter record', async () => {
|
||||
const { plan, appended } = setUp();
|
||||
|
||||
await plan.enter('ship-v2');
|
||||
|
||||
expect(appended.map((r) => r.type)).toContain('plan_mode.enter');
|
||||
const status = await plan.status();
|
||||
expect(status?.id).toBe('ship-v2');
|
||||
});
|
||||
|
||||
it('exiting plan mode appends a plan_mode.exit record and deactivates the plan', async () => {
|
||||
const { plan, appended } = setUp();
|
||||
|
||||
await plan.enter('ship-v2');
|
||||
plan.exit('ship-v2');
|
||||
|
||||
expect(appended.map((r) => r.type)).toEqual(['plan_mode.enter', 'plan_mode.exit']);
|
||||
expect(await plan.status()).toBeNull();
|
||||
});
|
||||
|
||||
it('cancelling plan mode appends a plan_mode.cancel record', async () => {
|
||||
const { plan, appended } = setUp();
|
||||
|
||||
await plan.enter('scratch');
|
||||
plan.cancel('scratch');
|
||||
|
||||
expect(appended.map((r) => r.type)).toEqual(['plan_mode.enter', 'plan_mode.cancel']);
|
||||
expect(await plan.status()).toBeNull();
|
||||
});
|
||||
|
||||
it('replays records through their resume facets to rebuild plan state', async () => {
|
||||
const { plan, facets } = setUp();
|
||||
|
||||
// Wake the lazy service so its constructor registers the resume facets.
|
||||
expect(await plan.status()).toBeNull();
|
||||
expect(facets.has('plan_mode.enter')).toBe(true);
|
||||
|
||||
await facets.get('plan_mode.enter')!.resume!({ type: 'plan_mode.enter', id: 'restored' });
|
||||
expect((await plan.status())?.id).toBe('restored');
|
||||
|
||||
await facets.get('plan_mode.exit')!.resume!({ type: 'plan_mode.exit' });
|
||||
expect(await plan.status()).toBeNull();
|
||||
});
|
||||
});
|
||||
|
|
@ -1,121 +0,0 @@
|
|||
/**
|
||||
* Scenario: the **os** slice — `IHostEnvironment` + the `IExecContext` seed.
|
||||
*
|
||||
* The os dimension is organised as:
|
||||
*
|
||||
* os/
|
||||
* interface/ ← contracts only: IHostEnvironment, IExecContext,
|
||||
* ISessionAgentFileSystem, IHostFileSystem,
|
||||
* ISessionProcessRunner, ISessionTerminalService,
|
||||
* ISessionTerminalBackend, IHostFolderBrowser
|
||||
* backends/
|
||||
* node-local/ ← HostEnvironmentService, SessionAgentFileSystem,
|
||||
* HostFileSystem, SessionProcessRunner, etc.
|
||||
*
|
||||
* Concept taught: not every dependency is *constructed* by the container. Some
|
||||
* enter the scope tree as plain **values** seeded through `stubPair(...)` /
|
||||
* `ScopeSeed`:
|
||||
*
|
||||
* - `IHostEnvironment` (App scope) — an immutable snapshot of the host OS,
|
||||
* shell, path style, and home directory. One per process.
|
||||
* - `IExecContext` (Session scope) — the session's `cwd` + env overlays. It is
|
||||
* a value, not a service: it has no `registerScopedService` entry, which is
|
||||
* why the dep-graph lists it as an "unresolved" token even though Session
|
||||
* and Agent services inject it. `sessionLifecycle` seeds it when a session
|
||||
* is created; `withCwd` / `withEnv` derive new contexts immutably.
|
||||
*
|
||||
* `SessionWorkspaceContextService` consumes `IExecContext` and resolves every
|
||||
* path relative to the seeded `cwd` — so the same service behaves differently
|
||||
* in two sibling Sessions purely because each was seeded a different context.
|
||||
*
|
||||
* Prerequisites: example 01 (container & scope tree).
|
||||
*
|
||||
* Run:
|
||||
* pnpm --filter @moonshot-ai/agent-core-v2 example -- examples/host.example.ts
|
||||
*/
|
||||
|
||||
import { beforeEach, describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
LifecycleScope,
|
||||
_clearScopedRegistryForTests,
|
||||
registerScopedService,
|
||||
} from '#/_base/di/scope';
|
||||
import { createScopedTestHost, stubPair } from '#/_base/di/test';
|
||||
|
||||
// ── os/interface ──────────────────────────────────────────────────────
|
||||
// Import contracts from the canonical os/interface paths.
|
||||
import { IHostEnvironment } from '#/os/interface/hostEnvironment';
|
||||
import {
|
||||
createExecContext,
|
||||
IExecContext,
|
||||
} from '#/os/interface/execContext';
|
||||
|
||||
// Workspace context stays in session/ — it's a business-level facade.
|
||||
import {
|
||||
ISessionWorkspaceContext,
|
||||
SessionWorkspaceContextService,
|
||||
} from '#/session/workspaceContext';
|
||||
|
||||
const fakeEnv: IHostEnvironment = {
|
||||
_serviceBrand: undefined,
|
||||
osKind: 'Linux',
|
||||
osArch: 'x86_64',
|
||||
osVersion: 'test',
|
||||
shellName: 'bash',
|
||||
shellPath: '/bin/bash',
|
||||
pathClass: 'posix',
|
||||
homeDir: '/home/test',
|
||||
ready: Promise.resolve(),
|
||||
};
|
||||
|
||||
describe('host slice (IHostEnvironment + IExecContext seed)', () => {
|
||||
beforeEach(() => {
|
||||
_clearScopedRegistryForTests();
|
||||
registerScopedService(
|
||||
LifecycleScope.Session,
|
||||
ISessionWorkspaceContext,
|
||||
SessionWorkspaceContextService,
|
||||
);
|
||||
});
|
||||
|
||||
it('resolves paths against the seeded IExecContext.cwd', () => {
|
||||
const host = createScopedTestHost([stubPair(IHostEnvironment, fakeEnv)]);
|
||||
const session = host.child(LifecycleScope.Session, 's1', [
|
||||
stubPair(IExecContext, createExecContext('/workspace')),
|
||||
]);
|
||||
|
||||
const ws = session.accessor.get(ISessionWorkspaceContext);
|
||||
expect(ws.workDir).toBe('/workspace');
|
||||
expect(ws.resolve('src/index.ts')).toBe('/workspace/src/index.ts');
|
||||
expect(ws.isWithin('/workspace/src/index.ts')).toBe(true);
|
||||
expect(ws.isWithin('/elsewhere/file.ts')).toBe(false);
|
||||
|
||||
host.dispose();
|
||||
});
|
||||
|
||||
it('isolates IExecContext between sibling Session scopes', () => {
|
||||
const host = createScopedTestHost([stubPair(IHostEnvironment, fakeEnv)]);
|
||||
const s1 = host.child(LifecycleScope.Session, 's1', [
|
||||
stubPair(IExecContext, createExecContext('/repo-a')),
|
||||
]);
|
||||
const s2 = host.child(LifecycleScope.Session, 's2', [
|
||||
stubPair(IExecContext, createExecContext('/repo-b')),
|
||||
]);
|
||||
|
||||
expect(s1.accessor.get(ISessionWorkspaceContext).workDir).toBe('/repo-a');
|
||||
expect(s2.accessor.get(ISessionWorkspaceContext).workDir).toBe('/repo-b');
|
||||
|
||||
host.dispose();
|
||||
});
|
||||
|
||||
it('derives a new context with withCwd without mutating the original', () => {
|
||||
const base = createExecContext('/workspace', [{ PATH: '/usr/bin' }]);
|
||||
const derived = base.withCwd('/workspace/sub');
|
||||
|
||||
expect(derived.cwd).toBe('/workspace/sub');
|
||||
expect(derived.envLayers).toEqual([{ PATH: '/usr/bin' }]);
|
||||
// Original is untouched — IExecContext is immutable.
|
||||
expect(base.cwd).toBe('/workspace');
|
||||
});
|
||||
});
|
||||
|
|
@ -1,157 +0,0 @@
|
|||
/**
|
||||
* Scenario: the **interaction** kernel and its `approval` / `question` facades,
|
||||
* resolved through the **Session scope** they belong to.
|
||||
*
|
||||
* All three Services are registered at `LifecycleScope.Session`, so this
|
||||
* example resolves them from a real Session scope (`createScopedTestHost` →
|
||||
* `host.child(LifecycleScope.Session, …)`), the same layer production uses. The
|
||||
* scoped registry is cleared and re-populated explicitly in `beforeEach` rather
|
||||
* than relying on import-order side effects.
|
||||
*
|
||||
* `ISessionInteractionService` is the only Service that owns state — a pending set
|
||||
* plus a recently-resolved ledger — and it is domain-agnostic.
|
||||
* `ISessionApprovalService` and `ISessionQuestionService` are zero-state typed facades over
|
||||
* it: they tag each request with `kind: 'approval'` / `kind: 'question'`,
|
||||
* rename the resolve verb (`decide` / `answer` → `respond`), and cast the
|
||||
* stored payload back to the typed request on `listPending`.
|
||||
*
|
||||
* Two calling styles are demonstrated:
|
||||
*
|
||||
* - **Blocking** (`request`): the caller `await`s a Promise that parks until a
|
||||
* response arrives. Used by in-turn code (a tool gating on a user decision).
|
||||
* - **Non-blocking** (`enqueue` + `onDidResolve`): the caller parks the request
|
||||
* and returns its `id` immediately; the outcome is delivered through the
|
||||
* `onDidResolve` stream. Used by edge callers that stream the result rather
|
||||
* than awaiting a Promise (e.g. over WebSocket).
|
||||
*
|
||||
* The final scenario proves Session-scope isolation: two sessions hold
|
||||
* independent brokers, so a request parked in session A is invisible to, and
|
||||
* not resolvable from, session B.
|
||||
*/
|
||||
|
||||
import type { ToolInputDisplay } from '@moonshot-ai/protocol';
|
||||
import { afterEach, beforeEach, describe, test } from 'vitest';
|
||||
|
||||
import { InstantiationType } from '#/_base/di/extensions';
|
||||
import { DisposableStore } from '#/_base/di/lifecycle';
|
||||
import {
|
||||
_clearScopedRegistryForTests,
|
||||
LifecycleScope,
|
||||
registerScopedService,
|
||||
type Scope,
|
||||
} from '#/_base/di/scope';
|
||||
import { createScopedTestHost, type ScopedTestHost } from '#/_base/di/test';
|
||||
import { type ApprovalRequest, SessionApprovalService, ISessionApprovalService } from '#/session/approval';
|
||||
import { ISessionInteractionService, SessionInteractionService } from '#/session/interaction';
|
||||
import { type QuestionRequest, ISessionQuestionService, SessionQuestionService } from '#/session/question';
|
||||
|
||||
const display: ToolInputDisplay = { kind: 'command', command: 'rm -rf /tmp/demo' };
|
||||
|
||||
function approval(id: string): ApprovalRequest {
|
||||
return { id, toolName: 'bash', action: 'run', display };
|
||||
}
|
||||
|
||||
function question(id: string): QuestionRequest {
|
||||
return {
|
||||
id,
|
||||
questions: [
|
||||
{
|
||||
question: 'What is your name?',
|
||||
options: [{ label: 'kimi' }, { label: 'other' }],
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
describe('interaction kernel + approval/question facades (Session scope)', () => {
|
||||
let disposables: DisposableStore;
|
||||
let host: ScopedTestHost;
|
||||
let session: Scope;
|
||||
|
||||
beforeEach(() => {
|
||||
_clearScopedRegistryForTests();
|
||||
registerScopedService(LifecycleScope.Session, ISessionInteractionService, SessionInteractionService, InstantiationType.Delayed, 'interaction');
|
||||
registerScopedService(LifecycleScope.Session, ISessionApprovalService, SessionApprovalService, InstantiationType.Delayed, 'approval');
|
||||
registerScopedService(LifecycleScope.Session, ISessionQuestionService, SessionQuestionService, InstantiationType.Delayed, 'question');
|
||||
|
||||
disposables = new DisposableStore();
|
||||
host = createScopedTestHost();
|
||||
session = host.child(LifecycleScope.Session, 'session-a');
|
||||
});
|
||||
afterEach(() => {
|
||||
disposables.dispose();
|
||||
host.dispose();
|
||||
});
|
||||
|
||||
test('blocking: approval.request parks until decide resolves the Promise', async () => {
|
||||
const approvals = session.accessor.get(ISessionApprovalService);
|
||||
|
||||
// The caller (e.g. a tool) awaits the decision. Nothing resolves yet.
|
||||
const decision = approvals.request(approval('bash-1'));
|
||||
console.log('1) after request, pending approvals:', approvals.listPending().map((r) => r.id));
|
||||
|
||||
// The edge (HTTP/WS `approvals:decide`) supplies the user's decision.
|
||||
approvals.decide('bash-1', { decision: 'approved' });
|
||||
console.log('2) resolved decision:', await decision);
|
||||
console.log('3) after decide, pending approvals:', approvals.listPending());
|
||||
});
|
||||
|
||||
test('non-blocking: question.enqueue returns immediately; the answer streams over onDidResolve', () => {
|
||||
const interaction = session.accessor.get(ISessionInteractionService);
|
||||
const questions = session.accessor.get(ISessionQuestionService);
|
||||
|
||||
// Edge callers observe outcomes through the stream instead of awaiting.
|
||||
const resolved: { id: string; response: unknown }[] = [];
|
||||
disposables.add(interaction.onDidResolve((r) => resolved.push(r)));
|
||||
|
||||
// enqueue parks the request and returns its id without blocking.
|
||||
const parked = questions.enqueue(question('q-name'));
|
||||
console.log('1) enqueued question (id known up front):', parked);
|
||||
console.log('2) pending questions:', questions.listPending());
|
||||
|
||||
// The answer arrives later (HTTP/WS `questions:answer`) and fans out.
|
||||
questions.answer('q-name', { answers: { q_0: 'kimi' } });
|
||||
console.log('3) onDidResolve stream delivered:', resolved);
|
||||
console.log('4) after answer, pending questions:', questions.listPending());
|
||||
});
|
||||
|
||||
test('one kernel backs both facades; onDidChangePending announces every mutation', () => {
|
||||
const interaction = session.accessor.get(ISessionInteractionService);
|
||||
const approvals = session.accessor.get(ISessionApprovalService);
|
||||
const questions = session.accessor.get(ISessionQuestionService);
|
||||
|
||||
let changes = 0;
|
||||
disposables.add(interaction.onDidChangePending(() => changes++));
|
||||
|
||||
void approvals.request(approval('bash-1')); // change #1 (park approval)
|
||||
questions.enqueue(question('q-name')); // change #2 (park question)
|
||||
|
||||
// The kernel sees every pending interaction, regardless of which facade parked it.
|
||||
console.log('1) kernel listPending (all kinds):', interaction.listPending().map((i) => i.kind));
|
||||
console.log('2) kernel listPending("approval"):', interaction.listPending('approval').map((i) => i.id));
|
||||
console.log('3) kernel listPending("question"):', interaction.listPending('question').map((i) => i.id));
|
||||
|
||||
approvals.decide('bash-1', { decision: 'rejected' }); // change #3 (resolve approval)
|
||||
questions.answer('q-name', { answers: { q_0: 'kimi' } }); // change #4 (resolve question)
|
||||
console.log('4) onDidChangePending fired', changes, 'times (park x2 + resolve x2)');
|
||||
});
|
||||
|
||||
test('Session scope isolates brokers: a request parked in A is invisible to B', async () => {
|
||||
const sessionB = host.child(LifecycleScope.Session, 'session-b');
|
||||
|
||||
const approvalsA = session.accessor.get(ISessionApprovalService);
|
||||
const approvalsB = sessionB.accessor.get(ISessionApprovalService);
|
||||
console.log('1) distinct broker instances per session:', approvalsA !== approvalsB);
|
||||
|
||||
const decisionA = approvalsA.request(approval('bash-1'));
|
||||
console.log('2) A pending after park:', approvalsA.listPending().map((r) => r.id));
|
||||
console.log('3) B pending (isolated):', approvalsB.listPending().map((r) => r.id));
|
||||
|
||||
// Deciding from B is a no-op — the id is parked in A's kernel, not B's.
|
||||
approvalsB.decide('bash-1', { decision: 'approved' });
|
||||
console.log('4) A still pending after B.decide (no-op):', approvalsA.listPending().map((r) => r.id));
|
||||
|
||||
approvalsA.decide('bash-1', { decision: 'approved' });
|
||||
console.log('5) A resolved by its own broker:', await decisionA);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,127 +0,0 @@
|
|||
/**
|
||||
* Scenario: the **model → provider** slice — inspecting the configured LLM
|
||||
* providers and model aliases.
|
||||
*
|
||||
* Concept taught: provider / model configuration is split across two App-scope
|
||||
* registries backed by the `providers` and `models` config sections:
|
||||
*
|
||||
* - `IProviderService` (App) holds the configured providers (type, baseUrl,
|
||||
* credentials) and emits `onDidChangeProviders` when the set changes.
|
||||
* - `IModelService` (App) holds model aliases (provider + model id + context
|
||||
* limits) and emits `onDidChangeModels`.
|
||||
*
|
||||
* Both are read/write registries over `IConfigService`: a `set` validates and
|
||||
* persists to `config.toml`, and the change event fires so downstream domains
|
||||
* react without threading model lists around.
|
||||
*
|
||||
* Wiring: the real composition root (`_harness`) provides every collaborator.
|
||||
* Each test gets its own isolated `config.toml` (a fresh `homeDir`) so writes
|
||||
* in one test cannot leak into the next test's default view.
|
||||
*
|
||||
* Run:
|
||||
* pnpm --filter @moonshot-ai/agent-core-v2 example -- examples/model-provider.example.ts
|
||||
*/
|
||||
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { mkdirSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, test } from 'vitest';
|
||||
|
||||
import { IConfigService } from '#/app/config';
|
||||
import { IModelService } from '#/app/model';
|
||||
import { IProviderService } from '#/app/provider';
|
||||
|
||||
import { createSliceHost, type SliceHost } from './_harness';
|
||||
|
||||
describe('model-provider slice (provider + model registries)', () => {
|
||||
let caseDir: string;
|
||||
let host: SliceHost;
|
||||
|
||||
beforeEach(() => {
|
||||
const resolved = process.env['KIMI_CODE_HOME'];
|
||||
if (resolved === undefined) {
|
||||
throw new Error('KIMI_CODE_HOME is not set; globalSetup should have initialized it');
|
||||
}
|
||||
// Give every test its own config.toml so provider / model writes in one test
|
||||
// cannot leak into the next test's "empty" default view.
|
||||
caseDir = join(resolved, randomUUID());
|
||||
mkdirSync(caseDir, { recursive: true });
|
||||
});
|
||||
|
||||
afterEach(() => host?.dispose());
|
||||
|
||||
test('IProviderService lists configured providers and reflects set/get/delete', async () => {
|
||||
host = createSliceHost({ homeDir: caseDir });
|
||||
const config = host.app.accessor.get(IConfigService);
|
||||
const providers = host.app.accessor.get(IProviderService);
|
||||
await config.ready;
|
||||
|
||||
// Default shape: a record with no entry under our (unused) key. We do not
|
||||
// assert exact emptiness — env bindings may synthesize a reserved provider.
|
||||
expect(typeof providers.list()).toBe('object');
|
||||
expect(providers.get('demo-openai')).toBeUndefined();
|
||||
|
||||
const added: string[] = [];
|
||||
const removed: string[] = [];
|
||||
const sub = providers.onDidChangeProviders((e) => {
|
||||
added.push(...e.added);
|
||||
removed.push(...e.removed);
|
||||
});
|
||||
|
||||
await providers.set('demo-openai', {
|
||||
type: 'openai',
|
||||
baseUrl: 'https://example.com/v1',
|
||||
apiKey: 'YOUR_API_KEY',
|
||||
});
|
||||
|
||||
expect(providers.get('demo-openai')).toMatchObject({
|
||||
type: 'openai',
|
||||
baseUrl: 'https://example.com/v1',
|
||||
});
|
||||
expect(providers.list()['demo-openai']).toBeDefined();
|
||||
expect(added).toContain('demo-openai');
|
||||
|
||||
await providers.delete('demo-openai');
|
||||
sub.dispose();
|
||||
|
||||
expect(providers.get('demo-openai')).toBeUndefined();
|
||||
expect(removed).toContain('demo-openai');
|
||||
});
|
||||
|
||||
test('IModelService lists configured model aliases and reflects set/get/delete', async () => {
|
||||
host = createSliceHost({ homeDir: caseDir });
|
||||
const config = host.app.accessor.get(IConfigService);
|
||||
const models = host.app.accessor.get(IModelService);
|
||||
await config.ready;
|
||||
|
||||
expect(typeof models.list()).toBe('object');
|
||||
expect(models.get('demo-model')).toBeUndefined();
|
||||
|
||||
const added: string[] = [];
|
||||
const removed: string[] = [];
|
||||
const sub = models.onDidChangeModels((e) => {
|
||||
added.push(...e.added);
|
||||
removed.push(...e.removed);
|
||||
});
|
||||
|
||||
await models.set('demo-model', {
|
||||
provider: 'demo-openai',
|
||||
model: 'gpt-demo',
|
||||
maxContextSize: 8192,
|
||||
});
|
||||
|
||||
expect(models.get('demo-model')).toMatchObject({
|
||||
provider: 'demo-openai',
|
||||
model: 'gpt-demo',
|
||||
});
|
||||
expect(models.list()['demo-model']).toBeDefined();
|
||||
expect(added).toContain('demo-model');
|
||||
|
||||
await models.delete('demo-model');
|
||||
sub.dispose();
|
||||
|
||||
expect(models.get('demo-model')).toBeUndefined();
|
||||
expect(removed).toContain('demo-model');
|
||||
});
|
||||
});
|
||||
|
|
@ -1,373 +0,0 @@
|
|||
/**
|
||||
* Scenario: the **Provider / Platform / Protocol / Model** slice, driven from
|
||||
* a real `~/.kimi-code/config.toml` and its credentials, and exercised through
|
||||
* the new `IModelResolver` → `Model` god-object path introduced in the
|
||||
* "Model god-object and protocol domains" change.
|
||||
*
|
||||
* Goals of this example:
|
||||
* 1. **Sandbox the real config.** At runtime, copy `~/.kimi-code/config.toml`
|
||||
* and `~/.kimi-code/credentials/` into the per-run `KIMI_CODE_HOME` the
|
||||
* example harness provisions (`.vitest-results/kimi-code-{ts}/`). The real
|
||||
* home is never read or written directly — even an OAuth token refresh
|
||||
* lands in the sandbox copy.
|
||||
* 2. **List everything.** Enumerate every `[providers.*]`, `[platforms.*]`,
|
||||
* supported `Protocol`, and `[models.*]` entry, then resolve each Model id
|
||||
* through `IModelResolver` and report whether it produces a runnable
|
||||
* `Model` (protocol, base URL, auth mode) — a concrete compatibility
|
||||
* matrix for the new god-object resolver.
|
||||
* 3. **Ping every Model.** Send a "ping" → expect a streamed response
|
||||
* against **every** Model that resolved (bounded concurrency, per-request
|
||||
* timeout), and report which ones actually answer — an end-to-end reachability
|
||||
* check for the whole configured catalogue, not just the default model.
|
||||
*
|
||||
* All Services come from `src/`; nothing here defines a new Service.
|
||||
*/
|
||||
|
||||
import { copyFileSync, existsSync, mkdirSync, readdirSync, statSync } from 'node:fs';
|
||||
import { homedir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
|
||||
import { afterAll, beforeAll, describe, expect, test } from 'vitest';
|
||||
|
||||
import '#/index';
|
||||
|
||||
import { type Scope, type ScopeSeed } from '#/_base/di/scope';
|
||||
import { bootstrap } from '#/app/bootstrap';
|
||||
import { IConfigService } from '#/app/config';
|
||||
import { createUserMessage, isContentPart, type TokenUsage } from '#/app/llmProtocol';
|
||||
import { IModelResolver, IModelService, type Model, type ModelConfig } from '#/app/model';
|
||||
import { IPlatformService } from '#/app/platform';
|
||||
import { IProviderService, type ProviderConfig } from '#/app/provider';
|
||||
import { IProtocolAdapterRegistry } from '#/app/protocol';
|
||||
import { ILogOptions, resolveLoggingConfig } from '#/app/log/logConfig';
|
||||
|
||||
const PER_REQUEST_TIMEOUT_MS = 30_000;
|
||||
const PING_CONCURRENCY = 4;
|
||||
const ALL_MODELS_TEST_TIMEOUT_MS = 300_000;
|
||||
|
||||
interface ModelReport {
|
||||
readonly id: string;
|
||||
readonly name?: string;
|
||||
readonly protocol?: string;
|
||||
readonly baseUrl?: string;
|
||||
readonly authMode: string;
|
||||
readonly resolved: boolean;
|
||||
readonly error?: string;
|
||||
}
|
||||
|
||||
describe('model / provider / platform / protocol slice (resolved from a sandboxed ~/.kimi-code)', () => {
|
||||
let app: Scope | undefined;
|
||||
let sandboxHome = '';
|
||||
let configCopied = false;
|
||||
let credentialsCopied = 0;
|
||||
let reports: readonly ModelReport[] = [];
|
||||
let useRealHome = false;
|
||||
|
||||
beforeAll(() => {
|
||||
useRealHome = process.env['KIMI_CODE_EXAMPLE_USE_REAL_HOME'] === '1';
|
||||
sandboxHome = useRealHome ? join(homedir(), '.kimi-code') : resolveSandboxHome();
|
||||
if (useRealHome) {
|
||||
// Run directly against the real home so OAuth token refresh can read AND
|
||||
// write back the real credentials. Read-only for config — this example
|
||||
// never calls IConfigService.set/replace.
|
||||
configCopied = true;
|
||||
credentialsCopied = 0;
|
||||
} else {
|
||||
const mirror = mirrorRealKimiHome(sandboxHome);
|
||||
configCopied = mirror.configCopied;
|
||||
credentialsCopied = mirror.credentialsCopied;
|
||||
}
|
||||
|
||||
const logSeed: ScopeSeed = [
|
||||
[ILogOptions, resolveLoggingConfig({ homeDir: sandboxHome, env: process.env })],
|
||||
];
|
||||
app = bootstrap({ homeDir: sandboxHome }, logSeed).app;
|
||||
});
|
||||
|
||||
afterAll(() => app?.dispose());
|
||||
|
||||
test('lists every Provider / Platform / Protocol and resolves every Model', async () => {
|
||||
const host = requireApp(app);
|
||||
const config = host.accessor.get(IConfigService);
|
||||
await config.ready;
|
||||
|
||||
const providers = host.accessor.get(IProviderService);
|
||||
const platforms = host.accessor.get(IPlatformService);
|
||||
const models = host.accessor.get(IModelService);
|
||||
const resolver = host.accessor.get(IModelResolver);
|
||||
const protocols = host.accessor.get(IProtocolAdapterRegistry);
|
||||
|
||||
// Touch each registry so its config section is registered before we read.
|
||||
const providerMap = providers.list();
|
||||
const platformMap = platforms.list();
|
||||
const modelMap = models.list();
|
||||
const supportedProtocols = protocols.supportedProtocols();
|
||||
|
||||
console.log(`\nhome: ${sandboxHome}${useRealHome ? ' (REAL ~/.kimi-code)' : ' (sandbox copy)'}`);
|
||||
console.log(`config.toml copied: ${useRealHome ? 'n/a (using real)' : configCopied}`);
|
||||
console.log(`credentials copied: ${useRealHome ? 'n/a (using real)' : credentialsCopied}`);
|
||||
console.log(`\nsupported protocols: ${supportedProtocols.join(', ') || '(none)'}`);
|
||||
|
||||
console.log(`\n[providers.*] (${Object.keys(providerMap).length}):`);
|
||||
for (const [id, p] of Object.entries(providerMap)) {
|
||||
console.log(` - ${id}: type=${p.type ?? '-'} baseUrl=${p.baseUrl ?? '-'} auth=${providerAuthMode(p)} platform=${p.platformId ?? '-'}`);
|
||||
}
|
||||
|
||||
console.log(`\n[platforms.*] (${Object.keys(platformMap).length}):`);
|
||||
if (Object.keys(platformMap).length === 0) console.log(' (none configured)');
|
||||
for (const [id, pl] of Object.entries(platformMap)) {
|
||||
const auth = pl.auth?.apiKey !== undefined ? 'apiKey' : pl.auth?.oauth !== undefined ? 'oauth' : pl.auth?.env !== undefined ? 'env' : '-';
|
||||
console.log(` - ${id}: auth=${auth} displayName=${pl.displayName ?? '-'}`);
|
||||
}
|
||||
|
||||
reports = Object.entries(modelMap).map(([id, m]) => resolveOne(id, m, providerMap, resolver));
|
||||
|
||||
console.log(`\n[models.*] (${reports.length}) — resolve compatibility:`);
|
||||
for (const r of reports) {
|
||||
const head = r.resolved ? 'OK ' : 'FAIL';
|
||||
const detail = r.resolved
|
||||
? `protocol=${r.protocol} baseUrl=${r.baseUrl} auth=${r.authMode} name=${r.name}`
|
||||
: `auth=${r.authMode} error=${r.error}`;
|
||||
console.log(` [${head}] ${r.id} → ${detail}`);
|
||||
}
|
||||
|
||||
// The example is meaningful even on a machine without the real config: it
|
||||
// simply reports an empty registry instead of failing.
|
||||
if (!configCopied) {
|
||||
console.log('\n(no ~/.kimi-code/config.toml found — reporting an empty registry)');
|
||||
return;
|
||||
}
|
||||
|
||||
expect(Object.keys(providerMap).length).toBeGreaterThan(0);
|
||||
expect(reports.length).toBeGreaterThan(0);
|
||||
// Every configured Model must at least resolve into a god-object; a
|
||||
// resolution failure here is a real compatibility regression.
|
||||
const failures = reports.filter((r) => !r.resolved);
|
||||
expect(
|
||||
failures,
|
||||
`models that failed to resolve: ${failures.map((f) => `${f.id}(${f.error})`).join(', ')}`,
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
test('sends a ping → pong request through EVERY resolvable Model', async () => {
|
||||
const host = requireApp(app);
|
||||
if (!configCopied || reports.length === 0) {
|
||||
console.log('skipped: no ~/.kimi-code/config.toml or no models configured');
|
||||
return;
|
||||
}
|
||||
|
||||
const resolver = host.accessor.get(IModelResolver);
|
||||
const candidates = reports.filter((r) => r.resolved);
|
||||
if (candidates.length === 0) {
|
||||
console.log('skipped: no resolvable models');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(
|
||||
`\npinging ${candidates.length} resolvable models ` +
|
||||
`(concurrency=${PING_CONCURRENCY}, per-request timeout=${PER_REQUEST_TIMEOUT_MS}ms):`,
|
||||
);
|
||||
|
||||
const outcomes = await mapPool(candidates, PING_CONCURRENCY, async (report) => {
|
||||
const model = resolver.resolve(report.id);
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), PER_REQUEST_TIMEOUT_MS);
|
||||
try {
|
||||
const result = await collectResponse(model, controller.signal);
|
||||
return {
|
||||
report,
|
||||
ok: true as const,
|
||||
text: result.text,
|
||||
finishReason: result.finishReason,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
report,
|
||||
ok: false as const,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
};
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
});
|
||||
|
||||
for (const o of outcomes) {
|
||||
if (o.ok) {
|
||||
console.log(
|
||||
` [OK ] ${o.report.id} → ${JSON.stringify(truncate(o.text, 40))} ` +
|
||||
`(finish=${o.finishReason ?? '-'})`,
|
||||
);
|
||||
} else {
|
||||
console.log(` [FAIL] ${o.report.id} → ${truncate(o.error, 140)}`);
|
||||
}
|
||||
}
|
||||
|
||||
const passed = outcomes.filter((o) => o.ok).length;
|
||||
console.log(`\nping-pong summary: ${passed}/${outcomes.length} models responded.`);
|
||||
|
||||
const failed = outcomes.filter((o) => !o.ok);
|
||||
expect(
|
||||
failed,
|
||||
`models that failed to respond: ${failed.map((f) => `${f.report.id}(${f.error})`).join(', ')}`,
|
||||
).toEqual([]);
|
||||
}, ALL_MODELS_TEST_TIMEOUT_MS);
|
||||
});
|
||||
|
||||
function resolveOne(
|
||||
id: string,
|
||||
model: ModelConfig,
|
||||
providers: Readonly<Record<string, ProviderConfig>>,
|
||||
resolver: IModelResolver,
|
||||
): ModelReport {
|
||||
const authMode = modelAuthMode(model, providers);
|
||||
try {
|
||||
const resolved = resolver.resolve(id);
|
||||
return {
|
||||
id,
|
||||
name: resolved.name,
|
||||
protocol: resolved.protocol,
|
||||
baseUrl: resolved.baseUrl,
|
||||
authMode,
|
||||
resolved: true,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
id,
|
||||
name: model.name ?? model.model,
|
||||
authMode,
|
||||
resolved: false,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/** Mirror the resolver's auth-precedence to label where each Model's
|
||||
* credential comes from, without ever reading the secret itself. */
|
||||
function modelAuthMode(
|
||||
model: ModelConfig,
|
||||
providers: Readonly<Record<string, ProviderConfig>>,
|
||||
): string {
|
||||
if (model.apiKey !== undefined && model.apiKey.length > 0) return 'model.apiKey';
|
||||
if (model.oauth !== undefined) return 'model.oauth';
|
||||
const providerId = model.providerId ?? model.provider;
|
||||
const provider = providerId === undefined ? undefined : providers[providerId];
|
||||
const platformId = provider?.platformId;
|
||||
if (platformId !== undefined && platformId !== '__unknown__') {
|
||||
return `platform(${platformId})`;
|
||||
}
|
||||
if (provider?.apiKey !== undefined && provider.apiKey.length > 0) return 'provider.apiKey';
|
||||
if (provider?.oauth !== undefined) return 'provider.oauth';
|
||||
return 'none';
|
||||
}
|
||||
|
||||
function providerAuthMode(provider: ProviderConfig): string {
|
||||
if (provider.apiKey !== undefined && provider.apiKey.length > 0) return 'apiKey';
|
||||
if (provider.oauth !== undefined) return 'oauth';
|
||||
if (provider.platformId !== undefined) return `platform(${provider.platformId})`;
|
||||
if (provider.env !== undefined) return 'env';
|
||||
return 'none';
|
||||
}
|
||||
|
||||
async function collectResponse(
|
||||
model: Model,
|
||||
signal: AbortSignal,
|
||||
): Promise<{ text: string; finishReason?: string; usage?: TokenUsage }> {
|
||||
let text = '';
|
||||
let think = '';
|
||||
let finishReason: string | undefined;
|
||||
let usage: TokenUsage | undefined;
|
||||
|
||||
const stream = model.request(
|
||||
{
|
||||
systemPrompt:
|
||||
'You are a connectivity check. The user will say "ping". Reply with the single word: pong',
|
||||
tools: [],
|
||||
messages: [createUserMessage('ping')],
|
||||
},
|
||||
signal,
|
||||
);
|
||||
|
||||
for await (const event of stream) {
|
||||
if (event.type === 'part') {
|
||||
const part = event.part;
|
||||
if (isContentPart(part) && part.type === 'text') text += part.text;
|
||||
else if (isContentPart(part) && part.type === 'think') think += part.think;
|
||||
} else if (event.type === 'usage') {
|
||||
usage = event.usage;
|
||||
} else if (event.type === 'finish') {
|
||||
finishReason = event.rawFinishReason ?? event.providerFinishReason;
|
||||
}
|
||||
}
|
||||
// Thinking models may put the answer in `think`; surface whichever carried
|
||||
// content so the report shows what came back.
|
||||
return { text: text.trim().length > 0 ? text : think, finishReason, usage };
|
||||
}
|
||||
|
||||
/** Run `fn` over `items` with at most `size` in flight, preserving order. */
|
||||
async function mapPool<T, R>(
|
||||
items: readonly T[],
|
||||
size: number,
|
||||
fn: (item: T) => Promise<R>,
|
||||
): Promise<R[]> {
|
||||
const results: R[] = new Array(items.length);
|
||||
let next = 0;
|
||||
const worker = async (): Promise<void> => {
|
||||
while (next < items.length) {
|
||||
const i = next++;
|
||||
results[i] = await fn(items[i] as T);
|
||||
}
|
||||
};
|
||||
await Promise.all(Array.from({ length: Math.min(size, items.length) }, worker));
|
||||
return results;
|
||||
}
|
||||
|
||||
function truncate(s: string, max: number): string {
|
||||
const oneLine = s.replaceAll(/\s+/g, ' ').trim();
|
||||
return oneLine.length > max ? `${oneLine.slice(0, max)}…` : oneLine;
|
||||
}
|
||||
|
||||
function resolveSandboxHome(): string {
|
||||
const fromEnv = process.env['KIMI_CODE_HOME'];
|
||||
if (fromEnv !== undefined && fromEnv.length > 0) return fromEnv;
|
||||
// Fallback for running this file outside the example harness: mirror into a
|
||||
// fresh temp dir so the real home is still never touched.
|
||||
const dir = join(homedir(), '.kimi-code-example-sandbox');
|
||||
mkdirSync(dir, { recursive: true });
|
||||
process.env['KIMI_CODE_HOME'] = dir;
|
||||
return dir;
|
||||
}
|
||||
|
||||
/** Copy `~/.kimi-code/config.toml` and `~/.kimi-code/credentials/*` into the
|
||||
* sandbox home. Never reads credential contents — only copies bytes. */
|
||||
function mirrorRealKimiHome(sandboxHome: string): {
|
||||
configCopied: boolean;
|
||||
credentialsCopied: number;
|
||||
} {
|
||||
const realHome = join(homedir(), '.kimi-code');
|
||||
let configCopied = false;
|
||||
const srcConfig = join(realHome, 'config.toml');
|
||||
if (existsSync(srcConfig)) {
|
||||
copyFileSync(srcConfig, join(sandboxHome, 'config.toml'));
|
||||
configCopied = true;
|
||||
}
|
||||
|
||||
let credentialsCopied = 0;
|
||||
const srcCreds = join(realHome, 'credentials');
|
||||
if (existsSync(srcCreds) && statSync(srcCreds).isDirectory()) {
|
||||
const dstCreds = join(sandboxHome, 'credentials');
|
||||
mkdirSync(dstCreds, { recursive: true });
|
||||
for (const entry of readdirSync(srcCreds)) {
|
||||
const src = join(srcCreds, entry);
|
||||
if (statSync(src).isFile()) {
|
||||
copyFileSync(src, join(dstCreds, entry));
|
||||
credentialsCopied++;
|
||||
}
|
||||
}
|
||||
}
|
||||
return { configCopied, credentialsCopied };
|
||||
}
|
||||
|
||||
function requireApp(app: Scope | undefined): Scope {
|
||||
if (app === undefined) throw new Error('App scope was not initialized in beforeAll');
|
||||
return app;
|
||||
}
|
||||
|
|
@ -1,237 +0,0 @@
|
|||
/**
|
||||
* Scenario: the **auth → modelCatalog** slice — a device-code OAuth login
|
||||
* followed by a managed `/models` refresh, with both steps observed through
|
||||
* `config.onDidChangeConfiguration`.
|
||||
*
|
||||
* This example exists to make one design point concrete: **the caller never
|
||||
* hand-rolls a `/models` request.** The flow is split into two internal,
|
||||
* config-driven steps, and the caller reacts to config changes instead of
|
||||
* plumbing model lists around:
|
||||
*
|
||||
* 1. **Login writes a credential, not models.** `IOAuthService.startLogin`
|
||||
* drives the device-code flow; on success `OAuthService` only provisions
|
||||
* the provider credential (the OAuth ref) into the `providers` config
|
||||
* section. That write fires `config.onDidChangeConfiguration('providers')`, which the
|
||||
* `provider` domain forwards as `providerService.onDidChangeProviders`. `auth` does
|
||||
* not know about `modelCatalog` — dependency direction stays one-way
|
||||
* (`modelCatalog` → `auth`, never the reverse).
|
||||
* 2. **Refresh pulls `/models` internally and merges it into config.**
|
||||
* `IOAuthService.refreshOAuthProviderModels` resolves the OAuth
|
||||
* token through `IOAuthService`, fetches the managed model list, and
|
||||
* writes the result into the `models` / `providers` / `defaultModel`
|
||||
* sections through `IConfigService` — each firing `onDidChangeConfiguration`. The caller
|
||||
* *triggers* the refresh explicitly (it is not auto-chained inside login),
|
||||
* then observes the new aliases arrive through config.
|
||||
*
|
||||
* Everything runs against the real App-scope Services **and** the real OAuth
|
||||
* clients — `KimiOAuthToolkit` (device-code protocol + token persistence) and
|
||||
* `fetchManagedKimiCodeModels` (the `/models` request) are not stubbed. The
|
||||
* only thing faked is the wire itself: `globalThis.fetch` is replaced with a
|
||||
* tiny URL/method router that answers the OAuth device-code endpoints and the
|
||||
* `/models` endpoint. No server listens on any port; the clients construct real
|
||||
* requests and read real `Response` objects, so the request / response shapes
|
||||
* (headers, snake_case wire, status-code branches) are exercised for real.
|
||||
*
|
||||
* All Services come from `src/`; nothing here defines a new Service.
|
||||
*/
|
||||
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { mkdirSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
|
||||
import { KIMI_CODE_PROVIDER_NAME } from '@moonshot-ai/kimi-code-oauth';
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
|
||||
|
||||
import { IOAuthService } from '#/app/auth';
|
||||
import { IConfigService } from '#/app/config';
|
||||
import { IModelService } from '#/app/model';
|
||||
import { IProviderService } from '#/app/provider';
|
||||
|
||||
import { createSliceHost, type SliceHost } from './_harness';
|
||||
|
||||
const STUB_ACCESS_TOKEN = 'stub-access-token';
|
||||
|
||||
function jsonResponse(status: number, body: unknown): Response {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace `globalThis.fetch` with a router that answers exactly the three
|
||||
* requests this slice issues: device authorization, device-code token polling,
|
||||
* and the managed `/models` listing. Anything else throws so an unexpected call
|
||||
* is loud instead of silently hitting the network.
|
||||
*/
|
||||
function installFetchMock(): void {
|
||||
const router = async (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
|
||||
const url =
|
||||
typeof input === 'string' ? input : input instanceof URL ? input.toString() : input.url;
|
||||
const method = (
|
||||
init?.method ?? (input instanceof Request ? input.method : 'GET')
|
||||
).toUpperCase();
|
||||
const path = new URL(url).pathname;
|
||||
|
||||
if (method === 'POST' && path.endsWith('/api/oauth/device_authorization')) {
|
||||
return jsonResponse(200, {
|
||||
user_code: 'STUB-USER-CODE',
|
||||
device_code: 'stub-device-code',
|
||||
verification_uri: 'https://example.com/device',
|
||||
verification_uri_complete: 'https://example.com/device?code=STUB-USER-CODE',
|
||||
expires_in: 900,
|
||||
interval: 0,
|
||||
});
|
||||
}
|
||||
|
||||
if (method === 'POST' && path.endsWith('/api/oauth/token')) {
|
||||
return jsonResponse(200, {
|
||||
access_token: STUB_ACCESS_TOKEN,
|
||||
refresh_token: 'stub-refresh-token',
|
||||
expires_in: 3600,
|
||||
token_type: 'Bearer',
|
||||
scope: '',
|
||||
});
|
||||
}
|
||||
|
||||
if (method === 'GET' && path.endsWith('/models')) {
|
||||
return jsonResponse(200, {
|
||||
data: [
|
||||
{
|
||||
id: 'k2-thinking',
|
||||
context_length: 262_144,
|
||||
supports_reasoning: true,
|
||||
supports_image_in: false,
|
||||
supports_video_in: false,
|
||||
supports_thinking_type: 'both',
|
||||
display_name: 'K2 Thinking',
|
||||
},
|
||||
{
|
||||
id: 'k2',
|
||||
context_length: 131_072,
|
||||
supports_reasoning: false,
|
||||
supports_image_in: false,
|
||||
supports_video_in: false,
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
throw new Error(`unexpected fetch: ${method} ${url}`);
|
||||
};
|
||||
|
||||
vi.stubGlobal('fetch', router);
|
||||
}
|
||||
|
||||
async function waitUntil(predicate: () => boolean, timeoutMs = 2000): Promise<void> {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
while (Date.now() < deadline) {
|
||||
if (predicate()) return;
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
}
|
||||
if (!predicate()) throw new Error('waitUntil timed out');
|
||||
}
|
||||
|
||||
describe('oauth → modelCatalog slice (request-layer fetch mock, real clients)', () => {
|
||||
let homeDir: string;
|
||||
let caseDir: string;
|
||||
let host: SliceHost | undefined;
|
||||
|
||||
beforeEach(() => {
|
||||
const resolved = process.env['KIMI_CODE_HOME'];
|
||||
if (resolved === undefined) {
|
||||
throw new Error('KIMI_CODE_HOME is not set; globalSetup should have initialized it');
|
||||
}
|
||||
homeDir = resolved;
|
||||
// The real `KimiOAuthToolkit` persists tokens to `{homeDir}/credentials`;
|
||||
// give each test its own home so a token saved by one test cannot make the
|
||||
// next test look "already authenticated" and skip the device-code flow.
|
||||
caseDir = join(homeDir, randomUUID());
|
||||
mkdirSync(caseDir, { recursive: true });
|
||||
installFetchMock();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
host?.dispose();
|
||||
host = undefined;
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
test('device-code login provisions the provider credential through config.onDidChangeConfiguration', async () => {
|
||||
host = createSliceHost({ homeDir: caseDir });
|
||||
const app = host.app;
|
||||
const config = app.accessor.get(IConfigService);
|
||||
const oauth = app.accessor.get(IOAuthService);
|
||||
const providers = app.accessor.get(IProviderService);
|
||||
await config.ready;
|
||||
providers.list();
|
||||
|
||||
const changed: string[] = [];
|
||||
const sub = config.onDidChangeConfiguration((e) => changed.push(e.domain));
|
||||
|
||||
const start = await oauth.startLogin();
|
||||
console.log('device code issued:', start.user_code, '→', start.verification_uri);
|
||||
expect(start.status).toBe('pending');
|
||||
|
||||
// The credential lands asynchronously: handleSuccess provisions the
|
||||
// provider after the device-code promise resolves. Wait for the OAuth ref
|
||||
// to appear in config rather than for the flow status, so the config write
|
||||
// is guaranteed to have committed.
|
||||
await waitUntil(() => providers.get(KIMI_CODE_PROVIDER_NAME)?.oauth !== undefined);
|
||||
sub.dispose();
|
||||
|
||||
const provider = providers.get(KIMI_CODE_PROVIDER_NAME);
|
||||
console.log('provisioned provider:', JSON.stringify(provider));
|
||||
console.log('config domains changed by login:', changed);
|
||||
|
||||
expect(provider?.oauth).toBeDefined();
|
||||
expect(changed).toContain('providers');
|
||||
expect(await oauth.status()).toEqual({ loggedIn: true, provider: KIMI_CODE_PROVIDER_NAME });
|
||||
});
|
||||
|
||||
test('refreshOAuthProviderModels fetches /models internally and lands aliases through config.onDidChangeConfiguration', async () => {
|
||||
host = createSliceHost({ homeDir: caseDir });
|
||||
const app = host.app;
|
||||
const config = app.accessor.get(IConfigService);
|
||||
const oauth = app.accessor.get(IOAuthService);
|
||||
const providers = app.accessor.get(IProviderService);
|
||||
const models = app.accessor.get(IModelService);
|
||||
await config.ready;
|
||||
providers.list();
|
||||
|
||||
// Login first so the provider holds an OAuth ref; the refresh resolves the
|
||||
// token from that ref. The caller triggers the refresh explicitly — login
|
||||
// does not auto-fetch models.
|
||||
await oauth.startLogin();
|
||||
await waitUntil(() => providers.get(KIMI_CODE_PROVIDER_NAME)?.oauth !== undefined);
|
||||
|
||||
const changed: string[] = [];
|
||||
const sub = config.onDidChangeConfiguration((e) => changed.push(e.domain));
|
||||
const result = await oauth.refreshOAuthProviderModels();
|
||||
sub.dispose();
|
||||
|
||||
const aliases = models.list();
|
||||
console.log('refresh result:', JSON.stringify(result));
|
||||
console.log('config domains changed by refresh:', changed);
|
||||
console.log('model aliases after refresh:', Object.keys(aliases));
|
||||
console.log('defaultModel:', config.get('defaultModel'));
|
||||
|
||||
expect(result.failed).toEqual([]);
|
||||
expect(result.unchanged).toEqual([]);
|
||||
expect(result.changed).toHaveLength(1);
|
||||
expect(result.changed[0]).toMatchObject({ provider_id: KIMI_CODE_PROVIDER_NAME, added: 2 });
|
||||
|
||||
// `applyManagedKimiCodeConfig` keys aliases as `kimi-code/<model id>`.
|
||||
expect(aliases['kimi-code/k2-thinking']).toMatchObject({
|
||||
provider: KIMI_CODE_PROVIDER_NAME,
|
||||
model: 'k2-thinking',
|
||||
displayName: 'K2 Thinking',
|
||||
});
|
||||
expect(aliases['kimi-code/k2']).toBeDefined();
|
||||
|
||||
// Models arrived through config, not through a caller-threaded return value.
|
||||
expect(changed).toContain('models');
|
||||
expect(changed).toContain('defaultModel');
|
||||
expect(config.get('defaultModel')).toBe('kimi-code/k2-thinking');
|
||||
});
|
||||
});
|
||||
|
|
@ -1,54 +0,0 @@
|
|||
/**
|
||||
* Scenario: the **observability** slice — `log` + `telemetry`.
|
||||
*
|
||||
* Builds a flat container that runs both services for real (neither has
|
||||
* cross-domain collaborators, so nothing is stubbed). `ILogService` writes
|
||||
* through the App console writer; `ITelemetryService` fans events out to a
|
||||
* console appender while merging bound context. The two compose: a child
|
||||
* logger and a context-scoped telemetry both carry their bound fields into
|
||||
* the output.
|
||||
*/
|
||||
|
||||
import { afterEach, beforeEach, describe, test } from 'vitest';
|
||||
|
||||
import { DisposableStore } from '#/_base/di/lifecycle';
|
||||
import { createServices, type TestInstantiationService } from '#/_base/di/test';
|
||||
import { ILogService, ILogWriterService } from '#/app/log/log';
|
||||
import { ConsoleLogWriterService, LogService } from '#/app/log/logService';
|
||||
import { ConsoleAppender } from '#/app/telemetry/consoleAppender';
|
||||
import { ITelemetryService } from '#/app/telemetry/telemetry';
|
||||
import { TelemetryService } from '#/app/telemetry/telemetryService';
|
||||
|
||||
describe('observability slice (log + telemetry)', () => {
|
||||
let disposables: DisposableStore;
|
||||
let ix: TestInstantiationService;
|
||||
|
||||
beforeEach(() => {
|
||||
disposables = new DisposableStore();
|
||||
ix = createServices(disposables, {
|
||||
additionalServices: (reg) => {
|
||||
reg.define(ILogWriterService, ConsoleLogWriterService);
|
||||
reg.define(ILogService, LogService);
|
||||
reg.define(ITelemetryService, TelemetryService);
|
||||
},
|
||||
});
|
||||
});
|
||||
afterEach(() => {
|
||||
disposables.dispose();
|
||||
});
|
||||
|
||||
test('emits structured logs and telemetry events with bound context', async () => {
|
||||
const log = ix.get(ILogService);
|
||||
log.setLevel('debug');
|
||||
log.debug('log: debug entry', { feature: 'observability' });
|
||||
log.info('log: info entry');
|
||||
log.child({ requestId: 'req-1' }).info('log: child entry with bound requestId');
|
||||
|
||||
const telemetry = ix.get(ITelemetryService);
|
||||
telemetry.setAppender(new ConsoleAppender());
|
||||
telemetry.setContext({ app: 'example' });
|
||||
telemetry.track('session_started', { sessionId: 's1' });
|
||||
telemetry.withContext({ agentId: 'a1' }).track('turn_completed', { turns: 3 });
|
||||
await telemetry.shutdown();
|
||||
});
|
||||
});
|
||||
|
|
@ -1,296 +0,0 @@
|
|||
/**
|
||||
* Scenario: the **permission** slice — `IAgentPermissionGate` composing
|
||||
* policy, mode, and rules into an allow/deny decision.
|
||||
*
|
||||
* Concept taught: the permission gate is a **chain-of-responsibility**. When a
|
||||
* tool is about to run, `IAgentPermissionGate.authorize(context)` delegates the
|
||||
* decision to `IAgentPermissionPolicyService`, which walks an ordered list of
|
||||
* policies and returns the first verdict. The verdict is driven by two other
|
||||
* Agent-scope services the gate also depends on:
|
||||
*
|
||||
* - `IAgentPermissionModeService` — the top-level posture (`manual` / `yolo`
|
||||
* / `auto`). `yolo` approves almost everything; `manual` lets the rule set
|
||||
* decide.
|
||||
* - `IAgentPermissionRulesService` — the user/session rules. A `deny` rule
|
||||
* always fires, regardless of mode; an `allow` rule approves a match.
|
||||
*
|
||||
* The full built-in policy chain (`AgentPermissionPolicyService`) constructs
|
||||
* ~18 policies that reach into git, plan, swarm, workspace, and other domains —
|
||||
* far more wiring than a teaching example needs. So this example registers the
|
||||
* **real** gate, mode, and rules services, but seeds a tiny in-file
|
||||
* `IAgentPermissionPolicyService` that mimics the chain by reading the *real*
|
||||
* mode + rules services. That keeps the decision honest: changing a real rule
|
||||
* on the real rules service flips the real gate's verdict.
|
||||
*
|
||||
* Prerequisites: example 01 (container & scope tree), example 03 (host seeds).
|
||||
*
|
||||
* Run:
|
||||
* pnpm --filter @moonshot-ai/agent-core-v2 example -- examples/permission.example.ts
|
||||
*/
|
||||
|
||||
import { beforeEach, describe, expect, it } from 'vitest';
|
||||
|
||||
import { SyncDescriptor } from '#/_base/di';
|
||||
import {
|
||||
LifecycleScope,
|
||||
_clearScopedRegistryForTests,
|
||||
registerScopedService,
|
||||
} from '#/_base/di/scope';
|
||||
import { createScopedTestHost, stubPair } from '#/_base/di/test';
|
||||
|
||||
import { IAgentContextInjectorService } from '#/agent/contextInjector';
|
||||
import { IAgentExternalHooksService } from '#/agent/externalHooks';
|
||||
import {
|
||||
AgentPermissionGate,
|
||||
IAgentPermissionGate,
|
||||
} from '#/agent/permissionGate';
|
||||
import {
|
||||
AgentPermissionModeService,
|
||||
IAgentPermissionModeService,
|
||||
} from '#/agent/permissionMode';
|
||||
import {
|
||||
IAgentPermissionPolicyService,
|
||||
type PermissionPolicyEvaluation,
|
||||
type PermissionRuleDecision,
|
||||
} from '#/agent/permissionPolicy';
|
||||
import {
|
||||
AgentPermissionRulesService,
|
||||
IAgentPermissionRulesService,
|
||||
matchPermissionRule,
|
||||
} from '#/agent/permissionRules';
|
||||
import { IAgentRecordService } from '#/agent/record';
|
||||
import {
|
||||
type ResolvedToolExecutionHookContext,
|
||||
} from '#/agent/tool';
|
||||
import { IAgentToolExecutorService } from '#/agent/toolExecutor';
|
||||
import { ITelemetryService, noopTelemetryService } from '#/app/telemetry';
|
||||
import { ISessionContext } from '#/session/sessionContext';
|
||||
|
||||
// --- Leaf fakes for collaborators outside the slice -----------------------
|
||||
// The gate/mode/rules constructors only *touch* these surfaces; everything
|
||||
// else is cast away. `record.define` / `contextInjector.register` must return
|
||||
// a disposable because the real services `_register(...)` them.
|
||||
const fakeRecord = {
|
||||
define: () => ({ dispose: () => {} }),
|
||||
append: () => {},
|
||||
} as unknown as IAgentRecordService;
|
||||
|
||||
const fakeContextInjector = {
|
||||
register: () => ({ dispose: () => {} }),
|
||||
} as unknown as IAgentContextInjectorService;
|
||||
|
||||
const fakeToolExecutor = {
|
||||
hooks: {
|
||||
onWillExecuteTool: { register: () => ({ dispose: () => {} }) },
|
||||
onDidExecuteTool: { register: () => ({ dispose: () => {} }) },
|
||||
},
|
||||
} as unknown as IAgentToolExecutorService;
|
||||
|
||||
const fakeExternalHooks = {} as unknown as IAgentExternalHooksService;
|
||||
|
||||
const fakeSession = {
|
||||
sessionId: 's1',
|
||||
workspaceId: 'ws1',
|
||||
sessionDir: '/tmp/s1',
|
||||
metaScope: 'test',
|
||||
} as unknown as ISessionContext;
|
||||
|
||||
// --- A tiny in-file policy chain ------------------------------------------
|
||||
// Mirrors the precedence of the real built-ins that read mode + rules:
|
||||
// 1. a matching user `deny` rule always fires (regardless of mode);
|
||||
// 2. `yolo` mode approves everything not denied;
|
||||
// 3. a matching user `allow` rule approves;
|
||||
// 4. otherwise no decision (the gate treats `undefined` as "allow").
|
||||
const USER_RULE_SCOPES = new Set(['turn-override', 'project', 'user']);
|
||||
|
||||
function matchingUserRule(
|
||||
context: ResolvedToolExecutionHookContext,
|
||||
decision: PermissionRuleDecision,
|
||||
rules: IAgentPermissionRulesService,
|
||||
) {
|
||||
for (const rule of rules.rules) {
|
||||
if (!USER_RULE_SCOPES.has(rule.scope)) continue;
|
||||
if (rule.decision !== decision) continue;
|
||||
if (
|
||||
matchPermissionRule({
|
||||
rule,
|
||||
toolName: context.toolCall.name,
|
||||
execution: context.execution,
|
||||
}) !== undefined
|
||||
) {
|
||||
return rule;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
class RuleBasedPermissionPolicy implements IAgentPermissionPolicyService {
|
||||
declare readonly _serviceBrand: undefined;
|
||||
|
||||
constructor(
|
||||
@IAgentPermissionModeService
|
||||
private readonly modeService: IAgentPermissionModeService,
|
||||
@IAgentPermissionRulesService
|
||||
private readonly rulesService: IAgentPermissionRulesService,
|
||||
) {}
|
||||
|
||||
async evaluate(
|
||||
context: ResolvedToolExecutionHookContext,
|
||||
): Promise<PermissionPolicyEvaluation | undefined> {
|
||||
if (matchingUserRule(context, 'deny', this.rulesService) !== undefined) {
|
||||
return {
|
||||
policyName: 'example-user-deny',
|
||||
result: {
|
||||
kind: 'deny',
|
||||
message: `Tool "${context.toolCall.name}" was denied by permission rule.`,
|
||||
},
|
||||
};
|
||||
}
|
||||
if (this.modeService.mode === 'yolo') {
|
||||
return { policyName: 'example-yolo', result: { kind: 'approve' } };
|
||||
}
|
||||
if (matchingUserRule(context, 'allow', this.rulesService) !== undefined) {
|
||||
return { policyName: 'example-user-allow', result: { kind: 'approve' } };
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
registerPolicy() {
|
||||
return { dispose: () => {} };
|
||||
}
|
||||
}
|
||||
|
||||
// --- Helper: build the smallest valid tool-execution context --------------
|
||||
function toolContext(toolName: string): ResolvedToolExecutionHookContext {
|
||||
const toolCall = {
|
||||
type: 'function' as const,
|
||||
id: `tc-${toolName}`,
|
||||
name: toolName,
|
||||
arguments: null,
|
||||
};
|
||||
return {
|
||||
turnId: '1',
|
||||
signal: new AbortController().signal,
|
||||
toolCall,
|
||||
toolCalls: [toolCall],
|
||||
args: {},
|
||||
execution: {
|
||||
approvalRule: toolName,
|
||||
execute: async () => ({ output: '' }),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe('permission slice (gate composing policy + mode + rules)', () => {
|
||||
beforeEach(() => {
|
||||
_clearScopedRegistryForTests();
|
||||
// The three real services of the slice. The heavy built-in policy chain is
|
||||
// replaced by the tiny `RuleBasedPermissionPolicy` above, which still reads
|
||||
// the real mode + rules services.
|
||||
registerScopedService(
|
||||
LifecycleScope.Agent,
|
||||
IAgentPermissionModeService,
|
||||
AgentPermissionModeService,
|
||||
);
|
||||
registerScopedService(
|
||||
LifecycleScope.Agent,
|
||||
IAgentPermissionRulesService,
|
||||
AgentPermissionRulesService,
|
||||
);
|
||||
registerScopedService(
|
||||
LifecycleScope.Agent,
|
||||
IAgentPermissionPolicyService,
|
||||
RuleBasedPermissionPolicy,
|
||||
);
|
||||
// `IAgentPermissionGate` is NOT registered here: its constructor takes a
|
||||
// leading `options` value before its @IX dependencies, so it is seeded as a
|
||||
// SyncDescriptor (carrying `[{}]`) on the Agent scope in `buildAgent()`.
|
||||
});
|
||||
|
||||
function buildAgent() {
|
||||
const host = createScopedTestHost([
|
||||
stubPair(ITelemetryService, noopTelemetryService),
|
||||
]);
|
||||
const session = host.child(LifecycleScope.Session, 's1', [
|
||||
stubPair(ISessionContext, fakeSession),
|
||||
]);
|
||||
const agent = host.childOf(session, LifecycleScope.Agent, 'main', [
|
||||
// The real gate expects a leading `PermissionGateOptions` argument
|
||||
// (before its @IX dependencies), so it is provided as a SyncDescriptor
|
||||
// with `[{}]` — the same shape the production composition root uses.
|
||||
[IAgentPermissionGate, new SyncDescriptor(AgentPermissionGate, [{}])],
|
||||
stubPair(IAgentRecordService, fakeRecord),
|
||||
stubPair(IAgentContextInjectorService, fakeContextInjector),
|
||||
stubPair(IAgentToolExecutorService, fakeToolExecutor),
|
||||
stubPair(IAgentExternalHooksService, fakeExternalHooks),
|
||||
]);
|
||||
return { host, agent };
|
||||
}
|
||||
|
||||
it('denies a tool when a matching deny rule is registered', async () => {
|
||||
const { host, agent } = buildAgent();
|
||||
agent.accessor
|
||||
.get(IAgentPermissionRulesService)
|
||||
.addRules([{ decision: 'deny', scope: 'user', pattern: 'Bash' }]);
|
||||
|
||||
const result = await agent.accessor
|
||||
.get(IAgentPermissionGate)
|
||||
.authorize(toolContext('Bash'));
|
||||
|
||||
expect(result?.block).toBe(true);
|
||||
expect(result?.reason).toContain('Bash');
|
||||
|
||||
host.dispose();
|
||||
});
|
||||
|
||||
it('flips the decision on the same agent when a rule changes', async () => {
|
||||
const { host, agent } = buildAgent();
|
||||
const gate = agent.accessor.get(IAgentPermissionGate);
|
||||
const rules = agent.accessor.get(IAgentPermissionRulesService);
|
||||
|
||||
// No rules + manual mode => the chain returns no decision, so the gate
|
||||
// allows the call (undefined verdict).
|
||||
expect(await gate.authorize(toolContext('Bash'))).toBeUndefined();
|
||||
|
||||
// Adding a deny rule flips the same tool from allowed to blocked.
|
||||
rules.addRules([{ decision: 'deny', scope: 'user', pattern: 'Bash' }]);
|
||||
|
||||
const denied = await gate.authorize(toolContext('Bash'));
|
||||
expect(denied?.block).toBe(true);
|
||||
|
||||
host.dispose();
|
||||
});
|
||||
|
||||
it('surfaces the composed mode + rules through gate.data()', () => {
|
||||
const { host, agent } = buildAgent();
|
||||
agent.accessor.get(IAgentPermissionModeService).setMode('yolo');
|
||||
agent.accessor
|
||||
.get(IAgentPermissionRulesService)
|
||||
.addRules([{ decision: 'allow', scope: 'user', pattern: 'Read' }]);
|
||||
|
||||
expect(agent.accessor.get(IAgentPermissionGate).data()).toEqual({
|
||||
mode: 'yolo',
|
||||
rules: [{ decision: 'allow', scope: 'user', pattern: 'Read' }],
|
||||
});
|
||||
|
||||
host.dispose();
|
||||
});
|
||||
|
||||
it('lets yolo mode approve, but a deny rule still blocks', async () => {
|
||||
const { host, agent } = buildAgent();
|
||||
const gate = agent.accessor.get(IAgentPermissionGate);
|
||||
agent.accessor.get(IAgentPermissionModeService).setMode('yolo');
|
||||
|
||||
// yolo approves a tool with no matching rule.
|
||||
expect(await gate.authorize(toolContext('Read'))).toBeUndefined();
|
||||
|
||||
// A deny rule fires regardless of mode.
|
||||
agent.accessor
|
||||
.get(IAgentPermissionRulesService)
|
||||
.addRules([{ decision: 'deny', scope: 'user', pattern: 'Read' }]);
|
||||
|
||||
expect((await gate.authorize(toolContext('Read')))?.block).toBe(true);
|
||||
|
||||
host.dispose();
|
||||
});
|
||||
});
|
||||
|
|
@ -1,155 +0,0 @@
|
|||
/**
|
||||
* Scenario: the **persistence** module — how the `persistence` dimension's
|
||||
* Services compose into a complete call chain (Store → Storage → backend),
|
||||
* shown through the real files that make up `~/.kimi-code`.
|
||||
*
|
||||
* The persistence dimension is organised as:
|
||||
*
|
||||
* persistence/
|
||||
* interface/ ← contracts only: IStorageService + role tokens,
|
||||
* IAppendLogStore, IAtomicDocumentStore, IQueryStore,
|
||||
* IFileStore, IAgentBlobStoreService
|
||||
* backends/
|
||||
* node-fs/ ← FileStorageService, AppendLogStore, AtomicDocumentStore,
|
||||
* FileStoreService, AgentBlobStoreService
|
||||
* memory/ ← InMemoryStorageService (test backend)
|
||||
*
|
||||
* Business code imports from `persistence/interface` and never sees a backend.
|
||||
* The composition root (`bootstrap`) wires each role token to a backend:
|
||||
*
|
||||
* IStorageService → FileStorageService(homeDir) [config store]
|
||||
* IAppendLogStorage → FileStorageService(homeDir) [wire logs]
|
||||
* IAtomicDocumentStorage → FileStorageService(homeDir) [JSON docs]
|
||||
* IBlobStorage → FileStorageService(homeDir) [blobs]
|
||||
*
|
||||
* A server-only profile could route any of these to Postgres / Redis / S3
|
||||
* without touching business code.
|
||||
*
|
||||
* Instead of writing to a made-up scope, each access pattern is demonstrated
|
||||
* against the actual on-disk path a real Domain Service persists to, so the
|
||||
* resulting files mirror a real `~/.kimi-code` tree:
|
||||
*
|
||||
* - `config.toml` — an **atomic document** (TOML codec), written through
|
||||
* `IAtomicTomlDocumentStore` (the same Store `config` uses).
|
||||
* - `sessions/<workspace>/<session>/session-meta/state.json` — an **atomic
|
||||
* document** (JSON codec), written through `IAtomicDocumentStore` (the same
|
||||
* Store `sessionMetadata` uses).
|
||||
* - `wire/<hash>.jsonl` — an **append log** (JSONL framing), written through
|
||||
* `IAppendLogStore` (the same Store `wireRecord` uses). `wireRecord` keys the
|
||||
* log by a hash of the home dir; this example writes one record stream under
|
||||
* the same `wire/` scope.
|
||||
*
|
||||
* All Services come from `src/`; nothing here defines a new Service.
|
||||
*/
|
||||
|
||||
import { mkdirSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
|
||||
import { afterEach, beforeEach, describe, test } from 'vitest';
|
||||
|
||||
import type { Scope } from '#/_base/di/scope';
|
||||
import { bootstrap } from '#/app/bootstrap/bootstrap';
|
||||
|
||||
// ── persistence/interface ─────────────────────────────────────────────
|
||||
// Contracts only — the four role tokens (IStorageService, IAppendLogStorage,
|
||||
// IAtomicDocumentStorage, IBlobStorage) share the same `IStorageService`
|
||||
// interface but are registered as distinct DI tokens so the composition root
|
||||
// can route each one to a different backend.
|
||||
import {
|
||||
IAppendLogStorage,
|
||||
IAtomicDocumentStorage,
|
||||
IStorageService,
|
||||
} from '#/persistence/interface/storage';
|
||||
|
||||
// Store-layer facades — typed access patterns on top of the byte-level storage.
|
||||
import { IAppendLogStore } from '#/persistence/interface/appendLogStore';
|
||||
import {
|
||||
IAtomicDocumentStore,
|
||||
IAtomicTomlDocumentStore,
|
||||
} from '#/persistence/interface/atomicDocumentStore';
|
||||
|
||||
// ── side-effect import ────────────────────────────────────────────────
|
||||
// Loading the backends barrel triggers `registerScopedService` calls that wire
|
||||
// the Store implementations (AppendLogStore, AtomicDocumentStore, etc.) into
|
||||
// the DI scope registry. Without this import the Stores would not resolve.
|
||||
import '#/persistence/backends/node-fs';
|
||||
|
||||
const textDecoder = new TextDecoder();
|
||||
|
||||
function decode(bytes: Uint8Array | undefined): string {
|
||||
return bytes === undefined ? '(undefined)' : textDecoder.decode(bytes);
|
||||
}
|
||||
|
||||
const WIRE_KEY = 'example';
|
||||
|
||||
describe('persistence module (Store → Storage → backend, real ~/.kimi-code files)', () => {
|
||||
let homeDir: string;
|
||||
let app: Scope;
|
||||
|
||||
beforeEach(() => {
|
||||
const resolved = process.env['KIMI_CODE_HOME'];
|
||||
if (resolved === undefined) {
|
||||
throw new Error('KIMI_CODE_HOME is not set; globalSetup should have initialized it');
|
||||
}
|
||||
homeDir = resolved;
|
||||
mkdirSync(homeDir, { recursive: true });
|
||||
app = bootstrap({ homeDir }).app;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
app.dispose();
|
||||
});
|
||||
|
||||
test('typed Store → raw Storage bytes → real file path', async () => {
|
||||
// 1) Atomic document, TOML codec → config.toml
|
||||
const tomlDocs = app.accessor.get(IAtomicTomlDocumentStore);
|
||||
const configBytes = app.accessor.get(IStorageService);
|
||||
const configValue = { theme: 'dark', telemetry: { enabled: true } };
|
||||
await tomlDocs.set('', 'config.toml', configValue);
|
||||
console.log('1) config.toml (atomic doc, TOML):');
|
||||
console.log(' typed get :', await tomlDocs.get('', 'config.toml'));
|
||||
console.log(' raw bytes :');
|
||||
for (const line of decode(await configBytes.read('', 'config.toml')).trim().split('\n')) {
|
||||
console.log(' ', line);
|
||||
}
|
||||
console.log(' path :', join(homeDir, 'config.toml'));
|
||||
|
||||
// 2) Atomic document, JSON codec → sessions/.../session-meta/state.json
|
||||
const docs = app.accessor.get(IAtomicDocumentStore);
|
||||
const docBytes = app.accessor.get(IAtomicDocumentStorage);
|
||||
const metaScope = 'sessions/example/s-example/session-meta';
|
||||
const meta = {
|
||||
id: 's-example',
|
||||
title: 'example session',
|
||||
createdAt: 1_000,
|
||||
updatedAt: 2_000,
|
||||
archived: false,
|
||||
};
|
||||
await docs.set(metaScope, 'state.json', meta);
|
||||
console.log('2) state.json (atomic doc, JSON):');
|
||||
console.log(' typed get :', await docs.get(metaScope, 'state.json'));
|
||||
console.log(' raw bytes :', decode(await docBytes.read(metaScope, 'state.json')).trim());
|
||||
console.log(' path :', join(homeDir, metaScope, 'state.json'));
|
||||
|
||||
// 3) Append log, JSONL framing → wire/<hash>.jsonl
|
||||
const logs = app.accessor.get(IAppendLogStore);
|
||||
const logBytes = app.accessor.get(IAppendLogStorage);
|
||||
const key = WIRE_KEY;
|
||||
logs.append('wire', key, { type: 'metadata', protocol_version: '1.5' });
|
||||
logs.append('wire', key, { type: 'swarm_mode.enter', trigger: 'manual' });
|
||||
logs.append('wire', key, { type: 'swarm_mode.exit' });
|
||||
await logs.flush();
|
||||
|
||||
const readBack: unknown[] = [];
|
||||
for await (const record of logs.read('wire', key)) {
|
||||
readBack.push(record);
|
||||
}
|
||||
console.log('3) wire/<hash>.jsonl (append log, JSONL):');
|
||||
console.log(' typed read:', readBack);
|
||||
console.log(' raw bytes :');
|
||||
for (const line of decode(await logBytes.read('wire', key)).trim().split('\n')) {
|
||||
console.log(' ', line);
|
||||
}
|
||||
console.log(' path :', join(homeDir, 'wire', `${key}.jsonl`));
|
||||
});
|
||||
});
|
||||
|
|
@ -1,53 +0,0 @@
|
|||
/**
|
||||
* Scenario: the **DI Scope** foundation — how resolution follows the tree.
|
||||
*
|
||||
* Not a business slice but the model every other slice rests on. Two rules,
|
||||
* shown with real services resolved through the composition root (`_harness`):
|
||||
*
|
||||
* - an **App-scoped** service (`ILogService`) resolves to the same instance
|
||||
* whether you ask the App scope or a child Session scope — resolution walks
|
||||
* up the tree and finds the one App instance;
|
||||
* - a **Session-scoped** service (`ISessionMetadata`) is one distinct instance
|
||||
* per session, so two sessions hold independent state.
|
||||
*
|
||||
* Wiring: the real composition root (`_harness`) provides every service; we open
|
||||
* two Session scopes to show the per-session isolation.
|
||||
*
|
||||
* Run:
|
||||
* pnpm --filter @moonshot-ai/agent-core-v2 example -- examples/scope.example.ts
|
||||
*/
|
||||
|
||||
import { afterEach, describe, expect, test } from 'vitest';
|
||||
|
||||
import { ILogService } from '#/app/log/log';
|
||||
import { ISessionMetadata } from '#/session/sessionMetadata/sessionMetadata';
|
||||
|
||||
import { createSliceHost, type SliceHost } from './_harness';
|
||||
|
||||
describe('di scope foundation (App singletons vs. per-Session instances)', () => {
|
||||
let host: SliceHost;
|
||||
afterEach(() => host?.dispose());
|
||||
|
||||
test('App services are shared; Session services are per-session', async () => {
|
||||
host = createSliceHost({ homeDir: process.env['KIMI_CODE_HOME']! });
|
||||
const sessionA = host.session;
|
||||
const sessionB = host.newSession('scope-b');
|
||||
|
||||
// App-scoped service: the same instance is visible from App and Session.
|
||||
const logFromApp = host.app.accessor.get(ILogService);
|
||||
const logFromSession = sessionA.accessor.get(ILogService);
|
||||
expect(logFromApp).toBe(logFromSession);
|
||||
|
||||
// Session-scoped service: each session gets its own instance + state.
|
||||
const metaA = sessionA.accessor.get(ISessionMetadata);
|
||||
const metaB = sessionB.accessor.get(ISessionMetadata);
|
||||
expect(metaA).not.toBe(metaB);
|
||||
await Promise.all([metaA.ready, metaB.ready]);
|
||||
|
||||
await metaA.setTitle('session A');
|
||||
await metaB.setTitle('session B');
|
||||
const [a, b] = await Promise.all([metaA.read(), metaB.read()]);
|
||||
expect(a.title).toBe('session A');
|
||||
expect(b.title).toBe('session B');
|
||||
});
|
||||
});
|
||||
|
|
@ -1,98 +0,0 @@
|
|||
/**
|
||||
* Scenario: the **session skill catalog** — loading the skills available in
|
||||
* the current directory and inspecting where each one came from.
|
||||
*
|
||||
* Concept taught: the skill domain is split across scopes by state identity.
|
||||
* `IGlobalSkillCatalog` (App) holds the process-wide set — code-defined
|
||||
* builtins plus user / brand skills discovered from the home directories — and
|
||||
* is loaded once; `ISessionSkillCatalog` (Session) merges that global set with
|
||||
* the project skills discovered from the session's current `workDir`
|
||||
* (`ISessionWorkspaceContext` ← `IExecContext.cwd`), reloading when the
|
||||
* workDir changes. Every `SkillDefinition` carries a `source` tag
|
||||
* (`builtin` | `user` | `extra` | `project`), so the catalog can report
|
||||
* *provenance* — which layer and which directory a skill came from — not just
|
||||
* its name.
|
||||
*
|
||||
* Wiring: the real composition root (`_harness`) provides every collaborator
|
||||
* (the filesystem `ISkillCatalogStore`, the workspace context, …) so the
|
||||
* catalog reads real `SKILL.md` files from disk. We seed an empty
|
||||
* `IPluginService` so the slice stays focused on the builtin / user / project
|
||||
* layers and contributes no plugin skills.
|
||||
*
|
||||
* Run:
|
||||
* pnpm --filter @moonshot-ai/agent-core-v2 example -- examples/session-skill.example.ts
|
||||
*/
|
||||
|
||||
import { afterEach, describe, expect, test } from 'vitest';
|
||||
|
||||
import { type ServiceIdentifier } from '#/_base/di/instantiation';
|
||||
import { IPluginService } from '#/app/plugin/plugin';
|
||||
import { ISessionSkillCatalog } from '#/session/sessionSkillCatalog';
|
||||
|
||||
import { createSliceHost, type SliceHost } from './_harness';
|
||||
|
||||
/** Plugin contribution plane turned off: no plugin skill roots, no reloads. */
|
||||
const noopPlugins: IPluginService = {
|
||||
_serviceBrand: undefined,
|
||||
pluginSkillRoots: async () => [],
|
||||
onDidReload: () => ({ dispose: () => {} }),
|
||||
} as unknown as IPluginService;
|
||||
|
||||
describe('session skill catalog (load from current dir + inspect provenance)', () => {
|
||||
let host: SliceHost;
|
||||
afterEach(() => host?.dispose());
|
||||
|
||||
function setUp() {
|
||||
if (process.env['KIMI_CODE_HOME'] === undefined) {
|
||||
throw new Error('KIMI_CODE_HOME is not set; globalSetup should have initialized it');
|
||||
}
|
||||
host = createSliceHost({
|
||||
homeDir: process.env['KIMI_CODE_HOME'],
|
||||
cwd: process.cwd(),
|
||||
sessionSeeds: [[IPluginService as ServiceIdentifier<unknown>, noopPlugins]],
|
||||
});
|
||||
return host.session.accessor.get(ISessionSkillCatalog);
|
||||
}
|
||||
|
||||
test('lists every merged skill with its source and path', async () => {
|
||||
const catalog = setUp();
|
||||
await catalog.load();
|
||||
await catalog.ready;
|
||||
|
||||
const skills = catalog.catalog.listSkills();
|
||||
console.log('total skills =', skills.length);
|
||||
|
||||
const counts = new Map<string, number>();
|
||||
for (const skill of skills) {
|
||||
counts.set(skill.source, (counts.get(skill.source) ?? 0) + 1);
|
||||
console.log(` [${skill.source}] ${skill.name}`);
|
||||
}
|
||||
console.log('by source =', Object.fromEntries(counts));
|
||||
|
||||
expect(skills.length).toBeGreaterThan(0);
|
||||
for (const skill of skills) {
|
||||
expect(['builtin', 'user', 'extra', 'project']).toContain(skill.source);
|
||||
}
|
||||
});
|
||||
|
||||
test('inspects a single skill by name and reports its provenance', async () => {
|
||||
const catalog = setUp();
|
||||
await catalog.load();
|
||||
|
||||
const first = catalog.catalog.listSkills()[0];
|
||||
expect(first).toBeDefined();
|
||||
if (first === undefined) return;
|
||||
|
||||
const inspected = catalog.catalog.getSkill(first.name);
|
||||
expect(inspected).toBeDefined();
|
||||
if (inspected === undefined) return;
|
||||
|
||||
console.log('inspect:', {
|
||||
name: inspected.name,
|
||||
source: inspected.source,
|
||||
dir: inspected.dir,
|
||||
});
|
||||
expect(inspected.name).toBe(first.name);
|
||||
expect(inspected.source).toBe(first.source);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,45 +0,0 @@
|
|||
/**
|
||||
* Scenario: the **session** slice — `sessionLifecycle` + `sessionMetadata`.
|
||||
*
|
||||
* Shows the session as a durable, tracked entity and how the slice's domains
|
||||
* compose: `ISessionLifecycleService` (App) creates Session child scopes —
|
||||
* seeding each with its identity and storage and materializing its metadata —
|
||||
* and tracks the live set, while each session's `ISessionMetadata` (Session)
|
||||
* reads and updates the persisted document through the App `storage` service.
|
||||
*
|
||||
* Wiring: the real composition root (`_harness`) provides every collaborator
|
||||
* (storage, skill catalog, log, …) so the slice runs for real against a temp
|
||||
* `KIMI_CODE_HOME`. Sessions are created through the lifecycle service itself.
|
||||
*
|
||||
* Run:
|
||||
* pnpm --filter @moonshot-ai/agent-core-v2 example -- examples/session.example.ts
|
||||
*/
|
||||
|
||||
import { afterEach, describe, expect, test } from 'vitest';
|
||||
|
||||
import { ISessionLifecycleService } from '#/app/sessionLifecycle/sessionLifecycle';
|
||||
import { ISessionMetadata } from '#/session/sessionMetadata/sessionMetadata';
|
||||
|
||||
import { createSliceHost, type SliceHost } from './_harness';
|
||||
|
||||
describe('session slice (sessionLifecycle + sessionMetadata)', () => {
|
||||
let host: SliceHost;
|
||||
afterEach(() => host?.dispose());
|
||||
|
||||
test('creates, tracks, persists, and closes sessions', async () => {
|
||||
host = createSliceHost({ homeDir: process.env['KIMI_CODE_HOME']! });
|
||||
const lifecycle = host.app.accessor.get(ISessionLifecycleService);
|
||||
|
||||
const first = await lifecycle.create({ sessionId: 'demo-a', workDir: process.env['KIMI_CODE_HOME']! });
|
||||
await lifecycle.create({ sessionId: 'demo-b', workDir: process.env['KIMI_CODE_HOME']! });
|
||||
expect(lifecycle.list().map((h) => h.id)).toEqual(expect.arrayContaining(['demo-a', 'demo-b']));
|
||||
|
||||
const meta = first.accessor.get(ISessionMetadata);
|
||||
await meta.ready;
|
||||
await meta.setTitle('first session');
|
||||
expect((await meta.read()).title).toBe('first session');
|
||||
|
||||
await lifecycle.close('demo-b');
|
||||
expect(lifecycle.list().map((h) => h.id)).not.toContain('demo-b');
|
||||
});
|
||||
});
|
||||
|
|
@ -1,124 +0,0 @@
|
|||
/**
|
||||
* Scenario: the **sessionIndex** module — a business-specific Store composed
|
||||
* from lower-level persistence Stores.
|
||||
*
|
||||
* Shows how a real Domain Service builds a query read-model by aggregating two
|
||||
* more fundamental Stores. `FileSessionIndex` (`ISessionIndex`) enumerates the
|
||||
* persisted session set with `IStorageService.list` (workspace and session
|
||||
* directories) and reads each session's `state.json` with
|
||||
* `IAtomicDocumentStore.get`, projecting the raw documents into
|
||||
* `Page<SessionSummary>`. It is the "business-specific Store" case from the
|
||||
* persistence layering rules: named after the domain because its semantics
|
||||
* (enumerate / filter / page sessions) are unique, not a generic access
|
||||
* pattern.
|
||||
*
|
||||
* The `state.json` documents are the same ones `sessionMetadata` writes during
|
||||
* `sessionLifecycle.create`; here they are seeded directly through the real
|
||||
* `IAtomicDocumentStore` so the scenario stays focused on the index read-model
|
||||
* rather than the session write path. All Services come from `src/`; nothing
|
||||
* here defines a new Service.
|
||||
*/
|
||||
|
||||
import { mkdirSync } from 'node:fs';
|
||||
|
||||
import { afterEach, beforeEach, describe, test } from 'vitest';
|
||||
import { relative } from 'pathe';
|
||||
|
||||
import type { Scope } from '#/_base/di/scope';
|
||||
import { bootstrap } from '#/app/bootstrap/bootstrap';
|
||||
import { IBootstrapService } from '#/app/bootstrap';
|
||||
import '#/app/bootstrap';
|
||||
import { ISessionIndex, type SessionSummary } from '#/app/sessionIndex';
|
||||
import '#/app/sessionIndex';
|
||||
import { IAtomicDocumentStore } from '#/persistence/interface/atomicDocumentStore';
|
||||
import '#/persistence/backends/node-fs';
|
||||
|
||||
const META_SCOPE = 'session-meta';
|
||||
const META_KEY = 'state.json';
|
||||
|
||||
interface SeedMeta {
|
||||
readonly title: string;
|
||||
readonly createdAt: number;
|
||||
readonly updatedAt: number;
|
||||
readonly archived: boolean;
|
||||
}
|
||||
|
||||
describe('sessionIndex module (business Store over storage Stores)', () => {
|
||||
let homeDir: string;
|
||||
let app: Scope;
|
||||
let sessionsScope: string;
|
||||
let docs: IAtomicDocumentStore;
|
||||
let index: ISessionIndex;
|
||||
|
||||
beforeEach(async () => {
|
||||
const resolved = process.env['KIMI_CODE_HOME'];
|
||||
if (resolved === undefined) {
|
||||
throw new Error('KIMI_CODE_HOME is not set; globalSetup should have initialized it');
|
||||
}
|
||||
homeDir = resolved;
|
||||
mkdirSync(homeDir, { recursive: true });
|
||||
|
||||
app = bootstrap({ homeDir }).app;
|
||||
const layout = app.accessor.get(IBootstrapService);
|
||||
sessionsScope = relative(layout.homeDir, layout.sessionsDir);
|
||||
docs = app.accessor.get(IAtomicDocumentStore);
|
||||
index = app.accessor.get(ISessionIndex);
|
||||
|
||||
await seed('ws-a', 's1', {
|
||||
title: 'first session',
|
||||
createdAt: 1000,
|
||||
updatedAt: 1000,
|
||||
archived: false,
|
||||
});
|
||||
await seed('ws-a', 's2', {
|
||||
title: 'most recently active',
|
||||
createdAt: 2000,
|
||||
updatedAt: 3000,
|
||||
archived: false,
|
||||
});
|
||||
await seed('ws-a', 's3', {
|
||||
title: 'archived session',
|
||||
createdAt: 1500,
|
||||
updatedAt: 2500,
|
||||
archived: true,
|
||||
});
|
||||
await seed('ws-b', 's4', {
|
||||
title: 'other workspace',
|
||||
createdAt: 500,
|
||||
updatedAt: 500,
|
||||
archived: false,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
app.dispose();
|
||||
});
|
||||
|
||||
async function seed(workspaceId: string, sessionId: string, meta: SeedMeta): Promise<void> {
|
||||
await docs.set(`${sessionsScope}/${workspaceId}/${sessionId}/${META_SCOPE}`, META_KEY, {
|
||||
id: sessionId,
|
||||
...meta,
|
||||
});
|
||||
}
|
||||
|
||||
test('enumerates, filters, pages, and counts persisted sessions', async () => {
|
||||
const print = (label: string, items: readonly SessionSummary[]): void => {
|
||||
console.log(label, items.map((s) => `${s.id}(${s.workspaceId}${s.archived ? ',archived' : ''})`));
|
||||
};
|
||||
|
||||
print('1) list({}) — non-archived, newest updatedAt first:', (await index.list({})).items);
|
||||
|
||||
print('2) list({ workspaceId: "ws-a" }):', (await index.list({ workspaceId: 'ws-a' })).items);
|
||||
|
||||
print(
|
||||
'3) list({ includeArchived: true }):',
|
||||
(await index.list({ includeArchived: true })).items,
|
||||
);
|
||||
|
||||
print('4) list({ limit: 2 }) — top two by updatedAt:', (await index.list({ limit: 2 })).items);
|
||||
|
||||
console.log('5) get("s4"):', await index.get('s4'));
|
||||
|
||||
console.log('6) countActive("ws-a"):', await index.countActive('ws-a'));
|
||||
});
|
||||
});
|
||||
|
|
@ -1,180 +0,0 @@
|
|||
/**
|
||||
* Scenario: the **shell / web / ask tools** slice — built-in tool
|
||||
* implementations discovered through the module-level contribution registry
|
||||
* and executed through the kaos execution boundary.
|
||||
*
|
||||
* Concept taught: the tools an agent can run are not hard-coded into the agent.
|
||||
* Each built-in tool is a DI class that self-registers via `registerTool(...)`
|
||||
* at module load. When an Agent scope is created, `IAgentToolRegistryService`'s
|
||||
* constructor consumes every module-level contribution — instantiating each
|
||||
* tool with `IInstantiationService.createInstance` and dropping the resulting
|
||||
* `ExecutableTool` into the per-agent runtime table:
|
||||
* - `BashTool` → `Bash`
|
||||
* - `FetchURLTool` → `FetchURL` (and `WebSearchTool` → `WebSearch`
|
||||
* when the host supplies a `WebSearchProvider`
|
||||
* via the `web` service options)
|
||||
* - `AskUserQuestionTool` → `AskUserQuestion`
|
||||
* Once registered, a tool is discovered through `list()` / `resolve(name)` and
|
||||
* run through the same `resolveExecution → execute(ctx)` path every tool uses.
|
||||
* `Bash` executes through the kaos `ISessionProcessRunner` (a real shell
|
||||
* process in the session cwd) — never `node:child_process` directly.
|
||||
*
|
||||
* Wiring: the real composition root (`_harness`) provides every collaborator
|
||||
* (the seeded `IExecContext`, the real `ISessionProcessRunner`,
|
||||
* `IAgentBackgroundService`, `IHostEnvironment`, …) so the tools register and
|
||||
* run for real with no hand-rolled stub list. `WebSearch` is host-injected: it
|
||||
* only lands in the registry when a `WebSearchProvider` is supplied, so we
|
||||
* demonstrate that path by registering a `WebSearchTool` backed by a canned
|
||||
* provider (no network) through the registry's public `register` API.
|
||||
*
|
||||
* Prerequisites: the container & scope-tree example and the tool-framework example.
|
||||
*
|
||||
* Run:
|
||||
* pnpm --filter @moonshot-ai/agent-core-v2 example -- examples/shell-web-tools.example.ts
|
||||
*/
|
||||
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
IAgentBuiltinToolsRegistrar,
|
||||
IAgentToolRegistryService,
|
||||
} from '#/agent/toolRegistry';
|
||||
import { IAgentWebService } from '#/agent/web';
|
||||
import {
|
||||
WebSearchTool,
|
||||
type WebSearchProvider,
|
||||
type WebSearchResult,
|
||||
} from '#/agent/web/tools/web-search';
|
||||
import { IHostEnvironment } from '#/os/interface/hostEnvironment';
|
||||
|
||||
import { createSliceHost, type SliceHost } from './_harness';
|
||||
|
||||
/** Read the JSON-schema `properties` bag off a tool's `parameters`. */
|
||||
function schemaProps(tool: { parameters?: Record<string, unknown> }): Record<string, unknown> {
|
||||
const params = tool.parameters as { properties?: Record<string, unknown> } | undefined;
|
||||
return params?.properties ?? {};
|
||||
}
|
||||
|
||||
describe('shell-web-tools slice (built-in tools via Agent-scope registration services)', () => {
|
||||
let host: SliceHost;
|
||||
afterEach(() => host?.dispose());
|
||||
|
||||
async function setUp() {
|
||||
host = createSliceHost({ homeDir: process.env['KIMI_CODE_HOME']! });
|
||||
// `BashTool` reads `IHostEnvironment.osKind` in its constructor; the host
|
||||
// environment probes the OS asynchronously, so await its `ready` gate
|
||||
// before the tool is constructed (the real composition root awaits this
|
||||
// before opening a Session scope).
|
||||
await host.app.accessor.get(IHostEnvironment).ready;
|
||||
// Force-instantiate the Eager builtin-tools registrar: its constructor
|
||||
// consumes every module-level `registerTool(...)` contribution and builds
|
||||
// each tool instance against this Agent scope (the same path
|
||||
// `AgentLifecycleService.create` runs in production). Bash / AskUser land
|
||||
// this way; `FetchURL` is registered by `IAgentWebService` (options-based
|
||||
// service), so resolve that too.
|
||||
host.agent.accessor.get(IAgentBuiltinToolsRegistrar);
|
||||
host.agent.accessor.get(IAgentWebService);
|
||||
return host.agent.accessor.get(IAgentToolRegistryService);
|
||||
}
|
||||
|
||||
it('registers the built-in Bash, FetchURL and AskUserQuestion tools with builtin metadata', async () => {
|
||||
const registry = await setUp();
|
||||
|
||||
const byName = new Map(registry.list().map((t) => [t.name, t]));
|
||||
|
||||
expect(byName.has('Bash')).toBe(true);
|
||||
expect(byName.has('FetchURL')).toBe(true);
|
||||
expect(byName.has('AskUserQuestion')).toBe(true);
|
||||
|
||||
for (const name of ['Bash', 'FetchURL', 'AskUserQuestion']) {
|
||||
const info = byName.get(name)!;
|
||||
expect(info.source).toBe('builtin');
|
||||
expect(info.description.length).toBeGreaterThan(0);
|
||||
expect(info.parameters).toBeDefined();
|
||||
}
|
||||
|
||||
// `WebSearch` is host-injected: the real web service registers it only when
|
||||
// a `WebSearchProvider` is supplied, which the default composition root does
|
||||
// not. The next test demonstrates that path explicitly.
|
||||
expect(byName.has('WebSearch')).toBe(false);
|
||||
});
|
||||
|
||||
it('resolves each built-in tool by name and exposes its input schema', async () => {
|
||||
const registry = await setUp();
|
||||
|
||||
const bash = registry.resolve('Bash');
|
||||
const fetch = registry.resolve('FetchURL');
|
||||
const ask = registry.resolve('AskUserQuestion');
|
||||
|
||||
expect(bash).toBeDefined();
|
||||
expect(fetch).toBeDefined();
|
||||
expect(ask).toBeDefined();
|
||||
expect(registry.resolve('DoesNotExist')).toBeUndefined();
|
||||
|
||||
expect(schemaProps(bash!)).toHaveProperty('command');
|
||||
expect(schemaProps(fetch!)).toHaveProperty('url');
|
||||
expect(schemaProps(ask!)).toHaveProperty('questions');
|
||||
});
|
||||
|
||||
it('invokes the Bash tool through the real process runner on a harmless command', async () => {
|
||||
const registry = await setUp();
|
||||
|
||||
const bash = registry.resolve('Bash');
|
||||
expect(bash).toBeDefined();
|
||||
if (bash === undefined) return;
|
||||
|
||||
const execution = await bash.resolveExecution({ command: 'echo hello' });
|
||||
expect('execute' in execution).toBe(true);
|
||||
if (!('execute' in execution)) return;
|
||||
|
||||
const result = await execution.execute({
|
||||
turnId: 't1',
|
||||
toolCallId: 'call-bash',
|
||||
signal: new AbortController().signal,
|
||||
});
|
||||
|
||||
expect(result.isError).not.toBe(true);
|
||||
expect(String(result.output)).toContain('hello');
|
||||
});
|
||||
|
||||
it('registers a host-injected WebSearch tool and invokes it without network', async () => {
|
||||
const registry = await setUp();
|
||||
|
||||
// Not present until a provider-backed tool is registered.
|
||||
expect(registry.resolve('WebSearch')).toBeUndefined();
|
||||
|
||||
const canned: WebSearchResult[] = [
|
||||
{ title: 'Example Result', url: 'https://example.com/', snippet: 'a canned snippet' },
|
||||
];
|
||||
const provider: WebSearchProvider = {
|
||||
async search(_query, options) {
|
||||
return options?.includeContent ? canned.map((r) => ({ ...r, content: 'body' })) : canned;
|
||||
},
|
||||
};
|
||||
|
||||
// Mirror what the real web service does when a provider is supplied.
|
||||
const registration = registry.register(new WebSearchTool(provider));
|
||||
|
||||
const search = registry.resolve('WebSearch');
|
||||
expect(search).toBeDefined();
|
||||
if (search === undefined) return;
|
||||
|
||||
const execution = await search.resolveExecution({ query: 'kimi code', limit: 5 });
|
||||
expect('execute' in execution).toBe(true);
|
||||
if (!('execute' in execution)) return;
|
||||
|
||||
const result = await execution.execute({
|
||||
turnId: 't1',
|
||||
toolCallId: 'call-search',
|
||||
signal: new AbortController().signal,
|
||||
});
|
||||
|
||||
expect(result.isError).not.toBe(true);
|
||||
expect(String(result.output)).toContain('Example Result');
|
||||
expect(String(result.output)).toContain('https://example.com/');
|
||||
|
||||
// Disposing the registration handle unregisters the tool again.
|
||||
registration.dispose();
|
||||
expect(registry.resolve('WebSearch')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
|
@ -1,157 +0,0 @@
|
|||
/**
|
||||
* Scenario: the **tool framework** slice — `IAgentToolRegistryService` as a
|
||||
* runtime registry.
|
||||
*
|
||||
* Concept taught: the tools an agent can run are not hard-coded into the agent.
|
||||
* They are *registered at runtime* into an Agent-scope `IAgentToolRegistryService`
|
||||
* and then discovered through `list()` / `resolve(name)`. The registry is the
|
||||
* single source of truth for "which tools exist in this agent", and it is
|
||||
* **one-per-Agent-scope**: two sibling agents get independent registries, while
|
||||
* repeated lookups inside one agent return the same instance. Registrations are
|
||||
* reversible — `register()` hands back an `IDisposable` that unregisters the
|
||||
* tool — and observable, through the `onRegistered` / `onUnregistered` hooks.
|
||||
*
|
||||
* `AgentToolRegistryService` is unusually self-contained for a service: its
|
||||
* constructor carries **no** `@IX` dependencies, so the slice needs no
|
||||
* `stubPair(...)` collaborators at all. We register a tiny in-file fake tool to
|
||||
* prove registration + lookup without pulling in real tool classes.
|
||||
*
|
||||
* Prerequisites: example 01 (container & scope tree).
|
||||
*
|
||||
* Run:
|
||||
* pnpm --filter @moonshot-ai/agent-core-v2 example -- examples/tool-framework.example.ts
|
||||
*/
|
||||
|
||||
import { beforeEach, describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
LifecycleScope,
|
||||
_clearScopedRegistryForTests,
|
||||
registerScopedService,
|
||||
} from '#/_base/di/scope';
|
||||
import { createScopedTestHost } from '#/_base/di/test';
|
||||
|
||||
import {
|
||||
AgentToolRegistryService,
|
||||
IAgentToolRegistryService,
|
||||
} from '#/agent/toolRegistry';
|
||||
import type { ExecutableTool } from '#/agent/tool';
|
||||
|
||||
/** Minimal `ExecutableTool` — just enough metadata for the registry to store. */
|
||||
function fakeTool(name: string): ExecutableTool {
|
||||
return {
|
||||
name,
|
||||
description: `fake ${name} tool`,
|
||||
parameters: { type: 'object', properties: {} },
|
||||
resolveExecution: () => ({
|
||||
approvalRule: 'allow',
|
||||
execute: async () => ({ output: `ok:${name}` }),
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
describe('tool-framework slice (IAgentToolRegistryService runtime registry)', () => {
|
||||
beforeEach(() => {
|
||||
_clearScopedRegistryForTests();
|
||||
// The only real service in this slice. It has no constructor dependencies,
|
||||
// so no collaborators need to be seeded on the App / Session scopes.
|
||||
registerScopedService(
|
||||
LifecycleScope.Agent,
|
||||
IAgentToolRegistryService,
|
||||
AgentToolRegistryService,
|
||||
);
|
||||
});
|
||||
|
||||
it('lists a registered tool and resolves it by name', () => {
|
||||
const host = createScopedTestHost();
|
||||
const session = host.child(LifecycleScope.Session, 's1');
|
||||
const agent = host.childOf(session, LifecycleScope.Agent, 'main');
|
||||
|
||||
const registry = agent.accessor.get(IAgentToolRegistryService);
|
||||
const echo = fakeTool('Echo');
|
||||
const fetch = fakeTool('Fetch');
|
||||
|
||||
registry.register(echo); // source defaults to 'builtin'
|
||||
registry.register(fetch, { source: 'mcp' });
|
||||
|
||||
// list() is sorted by name and carries the registration source.
|
||||
expect(registry.list()).toEqual([
|
||||
{
|
||||
name: 'Echo',
|
||||
description: 'fake Echo tool',
|
||||
parameters: { type: 'object', properties: {} },
|
||||
source: 'builtin',
|
||||
},
|
||||
{
|
||||
name: 'Fetch',
|
||||
description: 'fake Fetch tool',
|
||||
parameters: { type: 'object', properties: {} },
|
||||
source: 'mcp',
|
||||
},
|
||||
]);
|
||||
|
||||
// resolve() returns the exact registered instance; unknown names miss.
|
||||
expect(registry.resolve('Echo')).toBe(echo);
|
||||
expect(registry.resolve('Missing')).toBeUndefined();
|
||||
|
||||
host.dispose();
|
||||
});
|
||||
|
||||
it('is one-per-Agent-scope: isolated between siblings, singleton within one agent', () => {
|
||||
const host = createScopedTestHost();
|
||||
const session = host.child(LifecycleScope.Session, 's1');
|
||||
const agentA = host.childOf(session, LifecycleScope.Agent, 'a');
|
||||
const agentB = host.childOf(session, LifecycleScope.Agent, 'b');
|
||||
|
||||
const registryA = agentA.accessor.get(IAgentToolRegistryService);
|
||||
registryA.register(fakeTool('Echo'));
|
||||
|
||||
// Same instance on repeated access inside one agent (singleton per scope).
|
||||
expect(agentA.accessor.get(IAgentToolRegistryService)).toBe(registryA);
|
||||
|
||||
// A sibling agent gets its own independent registry.
|
||||
expect(registryA.list().map((t) => t.name)).toEqual(['Echo']);
|
||||
expect(agentB.accessor.get(IAgentToolRegistryService).list()).toEqual([]);
|
||||
|
||||
host.dispose();
|
||||
});
|
||||
|
||||
it('unregisters a tool when the registration disposable is disposed', () => {
|
||||
const host = createScopedTestHost();
|
||||
const session = host.child(LifecycleScope.Session, 's1');
|
||||
const agent = host.childOf(session, LifecycleScope.Agent, 'main');
|
||||
|
||||
const registry = agent.accessor.get(IAgentToolRegistryService);
|
||||
const registration = registry.register(fakeTool('Echo'));
|
||||
|
||||
expect(registry.resolve('Echo')).toBeDefined();
|
||||
|
||||
// Disposing the handle returned by register() removes the tool.
|
||||
registration.dispose();
|
||||
|
||||
expect(registry.resolve('Echo')).toBeUndefined();
|
||||
expect(registry.list()).toEqual([]);
|
||||
|
||||
host.dispose();
|
||||
});
|
||||
|
||||
it('fires the onRegistered hook when a tool is registered', async () => {
|
||||
const host = createScopedTestHost();
|
||||
const session = host.child(LifecycleScope.Session, 's1');
|
||||
const agent = host.childOf(session, LifecycleScope.Agent, 'main');
|
||||
|
||||
const registry = agent.accessor.get(IAgentToolRegistryService);
|
||||
const seen: string[] = [];
|
||||
registry.hooks.onRegistered.register('capture', (ctx) => {
|
||||
seen.push(ctx.tool.name);
|
||||
});
|
||||
|
||||
registry.register(fakeTool('Echo'));
|
||||
// register() runs the hook fire-and-forget; flush a microtask to observe it.
|
||||
await Promise.resolve();
|
||||
|
||||
expect(seen).toEqual(['Echo']);
|
||||
|
||||
host.dispose();
|
||||
});
|
||||
});
|
||||
|
|
@ -1,246 +0,0 @@
|
|||
/**
|
||||
* Scenario: the **turn-loop** slice — one execution round of an agent, owned
|
||||
* by `IAgentTurnService` and driven by `IAgentLoopService`.
|
||||
*
|
||||
* Concept taught: a *turn* is the unit of agent execution. `IAgentTurnService`
|
||||
* owns one round at a time — it mints the turn handle (`id`, `abortController`,
|
||||
* `ready`, `result`), records the launch, and exposes the lifecycle hooks that
|
||||
* collaborators hang behavior on:
|
||||
*
|
||||
* - `onLaunched` fires when a turn is launched (with the fresh handle).
|
||||
* - `onEnded` fires when the round finishes (with the terminal `TurnResult`).
|
||||
* - `turn.ready` resolves once the loop reaches its first `beforeStep`.
|
||||
* - `turn.result` resolves with the reason the round ended.
|
||||
*
|
||||
* `IAgentLoopService` is the engine *inside* the turn: its `runTurn(turn)`
|
||||
* drives the step loop (`beforeStep` → LLM → `afterStep` → …) and returns the
|
||||
* `TurnResult` the turn service then publishes through `onEnded`. The turn
|
||||
* service registers a hook on the loop's `beforeStep` to resolve `turn.ready`,
|
||||
* so the two services meet at the loop hooks rather than knowing each other's
|
||||
* internals.
|
||||
*
|
||||
* Wiring: the real composition root (`_harness`) provides every collaborator,
|
||||
* including the real `IAgentTurnService` and `IAgentRecordService`. The only
|
||||
* collaborator we substitute is `IAgentLoopService`, seeded via `agentSeeds`
|
||||
* with a tiny in-memory loop so we can launch a real turn and observe the full
|
||||
* lifecycle without booting an LLM. We spy on the real record service's
|
||||
* `append` / `signal` to observe the `turn.launch` / `turn.started` /
|
||||
* `turn.ended` traffic.
|
||||
*
|
||||
* Prerequisites: example 01 (container & scope tree); the `async-tasks` example
|
||||
* for the `onEnded` hook shape.
|
||||
*
|
||||
* Run:
|
||||
* pnpm --filter @moonshot-ai/agent-core-v2 example -- examples/turn-loop.example.ts
|
||||
*/
|
||||
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { mkdirSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { type ServiceIdentifier } from '#/_base/di/instantiation';
|
||||
import { IAgentLoopService } from '#/agent/loop';
|
||||
import { IAgentRecordService } from '#/agent/record';
|
||||
import {
|
||||
IAgentTurnService,
|
||||
type Turn,
|
||||
type TurnContextOverflowContext,
|
||||
type TurnEndedContext,
|
||||
type TurnResult,
|
||||
type TurnStepContext,
|
||||
type TurnStepUsageContext,
|
||||
} from '#/agent/turn';
|
||||
import { OrderedHookSlot } from '#/hooks';
|
||||
|
||||
import { createSliceHost, type SliceHost } from './_harness';
|
||||
|
||||
/**
|
||||
* Build an in-memory `IAgentLoopService` whose step-loop hooks are real
|
||||
* `OrderedHookSlot`s. The real `AgentLoopService` registers a
|
||||
* `turn-before-step-event` anchor in `beforeStep`, and `AgentTurnService`
|
||||
* orders its ready-resolving hook `{ before: 'turn-before-step-event' }` — so
|
||||
* we register the same anchor here, otherwise the turn service's constructor
|
||||
* throws on the missing ordering target.
|
||||
*/
|
||||
function makeLoop(runTurn: (turn: Turn) => Promise<TurnResult>): IAgentLoopService {
|
||||
const beforeStep = new OrderedHookSlot<TurnStepContext>();
|
||||
beforeStep.register('turn-before-step-event', async (_ctx, next) => {
|
||||
await next();
|
||||
});
|
||||
return {
|
||||
_serviceBrand: undefined,
|
||||
hooks: {
|
||||
beforeStep,
|
||||
onStepUsage: new OrderedHookSlot<TurnStepUsageContext>(),
|
||||
afterStep: new OrderedHookSlot<TurnStepContext>(),
|
||||
onContextOverflow: new OrderedHookSlot<TurnContextOverflowContext>(),
|
||||
},
|
||||
runTurn,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* A loop whose `runTurn` resolves the in-flight turn only when `finish` is
|
||||
* called. `started` resolves once the turn service has actually entered
|
||||
* `runTurn` (which happens after the async user-prompt hook step), so callers
|
||||
* must await it before calling `finish`.
|
||||
*/
|
||||
function controlledLoop(): {
|
||||
loop: IAgentLoopService;
|
||||
started: Promise<void>;
|
||||
finish: (result: TurnResult) => void;
|
||||
} {
|
||||
let finish!: (result: TurnResult) => void;
|
||||
let resolveStarted!: () => void;
|
||||
const started = new Promise<void>((resolve) => {
|
||||
resolveStarted = resolve;
|
||||
});
|
||||
const loop = makeLoop(
|
||||
() =>
|
||||
new Promise<TurnResult>((resolve) => {
|
||||
finish = resolve;
|
||||
resolveStarted();
|
||||
}),
|
||||
);
|
||||
return { loop, started, finish: (result) => finish(result) };
|
||||
}
|
||||
|
||||
/** A loop that drives one `beforeStep` → `afterStep` cycle, mimicking the real step loop. */
|
||||
function stepDrivingLoop(): IAgentLoopService {
|
||||
let loop!: IAgentLoopService;
|
||||
loop = makeLoop(async (turn) => {
|
||||
await loop.hooks.beforeStep.run({ turn, continueTurn: false });
|
||||
await loop.hooks.afterStep.run({ turn, continueTurn: false });
|
||||
return { reason: 'completed' };
|
||||
});
|
||||
return loop;
|
||||
}
|
||||
|
||||
describe('turn-loop slice (turn lifecycle + step loop)', () => {
|
||||
let host: SliceHost;
|
||||
afterEach(() => {
|
||||
host?.dispose();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
function setUp(loop: IAgentLoopService) {
|
||||
// Isolate the real file-backed services (record log, storage) in a per-test
|
||||
// home so concurrent example files never contend on the same on-disk paths.
|
||||
const caseDir = join(process.env['KIMI_CODE_HOME']!, randomUUID());
|
||||
mkdirSync(caseDir, { recursive: true });
|
||||
host = createSliceHost({
|
||||
homeDir: caseDir,
|
||||
// Substitute only the loop engine; the real turn service and record
|
||||
// service run end-to-end for real.
|
||||
agentSeeds: [[IAgentLoopService as ServiceIdentifier<unknown>, loop]],
|
||||
});
|
||||
// Resolve the real record service first and instrument it; the turn service
|
||||
// is constructed lazily and will pick up the same singleton.
|
||||
const records = host.agent.accessor.get(IAgentRecordService);
|
||||
const appended: Array<{ type: string; turnId?: number }> = [];
|
||||
const signaled: Array<{ type: string; reason?: string }> = [];
|
||||
vi.spyOn(records, 'append').mockImplementation((r) => {
|
||||
appended.push(r as { type: string; turnId?: number });
|
||||
});
|
||||
vi.spyOn(records, 'signal').mockImplementation((e) => {
|
||||
signaled.push(e as { type: string; reason?: string });
|
||||
});
|
||||
const turn = host.agent.accessor.get(IAgentTurnService);
|
||||
return { turn, appended, signaled };
|
||||
}
|
||||
|
||||
it('launching a turn fires onLaunched, returns the handle, and records turn.launch', async () => {
|
||||
const loop = makeLoop(async () => ({ reason: 'completed' }));
|
||||
const { turn, appended } = setUp(loop);
|
||||
|
||||
const launched: Turn[] = [];
|
||||
turn.hooks.onLaunched.register('observe', async (ctx, next) => {
|
||||
launched.push(ctx.turn);
|
||||
await next();
|
||||
});
|
||||
|
||||
const handle = turn.launch({ kind: 'user' });
|
||||
|
||||
// The handle is exposed synchronously and the turn is now the active round.
|
||||
expect(handle.id).toBe(0);
|
||||
expect(handle.abortController).toBeInstanceOf(AbortController);
|
||||
expect(turn.getActiveTurn()).toBe(handle);
|
||||
|
||||
const result = await handle.result;
|
||||
expect(result).toEqual({ reason: 'completed' });
|
||||
|
||||
expect(launched).toEqual([handle]);
|
||||
expect(appended).toContainEqual(expect.objectContaining({ type: 'turn.launch', turnId: 0 }));
|
||||
});
|
||||
|
||||
it('drives the step loop hooks and resolves turn.ready on the first beforeStep', async () => {
|
||||
const loop = stepDrivingLoop();
|
||||
const { turn } = setUp(loop);
|
||||
|
||||
const steps: string[] = [];
|
||||
loop.hooks.beforeStep.register('observe', async (_ctx, next) => {
|
||||
steps.push('beforeStep');
|
||||
await next();
|
||||
});
|
||||
loop.hooks.afterStep.register('observe', async (_ctx, next) => {
|
||||
steps.push('afterStep');
|
||||
await next();
|
||||
});
|
||||
|
||||
const handle = turn.launch({ kind: 'user' });
|
||||
await handle.result;
|
||||
|
||||
// The loop drove one step cycle in order.
|
||||
expect(steps).toEqual(['beforeStep', 'afterStep']);
|
||||
// The turn service's beforeStep hook resolves `turn.ready`.
|
||||
await expect(handle.ready).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('fires onEnded with the result and clears the active turn when the loop completes', async () => {
|
||||
const loop = makeLoop(async () => ({ reason: 'completed' }));
|
||||
const { turn, signaled } = setUp(loop);
|
||||
|
||||
const ended: TurnEndedContext[] = [];
|
||||
turn.hooks.onEnded.register('observe', async (ctx, next) => {
|
||||
ended.push(ctx);
|
||||
await next();
|
||||
});
|
||||
|
||||
const handle = turn.launch({ kind: 'user' });
|
||||
await handle.result;
|
||||
|
||||
expect(ended).toHaveLength(1);
|
||||
expect(ended[0]).toMatchObject({ turn: handle, result: { reason: 'completed' } });
|
||||
|
||||
// State transitions: the round is over, the slot is free, the reason is remembered.
|
||||
expect(turn.getActiveTurn()).toBeUndefined();
|
||||
expect(turn.lastEndedReason()).toBe('completed');
|
||||
|
||||
const types = signaled.map((e) => e.type);
|
||||
expect(types).toContain('turn.started');
|
||||
expect(types).toContain('turn.ended');
|
||||
expect(signaled.find((e) => e.type === 'turn.ended')).toMatchObject({ reason: 'completed' });
|
||||
});
|
||||
|
||||
it('rejects a second launch while a turn is active, then frees the slot when it ends', async () => {
|
||||
const { loop, started, finish } = controlledLoop();
|
||||
const { turn } = setUp(loop);
|
||||
|
||||
const first = turn.launch({ kind: 'user' });
|
||||
expect(turn.getActiveTurn()).toBe(first);
|
||||
|
||||
expect(() => turn.launch({ kind: 'user' })).toThrow(
|
||||
/Cannot launch a new turn while turn \d+ is active/,
|
||||
);
|
||||
|
||||
// Wait until the loop's runTurn has actually been entered, then let the
|
||||
// in-flight turn finish so the slot is released.
|
||||
await started;
|
||||
finish({ reason: 'completed' });
|
||||
await first.result;
|
||||
|
||||
expect(turn.getActiveTurn()).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
|
@ -1,115 +0,0 @@
|
|||
/**
|
||||
* Scenario: the **usage-replay** slice — usage metering backed by the record
|
||||
* log, and rebuilding state by replaying records.
|
||||
*
|
||||
* Concept taught: `IAgentUsageService` records token usage per model and per
|
||||
* turn. Every `record(model, usage, context)` appends a `usage.record` to the
|
||||
* append-log *and* applies it to the in-memory aggregate; on resume, the
|
||||
* `usage.record` `resume` facet replays each stored record to rebuild the same
|
||||
* aggregate without re-appending or re-signaling. The `context.type === 'turn'`
|
||||
* form additionally tracks a per-`turnId` window that resets when the turn
|
||||
* changes.
|
||||
*
|
||||
* `IAgentRecordService` (which owns the replay read model),
|
||||
* `IAgentSystemReminderService`, and `IAgentExternalHooksService` are siblings
|
||||
* in this cross-cutting layer; the composition root wires them for real, but
|
||||
* this slice focuses on usage because it is the smallest and self-contained.
|
||||
*
|
||||
* Wiring: the real composition root (`_harness`) provides every collaborator;
|
||||
* we spy on the real `IAgentRecordService` to observe the appended records and
|
||||
* capture the `resume` facet.
|
||||
*
|
||||
* Prerequisites: example 01 (container & scope tree).
|
||||
*
|
||||
* Run:
|
||||
* pnpm --filter @moonshot-ai/agent-core-v2 example -- examples/usage-replay.example.ts
|
||||
*/
|
||||
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { IAgentRecordService } from '#/agent/record';
|
||||
import { IAgentUsageService } from '#/agent/usage';
|
||||
import type { TokenUsage } from '#/app/llmProtocol';
|
||||
|
||||
import { createSliceHost, type SliceHost } from './_harness';
|
||||
|
||||
const u = (inputOther: number, output: number): TokenUsage => ({
|
||||
inputOther,
|
||||
output,
|
||||
inputCacheRead: 0,
|
||||
inputCacheCreation: 0,
|
||||
});
|
||||
|
||||
describe('usage-replay slice (IAgentUsageService + record fan-out)', () => {
|
||||
let host: SliceHost;
|
||||
afterEach(() => host?.dispose());
|
||||
|
||||
function setUp() {
|
||||
host = createSliceHost({ homeDir: process.env['KIMI_CODE_HOME']! });
|
||||
const records = host.agent.accessor.get(IAgentRecordService);
|
||||
const appended: Array<{ type: string; model?: string }> = [];
|
||||
const signals: Array<{ type: string }> = [];
|
||||
const facets = new Map<string, { resume?: (r: { type: string }) => unknown }>();
|
||||
vi.spyOn(records, 'append').mockImplementation((r) => {
|
||||
appended.push(r as { type: string; model?: string });
|
||||
});
|
||||
vi.spyOn(records, 'signal').mockImplementation((s) => {
|
||||
signals.push(s as { type: string });
|
||||
});
|
||||
vi.spyOn(records, 'define').mockImplementation((type, facet) => {
|
||||
facets.set(type as string, facet as { resume?: (r: { type: string }) => unknown });
|
||||
return { dispose: () => facets.delete(type as string) };
|
||||
});
|
||||
const usage = host.agent.accessor.get(IAgentUsageService);
|
||||
return { usage, appended, signals, facets };
|
||||
}
|
||||
|
||||
it('aggregates recorded usage per model and exposes a running total', () => {
|
||||
const { usage } = setUp();
|
||||
|
||||
usage.record('gpt-a', u(10, 5));
|
||||
usage.record('gpt-a', u(3, 2));
|
||||
usage.record('gpt-b', u(100, 50));
|
||||
|
||||
const status = usage.status();
|
||||
expect(status?.byModel?.['gpt-a']).toEqual(u(13, 7));
|
||||
expect(status?.byModel?.['gpt-b']).toEqual(u(100, 50));
|
||||
});
|
||||
|
||||
it('tracks the current turn and resets the window when the turnId changes', () => {
|
||||
const { usage } = setUp();
|
||||
|
||||
usage.record('m', u(1, 1), { type: 'turn', turnId: 1 });
|
||||
usage.record('m', u(2, 2), { type: 'turn', turnId: 1 });
|
||||
expect(usage.status()?.currentTurn).toEqual(u(3, 3));
|
||||
|
||||
usage.record('m', u(9, 9), { type: 'turn', turnId: 2 });
|
||||
expect(usage.status()?.currentTurn).toEqual(u(9, 9));
|
||||
});
|
||||
|
||||
it('record() fans out to one append plus an agent.status.updated signal', () => {
|
||||
const { usage, appended, signals } = setUp();
|
||||
|
||||
usage.record('m', u(1, 1));
|
||||
|
||||
expect(appended).toEqual([
|
||||
{ type: 'usage.record', model: 'm', usage: u(1, 1), context: undefined },
|
||||
]);
|
||||
expect(signals.map((s) => s.type)).toContain('agent.status.updated');
|
||||
});
|
||||
|
||||
it('rebuilds usage from restored records through the usage.record resume facet', () => {
|
||||
const { usage, facets, appended } = setUp();
|
||||
|
||||
// Wake the lazy service so its constructor registers the resume facet.
|
||||
usage.status();
|
||||
const resume = facets.get('usage.record')?.resume;
|
||||
expect(resume).toBeTypeOf('function');
|
||||
|
||||
resume!({ type: 'usage.record', model: 'restored', usage: u(7, 3) });
|
||||
|
||||
expect(usage.status()?.byModel?.['restored']).toEqual(u(7, 3));
|
||||
// Replay must not re-append or re-signal.
|
||||
expect(appended).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,95 +0,0 @@
|
|||
/**
|
||||
* Scenario: the **wire-record** module — a durable-record + replay chain built
|
||||
* on the append-log Store.
|
||||
*
|
||||
* Shows how a real Domain Service (`IAgentWireRecordService` / `AgentWireRecordService`)
|
||||
* aggregates the `IAppendLogStore` access pattern into a complete engine call
|
||||
* chain: `append` stamps and persists records (writing a `metadata` header
|
||||
* first), and `restore` reads the log back and replays each record through the
|
||||
* `register`-ed resumers so domain state can be rebuilt after a restart. The
|
||||
* record types (`swarm_mode.enter` / `swarm_mode.exit`) come from the real
|
||||
* `swarm` domain's `WireRecordMap` declaration merge.
|
||||
*
|
||||
* Persistence is gated on the `homedir` option: the scoped-registered
|
||||
* `IAgentWireRecordService` passes no options and is therefore in-memory only, so this
|
||||
* scenario constructs the real `AgentWireRecordService` with `createInstance(...,
|
||||
* { homedir })` — the same way production wires a persisting wire record —
|
||||
* resolving its `IAppendLogStore` dependency from the container. The resumers
|
||||
* are small side callbacks (not Services). All resolved Services come from
|
||||
* `src/`; nothing here defines a new Service.
|
||||
*/
|
||||
|
||||
import { mkdirSync } from 'node:fs';
|
||||
|
||||
import { afterEach, beforeEach, describe, test } from 'vitest';
|
||||
|
||||
import { DisposableStore } from '#/_base/di/lifecycle';
|
||||
import { createServices, type TestInstantiationService } from '#/_base/di/test';
|
||||
import { IAppendLogStorage } from '#/persistence/interface/storage';
|
||||
import { IAppendLogStore } from '#/persistence/interface/appendLogStore';
|
||||
import { FileStorageService } from '#/persistence/backends/node-fs/fileStorageService';
|
||||
import { AppendLogStore } from '#/persistence/backends/node-fs/appendLogStore';
|
||||
import { AgentWireRecordService, type IAgentWireRecordService } from '#/agent/wireRecord';
|
||||
import '#/agent/swarm/swarm';
|
||||
|
||||
const textDecoder = new TextDecoder();
|
||||
|
||||
describe('wire-record module (durable record + replay over IAppendLogStore)', () => {
|
||||
let homeDir: string;
|
||||
let disposables: DisposableStore;
|
||||
let ix: TestInstantiationService;
|
||||
|
||||
beforeEach(() => {
|
||||
const resolved = process.env['KIMI_CODE_HOME'];
|
||||
if (resolved === undefined) {
|
||||
throw new Error('KIMI_CODE_HOME is not set; globalSetup should have initialized it');
|
||||
}
|
||||
homeDir = resolved;
|
||||
mkdirSync(homeDir, { recursive: true });
|
||||
|
||||
disposables = new DisposableStore();
|
||||
ix = createServices(disposables, {
|
||||
additionalServices: (reg) => {
|
||||
reg.defineInstance(IAppendLogStorage, new FileStorageService(homeDir));
|
||||
reg.define(IAppendLogStore, AppendLogStore);
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
disposables.dispose();
|
||||
});
|
||||
|
||||
test('appends to a JSONL log, then restores and replays through resumers', async () => {
|
||||
const logBytes = ix.get(IAppendLogStorage);
|
||||
|
||||
// --- writer side: append records, which persist to wire/<hash>.jsonl ---
|
||||
const writer: IAgentWireRecordService = ix.createInstance(AgentWireRecordService, { homedir: homeDir });
|
||||
|
||||
writer.append({ type: 'swarm_mode.enter', trigger: 'manual' });
|
||||
writer.append({ type: 'swarm_mode.exit' });
|
||||
await writer.flush();
|
||||
|
||||
const [logKey] = await logBytes.list('wire');
|
||||
const raw = textDecoder.decode((await logBytes.read('wire', logKey)) ?? new Uint8Array());
|
||||
console.log('1) persisted log key:', logKey);
|
||||
console.log('2) raw JSONL (metadata header + appended records):');
|
||||
for (const line of raw.trim().split('\n')) {
|
||||
console.log(' ', line);
|
||||
}
|
||||
|
||||
// --- reader side: a fresh instance on the same log replays the records ---
|
||||
const replayed: string[] = [];
|
||||
const reader: IAgentWireRecordService = ix.createInstance(AgentWireRecordService, { homedir: homeDir });
|
||||
reader.register('swarm_mode.enter', (rec) => {
|
||||
replayed.push(`enter(trigger=${rec.trigger})`);
|
||||
});
|
||||
reader.register('swarm_mode.exit', () => {
|
||||
replayed.push('exit');
|
||||
});
|
||||
|
||||
const result = await reader.restore();
|
||||
console.log('3) restore result:', result);
|
||||
console.log('4) resumers replayed (in order):', replayed);
|
||||
});
|
||||
});
|
||||
|
|
@ -49,7 +49,6 @@
|
|||
"scripts": {
|
||||
"build": "tsdown",
|
||||
"test": "vitest run",
|
||||
"example": "vitest run --config vitest.examples.config.ts",
|
||||
"typecheck": "tsc -p tsconfig.json --noEmit",
|
||||
"lint:domain": "node scripts/check-domain-layers.mjs",
|
||||
"clean": "rm -rf dist",
|
||||
|
|
@ -62,14 +61,15 @@
|
|||
"@anthropic-ai/sdk": "^0.95.2",
|
||||
"@google/genai": "^1.49.0",
|
||||
"@modelcontextprotocol/sdk": "^1.29.0",
|
||||
"@mozilla/readability": "^0.6.0",
|
||||
"@moonshot-ai/kimi-code-oauth": "workspace:^",
|
||||
"@moonshot-ai/kimi-telemetry": "workspace:^",
|
||||
"@moonshot-ai/protocol": "workspace:^",
|
||||
"@mozilla/readability": "^0.6.0",
|
||||
"chokidar": "^4.0.3",
|
||||
"ignore": "^5.3.2",
|
||||
"js-yaml": "^4.1.1",
|
||||
"linkedom": "^0.18.12",
|
||||
"node-pty": "^1.1.0",
|
||||
"nunjucks": "^3.2.4",
|
||||
"openai": "^6.34.0",
|
||||
"pathe": "^2.0.3",
|
||||
|
|
@ -85,13 +85,13 @@
|
|||
"devDependencies": {
|
||||
"@dagrejs/dagre": "^1.1.4",
|
||||
"@types/js-yaml": "^4.0.9",
|
||||
"@types/yauzl": "^2.10.3",
|
||||
"@types/nunjucks": "^3.2.6",
|
||||
"@types/picomatch": "^4.0.3",
|
||||
"@types/react": "^19.1.2",
|
||||
"@types/react-dom": "^19.1.2",
|
||||
"@types/retry": "0.12.0",
|
||||
"@types/sinon": "^21.0.1",
|
||||
"@types/yauzl": "^2.10.3",
|
||||
"@vitejs/plugin-react": "^4.4.1",
|
||||
"@xyflow/react": "^12.4.0",
|
||||
"react": "^19.1.0",
|
||||
|
|
|
|||
|
|
@ -82,6 +82,10 @@ const DOMAIN_LAYER = new Map([
|
|||
['protocol', 1],
|
||||
['hooks', 1],
|
||||
['storage', 1],
|
||||
// `task` is the managed-concurrent-execution primitive (run + defer).
|
||||
// Depends only on `_base`; sits in L1 beside the other program-control
|
||||
// layer substrates.
|
||||
['task', 1],
|
||||
// persistence/ and os/ — the two-level scopes. `interface` holds contracts
|
||||
// (same layer as the old domains they replace); `backends` holds
|
||||
// implementations that may depend on cross-domain services at various layers.
|
||||
|
|
@ -94,8 +98,8 @@ const DOMAIN_LAYER = new Map([
|
|||
// L2 — data & cross-cutting capabilities
|
||||
['records', 2],
|
||||
['wireRecord', 2],
|
||||
['blobStore', 2],
|
||||
['filestore', 2],
|
||||
['blob', 2],
|
||||
['file', 2],
|
||||
['config', 2],
|
||||
['agentFs', 2],
|
||||
['process', 2],
|
||||
|
|
@ -116,7 +120,7 @@ const DOMAIN_LAYER = new Map([
|
|||
['flag', 3],
|
||||
['toolExecutor', 3],
|
||||
['toolRegistry', 3],
|
||||
['toolStore', 3],
|
||||
['toolState', 3],
|
||||
['userTool', 3],
|
||||
['permissionMode', 3],
|
||||
['permissionPolicy', 3],
|
||||
|
|
@ -158,6 +162,7 @@ const DOMAIN_LAYER = new Map([
|
|||
['background', 5],
|
||||
['mcp', 5],
|
||||
['cron', 5],
|
||||
['cronPersistence', 5],
|
||||
['agentTool', 5],
|
||||
['externalHooks', 5],
|
||||
// `btw` forks a single side-question sub-agent via `agentLifecycle`,
|
||||
|
|
@ -244,7 +249,7 @@ function domainFromRel(rel, { exemptRootFile }) {
|
|||
* - `swarm>agentLifecycle`: swarm spawns/manages sub-agents.
|
||||
* - `background>agentLifecycle`: background agent-tasks spawn sub-agents.
|
||||
* - `cron>agentLifecycle` : cron coordinator steers the main agent.
|
||||
* - `cron>sessionActivity`: cron scheduler gates on session idle.
|
||||
* - `cron>sessionContext`: cron scheduler reads session identity for store filtering.
|
||||
*
|
||||
* Post-rebase-v2 restructuring introduced cross-domain type sharing between
|
||||
* L3 (registries/capabilities) and L4 (agent behaviour). The tool contract
|
||||
|
|
@ -271,7 +276,7 @@ const ALLOWED_EXCEPTIONS = new Set([
|
|||
'swarm>agentLifecycle',
|
||||
'background>agentLifecycle',
|
||||
'cron>agentLifecycle',
|
||||
'cron>sessionActivity',
|
||||
'cron>sessionContext',
|
||||
'wireRecord>hooks',
|
||||
// L3/L4 type-sharing: tool contract + execution hook contexts now live in
|
||||
// `tool`; the remaining upward import is a `loop` error/event helper.
|
||||
|
|
|
|||
|
|
@ -76,8 +76,8 @@ const FRAMEWORK_BINDINGS: readonly { token: string; scope: ServiceScope; impl: s
|
|||
* Production composition-root bindings seeded by `bootstrap()` via
|
||||
* `ScopeOptions.extra`. `buildCollection` applies `extra` AFTER the static
|
||||
* `registerScopedService` registry, so these take precedence at runtime: they
|
||||
* override a static default where one exists (e.g. `ISkillCatalogStore` →
|
||||
* `FileSkillCatalogStore`) and supply the binding where the layer ships no
|
||||
* override a static default where one exists (e.g. `ISkillDiscovery` →
|
||||
* `FileSkillDiscovery`) and supply the binding where the layer ships no
|
||||
* in-package default (the Storage-layer tokens → `FileStorageService`, whose
|
||||
* in-memory backend is no longer auto-registered). The analyzer mirrors that
|
||||
* so the graph reflects the backend that actually runs in production.
|
||||
|
|
@ -91,7 +91,7 @@ const PRODUCTION_OVERRIDES: readonly { token: string; scope: ServiceScope; impl:
|
|||
{ token: 'IAppendLogStorage', scope: 'App', impl: 'FileStorageService' },
|
||||
{ token: 'IAtomicDocumentStorage', scope: 'App', impl: 'FileStorageService' },
|
||||
{ token: 'IBlobStorage', scope: 'App', impl: 'FileStorageService' },
|
||||
{ token: 'ISkillCatalogStore', scope: 'App', impl: 'FileSkillCatalogStore' },
|
||||
{ token: 'ISkillDiscovery', scope: 'App', impl: 'FileSkillDiscovery' },
|
||||
];
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -33,6 +33,40 @@ function errorMessage(err: unknown): string {
|
|||
return err instanceof Error ? err.message : String(err);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a `taskService.run()`-compatible executor that waits for a
|
||||
* subagent completion promise. Resolves with the subagent result on
|
||||
* success, throws on abort or failure.
|
||||
*/
|
||||
export function createAgentExecutor(
|
||||
handle: SubagentHandle,
|
||||
abortController: AbortController,
|
||||
): (signal: AbortSignal, output: (data: string) => void) => Promise<SubagentCompletion> {
|
||||
return async (signal, output) => {
|
||||
const requestAbort = (): void => {
|
||||
abortController.abort(signal.reason);
|
||||
};
|
||||
if (signal.aborted) {
|
||||
requestAbort();
|
||||
} else {
|
||||
signal.addEventListener('abort', requestAbort, { once: true });
|
||||
}
|
||||
|
||||
try {
|
||||
const outcome = await handle.completion;
|
||||
output(outcome.result);
|
||||
return outcome;
|
||||
} catch (error: unknown) {
|
||||
if (signal.aborted && (isAbortError(error) || error === signal.reason)) {
|
||||
throw error;
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
signal.removeEventListener('abort', requestAbort);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export class AgentBackgroundTask implements BackgroundTask {
|
||||
readonly kind = 'agent' as const;
|
||||
readonly idPrefix: string = 'agent';
|
||||
|
|
|
|||
|
|
@ -1,16 +1,19 @@
|
|||
import { createDecorator } from "#/_base/di";
|
||||
import type { ITaskHandle } from '#/app/task';
|
||||
import type { Hooks } from '#/hooks';
|
||||
import type {
|
||||
BackgroundTask,
|
||||
BackgroundTaskInfo,
|
||||
BackgroundTaskInfoBase,
|
||||
BackgroundTaskStatus,
|
||||
} from './task';
|
||||
|
||||
export { AgentBackgroundTask } from './agent-task';
|
||||
export { createAgentExecutor } from './agent-task';
|
||||
export type { AgentBackgroundTaskInfo, SubagentHandle } from './agent-task';
|
||||
export { ProcessBackgroundTask } from './process-task';
|
||||
export type { ProcessBackgroundTaskInfo } from './process-task';
|
||||
export { QuestionBackgroundTask } from './question-task';
|
||||
export { ProcessBackgroundTask, createProcessExecutor, ProcessExitError } from './process-task';
|
||||
export type { ProcessBackgroundTaskInfo, ProcessTaskResult } from './process-task';
|
||||
export { QuestionBackgroundTask, createQuestionExecutor, QuestionTaskError } from './question-task';
|
||||
export type { QuestionBackgroundTaskInfo } from './question-task';
|
||||
export { BackgroundTaskPersistence } from './persist';
|
||||
export type {
|
||||
|
|
@ -48,6 +51,36 @@ export interface RegisterBackgroundTaskOptions {
|
|||
|
||||
export type ForegroundTaskReleaseReason = 'detached' | 'terminal';
|
||||
|
||||
/**
|
||||
* Options for tracking a TaskHandle with the BackgroundService.
|
||||
* Callers create the handle via `taskService.run()`, then pass it here.
|
||||
*/
|
||||
export interface BackgroundTrackOptions {
|
||||
readonly idPrefix?: string;
|
||||
readonly description: string;
|
||||
/** If `true`, the task is immediately detached (background). Default: `true`. */
|
||||
readonly detached?: boolean;
|
||||
/** Deadline after which the handle is cancelled. */
|
||||
readonly timeoutMs?: number;
|
||||
/** Deadline to apply if a foreground task is detached. */
|
||||
readonly detachTimeoutMs?: number;
|
||||
/** Foreground caller signal (ignored for detached tasks). */
|
||||
readonly signal?: AbortSignal;
|
||||
/** Callback to force-stop the underlying work (e.g., SIGKILL). */
|
||||
readonly forceStop?: () => Promise<void>;
|
||||
/** Hook called when a foreground task is detached. */
|
||||
readonly onDetach?: () => void;
|
||||
/** Produce the typed `BackgroundTaskInfo` from the base fields. */
|
||||
readonly toInfo: (base: BackgroundTaskInfoBase) => BackgroundTaskInfo;
|
||||
}
|
||||
|
||||
/** Returned by `track()` so callers can race `handle.result` against detach. */
|
||||
export interface IBackgroundEntry {
|
||||
readonly taskId: string;
|
||||
/** Resolves with `'detached'` when the RPC layer detaches this task. */
|
||||
readonly onDidDetach: Promise<ForegroundTaskReleaseReason>;
|
||||
}
|
||||
|
||||
export interface BackgroundNotificationContext {
|
||||
readonly notificationType: string;
|
||||
readonly title: string;
|
||||
|
|
@ -59,10 +92,17 @@ export interface BackgroundNotificationContext {
|
|||
|
||||
export interface IAgentBackgroundService {
|
||||
readonly _serviceBrand: undefined;
|
||||
|
||||
readonly hooks: Hooks<{
|
||||
onDidNotify: BackgroundNotificationContext;
|
||||
}>;
|
||||
|
||||
/** Track a `ITaskHandle` (from `taskService.run()`). */
|
||||
track(handle: ITaskHandle, options: BackgroundTrackOptions): IBackgroundEntry;
|
||||
|
||||
/** @deprecated Use `taskService.run()` + `track()` instead. */
|
||||
registerTask(task: BackgroundTask, options?: RegisterBackgroundTaskOptions): string;
|
||||
|
||||
getTask(taskId: string): BackgroundTaskInfo | undefined;
|
||||
list(activeOnly?: boolean, limit?: number): readonly BackgroundTaskInfo[];
|
||||
persistOutput(taskId: string): void;
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ import { Disposable } from '#/_base/di';
|
|||
import { escapeXml, escapeXmlAttr } from '#/_base/utils/xml-escape';
|
||||
import type { BackgroundTaskOrigin } from '#/agent/contextMemory';
|
||||
import { renderNotificationXml } from '#/agent/contextMemory/notification-xml';
|
||||
import { ITaskService, type ITaskHandle, TERMINAL_TASK_STATES } from '#/app/task';
|
||||
import {
|
||||
TERMINAL_STATUSES,
|
||||
type BackgroundTaskInfoBase,
|
||||
|
|
@ -29,7 +30,7 @@ import { IAgentContextMemoryService } from '#/agent/contextMemory';
|
|||
import { IConfigService } from '#/app/config';
|
||||
import { IAgentPromptService } from '#/agent/prompt';
|
||||
import { ISessionContext } from '#/session/sessionContext';
|
||||
import { IAtomicDocumentStore, IStorageService } from '#/app/storage';
|
||||
import { IAtomicDocumentStore, IFileSystemStorageService } from '#/app/storage';
|
||||
import { ITelemetryService } from '#/app/telemetry';
|
||||
import { IAgentRecordService, type AgentRecord } from '#/agent/record';
|
||||
import {
|
||||
|
|
@ -40,7 +41,9 @@ import {
|
|||
type BackgroundTaskInfo,
|
||||
type BackgroundTaskOutputSnapshot,
|
||||
type BackgroundTaskStatus,
|
||||
type BackgroundTrackOptions,
|
||||
type ForegroundTaskReleaseReason,
|
||||
type IBackgroundEntry,
|
||||
type RegisterBackgroundTaskOptions,
|
||||
} from './background';
|
||||
import { BACKGROUND_SECTION, type BackgroundConfig } from './configSection';
|
||||
|
|
@ -87,12 +90,16 @@ interface BackgroundTaskNotificationContext {
|
|||
|
||||
interface ManagedTask {
|
||||
readonly taskId: string;
|
||||
readonly task: BackgroundTask;
|
||||
readonly task: BackgroundTask | undefined;
|
||||
readonly handle: ITaskHandle | undefined;
|
||||
readonly toInfoFn?: (base: BackgroundTaskInfoBase) => BackgroundTaskInfo;
|
||||
readonly forceStopFn?: () => Promise<void>;
|
||||
readonly onDetachFn?: () => void;
|
||||
readonly outputChunks: string[];
|
||||
outputSizeBytes: number;
|
||||
retainedOutputBytes: number;
|
||||
status: BackgroundTaskStatus;
|
||||
options: RegisterBackgroundTaskOptions;
|
||||
options: RegisterBackgroundTaskOptions & { description?: string };
|
||||
readonly startedAt: number;
|
||||
endedAt: number | null;
|
||||
foregroundRelease?: ForegroundRelease;
|
||||
|
|
@ -108,7 +115,9 @@ interface ManagedTask {
|
|||
pendingOutputBytes: number;
|
||||
outputPersistStarted: boolean;
|
||||
timeoutHandle?: ReturnType<typeof setTimeout>;
|
||||
timedOut: boolean;
|
||||
readonly waiters: Array<() => void>;
|
||||
handleSubscription?: { dispose(): void };
|
||||
}
|
||||
|
||||
const MAX_OUTPUT_BYTES = 1024 * 1024;
|
||||
|
|
@ -140,8 +149,9 @@ export class AgentBackgroundService extends Disposable implements IAgentBackgrou
|
|||
@IAgentContextMemoryService private readonly context: IAgentContextMemoryService,
|
||||
@IConfigService private readonly config: IConfigService,
|
||||
@IAtomicDocumentStore atomicDocs: IAtomicDocumentStore,
|
||||
@IStorageService byteStore: IStorageService,
|
||||
@IFileSystemStorageService byteStore: IFileSystemStorageService,
|
||||
@ISessionContext session: ISessionContext,
|
||||
@ITaskService private readonly taskService: ITaskService,
|
||||
) {
|
||||
super();
|
||||
this.persistence = new BackgroundTaskPersistence(
|
||||
|
|
@ -199,6 +209,7 @@ export class AgentBackgroundService extends Disposable implements IAgentBackgrou
|
|||
const entry: ManagedTask = {
|
||||
taskId: generateTaskId(task.idPrefix),
|
||||
task,
|
||||
handle: undefined,
|
||||
outputChunks: [],
|
||||
outputSizeBytes: 0,
|
||||
retainedOutputBytes: 0,
|
||||
|
|
@ -216,6 +227,7 @@ export class AgentBackgroundService extends Disposable implements IAgentBackgrou
|
|||
outputPersistStarted: detached,
|
||||
waiters: [],
|
||||
terminalFired: false,
|
||||
timedOut: false,
|
||||
};
|
||||
this.tasks.set(entry.taskId, entry);
|
||||
this.ghosts.delete(entry.taskId);
|
||||
|
|
@ -254,6 +266,85 @@ export class AgentBackgroundService extends Disposable implements IAgentBackgrou
|
|||
return entry.taskId;
|
||||
}
|
||||
|
||||
track(handle: ITaskHandle, options: BackgroundTrackOptions): IBackgroundEntry {
|
||||
const detached = options.detached ?? true;
|
||||
this.assertCanRegister(detached);
|
||||
|
||||
const taskId = generateTaskId(options.idPrefix ?? 'task');
|
||||
const timeoutMs = options.timeoutMs;
|
||||
|
||||
const entry: ManagedTask = {
|
||||
taskId,
|
||||
task: undefined,
|
||||
handle,
|
||||
toInfoFn: options.toInfo,
|
||||
forceStopFn: options.forceStop,
|
||||
onDetachFn: options.onDetach,
|
||||
outputChunks: [],
|
||||
outputSizeBytes: 0,
|
||||
retainedOutputBytes: 0,
|
||||
status: 'running',
|
||||
options: { detached, timeoutMs, detachTimeoutMs: options.detachTimeoutMs, signal: detached ? undefined : options.signal, description: options.description },
|
||||
startedAt: Date.now(),
|
||||
endedAt: null,
|
||||
foregroundRelease: detached ? undefined : createForegroundRelease(),
|
||||
abortController: new AbortController(),
|
||||
lifecyclePromise: Promise.resolve(),
|
||||
persistWriteQueue: Promise.resolve(),
|
||||
outputWriteQueue: Promise.resolve(),
|
||||
pendingOutput: [],
|
||||
pendingOutputBytes: 0,
|
||||
outputPersistStarted: detached,
|
||||
waiters: [],
|
||||
terminalFired: false,
|
||||
timedOut: false,
|
||||
};
|
||||
this.tasks.set(taskId, entry);
|
||||
this.ghosts.delete(taskId);
|
||||
|
||||
if (timeoutMs !== undefined && timeoutMs > 0) {
|
||||
entry.timeoutHandle = setTimeout(() => {
|
||||
entry.timedOut = true;
|
||||
handle.cancel();
|
||||
}, timeoutMs);
|
||||
entry.timeoutHandle.unref?.();
|
||||
}
|
||||
|
||||
const outputSub = handle.onDidOutput((chunk) => {
|
||||
this.appendOutput(entry, chunk);
|
||||
});
|
||||
|
||||
const stateSub = handle.onDidChangeState((state) => {
|
||||
if (!TERMINAL_TASK_STATES.has(state)) return;
|
||||
const status = entry.timedOut ? 'timed_out' as const
|
||||
: state === 'cancelled' ? 'killed' as const
|
||||
: state === 'failed' ? 'failed' as const
|
||||
: 'completed' as const;
|
||||
void this.settleTask(entry, { status, stopReason: entry.stopReason });
|
||||
});
|
||||
|
||||
entry.handleSubscription = {
|
||||
dispose() {
|
||||
outputSub.dispose();
|
||||
stateSub.dispose();
|
||||
},
|
||||
};
|
||||
|
||||
entry.lifecyclePromise = handle.result.then(() => {}, () => {});
|
||||
|
||||
this.installForegroundSignal(entry);
|
||||
|
||||
if (this.isDetached(entry)) {
|
||||
void this.persistLive(entry);
|
||||
this.recordTaskStarted(this.toInfo(entry));
|
||||
}
|
||||
|
||||
return {
|
||||
taskId,
|
||||
onDidDetach: entry.foregroundRelease?.promise ?? Promise.resolve('terminal' as const),
|
||||
};
|
||||
}
|
||||
|
||||
getTask(taskId: string): BackgroundTaskInfo | undefined {
|
||||
const entry = this.tasks.get(taskId);
|
||||
return entry === undefined ? this.ghosts.get(taskId) : this.toInfo(entry);
|
||||
|
|
@ -381,7 +472,8 @@ export class AgentBackgroundService extends Disposable implements IAgentBackgrou
|
|||
entry.foregroundSignalCleanup = undefined;
|
||||
this.applyDetachTimeout(entry);
|
||||
try {
|
||||
entry.task.onDetach?.();
|
||||
const onDetach = entry.onDetachFn ?? entry.task?.onDetach;
|
||||
onDetach?.();
|
||||
} catch {
|
||||
/* detach has already succeeded; hooks must not make RPC fail */
|
||||
}
|
||||
|
|
@ -402,8 +494,13 @@ export class AgentBackgroundService extends Disposable implements IAgentBackgrou
|
|||
}
|
||||
if (timeoutMs > 0) {
|
||||
entry.timeoutHandle = setTimeout(() => {
|
||||
entry.abortController.abort('Timed out');
|
||||
void this.settleTask(entry, { status: 'timed_out' });
|
||||
entry.timedOut = true;
|
||||
if (entry.handle) {
|
||||
entry.handle.cancel();
|
||||
} else {
|
||||
entry.abortController.abort('Timed out');
|
||||
void this.settleTask(entry, { status: 'timed_out' });
|
||||
}
|
||||
}, timeoutMs);
|
||||
entry.timeoutHandle.unref?.();
|
||||
}
|
||||
|
|
@ -426,7 +523,11 @@ export class AgentBackgroundService extends Disposable implements IAgentBackgrou
|
|||
}
|
||||
|
||||
entry.stopReason = stopReason;
|
||||
entry.abortController.abort(abortReason);
|
||||
if (entry.handle) {
|
||||
entry.handle.cancel();
|
||||
} else {
|
||||
entry.abortController.abort(abortReason);
|
||||
}
|
||||
|
||||
let graceTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
const graceful = await Promise.race([
|
||||
|
|
@ -450,7 +551,8 @@ export class AgentBackgroundService extends Disposable implements IAgentBackgrou
|
|||
|
||||
if (!graceful) {
|
||||
try {
|
||||
await entry.task.forceStop?.();
|
||||
const forceStop = entry.forceStopFn ?? entry.task?.forceStop;
|
||||
await forceStop?.();
|
||||
} catch {
|
||||
/* best effort */
|
||||
}
|
||||
|
|
@ -656,6 +758,8 @@ export class AgentBackgroundService extends Disposable implements IAgentBackgrou
|
|||
settlement.stopReason ?? (settlement.status === 'killed' ? entry.stopReason : undefined);
|
||||
entry.foregroundSignalCleanup?.();
|
||||
entry.foregroundSignalCleanup = undefined;
|
||||
entry.handleSubscription?.dispose();
|
||||
entry.handleSubscription = undefined;
|
||||
if (entry.timeoutHandle !== undefined) {
|
||||
clearTimeout(entry.timeoutHandle);
|
||||
entry.timeoutHandle = undefined;
|
||||
|
|
@ -830,7 +934,7 @@ export class AgentBackgroundService extends Disposable implements IAgentBackgrou
|
|||
private toInfo(entry: ManagedTask): BackgroundTaskInfo {
|
||||
const base: BackgroundTaskInfoBase = {
|
||||
taskId: entry.taskId,
|
||||
description: entry.task.description,
|
||||
description: entry.task?.description ?? entry.options.description ?? '',
|
||||
status: entry.status,
|
||||
detached: this.isDetached(entry) ? true : false,
|
||||
startedAt: entry.startedAt,
|
||||
|
|
@ -839,7 +943,8 @@ export class AgentBackgroundService extends Disposable implements IAgentBackgrou
|
|||
terminalNotificationSuppressed: entry.terminalNotificationSuppressed,
|
||||
timeoutMs: entry.options.timeoutMs,
|
||||
};
|
||||
return entry.task.toInfo(base);
|
||||
if (entry.toInfoFn) return entry.toInfoFn(base);
|
||||
return entry.task!.toInfo(base);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
*
|
||||
* Persists task state (`<taskId>.json`) and raw task output (`output.log`)
|
||||
* through the `storage` access-pattern stores (`IAtomicDocumentStore` for
|
||||
* atomic whole-document state, `IStorageService` byte primitives for ordered
|
||||
* atomic whole-document state, `IFileSystemStorageService` byte primitives for ordered
|
||||
* output append), addressed under the session's storage scope so the domain
|
||||
* never touches the filesystem. Task ids are validated against the
|
||||
* `{prefix}-{8 hex}` shape before use as path segments (path-traversal and
|
||||
|
|
@ -15,7 +15,7 @@
|
|||
|
||||
import { join } from 'pathe';
|
||||
|
||||
import type { IAtomicDocumentStore, IStorageService } from '#/app/storage';
|
||||
import type { IAtomicDocumentStore, IFileSystemStorageService } from '#/app/storage';
|
||||
|
||||
import type { BackgroundTaskInfo, BackgroundTaskStatus } from './task';
|
||||
|
||||
|
|
@ -43,7 +43,7 @@ export class BackgroundTaskPersistence {
|
|||
private readonly sessionDir: string,
|
||||
private readonly sessionScope: string,
|
||||
private readonly docs: IAtomicDocumentStore,
|
||||
private readonly bytes: IStorageService,
|
||||
private readonly bytes: IFileSystemStorageService,
|
||||
) {}
|
||||
|
||||
private tasksScope(): string {
|
||||
|
|
|
|||
|
|
@ -188,6 +188,108 @@ function observeProcessStream(
|
|||
});
|
||||
}
|
||||
|
||||
export interface ProcessTaskResult {
|
||||
readonly exitCode: number | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a `taskService.run()`-compatible executor that drives a spawned
|
||||
* process to completion. Returns a resolved `ProcessTaskResult` on exit 0,
|
||||
* throws on non-zero exit or abort.
|
||||
*/
|
||||
export function createProcessExecutor(
|
||||
proc: IProcess,
|
||||
onOutput?: ProcessBackgroundTaskOutputCallback,
|
||||
): (signal: AbortSignal, output: (data: string) => void) => Promise<ProcessTaskResult> {
|
||||
return async (signal, output) => {
|
||||
const forwardOutput = (chunk: string, kind: ProcessBackgroundTaskOutputKind): void => {
|
||||
if (chunk.length === 0) return;
|
||||
output(chunk);
|
||||
onOutput?.(kind, chunk);
|
||||
};
|
||||
|
||||
const streamDrained = Promise.all([
|
||||
observeProcessStreamRaw(proc.stdout, 'stdout', signal, forwardOutput),
|
||||
observeProcessStreamRaw(proc.stderr, 'stderr', signal, forwardOutput),
|
||||
]).then(() => undefined);
|
||||
void streamDrained.catch(() => {});
|
||||
|
||||
const requestStop = (): void => {
|
||||
void proc.kill('SIGTERM').catch(() => {});
|
||||
};
|
||||
if (signal.aborted) {
|
||||
requestStop();
|
||||
} else {
|
||||
signal.addEventListener('abort', requestStop, { once: true });
|
||||
}
|
||||
|
||||
try {
|
||||
const exitCode = await proc.wait();
|
||||
await waitForStreamDrain(streamDrained);
|
||||
signal.removeEventListener('abort', requestStop);
|
||||
await disposeProcess(proc);
|
||||
if (signal.aborted) throw signal.reason;
|
||||
if (exitCode !== 0) {
|
||||
const err = new ProcessExitError(exitCode);
|
||||
throw err;
|
||||
}
|
||||
return { exitCode };
|
||||
} catch (error: unknown) {
|
||||
await waitForStreamDrainSettled(streamDrained);
|
||||
signal.removeEventListener('abort', requestStop);
|
||||
await disposeProcess(proc);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export class ProcessExitError extends Error {
|
||||
constructor(readonly exitCode: number | null) {
|
||||
super(`Process exited with code ${exitCode}`);
|
||||
this.name = 'ProcessExitError';
|
||||
}
|
||||
}
|
||||
|
||||
function observeProcessStreamRaw(
|
||||
stream: Readable,
|
||||
kind: ProcessBackgroundTaskOutputKind,
|
||||
signal: AbortSignal,
|
||||
onChunk: (chunk: string, kind: ProcessBackgroundTaskOutputKind) => void,
|
||||
): Promise<void> {
|
||||
stream.setEncoding('utf8');
|
||||
const onData = (chunk: string): void => {
|
||||
onChunk(chunk, kind);
|
||||
};
|
||||
stream.on('data', onData);
|
||||
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
let ended = false;
|
||||
const cleanup = (): void => {
|
||||
stream.removeListener('data', onData);
|
||||
stream.removeListener('end', onEnd);
|
||||
stream.removeListener('close', onClose);
|
||||
stream.removeListener('error', onError);
|
||||
};
|
||||
const done = (): void => { cleanup(); resolve(); };
|
||||
const fail = (error: unknown): void => { cleanup(); reject(error); };
|
||||
const onEnd = (): void => { ended = true; done(); };
|
||||
const onClose = (): void => {
|
||||
if (ended || signal.aborted) { done(); return; }
|
||||
fail(createPrematureCloseError());
|
||||
};
|
||||
const onError = (error: Error): void => {
|
||||
if (signal.aborted) { done(); } else { fail(error); }
|
||||
};
|
||||
stream.once('end', onEnd);
|
||||
stream.once('close', onClose);
|
||||
stream.once('error', onError);
|
||||
});
|
||||
}
|
||||
|
||||
async function disposeProcess(proc: IProcess): Promise<void> {
|
||||
try { await proc.dispose(); } catch { /* best-effort */ }
|
||||
}
|
||||
|
||||
function createPrematureCloseError(): Error {
|
||||
const error = new Error('Premature close') as NodeJS.ErrnoException;
|
||||
error.code = 'ERR_STREAM_PREMATURE_CLOSE';
|
||||
|
|
|
|||
|
|
@ -16,6 +16,31 @@ export interface QuestionBackgroundTaskOptions {
|
|||
readonly toolCallId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a `taskService.run()`-compatible executor that runs a question
|
||||
* thunk and resolves with its result. Throws on error or abort.
|
||||
*/
|
||||
export function createQuestionExecutor(
|
||||
run: (signal: AbortSignal) => Promise<ExecutableToolResult>,
|
||||
): (signal: AbortSignal, output: (data: string) => void) => Promise<ExecutableToolResult> {
|
||||
return async (signal, output) => {
|
||||
const result = await run(signal);
|
||||
const text = serializeToolOutput(result.output);
|
||||
if (text.length > 0) output(text);
|
||||
if (result.isError === true) {
|
||||
throw new QuestionTaskError(errorStopReason(result) ?? 'Question failed');
|
||||
}
|
||||
return result;
|
||||
};
|
||||
}
|
||||
|
||||
export class QuestionTaskError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = 'QuestionTaskError';
|
||||
}
|
||||
}
|
||||
|
||||
export class QuestionBackgroundTask implements BackgroundTask {
|
||||
readonly kind = 'question' as const;
|
||||
readonly idPrefix = 'question';
|
||||
|
|
|
|||
29
packages/agent-core-v2/src/agent/blob/agentBlobService.ts
Normal file
29
packages/agent-core-v2/src/agent/blob/agentBlobService.ts
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
/**
|
||||
* `blob` domain — `IAgentBlobService` contract.
|
||||
*
|
||||
* Offloads large inline media payloads to content-addressed blob storage and
|
||||
* rehydrates them on read. Bound at Agent scope.
|
||||
*/
|
||||
|
||||
import type { ContentPart } from '#/app/llmProtocol';
|
||||
|
||||
import { createDecorator } from "#/_base/di";
|
||||
|
||||
export const BLOBREF_PROTOCOL = 'blobref:';
|
||||
export const MISSING_MEDIA_PLACEHOLDER = '[media missing]';
|
||||
|
||||
export interface AgentBlobServiceOptions {
|
||||
// Reserved for future overrides (threshold / cache size). The persistence
|
||||
// root is derived from `IAgentScopeContext.scope('blobs')`.
|
||||
}
|
||||
|
||||
export interface IAgentBlobService {
|
||||
readonly _serviceBrand: undefined;
|
||||
offloadParts(parts: readonly ContentPart[]): Promise<readonly ContentPart[]>;
|
||||
rehydrateParts(parts: readonly ContentPart[]): Promise<readonly ContentPart[]>;
|
||||
isBlobRef(url: string): boolean;
|
||||
}
|
||||
|
||||
export const IAgentBlobService = createDecorator<IAgentBlobService>(
|
||||
'agentBlobService',
|
||||
);
|
||||
203
packages/agent-core-v2/src/agent/blob/agentBlobServiceImpl.ts
Normal file
203
packages/agent-core-v2/src/agent/blob/agentBlobServiceImpl.ts
Normal file
|
|
@ -0,0 +1,203 @@
|
|||
/**
|
||||
* `blob` domain — `IAgentBlobService` implementation.
|
||||
*
|
||||
* Offloads large inline media payloads into content-addressed blobs and
|
||||
* rehydrates them on read; persists bytes through `IBlobStore` under the
|
||||
* agent's `scope('blobs')` root, matching the v1 `<agentDir>/blobs/<sha256>`
|
||||
* layout. Bound at Agent scope.
|
||||
*/
|
||||
|
||||
import { createHash } from 'node:crypto';
|
||||
import type { ContentPart } from '#/app/llmProtocol';
|
||||
import { InstantiationType } from '#/_base/di/extensions';
|
||||
import { LifecycleScope, registerScopedService } from '#/_base/di/scope';
|
||||
import { IAgentScopeContext } from '#/agent/scopeContext';
|
||||
import { IBlobStore } from '#/persistence/interface/blobStore';
|
||||
import {
|
||||
BLOBREF_PROTOCOL,
|
||||
IAgentBlobService,
|
||||
MISSING_MEDIA_PLACEHOLDER,
|
||||
type AgentBlobServiceOptions,
|
||||
} from './agentBlobService';
|
||||
|
||||
const DEFAULT_THRESHOLD = 4096;
|
||||
const DEFAULT_MAX_CACHE_SIZE = 50 * 1024 * 1024;
|
||||
const DATA_URI_HEADER_RE = /^data:([^;]+);base64,/;
|
||||
|
||||
export class AgentBlobServiceImpl implements IAgentBlobService {
|
||||
declare readonly _serviceBrand: undefined;
|
||||
|
||||
private readonly storageScope: string;
|
||||
private readonly cache = new Map<string, Buffer>();
|
||||
private readonly cacheSizes = new Map<string, number>();
|
||||
private currentCacheSize = 0;
|
||||
|
||||
constructor(
|
||||
private readonly options: AgentBlobServiceOptions = {},
|
||||
@IBlobStore private readonly blobs: IBlobStore,
|
||||
@IAgentScopeContext agentCtx: IAgentScopeContext,
|
||||
) {
|
||||
this.storageScope = agentCtx.scope('blobs');
|
||||
}
|
||||
|
||||
protected get threshold(): number {
|
||||
return DEFAULT_THRESHOLD;
|
||||
}
|
||||
|
||||
protected get maxCacheSize(): number {
|
||||
return DEFAULT_MAX_CACHE_SIZE;
|
||||
}
|
||||
|
||||
isBlobRef(url: string): boolean {
|
||||
return url.startsWith(BLOBREF_PROTOCOL);
|
||||
}
|
||||
|
||||
async offloadParts(parts: readonly ContentPart[]): Promise<readonly ContentPart[]> {
|
||||
let changed = false;
|
||||
const out: ContentPart[] = [];
|
||||
for (const part of parts) {
|
||||
const next = await this.offloadContentPart(part);
|
||||
if (next !== part) changed = true;
|
||||
out.push(next);
|
||||
}
|
||||
return changed ? out : parts;
|
||||
}
|
||||
|
||||
async rehydrateParts(parts: readonly ContentPart[]): Promise<readonly ContentPart[]> {
|
||||
let changed = false;
|
||||
const out: ContentPart[] = [];
|
||||
for (const part of parts) {
|
||||
const next = await this.rehydrateContentPart(part);
|
||||
if (next !== part) changed = true;
|
||||
out.push(next);
|
||||
}
|
||||
return changed ? out : parts;
|
||||
}
|
||||
|
||||
private async offloadContentPart(part: ContentPart): Promise<ContentPart> {
|
||||
let updated: Record<string, unknown> | undefined;
|
||||
for (const [key, value] of Object.entries(part)) {
|
||||
const mediaObj = asMediaContainer(value);
|
||||
if (mediaObj === undefined) continue;
|
||||
|
||||
const url = mediaObj.url;
|
||||
if (typeof url !== 'string') continue;
|
||||
|
||||
const newUrl = await this.maybeOffloadString(url);
|
||||
if (newUrl === url) continue;
|
||||
|
||||
if (updated === undefined) updated = { ...part };
|
||||
updated[key] = { ...(value as object), url: newUrl };
|
||||
}
|
||||
return updated === undefined ? part : (updated as unknown as ContentPart);
|
||||
}
|
||||
|
||||
private async rehydrateContentPart(part: ContentPart): Promise<ContentPart> {
|
||||
let updated: Record<string, unknown> | undefined;
|
||||
for (const [key, value] of Object.entries(part)) {
|
||||
const mediaObj = asMediaContainer(value);
|
||||
if (mediaObj === undefined) continue;
|
||||
|
||||
const url = mediaObj.url;
|
||||
if (typeof url !== 'string' || !this.isBlobRef(url)) continue;
|
||||
|
||||
const newUrl = await this.rehydrateBlobRefUrl(url);
|
||||
if (updated === undefined) updated = { ...part };
|
||||
updated[key] = { ...(value as object), url: newUrl ?? MISSING_MEDIA_PLACEHOLDER };
|
||||
}
|
||||
return updated === undefined ? part : (updated as unknown as ContentPart);
|
||||
}
|
||||
|
||||
private async rehydrateBlobRefUrl(url: string): Promise<string | undefined> {
|
||||
const rest = url.slice(BLOBREF_PROTOCOL.length);
|
||||
const semiIdx = rest.indexOf(';');
|
||||
if (semiIdx === -1) return undefined;
|
||||
|
||||
const mimeType = rest.slice(0, semiIdx);
|
||||
const hash = rest.slice(semiIdx + 1);
|
||||
if (hash.length === 0) return undefined;
|
||||
|
||||
const payload = await this.readBlob(hash);
|
||||
if (payload === undefined) return undefined;
|
||||
|
||||
return `data:${mimeType};base64,${payload.toString('base64')}`;
|
||||
}
|
||||
|
||||
private async readBlob(hash: string): Promise<Buffer | undefined> {
|
||||
const cached = this.cache.get(hash);
|
||||
if (cached !== undefined) {
|
||||
this.cache.delete(hash);
|
||||
this.cache.set(hash, cached);
|
||||
return cached;
|
||||
}
|
||||
|
||||
const payload = await this.blobs.get(this.storageScope, hash).catch(() => undefined);
|
||||
if (payload !== undefined) {
|
||||
this.setCache(hash, Buffer.from(payload));
|
||||
}
|
||||
return payload !== undefined ? Buffer.from(payload) : undefined;
|
||||
}
|
||||
|
||||
private async maybeOffloadString(value: string): Promise<string> {
|
||||
if (this.isBlobRef(value)) return value;
|
||||
|
||||
const match = DATA_URI_HEADER_RE.exec(value);
|
||||
if (match === null) return value;
|
||||
|
||||
const mimeType = match[1]!;
|
||||
const payload = value.slice(match[0].length);
|
||||
if (payload.length < this.threshold) return value;
|
||||
|
||||
return this.writeBlob(mimeType, payload);
|
||||
}
|
||||
|
||||
private async writeBlob(mimeType: string, base64Payload: string): Promise<string> {
|
||||
const hash = createHash('sha256').update(base64Payload, 'utf8').digest('hex');
|
||||
const binary = Buffer.from(base64Payload, 'base64');
|
||||
await this.blobs.put(this.storageScope, hash, binary);
|
||||
this.setCache(hash, binary);
|
||||
return `${BLOBREF_PROTOCOL}${mimeType};${hash}`;
|
||||
}
|
||||
|
||||
private setCache(hash: string, payload: Buffer): void {
|
||||
const size = payload.byteLength;
|
||||
if (this.cache.has(hash)) {
|
||||
const oldSize = this.cacheSizes.get(hash) ?? 0;
|
||||
this.currentCacheSize += size - oldSize;
|
||||
this.cache.delete(hash);
|
||||
} else {
|
||||
if (size > this.maxCacheSize) return;
|
||||
while (this.currentCacheSize + size > this.maxCacheSize && this.cache.size > 0) {
|
||||
this.evictLRU();
|
||||
}
|
||||
this.currentCacheSize += size;
|
||||
}
|
||||
this.cache.set(hash, payload);
|
||||
this.cacheSizes.set(hash, size);
|
||||
}
|
||||
|
||||
private evictLRU(): void {
|
||||
const lru = this.cache.keys().next().value;
|
||||
if (lru === undefined) return;
|
||||
const size = this.cacheSizes.get(lru) ?? 0;
|
||||
this.currentCacheSize -= size;
|
||||
this.cache.delete(lru);
|
||||
this.cacheSizes.delete(lru);
|
||||
}
|
||||
}
|
||||
|
||||
function asMediaContainer(value: unknown): { url: unknown } | undefined {
|
||||
if (value === null || typeof value !== 'object' || Array.isArray(value)) {
|
||||
return undefined;
|
||||
}
|
||||
const obj = value as Record<string, unknown>;
|
||||
return 'url' in obj ? (obj as { url: unknown }) : undefined;
|
||||
}
|
||||
|
||||
registerScopedService(
|
||||
LifecycleScope.Agent,
|
||||
IAgentBlobService,
|
||||
AgentBlobServiceImpl,
|
||||
InstantiationType.Delayed,
|
||||
'agentBlob',
|
||||
);
|
||||
6
packages/agent-core-v2/src/agent/blob/index.ts
Normal file
6
packages/agent-core-v2/src/agent/blob/index.ts
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
/**
|
||||
* `blob` domain barrel.
|
||||
*/
|
||||
|
||||
export * from './agentBlobService';
|
||||
export * from './agentBlobServiceImpl';
|
||||
|
|
@ -1,6 +0,0 @@
|
|||
/**
|
||||
* `blobStore` domain barrel — compatibility re-export.
|
||||
*/
|
||||
|
||||
export * from '#/persistence/interface/blobStore';
|
||||
export * from '#/persistence/backends/node-fs/blobStoreService';
|
||||
|
|
@ -1,81 +0,0 @@
|
|||
/**
|
||||
* `cron` domain (L5) — `IAgentCronService` contract.
|
||||
*
|
||||
* Owns the agent's set of scheduled cron tasks and the queries the cron tools
|
||||
* and the edge layer need against them. The data record (`CronTask`) lives here
|
||||
* beside the contract because every method takes or returns it. Bound at Agent
|
||||
* scope.
|
||||
*/
|
||||
|
||||
import type { ContentPart } from '#/app/llmProtocol';
|
||||
|
||||
import { createDecorator } from '#/_base/di';
|
||||
import type { Turn } from '#/agent/turn';
|
||||
|
||||
/**
|
||||
* Persistent representation of a cron task.
|
||||
*
|
||||
* - `id` — 8-hex; jitter is keyed off this hash, so a stable id == stable
|
||||
* jitter across schedule rewrites.
|
||||
* - `cron` — 5-field expression, evaluated in local time.
|
||||
* - `createdAt` — wall-clock epoch ms at original scheduling. NOT updated
|
||||
* when the scheduler fires; recurring uses it as the baseline floor when
|
||||
* no `lastFiredAt` has been recorded. Also the input to the 7-day stale
|
||||
* judgment.
|
||||
* - `recurring` — undefined / true means "fire repeatedly until deleted or
|
||||
* auto-expired"; false means "fire once then auto-delete".
|
||||
* - `lastFiredAt` — wall-clock epoch ms of the last ideal occurrence whose
|
||||
* jittered delivery has actually completed. Persisted so a `kimi resume`
|
||||
* does not replay already-delivered recurring fires. A value greater than
|
||||
* the current wall clock is treated as corrupt and ignored.
|
||||
*/
|
||||
export interface CronTask {
|
||||
readonly id: string;
|
||||
readonly cron: string;
|
||||
readonly prompt: string;
|
||||
readonly createdAt: number;
|
||||
readonly recurring?: boolean;
|
||||
readonly lastFiredAt?: number;
|
||||
}
|
||||
|
||||
/** Everything the caller supplies; `id` and `createdAt` are generated by the service. */
|
||||
export type CronTaskInit = Omit<CronTask, 'id' | 'createdAt'>;
|
||||
|
||||
export interface CronLoadOptions {
|
||||
readonly replace?: boolean;
|
||||
}
|
||||
|
||||
export interface IAgentCronService {
|
||||
readonly _serviceBrand: undefined;
|
||||
readonly isEnabled: boolean;
|
||||
|
||||
// —— task CRUD (used by the cron tools and the edge layer) ——
|
||||
addTask(init: CronTaskInit): CronTask;
|
||||
removeTasks(ids: readonly string[]): readonly string[];
|
||||
getTask(id: string): CronTask | undefined;
|
||||
list(): readonly CronTask[];
|
||||
|
||||
// —— scheduling queries (used by the cron tools and monitoring) ——
|
||||
/** Wall-clock epoch ms read through the configured clock source. */
|
||||
now(): number;
|
||||
isStale(task: CronTask): boolean;
|
||||
getNextFireTime(): number | null;
|
||||
getNextFireForTask(taskId: string): number | null;
|
||||
|
||||
// —— lifecycle (driven by the engine, resume, and the test seam) ——
|
||||
loadFromDisk(options?: CronLoadOptions): Promise<void>;
|
||||
start(): void;
|
||||
stop(): Promise<void>;
|
||||
tick(): void;
|
||||
flushPersist(): Promise<void>;
|
||||
handleMissed(
|
||||
tasks: readonly CronTask[],
|
||||
renderMissedNotification: (tasks: readonly CronTask[]) => readonly ContentPart[],
|
||||
): Turn | undefined;
|
||||
|
||||
// —— telemetry facade so the tools do not reach into ITelemetryService ——
|
||||
emitScheduled(task: CronTask): void;
|
||||
emitDeleted(taskId: string): void;
|
||||
}
|
||||
|
||||
export const IAgentCronService = createDecorator<IAgentCronService>('agentCronService');
|
||||
|
|
@ -1,9 +1,8 @@
|
|||
/**
|
||||
* `cron` domain barrel — re-exports the cron contract (`cron`) and its scoped
|
||||
* service (`cronService`), plus a side-effect import of each cron tool so its
|
||||
* `registerTool(...)` call runs at module load. Importing this barrel wires
|
||||
* `IAgentCronService` into the scope registry and adds the three cron tools
|
||||
* (`CronCreate` / `CronList` / `CronDelete`) to the tool contribution list.
|
||||
* `cron` domain barrel — re-exports cron utilities (expression parser, jitter,
|
||||
* format, clock, config) and registers the three cron tools (`CronCreate` /
|
||||
* `CronList` / `CronDelete`) via side-effect imports. The cron task record
|
||||
* type lives in `app/cronPersistence`; the scheduling engine lives in `session/cron`.
|
||||
*/
|
||||
|
||||
import './configSection';
|
||||
|
|
@ -11,5 +10,8 @@ import './tools/cron-create';
|
|||
import './tools/cron-delete';
|
||||
import './tools/cron-list';
|
||||
|
||||
export * from './cron';
|
||||
export * from './cronService';
|
||||
export * from './cron-expr';
|
||||
export * from './format';
|
||||
export * from './jitter';
|
||||
export * from './clock';
|
||||
export { CRON_SECTION, type CronConfig, DEFAULT_CRON_CONFIG } from './configSection';
|
||||
|
|
|
|||
|
|
@ -3,21 +3,20 @@
|
|||
* at a future wall-clock time, either once (`recurring: false`) or on a
|
||||
* cron cadence (`recurring: true`, the default).
|
||||
*
|
||||
* Tasks live in `AgentCronService` and are mirrored to
|
||||
* `<sessionDir>/agents/<agentId>/cron/<id>.json` via
|
||||
* `IAgentCronService.addTask`, so a
|
||||
* `kimi resume` of the same session reloads them and the scheduler
|
||||
* picks up where it left off (fires that fell during downtime are
|
||||
* collapsed into a single delivery with `coalescedCount`). Tasks do
|
||||
* Tasks live in `ISessionCronService` (Session scope) and are persisted
|
||||
* through the App-scoped `ICronTaskPersistence` under the project's cron
|
||||
* scope, so a `kimi resume` of the same session reloads them and the
|
||||
* scheduler picks up where it left off (fires that fell during downtime
|
||||
* are collapsed into a single delivery with `coalescedCount`). Tasks do
|
||||
* NOT carry over into a brand-new session.
|
||||
*
|
||||
* The tool itself is pure validation + bookkeeping; the firing /
|
||||
* coalesce / jitter / persistence logic lives in `AgentCronService`.
|
||||
* coalesce / jitter / persistence logic lives in `SessionCronService`.
|
||||
* This file only knows how to:
|
||||
*
|
||||
* 1. validate the request (killswitch, cron parse, 5-year window,
|
||||
* session cap, byte-length cap);
|
||||
* 2. add it to the service (which writes through to disk on success);
|
||||
* 2. add it to the service (which writes through to the store);
|
||||
* 3. report back the post-jitter `nextFireAt` and a human-readable
|
||||
* schedule for the model's benefit;
|
||||
* 4. emit `cron_scheduled` telemetry through the service (the tool
|
||||
|
|
@ -31,7 +30,7 @@ import { registerTool } from '#/agent/toolRegistry';
|
|||
import { toInputJsonSchema } from '#/_base/tools/support/input-schema';
|
||||
import { literalRulePattern } from '#/_base/tools/support/rule-match';
|
||||
import { IConfigService } from '#/app/config';
|
||||
import { IAgentCronService } from '#/agent/cron/cron';
|
||||
import { ISessionCronService } from '#/session/cron';
|
||||
import {
|
||||
CRON_SECTION,
|
||||
DEFAULT_CRON_CONFIG,
|
||||
|
|
@ -129,7 +128,7 @@ export class CronCreateTool implements BuiltinTool<CronCreateInput> {
|
|||
|
||||
constructor(
|
||||
private readonly disabled: boolean = false,
|
||||
@IAgentCronService private readonly cron: IAgentCronService,
|
||||
@ISessionCronService private readonly cron: ISessionCronService,
|
||||
) {}
|
||||
|
||||
resolveExecution(args: CronCreateInput): ToolExecution {
|
||||
|
|
@ -324,7 +323,6 @@ export class CronCreateTool implements BuiltinTool<CronCreateInput> {
|
|||
}
|
||||
|
||||
registerTool(CronCreateTool, {
|
||||
when: (accessor) => accessor.get(IAgentCronService).isEnabled,
|
||||
staticArgs: (accessor) => [
|
||||
accessor.get(IConfigService).get<CronConfig>(CRON_SECTION)?.disabled
|
||||
?? DEFAULT_CRON_CONFIG.disabled,
|
||||
|
|
|
|||
|
|
@ -40,7 +40,7 @@ import { z } from 'zod';
|
|||
import type { ExecutableTool as BuiltinTool, ToolExecution } from '#/agent/tool';
|
||||
import { registerTool } from '#/agent/toolRegistry';
|
||||
import { toInputJsonSchema } from '#/_base/tools/support/input-schema';
|
||||
import { IAgentCronService } from '#/agent/cron/cron';
|
||||
import { ISessionCronService } from '#/session/cron';
|
||||
import CRON_DELETE_DESCRIPTION from './cron-delete.md?raw';
|
||||
|
||||
// ── Constants ────────────────────────────────────────────────────────
|
||||
|
|
@ -72,7 +72,7 @@ export class CronDeleteTool implements BuiltinTool<CronDeleteInput> {
|
|||
CronDeleteInputSchema,
|
||||
);
|
||||
|
||||
constructor(@IAgentCronService private readonly cron: IAgentCronService) {}
|
||||
constructor(@ISessionCronService private readonly cron: ISessionCronService) {}
|
||||
|
||||
resolveExecution(args: CronDeleteInput): ToolExecution {
|
||||
// Format check up front. The store would reject the lookup anyway,
|
||||
|
|
@ -117,6 +117,4 @@ export class CronDeleteTool implements BuiltinTool<CronDeleteInput> {
|
|||
}
|
||||
}
|
||||
|
||||
registerTool(CronDeleteTool, {
|
||||
when: (accessor) => accessor.get(IAgentCronService).isEnabled,
|
||||
});
|
||||
registerTool(CronDeleteTool);
|
||||
|
|
|
|||
|
|
@ -27,7 +27,7 @@
|
|||
* decimal places. Useful context for the `stale`
|
||||
* flag and for the LLM's "should I still be
|
||||
* running?" judgement.
|
||||
* - `stale` — mirrors `IAgentCronService.isStale(task)`; see that
|
||||
* - `stale` — mirrors `ISessionCronService.isStale(task)`; see that
|
||||
* method for the precise rules
|
||||
* (`recurring && age >= 7 days`, gated by
|
||||
* `KIMI_CRON_NO_STALE`).
|
||||
|
|
@ -45,8 +45,8 @@ import { z } from 'zod';
|
|||
import type { ExecutableTool as BuiltinTool, ToolExecution } from '#/agent/tool';
|
||||
import { registerTool } from '#/agent/toolRegistry';
|
||||
import { toInputJsonSchema } from '#/_base/tools/support/input-schema';
|
||||
import { IAgentCronService } from '#/agent/cron/cron';
|
||||
import type { CronTask } from '#/agent/cron/cron';
|
||||
import { ISessionCronService } from '#/session/cron';
|
||||
import type { CronTask } from '#/app/cronPersistence';
|
||||
import {
|
||||
cronToHuman,
|
||||
parseCronExpression,
|
||||
|
|
@ -92,7 +92,7 @@ export class CronListTool implements BuiltinTool<CronListInput> {
|
|||
CronListInputSchema,
|
||||
);
|
||||
|
||||
constructor(@IAgentCronService private readonly cron: IAgentCronService) {}
|
||||
constructor(@ISessionCronService private readonly cron: ISessionCronService) {}
|
||||
|
||||
resolveExecution(_args: CronListInput): ToolExecution {
|
||||
return {
|
||||
|
|
@ -171,6 +171,4 @@ export class CronListTool implements BuiltinTool<CronListInput> {
|
|||
}
|
||||
}
|
||||
|
||||
registerTool(CronListTool, {
|
||||
when: (accessor) => accessor.get(IAgentCronService).isEnabled,
|
||||
});
|
||||
registerTool(CronListTool);
|
||||
|
|
|
|||
|
|
@ -24,7 +24,7 @@ import {
|
|||
renderTodoList,
|
||||
type TodoItem,
|
||||
} from '#/agent/todoList/tools/todo-list';
|
||||
import { IAgentToolStoreService } from '#/agent/toolStore';
|
||||
import { IAgentToolState } from '#/agent/toolState';
|
||||
import { IAgentTurnService } from '#/agent/turn';
|
||||
import {
|
||||
APIContextOverflowError,
|
||||
|
|
@ -109,7 +109,7 @@ export class AgentFullCompactionService extends Disposable implements IAgentFull
|
|||
@IAgentContextSizeService private readonly contextSize: IAgentContextSizeService,
|
||||
@IAgentLLMRequesterService private readonly llmRequester: IAgentLLMRequesterService,
|
||||
@IAgentProfileService private readonly profile: IAgentProfileService,
|
||||
@IAgentToolStoreService private readonly toolStore: IAgentToolStoreService,
|
||||
@IAgentToolState private readonly toolStore: IAgentToolState,
|
||||
@ITelemetryService private readonly telemetry: ITelemetryService,
|
||||
@IAgentRecordService private readonly record: IAgentRecordService,
|
||||
@IAgentTurnService turnService: IAgentTurnService,
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ export interface IAgentScopeContext {
|
|||
* Persistence scope rooted at this agent. `scope()` returns the agent
|
||||
* scope itself; `scope(subKey)` returns `${agentScope}/${subKey}` (e.g.
|
||||
* `scope('cron')` → `sessions/<wsId>/<sId>/agents/<aId>/cron`). Business
|
||||
* code passes the returned string straight to `IStorageService` /
|
||||
* code passes the returned string straight to `IFileSystemStorageService` /
|
||||
* `IAtomicDocumentStore` / `IAppendLogStore`.
|
||||
*/
|
||||
scope(subKey?: string): string;
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ import { IAgentContextMemoryService } from '#/agent/contextMemory';
|
|||
import { IAgentContextInjectorService } from '#/agent/contextInjector';
|
||||
import { IAgentProfileService } from '#/agent/profile';
|
||||
import { IAgentToolRegistryService } from '#/agent/toolRegistry';
|
||||
import { IAgentToolStoreService } from '#/agent/toolStore';
|
||||
import { IAgentToolState } from '#/agent/toolState';
|
||||
import { IAgentTodoListService } from './todoList';
|
||||
import { InstantiationType } from '#/_base/di/extensions';
|
||||
import { LifecycleScope, registerScopedService } from '#/_base/di/scope';
|
||||
|
|
@ -28,7 +28,7 @@ export class AgentTodoListService extends Disposable implements IAgentTodoListSe
|
|||
constructor(
|
||||
@IAgentContextMemoryService private readonly context: IAgentContextMemoryService,
|
||||
@IAgentProfileService private readonly profile: IAgentProfileService,
|
||||
@IAgentToolStoreService private readonly toolStore: IAgentToolStoreService,
|
||||
@IAgentToolState private readonly toolStore: IAgentToolState,
|
||||
@IAgentToolRegistryService toolRegistry: IAgentToolRegistryService,
|
||||
@IAgentContextInjectorService dynamicInjector: IAgentContextInjectorService,
|
||||
@IInstantiationService private readonly instantiationService: IInstantiationService,
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ import { z } from 'zod';
|
|||
import type { BuiltinTool } from '#/agent/tool';
|
||||
import type { ToolExecution } from '#/agent/tool';
|
||||
import { toInputJsonSchema } from '#/_base/tools/support/input-schema';
|
||||
import { IAgentToolStoreService } from '#/agent/toolStore';
|
||||
import { IAgentToolState } from '#/agent/toolState';
|
||||
import DESCRIPTION from './todo-list.md?raw';
|
||||
import TODO_LIST_WRITE_REMINDER from './todo-list-write-reminder.md?raw';
|
||||
|
||||
|
|
@ -42,7 +42,7 @@ export function readTodoItems(raw: unknown): readonly TodoItem[] {
|
|||
}));
|
||||
}
|
||||
|
||||
declare module '#/agent/toolStore' {
|
||||
declare module '#/agent/toolState' {
|
||||
interface ToolStoreData {
|
||||
todo: readonly TodoItem[];
|
||||
}
|
||||
|
|
@ -111,7 +111,7 @@ export class TodoListTool implements BuiltinTool<TodoListInput> {
|
|||
readonly description: string = DESCRIPTION;
|
||||
readonly parameters: Record<string, unknown> = toInputJsonSchema(TodoListInputSchema);
|
||||
|
||||
constructor(@IAgentToolStoreService private readonly store: IAgentToolStoreService) {}
|
||||
constructor(@IAgentToolState private readonly store: IAgentToolState) {}
|
||||
|
||||
resolveExecution(args: TodoListInput): ToolExecution {
|
||||
const description =
|
||||
|
|
|
|||
6
packages/agent-core-v2/src/agent/toolState/index.ts
Normal file
6
packages/agent-core-v2/src/agent/toolState/index.ts
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
/**
|
||||
* `toolState` domain barrel - re-exports the tool state service contract and implementation.
|
||||
*/
|
||||
|
||||
export * from './toolState';
|
||||
export * from './toolStateService';
|
||||
|
|
@ -15,7 +15,7 @@ export interface ToolStoreUpdate<K extends ToolStoreKey = ToolStoreKey> {
|
|||
readonly value: ToolStoreData[K];
|
||||
}
|
||||
|
||||
export interface IAgentToolStoreService extends ToolStore {
|
||||
export interface IAgentToolState extends ToolStore {
|
||||
readonly _serviceBrand: undefined;
|
||||
data(): Readonly<Partial<ToolStoreData>>;
|
||||
|
||||
|
|
@ -24,4 +24,4 @@ export interface IAgentToolStoreService extends ToolStore {
|
|||
}>;
|
||||
}
|
||||
|
||||
export const IAgentToolStoreService = createDecorator<IAgentToolStoreService>('agentToolStoreService');
|
||||
export const IAgentToolState = createDecorator<IAgentToolState>('agentToolState');
|
||||
|
|
@ -4,7 +4,7 @@ import {
|
|||
import { InstantiationType } from '#/_base/di/extensions';
|
||||
import { LifecycleScope, registerScopedService } from '#/_base/di/scope';
|
||||
import { OrderedHookSlot } from '#/hooks';
|
||||
import { IAgentToolStoreService, type ToolStoreData, type ToolStoreKey } from './toolStore';
|
||||
import { IAgentToolState, type ToolStoreData, type ToolStoreKey } from './toolState';
|
||||
import { IAgentRecordService, type AgentRecord } from '#/agent/record';
|
||||
|
||||
declare module '#/agent/wireRecord' {
|
||||
|
|
@ -16,7 +16,7 @@ declare module '#/agent/wireRecord' {
|
|||
}
|
||||
}
|
||||
|
||||
export class AgentToolStoreService extends Disposable implements IAgentToolStoreService {
|
||||
export class AgentToolStateService extends Disposable implements IAgentToolState {
|
||||
declare readonly _serviceBrand: undefined;
|
||||
private readonly store: Partial<ToolStoreData> = {};
|
||||
|
||||
|
|
@ -66,8 +66,8 @@ export class AgentToolStoreService extends Disposable implements IAgentToolStore
|
|||
|
||||
registerScopedService(
|
||||
LifecycleScope.Agent,
|
||||
IAgentToolStoreService,
|
||||
AgentToolStoreService,
|
||||
IAgentToolState,
|
||||
AgentToolStateService,
|
||||
InstantiationType.Delayed,
|
||||
'toolStore',
|
||||
'toolState',
|
||||
);
|
||||
|
|
@ -1,6 +0,0 @@
|
|||
/**
|
||||
* `toolStore` domain barrel - re-exports the toolStore service contract and implementation.
|
||||
*/
|
||||
|
||||
export * from './toolStore';
|
||||
export * from './toolStoreService';
|
||||
|
|
@ -6,7 +6,7 @@ import {
|
|||
Disposable,
|
||||
toDisposable,
|
||||
} from "#/_base/di";
|
||||
import { IAgentBlobStoreService } from '#/agent/blobStore';
|
||||
import { IAgentBlobService } from '#/agent/blob';
|
||||
import { IBootstrapService } from '#/app/bootstrap';
|
||||
import { onUnexpectedError } from '#/_base/errors/unexpectedError';
|
||||
import { IAppendLogStore } from '#/app/storage';
|
||||
|
|
@ -56,7 +56,7 @@ export class AgentWireRecordService extends Disposable implements IAgentWireReco
|
|||
constructor(
|
||||
private readonly options: WireRecordServiceOptions = {},
|
||||
@IBootstrapService bootstrap: IBootstrapService,
|
||||
@IAgentBlobStoreService private readonly blobStore?: IAgentBlobStoreService,
|
||||
@IAgentBlobService private readonly blobStore?: IAgentBlobService,
|
||||
@IAppendLogStore private readonly log?: IAppendLogStore,
|
||||
) {
|
||||
super();
|
||||
|
|
|
|||
|
|
@ -7,11 +7,9 @@
|
|||
* (`homeDir`, `configPath`, …). `resolveBootstrapOptions` is the single place
|
||||
* that reads `process.env` / `os.homedir()` / invocation input to resolve
|
||||
* the snapshot; everything downstream reads from `IBootstrapService` instead of
|
||||
* touching `process` directly. Bound at App scope. Also seeds the App storage
|
||||
* roles (`IStorageService`, `IAppendLogStorage`, `IAtomicDocumentStorage`,
|
||||
* `IBlobStorage`) each with its own `FileStorageService` rooted at `homeDir`
|
||||
* (via per-token `SyncDescriptor`s), so the byte layer (and every Store above
|
||||
* it) persists to disk while the roles stay independently routable.
|
||||
* touching `process` directly. Bound at App scope. Also seeds the
|
||||
* `IFileSystemStorageService` with a `FileStorageService` rooted at `homeDir`
|
||||
* so the byte layer (and every Store above it) persists to disk.
|
||||
*/
|
||||
|
||||
import { mkdirSync } from 'node:fs';
|
||||
|
|
@ -23,14 +21,11 @@ import { SyncDescriptor } from '#/_base/di/descriptors';
|
|||
import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation';
|
||||
import { createAppScope, type Scope, type ScopeSeed } from '#/_base/di/scope';
|
||||
import {
|
||||
IAppendLogStorage,
|
||||
IAtomicDocumentStorage,
|
||||
IBlobStorage,
|
||||
IStorageService,
|
||||
IFileSystemStorageService,
|
||||
} from '#/persistence/interface/storage';
|
||||
import { FileStorageService } from '#/persistence/backends/node-fs/fileStorageService';
|
||||
import { FileSkillCatalogStore } from '#/app/globalSkillCatalog/fileSkillCatalogStore';
|
||||
import { ISkillCatalogStore } from '#/app/globalSkillCatalog/skillCatalogStore';
|
||||
import { FileSkillDiscovery } from '#/app/globalSkillCatalog/fileSkillDiscovery';
|
||||
import { ISkillDiscovery } from '#/app/globalSkillCatalog/skillDiscovery';
|
||||
|
||||
export interface IBootstrapOptions {
|
||||
readonly homeDir: string;
|
||||
|
|
@ -48,7 +43,7 @@ export const IBootstrapOptions: ServiceIdentifier<IBootstrapOptions> =
|
|||
/**
|
||||
* Well-known top-level persistence areas. The bootstrap layer owns the mapping
|
||||
* from each semantic name to concrete backend addressing; business code passes
|
||||
* a scope string to `IStorageService` / `IAtomicDocumentStore` / `IAppendLogStore`
|
||||
* a scope string to `IFileSystemStorageService` / `IAtomicDocumentStore` / `IAppendLogStore`
|
||||
* without caring whether the byte layer talks to a filesystem, a database, or
|
||||
* a blob store.
|
||||
*/
|
||||
|
|
@ -59,7 +54,8 @@ export type PersistenceScopeName =
|
|||
| 'store'
|
||||
| 'logs'
|
||||
| 'cache'
|
||||
| 'credentials';
|
||||
| 'credentials'
|
||||
| 'cron';
|
||||
|
||||
export interface IBootstrapService {
|
||||
readonly _serviceBrand: undefined;
|
||||
|
|
@ -81,7 +77,7 @@ export interface IBootstrapService {
|
|||
|
||||
/**
|
||||
* Scope string for a well-known top-level persistence area. Business code
|
||||
* passes this to `IStorageService` / `IAtomicDocumentStore` / `IAppendLogStore`
|
||||
* passes this to `IFileSystemStorageService` / `IAtomicDocumentStore` / `IAppendLogStore`
|
||||
* — the backend layer converts it to concrete addressing.
|
||||
*/
|
||||
scope(name: PersistenceScopeName): string;
|
||||
|
|
@ -160,21 +156,10 @@ export function bootstrap(input: BootstrapInput = {}, extraSeeds: ScopeSeed = []
|
|||
}
|
||||
|
||||
function storageSeed(options: IBootstrapOptions): ScopeSeed {
|
||||
// Each storage role token resolves to its OWN `FileStorageService` instance
|
||||
// rooted at `homeDir`. The four roles are intentionally independent so a
|
||||
// composition profile can route any one of them (e.g. `IBlobStorage`) to a
|
||||
// different backend; bundling them into a single shared instance would bake
|
||||
// in the assumption that they are always the same backend. We seed a
|
||||
// per-token `SyncDescriptor` (VS Code's `new SyncDescriptor(Ctor, [args])`
|
||||
// pattern) so the container builds each instance via DI, while the `extra`
|
||||
// seed still overrides the in-memory default robustly.
|
||||
const file = (): SyncDescriptor<IStorageService> =>
|
||||
const file = (): SyncDescriptor<IFileSystemStorageService> =>
|
||||
new SyncDescriptor(FileStorageService, [options.homeDir], true);
|
||||
return [
|
||||
[IStorageService as ServiceIdentifier<unknown>, file()],
|
||||
[IAppendLogStorage as ServiceIdentifier<unknown>, file()],
|
||||
[IAtomicDocumentStorage as ServiceIdentifier<unknown>, file()],
|
||||
[IBlobStorage as ServiceIdentifier<unknown>, file()],
|
||||
[IFileSystemStorageService as ServiceIdentifier<unknown>, file()],
|
||||
];
|
||||
}
|
||||
|
||||
|
|
@ -184,8 +169,8 @@ function skillSeed(): ScopeSeed {
|
|||
// in the skill domain (this `extra` seed overrides it in production).
|
||||
return [
|
||||
[
|
||||
ISkillCatalogStore as ServiceIdentifier<unknown>,
|
||||
new SyncDescriptor(FileSkillCatalogStore, [], true),
|
||||
ISkillDiscovery as ServiceIdentifier<unknown>,
|
||||
new SyncDescriptor(FileSkillDiscovery, [], true),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -68,6 +68,7 @@ export class BootstrapService implements IBootstrapService {
|
|||
logs: relative(options.homeDir, this.logsDir),
|
||||
cache: relative(options.homeDir, this.cacheDir),
|
||||
credentials: 'credentials',
|
||||
cron: 'cron',
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
21
packages/agent-core-v2/src/app/cronPersistence/cronTask.ts
Normal file
21
packages/agent-core-v2/src/app/cronPersistence/cronTask.ts
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
/**
|
||||
* `cron` domain (L5) — shared `CronTask` data record.
|
||||
*
|
||||
* The authoritative definition of a cron task's persistent shape. Used by
|
||||
* `ICronTaskPersistence` (App scope) for project-level persistence and by
|
||||
* `ISessionCronService` (Session scope) for the live scheduling engine.
|
||||
* The `tags` map carries arbitrary metadata (e.g. `sessionId`) that the
|
||||
* Session projection uses to filter tasks belonging to the current session.
|
||||
*/
|
||||
|
||||
export interface CronTask {
|
||||
readonly id: string;
|
||||
readonly cron: string;
|
||||
readonly prompt: string;
|
||||
readonly createdAt: number;
|
||||
readonly recurring?: boolean;
|
||||
readonly lastFiredAt?: number;
|
||||
readonly tags?: Readonly<Record<string, string>>;
|
||||
}
|
||||
|
||||
export type CronTaskInit = Omit<CronTask, 'id' | 'createdAt'>;
|
||||
|
|
@ -0,0 +1,27 @@
|
|||
/**
|
||||
* `cron` domain (L5) — `ICronTaskPersistence` contract.
|
||||
*
|
||||
* Project-level persistence for cron tasks. Persists tasks under
|
||||
* `bootstrap.scope('cron')` as atomic documents keyed by
|
||||
* `<workspaceId>/<taskId>.json`. Provides CRUD and query-by-workspace.
|
||||
* A pure data layer — scheduling, timers, and fire delivery are owned by
|
||||
* `ISessionCronService` at Session scope. Bound at App scope.
|
||||
*/
|
||||
|
||||
import { createDecorator } from '#/_base/di';
|
||||
|
||||
import type { CronTask } from './cronTask';
|
||||
|
||||
export interface CronTaskQuery {
|
||||
readonly workspaceId: string;
|
||||
}
|
||||
|
||||
export interface ICronTaskPersistence {
|
||||
readonly _serviceBrand: undefined;
|
||||
get(workspaceId: string, taskId: string): Promise<CronTask | undefined>;
|
||||
list(query: CronTaskQuery): Promise<readonly CronTask[]>;
|
||||
save(workspaceId: string, task: CronTask): Promise<void>;
|
||||
delete(workspaceId: string, taskId: string): Promise<void>;
|
||||
}
|
||||
|
||||
export const ICronTaskPersistence = createDecorator<ICronTaskPersistence>('cronTaskPersistence');
|
||||
|
|
@ -0,0 +1,100 @@
|
|||
/**
|
||||
* `cron` domain (L5) — `ICronTaskPersistence` implementation.
|
||||
*
|
||||
* Persists cron tasks as atomic JSON documents under the `cron` persistence
|
||||
* scope (`bootstrap.scope('cron')`), laid out as `<workspaceId>/<id>.json`.
|
||||
* Pure CRUD — no scheduling logic. Bound at App scope.
|
||||
*/
|
||||
|
||||
import { Disposable } from '#/_base/di';
|
||||
import { InstantiationType } from '#/_base/di/extensions';
|
||||
import { LifecycleScope, registerScopedService } from '#/_base/di/scope';
|
||||
import { IAtomicDocumentStore } from '#/persistence/interface';
|
||||
import { IBootstrapService } from '#/app/bootstrap';
|
||||
|
||||
import { ICronTaskPersistence, type CronTaskQuery } from './cronTaskPersistence';
|
||||
import type { CronTask } from './cronTask';
|
||||
|
||||
export const CRON_ID_REGEX: RegExp = /^[0-9a-f]{8}$/;
|
||||
const JSON_SUFFIX = '.json';
|
||||
|
||||
export function isValidCronTask(obj: unknown): obj is CronTask {
|
||||
if (typeof obj !== 'object' || obj === null) return false;
|
||||
const o = obj as Record<string, unknown>;
|
||||
if (typeof o['id'] !== 'string' || !CRON_ID_REGEX.test(o['id'])) return false;
|
||||
if (typeof o['cron'] !== 'string') return false;
|
||||
if (typeof o['prompt'] !== 'string') return false;
|
||||
if (typeof o['createdAt'] !== 'number') return false;
|
||||
if (o['recurring'] !== undefined && typeof o['recurring'] !== 'boolean') return false;
|
||||
if (
|
||||
o['lastFiredAt'] !== undefined &&
|
||||
(typeof o['lastFiredAt'] !== 'number' || !Number.isFinite(o['lastFiredAt']))
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
if (o['tags'] !== undefined) {
|
||||
if (typeof o['tags'] !== 'object' || o['tags'] === null) return false;
|
||||
for (const v of Object.values(o['tags'] as Record<string, unknown>)) {
|
||||
if (typeof v !== 'string') return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
export class CronTaskPersistenceService extends Disposable implements ICronTaskPersistence {
|
||||
declare readonly _serviceBrand: undefined;
|
||||
|
||||
private readonly cronScope: string;
|
||||
|
||||
constructor(
|
||||
@IBootstrapService private readonly bootstrap: IBootstrapService,
|
||||
@IAtomicDocumentStore private readonly atomicDocs: IAtomicDocumentStore,
|
||||
) {
|
||||
super();
|
||||
this.cronScope = this.bootstrap.scope('cron');
|
||||
}
|
||||
|
||||
private workspaceScope(workspaceId: string): string {
|
||||
return `${this.cronScope}/${workspaceId}`;
|
||||
}
|
||||
|
||||
async get(workspaceId: string, taskId: string): Promise<CronTask | undefined> {
|
||||
const scope = this.workspaceScope(workspaceId);
|
||||
const value = await this.atomicDocs.get<CronTask>(scope, `${taskId}${JSON_SUFFIX}`);
|
||||
if (value === undefined || !isValidCronTask(value)) return undefined;
|
||||
return value;
|
||||
}
|
||||
|
||||
async list(query: CronTaskQuery): Promise<readonly CronTask[]> {
|
||||
const scope = this.workspaceScope(query.workspaceId);
|
||||
const keys = await this.atomicDocs.list(scope);
|
||||
const tasks: CronTask[] = [];
|
||||
for (const key of keys) {
|
||||
if (!key.endsWith(JSON_SUFFIX)) continue;
|
||||
const id = key.slice(0, -JSON_SUFFIX.length);
|
||||
if (!CRON_ID_REGEX.test(id)) continue;
|
||||
const value = await this.atomicDocs.get<CronTask>(scope, key);
|
||||
if (value === undefined || !isValidCronTask(value)) continue;
|
||||
tasks.push(value);
|
||||
}
|
||||
return tasks;
|
||||
}
|
||||
|
||||
async save(workspaceId: string, task: CronTask): Promise<void> {
|
||||
const scope = this.workspaceScope(workspaceId);
|
||||
await this.atomicDocs.set(scope, `${task.id}${JSON_SUFFIX}`, task);
|
||||
}
|
||||
|
||||
async delete(workspaceId: string, taskId: string): Promise<void> {
|
||||
const scope = this.workspaceScope(workspaceId);
|
||||
await this.atomicDocs.delete(scope, `${taskId}${JSON_SUFFIX}`);
|
||||
}
|
||||
}
|
||||
|
||||
registerScopedService(
|
||||
LifecycleScope.App,
|
||||
ICronTaskPersistence,
|
||||
CronTaskPersistenceService,
|
||||
InstantiationType.Delayed,
|
||||
'cron',
|
||||
);
|
||||
8
packages/agent-core-v2/src/app/cronPersistence/index.ts
Normal file
8
packages/agent-core-v2/src/app/cronPersistence/index.ts
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
/**
|
||||
* `cron` domain barrel — re-exports the cron task data record, the
|
||||
* `ICronTaskPersistence` contract, and registers the App-scoped persistence service.
|
||||
*/
|
||||
|
||||
export * from './cronTask';
|
||||
export * from './cronTaskPersistence';
|
||||
export * from './cronTaskPersistenceService';
|
||||
|
|
@ -1,14 +1,9 @@
|
|||
/**
|
||||
* `persistence/interface` — `IFileStore` contract and error helpers.
|
||||
* `file` domain — `IFileService` contract and error helpers.
|
||||
*
|
||||
* Process-global upload store backing the `/files` REST endpoints: persists
|
||||
* uploaded bytes in the `IBlobStorage` backend and their `FileMeta` index in the
|
||||
* same byte store, then hands callers a stream back on download. Bound at App
|
||||
* scope.
|
||||
*
|
||||
* This file ships the interface, DI token, error domain, and error helpers
|
||||
* only. The concrete `FileStoreService` implementation lives in
|
||||
* `persistence/backends/node-fs/fileStoreService.ts`.
|
||||
* uploaded bytes via `IBlobStore` and their `FileMeta` index in the same
|
||||
* store, then hands callers a stream back on download. Bound at App scope.
|
||||
*/
|
||||
|
||||
import type { Readable } from 'node:stream';
|
||||
|
|
@ -36,7 +31,7 @@ export interface GetResult {
|
|||
readonly stream: Readable;
|
||||
}
|
||||
|
||||
export interface IFileStore {
|
||||
export interface IFileService {
|
||||
readonly _serviceBrand: undefined;
|
||||
|
||||
save(source: Readable, filename: string, options?: SaveOptions): Promise<FileMeta>;
|
||||
|
|
@ -46,7 +41,7 @@ export interface IFileStore {
|
|||
delete(fileId: string): Promise<void>;
|
||||
}
|
||||
|
||||
export const IFileStore: ServiceIdentifier<IFileStore> = createDecorator<IFileStore>('fileStore');
|
||||
export const IFileService: ServiceIdentifier<IFileService> = createDecorator<IFileService>('fileService');
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Error domain
|
||||
|
|
@ -1,11 +1,11 @@
|
|||
/**
|
||||
* `filestore` domain (L2) — `IFileStore` implementation.
|
||||
* `file` domain (L2) — `IFileService` implementation.
|
||||
*
|
||||
* Streams uploads into the `IBlobStorage` backend under the `files` scope and
|
||||
* keeps a JSON `FileMeta` index in the same backend under the `filestore`
|
||||
* scope. Enforces the 50 MiB upload cap while collecting the stream, prunes the
|
||||
* Streams uploads into the `IBlobStore` under the `files` scope and keeps a
|
||||
* JSON `FileMeta` index in the same store under the `file` scope.
|
||||
* Enforces the 50 MiB upload cap while collecting the stream, prunes the
|
||||
* index when a referenced blob is missing, and hands downloads back as a lazy
|
||||
* `Readable` over `readStream`. Bound at App scope.
|
||||
* `Readable` over `getStream`. Bound at App scope.
|
||||
*/
|
||||
|
||||
import { randomUUID } from 'node:crypto';
|
||||
|
|
@ -15,18 +15,18 @@ import type { FileMeta } from '@moonshot-ai/protocol';
|
|||
|
||||
import { InstantiationType } from '#/_base/di/extensions';
|
||||
import { LifecycleScope, registerScopedService } from '#/_base/di/scope';
|
||||
import { IBlobStorage, type IStorageService } from '#/persistence/interface/storage';
|
||||
import { IBlobStore } from '#/persistence/interface/blobStore';
|
||||
import {
|
||||
DEFAULT_MAX_UPLOAD_BYTES,
|
||||
IFileStore,
|
||||
IFileService,
|
||||
fileNotFoundError,
|
||||
fileTooLargeError,
|
||||
type GetResult,
|
||||
type SaveOptions,
|
||||
} from '#/persistence/interface/fileStore';
|
||||
} from './fileService';
|
||||
|
||||
const BLOB_SCOPE = 'files';
|
||||
const INDEX_SCOPE = 'filestore';
|
||||
const INDEX_SCOPE = 'file';
|
||||
const INDEX_KEY = 'index.json';
|
||||
const FILE_ID_REGEX = /^f_[A-Za-z0-9][A-Za-z0-9_-]*$/;
|
||||
|
||||
|
|
@ -58,13 +58,13 @@ function isFileMeta(value: unknown): value is FileMeta {
|
|||
);
|
||||
}
|
||||
|
||||
export class FileStoreService implements IFileStore {
|
||||
export class FileServiceImpl implements IFileService {
|
||||
declare readonly _serviceBrand: undefined;
|
||||
|
||||
private indexCache: Map<string, FileMeta> | undefined;
|
||||
private indexLoadPromise: Promise<void> | undefined;
|
||||
|
||||
constructor(@IBlobStorage private readonly blobs: IStorageService) {}
|
||||
constructor(@IBlobStore private readonly blobs: IBlobStore) {}
|
||||
|
||||
async save(source: Readable, filename: string, options: SaveOptions = {}): Promise<FileMeta> {
|
||||
await this.ensureIndex();
|
||||
|
|
@ -82,7 +82,7 @@ export class FileStoreService implements IFileStore {
|
|||
}
|
||||
const data = Buffer.concat(chunks);
|
||||
|
||||
await this.blobs.write(BLOB_SCOPE, id, data, { atomic: true });
|
||||
await this.blobs.put(BLOB_SCOPE, id, data);
|
||||
|
||||
const now = Date.now();
|
||||
const meta: FileMeta = {
|
||||
|
|
@ -111,14 +111,14 @@ export class FileStoreService implements IFileStore {
|
|||
throw fileNotFoundError(fileId);
|
||||
}
|
||||
|
||||
const present = await this.blobs.list(BLOB_SCOPE, fileId);
|
||||
if (!present.includes(fileId)) {
|
||||
const present = await this.blobs.has(BLOB_SCOPE, fileId);
|
||||
if (!present) {
|
||||
this.indexCache!.delete(fileId);
|
||||
await this.writeIndex();
|
||||
throw fileNotFoundError(fileId);
|
||||
}
|
||||
|
||||
return { meta, stream: Readable.from(this.blobs.readStream(BLOB_SCOPE, fileId)) };
|
||||
return { meta, stream: Readable.from(this.blobs.getStream(BLOB_SCOPE, fileId)) };
|
||||
}
|
||||
|
||||
async delete(fileId: string): Promise<void> {
|
||||
|
|
@ -144,7 +144,7 @@ export class FileStoreService implements IFileStore {
|
|||
}
|
||||
|
||||
private async loadIndex(): Promise<void> {
|
||||
const raw = await this.blobs.read(INDEX_SCOPE, INDEX_KEY);
|
||||
const raw = await this.blobs.get(INDEX_SCOPE, INDEX_KEY);
|
||||
if (raw === undefined) {
|
||||
this.indexCache = new Map();
|
||||
return;
|
||||
|
|
@ -169,16 +169,14 @@ export class FileStoreService implements IFileStore {
|
|||
const cache = this.indexCache;
|
||||
if (cache === undefined) return;
|
||||
const payload: IndexFile = { version: 1, files: Array.from(cache.values()) };
|
||||
await this.blobs.write(INDEX_SCOPE, INDEX_KEY, textEncoder.encode(JSON.stringify(payload)), {
|
||||
atomic: true,
|
||||
});
|
||||
await this.blobs.put(INDEX_SCOPE, INDEX_KEY, textEncoder.encode(JSON.stringify(payload)));
|
||||
}
|
||||
}
|
||||
|
||||
registerScopedService(
|
||||
LifecycleScope.App,
|
||||
IFileStore,
|
||||
FileStoreService,
|
||||
IFileService,
|
||||
FileServiceImpl,
|
||||
InstantiationType.Delayed,
|
||||
'filestore',
|
||||
'file',
|
||||
);
|
||||
6
packages/agent-core-v2/src/app/file/index.ts
Normal file
6
packages/agent-core-v2/src/app/file/index.ts
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
/**
|
||||
* `file` domain barrel.
|
||||
*/
|
||||
|
||||
export * from './fileService';
|
||||
export * from './fileServiceImpl';
|
||||
|
|
@ -1,8 +0,0 @@
|
|||
/**
|
||||
* `filestore` domain barrel — compatibility re-export.
|
||||
*
|
||||
* Re-exports from the new canonical locations in `persistence/`.
|
||||
*/
|
||||
|
||||
export * from '#/persistence/interface/fileStore';
|
||||
export * from '#/persistence/backends/node-fs/fileStoreService';
|
||||
|
|
@ -3,7 +3,7 @@
|
|||
*
|
||||
* Registers the code-defined builtin skills into an in-memory catalog. Builtin
|
||||
* skills are constants (not discovered from storage), so they bypass the
|
||||
* `ISkillCatalogStore` and are registered directly by the global catalog.
|
||||
* `ISkillDiscovery` and are registered directly by the global catalog.
|
||||
*/
|
||||
|
||||
import type { InMemorySkillCatalog } from '#/app/globalSkillCatalog/registry';
|
||||
|
|
|
|||
|
|
@ -1,10 +1,10 @@
|
|||
/**
|
||||
* `globalSkillCatalog` domain (L5) — filesystem `ISkillCatalogStore` backend.
|
||||
* `globalSkillCatalog` domain (L5) — filesystem `ISkillDiscovery` backend.
|
||||
*
|
||||
* Discovers skill bundles by walking skill roots on the local filesystem and
|
||||
* parsing each SKILL.md through `parser`. This is the only file in the skill
|
||||
* domain that imports `node:fs`; the rest of the domain depends on the
|
||||
* `ISkillCatalogStore` interface and stays filesystem-agnostic. Bound at App
|
||||
* `ISkillDiscovery` interface and stays filesystem-agnostic. Bound at App
|
||||
* scope by the composition root (tests register the in-memory backend instead).
|
||||
*/
|
||||
|
||||
|
|
@ -16,7 +16,7 @@ import {
|
|||
UnsupportedSkillTypeError,
|
||||
parseSkillText,
|
||||
} from './parser';
|
||||
import type { SkillDiscoveryResult, ISkillCatalogStore } from './skillCatalogStore';
|
||||
import type { SkillDiscoveryResult, ISkillDiscovery } from './skillDiscovery';
|
||||
import type { SkillDefinition, SkillRoot, SkillSource, SkippedSkill } from './types';
|
||||
import { normalizeSkillName } from './types';
|
||||
|
||||
|
|
@ -31,7 +31,7 @@ const PROJECT_GENERIC_DIRS = ['.agents/skills'] as const;
|
|||
// loop forever. Real skill trees are 1-3 levels deep.
|
||||
const MAX_SKILL_SCAN_DEPTH = 8;
|
||||
|
||||
export class FileSkillCatalogStore implements ISkillCatalogStore {
|
||||
export class FileSkillDiscovery implements ISkillDiscovery {
|
||||
declare readonly _serviceBrand: undefined;
|
||||
|
||||
async discoverProject(
|
||||
|
|
@ -2,7 +2,7 @@
|
|||
* `globalSkillCatalog` domain (L5) — `IGlobalSkillCatalog` implementation.
|
||||
*
|
||||
* Registers the builtin skills and discovers user / brand skills through the
|
||||
* `ISkillCatalogStore`, using the user home directories from `bootstrap`. The
|
||||
* `ISkillDiscovery`, using the user home directories from `bootstrap`. The
|
||||
* result is cached after the first `load()`. Bound at App scope.
|
||||
*/
|
||||
|
||||
|
|
@ -13,7 +13,7 @@ import { IBootstrapService } from '#/app/bootstrap';
|
|||
import { registerBuiltinSkills } from '#/app/globalSkillCatalog/builtin';
|
||||
import { IGlobalSkillCatalog } from './globalSkillCatalog';
|
||||
import { InMemorySkillCatalog } from './registry';
|
||||
import { ISkillCatalogStore } from './skillCatalogStore';
|
||||
import { ISkillDiscovery } from './skillDiscovery';
|
||||
import type { SkillCatalog } from './types';
|
||||
|
||||
export class GlobalSkillCatalogService implements IGlobalSkillCatalog {
|
||||
|
|
@ -23,7 +23,7 @@ export class GlobalSkillCatalogService implements IGlobalSkillCatalog {
|
|||
private loaded = false;
|
||||
|
||||
constructor(
|
||||
@ISkillCatalogStore private readonly store: ISkillCatalogStore,
|
||||
@ISkillDiscovery private readonly store: ISkillDiscovery,
|
||||
@IBootstrapService private readonly bootstrap: IBootstrapService,
|
||||
) {}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
/**
|
||||
* `globalSkillCatalog` domain (L5) — in-memory `ISkillCatalogStore` backend.
|
||||
* `globalSkillCatalog` domain (L5) — in-memory `ISkillDiscovery` backend.
|
||||
*
|
||||
* Returns preset skill lists for project / user discovery without any IO.
|
||||
* Registered as the App-scope default so tests and scopes work without a
|
||||
|
|
@ -10,11 +10,11 @@
|
|||
import { InstantiationType } from '#/_base/di/extensions';
|
||||
import { LifecycleScope, registerScopedService } from '#/_base/di/scope';
|
||||
|
||||
import type { SkillDiscoveryResult } from './skillCatalogStore';
|
||||
import { ISkillCatalogStore } from './skillCatalogStore';
|
||||
import type { SkillDiscoveryResult } from './skillDiscovery';
|
||||
import { ISkillDiscovery } from './skillDiscovery';
|
||||
import type { SkillDefinition } from './types';
|
||||
|
||||
export class InMemorySkillCatalogStore implements ISkillCatalogStore {
|
||||
export class InMemorySkillDiscovery implements ISkillDiscovery {
|
||||
declare readonly _serviceBrand: undefined;
|
||||
|
||||
private projectSkills: readonly SkillDefinition[] = [];
|
||||
|
|
@ -39,8 +39,8 @@ export class InMemorySkillCatalogStore implements ISkillCatalogStore {
|
|||
|
||||
registerScopedService(
|
||||
LifecycleScope.App,
|
||||
ISkillCatalogStore,
|
||||
InMemorySkillCatalogStore,
|
||||
ISkillDiscovery,
|
||||
InMemorySkillDiscovery,
|
||||
InstantiationType.Delayed,
|
||||
'skill',
|
||||
);
|
||||
|
|
@ -2,13 +2,13 @@
|
|||
* `globalSkillCatalog` domain barrel — re-exports the skill catalog
|
||||
* contracts, parsers, registry, and the App-scope catalog services. Importing
|
||||
* this barrel registers the `IGlobalSkillCatalog` and the default in-memory
|
||||
* `ISkillCatalogStore` bindings into the scope registry.
|
||||
* `ISkillDiscovery` bindings into the scope registry.
|
||||
*/
|
||||
|
||||
export * from './types';
|
||||
export * from './parser';
|
||||
export * from './registry';
|
||||
export * from './skillCatalogStore';
|
||||
export * from './inMemorySkillCatalogStore';
|
||||
export * from './skillDiscovery';
|
||||
export * from './inMemorySkillDiscovery';
|
||||
export * from './globalSkillCatalog';
|
||||
export * from './globalSkillCatalogService';
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
/**
|
||||
* `globalSkillCatalog` domain (L5) — catalog Store contract.
|
||||
* `globalSkillCatalog` domain (L5) — catalog discovery contract.
|
||||
*
|
||||
* `ISkillCatalogStore` is a business-specific Store that hides how skill
|
||||
* `ISkillDiscovery` is a business-specific interface that hides how skill
|
||||
* bundles are discovered: a backend walks a skill root, reads each SKILL.md,
|
||||
* and parses it into `SkillDefinition`s. The skill domain depends on this
|
||||
* interface only and never touches `node:fs` / `hostFs`; the backend is chosen
|
||||
|
|
@ -19,7 +19,7 @@ export interface SkillDiscoveryResult {
|
|||
readonly scannedRoots: readonly string[];
|
||||
}
|
||||
|
||||
export interface ISkillCatalogStore {
|
||||
export interface ISkillDiscovery {
|
||||
readonly _serviceBrand: undefined;
|
||||
|
||||
discoverProject(
|
||||
|
|
@ -30,4 +30,4 @@ export interface ISkillCatalogStore {
|
|||
discoverUser(homeDir: string, osHomeDir: string): Promise<SkillDiscoveryResult>;
|
||||
}
|
||||
|
||||
export const ISkillCatalogStore = createDecorator<ISkillCatalogStore>('skillCatalogStore');
|
||||
export const ISkillDiscovery = createDecorator<ISkillDiscovery>('skillDiscovery');
|
||||
|
|
@ -24,7 +24,7 @@ import {
|
|||
HostFolderPermissionError,
|
||||
IHostFolderBrowser,
|
||||
RECENT_ROOTS_LIMIT,
|
||||
} from '#/os/interface/folderBrowser';
|
||||
} from './hostFolderBrowser';
|
||||
|
||||
export class HostFolderBrowser implements IHostFolderBrowser {
|
||||
declare readonly _serviceBrand: undefined;
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
/**
|
||||
* `hostFolderBrowser` domain barrel — compatibility re-export.
|
||||
* `hostFolderBrowser` domain barrel — re-exports the host folder picker
|
||||
* contract and its node-local backend.
|
||||
*/
|
||||
|
||||
export * from '#/os/interface/folderBrowser';
|
||||
export * from '#/os/backends/node-local/folderBrowserService';
|
||||
export * from './hostFolderBrowser';
|
||||
export * from './hostFolderBrowserService';
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
* Reads the persisted session set through the `storage` access-pattern stores,
|
||||
* rooted at the `sessionsDir` path layout fact from `bootstrap`. The directory
|
||||
* tree `<sessionsDir>/<workspaceId>/<sessionId>/` is the index: workspace and
|
||||
* session ids are enumerated via `IStorageService.list`, and each session's
|
||||
* session ids are enumerated via `IFileSystemStorageService.list`, and each session's
|
||||
* metadata document is read via `IAtomicDocumentStore` to build its summary.
|
||||
*
|
||||
* The session metadata document lives at `<sessionDir>/state.json`, a layout
|
||||
|
|
@ -21,7 +21,7 @@
|
|||
import { InstantiationType } from '#/_base/di/extensions';
|
||||
import { LifecycleScope, registerScopedService } from '#/_base/di/scope';
|
||||
import { IBootstrapService } from '#/app/bootstrap';
|
||||
import { IAtomicDocumentStore, IStorageService, type Page } from '#/app/storage';
|
||||
import { IAtomicDocumentStore, IFileSystemStorageService, type Page } from '#/app/storage';
|
||||
|
||||
import { ISessionIndex, type SessionListQuery, type SessionSummary } from './sessionIndex';
|
||||
|
||||
|
|
@ -43,7 +43,7 @@ export class FileSessionIndex implements ISessionIndex {
|
|||
|
||||
constructor(
|
||||
@IBootstrapService private readonly bootstrap: IBootstrapService,
|
||||
@IStorageService private readonly storage: IStorageService,
|
||||
@IFileSystemStorageService private readonly storage: IFileSystemStorageService,
|
||||
@IAtomicDocumentStore private readonly docs: IAtomicDocumentStore,
|
||||
) {}
|
||||
|
||||
|
|
|
|||
8
packages/agent-core-v2/src/app/task/index.ts
Normal file
8
packages/agent-core-v2/src/app/task/index.ts
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
/**
|
||||
* `task` domain barrel — re-exports the task contract and implementation.
|
||||
* Importing this barrel registers the `ITaskService` binding.
|
||||
*/
|
||||
|
||||
export * from './interface';
|
||||
import './taskService';
|
||||
export { TaskService } from './taskService';
|
||||
1
packages/agent-core-v2/src/app/task/interface/index.ts
Normal file
1
packages/agent-core-v2/src/app/task/interface/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export * from './task';
|
||||
71
packages/agent-core-v2/src/app/task/interface/task.ts
Normal file
71
packages/agent-core-v2/src/app/task/interface/task.ts
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
/**
|
||||
* `task` domain (L1) — managed concurrent execution primitive.
|
||||
*
|
||||
* Two creation modes:
|
||||
*
|
||||
* - `run(fn)` — active execution: wraps an async function with
|
||||
* `AbortSignal`, output stream, state machine, and disposal.
|
||||
* - `defer()` — passive wait: the caller controls when the handle
|
||||
* settles via `resolve` / `reject`.
|
||||
*
|
||||
* Consumers that need to track handles across turns (e.g. `background`)
|
||||
* compose on top of these primitives; `ITaskService` itself is stateless
|
||||
* beyond the set of live handles.
|
||||
*/
|
||||
|
||||
import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation';
|
||||
import type { Event } from '#/_base/event';
|
||||
import type { IDisposable } from '#/_base/di/lifecycle';
|
||||
|
||||
export type TaskState = 'pending' | 'running' | 'completed' | 'failed' | 'cancelled';
|
||||
|
||||
export const TERMINAL_TASK_STATES: ReadonlySet<TaskState> = new Set([
|
||||
'completed',
|
||||
'failed',
|
||||
'cancelled',
|
||||
]);
|
||||
|
||||
export class TaskCancelledError extends Error {
|
||||
constructor(readonly taskId: string) {
|
||||
super(`Task ${taskId} was cancelled`);
|
||||
this.name = 'TaskCancelledError';
|
||||
}
|
||||
}
|
||||
|
||||
export interface ITaskHandle<T = unknown> extends IDisposable {
|
||||
readonly id: string;
|
||||
readonly state: TaskState;
|
||||
readonly result: Promise<T>;
|
||||
readonly onDidChangeState: Event<TaskState>;
|
||||
readonly onDidOutput: Event<string>;
|
||||
cancel(): void;
|
||||
}
|
||||
|
||||
export interface IDeferredHandle<T = unknown> extends ITaskHandle<T> {
|
||||
resolve(value: T): void;
|
||||
reject(reason?: unknown): void;
|
||||
}
|
||||
|
||||
export interface ITaskService {
|
||||
readonly _serviceBrand: undefined;
|
||||
|
||||
/**
|
||||
* Create a task that actively runs `fn`. The function receives an
|
||||
* `AbortSignal` (cancelled when the handle is cancelled/disposed) and
|
||||
* an `output` callback for streaming data (e.g. process stdout).
|
||||
*
|
||||
* State: pending → running → completed | failed | cancelled.
|
||||
*/
|
||||
run<T>(fn: (signal: AbortSignal, output: (data: string) => void) => Promise<T>): ITaskHandle<T>;
|
||||
|
||||
/**
|
||||
* Create a passive task whose settlement is controlled by the caller
|
||||
* through the returned `resolve` / `reject` methods.
|
||||
*
|
||||
* State: pending → completed | failed | cancelled.
|
||||
*/
|
||||
defer<T>(): IDeferredHandle<T>;
|
||||
}
|
||||
|
||||
export const ITaskService: ServiceIdentifier<ITaskService> =
|
||||
createDecorator<ITaskService>('taskService');
|
||||
187
packages/agent-core-v2/src/app/task/taskService.ts
Normal file
187
packages/agent-core-v2/src/app/task/taskService.ts
Normal file
|
|
@ -0,0 +1,187 @@
|
|||
/**
|
||||
* `task` domain (L1) — `ITaskService` implementation.
|
||||
*
|
||||
* Manages task handles: each handle owns a state machine, an optional
|
||||
* `AbortController` (for `run()`), and `Emitter` pairs for state changes
|
||||
* and output. App-scoped — one instance per process.
|
||||
*/
|
||||
|
||||
import { Emitter, type Event } from '#/_base/event';
|
||||
import { InstantiationType } from '#/_base/di/extensions';
|
||||
import { Disposable, markAsDisposed, trackDisposable } from '#/_base/di/lifecycle';
|
||||
import { LifecycleScope, registerScopedService } from '#/_base/di/scope';
|
||||
|
||||
import {
|
||||
type ITaskHandle,
|
||||
type IDeferredHandle,
|
||||
ITaskService,
|
||||
type TaskState,
|
||||
TERMINAL_TASK_STATES,
|
||||
TaskCancelledError,
|
||||
} from './interface/task';
|
||||
|
||||
function isTerminal(state: TaskState): boolean {
|
||||
return TERMINAL_TASK_STATES.has(state);
|
||||
}
|
||||
|
||||
class RunHandle<T> implements ITaskHandle<T> {
|
||||
private _state: TaskState = 'pending';
|
||||
private readonly _abortController = new AbortController();
|
||||
private readonly _onDidChangeState = new Emitter<TaskState>();
|
||||
readonly onDidChangeState: Event<TaskState> = this._onDidChangeState.event;
|
||||
private readonly _onDidOutput = new Emitter<string>();
|
||||
readonly onDidOutput: Event<string> = this._onDidOutput.event;
|
||||
readonly result: Promise<T>;
|
||||
private _disposed = false;
|
||||
|
||||
constructor(
|
||||
readonly id: string,
|
||||
fn: (signal: AbortSignal, output: (data: string) => void) => Promise<T>,
|
||||
) {
|
||||
trackDisposable(this);
|
||||
|
||||
const output = (data: string): void => {
|
||||
if (!isTerminal(this._state) && !this._disposed) {
|
||||
this._onDidOutput.fire(data);
|
||||
}
|
||||
};
|
||||
|
||||
this._transition('running');
|
||||
|
||||
this.result = fn(this._abortController.signal, output).then(
|
||||
(value) => {
|
||||
if (this._abortController.signal.aborted) {
|
||||
this._transition('cancelled');
|
||||
throw new TaskCancelledError(this.id);
|
||||
}
|
||||
this._transition('completed');
|
||||
return value;
|
||||
},
|
||||
(error: unknown) => {
|
||||
if (this._abortController.signal.aborted) {
|
||||
this._transition('cancelled');
|
||||
} else {
|
||||
this._transition('failed');
|
||||
}
|
||||
throw error;
|
||||
},
|
||||
);
|
||||
|
||||
// Prevent unhandled rejection warnings when nobody has attached a handler yet.
|
||||
void this.result.catch(() => {});
|
||||
}
|
||||
|
||||
get state(): TaskState {
|
||||
return this._state;
|
||||
}
|
||||
|
||||
cancel(): void {
|
||||
if (isTerminal(this._state)) return;
|
||||
this._abortController.abort(new TaskCancelledError(this.id));
|
||||
this._transition('cancelled');
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
if (this._disposed) return;
|
||||
this._disposed = true;
|
||||
markAsDisposed(this);
|
||||
this.cancel();
|
||||
this._onDidChangeState.dispose();
|
||||
this._onDidOutput.dispose();
|
||||
}
|
||||
|
||||
private _transition(to: TaskState): void {
|
||||
if (isTerminal(this._state)) return;
|
||||
this._state = to;
|
||||
if (!this._disposed) {
|
||||
this._onDidChangeState.fire(to);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class DeferHandle<T> implements IDeferredHandle<T> {
|
||||
private _state: TaskState = 'pending';
|
||||
private _resolvePromise!: (value: T) => void;
|
||||
private _rejectPromise!: (reason: unknown) => void;
|
||||
private readonly _onDidChangeState = new Emitter<TaskState>();
|
||||
readonly onDidChangeState: Event<TaskState> = this._onDidChangeState.event;
|
||||
private readonly _onDidOutput = new Emitter<string>();
|
||||
readonly onDidOutput: Event<string> = this._onDidOutput.event;
|
||||
readonly result: Promise<T>;
|
||||
private _disposed = false;
|
||||
|
||||
constructor(readonly id: string) {
|
||||
trackDisposable(this);
|
||||
|
||||
this.result = new Promise<T>((resolve, reject) => {
|
||||
this._resolvePromise = resolve;
|
||||
this._rejectPromise = reject;
|
||||
});
|
||||
|
||||
void this.result.catch(() => {});
|
||||
}
|
||||
|
||||
get state(): TaskState {
|
||||
return this._state;
|
||||
}
|
||||
|
||||
resolve(value: T): void {
|
||||
if (isTerminal(this._state)) return;
|
||||
this._transition('completed');
|
||||
this._resolvePromise(value);
|
||||
}
|
||||
|
||||
reject(reason?: unknown): void {
|
||||
if (isTerminal(this._state)) return;
|
||||
this._transition('failed');
|
||||
this._rejectPromise(reason);
|
||||
}
|
||||
|
||||
cancel(): void {
|
||||
if (isTerminal(this._state)) return;
|
||||
this._transition('cancelled');
|
||||
this._rejectPromise(new TaskCancelledError(this.id));
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
if (this._disposed) return;
|
||||
this._disposed = true;
|
||||
markAsDisposed(this);
|
||||
this.cancel();
|
||||
this._onDidChangeState.dispose();
|
||||
this._onDidOutput.dispose();
|
||||
}
|
||||
|
||||
private _transition(to: TaskState): void {
|
||||
if (isTerminal(this._state)) return;
|
||||
this._state = to;
|
||||
if (!this._disposed) {
|
||||
this._onDidChangeState.fire(to);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class TaskService extends Disposable implements ITaskService {
|
||||
declare readonly _serviceBrand: undefined;
|
||||
private _nextId = 0;
|
||||
|
||||
run<T>(fn: (signal: AbortSignal, output: (data: string) => void) => Promise<T>): ITaskHandle<T> {
|
||||
return new RunHandle<T>(this._generateId(), fn);
|
||||
}
|
||||
|
||||
defer<T>(): IDeferredHandle<T> {
|
||||
return new DeferHandle<T>(this._generateId());
|
||||
}
|
||||
|
||||
private _generateId(): string {
|
||||
return `task-${this._nextId++}`;
|
||||
}
|
||||
}
|
||||
|
||||
registerScopedService(
|
||||
LifecycleScope.App,
|
||||
ITaskService,
|
||||
TaskService,
|
||||
InstantiationType.Delayed,
|
||||
'task',
|
||||
);
|
||||
|
|
@ -9,7 +9,7 @@
|
|||
import { randomUUID } from 'node:crypto';
|
||||
import { arch, platform, release } from 'node:os';
|
||||
|
||||
import type { IStorageService } from '#/app/storage';
|
||||
import type { IFileSystemStorageService } from '#/app/storage';
|
||||
|
||||
import type { ITelemetryAppender, TelemetryContextPatch, TelemetryProperties } from './telemetry';
|
||||
import {
|
||||
|
|
@ -22,7 +22,7 @@ import {
|
|||
} from './cloudTransport';
|
||||
|
||||
export interface CloudAppenderOptions {
|
||||
readonly storage: IStorageService;
|
||||
readonly storage: IFileSystemStorageService;
|
||||
readonly deviceId: string;
|
||||
readonly sessionId?: string;
|
||||
readonly appName: string;
|
||||
|
|
|
|||
|
|
@ -2,13 +2,13 @@
|
|||
* `telemetry` domain (L1) — `CloudTransport`, the HTTP transport behind
|
||||
* `CloudAppender`. Posts enriched events to the telemetry endpoint with Bearer
|
||||
* auth, retry, and a byte-store fallback for failed events, persisted through
|
||||
* the `storage` byte layer (`IStorageService`) under the `telemetry` scope.
|
||||
* the `storage` byte layer (`IFileSystemStorageService`) under the `telemetry` scope.
|
||||
* App-scoped; independent of `@moonshot-ai/kimi-telemetry`.
|
||||
*/
|
||||
|
||||
import { randomBytes } from 'node:crypto';
|
||||
|
||||
import type { IStorageService } from '#/app/storage';
|
||||
import type { IFileSystemStorageService } from '#/app/storage';
|
||||
|
||||
export type CloudPrimitive = boolean | number | string | undefined | null;
|
||||
|
||||
|
|
@ -35,7 +35,7 @@ export interface CloudPayload {
|
|||
}
|
||||
|
||||
export interface CloudTransportOptions {
|
||||
readonly storage: IStorageService;
|
||||
readonly storage: IFileSystemStorageService;
|
||||
readonly deviceId: string;
|
||||
readonly endpoint?: string;
|
||||
readonly getAccessToken?: () => string | null | Promise<string | null>;
|
||||
|
|
@ -61,7 +61,7 @@ const textEncoder = new TextEncoder();
|
|||
const textDecoder = new TextDecoder();
|
||||
|
||||
export class CloudTransport {
|
||||
private readonly storage: IStorageService;
|
||||
private readonly storage: IFileSystemStorageService;
|
||||
private readonly deviceId: string;
|
||||
private readonly endpoint: string;
|
||||
private readonly getAccessToken: (() => string | null | Promise<string | null>) | null;
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
/**
|
||||
* `workspaceRegistry` domain (L1) — `FileWorkspaceStore` implementation.
|
||||
* `workspaceRegistry` domain (L1) — `FileWorkspacePersistence` implementation.
|
||||
*
|
||||
* File backend of `IWorkspaceStore`. Persists the catalog as a single
|
||||
* File backend of `IWorkspacePersistence`. Persists the catalog as a single
|
||||
* v1-compatible `workspaces.json` document at the storage root
|
||||
* (`<homeDir>/workspaces.json`, via `scope = ''`) through the
|
||||
* `IAtomicDocumentStore` access-pattern Store. Bound at App scope.
|
||||
|
|
@ -13,10 +13,10 @@ import { IAtomicDocumentStore } from '#/app/storage';
|
|||
|
||||
import type { Workspace } from './workspaceRegistry';
|
||||
import {
|
||||
IWorkspaceStore,
|
||||
IWorkspacePersistence,
|
||||
type PersistedWorkspaceEntry,
|
||||
type PersistedWorkspaceFile,
|
||||
} from './workspaceStore';
|
||||
} from './workspacePersistence';
|
||||
|
||||
const WORKSPACE_REGISTRY_VERSION = 1;
|
||||
// Empty scope resolves to `<homeDir>/<key>` (join skips empty segments),
|
||||
|
|
@ -24,7 +24,7 @@ const WORKSPACE_REGISTRY_VERSION = 1;
|
|||
const WORKSPACE_REGISTRY_SCOPE = '';
|
||||
const WORKSPACE_REGISTRY_KEY = 'workspaces.json';
|
||||
|
||||
export class FileWorkspaceStore implements IWorkspaceStore {
|
||||
export class FileWorkspacePersistence implements IWorkspacePersistence {
|
||||
declare readonly _serviceBrand: undefined;
|
||||
|
||||
constructor(@IAtomicDocumentStore private readonly docs: IAtomicDocumentStore) {}
|
||||
|
|
@ -105,8 +105,8 @@ function parseTime(value: string, fallback: number): number {
|
|||
|
||||
registerScopedService(
|
||||
LifecycleScope.App,
|
||||
IWorkspaceStore,
|
||||
FileWorkspaceStore,
|
||||
IWorkspacePersistence,
|
||||
FileWorkspacePersistence,
|
||||
InstantiationType.Delayed,
|
||||
'workspaceRegistry',
|
||||
);
|
||||
|
|
@ -7,5 +7,5 @@
|
|||
|
||||
export * from './workspaceRegistry';
|
||||
export * from './workspaceRegistryService';
|
||||
export * from './workspaceStore';
|
||||
export * from './fileWorkspaceStore';
|
||||
export * from './workspacePersistence';
|
||||
export * from './fileWorkspacePersistence';
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
/**
|
||||
* `workspaceRegistry` domain (L1) — `IWorkspaceStore` contract.
|
||||
* `workspaceRegistry` domain (L1) — `IWorkspacePersistence` contract.
|
||||
*
|
||||
* Domain-specific persistence Store for the known-workspaces catalog. It hides
|
||||
* the on-disk document layout (`<homeDir>/workspaces.json`, the v1-compatible
|
||||
|
|
@ -30,7 +30,7 @@ export interface PersistedWorkspaceFile {
|
|||
readonly workspaces: Record<string, PersistedWorkspaceEntry>;
|
||||
}
|
||||
|
||||
export interface IWorkspaceStore {
|
||||
export interface IWorkspacePersistence {
|
||||
readonly _serviceBrand: undefined;
|
||||
|
||||
/**
|
||||
|
|
@ -46,5 +46,5 @@ export interface IWorkspaceStore {
|
|||
save(workspaces: readonly Workspace[]): Promise<void>;
|
||||
}
|
||||
|
||||
export const IWorkspaceStore: ServiceIdentifier<IWorkspaceStore> =
|
||||
createDecorator<IWorkspaceStore>('workspaceStore');
|
||||
export const IWorkspacePersistence: ServiceIdentifier<IWorkspacePersistence> =
|
||||
createDecorator<IWorkspacePersistence>('workspacePersistence');
|
||||
|
|
@ -2,7 +2,7 @@
|
|||
* `workspaceRegistry` domain (L1) — `IWorkspaceRegistry` implementation.
|
||||
*
|
||||
* Process-wide catalog of known workspaces, now durable: an in-memory cache
|
||||
* is loaded once from `IWorkspaceStore` (`<homeDir>/workspaces.json`, v1
|
||||
* is loaded once from `IWorkspacePersistence` (`<homeDir>/workspaces.json`, v1
|
||||
* compatible) and every mutation writes back through it. When the catalog is
|
||||
* absent or malformed, it is rebuilt once from the legacy
|
||||
* `<homeDir>/session_index.jsonl` (one workspace per distinct absolute
|
||||
|
|
@ -16,10 +16,10 @@ import { basename, isAbsolute } from 'pathe';
|
|||
import { InstantiationType } from '#/_base/di/extensions';
|
||||
import { LifecycleScope, registerScopedService } from '#/_base/di/scope';
|
||||
import { encodeWorkDirKey } from '#/_base/utils/workdir-slug';
|
||||
import { IStorageService } from '#/app/storage';
|
||||
import { IFileSystemStorageService } from '#/app/storage';
|
||||
|
||||
import { IWorkspaceRegistry, type Workspace, type WorkspaceUpdate } from './workspaceRegistry';
|
||||
import { IWorkspaceStore } from './workspaceStore';
|
||||
import { IWorkspacePersistence } from './workspacePersistence';
|
||||
|
||||
// Legacy v1 session index, read only for the one-shot rebuild. Empty scope
|
||||
// resolves to `<homeDir>/<key>` (join skips empty segments).
|
||||
|
|
@ -42,8 +42,8 @@ export class WorkspaceRegistryService implements IWorkspaceRegistry {
|
|||
private opQueue: Promise<unknown> = Promise.resolve();
|
||||
|
||||
constructor(
|
||||
@IWorkspaceStore private readonly store: IWorkspaceStore,
|
||||
@IStorageService private readonly storage: IStorageService,
|
||||
@IWorkspacePersistence private readonly store: IWorkspacePersistence,
|
||||
@IFileSystemStorageService private readonly storage: IFileSystemStorageService,
|
||||
) {}
|
||||
|
||||
list(): Promise<readonly Workspace[]> {
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ import { AuthErrors } from '#/app/auth/errors';
|
|||
import { BackgroundErrors } from '#/agent/background/errors';
|
||||
import { ChatProviderErrors } from '#/app/protocol/errors';
|
||||
import { ConfigErrors } from '#/app/config/errors';
|
||||
import { FileErrors } from '#/persistence/interface/fileStore';
|
||||
import { FileErrors } from '#/app/file/fileService';
|
||||
import { FsErrors } from '#/session/agentFs/errors';
|
||||
import { FullCompactionErrors } from '#/agent/fullCompaction/errors';
|
||||
import { GoalErrors } from '#/agent/goal/errors';
|
||||
|
|
@ -37,7 +37,7 @@ export { AuthErrors } from '#/app/auth/errors';
|
|||
export { BackgroundErrors } from '#/agent/background/errors';
|
||||
export { ChatProviderErrors } from '#/app/protocol/errors';
|
||||
export { ConfigErrors } from '#/app/config/errors';
|
||||
export { FileErrors } from '#/persistence/interface/fileStore';
|
||||
export { FileErrors } from '#/app/file/fileService';
|
||||
export { FsErrors } from '#/session/agentFs/errors';
|
||||
export { FullCompactionErrors } from '#/agent/fullCompaction/errors';
|
||||
export { GoalErrors } from '#/agent/goal/errors';
|
||||
|
|
|
|||
|
|
@ -12,6 +12,8 @@ export * from '#/app/telemetry';
|
|||
export * from '#/app/bootstrap';
|
||||
export * from '#/os/interface';
|
||||
export * from '#/os/backends/node-local';
|
||||
export * from '#/session/terminal';
|
||||
export * from '#/app/task';
|
||||
export { IEventService, type DomainEvent } from '#/app/event';
|
||||
export * from '#/app/llmProtocol';
|
||||
|
||||
|
|
@ -42,6 +44,8 @@ export * from '#/agent/usage';
|
|||
export * from '#/agent/toolDedupe';
|
||||
|
||||
export * from '#/agent/background';
|
||||
export * from '#/app/cronPersistence';
|
||||
export * from '#/session/cron';
|
||||
import '#/agent/cron';
|
||||
|
||||
export * from '#/session/agentLifecycle';
|
||||
|
|
@ -60,15 +64,19 @@ export * from '#/app/gateway';
|
|||
|
||||
export * from '#/session/workspaceContext';
|
||||
export * from '#/app/workspaceRegistry';
|
||||
export * from '#/session/execContext';
|
||||
export * from '#/session/process';
|
||||
export * from '#/session/agentFs';
|
||||
export * from '#/app/hostFolderBrowser';
|
||||
export * from '#/persistence/interface';
|
||||
export * from '#/persistence/backends/node-fs';
|
||||
export * from '#/persistence/backends/memory';
|
||||
export * from '#/app/auth';
|
||||
export * from '#/app/authLegacy';
|
||||
export * from '#/app/file';
|
||||
|
||||
// Ported agent services. These keep the current service boundaries during the migration.
|
||||
export * from '#/agent/blobStore';
|
||||
export * from '#/agent/blob';
|
||||
export * from '#/agent/contextMemory';
|
||||
export * from '#/agent/systemReminder';
|
||||
export * from '#/agent/contextProjector';
|
||||
|
|
@ -103,7 +111,7 @@ export {
|
|||
registerTool,
|
||||
} from '#/agent/toolRegistry';
|
||||
export type { ToolContribution, ToolContributionOptions } from '#/agent/toolRegistry';
|
||||
export * from '#/agent/toolStore';
|
||||
export * from '#/agent/toolState';
|
||||
export * from '#/agent/userTool';
|
||||
export * from '#/agent/wireRecord';
|
||||
export * from '#/agent/fileTools';
|
||||
|
|
|
|||
|
|
@ -0,0 +1,197 @@
|
|||
/**
|
||||
* `hostProcess` domain (L6) — `IHostProcessService` node-local implementation.
|
||||
*
|
||||
* Spawns child processes with `node:child_process.spawn`, wraps them in the
|
||||
* domain-facing `IHostProcess` handle, and provides cross-platform process-tree
|
||||
* termination. The service itself is stateless; each `spawn()` returns an
|
||||
* independent handle that owns its streams and exit promise. Bound at App scope.
|
||||
*/
|
||||
|
||||
import { spawn, type ChildProcess, type SpawnOptions } from 'node:child_process';
|
||||
import type { Readable, Writable } from 'node:stream';
|
||||
|
||||
import { BufferedReadable } from '#/_base/execEnv';
|
||||
import { InstantiationType } from '#/_base/di/extensions';
|
||||
import { LifecycleScope, registerScopedService } from '#/_base/di/scope';
|
||||
|
||||
import {
|
||||
HostProcessError,
|
||||
HostProcessErrorCode,
|
||||
IHostProcessService,
|
||||
type HostProcessOptions,
|
||||
type IHostProcess,
|
||||
} from '#/os/interface/hostProcess';
|
||||
|
||||
const isWindows: boolean = process.platform === 'win32';
|
||||
|
||||
function buildSpawnOptions(options: HostProcessOptions): SpawnOptions {
|
||||
const detached = options.detached ?? !isWindows;
|
||||
const spawnOptions: SpawnOptions = {
|
||||
cwd: options.cwd,
|
||||
env: buildEnv(options.env),
|
||||
stdio: options.mergeStderr ? ['pipe', 'pipe', 'pipe'] : ['pipe', 'pipe', 'pipe'],
|
||||
detached,
|
||||
windowsHide: options.windowsHide ?? true,
|
||||
};
|
||||
|
||||
if (options.shell !== undefined) {
|
||||
spawnOptions.shell = options.shell;
|
||||
}
|
||||
|
||||
return spawnOptions;
|
||||
}
|
||||
|
||||
function buildEnv(overrides: Record<string, string> | undefined): Record<string, string> | undefined {
|
||||
if (overrides === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
return { ...(process.env as Record<string, string>), ...overrides };
|
||||
}
|
||||
|
||||
function waitForSpawn(child: ChildProcess): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const onSpawn = (): void => {
|
||||
child.off('error', onError);
|
||||
resolve();
|
||||
};
|
||||
const onError = (err: Error): void => {
|
||||
child.off('spawn', onSpawn);
|
||||
reject(err);
|
||||
};
|
||||
child.once('spawn', onSpawn);
|
||||
child.once('error', onError);
|
||||
});
|
||||
}
|
||||
|
||||
class HostProcess implements IHostProcess {
|
||||
declare readonly _serviceBrand: undefined;
|
||||
|
||||
readonly stdin: Writable;
|
||||
readonly stdout: Readable;
|
||||
readonly stderr: Readable;
|
||||
readonly pid: number;
|
||||
|
||||
private readonly _child: ChildProcess;
|
||||
private _exitCode: number | null = null;
|
||||
private readonly _exitPromise: Promise<number>;
|
||||
private _disposed = false;
|
||||
|
||||
constructor(child: ChildProcess, mergeStderr: boolean) {
|
||||
if (child.stdin === null || child.stdout === null) {
|
||||
throw new HostProcessError(
|
||||
HostProcessErrorCode.SpawnFailed,
|
||||
'Process must be created with stdin/stdout pipes.',
|
||||
);
|
||||
}
|
||||
if (!mergeStderr && child.stderr === null) {
|
||||
throw new HostProcessError(
|
||||
HostProcessErrorCode.SpawnFailed,
|
||||
'Process must be created with stderr pipe unless mergeStderr is set.',
|
||||
);
|
||||
}
|
||||
|
||||
this._child = child;
|
||||
this.stdin = child.stdin;
|
||||
this.stdout = new BufferedReadable(child.stdout);
|
||||
this.stderr = mergeStderr
|
||||
? this.stdout
|
||||
: new BufferedReadable(child.stderr as Readable);
|
||||
this.pid = child.pid ?? -1;
|
||||
|
||||
this._exitPromise = new Promise<number>((resolve, reject) => {
|
||||
child.on('exit', (code: number | null) => {
|
||||
this._exitCode = code ?? -1;
|
||||
resolve(this._exitCode);
|
||||
});
|
||||
child.on('error', (error: Error) => {
|
||||
reject(error);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
get exitCode(): number | null {
|
||||
return this._exitCode;
|
||||
}
|
||||
|
||||
async wait(): Promise<number> {
|
||||
return this._exitPromise;
|
||||
}
|
||||
|
||||
async kill(signal?: NodeJS.Signals): Promise<void> {
|
||||
if (this.pid <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isWindows) {
|
||||
const taskkillArgs = ['/T', '/F', '/PID', String(this.pid)];
|
||||
return new Promise<void>((resolve) => {
|
||||
const killer = spawn('taskkill', taskkillArgs, {
|
||||
stdio: 'ignore',
|
||||
windowsHide: true,
|
||||
});
|
||||
const done = (): void => {
|
||||
resolve();
|
||||
};
|
||||
killer.once('error', done);
|
||||
killer.once('close', done);
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
process.kill(-this.pid, signal ?? 'SIGTERM');
|
||||
} catch (error) {
|
||||
const err = error as NodeJS.ErrnoException;
|
||||
if (err.code === 'ESRCH') return;
|
||||
if (err.code === 'EPERM') {
|
||||
try {
|
||||
this._child.kill(signal ?? 'SIGTERM');
|
||||
} catch {
|
||||
/* best effort */
|
||||
}
|
||||
return;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
if (this._disposed) return;
|
||||
this._disposed = true;
|
||||
this.stdin.destroy();
|
||||
this.stdout.destroy();
|
||||
if (this.stderr !== this.stdout) {
|
||||
this.stderr.destroy();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class HostProcessService implements IHostProcessService {
|
||||
declare readonly _serviceBrand: undefined;
|
||||
|
||||
async spawn(
|
||||
command: string,
|
||||
args: readonly string[] = [],
|
||||
options: HostProcessOptions = {},
|
||||
): Promise<IHostProcess> {
|
||||
const spawnOptions = buildSpawnOptions(options);
|
||||
const child = spawn(command, args as string[], spawnOptions);
|
||||
try {
|
||||
await waitForSpawn(child);
|
||||
} catch (error) {
|
||||
const err = error as NodeJS.ErrnoException;
|
||||
throw new HostProcessError(
|
||||
HostProcessErrorCode.SpawnFailed,
|
||||
`Failed to spawn "${command}": ${err.message}`,
|
||||
);
|
||||
}
|
||||
return new HostProcess(child, options.mergeStderr ?? false);
|
||||
}
|
||||
}
|
||||
|
||||
registerScopedService(
|
||||
LifecycleScope.App,
|
||||
IHostProcessService,
|
||||
HostProcessService,
|
||||
InstantiationType.Delayed,
|
||||
'hostProcess',
|
||||
);
|
||||
|
|
@ -0,0 +1,66 @@
|
|||
/**
|
||||
* `terminal` domain (L6) — `IHostTerminalService` implementation.
|
||||
*
|
||||
* App-scoped OS terminal process factory backed by `node-pty`. It spawns and
|
||||
* tracks every `TerminalProcess` so the whole process-wide PTY layer can be
|
||||
* torn down on disposal. It has no session, workspace, or buffering concerns;
|
||||
* those live in the Session-scoped `ISessionTerminalService`.
|
||||
*
|
||||
* `node-pty` is loaded lazily so merely importing this module (for example in
|
||||
* tests that override the service with a fake) does not require the native
|
||||
* module to be built or resolvable.
|
||||
*/
|
||||
|
||||
import type { IPty } from 'node-pty';
|
||||
|
||||
import { InstantiationType } from '#/_base/di/extensions';
|
||||
import { Disposable } from '#/_base/di/lifecycle';
|
||||
import { LifecycleScope, registerScopedService } from '#/_base/di/scope';
|
||||
|
||||
import { IHostTerminalService, type TerminalProcess, type TerminalSpawnOptions } from '#/os/interface/terminal';
|
||||
|
||||
export class HostTerminalService extends Disposable implements IHostTerminalService {
|
||||
declare readonly _serviceBrand: undefined;
|
||||
|
||||
private readonly processes = new Set<TerminalProcess>();
|
||||
|
||||
async spawn(options: TerminalSpawnOptions): Promise<TerminalProcess> {
|
||||
const pty = await import('node-pty');
|
||||
const proc: IPty = pty.spawn(options.shell, [], {
|
||||
name: 'xterm-256color',
|
||||
cwd: options.cwd,
|
||||
cols: options.cols,
|
||||
rows: options.rows,
|
||||
env: globalThis.process.env,
|
||||
});
|
||||
const terminalProcess: TerminalProcess = {
|
||||
onData: (listener) => proc.onData(listener),
|
||||
onExit: (listener) => proc.onExit((event) => listener({ exitCode: event.exitCode })),
|
||||
write: (data) => proc.write(data),
|
||||
resize: (cols, rows) => proc.resize(cols, rows),
|
||||
kill: () => proc.kill(),
|
||||
};
|
||||
this.processes.add(terminalProcess);
|
||||
return terminalProcess;
|
||||
}
|
||||
|
||||
override dispose(): void {
|
||||
for (const process of this.processes) {
|
||||
try {
|
||||
process.kill();
|
||||
} catch {
|
||||
// best-effort cleanup
|
||||
}
|
||||
}
|
||||
this.processes.clear();
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
registerScopedService(
|
||||
LifecycleScope.App,
|
||||
IHostTerminalService,
|
||||
HostTerminalService,
|
||||
InstantiationType.Delayed,
|
||||
'terminal',
|
||||
);
|
||||
|
|
@ -1,7 +1,4 @@
|
|||
export * from './hostEnvironmentService';
|
||||
export * from './hostFsService';
|
||||
export * from './agentFsService';
|
||||
export * from './processRunnerService';
|
||||
export * from './terminalBackend';
|
||||
export * from './terminalService';
|
||||
export * from './folderBrowserService';
|
||||
export * from './hostProcessService';
|
||||
export * from './hostTerminalService';
|
||||
|
|
|
|||
|
|
@ -1,161 +0,0 @@
|
|||
/**
|
||||
* `process` domain (L1) — spawned-process primitives.
|
||||
*
|
||||
* Vendored from the former `@moonshot-ai/kaos` `LocalProcess`. `SpawnedProcess`
|
||||
* wraps a Node `ChildProcess` into the domain-facing `IProcess` handle, and
|
||||
* `buildLocalSpawnOptions` / `waitForSpawn` are the two spawn-time helpers used
|
||||
* by the session process runner. Kept out of the runner file so the runner
|
||||
* only orchestrates cwd/env resolution and delegates the lifetime plumbing
|
||||
* here.
|
||||
*/
|
||||
|
||||
import { spawn, type ChildProcess, type SpawnOptions } from 'node:child_process';
|
||||
import type { Readable, Writable } from 'node:stream';
|
||||
|
||||
import { BufferedReadable } from '#/_base/execEnv';
|
||||
|
||||
import type { IProcess } from '#/os/interface/process';
|
||||
|
||||
export const isWindows: boolean = process.platform === 'win32';
|
||||
|
||||
export function buildLocalSpawnOptions(
|
||||
isWindowsHost: boolean,
|
||||
cwd: string,
|
||||
env: Record<string, string> | undefined,
|
||||
): SpawnOptions {
|
||||
return {
|
||||
cwd,
|
||||
env,
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
detached: !isWindowsHost,
|
||||
windowsHide: true,
|
||||
};
|
||||
}
|
||||
|
||||
// Wait for a freshly spawned ChildProcess to either emit 'spawn' (success) or
|
||||
// 'error' (ENOENT / EACCES / etc.). Until this resolves, callers should not
|
||||
// assume the child is running — they may otherwise write to the stdin of a
|
||||
// process that never existed.
|
||||
export function waitForSpawn(child: ChildProcess): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const onSpawn = (): void => {
|
||||
child.off('error', onError);
|
||||
resolve();
|
||||
};
|
||||
const onError = (err: Error): void => {
|
||||
child.off('spawn', onSpawn);
|
||||
reject(err);
|
||||
};
|
||||
child.once('spawn', onSpawn);
|
||||
child.once('error', onError);
|
||||
});
|
||||
}
|
||||
|
||||
export class SpawnedProcess implements IProcess {
|
||||
readonly stdin: Writable;
|
||||
readonly stdout: Readable;
|
||||
readonly stderr: Readable;
|
||||
readonly pid: number;
|
||||
|
||||
private readonly _child: ChildProcess;
|
||||
private _exitCode: number | null = null;
|
||||
private readonly _exitPromise: Promise<number>;
|
||||
private _disposed = false;
|
||||
|
||||
constructor(child: ChildProcess) {
|
||||
if (child.stdin === null || child.stdout === null || child.stderr === null) {
|
||||
throw new Error('Process must be created with stdin/stdout/stderr pipes.');
|
||||
}
|
||||
|
||||
this._child = child;
|
||||
this.stdin = child.stdin;
|
||||
this.stdout = new BufferedReadable(child.stdout);
|
||||
this.stderr = new BufferedReadable(child.stderr);
|
||||
this.pid = child.pid ?? -1;
|
||||
|
||||
this._exitPromise = new Promise<number>((resolve, reject) => {
|
||||
child.on('exit', (code: number | null) => {
|
||||
this._exitCode = code ?? -1;
|
||||
resolve(this._exitCode);
|
||||
});
|
||||
child.on('error', (error: Error) => {
|
||||
reject(error);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
get exitCode(): number | null {
|
||||
return this._exitCode;
|
||||
}
|
||||
|
||||
async wait(): Promise<number> {
|
||||
return this._exitPromise;
|
||||
}
|
||||
|
||||
kill(signal?: NodeJS.Signals): Promise<void> {
|
||||
// Reject if the process never actually started (spawn failed).
|
||||
// pid <= 0 indicates ChildProcess.pid was undefined, which happens
|
||||
// when spawn() fails to find/execute the command. Calling
|
||||
// process.kill(-1, ...) on POSIX would signal the entire process
|
||||
// group, potentially killing unrelated processes.
|
||||
if (this.pid <= 0) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
// On Windows, `ChildProcess.kill()` only signals the shell parent, leaving
|
||||
// grandchildren alive, so terminate the whole process tree with
|
||||
// `taskkill /T`. A graceful `taskkill /T` (no `/F`) does not actually
|
||||
// terminate a console node.exe tree, and Windows has no real graceful
|
||||
// signal for it — Node's own `ChildProcess.kill()` is always a forceful
|
||||
// TerminateProcess on Windows — so always force-terminate the tree.
|
||||
if (isWindows) {
|
||||
const taskkillArgs = ['/T', '/F', '/PID', String(this.pid)];
|
||||
return new Promise<void>((resolve) => {
|
||||
const killer = spawn('taskkill', taskkillArgs, {
|
||||
stdio: 'ignore',
|
||||
windowsHide: true,
|
||||
});
|
||||
const done = (): void => {
|
||||
resolve();
|
||||
};
|
||||
killer.once('error', done);
|
||||
killer.once('close', done);
|
||||
});
|
||||
}
|
||||
|
||||
// On POSIX, `detached:true` makes the child a process-group leader
|
||||
// (pgid === pid). A plain `ChildProcess.kill()` still only signals the
|
||||
// direct child, so a shell like `bash -c 'sleep 100 & sleep 100'` leaves
|
||||
// grandchildren orphaned. `process.kill(-pid, signal)` signals the group
|
||||
// (negative pid = process-group id under POSIX kill(2)).
|
||||
try {
|
||||
process.kill(-this.pid, signal ?? 'SIGTERM');
|
||||
} catch (error) {
|
||||
const err = error as NodeJS.ErrnoException;
|
||||
// ESRCH = group already gone (child exited + reaped between
|
||||
// `wait()` racing spawn + this call). Treat as successful kill.
|
||||
if (err.code === 'ESRCH') return Promise.resolve();
|
||||
// EPERM is typically a misconfiguration (e.g. non-detached
|
||||
// spawn earlier in the file); fall back to direct `.kill()` so
|
||||
// we at least signal the direct child instead of throwing.
|
||||
if (err.code === 'EPERM') {
|
||||
try {
|
||||
this._child.kill(signal ?? 'SIGTERM');
|
||||
} catch {
|
||||
/* best effort */
|
||||
}
|
||||
return Promise.resolve();
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
if (this._disposed) return;
|
||||
this._disposed = true;
|
||||
this.stdin.destroy();
|
||||
this.stdout.destroy();
|
||||
this.stderr.destroy();
|
||||
}
|
||||
}
|
||||
|
|
@ -1,31 +0,0 @@
|
|||
/**
|
||||
* `terminal` domain (L6) — default `ISessionTerminalBackend` stub.
|
||||
*
|
||||
* Placeholder backend registered so the binding graph is complete and
|
||||
* `ISessionTerminalService` resolves out of the box. It cannot spawn a real PTY; a
|
||||
* composition root that needs interactive terminals (for example the server
|
||||
* or the desktop app, both of which already depend on `node-pty`) supplies a
|
||||
* real backend through the scope registry to override this one.
|
||||
*/
|
||||
|
||||
import { InstantiationType } from '#/_base/di/extensions';
|
||||
import { NotImplementedError } from '#/_base/errors';
|
||||
import { LifecycleScope, registerScopedService } from '#/_base/di/scope';
|
||||
|
||||
import { type TerminalProcess, type TerminalSpawnOptions, ISessionTerminalBackend } from '#/os/interface/terminal';
|
||||
|
||||
export class SessionNotImplementedTerminalBackend implements ISessionTerminalBackend {
|
||||
declare readonly _serviceBrand: undefined;
|
||||
|
||||
spawn(_options: TerminalSpawnOptions): Promise<TerminalProcess> {
|
||||
throw new NotImplementedError('terminalBackend');
|
||||
}
|
||||
}
|
||||
|
||||
registerScopedService(
|
||||
LifecycleScope.Session,
|
||||
ISessionTerminalBackend,
|
||||
SessionNotImplementedTerminalBackend,
|
||||
InstantiationType.Delayed,
|
||||
'terminal',
|
||||
);
|
||||
84
packages/agent-core-v2/src/os/interface/hostProcess.ts
Normal file
84
packages/agent-core-v2/src/os/interface/hostProcess.ts
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
/**
|
||||
* `hostProcess` domain (L1) — the OS process-spawning contract.
|
||||
*
|
||||
* Defines `IHostProcessService`, the App-scope primitive used by any domain that
|
||||
* needs to spawn a child process on the host, plus the `IHostProcess` handle it
|
||||
* returns. The contract is deliberately close to Python `subprocess.Popen` /
|
||||
* `os.spawn*`: a single `spawn()` call returns a handle exposing stdin/stdout/
|
||||
* stderr, the pid, the exit code, and lifecycle methods. Bound at App scope;
|
||||
* backends in `os/backends/node-local` provide the Node implementation.
|
||||
*/
|
||||
|
||||
import type { Readable, Writable } from 'node:stream';
|
||||
|
||||
import { KimiError, type ErrorCode } from '#/_base/errors';
|
||||
import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation';
|
||||
|
||||
export interface HostProcessOptions {
|
||||
/** Working directory for the child. Defaults to `process.cwd()`. */
|
||||
readonly cwd?: string;
|
||||
/** Complete env bag for the child. When omitted the child inherits `process.env`. */
|
||||
readonly env?: Record<string, string>;
|
||||
/**
|
||||
* If `true`, the command is run through the system shell. If a string, it is
|
||||
* used as the shell path. Mirrors Python `subprocess.run(..., shell=True)`.
|
||||
*/
|
||||
readonly shell?: boolean | string;
|
||||
/**
|
||||
* Whether the child becomes a process-group leader. Default is `true` on
|
||||
* POSIX and `false` on Windows so that `kill()` can signal the whole tree.
|
||||
*/
|
||||
readonly detached?: boolean;
|
||||
/** Hide the child window on Windows. Default `true`. */
|
||||
readonly windowsHide?: boolean;
|
||||
/** Redirect stderr into stdout (the child still gets a merged stream). */
|
||||
readonly mergeStderr?: boolean;
|
||||
/** Optional timeout in milliseconds for `wait()`. */
|
||||
readonly timeout?: number;
|
||||
}
|
||||
|
||||
export interface IHostProcess {
|
||||
readonly _serviceBrand: undefined;
|
||||
|
||||
readonly pid: number;
|
||||
readonly exitCode: number | null;
|
||||
readonly stdin: Writable;
|
||||
readonly stdout: Readable;
|
||||
readonly stderr: Readable;
|
||||
|
||||
/** Wait for the process to exit and return its exit code. */
|
||||
wait(): Promise<number>;
|
||||
/** Kill the process tree (not just the direct child) with the given signal. */
|
||||
kill(signal?: NodeJS.Signals): Promise<void>;
|
||||
/** Release stdio streams. Does not kill the process. */
|
||||
dispose(): void;
|
||||
}
|
||||
|
||||
export interface IHostProcessService {
|
||||
readonly _serviceBrand: undefined;
|
||||
|
||||
/**
|
||||
* Spawn a child process on the host. Resolves once the child has successfully
|
||||
* started (or rejects with a coded error if spawn fails with ENOENT / EACCES
|
||||
* / etc.).
|
||||
*/
|
||||
spawn(
|
||||
command: string,
|
||||
args?: readonly string[],
|
||||
options?: HostProcessOptions,
|
||||
): Promise<IHostProcess>;
|
||||
}
|
||||
|
||||
export const IHostProcessService: ServiceIdentifier<IHostProcessService> =
|
||||
createDecorator<IHostProcessService>('hostProcessService');
|
||||
|
||||
export const HostProcessErrorCode = {
|
||||
SpawnFailed: 'process.spawn_failed' as ErrorCode,
|
||||
} as const;
|
||||
|
||||
export class HostProcessError extends KimiError {
|
||||
constructor(code: (typeof HostProcessErrorCode)[keyof typeof HostProcessErrorCode], message: string) {
|
||||
super(code, message);
|
||||
this.name = 'HostProcessError';
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue