kimi-code/packages/klient/examples/context-usage.ts
Haozhe 64f053cf46
feat: agent-core-v2 permission/workspace refactors and transcript durability (#2021)
* refactor(agent-core-v2): extract toolApproval domain from permissionGate

- add agent-scoped `toolApproval` domain owning the approval round-trip:
  builds approval requests, drives the session/approval broker, publishes
  permission.approval.* events, records session approval rules, and
  resolves ask continuations
- slim permissionPolicy down to the static risk-adjudication chain; drop
  the dynamic `registerPolicy` mechanism
- move harness constraints out of the policy chain into their owning
  domains as toolExecutor hooks ordered before 'permission': plan-mode
  guard (plan), swarm batch exclusivity and AgentSwarm approve (swarm),
  goal-start review (goal), exit-plan review (plan/exitPlanModeReview),
  and btw deny (session/btw)
- delete the now-unused policies (deny-all, plan-mode-guard-deny,
  plan-mode-tool-approve, goal-start-review-ask, swarm-mode-agent-swarm-
  approve, agent-swarm-exclusive-deny)
- update Permission.md, AGENTS.md, and check-domain-layers for the new
  domain layout

* feat(kimi-inspect): add App Services view for app-scope service reflection

- add `services` top-level view (`AppServicesView`) on the NavRail, showing
  the full-width app-scope Service panel grid; session/agent scopes stay in
  the Chat view's Inspector
- extract shared `ServicePanels` from Inspector and add `methodArgs` helper
  to build per-method argument editors from channel metadata
- support variadic service calls in `panels.ts` (`call(svc, method, ...args)`)
- update agent-core-dev skill docs for the toolApproval extraction and the
  guard/review-off-chain permission design

* refactor(agent-core-v2): restructure workspace domains

- rename workspaceRegistry to workspace and workspaceLocalConfig to
  projectLocalConfig
- extract id-spelling resolution into the workspaceAliases domain
  (IWorkspaceAliases)
- extract workspace-centric session queries into workspaceSessions
  (IWorkspaceSessions)
- update kap-server routes, klient contracts, and related tests to the
  new services

* feat(transcript): add op-batch sequencing and point-to-point catch-up

- wire: transcriptSeqSchema with per-agent batch seq watermark on
  transcript.reset/ops and the REST transcript response, the
  transcript_since subscription cursor, and the GET transcript/ops
  catch-up shape; seq stays optional everywhere so legacy peers fall
  back to loss-signal-driven refreshes
- wire: drop interactionFrame, interactions live on the interaction
  item in the ops stream
- kap-server: TranscriptService assigns consecutive per-agent batch
  seqs and retains them in a bounded in-memory journal; the
  transcript_since cursor replays journaled batches instead of a
  baseline reset, and the baseline reset is now items-empty because
  history always pages in over REST
- kimi-inspect: transcript REST/WS clients track the op-batch
  watermark and run seq-gap/reconnect catch-up with full-refresh
  fallback; add the Transcript audit panel (AuditTrail timeline,
  structural diff, state tree) docked right of the chat
- docs: sync AGENTS.md and agent-core-dev skill notes with the new
  transcript contract

* feat(transcript): persist plan revisions and task/interaction facts, add user-messages endpoint

- record each ExitPlanMode submission as a versioned plan blob via a reference-only plan.revision op, projected live and cold as a plan.revision marker plus the plan badge (reviewPath, version)
- persist task.started/task.terminated (with a bounded output tail) and interaction.request/interaction.resolved ops, and add foldFacts so a cold transcript rebuilds tasks, interactions, todos and goal/plan/swarm meta from the wire journal
- add GET /sessions/{session_id}/transcript/user-messages returning all turn-opening prompts grouped per agent

* fix(agent-core-v2): anchor external PreToolUse hooks before the permission gate

The toolApproval extraction forces the gate to construct early (planService injects it to anchor plan-guard), so the 'permission' hook registered ahead of 'externalHooks' and a policy ask waited on the approval broker before PreToolUse could block, hanging the turn. Fetch the gate first in registerListeners and register the PreToolUse hook with before: 'permission', falling back to appending when the gate is stubbed without its hook.

* refactor(agent-core-v2): replace ordered onBeforeExecuteTool hook with veto-event pattern

- introduce BeforeToolExecuteEvent with veto/allow/pass/waitUntil statements
- add BeforeToolExecuteEmitter with two-pass fire (immediate then deferred)
- split readiness work into separate onWillExecuteTool participation event
- migrate all domain listeners: permissionGate, plan, goal, swarm, btw,
  externalHooks, toolDedupe, mcp
- remove IAgentPermissionGate force-injection for hook ordering
- update docs, tests, and domain-layer check to match

* refactor(agent-core-v2): unify veto payload on ExecutableToolResult

- veto() and waitUntil factories now carry a plain ExecutableToolResult:
  isError reads as a denial, anything else as a short-circuit;
  the block/reason/syntheticResult weak union is gone
- add denyToolExecution(reason) helper for the common denial shape, and
  narrow the fire/authorize return to BeforeExecuteDecision ({ veto } or
  { executionMetadata })
- narrow the policy 'result' resolution to { kind: 'result'; result }
- settle vetoed calls through a single normalize/merge path in the executor

* fix(agent-core-v2): pull up IAgentPermissionGate in agent activation

The permission gate only subscribes `onBeforeExecuteTool` from its
constructor. The veto-event refactor removed the ordering-driven
force-injections that used to pull it up, so without an explicit
resolution tool execution would run without policy adjudication.

* refactor(agent-core-v2): fold systemReminder domain into contextMemory appendTagged

- add `appendTagged(content, tag, origin)` to `IAgentContextMemoryService`,
  storing content pure with a `tag` field on `ContextMessage`
- apply the XML tag at projection time in `contextProjector` via the new
  `tag.ts` helpers (`wrapTag` / `applyTagToContent`)
- delete the `systemReminder` domain and migrate all call sites
  (contextInjector, goal, plugin, prompt, swarm, btw, sessionInit,
  toolSelectAnnouncements) to `appendTagged`
- build toolDedupe reminder strings with `wrapTag`

* refactor(klient): merge providers/models/catalog into global.kosong facade

Converge three separate facade namespaces (global.providers,
global.models, global.catalog) into a single global.kosong facade
that exposes two domain concepts: provider (CRUD) and model
(read-only view). Add streaming generate() method for direct
LLM calls through the facade.

- Define ProviderAuth (api-key | oauth), ProviderInput,
  AnonymousProviderInput, GenerateInput, GenerateParams,
  GenerateEvent as klient-owned public types
- Extend KlientChannel with stream() for AsyncIterable transport
- Add streaming IPC protocol (stream/stream_data/stream_end/
  stream_error/stream_cancel frame types)
- Add StreamingProcedureContract, ScopedStreamCaller, and
  per-chunk zod validation in the contract layer
- Implement generate via dispatcher special-case routing to
  IModelCatalog.getRequester().request()
- Rename events: providers.changed -> kosong.providers.changed,
  models.changed -> kosong.models.changed,
  catalog.changed -> kosong.changed
- Remove GlobalProvidersFacade, GlobalModelsFacade,
  GlobalCatalogFacade, and ModelRecord from public exports
- Update all tests, examples, and README

BREAKING CHANGE: global.providers, global.models, and
global.catalog replaced by global.kosong; event names changed;
ModelRecord no longer exported.

* feat(transcript,kimi-inspect): add tag field to text frames and improve session creation

transcript:
- add optional `tag` field to TextFrame, textFrameSchema, and HistoryMessage
- propagate tag through contextTranscript MutableMessage

kimi-inspect:
- render tagged frames with violet badge and distinct styling in ChatView
- skip cwd prompt for workspace-based session creation in Sidebar
- auto-bind default model on new sessions via resolveDefaultModel

* refactor(transcript): rename wire/ directory to contract/

The transcript package's REST/WS schemas and event types lived in
src/wire/, which collided with the engine's persisted wire.jsonl
record vocabulary. Rename it to src/contract/ so "wire" unambiguously
refers to wire.jsonl records (foldWireRecordFacts, HistoryWireRecord
stay unchanged).

- rename src/wire/{schema,events}.ts to src/contract/
- update index exports and test imports accordingly
- reword comments: "wire shape" -> "contract shape", "on the wire" ->
  "in ops" / "in transit" / "on the WS channel" / "transcript API"
- events.ts: "transcript frame" -> "transcript event" for WS envelope
  messages, avoiding confusion with TranscriptFrame
- kap-server tests: TranscriptWire/TurnWire/FrameWire/OpsCatchupWire/
  UserMessagesWire -> *Contract
- update AGENTS.md references

* refactor(transcript): rename wire/ directory to contract/

The transcript package's REST/WS schemas and event types lived in
src/wire/, which collided with the engine's persisted wire.jsonl
record vocabulary. Rename it to src/contract/ so "wire" unambiguously
refers to wire.jsonl records (foldWireRecordFacts, HistoryWireRecord
stay unchanged).

- rename src/wire/{schema,events}.ts to src/contract/
- update index exports and test imports accordingly
- reword comments: "wire shape" -> "contract shape", "on the wire" ->
  "in ops" / "in transit" / "on the WS channel" / "transcript API"
- kap-server tests: TranscriptWire/TurnWire/FrameWire/OpsCatchupWire/
  UserMessagesWire -> *Contract
- update AGENTS.md references

* Revert "refactor(agent-core-v2): fold systemReminder domain into contextMemory appendTagged"

This reverts commit 55afaa3d96f729c4f73a71f1fbd23d3f6087453b.

Restore the systemReminder domain: reminders go back to being baked
into message text at write time, and ContextMessage loses the `tag`
field (projection-time wrapping is removed with it).

* feat(transcript): add wire-equivalent detail, dedupe session events

- transcript: add step usage/timing/retry, turn durationMs/error/usage,
  tool inputText/progress, task resultSummary/error, meta.agent status,
  a global prompts entity, and the 'hook' marker
- kap-server: project the new fields in coreEventMap and suppress
  transcript-projected session events on connections subscribed to the
  transcript protocol (live fan-out and cursor replay)
- kimi-inspect: mechanical type sync for the new snapshot prompts field

* fix(klient): resolve lint errors in ipc channel stream and e2e matrix test
2026-07-22 19:21:56 +08:00

206 lines
8.7 KiB
TypeScript

/**
* Trace how the reported "Context size" evolves on a brand-new session after
* a single "hi" prompt, against an in-process engine over the memory
* transport.
*
* What gets sampled, all through the klient facade:
* - `agent.getContext()` → `{ history, tokenCount }` — `tokenCount` is the
* last MEASURED exchange total (`contextSize.get().measured` engine-side);
* it is 0 until the first LLM response lands and stays flat between turns.
* - `agent.getUsage()` → accumulated token usage (`byModel` / `currentTurn`
* / `total`), recorded per request.
* - `agent.status.updated` events — the live `contextTokens` / `usage`
* slices that feed the TUI footer.
*
* A 250 ms poll diffs (history length, tokenCount, usage.total) and prints a
* line only when something changed, so the output is a timeline of exactly
* when the Context size reading moves — and when it does NOT.
*
* A throwaway model is seeded into the engine's temp home (an in-process
* engine has no default model), so both env vars are required. Run it (the
* engine sources need the decorators tsconfig + raw-text loader):
* KIMI_EXAMPLE_MODEL=... KIMI_EXAMPLE_API_KEY=... \
* pnpm -C packages/klient exec tsx --tsconfig ./tsconfig.examples.json \
* --import ../../build/register-raw-text-loader.mjs examples/context-usage.ts
*
* Env:
* KIMI_EXAMPLE_MODEL — gateway model id to seed (required)
* KIMI_EXAMPLE_API_KEY — API key for the seeded model (required)
* KIMI_EXAMPLE_BASE_URL — optional gateway base URL for the seeded model
* KIMI_EXAMPLE_PROTOCOL — optional wire protocol for the seeded model (default `openai`)
*/
import { mkdtemp, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { bootstrap, logSeed, resolveLoggingConfig } from '@moonshot-ai/agent-core-v2';
import { createKlient } from '@moonshot-ai/klient/memory';
const SEEDED_MODEL_ID = 'klient-example-model';
interface TokenUsage {
inputOther: number;
output: number;
inputCacheRead: number;
inputCacheCreation: number;
}
function usageTotal(usage: TokenUsage | undefined): number | undefined {
if (usage === undefined) return undefined;
return usage.inputOther + usage.output + usage.inputCacheRead + usage.inputCacheCreation;
}
const tick = (ms: number): Promise<void> =>
new Promise((resolve) => {
setTimeout(resolve, ms);
});
async function main(): Promise<void> {
const seedModel = process.env['KIMI_EXAMPLE_MODEL'];
const seedKey = process.env['KIMI_EXAMPLE_API_KEY'];
if (seedModel === undefined || seedKey === undefined) {
throw new Error('KIMI_EXAMPLE_MODEL and KIMI_EXAMPLE_API_KEY are required (see header)');
}
const homeDir = await mkdtemp(join(tmpdir(), 'klient-context-usage-'));
const { app } = bootstrap({ homeDir }, [
...logSeed(resolveLoggingConfig({ homeDir, env: process.env })),
]);
try {
const klient = createKlient({ scope: app });
const session = await klient.global.sessions.create({ workDir: process.cwd() });
console.log('[session] created ->', session.id);
const agent = klient.session(session.id).agent('main');
await klient.global.kosong.addProvider({
id: SEEDED_MODEL_ID,
model: seedModel,
protocol: (process.env['KIMI_EXAMPLE_PROTOCOL'] ?? 'openai'),
baseUrl: process.env['KIMI_EXAMPLE_BASE_URL'] ?? 'http://127.0.0.1:1',
auth: { method: 'api-key', apiKey: seedKey },
maxContextSize: 262_144,
});
await agent.setModel(SEEDED_MODEL_ID);
console.log('[model] bound ->', await agent.getModel());
const startedAt = Date.now();
const elapsed = (): string => `+${String(Date.now() - startedAt).padStart(6)}ms`;
// Live status slices (what the TUI footer consumes), as they arrive.
agent.events.on('agent.status.updated', (event) => {
const slice: Record<string, unknown> = {};
if ('contextTokens' in event) slice['contextTokens'] = event['contextTokens'];
if ('maxContextTokens' in event) slice['maxContextTokens'] = event['maxContextTokens'];
if ('contextUsage' in event) slice['contextUsage'] = event['contextUsage'];
if ('phase' in event) slice['phase'] = event['phase'];
const usage = event['usage'] as { total?: TokenUsage } | undefined;
if (usage !== undefined) slice['usage.total'] = usageTotal(usage.total);
console.log(`[event] ${elapsed()} agent.status.updated ->`, JSON.stringify(slice));
});
agent.events.on('turn.started', (event) => {
console.log(`[event] ${elapsed()} turn.started -> turnId=${String(event.turnId)}`);
});
agent.events.on('turn.ended', (event) => {
console.log(`[event] ${elapsed()} turn.ended -> reason=${event.reason}`);
});
agent.events.on('error', (event) => {
console.log(`[event] ${elapsed()} error ->`, JSON.stringify(event));
});
agent.events.onError((error) => {
console.log(`[event-err] ${elapsed()} ${error.message.split('\n')[0] ?? error.message}`);
});
const completed = new Promise<'completed' | 'failed' | 'timeout'>((resolve) => {
const timer = setTimeout(() => {
sub.dispose();
resolve('timeout');
}, 120_000);
const sub = agent.events.on('prompt.completed', (event) => {
clearTimeout(timer);
sub.dispose();
console.log(
`[event] ${elapsed()} prompt.completed -> reason=${event.reason ?? 'unknown'}`,
);
resolve(event.reason === 'failed' ? 'failed' : 'completed');
});
});
// Diff-polled snapshot of the RPC-visible readings.
let lastKey = '';
const snapshot = async (tag: string): Promise<void> => {
const [ctx, usage] = await Promise.all([agent.getContext(), agent.getUsage()]);
const total = usageTotal(usage.total);
const turn = usageTotal(usage.currentTurn);
const key = `${String(ctx.history.length)}/${String(ctx.tokenCount)}/${String(total)}/${String(turn)}`;
if (key === lastKey) return;
lastKey = key;
console.log(
`[poll] ${elapsed()} ${tag}`.padEnd(46),
`history=${String(ctx.history.length)} tokenCount(measured)=${String(ctx.tokenCount)}` +
` usage.total=${String(total)} usage.currentTurn=${String(turn)}`,
);
};
let polling = true;
const pollLoop = (async (): Promise<void> => {
while (polling) {
try {
await snapshot('');
} catch {
// transient RPC failure during the turn — keep polling
}
await tick(250);
}
})();
await snapshot('created (pre-prompt)');
console.log(`[prompt] ${elapsed()} sending "hi"`);
await agent.prompt({ input: [{ type: 'text', text: 'hi' }] });
const outcome = await completed;
polling = false;
await pollLoop;
lastKey = ''; // force the final line even if nothing moved since the last poll tick
await snapshot('after prompt.completed');
const ctx = await agent.getContext();
const usage = await agent.getUsage();
const total = usageTotal(usage.total);
console.log('---');
console.log('[result] outcome ->', outcome);
console.log('[result] history messages ->', ctx.history.length);
console.log('[result] tokenCount (measured) ->', ctx.tokenCount);
console.log('[result] usage.total ->', JSON.stringify(usage.total));
console.log('[result] usage.byModel ->', JSON.stringify(usage.byModel));
console.log(
`[check] tokenCount vs usage.total -> ${String(ctx.tokenCount)} vs ${String(total)}`,
);
console.log(
'[note] reading guide:\n' +
' - tokenCount is 0 until the first measured exchange lands, then it\n' +
' should equal THAT exchange\'s total (input + output); new messages\n' +
' appended between turns are the unmeasured tail.\n' +
' - after one covered exchange on a fresh session, cumulative\n' +
' usage.total and tokenCount should roughly agree; a large gap means\n' +
' the measured total never made it onto the wire model and the reading\n' +
' silently fell back to per-message estimates.\n' +
' - outcome "timeout" means the turn finished its work but the\n' +
' prompt.completed event never reached the client.',
);
console.log('[note] session left in the (disposed) temp home ->', session.id);
await klient.close();
if (outcome === 'failed') process.exit(1);
} finally {
app.dispose();
await rm(homeDir, { recursive: true, force: true });
}
}
try {
await main();
} catch (error) {
console.error(error);
process.exit(1);
}