mirror of
https://github.com/MoonshotAI/kimi-code.git
synced 2026-08-16 12:15:29 +00:00
feat(agent-core-v2): add per-scope keyed state container (#2192)
* refactor(agent-core-v2): instantiate all registered services eagerly at scope creation - add instantiateAll to scope creation: every service registered for a scope tier is constructed when the scope is created, following the static dependency graph; a failing constructor fails scope creation - drop the hand-maintained eager-resolution lists: igniteEagerServices in AgentLifecycleService, the force-instantiated session services in SessionLifecycleService, and the kosong config bridge get in bootstrap - update DI docs and agent-core-dev skill guidance for the new semantics - adjust affected tests (registry hygiene, timer draining, listener ordering) and add scope-tree coverage for eager instantiation * feat(agent-core-v2): add per-scope keyed state container (state domain) - add _base StateRegistry: typed StateKey/defineState descriptors with register/get/set, per-key onDidChange and global onDidChangeAny events, and BugIndicatingError on duplicate or unregistered key access - bind thin per-scope services at each tier: IStateService (App), ISessionStateService (Session), IAgentStateService (Agent), so scoped plain-data state lives in one observable container that dies with the scope - register the state domain in the layer check script and export the new services from the package index - add StateRegistry unit tests and scoped resolution tests * refactor(agent-core-v2): move session-scope service state into ISessionStateService - add StateRegistry.snapshot() with JSON-safe serialization for Map/Set/Date/circular values - migrate plain-data fields of 13 session-scope services (cron, interaction, sessionActivity, agentProfileCatalog, fs, fsWatch, log, metadata, skillCatalog, toolPolicy, workspaceCommand, workspaceContext) to defineState keys registered in sessionState - register SessionStateService in affected tests and add test/state/stubs.ts helper * feat(kimi-inspect): rework chat layout with session pane and tabbed right dock - add SessionPane column next to the sidebar: Services tab (pending interactions + session Service panels) and State tab polling ISessionStateService.snapshot() every second, rendered as a live diff tree - merge the transcript audit panel and the agent inspector into one RightPanel with Audit/Agent tabs that keep panel state across switches - extract InteractionsCard from Inspector into its own component - collapse multiline strings in StateTree into a compact hover-preview button with a viewport-clamped fixed popup - test: cover StateRegistry.snapshot() conversions (Map/Set/circular); pass a session state service to SessionInteractionService in kap-server test fakes - update the AGENTS.md project map for the new layout * refactor(agent-core-v2): move agent-scope service mutable state into agentState Register each Agent-scope service's mutable fields into the agent-state container (IAgentStateService) via defineState keys, and access them through get/set accessors backed by states.get/set. Promise locks, disposables, and other mechanism-only fields stay plain instance fields. - define per-service state keys (e.g. activityView.*, goal.*, toolDedupe.*) and register them in each service constructor - replace direct field reads/writes with states-backed accessors across the touched services - update the affected unit tests for the new IAgentStateService dependency * fix(agent-core-v2): collapse class instances in state snapshots - StateRegistry.snapshot() now stops at custom-prototype boundaries and emits '(ClassName)' markers, so resource graphs reachable from registered values (e.g. tool instances holding service references) can no longer exhaust the heap during export; plain data keeps recursing - add agent-scope state test covering the full assembled agent scope - kimi-inspect: extract the session State card into a shared StateCard and add an agent State tab in RightPanel polling IAgentStateService.snapshot() - document the per-scope state container pattern in the agent-core-dev skill * refactor(agent-core-v2): keep resource-holding state out of the state container - move fields holding live resources (tool entries, managed tasks, turn jobs, prompt records, MCP registrations, profile callbacks) back to plain private fields so the agent state service only holds snapshot-safe plain data - add gen:state-manifest script that statically collects defineState keys and their register call sites into docs/state-manifest.d.ts - add state manifest freshness test and update agent-state docs * chore: add changeset for session-scope state container * docs(agent-core-v2): move eager-instantiation notes into the scope.ts header * fix(kimi-inspect): suppress stale state tree while switching state owner
This commit is contained in:
parent
c497af60e6
commit
7799bd7346
119 changed files with 5233 additions and 639 deletions
|
|
@ -127,16 +127,20 @@ export class WSBroadcastService extends Disposable implements IWSBroadcastServic
|
|||
## §5 Eager vs delayed instantiation
|
||||
|
||||
```ts
|
||||
// Eager: constructed when the scope is created
|
||||
// Eager (default): constructed when the scope is created — registration alone
|
||||
// (via module import) is enough, nobody has to `get()` it first
|
||||
registerScopedService(LifecycleScope.App, ILogService, LogService, InstantiationType.Eager, 'log');
|
||||
|
||||
// Delayed: constructed on first get
|
||||
// Delayed: scope creation materializes only a Proxy; the real constructor
|
||||
// still defers to first property access
|
||||
registerScopedService(LifecycleScope.App, IScopeRegistry, ScopeRegistry, InstantiationType.Delayed, 'gateway');
|
||||
```
|
||||
|
||||
When a scope (App / Session / Agent) is created, the container instantiates **every** service registered for that tier: the dependency graph is statically known from the `@IX` constructor metadata, so construction automatically follows dependency order, and cycles still throw `CyclicDependencyError`. A failing constructor fails the whole scope creation.
|
||||
|
||||
A `Delayed` service returns a **Proxy** that constructs the real instance on first property access. Listeners registered on its `onDid…` / `onWill…` events before construction are not lost — the container records them and replays the subscriptions once the instance exists.
|
||||
|
||||
> Rule of thumb: `Eager` for dependency-free, frequently-used, or "early side effect" services (e.g. `ILogService`); default to `Delayed` otherwise.
|
||||
> Rule of thumb: keep the default `Eager` for everything; mark `Delayed` only when construction is genuinely expensive and you want it deferred (a legacy escape hatch — only a handful of services use it).
|
||||
|
||||
## §6 Using a service inside a plain function (`invokeFunction`)
|
||||
|
||||
|
|
|
|||
|
|
@ -203,6 +203,17 @@ A scoped Service may expose a factory method that returns a **new** instance of
|
|||
- `readonly` public fields only for immutable exposed state; prefer a getter (`get level()`) when the value can change.
|
||||
- Keep state minimal — a Service owns only the state that matches its scope's identity (design.md §2). Anything else belongs in a different Service.
|
||||
|
||||
### Runtime state goes into the per-scope state container
|
||||
|
||||
Session/Agent-scope Services register their runtime state into the scope's state container (`ISessionStateService` / `IAgentStateService`, both over `_base`'s `StateRegistry`) instead of holding it in bare instance fields, so per-scope state lives in one observable place (`snapshot()` / `onDidChange`) and dies with the scope. Reference: `session/interaction/interactionService.ts`.
|
||||
|
||||
- Declare keys in the domain file and export them: `export const interactionPendingKey = defineState<Map<string, Pending>>('interaction.pending', () => new Map())` — `<domain>.<field>` naming, factory initializers.
|
||||
- Inject `@ISessionStateService private readonly states` (or the Agent token) and `this.states.register(key)` per key at the top of the constructor.
|
||||
- Replace the field with accessors: a getter for collections only mutated in place (`this.foo.add(...)` keeps working — the container stores references, never clones); add a setter routed through `states.set` for reassigned scalars. Call sites stay unchanged.
|
||||
- Values must be plain data: scalars, arrays, and literal objects/Maps/Sets built from them. Never register class instances, resource handles (disposables, abort controllers, Promise locks), or objects holding service references — the regression precedent: one registry key whose class instances reached the whole DI graph deep-copied to hundreds of MB on `snapshot()` and OOM-killed the server. This means registries whose entries carry resources (the tool registry, the task map, prompt queues) stay as instance fields alongside Emitters, hook slots, disposable slots, waiter arrays, caches, and queue instances.
|
||||
- `snapshot()` additionally recurses plain data only: values with a custom prototype collapse to a `'(ClassName)'` marker — a `_base`-level backstop, not a license to register resource-bearing values.
|
||||
- Durable, replayable state does NOT belong here — it stays on wire Models. The container is memory-only.
|
||||
|
||||
## Events
|
||||
|
||||
v2 has two distinct event mechanisms. Pick by audience:
|
||||
|
|
|
|||
5
.changeset/agent-scope-state-container.md
Normal file
5
.changeset/agent-scope-state-container.md
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
"@moonshot-ai/kimi-code": patch
|
||||
---
|
||||
|
||||
Hold per-agent runtime state of the experimental engine in the agent-scope state container, so it is observable in one place and disposed with the agent; state snapshots collapse class instances to name markers so resource graphs cannot exhaust memory during export.
|
||||
5
.changeset/eager-scope-instantiation.md
Normal file
5
.changeset/eager-scope-instantiation.md
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
"@moonshot-ai/kimi-code": patch
|
||||
---
|
||||
|
||||
Instantiate every registered service eagerly at scope creation on the experimental engine, following the dependency graph automatically, and drop the hand-maintained lists that resolved side-effect services one by one at startup.
|
||||
5
.changeset/session-scope-state-container.md
Normal file
5
.changeset/session-scope-state-container.md
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
"@moonshot-ai/kimi-code": patch
|
||||
---
|
||||
|
||||
Hold per-session runtime state of the experimental engine in the session-scope state container, so it is observable in one place and disposed with the session.
|
||||
File diff suppressed because one or more lines are too long
|
|
@ -3,22 +3,26 @@
|
|||
* push anymore: the v2 socket (`/api/v2/ws`) that fed the core/session/agent
|
||||
* event streams was removed server-side, so Service panels and the pending
|
||||
* interactions card fetch on demand and the sidebar polls.
|
||||
* Layout: header / icon rail / view. The `chat` view is the classic trio
|
||||
* (left sidebar with workspaces + sessions, chat, inspector); the `models`
|
||||
* view is the full-width model catalog; the `services` view is the
|
||||
* full-width app-scope Service reflection (`AppServicesView`).
|
||||
* Layout: header / icon rail / view. The `chat` view is a strip of the
|
||||
* left sidebar (workspaces + sessions), the session pane (session Services
|
||||
* / State tabs), the chat column, and the right dock (`RightPanel`) merging
|
||||
* the transcript audit and the agent inspector under Audit / Agent tabs;
|
||||
* the `models` view is the full-width model catalog; the `services` view is
|
||||
* the full-width app-scope Service reflection (`AppServicesView`).
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { ISessionLifecycleService } from '@moonshot-ai/agent-core-v2/app/sessionLifecycle/sessionLifecycle';
|
||||
|
||||
import type { AuditTrail } from './audit/trail';
|
||||
import { AppServicesView } from './components/AppServicesView';
|
||||
import { ChatView } from './components/ChatView';
|
||||
import { Inspector } from './components/Inspector';
|
||||
import { ModelCatalogView } from './components/ModelCatalogView';
|
||||
import { NavRail, type AppView } from './components/NavRail';
|
||||
import { RightPanel } from './components/RightPanel';
|
||||
import { ServerSwitcher } from './components/ServerSwitcher';
|
||||
import { SessionPane } from './components/SessionPane';
|
||||
import { Sidebar } from './components/Sidebar';
|
||||
import { useConnection } from './connection';
|
||||
import { errorMessage } from './ui';
|
||||
|
|
@ -30,6 +34,8 @@ export function App() {
|
|||
const [view, setView] = useState<AppView>('chat');
|
||||
const [ready, setReady] = useState(false);
|
||||
const [resumeError, setResumeError] = useState<unknown>(null);
|
||||
/** Audit trail of the chat view's transcript channel, rendered in the right dock. */
|
||||
const [trail, setTrail] = useState<AuditTrail | null>(null);
|
||||
|
||||
// Resume (materialize) the session on the server when it is selected, so
|
||||
// session / agent scoped Services become reachable.
|
||||
|
|
@ -86,18 +92,25 @@ export function App() {
|
|||
) : (
|
||||
<>
|
||||
<Sidebar activeSessionId={sessionId} onSelectSession={setSessionId} />
|
||||
<SessionPane sessionId={sessionId} ready={ready} />
|
||||
{resumeError !== null ? (
|
||||
<div className="flex flex-1 items-center justify-center p-6 text-center text-[12px] text-red-400">
|
||||
Failed to open session: {errorMessage(resumeError)}
|
||||
</div>
|
||||
) : (
|
||||
<ChatView sessionId={sessionId} agentId={agentId} ready={ready} />
|
||||
<ChatView
|
||||
sessionId={sessionId}
|
||||
agentId={agentId}
|
||||
ready={ready}
|
||||
onTrailChange={setTrail}
|
||||
/>
|
||||
)}
|
||||
<Inspector
|
||||
<RightPanel
|
||||
sessionId={sessionId}
|
||||
agentId={agentId}
|
||||
onAgentChange={setAgentId}
|
||||
ready={ready}
|
||||
trail={trail}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -68,7 +68,6 @@ import {
|
|||
} from '../transcript/store';
|
||||
import { TranscriptWs } from '../transcript/ws';
|
||||
import { ActionButton, Badge, ErrorLine, JsonView, relTime } from '../ui';
|
||||
import { AuditPanel } from './audit/AuditPanel';
|
||||
|
||||
const noopSubscribe = () => () => {};
|
||||
|
||||
|
|
@ -326,10 +325,13 @@ export function ChatView({
|
|||
sessionId,
|
||||
agentId,
|
||||
ready,
|
||||
onTrailChange,
|
||||
}: {
|
||||
sessionId: string | null;
|
||||
agentId: string;
|
||||
ready: boolean;
|
||||
/** Hands the audit trail of the current channel up to the app shell (the audit panel lives in the right dock, not inside this view). */
|
||||
onTrailChange?: (trail: AuditTrail | null) => void;
|
||||
}) {
|
||||
const { klient, baseUrl, config } = useConnection();
|
||||
const [input, setInput] = useState('');
|
||||
|
|
@ -355,6 +357,12 @@ export function ChatView({
|
|||
);
|
||||
const items = state.items;
|
||||
|
||||
// The audit panel is rendered by the app shell's right dock; report the
|
||||
// trail (null while no channel exists) so it can subscribe to it there.
|
||||
useEffect(() => {
|
||||
onTrailChange?.(trail);
|
||||
}, [onTrailChange, trail]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const el = scrollRef.current;
|
||||
if (el === null) return;
|
||||
|
|
@ -485,122 +493,119 @@ export function ChatView({
|
|||
|
||||
return (
|
||||
<SessionContext.Provider value={sessionId}>
|
||||
<div className="flex min-w-0 flex-1">
|
||||
<div className="flex min-w-0 flex-1 flex-col">
|
||||
<div className="flex items-center gap-2 border-b border-neutral-800 px-4 py-2">
|
||||
<span className="font-mono text-[11px] text-neutral-400">{sessionId}</span>
|
||||
<Badge tone="sky">agent: {agentId}</Badge>
|
||||
{running ? <Badge tone="amber">turn running</Badge> : <Badge tone="green">idle</Badge>}
|
||||
{state.pendingInteractions.size > 0 ? (
|
||||
<Badge tone="amber">{state.pendingInteractions.size} pending</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto px-4 py-3" ref={scrollRef} onScroll={onScroll}>
|
||||
{state.hasMoreOlder ? (
|
||||
<div ref={topSentinelRef} className="mb-3 flex justify-center">
|
||||
<span className="text-[11px] text-neutral-600">
|
||||
{loadingOlder ? 'Loading earlier turns…' : ''}
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
{olderError !== null ? (
|
||||
<div className="mb-2">
|
||||
<ErrorLine error={olderError} />
|
||||
<div className="mt-1 flex justify-center">
|
||||
<ActionButton
|
||||
onClick={() => {
|
||||
setOlderError(null);
|
||||
void loadOlder();
|
||||
}}
|
||||
>
|
||||
Retry loading earlier turns
|
||||
</ActionButton>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
{loadError !== null ? (
|
||||
<div className="mb-2">
|
||||
<ErrorLine error={loadError} />
|
||||
<div className="mt-1 text-[11px] text-neutral-600">
|
||||
Failed to load the transcript — the server may be too old to expose the transcript
|
||||
API.
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
{items.length === 0 && loadError === null ? (
|
||||
<div className="text-[12px] text-neutral-600 italic">
|
||||
{loaded ? 'Empty transcript — send a prompt below.' : 'Loading transcript…'}
|
||||
</div>
|
||||
) : null}
|
||||
{latestTodo !== undefined && latestTodo.items.length > 0 ? (
|
||||
<div className="mb-3 rounded-lg border border-neutral-800 bg-neutral-900/40 px-3 py-2 text-[11px]">
|
||||
<div className="mb-1 text-neutral-500">todo (latest)</div>
|
||||
{latestTodo.items.map((entry, i) => (
|
||||
<div key={i} className="flex gap-2">
|
||||
<span className={entry.status === 'done' ? 'text-green-500' : entry.status === 'in_progress' ? 'text-sky-400' : 'text-neutral-600'}>
|
||||
{entry.status === 'done' ? '✔' : entry.status === 'in_progress' ? '◐' : '□'}
|
||||
</span>
|
||||
<span className={entry.status === 'done' ? 'text-neutral-600 line-through' : 'text-neutral-300'}>
|
||||
{entry.title}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
{items.map((item) => (
|
||||
// Native virtual screen: the browser skips layout/paint for
|
||||
// off-screen items and remembers their last rendered size
|
||||
// (`auto` in contain-intrinsic-size), so long transcripts stay
|
||||
// cheap without a windowing library.
|
||||
<div
|
||||
key={itemId(item)}
|
||||
style={{ contentVisibility: 'auto', containIntrinsicSize: 'auto 200px' }}
|
||||
>
|
||||
<ItemView
|
||||
item={item}
|
||||
tasks={state.tasks}
|
||||
interactions={state.interactions}
|
||||
attachments={state.attachments}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
{unanchoredInteractions.map((interaction) => (
|
||||
<InteractionEntityView key={interaction.interactionId} interaction={interaction} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="border-t border-neutral-800 p-3">
|
||||
{sendError !== null ? (
|
||||
<div className="mb-2">
|
||||
<ErrorLine error={sendError} />
|
||||
</div>
|
||||
) : null}
|
||||
<div className="flex gap-2">
|
||||
<textarea
|
||||
className="min-h-[40px] flex-1 resize-y rounded border border-neutral-700 bg-neutral-950 px-3 py-2 text-[13px] text-neutral-100 outline-none focus:border-sky-600"
|
||||
placeholder="Send a prompt to the active agent… (Enter to send, Shift+Enter for newline)"
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
void send();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<div className="flex flex-col gap-2">
|
||||
<ActionButton onClick={() => void send()} disabled={running || input.trim() === ''}>
|
||||
Send
|
||||
</ActionButton>
|
||||
<ActionButton onClick={() => void cancel()} danger disabled={!running}>
|
||||
Cancel
|
||||
<div className="flex min-w-0 flex-1 flex-col">
|
||||
<div className="flex items-center gap-2 border-b border-neutral-800 px-4 py-2">
|
||||
<span className="font-mono text-[11px] text-neutral-400">{sessionId}</span>
|
||||
<Badge tone="sky">agent: {agentId}</Badge>
|
||||
{running ? <Badge tone="amber">turn running</Badge> : <Badge tone="green">idle</Badge>}
|
||||
{state.pendingInteractions.size > 0 ? (
|
||||
<Badge tone="amber">{state.pendingInteractions.size} pending</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto px-4 py-3" ref={scrollRef} onScroll={onScroll}>
|
||||
{state.hasMoreOlder ? (
|
||||
<div ref={topSentinelRef} className="mb-3 flex justify-center">
|
||||
<span className="text-[11px] text-neutral-600">
|
||||
{loadingOlder ? 'Loading earlier turns…' : ''}
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
{olderError !== null ? (
|
||||
<div className="mb-2">
|
||||
<ErrorLine error={olderError} />
|
||||
<div className="mt-1 flex justify-center">
|
||||
<ActionButton
|
||||
onClick={() => {
|
||||
setOlderError(null);
|
||||
void loadOlder();
|
||||
}}
|
||||
>
|
||||
Retry loading earlier turns
|
||||
</ActionButton>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
{loadError !== null ? (
|
||||
<div className="mb-2">
|
||||
<ErrorLine error={loadError} />
|
||||
<div className="mt-1 text-[11px] text-neutral-600">
|
||||
Failed to load the transcript — the server may be too old to expose the transcript
|
||||
API.
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
{items.length === 0 && loadError === null ? (
|
||||
<div className="text-[12px] text-neutral-600 italic">
|
||||
{loaded ? 'Empty transcript — send a prompt below.' : 'Loading transcript…'}
|
||||
</div>
|
||||
) : null}
|
||||
{latestTodo !== undefined && latestTodo.items.length > 0 ? (
|
||||
<div className="mb-3 rounded-lg border border-neutral-800 bg-neutral-900/40 px-3 py-2 text-[11px]">
|
||||
<div className="mb-1 text-neutral-500">todo (latest)</div>
|
||||
{latestTodo.items.map((entry, i) => (
|
||||
<div key={i} className="flex gap-2">
|
||||
<span className={entry.status === 'done' ? 'text-green-500' : entry.status === 'in_progress' ? 'text-sky-400' : 'text-neutral-600'}>
|
||||
{entry.status === 'done' ? '✔' : entry.status === 'in_progress' ? '◐' : '□'}
|
||||
</span>
|
||||
<span className={entry.status === 'done' ? 'text-neutral-600 line-through' : 'text-neutral-300'}>
|
||||
{entry.title}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
{items.map((item) => (
|
||||
// Native virtual screen: the browser skips layout/paint for
|
||||
// off-screen items and remembers their last rendered size
|
||||
// (`auto` in contain-intrinsic-size), so long transcripts stay
|
||||
// cheap without a windowing library.
|
||||
<div
|
||||
key={itemId(item)}
|
||||
style={{ contentVisibility: 'auto', containIntrinsicSize: 'auto 200px' }}
|
||||
>
|
||||
<ItemView
|
||||
item={item}
|
||||
tasks={state.tasks}
|
||||
interactions={state.interactions}
|
||||
attachments={state.attachments}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
{unanchoredInteractions.map((interaction) => (
|
||||
<InteractionEntityView key={interaction.interactionId} interaction={interaction} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="border-t border-neutral-800 p-3">
|
||||
{sendError !== null ? (
|
||||
<div className="mb-2">
|
||||
<ErrorLine error={sendError} />
|
||||
</div>
|
||||
) : null}
|
||||
<div className="flex gap-2">
|
||||
<textarea
|
||||
className="min-h-[40px] flex-1 resize-y rounded border border-neutral-700 bg-neutral-950 px-3 py-2 text-[13px] text-neutral-100 outline-none focus:border-sky-600"
|
||||
placeholder="Send a prompt to the active agent… (Enter to send, Shift+Enter for newline)"
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
void send();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<div className="flex flex-col gap-2">
|
||||
<ActionButton onClick={() => void send()} disabled={running || input.trim() === ''}>
|
||||
Send
|
||||
</ActionButton>
|
||||
<ActionButton onClick={() => void cancel()} danger disabled={!running}>
|
||||
Cancel
|
||||
</ActionButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{trail !== null ? <AuditPanel trail={trail} /> : null}
|
||||
</div>
|
||||
</SessionContext.Provider>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,9 +1,11 @@
|
|||
/**
|
||||
* Right sidebar — access point for the Services of the active session and
|
||||
* the active agent. Hosts the agent switcher, two tabs (session / agent),
|
||||
* the pending-interaction card, and the Service panels (`ScopePanels`).
|
||||
* The app-scope (server-level) Services live in their own rail view
|
||||
* (`AppServicesView`), not here.
|
||||
* Agent inspector — the `Agent` tab of the chat view's right dock
|
||||
* (`RightPanel`; it used to be a standalone 420px column on the far right).
|
||||
* Hosts the agent switcher, the Plan lookup card, and the agent Service
|
||||
* panels (`ScopePanels`). The session scope lives in the `SessionPane`
|
||||
* column next to the sidebar (pending interactions, session Services,
|
||||
* session State); the app-scope (server-level) Services live in their own
|
||||
* rail view (`AppServicesView`), not here.
|
||||
*
|
||||
* Everything here is fetch-on-demand (Load / Refresh buttons): the v2 event
|
||||
* socket (`/api/v2/ws`) that used to push core/session/agent event streams
|
||||
|
|
@ -14,20 +16,15 @@
|
|||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import { ISessionApprovalService } from '@moonshot-ai/agent-core-v2/session/approval/approval';
|
||||
import { ISessionMetadata } from '@moonshot-ai/agent-core-v2/session/sessionMetadata/sessionMetadata';
|
||||
import { ISessionQuestionService } from '@moonshot-ai/agent-core-v2/session/question/question';
|
||||
import { ISessionInteractionService } from '@moonshot-ai/agent-core-v2/session/interaction/interaction';
|
||||
|
||||
import { serviceByName } from '../channel';
|
||||
import { useConnection } from '../connection';
|
||||
import { type AnyService } from '../panels';
|
||||
import { fetchTranscriptPlan, type TranscriptPlanInfo } from '../transcript/api';
|
||||
import { ActionButton, Badge, ErrorLine, JsonView, relTime } from '../ui';
|
||||
import { ActionButton, Badge, ErrorLine } from '../ui';
|
||||
import { ScopePanels } from './ServicePanels';
|
||||
|
||||
type Tab = 'session' | 'agent';
|
||||
|
||||
export function Inspector({
|
||||
sessionId,
|
||||
agentId,
|
||||
|
|
@ -40,7 +37,6 @@ export function Inspector({
|
|||
ready: boolean;
|
||||
}) {
|
||||
const { klient } = useConnection();
|
||||
const [tab, setTab] = useState<Tab>('session');
|
||||
|
||||
const meta = useQuery({
|
||||
queryKey: ['sessionMeta', sessionId],
|
||||
|
|
@ -81,17 +77,17 @@ export function Inspector({
|
|||
const proxyFor = useMemo(() => {
|
||||
return (name: string): AnyService | null => {
|
||||
return serviceByName<AnyService>(klient, name, {
|
||||
scope: tab,
|
||||
scope: 'agent',
|
||||
sessionId: sessionId !== null && ready ? sessionId : undefined,
|
||||
agentId: effectiveAgent,
|
||||
}) ?? null;
|
||||
};
|
||||
}, [klient, tab, sessionId, effectiveAgent, ready]);
|
||||
}, [klient, sessionId, effectiveAgent, ready]);
|
||||
|
||||
const sessionBlocked = sessionId === null || !ready;
|
||||
|
||||
return (
|
||||
<div className="flex h-full w-[420px] shrink-0 flex-col border-l border-neutral-800 bg-neutral-900/30">
|
||||
<div className="flex h-full flex-col">
|
||||
{/* Agent switcher */}
|
||||
{sessionId !== null ? (
|
||||
<div className="border-b border-neutral-800 px-3 py-2">
|
||||
|
|
@ -119,31 +115,11 @@ export function Inspector({
|
|||
</div>
|
||||
) : null}
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="flex border-b border-neutral-800 text-[11px]">
|
||||
{(['session', 'agent'] as const).map((t) => (
|
||||
<button
|
||||
key={t}
|
||||
className={`flex-1 px-2 py-2 font-medium uppercase tracking-wider ${
|
||||
tab === t ? 'bg-neutral-800 text-sky-400' : 'text-neutral-500 hover:text-neutral-300'
|
||||
}`}
|
||||
onClick={() => setTab(t)}
|
||||
>
|
||||
{t === 'session' ? 'Session' : 'Agent'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-3">
|
||||
{sessionBlocked ? (
|
||||
<div className="text-[12px] text-neutral-600">
|
||||
{sessionId === null ? 'No session selected.' : 'Loading session…'}
|
||||
</div>
|
||||
) : tab === 'session' ? (
|
||||
<>
|
||||
<InteractionsCard sessionId={sessionId} />
|
||||
<ScopePanels scope="session" proxyFor={proxyFor} />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<PlanCard sessionId={sessionId} agentId={effectiveAgent} />
|
||||
|
|
@ -159,176 +135,6 @@ export function Inspector({
|
|||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Pending interactions (approvals / questions) — fetched on demand: the
|
||||
// session `interactions` push stream went away with `/api/v2/ws`, so the
|
||||
// card refreshes only when Load is clicked.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface PendingInteraction {
|
||||
readonly id: string;
|
||||
/** Known kinds: 'approval' | 'question' | 'user_tool'; other kinds may appear. */
|
||||
readonly kind: string;
|
||||
readonly payload: Record<string, unknown>;
|
||||
readonly createdAt: number;
|
||||
}
|
||||
|
||||
function InteractionsCard({ sessionId }: { sessionId: string }) {
|
||||
const { klient } = useConnection();
|
||||
const [pending, setPending] = useState<readonly PendingInteraction[]>([]);
|
||||
const [error, setError] = useState<unknown>(null);
|
||||
const interaction = klient.session(sessionId).service(ISessionInteractionService);
|
||||
const approval = klient.session(sessionId).service(ISessionApprovalService);
|
||||
const question = klient.session(sessionId).service(ISessionQuestionService);
|
||||
|
||||
const reload = async () => {
|
||||
try {
|
||||
setError(null);
|
||||
setPending((await interaction.listPending()) as readonly PendingInteraction[]);
|
||||
} catch (error) {
|
||||
setError(error);
|
||||
}
|
||||
};
|
||||
|
||||
const decide = async (id: string, decision: 'approved' | 'rejected') => {
|
||||
try {
|
||||
await approval.decide(id, { decision });
|
||||
await reload();
|
||||
} catch (error) {
|
||||
setError(error);
|
||||
}
|
||||
};
|
||||
const answer = async (id: string, q: string, value: string) => {
|
||||
try {
|
||||
await question.answer(id, { answers: { [q]: value } });
|
||||
await reload();
|
||||
} catch (error) {
|
||||
setError(error);
|
||||
}
|
||||
};
|
||||
const dismiss = async (id: string) => {
|
||||
try {
|
||||
await question.dismiss(id);
|
||||
await reload();
|
||||
} catch (error) {
|
||||
setError(error);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mb-3 rounded-lg border border-amber-900/50 bg-amber-950/20">
|
||||
<div className="flex items-center justify-between border-b border-amber-900/40 px-3 py-2">
|
||||
<span className="text-[12px] font-medium text-amber-200">
|
||||
Pending interactions {pending.length > 0 ? `(${pending.length})` : ''}
|
||||
</span>
|
||||
<ActionButton onClick={() => void reload()}>Load</ActionButton>
|
||||
</div>
|
||||
<div className="px-3 py-2">
|
||||
{error !== null ? <div className="mb-2"><ErrorLine error={error} /></div> : null}
|
||||
{pending.length === 0 ? (
|
||||
<div className="text-[11px] text-neutral-600 italic">
|
||||
nothing pending (click Load to check)
|
||||
</div>
|
||||
) : (
|
||||
pending.map((item) => (
|
||||
<div key={item.id} className="mb-2 rounded border border-neutral-800 bg-neutral-950/60 p-2">
|
||||
<div className="mb-1 flex items-center gap-2">
|
||||
<Badge tone="amber">{item.kind}</Badge>
|
||||
<span className="font-mono text-[10px] text-neutral-500">{item.id}</span>
|
||||
<span className="text-[10px] text-neutral-600">{relTime(item.createdAt)}</span>
|
||||
</div>
|
||||
{item.kind === 'approval' ? (
|
||||
<>
|
||||
<div className="mb-1.5 text-[11px] text-neutral-300">
|
||||
<span className="text-neutral-500">tool </span>
|
||||
{payloadField(item.payload, 'toolName', '?')}
|
||||
<span className="text-neutral-500"> · </span>
|
||||
{payloadField(item.payload, 'action', '')}
|
||||
</div>
|
||||
<JsonView data={item.payload['display'] ?? item.payload} />
|
||||
<div className="mt-2 flex gap-1.5">
|
||||
<ActionButton onClick={() => void decide(item.id, 'approved')}>Approve</ActionButton>
|
||||
<ActionButton danger onClick={() => void decide(item.id, 'rejected')}>Reject</ActionButton>
|
||||
</div>
|
||||
</>
|
||||
) : item.kind === 'question' ? (
|
||||
<QuestionView
|
||||
payload={item.payload}
|
||||
onAnswer={(q, v) => void answer(item.id, q, v)}
|
||||
onDismiss={() => void dismiss(item.id)}
|
||||
/>
|
||||
) : (
|
||||
<JsonView data={item.payload} />
|
||||
)}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function QuestionView({
|
||||
payload,
|
||||
onAnswer,
|
||||
onDismiss,
|
||||
}: {
|
||||
payload: Record<string, unknown>;
|
||||
onAnswer: (question: string, value: string) => void;
|
||||
onDismiss: () => void;
|
||||
}) {
|
||||
const questions = (payload['questions'] ?? []) as readonly {
|
||||
question: string;
|
||||
options?: readonly { label: string }[];
|
||||
}[];
|
||||
return (
|
||||
<>
|
||||
{questions.map((q) => (
|
||||
<div key={q.question} className="mb-1.5">
|
||||
<div className="mb-1 text-[11px] text-neutral-300">{q.question}</div>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{(q.options ?? []).map((opt) => (
|
||||
<ActionButton key={opt.label} onClick={() => onAnswer(q.question, opt.label)}>
|
||||
{opt.label}
|
||||
</ActionButton>
|
||||
))}
|
||||
<ActionButton
|
||||
onClick={() => {
|
||||
const raw = window.prompt(q.question);
|
||||
if (raw !== null) onAnswer(q.question, raw);
|
||||
}}
|
||||
>
|
||||
Other…
|
||||
</ActionButton>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{questions.length === 0 ? <JsonView data={payload} /> : null}
|
||||
<div className="mt-1.5">
|
||||
<ActionButton danger onClick={onDismiss}>
|
||||
Dismiss
|
||||
</ActionButton>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a wire payload field as display text: strings pass through,
|
||||
* numbers/booleans are stringified, anything else (or missing) falls back —
|
||||
* never "[object Object]".
|
||||
*/
|
||||
function payloadField(
|
||||
payload: Record<string, unknown>,
|
||||
key: string,
|
||||
fallback: string,
|
||||
): string {
|
||||
const value = payload[key];
|
||||
if (typeof value === 'string') return value;
|
||||
if (typeof value === 'number' || typeof value === 'boolean') return String(value);
|
||||
return fallback;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Plan lookup — `GET /api/v1/sessions/{id}/transcript/plan`: the reviewed plan
|
||||
// of one ExitPlanMode tool call, queried by tool_call_id (copy it from a tool
|
||||
|
|
|
|||
178
apps/kimi-inspect/src/components/InteractionsCard.tsx
Normal file
178
apps/kimi-inspect/src/components/InteractionsCard.tsx
Normal file
|
|
@ -0,0 +1,178 @@
|
|||
/**
|
||||
* Pending interactions card (approvals / questions) of one session — fetched
|
||||
* on demand: the session `interactions` push stream went away with
|
||||
* `/api/v2/ws`, so the card refreshes only when Load is clicked.
|
||||
*/
|
||||
|
||||
import { useState } from 'react';
|
||||
|
||||
import { ISessionApprovalService } from '@moonshot-ai/agent-core-v2/session/approval/approval';
|
||||
import { ISessionInteractionService } from '@moonshot-ai/agent-core-v2/session/interaction/interaction';
|
||||
import { ISessionQuestionService } from '@moonshot-ai/agent-core-v2/session/question/question';
|
||||
|
||||
import { useConnection } from '../connection';
|
||||
import { ActionButton, Badge, ErrorLine, JsonView, relTime } from '../ui';
|
||||
|
||||
interface PendingInteraction {
|
||||
readonly id: string;
|
||||
/** Known kinds: 'approval' | 'question' | 'user_tool'; other kinds may appear. */
|
||||
readonly kind: string;
|
||||
readonly payload: Record<string, unknown>;
|
||||
readonly createdAt: number;
|
||||
}
|
||||
|
||||
export function InteractionsCard({ sessionId }: { sessionId: string }) {
|
||||
const { klient } = useConnection();
|
||||
const [pending, setPending] = useState<readonly PendingInteraction[]>([]);
|
||||
const [error, setError] = useState<unknown>(null);
|
||||
const interaction = klient.session(sessionId).service(ISessionInteractionService);
|
||||
const approval = klient.session(sessionId).service(ISessionApprovalService);
|
||||
const question = klient.session(sessionId).service(ISessionQuestionService);
|
||||
|
||||
const reload = async () => {
|
||||
try {
|
||||
setError(null);
|
||||
setPending((await interaction.listPending()) as readonly PendingInteraction[]);
|
||||
} catch (error) {
|
||||
setError(error);
|
||||
}
|
||||
};
|
||||
|
||||
const decide = async (id: string, decision: 'approved' | 'rejected') => {
|
||||
try {
|
||||
await approval.decide(id, { decision });
|
||||
await reload();
|
||||
} catch (error) {
|
||||
setError(error);
|
||||
}
|
||||
};
|
||||
const answer = async (id: string, q: string, value: string) => {
|
||||
try {
|
||||
await question.answer(id, { answers: { [q]: value } });
|
||||
await reload();
|
||||
} catch (error) {
|
||||
setError(error);
|
||||
}
|
||||
};
|
||||
const dismiss = async (id: string) => {
|
||||
try {
|
||||
await question.dismiss(id);
|
||||
await reload();
|
||||
} catch (error) {
|
||||
setError(error);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mb-3 rounded-lg border border-amber-900/50 bg-amber-950/20">
|
||||
<div className="flex items-center justify-between border-b border-amber-900/40 px-3 py-2">
|
||||
<span className="text-[12px] font-medium text-amber-200">
|
||||
Pending interactions {pending.length > 0 ? `(${pending.length})` : ''}
|
||||
</span>
|
||||
<ActionButton onClick={() => void reload()}>Load</ActionButton>
|
||||
</div>
|
||||
<div className="px-3 py-2">
|
||||
{error !== null ? <div className="mb-2"><ErrorLine error={error} /></div> : null}
|
||||
{pending.length === 0 ? (
|
||||
<div className="text-[11px] text-neutral-600 italic">
|
||||
nothing pending (click Load to check)
|
||||
</div>
|
||||
) : (
|
||||
pending.map((item) => (
|
||||
<div key={item.id} className="mb-2 rounded border border-neutral-800 bg-neutral-950/60 p-2">
|
||||
<div className="mb-1 flex items-center gap-2">
|
||||
<Badge tone="amber">{item.kind}</Badge>
|
||||
<span className="font-mono text-[10px] text-neutral-500">{item.id}</span>
|
||||
<span className="text-[10px] text-neutral-600">{relTime(item.createdAt)}</span>
|
||||
</div>
|
||||
{item.kind === 'approval' ? (
|
||||
<>
|
||||
<div className="mb-1.5 text-[11px] text-neutral-300">
|
||||
<span className="text-neutral-500">tool </span>
|
||||
{payloadField(item.payload, 'toolName', '?')}
|
||||
<span className="text-neutral-500"> · </span>
|
||||
{payloadField(item.payload, 'action', '')}
|
||||
</div>
|
||||
<JsonView data={item.payload['display'] ?? item.payload} />
|
||||
<div className="mt-2 flex gap-1.5">
|
||||
<ActionButton onClick={() => void decide(item.id, 'approved')}>Approve</ActionButton>
|
||||
<ActionButton danger onClick={() => void decide(item.id, 'rejected')}>Reject</ActionButton>
|
||||
</div>
|
||||
</>
|
||||
) : item.kind === 'question' ? (
|
||||
<QuestionView
|
||||
payload={item.payload}
|
||||
onAnswer={(q, v) => void answer(item.id, q, v)}
|
||||
onDismiss={() => void dismiss(item.id)}
|
||||
/>
|
||||
) : (
|
||||
<JsonView data={item.payload} />
|
||||
)}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function QuestionView({
|
||||
payload,
|
||||
onAnswer,
|
||||
onDismiss,
|
||||
}: {
|
||||
payload: Record<string, unknown>;
|
||||
onAnswer: (question: string, value: string) => void;
|
||||
onDismiss: () => void;
|
||||
}) {
|
||||
const questions = (payload['questions'] ?? []) as readonly {
|
||||
question: string;
|
||||
options?: readonly { label: string }[];
|
||||
}[];
|
||||
return (
|
||||
<>
|
||||
{questions.map((q) => (
|
||||
<div key={q.question} className="mb-1.5">
|
||||
<div className="mb-1 text-[11px] text-neutral-300">{q.question}</div>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{(q.options ?? []).map((opt) => (
|
||||
<ActionButton key={opt.label} onClick={() => onAnswer(q.question, opt.label)}>
|
||||
{opt.label}
|
||||
</ActionButton>
|
||||
))}
|
||||
<ActionButton
|
||||
onClick={() => {
|
||||
const raw = window.prompt(q.question);
|
||||
if (raw !== null) onAnswer(q.question, raw);
|
||||
}}
|
||||
>
|
||||
Other…
|
||||
</ActionButton>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{questions.length === 0 ? <JsonView data={payload} /> : null}
|
||||
<div className="mt-1.5">
|
||||
<ActionButton danger onClick={onDismiss}>
|
||||
Dismiss
|
||||
</ActionButton>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a wire payload field as display text: strings pass through,
|
||||
* numbers/booleans are stringified, anything else (or missing) falls back —
|
||||
* never "[object Object]".
|
||||
*/
|
||||
function payloadField(
|
||||
payload: Record<string, unknown>,
|
||||
key: string,
|
||||
fallback: string,
|
||||
): string {
|
||||
const value = payload[key];
|
||||
if (typeof value === 'string') return value;
|
||||
if (typeof value === 'number' || typeof value === 'boolean') return String(value);
|
||||
return fallback;
|
||||
}
|
||||
102
apps/kimi-inspect/src/components/RightPanel.tsx
Normal file
102
apps/kimi-inspect/src/components/RightPanel.tsx
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
/**
|
||||
* Right dock — the single right-hand column of the chat view. Merges what
|
||||
* used to be two separate columns (the transcript audit panel docked inside
|
||||
* the chat view, and the agent inspector on the far right) into one tabbed
|
||||
* column: `Audit` replays how the visible transcript store was built, entry
|
||||
* by entry; `Agent` hosts the agent switcher, the Plan lookup card, and the
|
||||
* agent Service panels; `State` reads the active agent's registered
|
||||
* plain-data state through `IAgentStateService.snapshot()` (the same live
|
||||
* diff-tree view as the session State tab in `SessionPane`, shared via
|
||||
* `StateCard`). Tabs switch with `hidden` instead of unmounting, so
|
||||
* panel-local state (the audit timeline position, Plan lookup input/results,
|
||||
* expanded Service panels, the state tree's open rows) survives tab
|
||||
* switches.
|
||||
*/
|
||||
|
||||
import { useState } from 'react';
|
||||
|
||||
import { IAgentStateService } from '@moonshot-ai/agent-core-v2/agent/state/agentState';
|
||||
|
||||
import { useConnection } from '../connection';
|
||||
import { Badge } from '../ui';
|
||||
import type { AuditTrail } from '../audit/trail';
|
||||
import { AuditPanel } from './audit/AuditPanel';
|
||||
import { Inspector } from './Inspector';
|
||||
import { StateCard } from './StateCard';
|
||||
|
||||
type Tab = 'audit' | 'agent' | 'state';
|
||||
|
||||
export function RightPanel({
|
||||
sessionId,
|
||||
agentId,
|
||||
onAgentChange,
|
||||
ready,
|
||||
trail,
|
||||
}: {
|
||||
sessionId: string | null;
|
||||
agentId: string;
|
||||
onAgentChange: (agentId: string) => void;
|
||||
ready: boolean;
|
||||
/** The chat view's audit trail; null until its transcript channel exists. */
|
||||
trail: AuditTrail | null;
|
||||
}) {
|
||||
const { klient } = useConnection();
|
||||
const [tab, setTab] = useState<Tab>('audit');
|
||||
|
||||
return (
|
||||
<div className="flex h-full w-[440px] shrink-0 flex-col border-l border-neutral-800 bg-neutral-900/30">
|
||||
<div className="flex border-b border-neutral-800 text-[11px]">
|
||||
{(['audit', 'agent', 'state'] as const).map((t) => (
|
||||
<button
|
||||
key={t}
|
||||
className={`flex-1 px-2 py-2 font-medium uppercase tracking-wider ${
|
||||
tab === t ? 'bg-neutral-800 text-sky-400' : 'text-neutral-500 hover:text-neutral-300'
|
||||
}`}
|
||||
onClick={() => setTab(t)}
|
||||
>
|
||||
{t === 'audit' ? 'Audit' : t === 'agent' ? 'Agent' : 'State'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className={tab === 'audit' ? 'flex min-h-0 flex-1 flex-col' : 'hidden'}>
|
||||
{trail !== null ? (
|
||||
<AuditPanel trail={trail} />
|
||||
) : (
|
||||
<div className="p-3 text-[12px] text-neutral-600">
|
||||
{sessionId === null
|
||||
? 'No session selected.'
|
||||
: 'Loading transcript — the audit trail appears once the channel is up.'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className={tab === 'agent' ? 'flex min-h-0 flex-1 flex-col' : 'hidden'}>
|
||||
<Inspector
|
||||
sessionId={sessionId}
|
||||
agentId={agentId}
|
||||
onAgentChange={onAgentChange}
|
||||
ready={ready}
|
||||
/>
|
||||
</div>
|
||||
<div className={tab === 'state' ? 'min-h-0 flex-1 overflow-y-auto' : 'hidden'}>
|
||||
<div className="p-3">
|
||||
{sessionId === null || !ready ? (
|
||||
<div className="text-[12px] text-neutral-600">
|
||||
{sessionId === null ? 'No session selected.' : 'Loading session…'}
|
||||
</div>
|
||||
) : (
|
||||
<StateCard
|
||||
id={`${sessionId}/${agentId}`}
|
||||
queryKey={['agentState', sessionId, agentId]}
|
||||
title="Agent state"
|
||||
label="agentStateService"
|
||||
badge={<Badge tone="sky">{agentId}</Badge>}
|
||||
fetchSnapshot={() =>
|
||||
klient.session(sessionId).agent(agentId).service(IAgentStateService).snapshot()
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
88
apps/kimi-inspect/src/components/SessionPane.tsx
Normal file
88
apps/kimi-inspect/src/components/SessionPane.tsx
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
/**
|
||||
* Session pane — the column right next to the session-list sidebar in the
|
||||
* chat view. Hosts everything session-scoped: the pending-interactions card
|
||||
* and the session Service panels under the `Services` tab, plus a `State`
|
||||
* tab reading the session's registered plain-data state through
|
||||
* `ISessionStateService.snapshot()` (every key a Session Service registered
|
||||
* into the session-state container, JSON-safe). The Service panels are
|
||||
* fetch-on-demand (no Service-event push channel exists); the State tab
|
||||
* instead auto-loads on mount and polls once a second, so it stays live
|
||||
* without a Refresh button.
|
||||
*/
|
||||
|
||||
import { useMemo, useState } from 'react';
|
||||
|
||||
import { ISessionStateService } from '@moonshot-ai/agent-core-v2/session/state/sessionState';
|
||||
|
||||
import { serviceByName } from '../channel';
|
||||
import { useConnection } from '../connection';
|
||||
import { type AnyService } from '../panels';
|
||||
import { InteractionsCard } from './InteractionsCard';
|
||||
import { ScopePanels } from './ServicePanels';
|
||||
import { StateCard } from './StateCard';
|
||||
|
||||
type Tab = 'services' | 'state';
|
||||
|
||||
export function SessionPane({
|
||||
sessionId,
|
||||
ready,
|
||||
}: {
|
||||
sessionId: string | null;
|
||||
ready: boolean;
|
||||
}) {
|
||||
const { klient } = useConnection();
|
||||
const [tab, setTab] = useState<Tab>('services');
|
||||
|
||||
const proxyFor = useMemo(() => {
|
||||
return (name: string): AnyService | null => {
|
||||
return (
|
||||
serviceByName<AnyService>(klient, name, {
|
||||
scope: 'session',
|
||||
sessionId: sessionId !== null && ready ? sessionId : undefined,
|
||||
}) ?? null
|
||||
);
|
||||
};
|
||||
}, [klient, sessionId, ready]);
|
||||
|
||||
const blocked = sessionId === null || !ready;
|
||||
|
||||
return (
|
||||
<div className="flex h-full w-[420px] shrink-0 flex-col border-l border-neutral-800 bg-neutral-900/30">
|
||||
<div className="flex border-b border-neutral-800 text-[11px]">
|
||||
{(['services', 'state'] as const).map((t) => (
|
||||
<button
|
||||
key={t}
|
||||
className={`flex-1 px-2 py-2 font-medium uppercase tracking-wider ${
|
||||
tab === t ? 'bg-neutral-800 text-sky-400' : 'text-neutral-500 hover:text-neutral-300'
|
||||
}`}
|
||||
onClick={() => setTab(t)}
|
||||
>
|
||||
{t === 'services' ? 'Services' : 'State'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto p-3">
|
||||
{blocked ? (
|
||||
<div className="text-[12px] text-neutral-600">
|
||||
{sessionId === null ? 'No session selected.' : 'Loading session…'}
|
||||
</div>
|
||||
) : tab === 'services' ? (
|
||||
<>
|
||||
<InteractionsCard sessionId={sessionId} />
|
||||
<ScopePanels scope="session" proxyFor={proxyFor} />
|
||||
</>
|
||||
) : (
|
||||
<StateCard
|
||||
id={sessionId}
|
||||
queryKey={['sessionState', sessionId]}
|
||||
title="Session state"
|
||||
label="sessionStateService"
|
||||
fetchSnapshot={() =>
|
||||
klient.session(sessionId).service(ISessionStateService).snapshot()
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
117
apps/kimi-inspect/src/components/StateCard.tsx
Normal file
117
apps/kimi-inspect/src/components/StateCard.tsx
Normal file
|
|
@ -0,0 +1,117 @@
|
|||
/**
|
||||
* State card — one scope's registered state as a live diff tree, shared by
|
||||
* the session `State` tab (`SessionPane`) and the agent `State` tab
|
||||
* (`RightPanel`). Same view as the audit panel's "Diff vs prev" (`StateTree`
|
||||
* over a `DiffNode` root), so every key is an interactive, collapsible row.
|
||||
* Auto-loads on mount and polls once a second: the first snapshot (and any
|
||||
* owner switch) renders as a plain tree, each later snapshot folds in as a
|
||||
* structural diff against the previous one (added = green, removed = red,
|
||||
* modified = amber). Changed subtrees auto-open; rows the user expanded stay
|
||||
* expanded across polls.
|
||||
*/
|
||||
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useEffect, useState, type ReactNode } from 'react';
|
||||
|
||||
import { diffValue, type DiffNode } from '../audit/diff';
|
||||
import { ActionButton, Badge, ErrorLine } from '../ui';
|
||||
import { StateTree, plainNode } from './audit/StateTree';
|
||||
|
||||
export function StateCard({
|
||||
id,
|
||||
queryKey,
|
||||
title,
|
||||
label,
|
||||
badge,
|
||||
fetchSnapshot,
|
||||
}: {
|
||||
/** Identity of the state owner (`sessionId`, or `sessionId/agentId`); a
|
||||
* change resets the diff baseline so a fresh owner renders as a plain tree
|
||||
* instead of an all-green diff against the previous owner. */
|
||||
id: string;
|
||||
queryKey: readonly unknown[];
|
||||
/** Card title, e.g. `Session state` / `Agent state`. */
|
||||
title: string;
|
||||
/** Mono caption next to the title: the state service's channel name. */
|
||||
label: string;
|
||||
badge?: ReactNode;
|
||||
fetchSnapshot: () => Promise<Record<string, unknown>>;
|
||||
}) {
|
||||
const query = useQuery({
|
||||
queryKey,
|
||||
queryFn: async () => ({ id, snapshot: await fetchSnapshot() }),
|
||||
refetchInterval: 1000,
|
||||
placeholderData: (previous) => previous,
|
||||
});
|
||||
|
||||
// Fold each poll into a diff against the previous snapshot. `plainNode`
|
||||
// on first load / owner switch — diffing a freshly loaded tree against
|
||||
// nothing (or against the previous owner) would paint it all-green. While
|
||||
// the new owner's fetch is in flight the query still carries the previous
|
||||
// owner's placeholder data (`data.id !== id`); ignore it so a stale tree
|
||||
// never renders under the new owner's badge.
|
||||
const [tree, setTree] = useState<{ id: string; node: DiffNode } | null>(null);
|
||||
useEffect(() => {
|
||||
const data = query.data;
|
||||
if (data === undefined || data.id !== id) return;
|
||||
setTree((prev) =>
|
||||
prev === null || prev.id !== data.id
|
||||
? { id: data.id, node: plainNode(data.snapshot) }
|
||||
: { id: data.id, node: diffValue(prev.node.value, data.snapshot) },
|
||||
);
|
||||
}, [query.data, id]);
|
||||
|
||||
// The tree is only shown when it belongs to the current owner — after an
|
||||
// owner switch the old tree is suppressed until the new snapshot arrives.
|
||||
const visibleTree = tree !== null && tree.id === id ? tree : null;
|
||||
|
||||
// One-click expand/collapse: bumping `nonce` remounts the tree, and the
|
||||
// fresh `defaultDepth` decides how deep nodes open (Infinity = expand all,
|
||||
// 0 = collapse all — diff-changed subtrees still auto-open, as in the
|
||||
// audit panel). Polls afterwards keep the local open state untouched.
|
||||
const [expand, setExpand] = useState({ nonce: 0, depth: 1 });
|
||||
|
||||
return (
|
||||
<div className="mb-3 rounded-lg border border-neutral-800 bg-neutral-900/60">
|
||||
<div className="flex items-center justify-between border-b border-neutral-800/60 px-3 py-2">
|
||||
<div>
|
||||
<span className="text-[12px] font-medium text-neutral-200">{title}</span>
|
||||
<span className="ml-2 font-mono text-[10px] text-neutral-600">{label}</span>
|
||||
{badge}
|
||||
{visibleTree !== null ? (
|
||||
<Badge tone="neutral">
|
||||
{Object.keys(visibleTree.node.value as Record<string, unknown>).length} keys
|
||||
</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<ActionButton
|
||||
onClick={() =>
|
||||
setExpand((s) => ({ nonce: s.nonce + 1, depth: Number.POSITIVE_INFINITY }))
|
||||
}
|
||||
>
|
||||
Expand
|
||||
</ActionButton>
|
||||
<ActionButton onClick={() => setExpand((s) => ({ nonce: s.nonce + 1, depth: 0 }))}>
|
||||
Collapse
|
||||
</ActionButton>
|
||||
<span className="text-[10px] text-neutral-600">live · 1s</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="px-3 py-2">
|
||||
{query.isError ? (
|
||||
<div className="mb-2">
|
||||
<ErrorLine error={query.error} />
|
||||
</div>
|
||||
) : null}
|
||||
{visibleTree === null ? (
|
||||
<div className="text-[11px] text-neutral-600 italic">
|
||||
{query.isPending || tree !== null ? 'Loading state…' : 'no state registered'}
|
||||
</div>
|
||||
) : (
|
||||
<StateTree key={expand.nonce} root={visibleTree.node} defaultDepth={expand.depth} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,6 +1,8 @@
|
|||
/**
|
||||
* Audit side panel for the chat view: replays how the visible
|
||||
* `TranscriptChatStore` was built, entry by entry.
|
||||
* Audit panel — the `Audit` tab of the chat view's right dock
|
||||
* (`RightPanel`): replays how the visible `TranscriptChatStore` was built,
|
||||
* entry by entry. It used to be a standalone column docked inside the chat
|
||||
* view.
|
||||
*
|
||||
* - Timeline (draggable slider + entry list): every REST page load, WS
|
||||
* frame (`transcript.ops` / `transcript.reset`), loss signal, and user
|
||||
|
|
@ -92,7 +94,7 @@ export function AuditPanel({ trail }: { trail: AuditTrail }) {
|
|||
}, [current, currentPos, entries, tab]);
|
||||
|
||||
return (
|
||||
<div className="flex w-[460px] shrink-0 flex-col border-l border-neutral-800">
|
||||
<div className="flex h-full flex-col">
|
||||
<div className="flex items-center gap-2 border-b border-neutral-800 px-3 py-2">
|
||||
<span className="text-[12px] font-medium text-neutral-200">Transcript audit</span>
|
||||
<Badge tone="neutral">{entries.length} entries</Badge>
|
||||
|
|
|
|||
|
|
@ -97,4 +97,17 @@ describe('StateTree', () => {
|
|||
}
|
||||
expect(html).not.toContain('{"kind"');
|
||||
});
|
||||
|
||||
it('collapses multiline strings into a hover-preview button', () => {
|
||||
const root = plainNode({ note: 'line one\nline two\nline three', single: 'one-liner' });
|
||||
const html = renderToStaticMarkup(<StateTree root={root} defaultDepth={1} />);
|
||||
// The multiline value renders as a compact button: first-line preview +
|
||||
// line count, with the remaining lines NOT inlined into the tree (they
|
||||
// only appear in the hover panel, which needs a real mouse to open).
|
||||
expect(html).toContain('"line one" ⏎ 3');
|
||||
expect(html).not.toContain('line two');
|
||||
expect(html).not.toContain('line three');
|
||||
// Single-line strings keep the inline rendering.
|
||||
expect(html).toContain('"one-liner"');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -5,7 +5,10 @@
|
|||
* collapsible tree, colored by the structural diff against the previous
|
||||
* trail entry: added = green, removed = red + strikethrough, modified =
|
||||
* amber (`old → new` on leaves). Every field is rendered — long strings
|
||||
* are tail-truncated (`tailTrunc`) but no key is ever dropped.
|
||||
* are tail-truncated (`tailTrunc`) but no key is ever dropped, and a string
|
||||
* containing newlines collapses into one compact button (first-line preview
|
||||
* + line count) whose hover pops a `fixed` full-text panel at the button's
|
||||
* right edge.
|
||||
*
|
||||
* The tree is driven entirely by a `DiffNode` root: diff mode passes
|
||||
* `diffValue(prev, next)`, plain state mode passes `plainNode(value)`.
|
||||
|
|
@ -16,7 +19,7 @@
|
|||
* unreadable one-line JSON dump.
|
||||
*/
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
|
||||
import { elementId, type DiffNode, type DiffStatus } from '../../audit/diff';
|
||||
import { tailTrunc } from '../../audit/truncate';
|
||||
|
|
@ -68,6 +71,7 @@ function Leaf({ value, tone }: { value: unknown; tone: DiffStatus }) {
|
|||
if (value === null) return <span className={className}>null</span>;
|
||||
if (value === undefined) return <span className={className}>undefined</span>;
|
||||
if (typeof value === 'string') {
|
||||
if (value.includes('\n')) return <MultilineString value={value} tone={tone} />;
|
||||
return <span className={`${className} whitespace-pre-wrap break-all`}>"{tailTrunc(value)}"</span>;
|
||||
}
|
||||
if (typeof value === 'number' || typeof value === 'boolean') {
|
||||
|
|
@ -76,6 +80,83 @@ function Leaf({ value, tone }: { value: unknown; tone: DiffStatus }) {
|
|||
return <span className={className}>{JSON.stringify(value) ?? 'unknown'}</span>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Multiline string leaf: rendered as one compact button (first-line preview
|
||||
* + line count) so a prompt or tool output never stretches the tree. Hovering
|
||||
* the button pops a `fixed` panel at its right edge with the full text —
|
||||
* `fixed` because the tree sits inside an `overflow-y-auto` column that
|
||||
* would clip an absolutely-positioned popup. A 150 ms close delay lets the
|
||||
* pointer travel from the button into the panel (to scroll or select).
|
||||
* `popupStyle` keeps the whole panel inside the viewport: the total height
|
||||
* is capped by the space left below the clamped top edge, and the panel
|
||||
* flips to the button's left side when the remaining right space is cramped.
|
||||
*/
|
||||
function popupStyle(rect: DOMRect): React.CSSProperties {
|
||||
const margin = 8;
|
||||
const vh = window.innerHeight;
|
||||
const vw = window.innerWidth;
|
||||
const maxHeight = Math.max(160, Math.floor(vh * 0.7));
|
||||
const top = Math.max(margin, Math.min(rect.top, vh - maxHeight - margin));
|
||||
const rightSpace = vw - rect.right - margin * 2;
|
||||
if (rightSpace >= 320) {
|
||||
return { left: rect.right + margin, top, maxHeight, width: Math.min(560, rightSpace) };
|
||||
}
|
||||
const leftSpace = rect.left - margin * 2;
|
||||
return {
|
||||
right: vw - rect.left + margin,
|
||||
top,
|
||||
maxHeight,
|
||||
width: Math.min(560, Math.max(280, leftSpace)),
|
||||
};
|
||||
}
|
||||
|
||||
function MultilineString({ value, tone }: { value: string; tone: DiffStatus }) {
|
||||
const [rect, setRect] = useState<DOMRect | null>(null);
|
||||
const closeTimer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
|
||||
useEffect(() => () => clearTimeout(closeTimer.current), []);
|
||||
|
||||
const lines = value.split('\n');
|
||||
const firstLine = lines[0] ?? '';
|
||||
const preview = firstLine.length > 40 ? `${firstLine.slice(0, 40)}…` : firstLine;
|
||||
|
||||
const open = (e: React.MouseEvent<HTMLButtonElement>) => {
|
||||
clearTimeout(closeTimer.current);
|
||||
setRect(e.currentTarget.getBoundingClientRect());
|
||||
};
|
||||
const scheduleClose = () => {
|
||||
clearTimeout(closeTimer.current);
|
||||
closeTimer.current = setTimeout(() => setRect(null), 150);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className={`${TEXT_TONE[tone]} cursor-pointer rounded border border-neutral-700 bg-neutral-900 px-1 hover:border-sky-600`}
|
||||
onMouseEnter={open}
|
||||
onMouseLeave={scheduleClose}
|
||||
>
|
||||
{`"${preview}" ⏎ ${lines.length}`}
|
||||
</button>
|
||||
{rect !== null ? (
|
||||
<div
|
||||
className="fixed z-50 flex flex-col rounded border border-neutral-700 bg-neutral-950 shadow-xl"
|
||||
style={popupStyle(rect)}
|
||||
onMouseEnter={() => clearTimeout(closeTimer.current)}
|
||||
onMouseLeave={scheduleClose}
|
||||
>
|
||||
<div className="shrink-0 border-b border-neutral-800 px-3 py-1 text-[10px] text-neutral-500">
|
||||
{lines.length} lines · {value.length} chars
|
||||
</div>
|
||||
<pre className="min-h-0 flex-1 overflow-auto px-3 py-2 font-mono text-[11px] whitespace-pre-wrap break-all text-neutral-200">
|
||||
{tailTrunc(value, 5000)}
|
||||
</pre>
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function Node({
|
||||
name,
|
||||
node,
|
||||
|
|
|
|||
|
|
@ -64,3 +64,4 @@ Per-domain references live in `docs/`.
|
|||
- [`docs/di-testing.md`](docs/di-testing.md) — Read **before writing or touching any DI/Scope test**: picking the right harness (`InstantiationService` vs `TestInstantiationService` vs `createScopedTestHost`), declaring deps with `@IService`, stubbing collaborators, and teardown via `DisposableStore`.
|
||||
- [`docs/config-manifest.toml`](docs/config-manifest.toml) — Generated list of every registered config section, in the on-disk `config.toml` shape (owner, scope, defaults, env bindings, schema fields). Do not edit by hand; regenerate with `pnpm gen:config-manifest` after adding or removing a `registerConfigSection` call — `test/app/config/configManifest.test.ts` enforces freshness.
|
||||
- [`docs/wire-manifest.d.ts`](docs/wire-manifest.d.ts) — Generated declaration file listing every registered wire record type as a payload interface (model, persist policy, `toEvent`, cross-reducers in the doc comment; payload fields in real TS type syntax), plus a `WirePayloadMap`. Do not edit by hand; regenerate with `pnpm gen:wire-manifest` after adding or removing a `defineOp` call — `test/wire/wireManifest.test.ts` enforces freshness and checks the file parses.
|
||||
- [`docs/state-manifest.d.ts`](docs/state-manifest.d.ts) — Generated declaration file listing every state key registered into `ISessionStateService` / `IAgentStateService`, as `SessionStateSnapshot` / `AgentStateSnapshot` interfaces (keys grouped by defining file), plus the `SessionStateKey` / `AgentStateKey` unions. Self-contained: every value type is expanded fully inline with each named type marked by a `/* TypeName — source/file.ts */` comment (recursion stops with a `recursive` marker) — no imports, no helper declarations. Do not edit by hand; regenerate with `pnpm gen:state-manifest` after adding or removing a `states.register(...)` call — `test/state/stateManifest.test.ts` enforces freshness and checks the file parses.
|
||||
|
|
|
|||
|
|
@ -88,7 +88,7 @@ export * from './greetService'; // import 这一行即触发上面的 register
|
|||
export * from './greet/index';
|
||||
```
|
||||
|
||||
于是「import 这个包」=「加载全部注册」。**没有中心装配文件**:绑定散落在各自域的实现文件里,靠 import 副作用收集。
|
||||
于是「import 这个包」=「加载全部注册」。**没有中心装配文件**:绑定散落在各自域的实现文件里,靠 import 副作用收集。而 Scope 创建时会把注册在该层的服务全部实例化(见场景 5),所以「import 即注册」就等于「注册即构造」——不需要在任何地方先 `get()` 一次来触发。
|
||||
|
||||
至此,任何人都能 `accessor.get(IGreeter)` 拿到这个全局唯一的服务。
|
||||
|
||||
|
|
@ -216,13 +216,16 @@ export class FlagService extends Disposable implements IFlagService {
|
|||
这一步引入:**`InstantiationType.Eager` vs `Delayed`**。
|
||||
|
||||
```ts
|
||||
// Eager(默认):第一次被解析(作为依赖或被 get)时同步构造,返回真实实例
|
||||
// Eager(默认):所在 Scope 创建时同步构造——import 触发注册,注册即被实例化,
|
||||
// 不需要任何人事先 get
|
||||
registerScopedService(LifecycleScope.App, ILogService, LogService, InstantiationType.Eager, 'log');
|
||||
|
||||
// Delayed:第一次被解析时先返回一个 Proxy,真实构造推迟到空闲时
|
||||
// Delayed:Scope 创建时只物化一个 Proxy,真实构造推迟到空闲时
|
||||
registerScopedService(LifecycleScope.App, IScopeRegistry, ScopeRegistry, InstantiationType.Delayed, 'gateway');
|
||||
```
|
||||
|
||||
每个 Scope(App / Session / Agent)创建时,容器会把注册在这一层的服务**全部**实例化:依赖图是静态已知的(`@IX` 记录在构造函数上),所以构造顺序自动按依赖关系拓扑展开,循环依赖照旧抛 `CyclicDependencyError`。任何一个服务构造失败,整个 Scope 创建失败。
|
||||
|
||||
Delayed 服务返回的是一个 **Proxy**:真实构造推迟到空闲回调,或在首次访问其属性 / 调用其方法时立即发生。即便还没构造好,别人提前订阅它的 `onDid…` / `onWill…` 事件也不会丢——容器会先记下监听器,实例真正出来后再回放订阅。
|
||||
|
||||
> 经验:默认即 `Eager`,绝大多数服务无需关心。只有「构造很贵、想推迟到空闲时」的服务才显式标 `Delayed`(遗留逃生舱,见场景 9.4)。
|
||||
|
|
@ -308,7 +311,7 @@ export class ScopeRegistry implements IScopeRegistry {
|
|||
- `instantiation.createChild(collection)` 造一个子容器,它的父指针指向当前容器——于是子容器能向上解析到 App 的服务(场景 3 的可见性规则)。
|
||||
- 给外部暴露时,用 `invokeFunction` 把子容器包成 `ServicesAccessor`(场景 6)。
|
||||
|
||||
> 更高层通常直接用 [`Scope.createChild(kind, id)`](../src/_base/di/scope.ts)(它帮你做了「筛描述符 + 建子容器」);只有需要手动控制 `ServiceCollection` 时才像上面这样写。
|
||||
> 更高层通常直接用 [`Scope.createChild(kind, id)`](../src/_base/di/scope.ts)(它帮你做了「筛描述符 + 建子容器 + eager 实例化整层服务」);只有需要手动控制 `ServiceCollection` 时才像上面这样写——注意手动 `createChild` 不会自动实例化,这一层的 `Eager` 服务会退回到首次 `get` 才构造。
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
1164
packages/agent-core-v2/docs/state-manifest.d.ts
vendored
Normal file
1164
packages/agent-core-v2/docs/state-manifest.d.ts
vendored
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -50,6 +50,7 @@
|
|||
"gen:contract-types": "node scripts/gen-contract-types.mjs",
|
||||
"gen:config-manifest": "tsx --import ../../build/register-raw-text-loader.mjs scripts/gen-config-manifest.mts",
|
||||
"gen:wire-manifest": "tsx --import ../../build/register-raw-text-loader.mjs scripts/gen-wire-manifest.mts",
|
||||
"gen:state-manifest": "tsx scripts/gen-state-manifest.mts",
|
||||
"lint:domain": "node scripts/check-domain-layers.mjs",
|
||||
"clean": "rm -rf dist",
|
||||
"dep-graph:analyze": "tsx scripts/dep-graph/cli.ts",
|
||||
|
|
|
|||
|
|
@ -102,6 +102,12 @@ const DOMAIN_LAYER = new Map([
|
|||
// Depends only on `_base`; sits in L1 beside the other program-control
|
||||
// layer substrates.
|
||||
['task', 1],
|
||||
// `state` is the per-scope keyed state container (`IStateService` /
|
||||
// `ISessionStateService` / `IAgentStateService`, one per scope tier under
|
||||
// `app/state`, `session/state`, `agent/state` — all resolve to this domain).
|
||||
// It wraps the `_base` `StateRegistry` and depends on nothing else, so any
|
||||
// domain may hold its plain-data state through it; sits in L1 beside `event`.
|
||||
['state', 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.
|
||||
|
|
|
|||
849
packages/agent-core-v2/scripts/gen-state-manifest.mts
Normal file
849
packages/agent-core-v2/scripts/gen-state-manifest.mts
Normal file
|
|
@ -0,0 +1,849 @@
|
|||
/**
|
||||
* Generates `docs/state-manifest.d.ts` — the single place to see every state
|
||||
* key registered into the Session-scope `ISessionStateService` or the
|
||||
* Agent-scope `IAgentStateService`.
|
||||
*
|
||||
* Pure static pass (state keys are registered inside DI scope constructors, so
|
||||
* there is no process-level registry to drain the way `gen-wire-manifest`
|
||||
* does):
|
||||
* 1. A ts-morph scan of `src/{agent,session}/**` collects every top-level
|
||||
* `defineState('name', ...)` key constant.
|
||||
* 2. Every `.register(key)` call site resolves its argument back to a key
|
||||
* constant (following imports); the key joins the scope of the
|
||||
* registering file (`src/session/**` → Session, `src/agent/**` → Agent).
|
||||
* A key that is defined but never registered is excluded.
|
||||
*
|
||||
* The output is a self-contained `.d.ts`: each key's value type is the
|
||||
* compile-time `StateKey<T>` parameter, expanded fully inline through the type
|
||||
* checker — no imports and no helper declarations. Every named type is marked
|
||||
* at its expansion site with an inline `TypeName — source/file.ts` comment;
|
||||
* recursion stops with a `TypeName — recursive` marker on an `unknown`. Generic
|
||||
* instantiations are expanded structurally and classes render as their public
|
||||
* instance shape; only lib globals (`Map`/`Set`/…) and a few noted external
|
||||
* ambient types keep their names.
|
||||
*
|
||||
* Usage:
|
||||
* pnpm --filter @moonshot-ai/agent-core-v2 gen:state-manifest # write the file
|
||||
* pnpm --filter @moonshot-ai/agent-core-v2 gen:state-manifest --check # freshness check (CI-style)
|
||||
*
|
||||
* Freshness is also enforced by `test/state/stateManifest.test.ts`.
|
||||
*/
|
||||
|
||||
import { readFileSync, writeFileSync } from 'node:fs';
|
||||
import { join, relative } from 'node:path';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
|
||||
import {
|
||||
Node,
|
||||
Project,
|
||||
SyntaxKind,
|
||||
ts,
|
||||
type Identifier,
|
||||
type Signature,
|
||||
type Symbol as MorphSymbol,
|
||||
type Type as MorphType,
|
||||
type VariableDeclaration,
|
||||
} from 'ts-morph';
|
||||
|
||||
const PKG = join(import.meta.dirname, '..');
|
||||
const REPO_ROOT = join(PKG, '..', '..');
|
||||
const SRC = join(PKG, 'src');
|
||||
export const MANIFEST_PATH = join(PKG, 'docs', 'state-manifest.d.ts');
|
||||
|
||||
/** src first-level directory → manifest section. */
|
||||
const SCOPES = [
|
||||
{
|
||||
dir: 'session',
|
||||
label: 'Session',
|
||||
interfaceName: 'SessionStateSnapshot',
|
||||
keyUnionName: 'SessionStateKey',
|
||||
},
|
||||
{
|
||||
dir: 'agent',
|
||||
label: 'Agent',
|
||||
interfaceName: 'AgentStateSnapshot',
|
||||
keyUnionName: 'AgentStateKey',
|
||||
},
|
||||
] as const;
|
||||
|
||||
type ScopeDir = (typeof SCOPES)[number]['dir'];
|
||||
|
||||
interface KeyDef {
|
||||
readonly constName: string;
|
||||
readonly keyName: string;
|
||||
/** Absolute path of the file defining the key constant. */
|
||||
readonly file: string;
|
||||
readonly exported: boolean;
|
||||
readonly declaration: VariableDeclaration;
|
||||
}
|
||||
|
||||
interface Registration {
|
||||
readonly def: KeyDef;
|
||||
readonly scope: ScopeDir;
|
||||
}
|
||||
|
||||
interface StateManifestModel {
|
||||
readonly registrations: readonly Registration[];
|
||||
/** Keys defined under the scope dirs but never registered (dead candidates). */
|
||||
readonly unregistered: readonly KeyDef[];
|
||||
}
|
||||
|
||||
function scopeDirOf(file: string): ScopeDir | undefined {
|
||||
const first = relative(SRC, file).split(/[\\/]/)[0];
|
||||
return SCOPES.some((scope) => scope.dir === first) ? (first as ScopeDir) : undefined;
|
||||
}
|
||||
|
||||
/** Package-root-relative posix path (used in index/comment columns). */
|
||||
function srcRelative(file: string): string {
|
||||
return relative(PKG, file).split('\\').join('/');
|
||||
}
|
||||
|
||||
/** Repo-root-relative posix path (used in type-name comments). */
|
||||
function repoRelative(file: string): string {
|
||||
return relative(REPO_ROOT, file).split('\\').join('/');
|
||||
}
|
||||
|
||||
/** Quote a property key only when it is not a plain identifier. */
|
||||
function tsFieldKey(key: string): string {
|
||||
return /^[$A-Z_a-z][$\w]*$/.test(key) ? key : JSON.stringify(key);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Static pass — key constants and their register call sites
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Pass 1 — every top-level `defineState('name', ...)` constant under the scope dirs. */
|
||||
function collectKeyDefs(project: Project): Map<VariableDeclaration, KeyDef> {
|
||||
const defs = new Map<VariableDeclaration, KeyDef>();
|
||||
for (const sf of project.getSourceFiles()) {
|
||||
if (scopeDirOf(sf.getFilePath()) === undefined) continue;
|
||||
for (const statement of sf.getVariableStatements()) {
|
||||
for (const declaration of statement.getDeclarations()) {
|
||||
const initializer = declaration.getInitializer();
|
||||
if (initializer === undefined || !Node.isCallExpression(initializer)) continue;
|
||||
if (initializer.getExpression().getText() !== 'defineState') continue;
|
||||
const [nameArg] = initializer.getArguments();
|
||||
if (nameArg === undefined || !Node.isStringLiteral(nameArg)) continue;
|
||||
defs.set(declaration, {
|
||||
constName: declaration.getName(),
|
||||
keyName: nameArg.getLiteralValue(),
|
||||
file: sf.getFilePath(),
|
||||
exported: statement.isExported(),
|
||||
declaration,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
return defs;
|
||||
}
|
||||
|
||||
/** Resolve a `.register(...)` argument back to its `defineState` constant. */
|
||||
function resolveKeyDef(
|
||||
identifier: Identifier,
|
||||
defs: ReadonlyMap<VariableDeclaration, KeyDef>,
|
||||
): KeyDef | undefined {
|
||||
for (const info of identifier.getDefinitions()) {
|
||||
const node = info.getDeclarationNode();
|
||||
if (node !== undefined && Node.isVariableDeclaration(node)) {
|
||||
const def = defs.get(node);
|
||||
if (def !== undefined) return def;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** Pass 2 — every `.register(key)` call site whose argument is a state key. */
|
||||
function collectRegistrations(
|
||||
project: Project,
|
||||
defs: ReadonlyMap<VariableDeclaration, KeyDef>,
|
||||
): Registration[] {
|
||||
const registrations: Registration[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const sf of project.getSourceFiles()) {
|
||||
const scope = scopeDirOf(sf.getFilePath());
|
||||
if (scope === undefined) continue;
|
||||
for (const call of sf.getDescendantsOfKind(SyntaxKind.CallExpression)) {
|
||||
const expression = call.getExpression();
|
||||
if (!Node.isPropertyAccessExpression(expression) || expression.getName() !== 'register') {
|
||||
continue;
|
||||
}
|
||||
const args = call.getArguments();
|
||||
const [arg] = args;
|
||||
if (args.length !== 1 || arg === undefined || !Node.isIdentifier(arg)) continue;
|
||||
const def = resolveKeyDef(arg, defs);
|
||||
if (def === undefined) continue;
|
||||
if (!def.exported) {
|
||||
throw new Error(
|
||||
`[gen-state-manifest] state key '${def.keyName}' (${srcRelative(def.file)}) is ` +
|
||||
'registered but its key constant is not exported — the manifest cannot reference it.',
|
||||
);
|
||||
}
|
||||
const dedupe = `${scope}:${def.keyName}`;
|
||||
if (seen.has(dedupe)) {
|
||||
throw new Error(
|
||||
`[gen-state-manifest] state key '${def.keyName}' is registered twice in ${scope} scope.`,
|
||||
);
|
||||
}
|
||||
seen.add(dedupe);
|
||||
registrations.push({ def, scope });
|
||||
}
|
||||
}
|
||||
return registrations;
|
||||
}
|
||||
|
||||
function createProject(): Project {
|
||||
const project = new Project({
|
||||
tsConfigFilePath: join(PKG, 'tsconfig.json'),
|
||||
skipAddingFilesFromTsConfig: true,
|
||||
});
|
||||
project.addSourceFilesAtPaths(join(SRC, '**', '*.ts'));
|
||||
return project;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Type expansion — render every key's value type fully inline.
|
||||
//
|
||||
// Every named type declared in the repo is expanded at the use site and marked
|
||||
// with a `/* TypeName — source/file.ts */` comment; a recursion point stops
|
||||
// with a `/* TypeName — recursive (...) */ unknown` marker. Types from lib
|
||||
// (`Map`, `Set`, `Date`, …) or node_modules are ambient and keep their names
|
||||
// (type arguments are still rendered recursively). Generic instantiations and
|
||||
// anonymous shapes are expanded structurally from their apparent members, so
|
||||
// the checker always hands us substituted, concrete member types.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const NO_TRUNCATION = ts.TypeFormatFlags.NoTruncation;
|
||||
|
||||
class TypeRenderer {
|
||||
private readonly checker: ts.TypeChecker;
|
||||
/** Cycle guard for anonymous / generic-instantiation structural expansion. */
|
||||
private readonly expanding = new Set<ts.Type>();
|
||||
/** Named types currently being expanded along this path (recursion guard). */
|
||||
private readonly expandingNamed: ts.Symbol[] = [];
|
||||
/** Ambient names kept as-is whose declaration lives outside the TS lib. */
|
||||
readonly externals = new Set<string>();
|
||||
/** Degradations worth reporting (cycle fallbacks). */
|
||||
readonly warnings = new Set<string>();
|
||||
|
||||
constructor(private readonly project: Project) {
|
||||
this.checker = project.getTypeChecker().compilerObject;
|
||||
}
|
||||
|
||||
/** Render the value type `T` of a key's `StateKey<T>`. */
|
||||
renderKeyType(def: KeyDef): string {
|
||||
const valueType = def.declaration.getType().getTypeArguments()[0];
|
||||
if (valueType === undefined) {
|
||||
throw new Error(
|
||||
`[gen-state-manifest] cannot resolve the value type of '${def.keyName}' (${srcRelative(def.file)}).`,
|
||||
);
|
||||
}
|
||||
return this.renderType(valueType, def.declaration, 0);
|
||||
}
|
||||
|
||||
// -- core dispatch --------------------------------------------------------
|
||||
|
||||
private renderType(
|
||||
type: MorphType,
|
||||
location: Node,
|
||||
depth: number,
|
||||
skipSymbol?: ts.Symbol,
|
||||
): string {
|
||||
if (depth > 40) return this.fallback(type, location, 'depth cap');
|
||||
|
||||
// A single enum-literal type (e.g. `FaultKind.A`) — value + enum comment.
|
||||
if ((type.getFlags() & ts.TypeFlags.EnumLiteral) !== 0) {
|
||||
return this.renderEnumLiteral(type);
|
||||
}
|
||||
|
||||
// The boolean union (`false | true`) collapses to `boolean`.
|
||||
if (type.isUnion() && (type.getFlags() & ts.TypeFlags.Boolean) !== 0) return 'boolean';
|
||||
|
||||
if (type.isUnion()) {
|
||||
const alias = this.tryRenderAlias(type, location, depth, skipSymbol);
|
||||
if (alias !== undefined) return alias;
|
||||
const enumUnion = this.tryRenderEnumUnion(type);
|
||||
if (enumUnion !== undefined) return enumUnion;
|
||||
return this.renderUnionMembers(type.getUnionTypes(), location, depth);
|
||||
}
|
||||
|
||||
if (type.isIntersection()) {
|
||||
const alias = this.tryRenderAlias(type, location, depth, skipSymbol);
|
||||
if (alias !== undefined) return alias;
|
||||
return type
|
||||
.getIntersectionTypes()
|
||||
.map((member) => this.renderType(member, location, depth + 1))
|
||||
.join(' & ');
|
||||
}
|
||||
|
||||
if (this.isLeaf(type)) return this.leafText(type);
|
||||
|
||||
if (type.isTuple()) {
|
||||
const elements = type
|
||||
.getTupleElements()
|
||||
.map((element) => this.renderType(element, location, depth + 1));
|
||||
return `[${elements.join(', ')}]`;
|
||||
}
|
||||
|
||||
if (type.isArray()) {
|
||||
const element = type.getArrayElementType();
|
||||
if (element === undefined) return this.fallback(type, location, 'array without element');
|
||||
const rendered = this.renderType(element, location, depth + 1);
|
||||
const text =
|
||||
element.isUnion() || element.isIntersection() ? `(${rendered})[]` : `${rendered}[]`;
|
||||
return type.getSymbol()?.getName() === 'ReadonlyArray' ? `readonly ${text}` : text;
|
||||
}
|
||||
|
||||
if (type.isObject()) {
|
||||
return this.renderObjectType(type, location, depth, skipSymbol);
|
||||
}
|
||||
|
||||
return this.fallback(type, location, 'unhandled type kind');
|
||||
}
|
||||
|
||||
/**
|
||||
* Render union members: a `false | true` pair anywhere collapses to
|
||||
* `boolean`, `null`/`undefined` sort last, duplicates removed, and parens
|
||||
* are only added when the union actually has multiple members.
|
||||
*/
|
||||
private renderUnionMembers(
|
||||
members: readonly MorphType[],
|
||||
location: Node,
|
||||
depth: number,
|
||||
): string {
|
||||
const booleanLiterals = members.filter((m) => m.isBooleanLiteral());
|
||||
const collapseBoolean =
|
||||
booleanLiterals.length === 2 &&
|
||||
new Set(booleanLiterals.map((m) => this.leafText(m))).size === 2;
|
||||
const rest = collapseBoolean ? members.filter((m) => !m.isBooleanLiteral()) : members;
|
||||
const rank = (type: MorphType): number => (type.isNull() ? 1 : type.isUndefined() ? 2 : 0);
|
||||
const rendered = rest
|
||||
.map((member) => ({ member, rank: rank(member) }))
|
||||
.toSorted((a, b) => a.rank - b.rank)
|
||||
.map(({ member }) => ({
|
||||
member,
|
||||
text: this.renderType(member, location, depth + 1),
|
||||
}));
|
||||
const multi = rendered.length + (collapseBoolean ? 1 : 0) > 1;
|
||||
const parts = rendered.map(({ member, text }) =>
|
||||
multi && this.needsParensInUnion(member) ? `(${text})` : text,
|
||||
);
|
||||
if (collapseBoolean) parts.unshift('boolean');
|
||||
return [...new Set(parts)].join(' | ');
|
||||
}
|
||||
|
||||
private isLeaf(type: MorphType): boolean {
|
||||
const flags = type.getFlags();
|
||||
return (
|
||||
type.isString() ||
|
||||
type.isNumber() ||
|
||||
type.isBoolean() ||
|
||||
type.isStringLiteral() ||
|
||||
type.isNumberLiteral() ||
|
||||
type.isBooleanLiteral() ||
|
||||
type.isNull() ||
|
||||
type.isUndefined() ||
|
||||
type.isUnknown() ||
|
||||
type.isAny() ||
|
||||
type.isNever() ||
|
||||
type.isTypeParameter() ||
|
||||
(flags &
|
||||
(ts.TypeFlags.Void |
|
||||
ts.TypeFlags.BigInt |
|
||||
ts.TypeFlags.BigIntLiteral |
|
||||
ts.TypeFlags.ESSymbol |
|
||||
ts.TypeFlags.UniqueESSymbol |
|
||||
ts.TypeFlags.TemplateLiteral)) !==
|
||||
0
|
||||
);
|
||||
}
|
||||
|
||||
/** typeToString is only safe on leaf types (never emits `import(...)`). */
|
||||
private leafText(type: MorphType): string {
|
||||
const text = this.checker.typeToString(type.compilerType, undefined, NO_TRUNCATION);
|
||||
// Normalize double-quoted string literals to the repo's single-quote style.
|
||||
if (text.length >= 2 && text.startsWith('"') && text.endsWith('"')) {
|
||||
const value = JSON.parse(text) as string;
|
||||
return value.includes("'") ? JSON.stringify(value) : `'${value}'`;
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
private needsParensInUnion(type: MorphType): boolean {
|
||||
return type.isIntersection() || (type.isObject() && type.getCallSignatures().length > 0);
|
||||
}
|
||||
|
||||
private fallback(type: MorphType, location: Node, reason: string): string {
|
||||
const text = this.checker.typeToString(type.compilerType, location.compilerNode, NO_TRUNCATION);
|
||||
this.warnings.add(`${reason}: fell back to '${text.slice(0, 80)}'`);
|
||||
return text;
|
||||
}
|
||||
|
||||
// -- enums ----------------------------------------------------------------
|
||||
|
||||
/** The literal value of an enum-literal type, quoted TS-style. */
|
||||
private enumLiteralValue(type: MorphType): string {
|
||||
const value = (type.compilerType as ts.LiteralType).value;
|
||||
if (typeof value === 'string') {
|
||||
return value.includes("'") ? JSON.stringify(value) : `'${value}'`;
|
||||
}
|
||||
if (typeof value === 'number') return String(value);
|
||||
return this.leafText(type);
|
||||
}
|
||||
|
||||
/** The enum declaration backing an enum-literal type, if any. */
|
||||
private enumDeclOf(type: MorphType): Node | undefined {
|
||||
const memberDecl = type.getSymbol()?.getDeclarations()[0];
|
||||
if (memberDecl === undefined || !Node.isEnumMember(memberDecl)) return undefined;
|
||||
return memberDecl.getParent();
|
||||
}
|
||||
|
||||
private renderEnumLiteral(type: MorphType): string {
|
||||
const enumDecl = this.enumDeclOf(type);
|
||||
const text = this.enumLiteralValue(type);
|
||||
if (enumDecl !== undefined && Node.isEnumDeclaration(enumDecl)) {
|
||||
const sym = enumDecl.getSymbol();
|
||||
if (sym !== undefined) {
|
||||
return `/* ${sym.getName()} — ${repoRelative(enumDecl.getSourceFile().getFilePath())} */ ${text}`;
|
||||
}
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
/** Collapse a union covering every member of one enum: comment + values. */
|
||||
private tryRenderEnumUnion(type: MorphType): string | undefined {
|
||||
const members = type.getUnionTypes();
|
||||
if (members.length === 0) return undefined;
|
||||
let enumDecl: Node | undefined;
|
||||
for (const member of members) {
|
||||
if ((member.getFlags() & ts.TypeFlags.EnumLiteral) === 0) return undefined;
|
||||
const parent = this.enumDeclOf(member);
|
||||
if (parent === undefined) return undefined;
|
||||
if (enumDecl === undefined) enumDecl = parent;
|
||||
else if (parent !== enumDecl) return undefined;
|
||||
}
|
||||
if (enumDecl === undefined || !Node.isEnumDeclaration(enumDecl)) return undefined;
|
||||
if (enumDecl.getMembers().length !== members.length) return undefined;
|
||||
const sym = enumDecl.getSymbol();
|
||||
if (sym === undefined) return undefined;
|
||||
const values = members.map((member) => this.enumLiteralValue(member));
|
||||
return `/* ${sym.getName()} — ${repoRelative(enumDecl.getSourceFile().getFilePath())} */ ${values.join(' | ')}`;
|
||||
}
|
||||
|
||||
// -- named-type annotation --------------------------------------------------
|
||||
|
||||
/**
|
||||
* Where do the symbol's declarations live: repo ('named' — expand inline
|
||||
* with a name comment), lib/node_modules ('ambient' — keep the name), or
|
||||
* mixed/anonymous ('inline' — expand without a comment).
|
||||
*/
|
||||
private classify(sym: MorphSymbol): 'named' | 'ambient' | 'inline' {
|
||||
const decls = sym.getDeclarations();
|
||||
if (decls.length === 0) return 'inline';
|
||||
const inNodeModules = (file: string) => /[\\/]node_modules[\\/]/.test(file);
|
||||
if (decls.every((d) => !inNodeModules(d.getSourceFile().getFilePath()))) {
|
||||
const named = decls.some(
|
||||
(d) =>
|
||||
Node.isInterfaceDeclaration(d) ||
|
||||
Node.isClassDeclaration(d) ||
|
||||
Node.isTypeAliasDeclaration(d) ||
|
||||
Node.isEnumDeclaration(d),
|
||||
);
|
||||
const generic = decls.some(
|
||||
(d) =>
|
||||
(Node.isInterfaceDeclaration(d) ||
|
||||
Node.isClassDeclaration(d) ||
|
||||
Node.isTypeAliasDeclaration(d)) &&
|
||||
d.getTypeParameters().length > 0,
|
||||
);
|
||||
return named && !generic ? 'named' : 'inline';
|
||||
}
|
||||
if (decls.every((d) => inNodeModules(d.getSourceFile().getFilePath()))) return 'ambient';
|
||||
return 'inline';
|
||||
}
|
||||
|
||||
private noteExternal(sym: MorphSymbol): void {
|
||||
const isTsLib = (file: string) => /[\\/]node_modules[\\/]typescript[\\/]lib[\\/]/.test(file);
|
||||
if (sym.getDeclarations().some((d) => !isTsLib(d.getSourceFile().getFilePath()))) {
|
||||
this.externals.add(sym.getName());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* `Name — origin` comment prefixed to the expansion. A named type already
|
||||
* on the expansion path stops with a recursion marker instead.
|
||||
*/
|
||||
private renderNamed(sym: MorphSymbol, expand: () => string): string {
|
||||
const decl = sym.getDeclarations()[0];
|
||||
const origin =
|
||||
decl !== undefined
|
||||
? repoRelative(decl.getSourceFile().getFilePath())
|
||||
: '(unknown source)';
|
||||
const name = sym.getName();
|
||||
if (this.expandingNamed.includes(sym.compilerSymbol)) {
|
||||
return `/* ${name} — recursive (${origin}) */ unknown`;
|
||||
}
|
||||
this.expandingNamed.push(sym.compilerSymbol);
|
||||
try {
|
||||
return `/* ${name} — ${origin} */ ${expand()}`;
|
||||
} finally {
|
||||
this.expandingNamed.pop();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a type through the alias it was referenced with, when that alias is
|
||||
* worth keeping: a repo-declared non-generic alias expands inline under its
|
||||
* name comment; a lib or node_modules alias (`Readonly`, `Record`,
|
||||
* `Partial`, …) is referenced as `Name<args>` with recursive arguments.
|
||||
* `skipSymbol` suppresses the alias's own annotation while its right-hand
|
||||
* side is being rendered (the alias type still carries itself as aliasSymbol).
|
||||
*/
|
||||
private tryRenderAlias(
|
||||
type: MorphType,
|
||||
location: Node,
|
||||
depth: number,
|
||||
skipSymbol?: ts.Symbol,
|
||||
): string | undefined {
|
||||
const alias = type.getAliasSymbol();
|
||||
if (alias === undefined || alias.compilerSymbol === skipSymbol) return undefined;
|
||||
const kind = this.classify(alias);
|
||||
if (kind === 'named') {
|
||||
const decl = alias.getDeclarations().find((d) => Node.isTypeAliasDeclaration(d));
|
||||
if (decl === undefined || !Node.isTypeAliasDeclaration(decl)) return undefined;
|
||||
return this.renderNamed(alias, () =>
|
||||
this.renderType(decl.getType(), decl, depth + 1, alias.compilerSymbol),
|
||||
);
|
||||
}
|
||||
if (kind === 'ambient') {
|
||||
const args = type.getAliasTypeArguments();
|
||||
if (args.length === 0) return undefined;
|
||||
this.noteExternal(alias);
|
||||
const rendered = args.map((arg) => this.renderType(arg, location, depth + 1));
|
||||
return `${alias.getName()}<${rendered.join(', ')}>`;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// -- object types -----------------------------------------------------------
|
||||
|
||||
private renderObjectType(
|
||||
type: MorphType,
|
||||
location: Node,
|
||||
depth: number,
|
||||
skipSymbol?: ts.Symbol,
|
||||
): string {
|
||||
const alias = this.tryRenderAlias(type, location, depth, skipSymbol);
|
||||
if (alias !== undefined) return alias;
|
||||
const sym = type.getSymbol();
|
||||
const typeArgs = type.getTypeArguments();
|
||||
// `__type`/`__object` are checker names for anonymous shapes — they are
|
||||
// never real symbols, so skip the named-type paths and expand structurally.
|
||||
const anonymous = sym === undefined || /^__(type|object)$/.test(sym.getName());
|
||||
if (!anonymous && sym.compilerSymbol !== skipSymbol) {
|
||||
const kind = this.classify(sym);
|
||||
if (kind === 'named' && typeArgs.length === 0) {
|
||||
return this.renderNamed(sym, () => this.renderStructural(type, location, depth));
|
||||
}
|
||||
if (kind === 'ambient') {
|
||||
this.noteExternal(sym);
|
||||
const name = sym.getName();
|
||||
if (typeArgs.length === 0) return name;
|
||||
const args = typeArgs.map((arg) => this.renderType(arg, location, depth + 1));
|
||||
return `${name}<${args.join(', ')}>`;
|
||||
}
|
||||
}
|
||||
return this.renderStructural(type, location, depth);
|
||||
}
|
||||
|
||||
/** Structural rendering from the type's apparent members (braced or arrow). */
|
||||
private renderStructural(type: MorphType, location: Node, depth: number): string {
|
||||
// Cycle guard for self-referential instantiations expanded inline.
|
||||
if (this.expanding.has(type.compilerType)) {
|
||||
return this.fallback(type, location, 'cycle expanding');
|
||||
}
|
||||
this.expanding.add(type.compilerType);
|
||||
try {
|
||||
const callSignatures = type.getCallSignatures();
|
||||
const props = type.getProperties().filter((prop) => this.isPublic(prop));
|
||||
const stringIndex = type.getStringIndexType();
|
||||
const numberIndex = type.getNumberIndexType();
|
||||
if (
|
||||
callSignatures.length > 0 &&
|
||||
props.length === 0 &&
|
||||
stringIndex === undefined &&
|
||||
numberIndex === undefined
|
||||
) {
|
||||
if (callSignatures.length === 1 && callSignatures[0] !== undefined) {
|
||||
return this.renderSignature(callSignatures[0], location, depth, 'arrow');
|
||||
}
|
||||
return callSignatures
|
||||
.map((sig) => `(${this.renderSignature(sig, location, depth, 'arrow')})`)
|
||||
.join(' & ');
|
||||
}
|
||||
const body = this.renderObjectBody(type, location, depth, callSignatures, props);
|
||||
if (body.length === 0) return '{}';
|
||||
return `{\n${body.join('\n')}\n}`;
|
||||
} finally {
|
||||
this.expanding.delete(type.compilerType);
|
||||
}
|
||||
}
|
||||
|
||||
/** Member lines of an object type, each indented by two spaces. */
|
||||
private renderObjectBody(
|
||||
type: MorphType,
|
||||
location: Node,
|
||||
depth: number,
|
||||
callSignatures?: readonly Signature[],
|
||||
props?: readonly MorphSymbol[],
|
||||
): string[] {
|
||||
const lines: string[] = [];
|
||||
for (const sig of callSignatures ?? type.getCallSignatures()) {
|
||||
lines.push(` ${this.renderSignature(sig, location, depth, 'call')};`);
|
||||
}
|
||||
for (const prop of props ?? type.getProperties().filter((p) => this.isPublic(p))) {
|
||||
const decl = prop.getDeclarations()[0];
|
||||
const at = decl ?? location;
|
||||
const propType = prop.getTypeAtLocation(at);
|
||||
const optional = (prop.getFlags() & ts.SymbolFlags.Optional) !== 0;
|
||||
// An optional prop's `| undefined` is redundant with the `?` — drop it.
|
||||
const rendered =
|
||||
optional && propType.isUnion()
|
||||
? this.renderUnionMembers(
|
||||
propType.getUnionTypes().filter((member) => !member.isUndefined()),
|
||||
at,
|
||||
depth,
|
||||
)
|
||||
: this.renderType(propType, at, depth + 1);
|
||||
const readonly =
|
||||
decl !== undefined && Node.isPropertySignature(decl) && decl.isReadonly()
|
||||
? 'readonly '
|
||||
: '';
|
||||
const propLines = rendered.split('\n');
|
||||
propLines[propLines.length - 1] += ';';
|
||||
lines.push(
|
||||
` ${readonly}${tsFieldKey(prop.getName())}${optional ? '?' : ''}: ${propLines[0]}`,
|
||||
...propLines.slice(1).map((line) => ` ${line}`),
|
||||
);
|
||||
}
|
||||
const stringIndex = type.getStringIndexType();
|
||||
if (stringIndex !== undefined) {
|
||||
lines.push(` [key: string]: ${this.renderType(stringIndex, location, depth + 1)};`);
|
||||
}
|
||||
const numberIndex = type.getNumberIndexType();
|
||||
if (numberIndex !== undefined) {
|
||||
lines.push(` [key: number]: ${this.renderType(numberIndex, location, depth + 1)};`);
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
|
||||
private renderSignature(
|
||||
sig: Signature,
|
||||
location: Node,
|
||||
depth: number,
|
||||
style: 'arrow' | 'call',
|
||||
): string {
|
||||
const params: string[] = [];
|
||||
for (const param of sig.getParameters()) {
|
||||
if (param.getName() === 'this') continue;
|
||||
const decl = param.getDeclarations()[0];
|
||||
const at = decl ?? location;
|
||||
const paramType = param.getTypeAtLocation(at);
|
||||
let optional = (param.getFlags() & ts.SymbolFlags.Optional) !== 0;
|
||||
let rest = false;
|
||||
if (decl !== undefined && Node.isParameterDeclaration(decl)) {
|
||||
optional = optional || decl.hasQuestionToken() || decl.getInitializer() !== undefined;
|
||||
rest = decl.isRestParameter();
|
||||
}
|
||||
const rendered =
|
||||
optional && paramType.isUnion()
|
||||
? this.renderUnionMembers(
|
||||
paramType.getUnionTypes().filter((member) => !member.isUndefined()),
|
||||
at,
|
||||
depth,
|
||||
)
|
||||
: this.renderType(paramType, at, depth + 1);
|
||||
params.push(`${rest ? '...' : ''}${param.getName()}${optional ? '?' : ''}: ${rendered}`);
|
||||
}
|
||||
const returnType = this.renderType(sig.getReturnType(), location, depth + 1);
|
||||
return style === 'arrow'
|
||||
? `(${params.join(', ')}) => ${returnType}`
|
||||
: `(${params.join(', ')}): ${returnType}`;
|
||||
}
|
||||
|
||||
private isPublic(prop: MorphSymbol): boolean {
|
||||
if (prop.getName().startsWith('#')) return false;
|
||||
return !prop.getDeclarations().some((decl) => {
|
||||
const modifiers = ts.canHaveModifiers(decl.compilerNode)
|
||||
? ts.getModifiers(decl.compilerNode)
|
||||
: undefined;
|
||||
return (
|
||||
modifiers !== undefined &&
|
||||
modifiers.some(
|
||||
(m) =>
|
||||
m.kind === ts.SyntaxKind.PrivateKeyword || m.kind === ts.SyntaxKind.ProtectedKeyword,
|
||||
)
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Manifest rendering
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function renderManifest(
|
||||
model: StateManifestModel,
|
||||
project: Project,
|
||||
): { manifest: string; warnings: readonly string[] } {
|
||||
const renderer = new TypeRenderer(project);
|
||||
const byScope = new Map<ScopeDir, Registration[]>();
|
||||
for (const scope of SCOPES) byScope.set(scope.dir, []);
|
||||
for (const registration of model.registrations) byScope.get(registration.scope)?.push(registration);
|
||||
for (const [dir, regs] of byScope) {
|
||||
byScope.set(
|
||||
dir,
|
||||
regs.toSorted((a, b) => a.def.keyName.localeCompare(b.def.keyName)),
|
||||
);
|
||||
}
|
||||
|
||||
// Snapshot interfaces — rendering these fills the external-name registry.
|
||||
const sections: string[] = [];
|
||||
for (const scope of SCOPES) {
|
||||
const regs = byScope.get(scope.dir) ?? [];
|
||||
const lines = [
|
||||
`/** ${scope.label}-scope keys registered into I${scope.label}StateService. */`,
|
||||
`export interface ${scope.interfaceName} {`,
|
||||
];
|
||||
const byFile = new Map<string, Registration[]>();
|
||||
for (const r of regs) {
|
||||
const list = byFile.get(r.def.file) ?? [];
|
||||
list.push(r);
|
||||
byFile.set(r.def.file, list);
|
||||
}
|
||||
for (const file of [...byFile.keys()].toSorted()) {
|
||||
lines.push(` // ${srcRelative(file)}`);
|
||||
for (const r of byFile.get(file) ?? []) {
|
||||
const rendered = renderer.renderKeyType(r.def).split('\n');
|
||||
rendered[rendered.length - 1] += ';';
|
||||
lines.push(` '${r.def.keyName}': ${rendered[0]}`, ...rendered.slice(1).map((l) => ` ${l}`));
|
||||
}
|
||||
}
|
||||
lines.push('}');
|
||||
lines.push('');
|
||||
lines.push(`export type ${scope.keyUnionName} = keyof ${scope.interfaceName};`);
|
||||
sections.push(lines.join('\n'));
|
||||
}
|
||||
|
||||
const counts = SCOPES.map((s) => `${s.label}: ${byScope.get(s.dir)?.length ?? 0} keys`).join(
|
||||
' · ',
|
||||
);
|
||||
const out: string[] = [
|
||||
'// Session & Agent State Manifest',
|
||||
'//',
|
||||
'// Generated by scripts/gen-state-manifest.mts — do not edit by hand.',
|
||||
'// Regenerate with: pnpm --filter @moonshot-ai/agent-core-v2 gen:state-manifest',
|
||||
'//',
|
||||
'// Every state key registered into the Session-scope ISessionStateService or the',
|
||||
'// Agent-scope IAgentStateService (see src/_base/state/stateRegistry.ts), collected',
|
||||
'// statically from the `states.register(...)` call sites — a key defined via',
|
||||
'// defineState but never registered does not appear here. Each entry shows the',
|
||||
'// compile-time StateKey<T> value type fully expanded inline, so the manifest is',
|
||||
'// self-contained (no imports, no helper declarations). A named type is marked',
|
||||
'// at its expansion site with a `/* TypeName — source/file.ts */` comment; a',
|
||||
'// `/* TypeName — recursive (...) */ unknown` marker stops a recursive expansion.',
|
||||
'// Lib globals (Map/Set/Record/…) are referenced as-is. Generic instantiations',
|
||||
'// expand structurally; classes render as their public instance shape. The',
|
||||
'// defining source file heads each group.',
|
||||
];
|
||||
if (renderer.externals.size > 0) {
|
||||
out.push(
|
||||
'//',
|
||||
`// External ambient types referenced but not expanded (from node_modules): ${[...renderer.externals].toSorted().join(', ')}`,
|
||||
);
|
||||
}
|
||||
out.push(
|
||||
'//',
|
||||
'// snapshot() returns JSON-safe deep copies of these values: Maps become plain',
|
||||
'// objects (or [key, value] entry arrays when a key is not string/number), Sets',
|
||||
'// become arrays, bigints become strings, functions are dropped, circular',
|
||||
"// references become '(circular)', and class instances collapse to a '(ClassName)'",
|
||||
'// marker — the wire shape of an entry is the JSON projection of the type here.',
|
||||
'//',
|
||||
`// Index (${counts})`,
|
||||
);
|
||||
for (const scope of SCOPES) {
|
||||
const regs = byScope.get(scope.dir) ?? [];
|
||||
const width = Math.max(0, ...regs.map((r) => r.def.keyName.length));
|
||||
out.push(`// ${scope.label}`);
|
||||
for (const r of regs) {
|
||||
out.push(`// ${r.def.keyName.padEnd(width)} ${srcRelative(r.def.file)}`);
|
||||
}
|
||||
}
|
||||
out.push('');
|
||||
out.push(...sections.flatMap((section) => [section, '']));
|
||||
return { manifest: `${out.join('\n').trimEnd()}\n`, warnings: [...renderer.warnings] };
|
||||
}
|
||||
|
||||
interface BuildResult {
|
||||
readonly model: StateManifestModel;
|
||||
readonly manifest: string;
|
||||
readonly warnings: readonly string[];
|
||||
}
|
||||
|
||||
function buildAll(): BuildResult {
|
||||
const project = createProject();
|
||||
const defs = collectKeyDefs(project);
|
||||
const registrations = collectRegistrations(project, defs);
|
||||
const registered = new Set(registrations.map((r) => r.def));
|
||||
const unregistered = [...defs.values()].filter((def) => !registered.has(def));
|
||||
const model: StateManifestModel = { registrations, unregistered };
|
||||
const { manifest, warnings } = renderManifest(model, project);
|
||||
return { model, manifest, warnings };
|
||||
}
|
||||
|
||||
export function buildStateManifest(): string {
|
||||
return buildAll().manifest;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CLI
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function main(): void {
|
||||
const check = process.argv.includes('--check');
|
||||
const { model, manifest, warnings } = buildAll();
|
||||
for (const warning of warnings) {
|
||||
console.warn(`[gen-state-manifest] warning: ${warning}`);
|
||||
}
|
||||
if (check) {
|
||||
let current: string | undefined;
|
||||
try {
|
||||
current = readFileSync(MANIFEST_PATH, 'utf-8');
|
||||
} catch {
|
||||
current = undefined;
|
||||
}
|
||||
if (current !== manifest) {
|
||||
console.error(
|
||||
`[gen-state-manifest] ${relative(process.cwd(), MANIFEST_PATH)} is stale. ` +
|
||||
'Regenerate with `pnpm --filter @moonshot-ai/agent-core-v2 gen:state-manifest`.',
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log('[gen-state-manifest] up to date');
|
||||
return;
|
||||
}
|
||||
writeFileSync(MANIFEST_PATH, manifest);
|
||||
console.log(`[gen-state-manifest] wrote ${relative(process.cwd(), MANIFEST_PATH)}`);
|
||||
if (model.unregistered.length > 0) {
|
||||
console.log(
|
||||
`[gen-state-manifest] note: ${model.unregistered.length} defineState key(s) never registered (excluded):`,
|
||||
);
|
||||
for (const def of model.unregistered) {
|
||||
console.log(` - ${def.keyName} (${srcRelative(def.file)})`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (process.argv[1] !== undefined && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
||||
main();
|
||||
}
|
||||
|
|
@ -1,5 +1,13 @@
|
|||
/**
|
||||
* `di` domain (L0) — DI Scope tree (`Scope`, `LifecycleScope`) and scoped service registry.
|
||||
*
|
||||
* Creating a scope eagerly instantiates every service registered for it:
|
||||
* registration plus scope creation construct a side-effect service, nobody
|
||||
* has to `get()` it first. Each `get` runs a DFS over the static dependency
|
||||
* graph, so dependencies are always constructed before their dependents and
|
||||
* cycles still throw `CyclicDependencyError`; `Delayed` descriptors only
|
||||
* materialize their lazy proxy at scope creation — the real constructor
|
||||
* still defers to first property access.
|
||||
*/
|
||||
|
||||
import { SyncDescriptor } from './descriptors';
|
||||
|
|
@ -89,6 +97,15 @@ function buildCollection(kind: LifecycleScope, extra?: ScopeSeed): ServiceCollec
|
|||
return collection;
|
||||
}
|
||||
|
||||
function instantiateAll(
|
||||
instantiation: IInstantiationService,
|
||||
collection: ServiceCollection,
|
||||
): void {
|
||||
collection.forEach((id) => {
|
||||
instantiation.invokeFunction((accessor) => accessor.get(id));
|
||||
});
|
||||
}
|
||||
|
||||
export function createScopedChildHandle(
|
||||
parent: IInstantiationService,
|
||||
kind: LifecycleScope,
|
||||
|
|
@ -97,6 +114,12 @@ export function createScopedChildHandle(
|
|||
): IScopeHandle {
|
||||
const collection = buildCollection(kind, options.extra);
|
||||
const child = parent.createChild(collection);
|
||||
try {
|
||||
instantiateAll(child, collection);
|
||||
} catch (error) {
|
||||
child.dispose();
|
||||
throw error;
|
||||
}
|
||||
const accessor: ServicesAccessor = {
|
||||
get: <T>(serviceId: ServiceIdentifier<T>): T =>
|
||||
child.invokeFunction((a) => a.get(serviceId)),
|
||||
|
|
@ -127,6 +150,12 @@ export class Scope implements IDisposable {
|
|||
const kind = LifecycleScope.App;
|
||||
const collection = buildCollection(kind, options.extra);
|
||||
const instantiation = new InstantiationService(collection, true);
|
||||
try {
|
||||
instantiateAll(instantiation, collection);
|
||||
} catch (error) {
|
||||
instantiation.dispose();
|
||||
throw error;
|
||||
}
|
||||
return new Scope(options.id ?? 'app', kind, instantiation);
|
||||
}
|
||||
|
||||
|
|
@ -148,6 +177,12 @@ export class Scope implements IDisposable {
|
|||
}
|
||||
const collection = buildCollection(kind, options.extra);
|
||||
const childInstantiation = this.instantiation.createChild(collection);
|
||||
try {
|
||||
instantiateAll(childInstantiation, collection);
|
||||
} catch (error) {
|
||||
childInstantiation.dispose();
|
||||
throw error;
|
||||
}
|
||||
const child = new Scope(id, kind, childInstantiation, this);
|
||||
this.children.set(id, child);
|
||||
return child;
|
||||
|
|
|
|||
153
packages/agent-core-v2/src/_base/state/stateRegistry.ts
Normal file
153
packages/agent-core-v2/src/_base/state/stateRegistry.ts
Normal file
|
|
@ -0,0 +1,153 @@
|
|||
/**
|
||||
* `state` domain (L0) — scope-agnostic keyed state container primitives.
|
||||
*
|
||||
* Owns the typed `StateKey<T>` / `defineState(name, initial)` descriptor (the
|
||||
* state counterpart of wire's `defineModel`), the `IStateRegistry` base
|
||||
* interface shared by the per-scope state services (`IStateService` /
|
||||
* `ISessionStateService` / `IAgentStateService`), and the `StateRegistry`
|
||||
* implementation backing them: a `Map`-backed store where keys are declared
|
||||
* up front (`register`), read and replaced (`get` / `set`), and observed
|
||||
* (`onDidChange(key)` per key, `onDidChangeAny` globally). Two exports serve
|
||||
* debugging: `entries()` returns the live key/value references for in-process
|
||||
* readers, and `snapshot()` returns a JSON-safe deep copy for RPC / inspector
|
||||
* export: Maps become plain objects or entry arrays, Sets become arrays,
|
||||
* functions are dropped, circular references become `'(circular)'`, and
|
||||
* instances with a custom prototype (service references, tools, Promises)
|
||||
* collapse to a `'(ClassName)'` marker — plain data is recursed, resource
|
||||
* graphs are not, so a value that reaches into the DI object graph cannot
|
||||
* fan the copy out until the heap is exhausted. Misuse (duplicate registration, reading or writing an
|
||||
* unregistered key) is a caller bug and raises `BugIndicatingError`.
|
||||
*
|
||||
* Values are stored as-is — the container does not freeze or clone, so
|
||||
* replacing the whole value via `set` is the recommended update style;
|
||||
* mutating a held `Map` / `Set` in place bypasses change notification.
|
||||
* Persistence and replay are out of scope here: durable, replayable state
|
||||
* belongs to wire Models. Scope-agnostic.
|
||||
*/
|
||||
|
||||
import { Disposable } from '../di/lifecycle';
|
||||
import { BugIndicatingError } from '../errors/errors';
|
||||
import { Emitter, type Event } from '../event';
|
||||
|
||||
export interface StateKey<T> {
|
||||
readonly name: string;
|
||||
readonly initial: () => T;
|
||||
}
|
||||
|
||||
export function defineState<T>(name: string, initial: () => T): StateKey<T> {
|
||||
return { name, initial };
|
||||
}
|
||||
|
||||
export interface StateChange {
|
||||
readonly key: string;
|
||||
readonly value: unknown;
|
||||
}
|
||||
|
||||
export interface IStateRegistry {
|
||||
register<T>(key: StateKey<T>): void;
|
||||
has(key: StateKey<unknown>): boolean;
|
||||
get<T>(key: StateKey<T>): T;
|
||||
set<T>(key: StateKey<T>, value: T): void;
|
||||
onDidChange<T>(key: StateKey<T>): Event<T>;
|
||||
readonly onDidChangeAny: Event<StateChange>;
|
||||
entries(): readonly [string, unknown][];
|
||||
snapshot(): Record<string, unknown>;
|
||||
}
|
||||
|
||||
export class StateRegistry extends Disposable implements IStateRegistry {
|
||||
private readonly values = new Map<string, unknown>();
|
||||
private readonly keyEmitters = new Map<string, Emitter<unknown>>();
|
||||
private readonly anyEmitter = this._register(new Emitter<StateChange>());
|
||||
readonly onDidChangeAny: Event<StateChange> = this.anyEmitter.event;
|
||||
|
||||
register<T>(key: StateKey<T>): void {
|
||||
if (this.values.has(key.name)) {
|
||||
throw new BugIndicatingError(`state key '${key.name}' is already registered`);
|
||||
}
|
||||
this.values.set(key.name, key.initial());
|
||||
}
|
||||
|
||||
has(key: StateKey<unknown>): boolean {
|
||||
return this.values.has(key.name);
|
||||
}
|
||||
|
||||
get<T>(key: StateKey<T>): T {
|
||||
if (!this.values.has(key.name)) {
|
||||
throw new BugIndicatingError(`state key '${key.name}' is not registered`);
|
||||
}
|
||||
return this.values.get(key.name) as T;
|
||||
}
|
||||
|
||||
set<T>(key: StateKey<T>, value: T): void {
|
||||
if (!this.values.has(key.name)) {
|
||||
throw new BugIndicatingError(`state key '${key.name}' is not registered`);
|
||||
}
|
||||
this.values.set(key.name, value);
|
||||
this.keyEmitters.get(key.name)?.fire(value);
|
||||
this.anyEmitter.fire({ key: key.name, value });
|
||||
}
|
||||
|
||||
onDidChange<T>(key: StateKey<T>): Event<T> {
|
||||
let emitter = this.keyEmitters.get(key.name);
|
||||
if (emitter === undefined) {
|
||||
emitter = this._register(new Emitter<unknown>());
|
||||
this.keyEmitters.set(key.name, emitter);
|
||||
}
|
||||
return emitter.event as Event<T>;
|
||||
}
|
||||
|
||||
entries(): readonly [string, unknown][] {
|
||||
return Array.from(this.values.entries());
|
||||
}
|
||||
|
||||
snapshot(): Record<string, unknown> {
|
||||
const out: Record<string, unknown> = {};
|
||||
for (const [key, value] of this.values) {
|
||||
out[key] = toJsonSafe(value, new WeakSet());
|
||||
}
|
||||
return out;
|
||||
}
|
||||
}
|
||||
|
||||
function toJsonSafe(value: unknown, seen: WeakSet<object>): unknown {
|
||||
if (value === undefined || value === null) return null;
|
||||
if (typeof value === 'function') return '(function)';
|
||||
if (typeof value === 'bigint') return value.toString();
|
||||
if (typeof value !== 'object') return value;
|
||||
if (seen.has(value)) return '(circular)';
|
||||
seen.add(value);
|
||||
try {
|
||||
if (value instanceof Date) return value.toJSON();
|
||||
if (Array.isArray(value)) return value.map((item) => toJsonSafe(item, seen));
|
||||
if (value instanceof Map) {
|
||||
const entries = [...value.entries()];
|
||||
const objectKeys = entries.every(([key]) => ['string', 'number'].includes(typeof key));
|
||||
if (objectKeys) {
|
||||
return Object.fromEntries(
|
||||
entries.map(([key, item]) => [key, toJsonSafe(item, seen)] as const),
|
||||
);
|
||||
}
|
||||
return entries.map(([key, item]) => [toJsonSafe(key, seen), toJsonSafe(item, seen)]);
|
||||
}
|
||||
if (value instanceof Set) {
|
||||
return [...value.values()].map((item) => toJsonSafe(item, seen));
|
||||
}
|
||||
// Plain objects (literal / interface-shaped) are recursed; instances with
|
||||
// a custom prototype (services, tools, AbortControllers, Promises, Errors)
|
||||
// are resource graphs, not data — walking them fans out across the whole
|
||||
// DI object graph and can exhaust the heap, so they collapse to a marker.
|
||||
const proto: unknown = Object.getPrototypeOf(value);
|
||||
if (proto !== Object.prototype && proto !== null) {
|
||||
const ctor = (value as { constructor?: { name?: string } }).constructor;
|
||||
return `(${ctor?.name ?? 'object'})`;
|
||||
}
|
||||
const out: Record<string, unknown> = {};
|
||||
for (const [key, item] of Object.entries(value)) {
|
||||
if (typeof item === 'function') continue;
|
||||
out[key] = toJsonSafe(item, seen);
|
||||
}
|
||||
return out;
|
||||
} finally {
|
||||
seen.delete(value);
|
||||
}
|
||||
}
|
||||
|
|
@ -8,14 +8,21 @@
|
|||
* the background-work slice. The view seeds once from `IAgentLoopService`,
|
||||
* `IAgentTaskService`, and `IAgentFullCompactionService` (reads, never writes)
|
||||
* and otherwise holds only derived state, so it can be discarded and rebuilt
|
||||
* at any time. Bound at Agent scope.
|
||||
* at any time. The mutable view state (`lifecycle`, `turn`, `lastTurn`,
|
||||
* `background`, `current`) is registered into `agentState`
|
||||
* (`IAgentStateService`) and read/written through it; the event-bus
|
||||
* subscription handles stay mechanism held by the `Disposable` base, and
|
||||
* `MutableTurn`'s in-place-mutated Maps stay instance fields of that
|
||||
* per-turn class. Bound at Agent scope.
|
||||
*/
|
||||
|
||||
import { Disposable } from '#/_base/di/lifecycle';
|
||||
import { InstantiationType } from '#/_base/di/extensions';
|
||||
import { LifecycleScope, registerScopedService } from '#/_base/di/scope';
|
||||
import { defineState } from '#/_base/state/stateRegistry';
|
||||
import { IEventBus } from '#/app/event/eventBus';
|
||||
import { IAgentLoopService } from '#/agent/loop/loop';
|
||||
import { IAgentStateService } from '#/agent/state/agentState';
|
||||
import { IAgentTaskService } from '#/agent/task/task';
|
||||
import { IAgentFullCompactionService } from '#/agent/fullCompaction/fullCompaction';
|
||||
import { USER_PROMPT_ORIGIN } from '#/agent/contextMemory/types';
|
||||
|
|
@ -38,22 +45,43 @@ import { IAgentActivityView } from './activityView';
|
|||
type EndingReason = NonNullable<ActivityTurnState['endingReason']>;
|
||||
const FULL_COMPACTION_BACKGROUND_ID = 'full-compaction';
|
||||
|
||||
export const activityViewLifecycleKey = defineState<ActivityViewLifecycle>(
|
||||
'activityView.lifecycle',
|
||||
() => 'ready',
|
||||
);
|
||||
export const activityViewTurnKey = defineState<MutableTurn | undefined>(
|
||||
'activityView.turn',
|
||||
() => undefined as MutableTurn | undefined,
|
||||
);
|
||||
export const activityViewLastTurnKey = defineState<ActivityLastTurnState | undefined>(
|
||||
'activityView.lastTurn',
|
||||
() => undefined as ActivityLastTurnState | undefined,
|
||||
);
|
||||
export const activityViewBackgroundKey = defineState<Map<string, BackgroundRef>>(
|
||||
'activityView.background',
|
||||
() => new Map(),
|
||||
);
|
||||
export const activityViewCurrentKey = defineState<AgentActivityState>('activityView.current', () => ({
|
||||
lifecycle: 'ready',
|
||||
background: [],
|
||||
}));
|
||||
|
||||
export class AgentActivityView extends Disposable implements IAgentActivityView {
|
||||
declare readonly _serviceBrand: undefined;
|
||||
|
||||
private lifecycle: ActivityViewLifecycle = 'ready';
|
||||
private turn: MutableTurn | undefined;
|
||||
private lastTurn: ActivityLastTurnState | undefined;
|
||||
private readonly background = new Map<string, BackgroundRef>();
|
||||
private current: AgentActivityState = { lifecycle: 'ready', background: [] };
|
||||
|
||||
constructor(
|
||||
@IEventBus private readonly eventBus: IEventBus,
|
||||
@IAgentLoopService private readonly loop: IAgentLoopService,
|
||||
@IAgentTaskService private readonly tasks: IAgentTaskService,
|
||||
@IAgentFullCompactionService private readonly fullCompaction: IAgentFullCompactionService,
|
||||
@IAgentStateService private readonly states: IAgentStateService,
|
||||
) {
|
||||
super();
|
||||
this.states.register(activityViewLifecycleKey);
|
||||
this.states.register(activityViewTurnKey);
|
||||
this.states.register(activityViewLastTurnKey);
|
||||
this.states.register(activityViewBackgroundKey);
|
||||
this.states.register(activityViewCurrentKey);
|
||||
this.seedFromLoop();
|
||||
this.seedFromTasks();
|
||||
this.seedFromFullCompaction();
|
||||
|
|
@ -145,6 +173,42 @@ export class AgentActivityView extends Disposable implements IAgentActivityView
|
|||
);
|
||||
}
|
||||
|
||||
private get lifecycle(): ActivityViewLifecycle {
|
||||
return this.states.get(activityViewLifecycleKey);
|
||||
}
|
||||
|
||||
private set lifecycle(value: ActivityViewLifecycle) {
|
||||
this.states.set(activityViewLifecycleKey, value);
|
||||
}
|
||||
|
||||
private get turn(): MutableTurn | undefined {
|
||||
return this.states.get(activityViewTurnKey);
|
||||
}
|
||||
|
||||
private set turn(value: MutableTurn | undefined) {
|
||||
this.states.set(activityViewTurnKey, value);
|
||||
}
|
||||
|
||||
private get lastTurn(): ActivityLastTurnState | undefined {
|
||||
return this.states.get(activityViewLastTurnKey);
|
||||
}
|
||||
|
||||
private set lastTurn(value: ActivityLastTurnState | undefined) {
|
||||
this.states.set(activityViewLastTurnKey, value);
|
||||
}
|
||||
|
||||
private get background(): Map<string, BackgroundRef> {
|
||||
return this.states.get(activityViewBackgroundKey);
|
||||
}
|
||||
|
||||
private get current(): AgentActivityState {
|
||||
return this.states.get(activityViewCurrentKey);
|
||||
}
|
||||
|
||||
private set current(value: AgentActivityState) {
|
||||
this.states.set(activityViewCurrentKey, value);
|
||||
}
|
||||
|
||||
state(): AgentActivityState {
|
||||
return this.current;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,15 +3,20 @@
|
|||
*
|
||||
* Injects registered context providers through `loop` and `systemReminder`,
|
||||
* tracks their positions in `contextMemory` through `eventBus`, and reconciles
|
||||
* those positions after `wire` restoration. Bound at Agent scope.
|
||||
* those positions after `wire` restoration. The plain-data `isNewTurn` flag is
|
||||
* registered into `agentState` (`IAgentStateService`) and read/written through
|
||||
* it; `entries` stays a plain instance field (its values hold provider
|
||||
* functions, not plain data). Bound at Agent scope.
|
||||
*/
|
||||
|
||||
import { Disposable, toDisposable } from "#/_base/di/lifecycle";
|
||||
import { InstantiationType } from '#/_base/di/extensions';
|
||||
import { LifecycleScope, registerScopedService } from '#/_base/di/scope';
|
||||
import { defineState } from '#/_base/state/stateRegistry';
|
||||
|
||||
import { IAgentContextMemoryService } from '#/agent/contextMemory/contextMemory';
|
||||
import { IAgentLoopService } from '#/agent/loop/loop';
|
||||
import { IAgentStateService } from '#/agent/state/agentState';
|
||||
import { IAgentSystemReminderService } from '#/agent/systemReminder/systemReminder';
|
||||
import { IEventBus } from '#/app/event/eventBus';
|
||||
import type { ContextMessage } from '#/agent/contextMemory/types';
|
||||
|
|
@ -27,10 +32,14 @@ interface ContextInjectionEntry {
|
|||
readonly positions: number[];
|
||||
}
|
||||
|
||||
export const contextInjectorIsNewTurnKey = defineState<boolean>(
|
||||
'contextInjector.isNewTurn',
|
||||
() => true,
|
||||
);
|
||||
|
||||
export class AgentContextInjectorService extends Disposable implements IAgentContextInjectorService {
|
||||
declare readonly _serviceBrand: undefined;
|
||||
private readonly entries = new Set<ContextInjectionEntry>();
|
||||
private isNewTurn = true;
|
||||
|
||||
constructor(
|
||||
@IAgentContextMemoryService private readonly context: IAgentContextMemoryService,
|
||||
|
|
@ -38,8 +47,10 @@ export class AgentContextInjectorService extends Disposable implements IAgentCon
|
|||
@IAgentSystemReminderService private readonly reminders: IAgentSystemReminderService,
|
||||
@IEventBus private readonly eventBus: IEventBus,
|
||||
@IWireService wire: IWireService,
|
||||
@IAgentStateService private readonly states: IAgentStateService,
|
||||
) {
|
||||
super();
|
||||
this.states.register(contextInjectorIsNewTurnKey);
|
||||
this._register(
|
||||
loopService.hooks.onWillBeginStep.register('context-injector', async (_ctx, next) => {
|
||||
await next();
|
||||
|
|
@ -64,6 +75,14 @@ export class AgentContextInjectorService extends Disposable implements IAgentCon
|
|||
);
|
||||
}
|
||||
|
||||
private get isNewTurn(): boolean {
|
||||
return this.states.get(contextInjectorIsNewTurnKey);
|
||||
}
|
||||
|
||||
private set isNewTurn(value: boolean) {
|
||||
this.states.set(contextInjectorIsNewTurnKey, value);
|
||||
}
|
||||
|
||||
register(
|
||||
name: string,
|
||||
provider: ContextInjectionProvider,
|
||||
|
|
|
|||
|
|
@ -11,7 +11,9 @@
|
|||
* assistant step that kept only an empty thinking part — dropped whole) are
|
||||
* reported through an optional sink and surfaced once here as a
|
||||
* single deduped warning plus a `context_projection_repaired` telemetry event,
|
||||
* so a silently-mangled history always leaves a trace.
|
||||
* so a silently-mangled history always leaves a trace. The mutable
|
||||
* repair-dedup signature (`lastRepairSignature`) is registered into
|
||||
* `agentState` (`IAgentStateService`) and read/written through it.
|
||||
*
|
||||
* `projectMediaDegraded` / `projectMediaStripped` are the fallback
|
||||
* projections for the two deterministic provider rejections: media-degraded
|
||||
|
|
@ -27,9 +29,11 @@ import { createHash } from 'node:crypto';
|
|||
import { InstantiationType } from '#/_base/di/extensions';
|
||||
import { LifecycleScope, registerScopedService } from '#/_base/di/scope';
|
||||
import { ILogService } from '#/_base/log/log';
|
||||
import { defineState } from '#/_base/state/stateRegistry';
|
||||
import { renderToolResultForModel } from '#/agent/contextMemory/toolResultRender';
|
||||
import type { ContextMessage } from '#/agent/contextMemory/types';
|
||||
import { isVacuousContentPart } from '#/agent/contextMemory/vacuousContent';
|
||||
import { IAgentStateService } from '#/agent/state/agentState';
|
||||
import { ErrorCodes, Error2 } from '#/errors';
|
||||
import type { ContentPart, Message } from '#/kosong/contract/message';
|
||||
import { ITelemetryService } from '#/app/telemetry/telemetry';
|
||||
|
|
@ -38,15 +42,29 @@ import {
|
|||
type MediaStripSnapshot,
|
||||
} from './contextProjector';
|
||||
|
||||
export const contextProjectorLastRepairSignatureKey = defineState<string | null>(
|
||||
'contextProjector.lastRepairSignature',
|
||||
() => null,
|
||||
);
|
||||
|
||||
export class AgentContextProjectorService implements IAgentContextProjectorService {
|
||||
declare readonly _serviceBrand: undefined;
|
||||
|
||||
private lastRepairSignature: string | null = null;
|
||||
|
||||
constructor(
|
||||
@ILogService private readonly log: ILogService,
|
||||
@ITelemetryService private readonly telemetry: ITelemetryService,
|
||||
) {}
|
||||
@IAgentStateService private readonly states: IAgentStateService,
|
||||
) {
|
||||
this.states.register(contextProjectorLastRepairSignatureKey);
|
||||
}
|
||||
|
||||
private get lastRepairSignature(): string | null {
|
||||
return this.states.get(contextProjectorLastRepairSignatureKey);
|
||||
}
|
||||
|
||||
private set lastRepairSignature(value: string | null) {
|
||||
this.states.set(contextProjectorLastRepairSignatureKey, value);
|
||||
}
|
||||
|
||||
project(messages: readonly ContextMessage[]): readonly Message[] {
|
||||
return this.projectWithTrace(messages, project);
|
||||
|
|
|
|||
|
|
@ -15,15 +15,19 @@
|
|||
* sub-ranges fall back to a per-message estimate), `estimated` is the live token
|
||||
* estimate of the not-yet-measured portion, and `size = measured + estimated`.
|
||||
* The sparse `measuredPrefixTokens` / per-message `estimates` are deliberately
|
||||
* not persisted (see `contextSizeOps`). Bound at Agent scope.
|
||||
* not persisted (see `contextSizeOps`). The mutable emission watermark
|
||||
* (`lastEmittedTokens`) is registered into `agentState` (`IAgentStateService`)
|
||||
* and read/written through it. Bound at Agent scope.
|
||||
*/
|
||||
|
||||
import { Disposable } from '#/_base/di/lifecycle';
|
||||
import { InstantiationType } from '#/_base/di/extensions';
|
||||
import { LifecycleScope, registerScopedService } from '#/_base/di/scope';
|
||||
import { defineState } from '#/_base/state/stateRegistry';
|
||||
import { estimateTokensForMessages } from '#/kosong/contract/tokens';
|
||||
import { IAgentContextMemoryService } from '#/agent/contextMemory/contextMemory';
|
||||
import type { ContextMessage } from '#/agent/contextMemory/types';
|
||||
import { IAgentStateService } from '#/agent/state/agentState';
|
||||
import type { Message } from '#/kosong/contract/message';
|
||||
import type { TokenUsage } from '#/kosong/contract/usage';
|
||||
import { IWireService } from '#/wire/wire';
|
||||
|
|
@ -31,16 +35,29 @@ import { IWireService } from '#/wire/wire';
|
|||
import { IAgentContextSizeService, type ContextSize } from './contextSize';
|
||||
import { ContextSizeModel, contextSizeMeasured } from './contextSizeOps';
|
||||
|
||||
export const contextSizeLastEmittedTokensKey = defineState<number>(
|
||||
'contextSize.lastEmittedTokens',
|
||||
() => 0,
|
||||
);
|
||||
|
||||
export class AgentContextSizeService extends Disposable implements IAgentContextSizeService {
|
||||
declare readonly _serviceBrand: undefined;
|
||||
|
||||
private lastEmittedTokens = 0;
|
||||
|
||||
constructor(
|
||||
@IAgentContextMemoryService private readonly context: IAgentContextMemoryService,
|
||||
@IWireService private readonly wire: IWireService,
|
||||
@IAgentStateService private readonly states: IAgentStateService,
|
||||
) {
|
||||
super();
|
||||
this.states.register(contextSizeLastEmittedTokensKey);
|
||||
}
|
||||
|
||||
private get lastEmittedTokens(): number {
|
||||
return this.states.get(contextSizeLastEmittedTokensKey);
|
||||
}
|
||||
|
||||
private set lastEmittedTokens(value: number) {
|
||||
this.states.set(contextSizeLastEmittedTokensKey, value);
|
||||
}
|
||||
|
||||
get(start?: number, end?: number): ContextSize {
|
||||
|
|
|
|||
|
|
@ -13,14 +13,19 @@
|
|||
* UserPromptSubmit hook results through `contextMemory`, drives Stop hook
|
||||
* continuations by enqueueing a mergeable `StepRequest` onto `loop`, and
|
||||
* passes the current session id from `sessionContext`
|
||||
* into hook runner payloads.
|
||||
* into hook runner payloads. The one mutable latch
|
||||
* (`stopHookContinuationUsed`, the Stop-hook re-entry guard) is registered
|
||||
* into `agentState` (`IAgentStateService`) and read/written through it; the
|
||||
* hook listener registrations stay ordinary disposables on the instance.
|
||||
*/
|
||||
|
||||
import { IInstantiationService } from '#/_base/di/instantiation';
|
||||
import { Disposable } from '#/_base/di/lifecycle';
|
||||
import { InstantiationType } from '#/_base/di/extensions';
|
||||
import { LifecycleScope, registerScopedService } from '#/_base/di/scope';
|
||||
import { defineState } from '#/_base/state/stateRegistry';
|
||||
import { isPlainRecord } from '#/_base/utils/canonical-args';
|
||||
import { IAgentStateService } from '#/agent/state/agentState';
|
||||
import { IAgentTaskService, type AgentTaskNotificationContext } from '#/agent/task/task';
|
||||
import { IAgentContextMemoryService } from '#/agent/contextMemory/contextMemory';
|
||||
import { USER_PROMPT_ORIGIN } from '#/agent/contextMemory/types';
|
||||
|
|
@ -65,22 +70,35 @@ declare module '#/app/event/eventBus' {
|
|||
}
|
||||
}
|
||||
|
||||
export const externalHooksStopHookContinuationUsedKey = defineState<boolean>(
|
||||
'externalHooks.stopHookContinuationUsed',
|
||||
() => false,
|
||||
);
|
||||
|
||||
export class AgentExternalHooksService extends Disposable implements IAgentExternalHooksService {
|
||||
declare readonly _serviceBrand: undefined;
|
||||
|
||||
private stopHookContinuationUsed = false;
|
||||
|
||||
constructor(
|
||||
@IExternalHooksRunnerService private readonly runner: IExternalHooksRunnerService,
|
||||
@IAgentContextMemoryService private readonly context: IAgentContextMemoryService,
|
||||
@IEventBus private readonly eventBus: IEventBus,
|
||||
@IInstantiationService private readonly instantiation: IInstantiationService,
|
||||
@ISessionContext private readonly sessionContext: ISessionContext,
|
||||
@IAgentStateService private readonly states: IAgentStateService,
|
||||
) {
|
||||
super();
|
||||
this.states.register(externalHooksStopHookContinuationUsedKey);
|
||||
this.registerListeners();
|
||||
}
|
||||
|
||||
private get stopHookContinuationUsed(): boolean {
|
||||
return this.states.get(externalHooksStopHookContinuationUsedKey);
|
||||
}
|
||||
|
||||
private set stopHookContinuationUsed(value: boolean) {
|
||||
this.states.set(externalHooksStopHookContinuationUsedKey, value);
|
||||
}
|
||||
|
||||
private fireAndForget(
|
||||
event: string,
|
||||
inputData: Record<string, unknown>,
|
||||
|
|
|
|||
|
|
@ -3,11 +3,15 @@
|
|||
*
|
||||
* Agent-scope one-shot latch: `arm` (flag-gated) stores the next fault,
|
||||
* `take` (the llmRequester's per-attempt consumption point) consumes and
|
||||
* records it. Bound at Agent scope.
|
||||
* records it. Both state slots (`armed`, `fired`) are registered into
|
||||
* `agentState` (`IAgentStateService`) and read/written through it. Bound at
|
||||
* Agent scope.
|
||||
*/
|
||||
|
||||
import { InstantiationType } from '#/_base/di/extensions';
|
||||
import { LifecycleScope, registerScopedService } from '#/_base/di/scope';
|
||||
import { defineState } from '#/_base/state/stateRegistry';
|
||||
import { IAgentStateService } from '#/agent/state/agentState';
|
||||
import { IFlagService } from '#/app/flag/flag';
|
||||
import { ErrorCodes, Error2 } from '#/errors';
|
||||
|
||||
|
|
@ -18,13 +22,34 @@ import {
|
|||
type FaultKind,
|
||||
} from './faultInjection';
|
||||
|
||||
export const faultInjectionArmedKey = defineState<FaultKind | undefined>(
|
||||
'faultInjection.armed',
|
||||
() => undefined as FaultKind | undefined,
|
||||
);
|
||||
export const faultInjectionFiredKey = defineState<FaultKind[]>('faultInjection.fired', () => []);
|
||||
|
||||
export class FaultInjectionService implements IFaultInjectionService {
|
||||
declare readonly _serviceBrand: undefined;
|
||||
|
||||
private armed: FaultKind | undefined;
|
||||
private readonly fired: FaultKind[] = [];
|
||||
constructor(
|
||||
@IFlagService private readonly flags: IFlagService,
|
||||
@IAgentStateService private readonly states: IAgentStateService,
|
||||
) {
|
||||
this.states.register(faultInjectionArmedKey);
|
||||
this.states.register(faultInjectionFiredKey);
|
||||
}
|
||||
|
||||
constructor(@IFlagService private readonly flags: IFlagService) {}
|
||||
private get armed(): FaultKind | undefined {
|
||||
return this.states.get(faultInjectionArmedKey);
|
||||
}
|
||||
|
||||
private set armed(value: FaultKind | undefined) {
|
||||
this.states.set(faultInjectionArmedKey, value);
|
||||
}
|
||||
|
||||
private get fired(): FaultKind[] {
|
||||
return this.states.get(faultInjectionFiredKey);
|
||||
}
|
||||
|
||||
arm(kind: FaultKind): void {
|
||||
if (!this.flags.enabled(FAULT_INJECTION_FLAG_ID)) {
|
||||
|
|
|
|||
|
|
@ -1,8 +1,27 @@
|
|||
/**
|
||||
* `fullCompaction` domain (L4) — `IAgentFullCompactionService` implementation.
|
||||
*
|
||||
* Runs full-history compaction: reserves the per-turn compaction slot, drives
|
||||
* the compaction LLM round (with overflow / truncation shrink retries),
|
||||
* applies the summary back into context memory, and recovers the loop from
|
||||
* context-overflow failures by blocking the turn on the in-flight job. The
|
||||
* mutable plain-data state (`compactionCountInTurn`,
|
||||
* `observedMaxContextTokensByModel`, `lastCompactedTokenCount`,
|
||||
* `consecutiveOverflowCompactions`, `activeTurnId`) is registered into
|
||||
* `agentState` (`IAgentStateService`) and read/written through it;
|
||||
* `_compacting` (the in-flight job — AbortController / Promise / trace), the
|
||||
* `hooks.onWillCompact` slot, the `_onDidFinishCompaction` Emitter, the
|
||||
* `strategy`, and the lazily-resolved `contextInjectorService` stay instance
|
||||
* fields (mechanism, not plain data). Bound at Agent scope; Eager so the
|
||||
* overflow recovery handler registers before the first turn runs.
|
||||
*/
|
||||
|
||||
import { Disposable } from "#/_base/di/lifecycle";
|
||||
import { InstantiationType } from '#/_base/di/extensions';
|
||||
import { IInstantiationService } from '#/_base/di/instantiation';
|
||||
import { LifecycleScope, registerScopedService } from '#/_base/di/scope';
|
||||
import { ILogService } from '#/_base/log/log';
|
||||
import { defineState } from '#/_base/state/stateRegistry';
|
||||
import { renderPrompt } from "#/_base/utils/render-prompt";
|
||||
import {
|
||||
estimateTokens,
|
||||
|
|
@ -21,6 +40,7 @@ import { retryBackoffDelays, sleepForRetry } from '#/_base/utils/retry';
|
|||
import { IAgentLoopService, type LoopErrorContext } from '#/agent/loop/loop';
|
||||
import { isAbortError } from '#/_base/utils/abort';
|
||||
import { IAgentProfileService, type ProfileModelContext } from '#/agent/profile/profile';
|
||||
import { IAgentStateService } from '#/agent/state/agentState';
|
||||
import { IAgentToolRegistryService } from '#/agent/toolRegistry/toolRegistry';
|
||||
import { stripDynamicToolContext } from '#/agent/toolSelect/dynamicTools';
|
||||
import { IAgentToolSelectService } from '#/agent/toolSelect/toolSelect';
|
||||
|
|
@ -98,6 +118,27 @@ class CompactionTruncatedError extends Error {
|
|||
}
|
||||
}
|
||||
|
||||
export const fullCompactionCompactionCountInTurnKey = defineState<number>(
|
||||
'fullCompaction.compactionCountInTurn',
|
||||
() => 0,
|
||||
);
|
||||
export const fullCompactionObservedMaxContextTokensByModelKey = defineState<Map<string, number>>(
|
||||
'fullCompaction.observedMaxContextTokensByModel',
|
||||
() => new Map(),
|
||||
);
|
||||
export const fullCompactionLastCompactedTokenCountKey = defineState<number | null>(
|
||||
'fullCompaction.lastCompactedTokenCount',
|
||||
() => null,
|
||||
);
|
||||
export const fullCompactionConsecutiveOverflowCompactionsKey = defineState<number>(
|
||||
'fullCompaction.consecutiveOverflowCompactions',
|
||||
() => 0,
|
||||
);
|
||||
export const fullCompactionActiveTurnIdKey = defineState<number | undefined>(
|
||||
'fullCompaction.activeTurnId',
|
||||
() => undefined as number | undefined,
|
||||
);
|
||||
|
||||
export class AgentFullCompactionService extends Disposable implements IAgentFullCompactionService {
|
||||
declare readonly _serviceBrand: undefined;
|
||||
readonly hooks: IAgentFullCompactionService['hooks'] = {
|
||||
|
|
@ -107,12 +148,7 @@ export class AgentFullCompactionService extends Disposable implements IAgentFull
|
|||
readonly onDidFinishCompaction: Event<FullCompactionTask> = this._onDidFinishCompaction.event;
|
||||
|
||||
private readonly strategy: CompactionStrategy;
|
||||
private compactionCountInTurn = 0;
|
||||
private _compacting: ActiveCompaction | null = null;
|
||||
private readonly observedMaxContextTokensByModel = new Map<string, number>();
|
||||
private lastCompactedTokenCount: number | null = null;
|
||||
private consecutiveOverflowCompactions = 0;
|
||||
private activeTurnId: number | undefined;
|
||||
private contextInjectorService: IAgentContextInjectorService | undefined;
|
||||
|
||||
constructor(
|
||||
|
|
@ -129,8 +165,14 @@ export class AgentFullCompactionService extends Disposable implements IAgentFull
|
|||
@IEventBus private readonly eventBus: IEventBus,
|
||||
@ILogService private readonly log: ILogService,
|
||||
@IAgentLoopService private readonly loopService: IAgentLoopService,
|
||||
@IAgentStateService private readonly states: IAgentStateService,
|
||||
) {
|
||||
super();
|
||||
this.states.register(fullCompactionCompactionCountInTurnKey);
|
||||
this.states.register(fullCompactionObservedMaxContextTokensByModelKey);
|
||||
this.states.register(fullCompactionLastCompactedTokenCountKey);
|
||||
this.states.register(fullCompactionConsecutiveOverflowCompactionsKey);
|
||||
this.states.register(fullCompactionActiveTurnIdKey);
|
||||
this.strategy = new RuntimeCompactionStrategy(() => this.resolveModelContextWithEffectiveMax());
|
||||
this._register(
|
||||
this.wire.hooks.onDidRestore.register('full-compaction', async (_ctx, next) => {
|
||||
|
|
@ -167,6 +209,42 @@ export class AgentFullCompactionService extends Disposable implements IAgentFull
|
|||
);
|
||||
}
|
||||
|
||||
private get compactionCountInTurn(): number {
|
||||
return this.states.get(fullCompactionCompactionCountInTurnKey);
|
||||
}
|
||||
|
||||
private set compactionCountInTurn(value: number) {
|
||||
this.states.set(fullCompactionCompactionCountInTurnKey, value);
|
||||
}
|
||||
|
||||
private get observedMaxContextTokensByModel(): Map<string, number> {
|
||||
return this.states.get(fullCompactionObservedMaxContextTokensByModelKey);
|
||||
}
|
||||
|
||||
private get lastCompactedTokenCount(): number | null {
|
||||
return this.states.get(fullCompactionLastCompactedTokenCountKey);
|
||||
}
|
||||
|
||||
private set lastCompactedTokenCount(value: number | null) {
|
||||
this.states.set(fullCompactionLastCompactedTokenCountKey, value);
|
||||
}
|
||||
|
||||
private get consecutiveOverflowCompactions(): number {
|
||||
return this.states.get(fullCompactionConsecutiveOverflowCompactionsKey);
|
||||
}
|
||||
|
||||
private set consecutiveOverflowCompactions(value: number) {
|
||||
this.states.set(fullCompactionConsecutiveOverflowCompactionsKey, value);
|
||||
}
|
||||
|
||||
private get activeTurnId(): number | undefined {
|
||||
return this.states.get(fullCompactionActiveTurnIdKey);
|
||||
}
|
||||
|
||||
private set activeTurnId(value: number | undefined) {
|
||||
this.states.set(fullCompactionActiveTurnIdKey, value);
|
||||
}
|
||||
|
||||
get compacting(): FullCompactionTask | null {
|
||||
return this._compacting;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -28,7 +28,15 @@
|
|||
* `goal_start` display outside `auto` mode defers to a cold `waitUntil`
|
||||
* factory that runs the goal-start review through `toolApproval` under the
|
||||
* origin `goal-start-review-ask` — including the permission-mode switch
|
||||
* picked on the approval surface. Bound at Agent scope.
|
||||
* picked on the approval surface. The mutable turn-tracking and wall-clock
|
||||
* state (`liveTurnId`, `goalDrivenTurns`, `countedGoalTurns`,
|
||||
* `goalStarterTurns`, `goalOutcomeToolResultTurns`,
|
||||
* `goalOutcomeContinuationTurns`, `budgetGraceTurns`,
|
||||
* `pendingContinuationGoals`, `goalTurnTargets`, `exhaustedTurnBudgetGoals`,
|
||||
* `liveWallClockStartedAt`, `resumeContinuation`) is registered into
|
||||
* `agentState` (`IAgentStateService`) and read/written through it; the
|
||||
* `pendingContinuation` promise lock and the `wallClockDeadline` disposable
|
||||
* slot stay plain fields. Bound at Agent scope.
|
||||
* Subagent instances reject every goal command and do not install goal
|
||||
* injection, accounting, budget, or continuation hooks.
|
||||
*/
|
||||
|
|
@ -39,6 +47,7 @@ import type { TurnEndedEvent, TurnStartedEvent } from '#/agent/loop/turnEvents';
|
|||
import { Disposable, MutableDisposable, type IDisposable } from '#/_base/di/lifecycle';
|
||||
import { InstantiationType } from '#/_base/di/extensions';
|
||||
import { LifecycleScope, registerScopedService } from '#/_base/di/scope';
|
||||
import { defineState } from '#/_base/state/stateRegistry';
|
||||
import { abortError } from '#/_base/utils/abort';
|
||||
import { isPlainRecord } from '#/_base/utils/canonical-args';
|
||||
import { IAgentContextInjectorService } from '#/agent/contextInjector/contextInjector';
|
||||
|
|
@ -54,6 +63,7 @@ import { LOOP_CONTROL_SECTION, type LoopControl } from '#/agent/loop/configSecti
|
|||
import { ContinuationStepRequest, MessageStepRequest } from '#/agent/loop/stepRequest';
|
||||
import { IAgentSystemReminderService } from '#/agent/systemReminder/systemReminder';
|
||||
import { IAgentScopeContext } from '#/agent/scopeContext/scopeContext';
|
||||
import { IAgentStateService } from '#/agent/state/agentState';
|
||||
import type { ExecutableToolResult } from '#/tool/toolContract';
|
||||
import { IAgentPermissionModeService } from '#/agent/permissionMode/permissionMode';
|
||||
import type { PermissionMode } from '#/agent/permissionPolicy/types';
|
||||
|
|
@ -178,6 +188,11 @@ interface PendingContinuation {
|
|||
turnId?: number;
|
||||
}
|
||||
|
||||
interface ResumeContinuation {
|
||||
readonly turnId: number;
|
||||
readonly goalId: string;
|
||||
}
|
||||
|
||||
const GoalForkNoticeModel = defineModel<GoalForkNoticeState>(
|
||||
'goalForkNotice',
|
||||
() => ({ goalPresent: false, reminderPending: false }),
|
||||
|
|
@ -208,23 +223,60 @@ function isGoalContinuationOrigin(origin: TurnStartedEvent['origin']): boolean {
|
|||
return origin.kind === 'system_trigger' && origin.name === 'goal_continuation';
|
||||
}
|
||||
|
||||
export const goalLiveTurnIdKey = defineState<number | undefined>(
|
||||
'goal.liveTurnId',
|
||||
() => undefined as number | undefined,
|
||||
);
|
||||
export const goalGoalDrivenTurnsKey = defineState<Map<number, string>>(
|
||||
'goal.goalDrivenTurns',
|
||||
() => new Map(),
|
||||
);
|
||||
export const goalCountedGoalTurnsKey = defineState<Set<number>>(
|
||||
'goal.countedGoalTurns',
|
||||
() => new Set(),
|
||||
);
|
||||
export const goalGoalStarterTurnsKey = defineState<Set<number>>(
|
||||
'goal.goalStarterTurns',
|
||||
() => new Set(),
|
||||
);
|
||||
export const goalGoalOutcomeToolResultTurnsKey = defineState<Map<number, string>>(
|
||||
'goal.goalOutcomeToolResultTurns',
|
||||
() => new Map(),
|
||||
);
|
||||
export const goalGoalOutcomeContinuationTurnsKey = defineState<Set<number>>(
|
||||
'goal.goalOutcomeContinuationTurns',
|
||||
() => new Set(),
|
||||
);
|
||||
export const goalBudgetGraceTurnsKey = defineState<Set<number>>(
|
||||
'goal.budgetGraceTurns',
|
||||
() => new Set(),
|
||||
);
|
||||
export const goalPendingContinuationGoalsKey = defineState<Map<number, string>>(
|
||||
'goal.pendingContinuationGoals',
|
||||
() => new Map(),
|
||||
);
|
||||
export const goalGoalTurnTargetsKey = defineState<Map<number, string>>(
|
||||
'goal.goalTurnTargets',
|
||||
() => new Map(),
|
||||
);
|
||||
export const goalExhaustedTurnBudgetGoalsKey = defineState<Map<number, string>>(
|
||||
'goal.exhaustedTurnBudgetGoals',
|
||||
() => new Map(),
|
||||
);
|
||||
export const goalLiveWallClockStartedAtKey = defineState<number | undefined>(
|
||||
'goal.liveWallClockStartedAt',
|
||||
() => undefined as number | undefined,
|
||||
);
|
||||
export const goalResumeContinuationKey = defineState<ResumeContinuation | undefined>(
|
||||
'goal.resumeContinuation',
|
||||
() => undefined as ResumeContinuation | undefined,
|
||||
);
|
||||
|
||||
export class AgentGoalService extends Disposable implements IAgentGoalService {
|
||||
declare readonly _serviceBrand: undefined;
|
||||
|
||||
private liveTurnId?: number;
|
||||
private readonly goalDrivenTurns = new Map<number, string>();
|
||||
private readonly countedGoalTurns = new Set<number>();
|
||||
private readonly goalStarterTurns = new Set<number>();
|
||||
private readonly goalOutcomeToolResultTurns = new Map<number, string>();
|
||||
private readonly goalOutcomeContinuationTurns = new Set<number>();
|
||||
private readonly budgetGraceTurns = new Set<number>();
|
||||
private readonly pendingContinuationGoals = new Map<number, string>();
|
||||
private readonly goalTurnTargets = new Map<number, string>();
|
||||
private readonly exhaustedTurnBudgetGoals = new Map<number, string>();
|
||||
private readonly wallClockDeadline = this._register(new MutableDisposable<IDisposable>());
|
||||
private liveWallClockStartedAt?: number;
|
||||
private pendingContinuation?: PendingContinuation;
|
||||
private resumeContinuation?: { readonly turnId: number; readonly goalId: string };
|
||||
|
||||
constructor(
|
||||
@IWireService private readonly wire: IWireService,
|
||||
|
|
@ -240,8 +292,21 @@ export class AgentGoalService extends Disposable implements IAgentGoalService {
|
|||
@IConfigService private readonly config: IConfigService,
|
||||
@IGoalDeadlineScheduler private readonly deadlineScheduler: IGoalDeadlineScheduler,
|
||||
@IAgentScopeContext private readonly agentContext: IAgentScopeContext,
|
||||
@IAgentStateService private readonly states: IAgentStateService,
|
||||
) {
|
||||
super();
|
||||
this.states.register(goalLiveTurnIdKey);
|
||||
this.states.register(goalGoalDrivenTurnsKey);
|
||||
this.states.register(goalCountedGoalTurnsKey);
|
||||
this.states.register(goalGoalStarterTurnsKey);
|
||||
this.states.register(goalGoalOutcomeToolResultTurnsKey);
|
||||
this.states.register(goalGoalOutcomeContinuationTurnsKey);
|
||||
this.states.register(goalBudgetGraceTurnsKey);
|
||||
this.states.register(goalPendingContinuationGoalsKey);
|
||||
this.states.register(goalGoalTurnTargetsKey);
|
||||
this.states.register(goalExhaustedTurnBudgetGoalsKey);
|
||||
this.states.register(goalLiveWallClockStartedAtKey);
|
||||
this.states.register(goalResumeContinuationKey);
|
||||
if (!this.isSupportedAgent) return;
|
||||
this._register(
|
||||
new GoalInjection(
|
||||
|
|
@ -338,6 +403,66 @@ export class AgentGoalService extends Disposable implements IAgentGoalService {
|
|||
);
|
||||
}
|
||||
|
||||
private get liveTurnId(): number | undefined {
|
||||
return this.states.get(goalLiveTurnIdKey);
|
||||
}
|
||||
|
||||
private set liveTurnId(value: number | undefined) {
|
||||
this.states.set(goalLiveTurnIdKey, value);
|
||||
}
|
||||
|
||||
private get goalDrivenTurns(): Map<number, string> {
|
||||
return this.states.get(goalGoalDrivenTurnsKey);
|
||||
}
|
||||
|
||||
private get countedGoalTurns(): Set<number> {
|
||||
return this.states.get(goalCountedGoalTurnsKey);
|
||||
}
|
||||
|
||||
private get goalStarterTurns(): Set<number> {
|
||||
return this.states.get(goalGoalStarterTurnsKey);
|
||||
}
|
||||
|
||||
private get goalOutcomeToolResultTurns(): Map<number, string> {
|
||||
return this.states.get(goalGoalOutcomeToolResultTurnsKey);
|
||||
}
|
||||
|
||||
private get goalOutcomeContinuationTurns(): Set<number> {
|
||||
return this.states.get(goalGoalOutcomeContinuationTurnsKey);
|
||||
}
|
||||
|
||||
private get budgetGraceTurns(): Set<number> {
|
||||
return this.states.get(goalBudgetGraceTurnsKey);
|
||||
}
|
||||
|
||||
private get pendingContinuationGoals(): Map<number, string> {
|
||||
return this.states.get(goalPendingContinuationGoalsKey);
|
||||
}
|
||||
|
||||
private get goalTurnTargets(): Map<number, string> {
|
||||
return this.states.get(goalGoalTurnTargetsKey);
|
||||
}
|
||||
|
||||
private get exhaustedTurnBudgetGoals(): Map<number, string> {
|
||||
return this.states.get(goalExhaustedTurnBudgetGoalsKey);
|
||||
}
|
||||
|
||||
private get liveWallClockStartedAt(): number | undefined {
|
||||
return this.states.get(goalLiveWallClockStartedAtKey);
|
||||
}
|
||||
|
||||
private set liveWallClockStartedAt(value: number | undefined) {
|
||||
this.states.set(goalLiveWallClockStartedAtKey, value);
|
||||
}
|
||||
|
||||
private get resumeContinuation(): ResumeContinuation | undefined {
|
||||
return this.states.get(goalResumeContinuationKey);
|
||||
}
|
||||
|
||||
private set resumeContinuation(value: ResumeContinuation | undefined) {
|
||||
this.states.set(goalResumeContinuationKey, value);
|
||||
}
|
||||
|
||||
private get isSupportedAgent(): boolean {
|
||||
return this.agentContext.agentId === 'main';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -23,12 +23,17 @@
|
|||
* per-request fields) through `log`, publishes advisory model-capability
|
||||
* warnings through `eventBus`, records durable request-trace Ops
|
||||
* through `wire`, reports each request's `x-trace-id` to its caller, and
|
||||
* reports provider failures through `telemetry`. Bound at Agent scope.
|
||||
* reports provider failures through `telemetry`. The mutable request state
|
||||
* (`lastConfigLogSignature`, `turnConfigs`, `mediaDegradedTurns`,
|
||||
* `mediaStrippedTurns`, `emittedThinkingEffortWarnings`) is registered into
|
||||
* `agentState` (`IAgentStateService`) and read/written through it. Bound at
|
||||
* Agent scope.
|
||||
*/
|
||||
|
||||
import { createHash } from 'node:crypto';
|
||||
import { InstantiationType } from '#/_base/di/extensions';
|
||||
import { LifecycleScope, registerScopedService } from '#/_base/di/scope';
|
||||
import { defineState } from '#/_base/state/stateRegistry';
|
||||
import { IAgentContextMemoryService } from '#/agent/contextMemory/contextMemory';
|
||||
import {
|
||||
IAgentContextProjectorService,
|
||||
|
|
@ -40,6 +45,7 @@ import {
|
|||
type FaultKind,
|
||||
} from '#/agent/faultInjection/faultInjection';
|
||||
import { IAgentProfileService, type ProfileModelContext } from '#/agent/profile/profile';
|
||||
import { IAgentStateService } from '#/agent/state/agentState';
|
||||
import { IAgentToolRegistryService } from '#/agent/toolRegistry/toolRegistry';
|
||||
import { IAgentToolSelectService } from '#/agent/toolSelect/toolSelect';
|
||||
import { IAgentVideoResolverService } from '#/agent/media/videoResolver';
|
||||
|
|
@ -140,15 +146,30 @@ interface TurnRequestConfig {
|
|||
readonly systemPrompt: string;
|
||||
}
|
||||
|
||||
export const llmRequesterLastConfigLogSignatureKey = defineState<string | undefined>(
|
||||
'llmRequester.lastConfigLogSignature',
|
||||
() => undefined as string | undefined,
|
||||
);
|
||||
export const llmRequesterTurnConfigsKey = defineState<Map<number, TurnRequestConfig>>(
|
||||
'llmRequester.turnConfigs',
|
||||
() => new Map(),
|
||||
);
|
||||
export const llmRequesterMediaDegradedTurnsKey = defineState<Set<number>>(
|
||||
'llmRequester.mediaDegradedTurns',
|
||||
() => new Set(),
|
||||
);
|
||||
export const llmRequesterMediaStrippedTurnsKey = defineState<Map<number, MediaStripSnapshot>>(
|
||||
'llmRequester.mediaStrippedTurns',
|
||||
() => new Map(),
|
||||
);
|
||||
export const llmRequesterEmittedThinkingEffortWarningsKey = defineState<Set<string>>(
|
||||
'llmRequester.emittedThinkingEffortWarnings',
|
||||
() => new Set(),
|
||||
);
|
||||
|
||||
export class AgentLLMRequesterService implements IAgentLLMRequesterService {
|
||||
declare readonly _serviceBrand: undefined;
|
||||
|
||||
private lastConfigLogSignature: string | undefined;
|
||||
private readonly turnConfigs = new Map<number, TurnRequestConfig>();
|
||||
private readonly mediaDegradedTurns = new Set<number>();
|
||||
private readonly mediaStrippedTurns = new Map<number, MediaStripSnapshot>();
|
||||
private readonly emittedThinkingEffortWarnings = new Set<string>();
|
||||
|
||||
constructor(
|
||||
@IAgentContextMemoryService private readonly context: IAgentContextMemoryService,
|
||||
@IAgentContextProjectorService private readonly projector: IAgentContextProjectorService,
|
||||
|
|
@ -166,7 +187,38 @@ export class AgentLLMRequesterService implements IAgentLLMRequesterService {
|
|||
@IWireService private readonly wire: IWireService,
|
||||
@IFaultInjectionService private readonly faultInjection: IFaultInjectionService,
|
||||
@IEventBus private readonly eventBus: IEventBus,
|
||||
) {}
|
||||
@IAgentStateService private readonly states: IAgentStateService,
|
||||
) {
|
||||
this.states.register(llmRequesterLastConfigLogSignatureKey);
|
||||
this.states.register(llmRequesterTurnConfigsKey);
|
||||
this.states.register(llmRequesterMediaDegradedTurnsKey);
|
||||
this.states.register(llmRequesterMediaStrippedTurnsKey);
|
||||
this.states.register(llmRequesterEmittedThinkingEffortWarningsKey);
|
||||
}
|
||||
|
||||
private get lastConfigLogSignature(): string | undefined {
|
||||
return this.states.get(llmRequesterLastConfigLogSignatureKey);
|
||||
}
|
||||
|
||||
private set lastConfigLogSignature(value: string | undefined) {
|
||||
this.states.set(llmRequesterLastConfigLogSignatureKey, value);
|
||||
}
|
||||
|
||||
private get turnConfigs(): Map<number, TurnRequestConfig> {
|
||||
return this.states.get(llmRequesterTurnConfigsKey);
|
||||
}
|
||||
|
||||
private get mediaDegradedTurns(): Set<number> {
|
||||
return this.states.get(llmRequesterMediaDegradedTurnsKey);
|
||||
}
|
||||
|
||||
private get mediaStrippedTurns(): Map<number, MediaStripSnapshot> {
|
||||
return this.states.get(llmRequesterMediaStrippedTurnsKey);
|
||||
}
|
||||
|
||||
private get emittedThinkingEffortWarnings(): Set<string> {
|
||||
return this.states.get(llmRequesterEmittedThinkingEffortWarningsKey);
|
||||
}
|
||||
|
||||
prepareTurnConfig(turnId: number): PreparedTurnRequestConfig | undefined {
|
||||
if (!this.profile.hasProvider()) return undefined;
|
||||
|
|
|
|||
|
|
@ -25,10 +25,17 @@
|
|||
* compacts and re-enqueues it — so the loop only learns caught-or-not, while
|
||||
* an unclaimed or uncaught error fails the turn. Emits `turn.*` / delta
|
||||
* events through `event`, persists loop events through `contextMemory`, and
|
||||
* reads the step budget from `config`. Bound at Agent scope. The `turnEvents`
|
||||
* import is load-bearing beyond the prompt-text helper: it loads the
|
||||
* `DomainEventMap` augmentation for the `turn.*` / delta events published
|
||||
* here, which lives with the event definitions.
|
||||
* reads the step budget from `config`. The plain-data loop state
|
||||
* (`nextReservedTurnId`, `lastRequestTraceId`, `disposing`) is registered
|
||||
* into `agentState` (`IAgentStateService`) and read/written through it;
|
||||
* `pendingTurns` and `activeTurnJob` stay plain fields because a `TurnJob`
|
||||
* holds resources (`AbortController`, controlled promises, a
|
||||
* `StepRequestQueue`) that must not be snapshotted, alongside the mechanism
|
||||
* resources (`standaloneStepQueue`, `pendingAssignments`, `errorHandlers`,
|
||||
* `settleWaiters`, `activeRequestTrace`). Bound at Agent
|
||||
* scope. The `turnEvents` import is load-bearing beyond the prompt-text
|
||||
* helper: it loads the `DomainEventMap` augmentation for the `turn.*` / delta
|
||||
* events published here, which lives with the event definitions.
|
||||
*/
|
||||
|
||||
import { randomUUID } from 'node:crypto';
|
||||
|
|
@ -38,6 +45,7 @@ import { createControlledPromise } from '@antfu/utils';
|
|||
import { InstantiationType } from '#/_base/di/extensions';
|
||||
import { Disposable, toDisposable, type IDisposable } from '#/_base/di/lifecycle';
|
||||
import { LifecycleScope, registerScopedService } from '#/_base/di/scope';
|
||||
import { defineState } from '#/_base/state/stateRegistry';
|
||||
import { abortError, isAbortError, isUserCancellation, userCancellationReason } from '#/_base/utils/abort';
|
||||
import { toErrorMessage } from '#/_base/errors/errorMessage';
|
||||
import { IAgentLLMRequesterService, type AgentLLMRequestFinish } from '#/agent/llmRequester/llmRequester';
|
||||
|
|
@ -52,6 +60,7 @@ import { BugIndicatingError, ErrorCodes, Error2, isError2, toKimiErrorPayload }
|
|||
import { OrderedHookSlot } from '#/hooks';
|
||||
|
||||
import { IAgentContextMemoryService } from '#/agent/contextMemory/contextMemory';
|
||||
import { IAgentStateService } from '#/agent/state/agentState';
|
||||
import { IAgentTelemetryContextService } from '#/app/telemetry/agentTelemetryContext';
|
||||
import type {
|
||||
TurnEndedEvent as TurnEndedTelemetryEvent,
|
||||
|
|
@ -89,6 +98,16 @@ import { cancelTurn, promptTurn, TurnModel } from './turnOps';
|
|||
|
||||
export type LoopInterruptReason = 'aborted' | 'max_steps' | 'error';
|
||||
|
||||
export const loopNextReservedTurnIdKey = defineState<number | undefined>(
|
||||
'loop.nextReservedTurnId',
|
||||
() => undefined as number | undefined,
|
||||
);
|
||||
export const loopLastRequestTraceIdKey = defineState<string | undefined>(
|
||||
'loop.lastRequestTraceId',
|
||||
() => undefined as string | undefined,
|
||||
);
|
||||
export const loopDisposingKey = defineState<boolean>('loop.disposing', () => false);
|
||||
|
||||
export class AgentLoopService extends Disposable implements IAgentLoopService {
|
||||
declare readonly _serviceBrand: undefined;
|
||||
|
||||
|
|
@ -102,11 +121,8 @@ export class AgentLoopService extends Disposable implements IAgentLoopService {
|
|||
private readonly errorHandlers: LoopErrorHandler[] = [];
|
||||
private readonly pendingTurns: TurnJob[] = [];
|
||||
private activeTurnJob: TurnJob | undefined;
|
||||
private nextReservedTurnId: number | undefined;
|
||||
private readonly settleWaiters: Array<() => void> = [];
|
||||
private activeRequestTrace: LLMRequestTrace | undefined;
|
||||
private lastRequestTraceId: string | undefined;
|
||||
private disposing = false;
|
||||
|
||||
constructor(
|
||||
@IAgentContextMemoryService private readonly context: IAgentContextMemoryService,
|
||||
|
|
@ -117,8 +133,36 @@ export class AgentLoopService extends Disposable implements IAgentLoopService {
|
|||
@IWireService private readonly wire: IWireService,
|
||||
@ITelemetryService private readonly telemetry: ITelemetryService,
|
||||
@IAgentTelemetryContextService private readonly telemetryContext: IAgentTelemetryContextService,
|
||||
@IAgentStateService private readonly states: IAgentStateService,
|
||||
) {
|
||||
super();
|
||||
this.states.register(loopNextReservedTurnIdKey);
|
||||
this.states.register(loopLastRequestTraceIdKey);
|
||||
this.states.register(loopDisposingKey);
|
||||
}
|
||||
|
||||
private get nextReservedTurnId(): number | undefined {
|
||||
return this.states.get(loopNextReservedTurnIdKey);
|
||||
}
|
||||
|
||||
private set nextReservedTurnId(value: number | undefined) {
|
||||
this.states.set(loopNextReservedTurnIdKey, value);
|
||||
}
|
||||
|
||||
private get lastRequestTraceId(): string | undefined {
|
||||
return this.states.get(loopLastRequestTraceIdKey);
|
||||
}
|
||||
|
||||
private set lastRequestTraceId(value: string | undefined) {
|
||||
this.states.set(loopLastRequestTraceIdKey, value);
|
||||
}
|
||||
|
||||
private get disposing(): boolean {
|
||||
return this.states.get(loopDisposingKey);
|
||||
}
|
||||
|
||||
private set disposing(value: boolean) {
|
||||
this.states.set(loopDisposingKey, value);
|
||||
}
|
||||
|
||||
override dispose(): void {
|
||||
|
|
|
|||
|
|
@ -1,13 +1,30 @@
|
|||
/**
|
||||
* `mcp` domain (L5) — `IAgentMcpService` implementation.
|
||||
*
|
||||
* Mirrors the session-level MCP connection manager's server set into the
|
||||
* agent's tool registry: registers qualified tools for connected servers,
|
||||
* keeps them registered across reconnects, swaps in the OAuth tool for
|
||||
* `needs-auth` servers, journals tool discoveries on the wire (queued until
|
||||
* restore finishes), and publishes `mcp.server.status` / `tool.list.updated`
|
||||
* events. The plain-data state (`mcpToolsByServer`, `discoveryWritesReady`)
|
||||
* is registered into `agentState` (`IAgentStateService`) and read/written
|
||||
* through it; `mcpTools` stays a plain instance field (its values hold
|
||||
* disposable resource handles, not plain data), as does `pendingDiscoveries`
|
||||
* (a closure queue of deferred discovery writes). Bound at Agent scope.
|
||||
*/
|
||||
|
||||
import { createHash } from 'node:crypto';
|
||||
|
||||
import { InstantiationType } from '#/_base/di/extensions';
|
||||
import { LifecycleScope, registerScopedService } from '#/_base/di/scope';
|
||||
import { defineState } from '#/_base/state/stateRegistry';
|
||||
import type { Tool as KosongTool } from '#/kosong/contract/tool';
|
||||
|
||||
import { Disposable, type IDisposable } from "#/_base/di/lifecycle";
|
||||
import type { KimiErrorPayload } from '#/_base/errors/serialize';
|
||||
import { ErrorCodes, makeErrorPayload } from "#/errors";
|
||||
import { abortable } from '#/_base/utils/abort';
|
||||
import { IAgentStateService } from '#/agent/state/agentState';
|
||||
import { IEventBus } from '#/app/event/eventBus';
|
||||
import { ITelemetryService } from '#/app/telemetry/telemetry';
|
||||
import { sessionMediaOriginalsDir } from '#/agent/media/image-originals';
|
||||
|
|
@ -66,12 +83,19 @@ interface McpToolRegistration {
|
|||
readonly serverName: string;
|
||||
}
|
||||
|
||||
export const mcpMcpToolsByServerKey = defineState<Map<string, string[]>>(
|
||||
'mcp.mcpToolsByServer',
|
||||
() => new Map(),
|
||||
);
|
||||
export const mcpDiscoveryWritesReadyKey = defineState<boolean>(
|
||||
'mcp.discoveryWritesReady',
|
||||
() => false,
|
||||
);
|
||||
|
||||
export class AgentMcpService extends Disposable implements IAgentMcpService {
|
||||
declare readonly _serviceBrand: undefined;
|
||||
private readonly mcpTools = new Map<string, McpToolRegistration>();
|
||||
private readonly mcpToolsByServer = new Map<string, string[]>();
|
||||
private readonly pendingDiscoveries: Array<() => void> = [];
|
||||
private discoveryWritesReady = false;
|
||||
|
||||
constructor(
|
||||
@ISessionMcpService private readonly sessionMcp: ISessionMcpService,
|
||||
|
|
@ -81,8 +105,11 @@ export class AgentMcpService extends Disposable implements IAgentMcpService {
|
|||
@IAgentToolExecutorService toolExecutor: IAgentToolExecutorService,
|
||||
@IWireService private readonly wire: IWireService,
|
||||
@ITelemetryService private readonly telemetry: ITelemetryService,
|
||||
@IAgentStateService private readonly states: IAgentStateService,
|
||||
) {
|
||||
super();
|
||||
this.states.register(mcpMcpToolsByServerKey);
|
||||
this.states.register(mcpDiscoveryWritesReadyKey);
|
||||
this.attachMcpTools();
|
||||
this._register(
|
||||
toolExecutor.onWillExecuteTool((event) => {
|
||||
|
|
@ -97,6 +124,18 @@ export class AgentMcpService extends Disposable implements IAgentMcpService {
|
|||
);
|
||||
}
|
||||
|
||||
private get mcpToolsByServer(): Map<string, string[]> {
|
||||
return this.states.get(mcpMcpToolsByServerKey);
|
||||
}
|
||||
|
||||
private get discoveryWritesReady(): boolean {
|
||||
return this.states.get(mcpDiscoveryWritesReadyKey);
|
||||
}
|
||||
|
||||
private set discoveryWritesReady(value: boolean) {
|
||||
this.states.set(mcpDiscoveryWritesReadyKey, value);
|
||||
}
|
||||
|
||||
get oauthService() {
|
||||
return this.sessionMcp.connectionManager().oauthService;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,14 +17,20 @@
|
|||
* converts `video_url` takes the inline fallback when no upload hook
|
||||
* exists.
|
||||
*
|
||||
* `AgentLifecycleService.create` force-instantiates this service right after
|
||||
* the builtin-tools registrar, before any `opts.binding` bind runs, so the
|
||||
* first `agent.status.updated` is always observed.
|
||||
* The plain-data state (`registeredKey`) is registered into `agentState`
|
||||
* (`IAgentStateService`) and read/written through it; `registration` stays an
|
||||
* instance field (the live `IDisposable` tool-registration handle, not plain
|
||||
* data).
|
||||
*
|
||||
* Agent scope creation instantiates this service before any `opts.binding`
|
||||
* bind runs, so the first `agent.status.updated` is always observed.
|
||||
*/
|
||||
|
||||
import { Disposable, toDisposable, type IDisposable } from '#/_base/di/lifecycle';
|
||||
import { InstantiationType } from '#/_base/di/extensions';
|
||||
import { LifecycleScope, registerScopedService } from '#/_base/di/scope';
|
||||
import { defineState } from '#/_base/state/stateRegistry';
|
||||
import { IAgentStateService } from '#/agent/state/agentState';
|
||||
import { IEventBus } from '#/app/event/eventBus';
|
||||
import { ITelemetryService } from '#/app/telemetry/telemetry';
|
||||
import { IModelCatalog, type Model } from '#/kosong/model/catalog';
|
||||
|
|
@ -40,11 +46,15 @@ import { extendWorkspaceWithSkillRoots } from '#/tool/path-access';
|
|||
import { IAgentMediaToolsRegistrar } from './mediaTools';
|
||||
import { createVideoUploader, registerMediaTools } from './registerMediaTools';
|
||||
|
||||
export const mediaRegisteredKeyKey = defineState<string | undefined>(
|
||||
'media.registeredKey',
|
||||
() => undefined as string | undefined,
|
||||
);
|
||||
|
||||
export class AgentMediaToolsRegistrar extends Disposable implements IAgentMediaToolsRegistrar {
|
||||
declare readonly _serviceBrand: undefined;
|
||||
|
||||
private registration: IDisposable | undefined;
|
||||
private registeredKey: string | undefined;
|
||||
|
||||
constructor(
|
||||
@IAgentToolRegistryService private readonly toolRegistry: IAgentToolRegistryService,
|
||||
|
|
@ -55,14 +65,24 @@ export class AgentMediaToolsRegistrar extends Disposable implements IAgentMediaT
|
|||
@IHostEnvironment private readonly env: IHostEnvironment,
|
||||
@ISessionWorkspaceContext private readonly workspaceCtx: ISessionWorkspaceContext,
|
||||
@ITelemetryService private readonly telemetry: ITelemetryService,
|
||||
@IAgentStateService private readonly states: IAgentStateService,
|
||||
@ISessionSkillCatalog private readonly skillCatalog?: ISessionSkillCatalog,
|
||||
) {
|
||||
super();
|
||||
this.states.register(mediaRegisteredKeyKey);
|
||||
this.refresh();
|
||||
this._register(eventBus.subscribe('agent.status.updated', () => this.refresh()));
|
||||
this._register(toDisposable(() => this.registration?.dispose()));
|
||||
}
|
||||
|
||||
private get registeredKey(): string | undefined {
|
||||
return this.states.get(mediaRegisteredKeyKey);
|
||||
}
|
||||
|
||||
private set registeredKey(value: string | undefined) {
|
||||
this.states.set(mediaRegisteredKeyKey, value);
|
||||
}
|
||||
|
||||
private refresh(): void {
|
||||
const capabilities = this.profile.getModelCapabilities();
|
||||
const key = [
|
||||
|
|
|
|||
|
|
@ -18,13 +18,17 @@
|
|||
* the agent's lifetime. Resolution outcomes are memoized per (file, provider)
|
||||
* for step/retry stability — except a transient upload failure, which
|
||||
* degrades only the current request to the tag form so a later step retries
|
||||
* the upload instead of freezing the fallback. Bound at Agent scope.
|
||||
* the upload instead of freezing the fallback. The plain-data state
|
||||
* (`resolved`) is registered into `agentState` (`IAgentStateService`) and
|
||||
* read/written through it. Bound at Agent scope.
|
||||
*/
|
||||
|
||||
import { createHash } from 'node:crypto';
|
||||
|
||||
import { InstantiationType } from '#/_base/di/extensions';
|
||||
import { LifecycleScope, registerScopedService } from '#/_base/di/scope';
|
||||
import { defineState } from '#/_base/state/stateRegistry';
|
||||
import { IAgentStateService } from '#/agent/state/agentState';
|
||||
import { IFileService } from '#/app/file/fileService';
|
||||
import { ITelemetryService } from '#/app/telemetry/telemetry';
|
||||
import type { ContentPart, Message } from '#/kosong/contract/message';
|
||||
|
|
@ -50,16 +54,26 @@ const VIDEO_UNAVAILABLE_TEXT =
|
|||
const textEncoder = new TextEncoder();
|
||||
const textDecoder = new TextDecoder();
|
||||
|
||||
export const mediaResolvedKey = defineState<Map<string, ContentPart>>(
|
||||
'media.resolved',
|
||||
() => new Map(),
|
||||
);
|
||||
|
||||
export class AgentVideoResolverService implements IAgentVideoResolverService {
|
||||
declare readonly _serviceBrand: undefined;
|
||||
|
||||
private readonly resolved = new Map<string, ContentPart>();
|
||||
|
||||
constructor(
|
||||
@IFileService private readonly files: IFileService,
|
||||
@IBlobStore private readonly blobs: IBlobStore,
|
||||
@ITelemetryService private readonly telemetry: ITelemetryService,
|
||||
) {}
|
||||
@IAgentStateService private readonly states: IAgentStateService,
|
||||
) {
|
||||
this.states.register(mediaResolvedKey);
|
||||
}
|
||||
|
||||
private get resolved(): Map<string, ContentPart> {
|
||||
return this.states.get(mediaResolvedKey);
|
||||
}
|
||||
|
||||
async resolve(
|
||||
messages: readonly Message[],
|
||||
|
|
|
|||
|
|
@ -6,34 +6,50 @@
|
|||
* `contextInjector`. Dedup is history-derived: the framework mirrors this
|
||||
* variant's live positions across splices, so a reminder folded away by
|
||||
* compaction (or undo) is re-announced on the next inject, matching v1's
|
||||
* compaction behavior.
|
||||
* compaction behavior. The plain-data state (`lastMode`) is registered into
|
||||
* `agentState` (`IAgentStateService`) and read/written through it.
|
||||
*/
|
||||
|
||||
import { Disposable } from '#/_base/di/lifecycle';
|
||||
import { defineState } from '#/_base/state/stateRegistry';
|
||||
import {
|
||||
IAgentContextInjectorService,
|
||||
type ContextInjectionContext,
|
||||
} from '#/agent/contextInjector/contextInjector';
|
||||
import type { IAgentPermissionModeService } from '#/agent/permissionMode/permissionMode';
|
||||
import type { PermissionMode } from '#/agent/permissionPolicy/types';
|
||||
import { IAgentStateService } from '#/agent/state/agentState';
|
||||
import AUTO_MODE_ENTER_REMINDER from './permission-mode-auto-enter-reminder.md?raw';
|
||||
import AUTO_MODE_EXIT_REMINDER from './permission-mode-auto-exit-reminder.md?raw';
|
||||
|
||||
const PERMISSION_MODE_INJECTION_VARIANT = 'permission_mode';
|
||||
|
||||
export class PermissionModeInjection extends Disposable {
|
||||
private lastMode: PermissionMode | undefined;
|
||||
export const permissionModeLastModeKey = defineState<PermissionMode | undefined>(
|
||||
'permissionMode.lastMode',
|
||||
() => undefined as PermissionMode | undefined,
|
||||
);
|
||||
|
||||
export class PermissionModeInjection extends Disposable {
|
||||
constructor(
|
||||
private readonly permissionMode: Pick<IAgentPermissionModeService, 'mode'>,
|
||||
@IAgentContextInjectorService dynamicInjector: IAgentContextInjectorService,
|
||||
@IAgentStateService private readonly states: IAgentStateService,
|
||||
) {
|
||||
super();
|
||||
this.states.register(permissionModeLastModeKey);
|
||||
this._register(
|
||||
dynamicInjector.register(PERMISSION_MODE_INJECTION_VARIANT, (ctx) => this.reminder(ctx)),
|
||||
);
|
||||
}
|
||||
|
||||
private get lastMode(): PermissionMode | undefined {
|
||||
return this.states.get(permissionModeLastModeKey);
|
||||
}
|
||||
|
||||
private set lastMode(value: PermissionMode | undefined) {
|
||||
this.states.set(permissionModeLastModeKey, value);
|
||||
}
|
||||
|
||||
private reminder({ injectedPositions }: ContextInjectionContext): string | undefined {
|
||||
const currentMode = this.permissionMode.mode;
|
||||
const previousMode = this.lastMode;
|
||||
|
|
|
|||
|
|
@ -6,16 +6,20 @@
|
|||
* and on the first inject after deactivation it emits the exit reminder. It reads
|
||||
* the live plan state through `IAgentPlanService.status()` and the recent history
|
||||
* through `IAgentContextMemoryService`, so no derived-state closures are needed.
|
||||
* The telemetry `mode` restore on replay is NOT part of this provider — it lives
|
||||
* The plain-data state (`wasActive`) is registered into `agentState`
|
||||
* (`IAgentStateService`) and read/written through it. The telemetry `mode`
|
||||
* restore on replay is NOT part of this provider — it lives
|
||||
* in `AgentPlanService.restoreTelemetryMode`.
|
||||
*/
|
||||
|
||||
import { Disposable } from '#/_base/di/lifecycle';
|
||||
import { defineState } from '#/_base/state/stateRegistry';
|
||||
import { IAgentContextInjectorService } from '#/agent/contextInjector/contextInjector';
|
||||
import { IAgentContextMemoryService } from '#/agent/contextMemory/contextMemory';
|
||||
import type { ContextMessage } from '#/agent/contextMemory/types';
|
||||
import { IAgentPlanService } from '#/agent/plan/plan';
|
||||
import type { PlanFilePath } from '#/agent/plan/plan';
|
||||
import { IAgentStateService } from '#/agent/state/agentState';
|
||||
import PLAN_MODE_EXIT_REMINDER from './plan-mode-exit-reminder.md?raw';
|
||||
import PLAN_MODE_FULL_REMINDER from './plan-mode-full-reminder.md?raw';
|
||||
import PLAN_MODE_INLINE_FULL_REMINDER from './plan-mode-inline-full-reminder.md?raw';
|
||||
|
|
@ -28,26 +32,29 @@ const PLAN_MODE_DEDUP_MIN_TURNS = 2;
|
|||
const PLAN_MODE_FULL_REFRESH_TURNS = 5;
|
||||
const PLAN_MODE_INJECTION_VARIANT = 'plan_mode';
|
||||
|
||||
export const planWasActiveKey = defineState<boolean>('plan.wasActive', () => false);
|
||||
|
||||
export class PlanModeInjection extends Disposable {
|
||||
constructor(
|
||||
@IAgentContextInjectorService dynamicInjector: IAgentContextInjectorService,
|
||||
@IAgentPlanService private readonly plan: IAgentPlanService,
|
||||
@IAgentContextMemoryService private readonly context: IAgentContextMemoryService,
|
||||
@IAgentStateService private readonly states: IAgentStateService,
|
||||
) {
|
||||
super();
|
||||
this.states.register(planWasActiveKey);
|
||||
|
||||
let wasActive = false;
|
||||
this._register(
|
||||
dynamicInjector.register(PLAN_MODE_INJECTION_VARIANT, async ({ lastInjectedAt: injectedAt }) => {
|
||||
const data = await this.plan.status();
|
||||
if (data === null) {
|
||||
if (!wasActive) return undefined;
|
||||
wasActive = false;
|
||||
if (!this.states.get(planWasActiveKey)) return undefined;
|
||||
this.states.set(planWasActiveKey, false);
|
||||
return PLAN_MODE_EXIT_REMINDER;
|
||||
}
|
||||
const planFilePath = data.path;
|
||||
if (!wasActive) {
|
||||
wasActive = true;
|
||||
if (!this.states.get(planWasActiveKey)) {
|
||||
this.states.set(planWasActiveKey, true);
|
||||
if (data.content.trim().length > 0) {
|
||||
return reentryReminder(planFilePath);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -35,6 +35,7 @@ import { IAgentContextInjectorService } from '#/agent/contextInjector/contextInj
|
|||
import { IAgentPermissionModeService } from '#/agent/permissionMode/permissionMode';
|
||||
import { PlanModeInjection } from '#/agent/plan/injection/planModeInjection';
|
||||
import { IAgentScopeContext } from '#/agent/scopeContext/scopeContext';
|
||||
import { IAgentStateService } from '#/agent/state/agentState';
|
||||
import { IAgentToolApprovalService } from '#/agent/toolApproval/toolApproval';
|
||||
import { denyToolExecution } from '#/agent/toolExecutor/beforeToolExecuteEvent';
|
||||
import { IAgentToolExecutorService } from '#/agent/toolExecutor/toolExecutor';
|
||||
|
|
@ -81,6 +82,7 @@ export class AgentPlanService extends Disposable implements IAgentPlanService {
|
|||
@IAgentToolApprovalService private readonly toolApproval: IAgentToolApprovalService,
|
||||
@IAgentPermissionModeService private readonly modeService: IAgentPermissionModeService,
|
||||
@ITelemetryService telemetry: ITelemetryService,
|
||||
@IAgentStateService states: IAgentStateService,
|
||||
) {
|
||||
super();
|
||||
|
||||
|
|
@ -93,7 +95,7 @@ export class AgentPlanService extends Disposable implements IAgentPlanService {
|
|||
}),
|
||||
);
|
||||
|
||||
this._register(new PlanModeInjection(dynamicInjector, this, this.context));
|
||||
this._register(new PlanModeInjection(dynamicInjector, this, this.context, states));
|
||||
this._register(this.registerPlanGuard(toolExecutor));
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -4,8 +4,8 @@
|
|||
* Renders session-start skills from `plugin` and `sessionSkillCatalog`, injects
|
||||
* them through `contextInjector` and `systemReminder`, and uses `contextMemory`
|
||||
* to neutralize stale guidance. Main-agent-only (v1 parity): the service
|
||||
* self-gates on `agentId === 'main'`, and the agent bootstrap force-instantiates
|
||||
* it (`igniteEagerServices`) so other agents construct it as a no-op. Resolves
|
||||
* self-gates on `agentId === 'main'`; Agent scope creation instantiates it for
|
||||
* every agent, so other agents construct it as a no-op. Resolves
|
||||
* session prompt context through `sessionContext` and reports missing skills
|
||||
* through `log`. Bound at Agent scope.
|
||||
*/
|
||||
|
|
@ -49,9 +49,9 @@ export class AgentPluginService extends Disposable implements IAgentPluginServic
|
|||
) {
|
||||
super();
|
||||
// Plugin session-start guidance is main-agent-only (v1 parity:
|
||||
// `pluginSessionStarts: type === 'main' ? … : undefined`). The bootstrap
|
||||
// force-instantiates this Delayed service for every agent
|
||||
// (`igniteEagerServices`); non-main agents no-op.
|
||||
// `pluginSessionStarts: type === 'main' ? … : undefined`). Agent scope
|
||||
// creation instantiates this service for every agent; non-main agents
|
||||
// no-op.
|
||||
if (scopeContext.agentId !== MAIN_AGENT_ID) return;
|
||||
this._register(
|
||||
injector.register(
|
||||
|
|
|
|||
|
|
@ -17,8 +17,8 @@
|
|||
* persisted base (`ActiveToolsModel`, rebuilt by `wire.replay`) overlaid with
|
||||
* the ephemeral per-tool deltas from `addActiveTool` / `removeActiveTool`
|
||||
* (used by `userTool`; intentionally not persisted, re-derived on resume); the
|
||||
* live overlay is cached in a field and falls back to the Model when unset, so
|
||||
* no restore-ordering coupling with `userTool` arises. Profile and client
|
||||
* live overlay is held in `agentState` and falls back to the Model when unset,
|
||||
* so no restore-ordering coupling with `userTool` arises. Profile and client
|
||||
* policy are persisted independently. The `agent.status.updated`
|
||||
* / `warning` events now ride `IEventBus` (`agent.status.updated` canonical in
|
||||
* `usageOps`). `chdir` and
|
||||
|
|
@ -43,12 +43,19 @@
|
|||
* typo in one agent file cannot legitimize the same typo in another, and
|
||||
* flag-gated tools (which every builtin profile lists) stay "known" even when
|
||||
* unregistered.
|
||||
* Bound at Agent scope.
|
||||
* The mutable plain-data state (`activeToolNamesOverlay` / `agentsMdWarning`
|
||||
* / the two emitted-warning dedupe sets) is registered into `agentState`
|
||||
* (`IAgentStateService`) and read/written through it; `optionsValue` (holds
|
||||
* the `cwd` / `chdir` / `emitStatusUpdated` callbacks) and `activeProfile`
|
||||
* (a `ResolvedAgentProfile` carrying the `systemPrompt` function) stay plain
|
||||
* fields because the container only holds pure data structures. Bound at
|
||||
* Agent scope.
|
||||
*/
|
||||
|
||||
import { InstantiationType } from '#/_base/di/extensions';
|
||||
import { Disposable } from '#/_base/di/lifecycle';
|
||||
import { LifecycleScope, registerScopedService } from '#/_base/di/scope';
|
||||
import { defineState } from '#/_base/state/stateRegistry';
|
||||
import { UNKNOWN_CAPABILITY, type ModelCapability } from '#/kosong/contract/capability';
|
||||
import { type SamplingOptions, type ThinkingEffort } from '#/kosong/contract/provider';
|
||||
import { IModelCatalog, type Model } from '#/kosong/model/catalog';
|
||||
|
|
@ -80,6 +87,7 @@ import { ISessionSkillCatalog } from '#/session/sessionSkillCatalog/skillCatalog
|
|||
import { ISessionAgentProfileCatalog } from '#/session/sessionAgentProfileCatalog/sessionAgentProfileCatalog';
|
||||
import { ISessionToolPolicy } from '#/session/sessionToolPolicy/sessionToolPolicy';
|
||||
import type { ResolvedAgentProfile, SystemPromptContext } from '#/agent/profile/profile';
|
||||
import { IAgentStateService } from '#/agent/state/agentState';
|
||||
|
||||
import { ITelemetryService } from '#/app/telemetry/telemetry';
|
||||
import { IAgentTelemetryContextService } from '#/app/telemetry/agentTelemetryContext';
|
||||
|
|
@ -139,14 +147,27 @@ function describeInactiveToolPattern(
|
|||
}
|
||||
}
|
||||
|
||||
export const profileActiveToolNamesOverlayKey = defineState<readonly string[] | undefined>(
|
||||
'profile.activeToolNamesOverlay',
|
||||
() => undefined as readonly string[] | undefined,
|
||||
);
|
||||
export const profileAgentsMdWarningKey = defineState<string | undefined>(
|
||||
'profile.agentsMdWarning',
|
||||
() => undefined as string | undefined,
|
||||
);
|
||||
export const profileEmittedThinkingEffortWarningsKey = defineState<Set<string>>(
|
||||
'profile.emittedThinkingEffortWarnings',
|
||||
() => new Set(),
|
||||
);
|
||||
export const profileEmittedToolPatternWarningsKey = defineState<Set<string>>(
|
||||
'profile.emittedToolPatternWarnings',
|
||||
() => new Set(),
|
||||
);
|
||||
|
||||
export class AgentProfileService extends Disposable implements IAgentProfileService {
|
||||
declare readonly _serviceBrand: undefined;
|
||||
|
||||
private optionsValue: ProfileServiceOptions = {};
|
||||
private activeToolNamesOverlay: readonly string[] | undefined;
|
||||
private agentsMdWarning: string | undefined;
|
||||
private readonly emittedThinkingEffortWarnings = new Set<string>();
|
||||
private readonly emittedToolPatternWarnings = new Set<string>();
|
||||
|
||||
private get activeToolNames(): ActiveToolsState {
|
||||
return (
|
||||
|
|
@ -175,8 +196,13 @@ export class AgentProfileService extends Disposable implements IAgentProfileServ
|
|||
@ISessionToolPolicy private readonly sessionToolPolicy: ISessionToolPolicy,
|
||||
@IAgentToolRegistryService private readonly toolRegistry: IAgentToolRegistryService,
|
||||
@IAgentProfileCatalogService private readonly builtinProfiles: IAgentProfileCatalogService,
|
||||
@IAgentStateService private readonly states: IAgentStateService,
|
||||
) {
|
||||
super();
|
||||
this.states.register(profileActiveToolNamesOverlayKey);
|
||||
this.states.register(profileAgentsMdWarningKey);
|
||||
this.states.register(profileEmittedThinkingEffortWarningsKey);
|
||||
this.states.register(profileEmittedToolPatternWarningsKey);
|
||||
this.configure({});
|
||||
this._register(
|
||||
this.sessionToolPolicy.onDidChange((event) => {
|
||||
|
|
@ -193,6 +219,30 @@ export class AgentProfileService extends Disposable implements IAgentProfileServ
|
|||
);
|
||||
}
|
||||
|
||||
private get activeToolNamesOverlay(): readonly string[] | undefined {
|
||||
return this.states.get(profileActiveToolNamesOverlayKey);
|
||||
}
|
||||
|
||||
private set activeToolNamesOverlay(value: readonly string[] | undefined) {
|
||||
this.states.set(profileActiveToolNamesOverlayKey, value);
|
||||
}
|
||||
|
||||
private get agentsMdWarning(): string | undefined {
|
||||
return this.states.get(profileAgentsMdWarningKey);
|
||||
}
|
||||
|
||||
private set agentsMdWarning(value: string | undefined) {
|
||||
this.states.set(profileAgentsMdWarningKey, value);
|
||||
}
|
||||
|
||||
private get emittedThinkingEffortWarnings(): Set<string> {
|
||||
return this.states.get(profileEmittedThinkingEffortWarningsKey);
|
||||
}
|
||||
|
||||
private get emittedToolPatternWarnings(): Set<string> {
|
||||
return this.states.get(profileEmittedToolPatternWarningsKey);
|
||||
}
|
||||
|
||||
configure(options: ProfileServiceOptions): void {
|
||||
this.optionsValue = {
|
||||
cwd: options.cwd ?? this.optionsValue.cwd,
|
||||
|
|
|
|||
|
|
@ -4,12 +4,18 @@
|
|||
* Assigns prompt and message identities, serializes user prompts through an
|
||||
* active slot and FIFO, converts selected pending prompts into active-turn
|
||||
* steers, settles lifecycle handles, and keeps system input outside the prompt
|
||||
* resource model. Bound at Agent scope.
|
||||
* resource model. The pure-data `launching` flag is registered into
|
||||
* `agentState` (`IAgentStateService`) and read/written through it; the
|
||||
* `active` / `pending` / `steered` records stay plain fields because their
|
||||
* `Record` values carry Deferred promise handles (the container only holds
|
||||
* pure data structures), as do the lazily-resolved `fullCompactionService`
|
||||
* reference and the `hooks` slot. Bound at Agent scope.
|
||||
*/
|
||||
|
||||
import { InstantiationType } from '#/_base/di/extensions';
|
||||
import { IInstantiationService } from '#/_base/di/instantiation';
|
||||
import { LifecycleScope, registerScopedService } from '#/_base/di/scope';
|
||||
import { defineState } from '#/_base/state/stateRegistry';
|
||||
import { extractImageCompressionCaptions } from '#/agent/media/image-compress';
|
||||
import { userCancellationReason } from '#/_base/utils/abort';
|
||||
import { IAgentContextMemoryService } from '#/agent/contextMemory/contextMemory';
|
||||
|
|
@ -19,6 +25,7 @@ import { USER_PROMPT_ORIGIN, type ContextMessage } from '#/agent/contextMemory/t
|
|||
import { IAgentFullCompactionService } from '#/agent/fullCompaction/fullCompaction';
|
||||
import { IAgentLoopService, type Turn, type TurnResult } from '#/agent/loop/loop';
|
||||
import { steerTurn } from '#/agent/loop/turnOps';
|
||||
import { IAgentStateService } from '#/agent/state/agentState';
|
||||
import { IAgentSystemReminderService } from '#/agent/systemReminder/systemReminder';
|
||||
import type { ExecutableToolResult } from '#/tool/toolContract';
|
||||
import type { ToolDidExecuteContext } from '#/agent/toolExecutor/toolHooks';
|
||||
|
|
@ -57,12 +64,13 @@ interface Record extends PromptSnapshot {
|
|||
handle: PromptHandle;
|
||||
}
|
||||
|
||||
export const promptLaunchingKey = defineState<boolean>('prompt.launching', () => false);
|
||||
|
||||
export class AgentPromptService implements IAgentPromptService {
|
||||
declare readonly _serviceBrand: undefined;
|
||||
private active: (Record & { turn: Turn }) | undefined;
|
||||
private readonly pending: Record[] = [];
|
||||
private readonly steered = new Map<string, Record[]>();
|
||||
private launching = false;
|
||||
private fullCompactionService: IAgentFullCompactionService | undefined;
|
||||
readonly hooks = { onBeforeSubmitPrompt: new OrderedHookSlot<PromptSubmitContext>() };
|
||||
|
||||
|
|
@ -74,13 +82,23 @@ export class AgentPromptService implements IAgentPromptService {
|
|||
@IAgentToolExecutorService toolExecutor: IAgentToolExecutorService,
|
||||
@IWireService private readonly wire: IWireService,
|
||||
@IEventBus private readonly eventBus: IEventBus,
|
||||
@IAgentStateService private readonly states: IAgentStateService,
|
||||
) {
|
||||
this.states.register(promptLaunchingKey);
|
||||
toolExecutor.hooks.onDidExecuteTool.register('prompt-service-delivery', async (ctx, next) => {
|
||||
await this.deliverToolResult(ctx);
|
||||
await next();
|
||||
});
|
||||
}
|
||||
|
||||
private get launching(): boolean {
|
||||
return this.states.get(promptLaunchingKey);
|
||||
}
|
||||
|
||||
private set launching(value: boolean) {
|
||||
this.states.set(promptLaunchingKey, value);
|
||||
}
|
||||
|
||||
async enqueue(input: PromptInput): Promise<PromptHandle> {
|
||||
const id = input.id ?? input.message.id ?? newMessageId();
|
||||
const message = { ...input.message, id };
|
||||
|
|
|
|||
|
|
@ -15,14 +15,20 @@
|
|||
* missed `shell.started` can still route the chunk. A failure text that was
|
||||
* never streamed (empty stdout/stderr) is emitted as a `shell.output` chunk
|
||||
* before `shell.completed`, so live consumers see the output too.
|
||||
*
|
||||
* The plain-data state (`shellCommandTasks`) is registered into `agentState`
|
||||
* (`IAgentStateService`) and read/written through it; `shellCommandControllers`
|
||||
* stays an instance field (per-command `AbortController`s, not plain data).
|
||||
*/
|
||||
|
||||
import { InstantiationType } from '#/_base/di/extensions';
|
||||
import { LifecycleScope, registerScopedService } from '#/_base/di/scope';
|
||||
import { defineState } from '#/_base/state/stateRegistry';
|
||||
import { userCancellationReason } from '#/_base/utils/abort';
|
||||
import { escapeXml } from '#/_base/utils/xml-escape';
|
||||
import { IAgentContextMemoryService } from '#/agent/contextMemory/contextMemory';
|
||||
import { IAgentPromptService } from '#/agent/prompt/prompt';
|
||||
import { IAgentStateService } from '#/agent/state/agentState';
|
||||
import type { ToolUpdate } from '#/tool/toolContract';
|
||||
import { IAgentToolRegistryService } from '#/agent/toolRegistry/toolRegistry';
|
||||
import { IEventBus } from '#/app/event/eventBus';
|
||||
|
|
@ -73,17 +79,28 @@ declare module '#/app/event/eventBus' {
|
|||
|
||||
const SHELL_FOREGROUND_TIMEOUT_S = 2 * 60;
|
||||
|
||||
export const shellCommandTasksKey = defineState<Map<string, string>>(
|
||||
'shellCommand.tasks',
|
||||
() => new Map(),
|
||||
);
|
||||
|
||||
export class AgentShellCommandService implements IAgentShellCommandService {
|
||||
declare readonly _serviceBrand: undefined;
|
||||
private readonly shellCommandControllers = new Map<string, AbortController>();
|
||||
private readonly shellCommandTasks = new Map<string, string>();
|
||||
|
||||
constructor(
|
||||
@IAgentToolRegistryService private readonly toolRegistry: IAgentToolRegistryService,
|
||||
@IAgentContextMemoryService private readonly context: IAgentContextMemoryService,
|
||||
@IAgentPromptService private readonly promptService: IAgentPromptService,
|
||||
@IEventBus private readonly eventBus: IEventBus,
|
||||
) { }
|
||||
@IAgentStateService private readonly states: IAgentStateService,
|
||||
) {
|
||||
this.states.register(shellCommandTasksKey);
|
||||
}
|
||||
|
||||
private get shellCommandTasks(): Map<string, string> {
|
||||
return this.states.get(shellCommandTasksKey);
|
||||
}
|
||||
|
||||
async run(input: RunShellCommandInput): Promise<RunShellCommandResult> {
|
||||
this.appendShellInput(input.command);
|
||||
|
|
|
|||
20
packages/agent-core-v2/src/agent/state/agentState.ts
Normal file
20
packages/agent-core-v2/src/agent/state/agentState.ts
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
/**
|
||||
* `state` domain (L1) — Agent-scope keyed state container contract.
|
||||
*
|
||||
* Defines `IAgentStateService`, the Agent-scope state service: Agent-tier
|
||||
* services declare their plain-data state as typed keys (`defineState` from
|
||||
* `_base`) and read/write them through this container, so per-agent shared
|
||||
* state lives in one observable place and dies with the agent. Shares the
|
||||
* `IStateRegistry` method set with its App/Session counterparts. Bound at
|
||||
* Agent scope.
|
||||
*/
|
||||
|
||||
import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation';
|
||||
import type { IStateRegistry } from '#/_base/state/stateRegistry';
|
||||
|
||||
export interface IAgentStateService extends IStateRegistry {
|
||||
readonly _serviceBrand: undefined;
|
||||
}
|
||||
|
||||
export const IAgentStateService: ServiceIdentifier<IAgentStateService> =
|
||||
createDecorator<IAgentStateService>('agentStateService');
|
||||
25
packages/agent-core-v2/src/agent/state/agentStateService.ts
Normal file
25
packages/agent-core-v2/src/agent/state/agentStateService.ts
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
/**
|
||||
* `state` domain (L1) — `IAgentStateService` implementation.
|
||||
*
|
||||
* Thin per-scope binding over the `_base` `StateRegistry`; the container owns
|
||||
* construction and disposal, so registered state dies with the scope. Bound
|
||||
* at Agent scope.
|
||||
*/
|
||||
|
||||
import { InstantiationType } from '#/_base/di/extensions';
|
||||
import { LifecycleScope, registerScopedService } from '#/_base/di/scope';
|
||||
import { StateRegistry } from '#/_base/state/stateRegistry';
|
||||
|
||||
import { IAgentStateService } from './agentState';
|
||||
|
||||
export class AgentStateService extends StateRegistry implements IAgentStateService {
|
||||
declare readonly _serviceBrand: undefined;
|
||||
}
|
||||
|
||||
registerScopedService(
|
||||
LifecycleScope.Agent,
|
||||
IAgentStateService,
|
||||
AgentStateService,
|
||||
InstantiationType.Eager,
|
||||
'state',
|
||||
);
|
||||
|
|
@ -9,13 +9,16 @@
|
|||
* step numbering and consumes `maxSteps` budget like any other step. Each
|
||||
* claimed failure publishes `turn.step.retrying`. Consecutive attempts are
|
||||
* counted per failed driver and reset when any step succeeds (`onDidFinishStep`)
|
||||
* or a new turn starts. Bound at Agent scope; Eager so the handler registers
|
||||
* or a new turn starts. The mutable retry state (`lastFailedDriverId`,
|
||||
* `failedAttempts`) is registered into `agentState` (`IAgentStateService`) and
|
||||
* read/written through it. Bound at Agent scope; Eager so the handler registers
|
||||
* before the first turn runs (same rationale as `fullCompaction`).
|
||||
*/
|
||||
|
||||
import { Disposable } from '#/_base/di/lifecycle';
|
||||
import { InstantiationType } from '#/_base/di/extensions';
|
||||
import { LifecycleScope, registerScopedService } from '#/_base/di/scope';
|
||||
import { defineState } from '#/_base/state/stateRegistry';
|
||||
import {
|
||||
DEFAULT_MAX_RETRY_ATTEMPTS,
|
||||
readRetryAfterMs,
|
||||
|
|
@ -32,6 +35,7 @@ import {
|
|||
type LoopErrorContext,
|
||||
} from '#/agent/loop/loop';
|
||||
import { LOOP_CONTROL_SECTION, type LoopControl } from '#/agent/loop/configSection';
|
||||
import { IAgentStateService } from '#/agent/state/agentState';
|
||||
|
||||
import { IAgentStepRetryService } from './stepRetry';
|
||||
|
||||
|
|
@ -55,18 +59,27 @@ declare module '#/app/event/eventBus' {
|
|||
}
|
||||
}
|
||||
|
||||
export const stepRetryLastFailedDriverIdKey = defineState<string | undefined>(
|
||||
'stepRetry.lastFailedDriverId',
|
||||
() => undefined as string | undefined,
|
||||
);
|
||||
export const stepRetryFailedAttemptsKey = defineState<number>(
|
||||
'stepRetry.failedAttempts',
|
||||
() => 0,
|
||||
);
|
||||
|
||||
export class AgentStepRetryService extends Disposable implements IAgentStepRetryService {
|
||||
declare readonly _serviceBrand: undefined;
|
||||
|
||||
private lastFailedDriverId: string | undefined;
|
||||
private failedAttempts = 0;
|
||||
|
||||
constructor(
|
||||
@IAgentLoopService private readonly loopService: IAgentLoopService,
|
||||
@IConfigService private readonly config: IConfigService,
|
||||
@IEventBus private readonly eventBus: IEventBus,
|
||||
@IAgentStateService private readonly states: IAgentStateService,
|
||||
) {
|
||||
super();
|
||||
this.states.register(stepRetryLastFailedDriverIdKey);
|
||||
this.states.register(stepRetryFailedAttemptsKey);
|
||||
this._register(
|
||||
this.loopService.registerLoopErrorHandler({
|
||||
id: 'step-retry',
|
||||
|
|
@ -83,6 +96,22 @@ export class AgentStepRetryService extends Disposable implements IAgentStepRetry
|
|||
this._register(this.eventBus.subscribe('turn.started', () => this.resetAttempts()));
|
||||
}
|
||||
|
||||
private get lastFailedDriverId(): string | undefined {
|
||||
return this.states.get(stepRetryLastFailedDriverIdKey);
|
||||
}
|
||||
|
||||
private set lastFailedDriverId(value: string | undefined) {
|
||||
this.states.set(stepRetryLastFailedDriverIdKey, value);
|
||||
}
|
||||
|
||||
private get failedAttempts(): number {
|
||||
return this.states.get(stepRetryFailedAttemptsKey);
|
||||
}
|
||||
|
||||
private set failedAttempts(value: number) {
|
||||
this.states.set(stepRetryFailedAttemptsKey, value);
|
||||
}
|
||||
|
||||
private resetAttempts(): void {
|
||||
this.lastFailedDriverId = undefined;
|
||||
this.failedAttempts = 0;
|
||||
|
|
|
|||
|
|
@ -26,6 +26,12 @@
|
|||
* remain governed by the Session lifecycle. Scope disposal paths that bypass
|
||||
* graceful close synchronously cancel/abort work and immediately attempt a
|
||||
* best-effort force-stop to reduce the risk of surviving child processes.
|
||||
* The plain-data task state (`ghosts`, `scheduledNotificationKeys`,
|
||||
* `deliveredNotificationKeys`, `activeTaskReminderPending`) is registered
|
||||
* into `agentState` (`IAgentStateService`) and read/written through it; the
|
||||
* live `tasks` registry stays a plain field because a `ManagedTask` holds
|
||||
* resources (promise chains, an `AbortController`, task handles) that must
|
||||
* not be snapshotted, as does the `persistence` construction-time helper.
|
||||
* Bound at Agent scope.
|
||||
*/
|
||||
|
||||
|
|
@ -38,6 +44,7 @@ import { LifecycleScope, registerScopedService } from '#/_base/di/scope';
|
|||
import type { ContentPart } from '#/kosong/contract/message';
|
||||
|
||||
import { Disposable } from '#/_base/di/lifecycle';
|
||||
import { defineState } from '#/_base/state/stateRegistry';
|
||||
import {
|
||||
abortable,
|
||||
userCancellationReason,
|
||||
|
|
@ -49,6 +56,7 @@ import { IAgentContextInjectorService } from '#/agent/contextInjector/contextInj
|
|||
import { IAgentLoopService } from '#/agent/loop/loop';
|
||||
import { MessageStepRequest } from '#/agent/loop/stepRequest';
|
||||
import { IAgentScopeContext } from '#/agent/scopeContext/scopeContext';
|
||||
import { IAgentStateService } from '#/agent/state/agentState';
|
||||
import { ITaskService, type ITaskHandle, TERMINAL_TASK_STATES } from '#/app/task/task';
|
||||
import {
|
||||
TERMINAL_STATUSES,
|
||||
|
|
@ -213,15 +221,28 @@ export class TaskNotificationStepRequest extends MessageStepRequest {
|
|||
}
|
||||
}
|
||||
|
||||
export const taskGhostsKey = defineState<Map<string, AgentTaskInfo>>(
|
||||
'task.ghosts',
|
||||
() => new Map(),
|
||||
);
|
||||
export const taskScheduledNotificationKeysKey = defineState<Set<string>>(
|
||||
'task.scheduledNotificationKeys',
|
||||
() => new Set(),
|
||||
);
|
||||
export const taskDeliveredNotificationKeysKey = defineState<Set<string>>(
|
||||
'task.deliveredNotificationKeys',
|
||||
() => new Set(),
|
||||
);
|
||||
export const taskActiveTaskReminderPendingKey = defineState<boolean>(
|
||||
'task.activeTaskReminderPending',
|
||||
() => false,
|
||||
);
|
||||
|
||||
export class AgentTaskService extends Disposable implements IAgentTaskService {
|
||||
declare readonly _serviceBrand: undefined;
|
||||
|
||||
private readonly tasks = new Map<string, ManagedTask>();
|
||||
private readonly ghosts = new Map<string, AgentTaskInfo>();
|
||||
private readonly scheduledNotificationKeys = new Set<string>();
|
||||
private readonly deliveredNotificationKeys = new Set<string>();
|
||||
private readonly persistence: AgentTaskPersistence;
|
||||
private activeTaskReminderPending = false;
|
||||
|
||||
constructor(
|
||||
@ITelemetryService private readonly telemetry: ITelemetryService,
|
||||
|
|
@ -236,8 +257,13 @@ export class AgentTaskService extends Disposable implements IAgentTaskService {
|
|||
@IEventBus private readonly eventBus: IEventBus,
|
||||
@IAgentContextInjectorService injector: IAgentContextInjectorService,
|
||||
@IAgentLoopService private readonly loop: IAgentLoopService,
|
||||
@IAgentStateService private readonly states: IAgentStateService,
|
||||
) {
|
||||
super();
|
||||
this.states.register(taskGhostsKey);
|
||||
this.states.register(taskScheduledNotificationKeysKey);
|
||||
this.states.register(taskDeliveredNotificationKeysKey);
|
||||
this.states.register(taskActiveTaskReminderPendingKey);
|
||||
const fallbackRoot =
|
||||
scopeContext.agentId === 'main'
|
||||
? { dir: session.sessionDir, scope: session.scope() }
|
||||
|
|
@ -277,6 +303,26 @@ export class AgentTaskService extends Disposable implements IAgentTaskService {
|
|||
);
|
||||
}
|
||||
|
||||
private get ghosts(): Map<string, AgentTaskInfo> {
|
||||
return this.states.get(taskGhostsKey);
|
||||
}
|
||||
|
||||
private get scheduledNotificationKeys(): Set<string> {
|
||||
return this.states.get(taskScheduledNotificationKeysKey);
|
||||
}
|
||||
|
||||
private get deliveredNotificationKeys(): Set<string> {
|
||||
return this.states.get(taskDeliveredNotificationKeysKey);
|
||||
}
|
||||
|
||||
private get activeTaskReminderPending(): boolean {
|
||||
return this.states.get(taskActiveTaskReminderPendingKey);
|
||||
}
|
||||
|
||||
private set activeTaskReminderPending(value: boolean) {
|
||||
this.states.set(taskActiveTaskReminderPendingKey, value);
|
||||
}
|
||||
|
||||
private async restoreAfterReplay(): Promise<void> {
|
||||
this.restoreGhostsFromWire();
|
||||
await this.loadFromDisk({ replace: false });
|
||||
|
|
|
|||
|
|
@ -5,7 +5,12 @@
|
|||
* hooks, an `onBeforeExecuteTool` veto listener (same-step duplicates are
|
||||
* vetoed with a placeholder synthetic result), and an `onDidExecuteTool`
|
||||
* hook to drive same-step suppression and cross-step repeat reminders, and
|
||||
* reports repeat telemetry through `telemetry`. Constructed eagerly at
|
||||
* reports repeat telemetry through `telemetry`. The mutable dedupe state
|
||||
* (`stepCalls`, `originalCallIndex`, `syntheticCallIds`, `callKeyByCallId`,
|
||||
* `consecutiveKey`, `consecutiveCount`, `activeTurnId`, `activeStep`) is
|
||||
* registered into `agentState` (`IAgentStateService`) and read/written
|
||||
* through it; the `stepDeferreds` promise locks stay plain fields.
|
||||
* Constructed eagerly at
|
||||
* Agent scope so the hooks are installed without any other service
|
||||
* injecting it.
|
||||
*/
|
||||
|
|
@ -15,11 +20,13 @@ import { createHash } from 'node:crypto';
|
|||
import { InstantiationType } from '#/_base/di/extensions';
|
||||
import { Disposable } from '#/_base/di/lifecycle';
|
||||
import { LifecycleScope, registerScopedService } from '#/_base/di/scope';
|
||||
import { defineState } from '#/_base/state/stateRegistry';
|
||||
import { canonicalTelemetryArgs } from '#/_base/utils/canonical-args';
|
||||
import type { ToolCallDedupDetectedEvent, ToolCallRepeatEvent } from '#/app/telemetry/events';
|
||||
import { ITelemetryService } from '#/app/telemetry/telemetry';
|
||||
import type { LLMRequestTrace } from '#/kosong/contract/requestTrace';
|
||||
import { IAgentLoopService } from '#/agent/loop/loop';
|
||||
import { IAgentStateService } from '#/agent/state/agentState';
|
||||
import { IAgentToolExecutorService, type ToolCallDupType } from '#/agent/toolExecutor/toolExecutor';
|
||||
import type { ContentPart } from '#/kosong/contract/message';
|
||||
import { IAgentToolDedupeService, type ToolDedupeResult } from './toolDedupe';
|
||||
|
|
@ -107,24 +114,52 @@ function forceStopResult(result: ToolDedupeResult, reminderText: string): ToolDe
|
|||
|
||||
const DEDUPE_PLACEHOLDER_RESULT: ToolDedupeResult = { output: '' };
|
||||
|
||||
export const toolDedupeStepCallsKey = defineState<string[]>('toolDedupe.stepCalls', () => []);
|
||||
export const toolDedupeOriginalCallIndexKey = defineState<Map<string, number>>(
|
||||
'toolDedupe.originalCallIndex',
|
||||
() => new Map(),
|
||||
);
|
||||
export const toolDedupeSyntheticCallIdsKey = defineState<Set<string>>(
|
||||
'toolDedupe.syntheticCallIds',
|
||||
() => new Set(),
|
||||
);
|
||||
export const toolDedupeCallKeyByCallIdKey = defineState<Map<string, string>>(
|
||||
'toolDedupe.callKeyByCallId',
|
||||
() => new Map(),
|
||||
);
|
||||
export const toolDedupeConsecutiveKeyKey = defineState<string | null>(
|
||||
'toolDedupe.consecutiveKey',
|
||||
() => null,
|
||||
);
|
||||
export const toolDedupeConsecutiveCountKey = defineState<number>(
|
||||
'toolDedupe.consecutiveCount',
|
||||
() => 0,
|
||||
);
|
||||
export const toolDedupeActiveTurnIdKey = defineState<number | undefined>(
|
||||
'toolDedupe.activeTurnId',
|
||||
() => undefined as number | undefined,
|
||||
);
|
||||
export const toolDedupeActiveStepKey = defineState<number>('toolDedupe.activeStep', () => 0);
|
||||
|
||||
export class AgentToolDedupeService extends Disposable implements IAgentToolDedupeService {
|
||||
declare readonly _serviceBrand: undefined;
|
||||
private readonly stepDeferreds = new Map<string, Deferred<ToolDedupeResult>>();
|
||||
private stepCalls: string[] = [];
|
||||
private readonly originalCallIndex = new Map<string, number>();
|
||||
private readonly syntheticCallIds = new Set<string>();
|
||||
private readonly callKeyByCallId = new Map<string, string>();
|
||||
private consecutiveKey: string | null = null;
|
||||
private consecutiveCount = 0;
|
||||
private activeTurnId: number | undefined;
|
||||
private activeStep = 0;
|
||||
|
||||
constructor(
|
||||
@ITelemetryService private readonly telemetry: ITelemetryService,
|
||||
@IAgentLoopService loop: IAgentLoopService,
|
||||
@IAgentToolExecutorService private readonly toolExecutor: IAgentToolExecutorService,
|
||||
@IAgentStateService private readonly states: IAgentStateService,
|
||||
) {
|
||||
super();
|
||||
this.states.register(toolDedupeStepCallsKey);
|
||||
this.states.register(toolDedupeOriginalCallIndexKey);
|
||||
this.states.register(toolDedupeSyntheticCallIdsKey);
|
||||
this.states.register(toolDedupeCallKeyByCallIdKey);
|
||||
this.states.register(toolDedupeConsecutiveKeyKey);
|
||||
this.states.register(toolDedupeConsecutiveCountKey);
|
||||
this.states.register(toolDedupeActiveTurnIdKey);
|
||||
this.states.register(toolDedupeActiveStepKey);
|
||||
loop.hooks.onWillBeginStep.register('toolDedupe', async (ctx, next) => {
|
||||
this.beginStep(ctx.turnId, ctx.step);
|
||||
await next();
|
||||
|
|
@ -159,6 +194,58 @@ export class AgentToolDedupeService extends Disposable implements IAgentToolDedu
|
|||
});
|
||||
}
|
||||
|
||||
private get stepCalls(): string[] {
|
||||
return this.states.get(toolDedupeStepCallsKey);
|
||||
}
|
||||
|
||||
private set stepCalls(value: string[]) {
|
||||
this.states.set(toolDedupeStepCallsKey, value);
|
||||
}
|
||||
|
||||
private get originalCallIndex(): Map<string, number> {
|
||||
return this.states.get(toolDedupeOriginalCallIndexKey);
|
||||
}
|
||||
|
||||
private get syntheticCallIds(): Set<string> {
|
||||
return this.states.get(toolDedupeSyntheticCallIdsKey);
|
||||
}
|
||||
|
||||
private get callKeyByCallId(): Map<string, string> {
|
||||
return this.states.get(toolDedupeCallKeyByCallIdKey);
|
||||
}
|
||||
|
||||
private get consecutiveKey(): string | null {
|
||||
return this.states.get(toolDedupeConsecutiveKeyKey);
|
||||
}
|
||||
|
||||
private set consecutiveKey(value: string | null) {
|
||||
this.states.set(toolDedupeConsecutiveKeyKey, value);
|
||||
}
|
||||
|
||||
private get consecutiveCount(): number {
|
||||
return this.states.get(toolDedupeConsecutiveCountKey);
|
||||
}
|
||||
|
||||
private set consecutiveCount(value: number) {
|
||||
this.states.set(toolDedupeConsecutiveCountKey, value);
|
||||
}
|
||||
|
||||
private get activeTurnId(): number | undefined {
|
||||
return this.states.get(toolDedupeActiveTurnIdKey);
|
||||
}
|
||||
|
||||
private set activeTurnId(value: number | undefined) {
|
||||
this.states.set(toolDedupeActiveTurnIdKey, value);
|
||||
}
|
||||
|
||||
private get activeStep(): number {
|
||||
return this.states.get(toolDedupeActiveStepKey);
|
||||
}
|
||||
|
||||
private set activeStep(value: number) {
|
||||
this.states.set(toolDedupeActiveStepKey, value);
|
||||
}
|
||||
|
||||
private beginStep(turnId?: number, step?: number): void {
|
||||
if (turnId !== undefined && turnId !== this.activeTurnId) {
|
||||
this.activeTurnId = turnId;
|
||||
|
|
|
|||
|
|
@ -7,13 +7,18 @@
|
|||
* through the ordered `onDidExecuteTool` hook, publishes tool lifecycle
|
||||
* events through `event`, records telemetry through `telemetry`, truncates
|
||||
* oversized outputs through `toolResultTruncation`, and logs parse
|
||||
* diagnostics through `log`. Bound at Agent scope.
|
||||
* diagnostics through `log`. The mutable dup-type tracking state
|
||||
* (`toolCallDupTypes`, `dupTypeTurnId`) is registered into `agentState`
|
||||
* (`IAgentStateService`) and read/written through it; the emitters, the hook
|
||||
* slot, and the describer/guard registration slots stay plain fields. Bound
|
||||
* at Agent scope.
|
||||
*/
|
||||
|
||||
import { InstantiationType } from '#/_base/di/extensions';
|
||||
import { toDisposable } from '#/_base/di/lifecycle';
|
||||
import { LifecycleScope, registerScopedService } from '#/_base/di/scope';
|
||||
import { AsyncEmitter, type Event } from '#/_base/event';
|
||||
import { defineState } from '#/_base/state/stateRegistry';
|
||||
import type { ContentPart, ToolCall } from '#/kosong/contract/message';
|
||||
import type { ToolInputDisplay } from '@moonshot-ai/protocol';
|
||||
|
||||
|
|
@ -41,6 +46,7 @@ import type {
|
|||
ToolDidExecuteContext,
|
||||
WillExecuteToolEvent,
|
||||
} from '#/agent/toolExecutor/toolHooks';
|
||||
import { IAgentStateService } from '#/agent/state/agentState';
|
||||
import { IAgentToolRegistryService } from '#/agent/toolRegistry/toolRegistry';
|
||||
import { ILogService } from '#/_base/log/log';
|
||||
import type { ToolCallEvent } from '#/app/telemetry/events';
|
||||
|
|
@ -99,6 +105,15 @@ type ToolExecutionStreamEvent =
|
|||
readonly settled: SettledToolExecutionResult;
|
||||
};
|
||||
|
||||
export const toolExecutorToolCallDupTypesKey = defineState<Map<string, ToolCallDupType>>(
|
||||
'toolExecutor.toolCallDupTypes',
|
||||
() => new Map(),
|
||||
);
|
||||
export const toolExecutorDupTypeTurnIdKey = defineState<number | undefined>(
|
||||
'toolExecutor.dupTypeTurnId',
|
||||
() => undefined as number | undefined,
|
||||
);
|
||||
|
||||
export class AgentToolExecutorService implements IAgentToolExecutorService {
|
||||
declare readonly _serviceBrand: undefined;
|
||||
|
||||
|
|
@ -114,8 +129,6 @@ export class AgentToolExecutorService implements IAgentToolExecutorService {
|
|||
private missingToolDescriber: MissingToolDescriber | undefined;
|
||||
private unavailableToolDescriber: UnavailableToolDescriber | undefined;
|
||||
private toolCallGuard: ToolCallGuard | undefined;
|
||||
private readonly toolCallDupTypes = new Map<string, ToolCallDupType>();
|
||||
private dupTypeTurnId: number | undefined;
|
||||
|
||||
recordDupType(toolCallId: string, dupType: ToolCallDupType): void {
|
||||
this.toolCallDupTypes.set(toolCallId, dupType);
|
||||
|
|
@ -148,8 +161,24 @@ export class AgentToolExecutorService implements IAgentToolExecutorService {
|
|||
@ITelemetryService private readonly telemetry: ITelemetryService,
|
||||
@IAgentToolResultTruncationService
|
||||
private readonly resultTruncation: IAgentToolResultTruncationService,
|
||||
@IAgentStateService private readonly states: IAgentStateService,
|
||||
@ILogService private readonly log?: ILogService,
|
||||
) {}
|
||||
) {
|
||||
this.states.register(toolExecutorToolCallDupTypesKey);
|
||||
this.states.register(toolExecutorDupTypeTurnIdKey);
|
||||
}
|
||||
|
||||
private get toolCallDupTypes(): Map<string, ToolCallDupType> {
|
||||
return this.states.get(toolExecutorToolCallDupTypesKey);
|
||||
}
|
||||
|
||||
private get dupTypeTurnId(): number | undefined {
|
||||
return this.states.get(toolExecutorDupTypeTurnIdKey);
|
||||
}
|
||||
|
||||
private set dupTypeTurnId(value: number | undefined) {
|
||||
this.states.set(toolExecutorDupTypeTurnIdKey, value);
|
||||
}
|
||||
|
||||
async *execute(
|
||||
calls: ToolCall[],
|
||||
|
|
|
|||
|
|
@ -17,8 +17,8 @@
|
|||
* has finished its own constructor and downstream `@IAgentToolRegistryService`
|
||||
* resolutions hit the cached instance instead of re-entering construction.
|
||||
*
|
||||
* `AgentLifecycleService.create` force-instantiates this service on Agent scope
|
||||
* creation so builtin tools land in the registry before the first turn.
|
||||
* Agent scope creation eagerly instantiates this service, so builtin tools
|
||||
* land in the registry before the first turn.
|
||||
*/
|
||||
|
||||
import { Disposable } from '#/_base/di/lifecycle';
|
||||
|
|
|
|||
|
|
@ -1,3 +1,11 @@
|
|||
/**
|
||||
* `toolRegistry` domain (L3) — `IAgentToolRegistryService` implementation.
|
||||
*
|
||||
* The per-agent tool table (`tools`) stays a plain instance field: its values
|
||||
* hold `ExecutableTool` class instances, not plain data, so it is not
|
||||
* registered into `agentState` (`IAgentStateService`). Bound at Agent scope.
|
||||
*/
|
||||
|
||||
import { toDisposable, type IDisposable } from "#/_base/di/lifecycle";
|
||||
import { InstantiationType } from '#/_base/di/extensions';
|
||||
import { LifecycleScope, registerScopedService } from '#/_base/di/scope';
|
||||
|
|
|
|||
|
|
@ -6,13 +6,17 @@
|
|||
* through `systemReminder`, hooks into `loop` before each step, reads
|
||||
* announcement text from `IAgentToolSelectService`, and observes compaction
|
||||
* boundaries from `event`. Turn boundaries need no state: every turn starts
|
||||
* at loop step 1, which always evaluates injection. Bound at Agent scope.
|
||||
* at loop step 1, which always evaluates injection. The compaction-boundary
|
||||
* flag (`needsBoundaryInjection`) is registered into `agentState`
|
||||
* (`IAgentStateService`) and read/written through it. Bound at Agent scope.
|
||||
*/
|
||||
|
||||
import { InstantiationType } from '#/_base/di/extensions';
|
||||
import { Disposable } from '#/_base/di/lifecycle';
|
||||
import { LifecycleScope, registerScopedService } from '#/_base/di/scope';
|
||||
import { defineState } from '#/_base/state/stateRegistry';
|
||||
import { IAgentLoopService } from '#/agent/loop/loop';
|
||||
import { IAgentStateService } from '#/agent/state/agentState';
|
||||
import { IAgentSystemReminderService } from '#/agent/systemReminder/systemReminder';
|
||||
import { IEventBus } from '#/app/event/eventBus';
|
||||
|
||||
|
|
@ -20,17 +24,23 @@ import { LOADABLE_TOOLS_TRIGGER } from './dynamicTools';
|
|||
import { IAgentToolSelectService } from './toolSelect';
|
||||
import { IAgentToolSelectAnnouncementsService } from './toolSelectAnnouncements';
|
||||
|
||||
export const toolSelectNeedsBoundaryInjectionKey = defineState<boolean>(
|
||||
'toolSelect.needsBoundaryInjection',
|
||||
() => false,
|
||||
);
|
||||
|
||||
export class AgentToolSelectAnnouncementsService extends Disposable implements IAgentToolSelectAnnouncementsService {
|
||||
declare readonly _serviceBrand: undefined;
|
||||
private needsBoundaryInjection = false;
|
||||
|
||||
constructor(
|
||||
@IAgentToolSelectService toolSelect: IAgentToolSelectService,
|
||||
@IAgentSystemReminderService private readonly reminders: IAgentSystemReminderService,
|
||||
@IEventBus eventBus: IEventBus,
|
||||
@IAgentLoopService loopService: IAgentLoopService,
|
||||
@IAgentStateService private readonly states: IAgentStateService,
|
||||
) {
|
||||
super();
|
||||
this.states.register(toolSelectNeedsBoundaryInjectionKey);
|
||||
this._register(
|
||||
eventBus.subscribe('compaction.completed', () => {
|
||||
this.needsBoundaryInjection = true;
|
||||
|
|
@ -46,6 +56,14 @@ export class AgentToolSelectAnnouncementsService extends Disposable implements I
|
|||
);
|
||||
}
|
||||
|
||||
private get needsBoundaryInjection(): boolean {
|
||||
return this.states.get(toolSelectNeedsBoundaryInjectionKey);
|
||||
}
|
||||
|
||||
private set needsBoundaryInjection(value: boolean) {
|
||||
this.states.set(toolSelectNeedsBoundaryInjectionKey, value);
|
||||
}
|
||||
|
||||
private inject(toolSelect: IAgentToolSelectService): void {
|
||||
const announcement = toolSelect.loadableToolsAnnouncement();
|
||||
if (announcement === undefined) return;
|
||||
|
|
|
|||
|
|
@ -6,18 +6,22 @@
|
|||
* loadable-tools announcement text. Reads live tools from `toolRegistry`,
|
||||
* active-tool and capability state from `profile`, gates through `flag`,
|
||||
* hooks into `toolExecutor`, and listens to context lifecycle events through
|
||||
* `event`. Bound at Agent scope.
|
||||
* `event`. The mutable load-tracking state (`pendingLoaded`) is registered
|
||||
* into `agentState` (`IAgentStateService`) and read/written through it. Bound
|
||||
* at Agent scope.
|
||||
*/
|
||||
|
||||
import { InstantiationType } from '#/_base/di/extensions';
|
||||
import { Disposable } from '#/_base/di/lifecycle';
|
||||
import { LifecycleScope, registerScopedService } from '#/_base/di/scope';
|
||||
import { defineState } from '#/_base/state/stateRegistry';
|
||||
import { IEventBus } from '#/app/event/eventBus';
|
||||
import { IFlagService } from '#/app/flag/flag';
|
||||
import type { Tool } from '#/kosong/contract/tool';
|
||||
import { IAgentContextMemoryService } from '#/agent/contextMemory/contextMemory';
|
||||
import type { ContextMessage } from '#/agent/contextMemory/types';
|
||||
import { IAgentProfileService } from '#/agent/profile/profile';
|
||||
import { IAgentStateService } from '#/agent/state/agentState';
|
||||
import { IAgentToolPolicyService } from '#/agent/toolPolicy/toolPolicy';
|
||||
import { isMcpToolName, type ToolInfo } from '#/tool/toolContract';
|
||||
import { IAgentToolExecutorService } from '#/agent/toolExecutor/toolExecutor';
|
||||
|
|
@ -38,9 +42,13 @@ import {
|
|||
type ShapedToolEntry,
|
||||
} from './toolSelect';
|
||||
|
||||
export const toolSelectPendingLoadedKey = defineState<Set<string>>(
|
||||
'toolSelect.pendingLoaded',
|
||||
() => new Set(),
|
||||
);
|
||||
|
||||
export class AgentToolSelectService extends Disposable implements IAgentToolSelectService {
|
||||
declare readonly _serviceBrand: undefined;
|
||||
private readonly pendingLoaded = new Set<string>();
|
||||
|
||||
constructor(
|
||||
@IAgentToolRegistryService private readonly toolRegistry: IAgentToolRegistryService,
|
||||
|
|
@ -50,8 +58,10 @@ export class AgentToolSelectService extends Disposable implements IAgentToolSele
|
|||
@IAgentToolExecutorService toolExecutor: IAgentToolExecutorService,
|
||||
@IFlagService private readonly flags: IFlagService,
|
||||
@IEventBus eventBus: IEventBus,
|
||||
@IAgentStateService private readonly states: IAgentStateService,
|
||||
) {
|
||||
super();
|
||||
this.states.register(toolSelectPendingLoadedKey);
|
||||
this._register(
|
||||
toolExecutor.registerUnavailableToolDescriber((name) => this.describeUnavailableTool(name)),
|
||||
);
|
||||
|
|
@ -74,6 +84,10 @@ export class AgentToolSelectService extends Disposable implements IAgentToolSele
|
|||
);
|
||||
}
|
||||
|
||||
private get pendingLoaded(): Set<string> {
|
||||
return this.states.get(toolSelectPendingLoadedKey);
|
||||
}
|
||||
|
||||
enabled(): boolean {
|
||||
const capabilities = this.profile.getModelCapabilities();
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -4,8 +4,10 @@
|
|||
* Accumulates the agent's token usage in the `wire` `UsageModel`, mutating it
|
||||
* only through the `usage.record` Op (`wire.dispatch(recordUsage(...))`) and
|
||||
* deriving `status()` snapshots from `wire.getModel`. The per-turn accumulator
|
||||
* (`currentTurn`) is live-only service state — it is not persisted and resets
|
||||
* on resume, matching v1. The usage slice of `agent.status.updated` is
|
||||
* (`currentTurnId` / `currentTurn`) is live-only service state — it is not
|
||||
* persisted and resets on resume, matching v1 — and is registered into
|
||||
* `agentState` (`IAgentStateService`) and read/written through it. The usage
|
||||
* slice of `agent.status.updated` is
|
||||
* published here after each live record (replay stays silent, like v1's
|
||||
* restore), and the `onDidRecord` event notifies agent-scoped consumers of the
|
||||
* live record. Bound at Agent scope.
|
||||
|
|
@ -16,8 +18,10 @@ import { Disposable } from '#/_base/di/lifecycle';
|
|||
import { InstantiationType } from '#/_base/di/extensions';
|
||||
import { LifecycleScope, registerScopedService } from '#/_base/di/scope';
|
||||
import { Emitter, type Event } from '#/_base/event';
|
||||
import { defineState } from '#/_base/state/stateRegistry';
|
||||
|
||||
import type { AgentLLMRequestSource } from '#/agent/llmRequester/llmRequester';
|
||||
import { IAgentStateService } from '#/agent/state/agentState';
|
||||
import { IEventBus } from '#/app/event/eventBus';
|
||||
import { IWireService } from '#/wire/wire';
|
||||
import type { UsageRecordedContext, UsageStatus } from './usage';
|
||||
|
|
@ -30,20 +34,45 @@ import {
|
|||
type UsageRecordScope,
|
||||
} from './usageOps';
|
||||
|
||||
export const usageCurrentTurnIdKey = defineState<number | undefined>(
|
||||
'usage.currentTurnId',
|
||||
() => undefined as number | undefined,
|
||||
);
|
||||
export const usageCurrentTurnKey = defineState<TokenUsage | undefined>(
|
||||
'usage.currentTurn',
|
||||
() => undefined as TokenUsage | undefined,
|
||||
);
|
||||
|
||||
export class AgentUsageService extends Disposable implements IAgentUsageService {
|
||||
declare readonly _serviceBrand: undefined;
|
||||
|
||||
private readonly _onDidRecord = this._register(new Emitter<UsageRecordedContext>());
|
||||
readonly onDidRecord: Event<UsageRecordedContext> = this._onDidRecord.event;
|
||||
|
||||
private currentTurnId: number | undefined;
|
||||
private currentTurn: TokenUsage | undefined;
|
||||
|
||||
constructor(
|
||||
@IWireService private readonly wire: IWireService,
|
||||
@IAgentStateService private readonly states: IAgentStateService,
|
||||
@IEventBus private readonly eventBus?: IEventBus,
|
||||
) {
|
||||
super();
|
||||
this.states.register(usageCurrentTurnIdKey);
|
||||
this.states.register(usageCurrentTurnKey);
|
||||
}
|
||||
|
||||
private get currentTurnId(): number | undefined {
|
||||
return this.states.get(usageCurrentTurnIdKey);
|
||||
}
|
||||
|
||||
private set currentTurnId(value: number | undefined) {
|
||||
this.states.set(usageCurrentTurnIdKey, value);
|
||||
}
|
||||
|
||||
private get currentTurn(): TokenUsage | undefined {
|
||||
return this.states.get(usageCurrentTurnKey);
|
||||
}
|
||||
|
||||
private set currentTurn(value: TokenUsage | undefined) {
|
||||
this.states.set(usageCurrentTurnKey, value);
|
||||
}
|
||||
|
||||
record(model: string, usage: TokenUsage, source?: AgentLLMRequestSource): void {
|
||||
|
|
|
|||
|
|
@ -27,7 +27,6 @@ import {
|
|||
import { FileStorageService } from '#/persistence/backends/node-fs/fileStorageService';
|
||||
import { FileSkillDiscovery } from '#/app/skillCatalog/fileSkillDiscovery';
|
||||
import { ISkillDiscovery } from '#/app/skillCatalog/skillDiscovery';
|
||||
import { IKosongConfigService } from '#/app/kosongConfig/kosongConfig';
|
||||
|
||||
export interface IBootstrapOptions {
|
||||
readonly homeDir: string;
|
||||
|
|
@ -121,10 +120,6 @@ export function bootstrap(input: BootstrapInput = {}, extraSeeds: ScopeSeed = []
|
|||
const app = createAppScope({
|
||||
extra: [...bootstrapSeed(input), ...storageSeed(options), ...skillSeed(), ...extraSeeds],
|
||||
});
|
||||
// Instantiate the kosong persistence bridge eagerly: kosong's registries
|
||||
// only become `ready` once the bridge has hydrated them from config, and
|
||||
// Eager registration alone never constructs a service.
|
||||
app.accessor.get(IKosongConfigService);
|
||||
return { app };
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -29,10 +29,11 @@
|
|||
* rejects for a fatal explicit-source error, exactly the case that should
|
||||
* fail fast, and on that failure the half-materialized handle is disposed
|
||||
* instead of poisoning the session cache (the skill catalog, by contrast, is
|
||||
* kicked fire-and-forget). The session-level eager services whose
|
||||
* subscriptions must exist before the first agent / turn (external hooks,
|
||||
* cron, the secondary-model startup warning) are force-instantiated at the
|
||||
* same point.
|
||||
* kicked fire-and-forget). The session-level services whose subscriptions
|
||||
* must exist before the first agent / turn (external hooks, cron, the
|
||||
* secondary-model startup warning) are constructed with the scope itself —
|
||||
* Session scope creation eagerly instantiates every service registered at
|
||||
* this tier.
|
||||
*/
|
||||
|
||||
import { randomUUID } from 'node:crypto';
|
||||
|
|
@ -77,10 +78,7 @@ import { IAgentLifecycleService, MAIN_AGENT_ID } from '#/session/agentLifecycle/
|
|||
import { ensureMainAgent } from '#/session/agentLifecycle/mainAgent';
|
||||
import { ISessionMcpService } from '#/session/mcp/sessionMcp';
|
||||
import { labelsFromAgentMeta } from '#/session/agentLifecycle/subagentMetadata';
|
||||
import { ISessionExternalHooksService } from '#/session/externalHooks/externalHooks';
|
||||
import { ISessionContext, sessionContextSeed } from '#/session/sessionContext/sessionContext';
|
||||
import { ISessionCronService } from '#/session/cron/sessionCronService';
|
||||
import { ISessionSecondaryModelWarningService } from '#/session/subagent/secondaryModelWarning';
|
||||
import { ISessionMetadata, type SessionMeta } from '#/session/sessionMetadata/sessionMetadata';
|
||||
import { ISessionSkillCatalog } from '#/session/sessionSkillCatalog/skillCatalog';
|
||||
import { ISessionAgentProfileCatalog } from '#/session/sessionAgentProfileCatalog/sessionAgentProfileCatalog';
|
||||
|
|
@ -222,9 +220,6 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec
|
|||
void handle.accessor.get(ISessionSkillCatalog).ready;
|
||||
await handle.accessor.get(ISessionAgentProfileCatalog).ready;
|
||||
await handle.accessor.get(ISessionMcpService).ensureMcpReady(opts.mcpServers);
|
||||
handle.accessor.get(ISessionExternalHooksService);
|
||||
handle.accessor.get(ISessionCronService);
|
||||
handle.accessor.get(ISessionSecondaryModelWarningService);
|
||||
} catch (error) {
|
||||
handle.dispose();
|
||||
throw error;
|
||||
|
|
|
|||
20
packages/agent-core-v2/src/app/state/state.ts
Normal file
20
packages/agent-core-v2/src/app/state/state.ts
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
/**
|
||||
* `state` domain (L1) — App-scope keyed state container contract.
|
||||
*
|
||||
* Defines `IStateService`, the App-scope state service: App-tier services
|
||||
* declare their plain-data state as typed keys (`defineState` from `_base`)
|
||||
* and read/write them through this container, so process-wide shared state
|
||||
* lives in one observable place instead of scattering across private fields.
|
||||
* Shares the `IStateRegistry` method set with its Session/Agent counterparts.
|
||||
* Bound at App scope.
|
||||
*/
|
||||
|
||||
import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation';
|
||||
import type { IStateRegistry } from '#/_base/state/stateRegistry';
|
||||
|
||||
export interface IStateService extends IStateRegistry {
|
||||
readonly _serviceBrand: undefined;
|
||||
}
|
||||
|
||||
export const IStateService: ServiceIdentifier<IStateService> =
|
||||
createDecorator<IStateService>('stateService');
|
||||
19
packages/agent-core-v2/src/app/state/stateService.ts
Normal file
19
packages/agent-core-v2/src/app/state/stateService.ts
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
/**
|
||||
* `state` domain (L1) — `IStateService` implementation.
|
||||
*
|
||||
* Thin per-scope binding over the `_base` `StateRegistry`; the container owns
|
||||
* construction and disposal, so registered state dies with the scope. Bound
|
||||
* at App scope.
|
||||
*/
|
||||
|
||||
import { InstantiationType } from '#/_base/di/extensions';
|
||||
import { LifecycleScope, registerScopedService } from '#/_base/di/scope';
|
||||
import { StateRegistry } from '#/_base/state/stateRegistry';
|
||||
|
||||
import { IStateService } from './state';
|
||||
|
||||
export class StateService extends StateRegistry implements IStateService {
|
||||
declare readonly _serviceBrand: undefined;
|
||||
}
|
||||
|
||||
registerScopedService(LifecycleScope.App, IStateService, StateService, InstantiationType.Eager, 'state');
|
||||
|
|
@ -60,6 +60,13 @@ import '#/app/event/eventBusService';
|
|||
import '#/app/event/eventService';
|
||||
export { IEventBus, type DomainEvent } from '#/app/event/eventBus';
|
||||
export { IEventService, type DomainEvent as GlobalEvent } from '#/app/event/event';
|
||||
export * from '#/_base/state/stateRegistry';
|
||||
export * from '#/app/state/state';
|
||||
import '#/app/state/stateService';
|
||||
export * from '#/session/state/sessionState';
|
||||
import '#/session/state/sessionStateService';
|
||||
export * from '#/agent/state/agentState';
|
||||
import '#/agent/state/agentStateService';
|
||||
export * from '#/kosong/contract/capability';
|
||||
export * from '#/kosong/contract/errors';
|
||||
export * from '#/kosong/contract/message';
|
||||
|
|
|
|||
|
|
@ -37,34 +37,17 @@ import { IEventBus } from '#/app/event/eventBus';
|
|||
import { DEFAULT_PERMISSION_MODE_SECTION } from '#/agent/permissionMode/configSection';
|
||||
import { PermissionModeConfiguredModel } from '#/agent/permissionMode/permissionModeOps';
|
||||
import type { PermissionMode } from '#/agent/permissionPolicy/types';
|
||||
import { IAgentToolDedupeService } from '#/agent/toolDedupe/toolDedupe';
|
||||
import { IAgentTaskService } from '#/agent/task/task';
|
||||
import { ISessionContext } from '#/session/sessionContext/sessionContext';
|
||||
import { ISessionMetadata } from '#/session/sessionMetadata/sessionMetadata';
|
||||
import { ISessionMcpService } from '#/session/mcp/sessionMcp';
|
||||
import { IAgentScopeContext, makeAgentScopeContext } from '#/agent/scopeContext/scopeContext';
|
||||
import { IAgentLoopService } from '#/agent/loop/loop';
|
||||
import { IAgentActivityView } from '#/agent/activityView/activityView';
|
||||
import { IAgentProfileService } from '#/agent/profile/profile';
|
||||
import { abortError } from '#/_base/utils/abort';
|
||||
import { IAgentLoopContinuationService } from '#/agent/loop/loopContinuation';
|
||||
import { IAgentStepRetryService } from '#/agent/stepRetry/stepRetry';
|
||||
import { IAgentToolSelectService } from '#/agent/toolSelect/toolSelect';
|
||||
import { IAgentToolSelectAnnouncementsService } from '#/agent/toolSelect/toolSelectAnnouncements';
|
||||
import { IAgentPermissionModeService } from '#/agent/permissionMode/permissionMode';
|
||||
import { IAgentPermissionGate } from '#/agent/permissionGate/permissionGate';
|
||||
import { IAgentContextMemoryService } from '#/agent/contextMemory/contextMemory';
|
||||
import { IAgentContextInjectorService } from '#/agent/contextInjector/contextInjector';
|
||||
import { IAgentFullCompactionService } from '#/agent/fullCompaction/fullCompaction';
|
||||
import { IAgentGoalService } from '#/agent/goal/goal';
|
||||
import { IAgentPlanService } from '#/agent/plan/plan';
|
||||
import { IAgentUserToolService } from '#/agent/userTool/userTool';
|
||||
import { IAgentBuiltinToolsRegistrar } from '#/agent/toolRegistry/builtinToolsRegistrar';
|
||||
import { IAgentMediaToolsRegistrar } from '#/agent/media/mediaTools';
|
||||
import { IImageConfigBridge } from '#/agent/media/imageConfigBridge';
|
||||
import { IAgentMcpService } from '#/agent/mcp/mcp';
|
||||
import { IAgentExternalHooksService } from '#/agent/externalHooks/externalHooks';
|
||||
import { IAgentPluginService } from '#/agent/plugin/agentPlugin';
|
||||
import { ISessionInteractionService } from '#/session/interaction/interaction';
|
||||
import { IWireService } from '#/wire/wire';
|
||||
import { ITelemetryService } from '#/app/telemetry/telemetry';
|
||||
|
|
@ -205,7 +188,6 @@ export class AgentLifecycleService extends Disposable implements IAgentLifecycle
|
|||
labels: opts.labels,
|
||||
});
|
||||
this.onDidCreateEmitter.fire(handle);
|
||||
this.igniteEagerServices(handle);
|
||||
await mcpReady;
|
||||
await wire.restore();
|
||||
await this.bindBootstrap(handle, opts);
|
||||
|
|
@ -222,47 +204,6 @@ export class AgentLifecycleService extends Disposable implements IAgentLifecycle
|
|||
}
|
||||
}
|
||||
|
||||
// Force-instantiate the agent-scope observer/registrar services before the
|
||||
// first turn: each exists only for its constructor side effects (registering
|
||||
// built-in tools, subscribing hooks), so nothing injects them directly.
|
||||
// `InstantiationType.Eager` does NOT auto-instantiate in this DI — it only
|
||||
// skips the lazy proxy at resolve time — so they must be resolved here or
|
||||
// their registrations (built-in tools, loop error handlers, MCP tools) would
|
||||
// never happen.
|
||||
private igniteEagerServices(handle: IAgentScopeHandle): void {
|
||||
handle.accessor.get(IAgentBuiltinToolsRegistrar);
|
||||
handle.accessor.get(IAgentMediaToolsRegistrar);
|
||||
handle.accessor.get(IImageConfigBridge);
|
||||
handle.accessor.get(IAgentToolDedupeService);
|
||||
handle.accessor.get(IAgentExternalHooksService);
|
||||
// The permission gate exists only to subscribe `onBeforeExecuteTool` from
|
||||
// its constructor; the veto-event refactor removed the ordering-driven
|
||||
// force-injections that used to pull it up, so it must be resolved here or
|
||||
// tool execution would run without policy adjudication.
|
||||
handle.accessor.get(IAgentPermissionGate);
|
||||
handle.accessor.get(IAgentMcpService);
|
||||
// Agent plugin service: registers main-agent-only plugin session-start
|
||||
// guidance before the first turn (self-gates to a no-op for other agents).
|
||||
handle.accessor.get(IAgentPluginService);
|
||||
// Tool-select services: precompute tool selection and the announcements
|
||||
// derived from it before the first turn.
|
||||
handle.accessor.get(IAgentToolSelectService);
|
||||
handle.accessor.get(IAgentToolSelectAnnouncementsService);
|
||||
handle.accessor.get(IAgentStepRetryService);
|
||||
handle.accessor.get(IAgentLoopContinuationService);
|
||||
handle.accessor.get(IAgentContextMemoryService);
|
||||
handle.accessor.get(IAgentContextInjectorService);
|
||||
handle.accessor.get(IAgentGoalService);
|
||||
handle.accessor.get(IAgentPlanService);
|
||||
handle.accessor.get(IAgentTaskService);
|
||||
handle.accessor.get(IAgentUserToolService);
|
||||
handle.accessor.get(IAgentFullCompactionService);
|
||||
// The activity view publishes `agent.activity.updated` from its constructor
|
||||
// subscriptions; without an explicit resolve nothing injects it and the
|
||||
// wire would never see the projection.
|
||||
handle.accessor.get(IAgentActivityView);
|
||||
}
|
||||
|
||||
private async bindBootstrap(
|
||||
handle: IAgentScopeHandle,
|
||||
opts: CreateAgentOptions,
|
||||
|
|
|
|||
|
|
@ -11,7 +11,10 @@
|
|||
* through `IAgentPromptService` when a task fires, and registers the cron
|
||||
* tools (`CronCreate` / `CronList` / `CronDelete`) into the main agent's
|
||||
* `IAgentToolRegistryService` once `IAgentLifecycleService` signals
|
||||
* `onDidCreateMain`. Bound at Session scope.
|
||||
* `onDidCreateMain`. The plain-data state (`tasks`, `parsedCache`,
|
||||
* `lastSeenAt`, `seededFromStore`, `inFlight`, `started`) is registered into
|
||||
* `sessionState` (`ISessionStateService`) and read/written through it. Bound
|
||||
* at Session scope.
|
||||
*/
|
||||
|
||||
import { ulid } from 'ulid';
|
||||
|
|
@ -23,6 +26,7 @@ import { Disposable, toDisposable } from '#/_base/di/lifecycle';
|
|||
import { InstantiationType } from '#/_base/di/extensions';
|
||||
import { IInstantiationService } from '#/_base/di/instantiation';
|
||||
import { type IAgentScopeHandle, LifecycleScope, registerScopedService } from '#/_base/di/scope';
|
||||
import { defineState } from '#/_base/state/stateRegistry';
|
||||
import { IntervalTimer } from '#/_base/utils/timer';
|
||||
|
||||
import { IConfigService } from '#/app/config/config';
|
||||
|
|
@ -36,6 +40,7 @@ import { ICronTaskPersistence } from '#/app/cron/cronTaskPersistence';
|
|||
import { renderCronFireXml } from '#/app/cron/format';
|
||||
import { jitteredNextCronRunMs, oneShotJitteredNextCronRunMs } from '#/app/cron/jitter';
|
||||
import { ISessionContext } from '#/session/sessionContext/sessionContext';
|
||||
import { ISessionStateService } from '#/session/state/sessionState';
|
||||
import { IAgentLifecycleService } from '#/session/agentLifecycle/agentLifecycle';
|
||||
import type { ContextMessage } from '#/agent/contextMemory/types';
|
||||
import { IAgentPromptService } from '#/agent/prompt/prompt';
|
||||
|
|
@ -57,6 +62,16 @@ export const CRON_FIRED = 'cron_fired' as const;
|
|||
export const CRON_MISSED = 'cron_missed' as const;
|
||||
export const CRON_DELETED = 'cron_deleted' as const;
|
||||
|
||||
export const cronTasksKey = defineState<Map<string, CronTask>>('cron.tasks', () => new Map());
|
||||
export const cronParsedCacheKey = defineState<Map<string, ParsedCronExpression>>(
|
||||
'cron.parsedCache',
|
||||
() => new Map(),
|
||||
);
|
||||
export const cronLastSeenAtKey = defineState<Map<string, number>>('cron.lastSeenAt', () => new Map());
|
||||
export const cronSeededFromStoreKey = defineState<Set<string>>('cron.seededFromStore', () => new Set());
|
||||
export const cronInFlightKey = defineState<Set<string>>('cron.inFlight', () => new Set());
|
||||
export const cronStartedKey = defineState<boolean>('cron.started', () => false);
|
||||
|
||||
const STALE_THRESHOLD_MS = 7 * 24 * 60 * 60 * 1000;
|
||||
const DEFAULT_POLL_INTERVAL_MS = 1_000;
|
||||
const MAX_COALESCE_ITERATIONS = 10_000;
|
||||
|
|
@ -66,21 +81,16 @@ const MAX_ID_ATTEMPTS = 8;
|
|||
export class SessionCronServiceImpl extends Disposable implements ISessionCronService {
|
||||
declare readonly _serviceBrand: undefined;
|
||||
|
||||
private readonly tasks = new Map<string, CronTask>();
|
||||
private readonly parsedCache = new Map<string, ParsedCronExpression>();
|
||||
private readonly lastSeenAt = new Map<string, number>();
|
||||
private readonly seededFromStore = new Set<string>();
|
||||
private readonly inFlight = new Set<string>();
|
||||
private readonly timer = this._register(new IntervalTimer({ unref: true }));
|
||||
private readonly persistQueues = new Map<string, Promise<void>>();
|
||||
|
||||
private clocks: ClockSources = SYSTEM_CLOCKS;
|
||||
readonly isEnabled: boolean = true;
|
||||
|
||||
private started = false;
|
||||
private sigusr1Handler: NodeJS.SignalsListener | null = null;
|
||||
|
||||
constructor(
|
||||
@ISessionStateService private readonly states: ISessionStateService,
|
||||
@ISessionContext private readonly ctx: ISessionContext,
|
||||
@ICronTaskPersistence private readonly store: ICronTaskPersistence,
|
||||
@IAgentLifecycleService private readonly agentLifecycle: IAgentLifecycleService,
|
||||
|
|
@ -89,6 +99,13 @@ export class SessionCronServiceImpl extends Disposable implements ISessionCronSe
|
|||
) {
|
||||
super();
|
||||
|
||||
this.states.register(cronTasksKey);
|
||||
this.states.register(cronParsedCacheKey);
|
||||
this.states.register(cronLastSeenAtKey);
|
||||
this.states.register(cronSeededFromStoreKey);
|
||||
this.states.register(cronInFlightKey);
|
||||
this.states.register(cronStartedKey);
|
||||
|
||||
this._register(
|
||||
this.agentLifecycle.onDidCreate((handle) => {
|
||||
if (handle.id !== 'main') return;
|
||||
|
|
@ -108,6 +125,34 @@ export class SessionCronServiceImpl extends Disposable implements ISessionCronSe
|
|||
);
|
||||
}
|
||||
|
||||
private get tasks(): Map<string, CronTask> {
|
||||
return this.states.get(cronTasksKey);
|
||||
}
|
||||
|
||||
private get parsedCache(): Map<string, ParsedCronExpression> {
|
||||
return this.states.get(cronParsedCacheKey);
|
||||
}
|
||||
|
||||
private get lastSeenAt(): Map<string, number> {
|
||||
return this.states.get(cronLastSeenAtKey);
|
||||
}
|
||||
|
||||
private get seededFromStore(): Set<string> {
|
||||
return this.states.get(cronSeededFromStoreKey);
|
||||
}
|
||||
|
||||
private get inFlight(): Set<string> {
|
||||
return this.states.get(cronInFlightKey);
|
||||
}
|
||||
|
||||
private get started(): boolean {
|
||||
return this.states.get(cronStartedKey);
|
||||
}
|
||||
|
||||
private set started(value: boolean) {
|
||||
this.states.set(cronStartedKey, value);
|
||||
}
|
||||
|
||||
private bindMainAgent(handle: IAgentScopeHandle): void {
|
||||
const wire = handle.accessor.get(IWireService);
|
||||
this._register(
|
||||
|
|
|
|||
|
|
@ -6,7 +6,9 @@
|
|||
* request/resolution is also journaled as a persisted `interaction.request` /
|
||||
* `interaction.resolved` Op on the ORIGIN agent's wire (`origin.agentId ??
|
||||
* 'main'`), so the journal can rebuild interaction entities on a cold
|
||||
* transcript fold. `IAgentLifecycleService` is resolved lazily at dispatch
|
||||
* transcript fold. The plain-data state (`pending`, `recentlyResolved`,
|
||||
* `nextId`) is registered into `sessionState` (`ISessionStateService`) and
|
||||
* read/written through it. `IAgentLifecycleService` is resolved lazily at dispatch
|
||||
* time (via `IInstantiationService.invokeFunction`) because the lifecycle
|
||||
* service already depends on this kernel for turn-end cancellation — a
|
||||
* constructor edge would close a DI cycle. Direct construction without a
|
||||
|
|
@ -20,8 +22,10 @@ import { InstantiationType } from '#/_base/di/extensions';
|
|||
import { IInstantiationService } from '#/_base/di/instantiation';
|
||||
import { Disposable } from '#/_base/di/lifecycle';
|
||||
import { LifecycleScope, registerScopedService } from '#/_base/di/scope';
|
||||
import { defineState } from '#/_base/state/stateRegistry';
|
||||
|
||||
import { IAgentLifecycleService } from '#/session/agentLifecycle/agentLifecycle';
|
||||
import { ISessionStateService } from '#/session/state/sessionState';
|
||||
import { IWireService } from '#/wire/wire';
|
||||
|
||||
import {
|
||||
|
|
@ -44,21 +48,48 @@ const RECENTLY_RESOLVED_TTL_MS = 60_000;
|
|||
const RECENTLY_RESOLVED_MAX = 256;
|
||||
const MAIN_AGENT_ID = 'main';
|
||||
|
||||
export const interactionPendingKey = defineState<Map<string, Pending>>(
|
||||
'interaction.pending',
|
||||
() => new Map(),
|
||||
);
|
||||
export const interactionRecentlyResolvedKey = defineState<Map<string, number>>(
|
||||
'interaction.recentlyResolved',
|
||||
() => new Map(),
|
||||
);
|
||||
export const interactionNextIdKey = defineState<number>('interaction.nextId', () => 0);
|
||||
|
||||
export class SessionInteractionService extends Disposable implements ISessionInteractionService {
|
||||
declare readonly _serviceBrand: undefined;
|
||||
|
||||
private readonly pending = new Map<string, Pending>();
|
||||
private readonly recentlyResolved = new Map<string, number>();
|
||||
private readonly _onDidChangePending = this._register(new Emitter<InteractionPendingChangedEvent>());
|
||||
readonly onDidChangePending: Event<InteractionPendingChangedEvent> = this._onDidChangePending.event;
|
||||
private readonly _onDidResolve = this._register(new Emitter<InteractionResolution>());
|
||||
readonly onDidResolve: Event<InteractionResolution> = this._onDidResolve.event;
|
||||
private nextId = 0;
|
||||
|
||||
constructor(
|
||||
@ISessionStateService private readonly states: ISessionStateService,
|
||||
@IInstantiationService private readonly instantiation?: IInstantiationService,
|
||||
) {
|
||||
super();
|
||||
this.states.register(interactionPendingKey);
|
||||
this.states.register(interactionRecentlyResolvedKey);
|
||||
this.states.register(interactionNextIdKey);
|
||||
}
|
||||
|
||||
private get pending(): Map<string, Pending> {
|
||||
return this.states.get(interactionPendingKey);
|
||||
}
|
||||
|
||||
private get recentlyResolved(): Map<string, number> {
|
||||
return this.states.get(interactionRecentlyResolvedKey);
|
||||
}
|
||||
|
||||
private get nextId(): number {
|
||||
return this.states.get(interactionNextIdKey);
|
||||
}
|
||||
|
||||
private set nextId(value: number) {
|
||||
this.states.set(interactionNextIdKey, value);
|
||||
}
|
||||
|
||||
cancelPendingForTurn(turnId: number): void {
|
||||
|
|
|
|||
|
|
@ -6,18 +6,23 @@
|
|||
* attach, `agent.activity.updated` over each agent's `event` bus afterwards)
|
||||
* — together with the pending-interaction set from `interaction` into the
|
||||
* session-level aggregate, and fires `onDidChange` with the domain cause
|
||||
* only when the aggregate tuple actually changes. Bound at Session scope.
|
||||
* only when the aggregate tuple actually changes. The plain-data state
|
||||
* (`folds`, `current`) is registered into `sessionState`
|
||||
* (`ISessionStateService`) and read/written through it. Bound at Session
|
||||
* scope.
|
||||
*/
|
||||
|
||||
import { InstantiationType } from '#/_base/di/extensions';
|
||||
import { Disposable, toDisposable, type IDisposable } from '#/_base/di/lifecycle';
|
||||
import { LifecycleScope, registerScopedService, type IAgentScopeHandle } from '#/_base/di/scope';
|
||||
import { Emitter, type Event } from '#/_base/event';
|
||||
import { defineState } from '#/_base/state/stateRegistry';
|
||||
import { IEventBus } from '#/app/event/eventBus';
|
||||
import { IAgentActivityView, type AgentActivityState } from '#/agent/activityView/activityView';
|
||||
import type { TurnEndReason } from '#/agent/loop/turnEvents';
|
||||
import { IAgentLifecycleService, MAIN_AGENT_ID } from '#/session/agentLifecycle/agentLifecycle';
|
||||
import { ISessionInteractionService, type Interaction } from '#/session/interaction/interaction';
|
||||
import { ISessionStateService } from '#/session/state/sessionState';
|
||||
|
||||
import {
|
||||
ISessionActivityView,
|
||||
|
|
@ -34,21 +39,33 @@ interface AgentWorkFold {
|
|||
lastTurnReason?: SessionTurnOutcome;
|
||||
}
|
||||
|
||||
export const sessionActivityFoldsKey = defineState<Map<string, AgentWorkFold>>(
|
||||
'sessionActivity.folds',
|
||||
() => new Map(),
|
||||
);
|
||||
export const sessionActivityCurrentKey = defineState<SessionActivityState>('sessionActivity.current', () => ({
|
||||
busy: false,
|
||||
mainTurnActive: false,
|
||||
pendingInteraction: 'none',
|
||||
lastTurnReason: undefined,
|
||||
}));
|
||||
|
||||
export class SessionActivityView extends Disposable implements ISessionActivityView {
|
||||
declare readonly _serviceBrand: undefined;
|
||||
|
||||
private readonly _onDidChange = this._register(new Emitter<SessionActivityChangedEvent>());
|
||||
readonly onDidChange: Event<SessionActivityChangedEvent> = this._onDidChange.event;
|
||||
|
||||
private readonly folds = new Map<string, AgentWorkFold>();
|
||||
private readonly agentSubscriptions = new Map<string, IDisposable>();
|
||||
private current: SessionActivityState;
|
||||
|
||||
constructor(
|
||||
@ISessionStateService private readonly states: ISessionStateService,
|
||||
@IAgentLifecycleService private readonly agents: IAgentLifecycleService,
|
||||
@ISessionInteractionService private readonly interactions: ISessionInteractionService,
|
||||
) {
|
||||
super();
|
||||
this.states.register(sessionActivityFoldsKey);
|
||||
this.states.register(sessionActivityCurrentKey);
|
||||
for (const handle of this.agents.list()) this.attachAgent(handle);
|
||||
this.current = this.aggregate();
|
||||
this._register(
|
||||
|
|
@ -73,6 +90,18 @@ export class SessionActivityView extends Disposable implements ISessionActivityV
|
|||
);
|
||||
}
|
||||
|
||||
private get folds(): Map<string, AgentWorkFold> {
|
||||
return this.states.get(sessionActivityFoldsKey);
|
||||
}
|
||||
|
||||
private get current(): SessionActivityState {
|
||||
return this.states.get(sessionActivityCurrentKey);
|
||||
}
|
||||
|
||||
private set current(value: SessionActivityState) {
|
||||
this.states.set(sessionActivityCurrentKey, value);
|
||||
}
|
||||
|
||||
state(): SessionActivityState {
|
||||
return this.current;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,7 +17,9 @@
|
|||
* fixed, and `loadAll` merges whatever loaded even when a fatal source
|
||||
* rejects mid-pass. The swallowed handler on `ready` keeps an un-awaited
|
||||
* rejection from crashing the process, and event-driven reloads get the
|
||||
* same warning treatment.
|
||||
* same warning treatment. The plain-data state (`contributions`, `merged`)
|
||||
* is registered into `sessionState` (`ISessionStateService`) and
|
||||
* read/written through it.
|
||||
* Bound at Session scope.
|
||||
*/
|
||||
|
||||
|
|
@ -27,6 +29,7 @@ import { Emitter, type Event } from '#/_base/event';
|
|||
import { ILogService } from '#/_base/log/log';
|
||||
import { LifecycleScope, registerScopedService } from '#/_base/di/scope';
|
||||
import { isError2 } from '#/_base/errors/errors';
|
||||
import { defineState } from '#/_base/state/stateRegistry';
|
||||
import {
|
||||
DEFAULT_AGENT_PROFILE_NAME,
|
||||
IAgentProfileCatalogService,
|
||||
|
|
@ -37,12 +40,21 @@ import type {
|
|||
IAgentProfileSource,
|
||||
} from '#/app/agentFileCatalog/agentProfileSource';
|
||||
import { IUserFileAgentSource } from '#/app/agentFileCatalog/userFileAgentSource';
|
||||
import { ISessionStateService } from '#/session/state/sessionState';
|
||||
|
||||
import { IExplicitFileAgentSource } from './explicitFileAgentSource';
|
||||
import { IExtraFileAgentSource } from './extraFileAgentSource';
|
||||
import { IProjectFileAgentSource } from './projectFileAgentSource';
|
||||
import { ISessionAgentProfileCatalog } from './sessionAgentProfileCatalog';
|
||||
|
||||
export const agentProfileCatalogContributionsKey = defineState<
|
||||
Map<string, { readonly c: AgentProfileContribution; readonly priority: number }>
|
||||
>('sessionAgentProfileCatalog.contributions', () => new Map());
|
||||
export const agentProfileCatalogMergedKey = defineState<Map<string, AgentProfile>>(
|
||||
'sessionAgentProfileCatalog.merged',
|
||||
() => new Map(),
|
||||
);
|
||||
|
||||
export class SessionAgentProfileCatalogService
|
||||
extends Disposable
|
||||
implements ISessionAgentProfileCatalog
|
||||
|
|
@ -50,17 +62,13 @@ export class SessionAgentProfileCatalogService
|
|||
declare readonly _serviceBrand: undefined;
|
||||
|
||||
private readonly sources: readonly IAgentProfileSource[];
|
||||
private readonly contributions = new Map<
|
||||
string,
|
||||
{ readonly c: AgentProfileContribution; readonly priority: number }
|
||||
>();
|
||||
private readonly sourceLoadTails = new Map<IAgentProfileSource, Promise<void>>();
|
||||
private merged = new Map<string, AgentProfile>();
|
||||
private readyPromise: Promise<void>;
|
||||
private readonly onDidChangeEmitter = this._register(new Emitter<string>());
|
||||
readonly onDidChange: Event<string> = this.onDidChangeEmitter.event;
|
||||
|
||||
constructor(
|
||||
@ISessionStateService private readonly states: ISessionStateService,
|
||||
@IAgentProfileCatalogService private readonly builtin: IAgentProfileCatalogService,
|
||||
@IUserFileAgentSource user: IUserFileAgentSource,
|
||||
@IExtraFileAgentSource extra: IExtraFileAgentSource,
|
||||
|
|
@ -69,6 +77,8 @@ export class SessionAgentProfileCatalogService
|
|||
@ILogService private readonly log: ILogService,
|
||||
) {
|
||||
super();
|
||||
this.states.register(agentProfileCatalogContributionsKey);
|
||||
this.states.register(agentProfileCatalogMergedKey);
|
||||
this.sources = [user, extra, project, explicit].toSorted(
|
||||
(a, b) => a.priority - b.priority,
|
||||
);
|
||||
|
|
@ -88,6 +98,21 @@ export class SessionAgentProfileCatalogService
|
|||
void this.readyPromise.catch(() => undefined);
|
||||
}
|
||||
|
||||
private get contributions(): Map<
|
||||
string,
|
||||
{ readonly c: AgentProfileContribution; readonly priority: number }
|
||||
> {
|
||||
return this.states.get(agentProfileCatalogContributionsKey);
|
||||
}
|
||||
|
||||
private get merged(): Map<string, AgentProfile> {
|
||||
return this.states.get(agentProfileCatalogMergedKey);
|
||||
}
|
||||
|
||||
private set merged(value: Map<string, AgentProfile>) {
|
||||
this.states.set(agentProfileCatalogMergedKey, value);
|
||||
}
|
||||
|
||||
get ready(): Promise<void> {
|
||||
return this.readyPromise;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,6 +13,8 @@
|
|||
* check first, then re-verifies the candidate through `IHostFileSystem.realpath`
|
||||
* (resolving the longest existing prefix, so not-yet-created paths still work):
|
||||
* a symlink inside the workspace must not steer fs actions to files outside it.
|
||||
* The plain-data state (`rgResolution`, `realRootsCache`) is registered into
|
||||
* `sessionState` (`ISessionStateService`) and read/written through it.
|
||||
*/
|
||||
|
||||
import { basename, dirname, isAbsolute, join, relative, sep } from 'node:path';
|
||||
|
|
@ -62,6 +64,7 @@ import ignore, { type Ignore } from 'ignore';
|
|||
|
||||
import { InstantiationType } from '#/_base/di/extensions';
|
||||
import { LifecycleScope, registerScopedService } from '#/_base/di/scope';
|
||||
import { defineState } from '#/_base/state/stateRegistry';
|
||||
import {
|
||||
buildEtag,
|
||||
countLines,
|
||||
|
|
@ -75,6 +78,7 @@ import { IGitService } from '#/app/git/git';
|
|||
import { ITelemetryService } from '#/app/telemetry/telemetry';
|
||||
import { IHostFileSystem, type HostDirEntry, type HostFileStat } from '#/os/interface/hostFileSystem';
|
||||
import { ISessionProcessRunner } from '#/session/process/processRunner';
|
||||
import { ISessionStateService } from '#/session/state/sessionState';
|
||||
import { ISessionWorkspaceContext } from '#/session/workspaceContext/workspaceContext';
|
||||
|
||||
import { type FsDownloadResolved, type FsPathResolved, ISessionFsService } from './fs';
|
||||
|
|
@ -100,19 +104,38 @@ const FS_READ_MAX_BYTES = 10 * 1024 * 1024;
|
|||
const HIDDEN_NAME_RE = /^\./;
|
||||
const MACOS_NOISE = new Set(['.DS_Store', '.AppleDouble', '.LSOverride']);
|
||||
|
||||
export const sessionFsRgResolutionKey = defineState<RgResolution | null | undefined>(
|
||||
'sessionFs.rgResolution',
|
||||
() => undefined,
|
||||
);
|
||||
export const sessionFsRealRootsCacheKey = defineState<
|
||||
{ readonly key: string; readonly roots: readonly string[] } | undefined
|
||||
>('sessionFs.realRootsCache', () => undefined);
|
||||
|
||||
export class SessionFsService implements ISessionFsService {
|
||||
declare readonly _serviceBrand: undefined;
|
||||
|
||||
private readonly gitignoreCache = new Map<string, Ignore>();
|
||||
private rgResolution: RgResolution | null | undefined = undefined;
|
||||
|
||||
constructor(
|
||||
@ISessionStateService private readonly states: ISessionStateService,
|
||||
@ISessionWorkspaceContext private readonly workspace: ISessionWorkspaceContext,
|
||||
@IHostFileSystem private readonly hostFs: IHostFileSystem,
|
||||
@ISessionProcessRunner private readonly runner: ISessionProcessRunner,
|
||||
@ITelemetryService private readonly telemetry: ITelemetryService,
|
||||
@IGitService private readonly git: IGitService,
|
||||
) {}
|
||||
) {
|
||||
this.states.register(sessionFsRgResolutionKey);
|
||||
this.states.register(sessionFsRealRootsCacheKey);
|
||||
}
|
||||
|
||||
private get rgResolution(): RgResolution | null | undefined {
|
||||
return this.states.get(sessionFsRgResolutionKey);
|
||||
}
|
||||
|
||||
private set rgResolution(value: RgResolution | null | undefined) {
|
||||
this.states.set(sessionFsRgResolutionKey, value);
|
||||
}
|
||||
|
||||
private absOf(rel: string): string {
|
||||
return rel === '' || rel === '.' ? this.workspace.workDir : join(this.workspace.workDir, rel);
|
||||
|
|
@ -693,7 +716,17 @@ export class SessionFsService implements ISessionFsService {
|
|||
return this.rgResolution;
|
||||
}
|
||||
|
||||
private realRootsCache: { readonly key: string; readonly roots: readonly string[] } | undefined;
|
||||
private get realRootsCache():
|
||||
| { readonly key: string; readonly roots: readonly string[] }
|
||||
| undefined {
|
||||
return this.states.get(sessionFsRealRootsCacheKey);
|
||||
}
|
||||
|
||||
private set realRootsCache(
|
||||
value: { readonly key: string; readonly roots: readonly string[] } | undefined,
|
||||
) {
|
||||
this.states.set(sessionFsRealRootsCacheKey, value);
|
||||
}
|
||||
|
||||
private async realRoots(): Promise<readonly string[]> {
|
||||
const dirs = [this.workspace.workDir, ...this.workspace.additionalDirs];
|
||||
|
|
|
|||
|
|
@ -5,9 +5,11 @@
|
|||
* events to the caller-declared subtree and to non-`.gitignore`d paths,
|
||||
* debounces them into fixed windows and re-exposes them as workspace-relative
|
||||
* `FsChangeEvent`s. The os watcher is started lazily on the first non-empty
|
||||
* subscription and stopped when the subscription set becomes empty. Path
|
||||
* confinement is lexical (`ISessionWorkspaceContext.isWithin`), matching
|
||||
* `sessionFs`.
|
||||
* subscription and stopped when the subscription set becomes empty. The
|
||||
* plain-data state (`watched`, `pending`, `rawCount`, `truncated`,
|
||||
* `gitignoreLoaded`) is registered into `sessionState` (`ISessionStateService`)
|
||||
* and read/written through it. Path confinement is lexical
|
||||
* (`ISessionWorkspaceContext.isWithin`), matching `sessionFs`.
|
||||
*/
|
||||
|
||||
import { isAbsolute, join, relative, sep } from 'node:path';
|
||||
|
|
@ -18,6 +20,7 @@ import { Disposable, type IDisposable } from '#/_base/di/lifecycle';
|
|||
import { Emitter, type Event } from '#/_base/event';
|
||||
import { InstantiationType } from '#/_base/di/extensions';
|
||||
import { LifecycleScope, registerScopedService } from '#/_base/di/scope';
|
||||
import { defineState } from '#/_base/state/stateRegistry';
|
||||
import { ErrorCodes, Error2 } from '#/errors';
|
||||
import { IHostFileSystem } from '#/os/interface/hostFileSystem';
|
||||
import {
|
||||
|
|
@ -25,6 +28,7 @@ import {
|
|||
type IHostFsWatchHandle,
|
||||
IHostFsWatchService,
|
||||
} from '#/os/interface/hostFsWatch';
|
||||
import { ISessionStateService } from '#/session/state/sessionState';
|
||||
import { ISessionWorkspaceContext } from '#/session/workspaceContext/workspaceContext';
|
||||
import type { FsChangeEntry, FsChangeEvent } from './fsWatch';
|
||||
|
||||
|
|
@ -40,20 +44,28 @@ function readPositiveIntEnv(name: string, fallback: number): number {
|
|||
return Number.isFinite(n) && n > 0 ? n : fallback;
|
||||
}
|
||||
|
||||
export const sessionFsWatchWatchedKey = defineState<Set<string>>(
|
||||
'sessionFsWatch.watched',
|
||||
() => new Set<string>(),
|
||||
);
|
||||
export const sessionFsWatchPendingKey = defineState<FsChangeEntry[]>('sessionFsWatch.pending', () => []);
|
||||
export const sessionFsWatchRawCountKey = defineState<number>('sessionFsWatch.rawCount', () => 0);
|
||||
export const sessionFsWatchTruncatedKey = defineState<boolean>('sessionFsWatch.truncated', () => false);
|
||||
export const sessionFsWatchGitignoreLoadedKey = defineState<boolean>(
|
||||
'sessionFsWatch.gitignoreLoaded',
|
||||
() => false,
|
||||
);
|
||||
|
||||
export class SessionFsWatchService extends Disposable implements ISessionFsWatchService {
|
||||
declare readonly _serviceBrand: undefined;
|
||||
|
||||
private readonly emitter = this._register(new Emitter<FsChangeEvent>());
|
||||
readonly onDidChangeFiles: Event<FsChangeEvent> = this.emitter.event;
|
||||
|
||||
private watched = new Set<string>();
|
||||
private handle: IHostFsWatchHandle | undefined;
|
||||
private handleSub: IDisposable | undefined;
|
||||
|
||||
private debounceTimer: NodeJS.Timeout | undefined;
|
||||
private pending: FsChangeEntry[] = [];
|
||||
private rawCount = 0;
|
||||
private truncated = false;
|
||||
|
||||
private readonly debounceMs = readPositiveIntEnv(
|
||||
'KIMI_CODE_FS_WATCH_DEBOUNCE_MS',
|
||||
|
|
@ -65,14 +77,59 @@ export class SessionFsWatchService extends Disposable implements ISessionFsWatch
|
|||
);
|
||||
|
||||
private readonly matcher: Ignore = ignore().add('.git/');
|
||||
private gitignoreLoaded = false;
|
||||
|
||||
constructor(
|
||||
@ISessionStateService private readonly states: ISessionStateService,
|
||||
@ISessionWorkspaceContext private readonly workspace: ISessionWorkspaceContext,
|
||||
@IHostFsWatchService private readonly hostFsWatch: IHostFsWatchService,
|
||||
@IHostFileSystem private readonly hostFs: IHostFileSystem,
|
||||
) {
|
||||
super();
|
||||
this.states.register(sessionFsWatchWatchedKey);
|
||||
this.states.register(sessionFsWatchPendingKey);
|
||||
this.states.register(sessionFsWatchRawCountKey);
|
||||
this.states.register(sessionFsWatchTruncatedKey);
|
||||
this.states.register(sessionFsWatchGitignoreLoadedKey);
|
||||
}
|
||||
|
||||
private get watched(): Set<string> {
|
||||
return this.states.get(sessionFsWatchWatchedKey);
|
||||
}
|
||||
|
||||
private set watched(value: Set<string>) {
|
||||
this.states.set(sessionFsWatchWatchedKey, value);
|
||||
}
|
||||
|
||||
private get pending(): FsChangeEntry[] {
|
||||
return this.states.get(sessionFsWatchPendingKey);
|
||||
}
|
||||
|
||||
private set pending(value: FsChangeEntry[]) {
|
||||
this.states.set(sessionFsWatchPendingKey, value);
|
||||
}
|
||||
|
||||
private get rawCount(): number {
|
||||
return this.states.get(sessionFsWatchRawCountKey);
|
||||
}
|
||||
|
||||
private set rawCount(value: number) {
|
||||
this.states.set(sessionFsWatchRawCountKey, value);
|
||||
}
|
||||
|
||||
private get truncated(): boolean {
|
||||
return this.states.get(sessionFsWatchTruncatedKey);
|
||||
}
|
||||
|
||||
private set truncated(value: boolean) {
|
||||
this.states.set(sessionFsWatchTruncatedKey, value);
|
||||
}
|
||||
|
||||
private get gitignoreLoaded(): boolean {
|
||||
return this.states.get(sessionFsWatchGitignoreLoadedKey);
|
||||
}
|
||||
|
||||
private set gitignoreLoaded(value: boolean) {
|
||||
this.states.set(sessionFsWatchGitignoreLoadedKey, value);
|
||||
}
|
||||
|
||||
get watchedPaths(): readonly string[] {
|
||||
|
|
|
|||
|
|
@ -6,24 +6,43 @@
|
|||
* path already identifies the session). Registered to the single `ILogService`
|
||||
* token at Session scope, so every Session/Agent consumer injecting
|
||||
* `@ILogService` lands here (Agent has no own binding and falls back to this).
|
||||
* Flushes synchronously when the Session scope is disposed.
|
||||
* Flushes synchronously when the Session scope is disposed. The plain-data
|
||||
* state (`rootLevel`) is registered into `sessionState`
|
||||
* (`ISessionStateService`) and read/written through it.
|
||||
*/
|
||||
|
||||
import { InstantiationType } from '#/_base/di/extensions';
|
||||
import { LifecycleScope, registerScopedService } from '#/_base/di/scope';
|
||||
import { defineState } from '#/_base/state/stateRegistry';
|
||||
import { ISessionContext } from '#/session/sessionContext/sessionContext';
|
||||
import { ISessionStateService } from '#/session/state/sessionState';
|
||||
|
||||
import { ILogService, type LogLevel } from '#/_base/log/log';
|
||||
import { createFileLogWriter, type FileLogWriter } from '#/_base/log/fileLog';
|
||||
import { ILogOptions, resolveSessionLogPath } from '#/_base/log/logConfig';
|
||||
import { BoundLogger, type LogLevelState } from '#/_base/log/logService';
|
||||
|
||||
export const sessionLogRootLevelKey = defineState<LogLevelState>('sessionLog.rootLevel', () => ({
|
||||
level: 'info',
|
||||
}));
|
||||
|
||||
/**
|
||||
* Registers the root level into `sessionState` and hands the stored object to
|
||||
* the `BoundLogger` base, so base and subclass share one `LogLevelState`.
|
||||
* Runs inside the `super(...)` arguments, where `this` is not yet available.
|
||||
*/
|
||||
function seedRootLevel(states: ISessionStateService, level: LogLevel): LogLevelState {
|
||||
states.register(sessionLogRootLevelKey);
|
||||
states.set(sessionLogRootLevelKey, { level });
|
||||
return states.get(sessionLogRootLevelKey);
|
||||
}
|
||||
|
||||
export class SessionLogService extends BoundLogger implements ILogService {
|
||||
declare readonly _serviceBrand: undefined;
|
||||
private readonly sink: FileLogWriter;
|
||||
private readonly rootLevel: LogLevelState;
|
||||
|
||||
constructor(
|
||||
@ISessionStateService private readonly states: ISessionStateService,
|
||||
@ILogOptions options: ILogOptions,
|
||||
@ISessionContext session: ISessionContext,
|
||||
) {
|
||||
|
|
@ -33,10 +52,12 @@ export class SessionLogService extends BoundLogger implements ILogService {
|
|||
files: options.sessionFiles,
|
||||
format: { omitContextKeys: ['sessionId'] },
|
||||
});
|
||||
const rootLevel: LogLevelState = { level: options.level };
|
||||
super(sink, rootLevel, { sessionId: session.sessionId });
|
||||
super(sink, seedRootLevel(states, options.level), { sessionId: session.sessionId });
|
||||
this.sink = sink;
|
||||
this.rootLevel = rootLevel;
|
||||
}
|
||||
|
||||
private get rootLevel(): LogLevelState {
|
||||
return this.states.get(sessionLogRootLevelKey);
|
||||
}
|
||||
|
||||
get level(): LogLevel {
|
||||
|
|
|
|||
|
|
@ -5,6 +5,8 @@
|
|||
* access-pattern store (`IAtomicDocumentStore`), rooted at the `metaScope`
|
||||
* namespace from `sessionContext`. Loads the existing document on
|
||||
* construction (creating it on first run), and logs through `log`. The
|
||||
* plain-data state (`data`) is registered into `sessionState`
|
||||
* (`ISessionStateService`) and read/written through it. The
|
||||
* document always carries the `agents` / `custom` maps that v1's
|
||||
* `Session.resume()` reads unconditionally — seeded at creation, backfilled
|
||||
* and persisted on load for documents written before the seeding existed
|
||||
|
|
@ -29,10 +31,12 @@ import { Disposable } from '#/_base/di/lifecycle';
|
|||
import { LifecycleScope, registerScopedService } from '#/_base/di/scope';
|
||||
import { Emitter, type Event } from '#/_base/event';
|
||||
import { ILogService } from '#/_base/log/log';
|
||||
import { defineState } from '#/_base/state/stateRegistry';
|
||||
import { IFlagService } from '#/app/flag/flag';
|
||||
import { IAtomicDocumentStore } from '#/persistence/interface/atomicDocumentStore';
|
||||
import { IQueryStore } from '#/persistence/interface/queryStore';
|
||||
import { ISessionContext } from '#/session/sessionContext/sessionContext';
|
||||
import { ISessionStateService } from '#/session/state/sessionState';
|
||||
|
||||
import {
|
||||
ISessionMetadata,
|
||||
|
|
@ -47,6 +51,11 @@ const META_KEY = 'state.json';
|
|||
const SESSION_COLLECTION = 'session';
|
||||
const READ_MODEL_FLAG = 'persistence_minidb_readmodel';
|
||||
|
||||
export const sessionMetadataDataKey = defineState<SessionMeta | undefined>(
|
||||
'sessionMetadata.data',
|
||||
() => undefined,
|
||||
);
|
||||
|
||||
export class SessionMetadata extends Disposable implements ISessionMetadata {
|
||||
declare readonly _serviceBrand: undefined;
|
||||
readonly ready: Promise<void>;
|
||||
|
|
@ -57,9 +66,9 @@ export class SessionMetadata extends Disposable implements ISessionMetadata {
|
|||
);
|
||||
private readonly scope: string;
|
||||
private updateQueue: Promise<void> = Promise.resolve();
|
||||
private data!: SessionMeta;
|
||||
|
||||
constructor(
|
||||
@ISessionStateService private readonly states: ISessionStateService,
|
||||
@ISessionContext private readonly ctx: ISessionContext,
|
||||
@IAtomicDocumentStore private readonly store: IAtomicDocumentStore,
|
||||
@ILogService private readonly log: ILogService,
|
||||
|
|
@ -67,11 +76,20 @@ export class SessionMetadata extends Disposable implements ISessionMetadata {
|
|||
@IFlagService private readonly flags: IFlagService,
|
||||
) {
|
||||
super();
|
||||
this.states.register(sessionMetadataDataKey);
|
||||
this.scope = ctx.metaScope;
|
||||
this.onDidChangeMetadata = this._onDidChangeMetadata.event;
|
||||
this.ready = this.load();
|
||||
}
|
||||
|
||||
private get data(): SessionMeta {
|
||||
return this.states.get(sessionMetadataDataKey) as SessionMeta;
|
||||
}
|
||||
|
||||
private set data(value: SessionMeta) {
|
||||
this.states.set(sessionMetadataDataKey, value);
|
||||
}
|
||||
|
||||
async read(): Promise<SessionMeta> {
|
||||
await this.ready;
|
||||
return this.data;
|
||||
|
|
|
|||
|
|
@ -3,19 +3,23 @@
|
|||
*
|
||||
* Merges builtin, user, explicit, extra, workspace, and plugin skill sources
|
||||
* by priority, serializing refreshes for each source. Exposes the merged read
|
||||
* view and accepts ad-hoc contributions through `ISkillCatalogSink`. Bound at
|
||||
* Session scope.
|
||||
* view and accepts ad-hoc contributions through `ISkillCatalogSink`. The
|
||||
* plain-data state (`contributions`, `merged`) is registered into
|
||||
* `sessionState` (`ISessionStateService`) and read/written through it. Bound
|
||||
* at Session scope.
|
||||
*/
|
||||
|
||||
import { Disposable } from '#/_base/di/lifecycle';
|
||||
import { InstantiationType } from '#/_base/di/extensions';
|
||||
import { Emitter, type Event } from '#/_base/event';
|
||||
import { LifecycleScope, registerScopedService } from '#/_base/di/scope';
|
||||
import { defineState } from '#/_base/state/stateRegistry';
|
||||
import { IBuiltinSkillSource } from '#/app/skillCatalog/builtinSkillSource';
|
||||
import { InMemorySkillCatalog } from '#/app/skillCatalog/registry';
|
||||
import type { ISkillSource, SkillContribution } from '#/app/skillCatalog/skillSource';
|
||||
import type { SkillCatalog } from '#/app/skillCatalog/types';
|
||||
import { IUserFileSkillSource } from '#/app/skillCatalog/userFileSkillSource';
|
||||
import { ISessionStateService } from '#/session/state/sessionState';
|
||||
|
||||
import { IPluginSkillSource } from './pluginSkillSource';
|
||||
import { IExtraFileSkillSource } from './extraFileSkillSource';
|
||||
|
|
@ -23,6 +27,14 @@ import { IExplicitFileSkillSource } from './explicitFileSkillSource';
|
|||
import { ISessionSkillCatalog, type ISkillCatalogSink } from './skillCatalog';
|
||||
import { IWorkspaceFileSkillSource } from './workspaceFileSkillSource';
|
||||
|
||||
export const skillCatalogContributionsKey = defineState<
|
||||
Map<string, { readonly c: SkillContribution; readonly priority: number }>
|
||||
>('sessionSkillCatalog.contributions', () => new Map());
|
||||
export const skillCatalogMergedKey = defineState<InMemorySkillCatalog>(
|
||||
'sessionSkillCatalog.merged',
|
||||
() => new InMemorySkillCatalog(),
|
||||
);
|
||||
|
||||
export class SessionSkillCatalogService
|
||||
extends Disposable
|
||||
implements ISessionSkillCatalog, ISkillCatalogSink
|
||||
|
|
@ -30,17 +42,13 @@ export class SessionSkillCatalogService
|
|||
declare readonly _serviceBrand: undefined;
|
||||
|
||||
private readonly sources: readonly ISkillSource[];
|
||||
private readonly contributions = new Map<
|
||||
string,
|
||||
{ readonly c: SkillContribution; readonly priority: number }
|
||||
>();
|
||||
private readonly sourceLoadTails = new Map<ISkillSource, Promise<void>>();
|
||||
private merged = new InMemorySkillCatalog();
|
||||
readonly ready: Promise<void>;
|
||||
private readonly onDidChangeEmitter = this._register(new Emitter<string>());
|
||||
readonly onDidChange: Event<string> = this.onDidChangeEmitter.event;
|
||||
|
||||
constructor(
|
||||
@ISessionStateService private readonly states: ISessionStateService,
|
||||
@IBuiltinSkillSource builtin: IBuiltinSkillSource,
|
||||
@IUserFileSkillSource user: IUserFileSkillSource,
|
||||
@IExplicitFileSkillSource explicit: IExplicitFileSkillSource,
|
||||
|
|
@ -49,6 +57,8 @@ export class SessionSkillCatalogService
|
|||
@IPluginSkillSource plugin: IPluginSkillSource,
|
||||
) {
|
||||
super();
|
||||
this.states.register(skillCatalogContributionsKey);
|
||||
this.states.register(skillCatalogMergedKey);
|
||||
this.sources = [builtin, user, explicit, extra, workspace, plugin].toSorted((a, b) => a.priority - b.priority);
|
||||
for (const s of this.sources) {
|
||||
if (s.onDidChange) this._register(s.onDidChange(() => { void this.reloadSource(s.id); }));
|
||||
|
|
@ -56,6 +66,21 @@ export class SessionSkillCatalogService
|
|||
this.ready = this.loadAll();
|
||||
}
|
||||
|
||||
private get contributions(): Map<
|
||||
string,
|
||||
{ readonly c: SkillContribution; readonly priority: number }
|
||||
> {
|
||||
return this.states.get(skillCatalogContributionsKey);
|
||||
}
|
||||
|
||||
private get merged(): InMemorySkillCatalog {
|
||||
return this.states.get(skillCatalogMergedKey);
|
||||
}
|
||||
|
||||
private set merged(value: InMemorySkillCatalog) {
|
||||
this.states.set(skillCatalogMergedKey, value);
|
||||
}
|
||||
|
||||
get catalog(): SkillCatalog {
|
||||
return this.merged;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,15 +3,19 @@
|
|||
*
|
||||
* Stores the client-managed denylist as one atomic document below the session
|
||||
* scope and serializes replacements. A successful replacement awaits all
|
||||
* registered Agent prompt refreshes before returning. Bound at Session scope.
|
||||
* registered Agent prompt refreshes before returning. The plain-data state
|
||||
* (`state`) is registered into `sessionState` (`ISessionStateService`) and
|
||||
* read/written through it. Bound at Session scope.
|
||||
*/
|
||||
|
||||
import { InstantiationType } from '#/_base/di/extensions';
|
||||
import { Disposable } from '#/_base/di/lifecycle';
|
||||
import { LifecycleScope, registerScopedService } from '#/_base/di/scope';
|
||||
import { AsyncEmitter, type Event } from '#/_base/event';
|
||||
import { defineState } from '#/_base/state/stateRegistry';
|
||||
import { IAtomicDocumentStore } from '#/persistence/interface/atomicDocumentStore';
|
||||
import { ISessionContext } from '#/session/sessionContext/sessionContext';
|
||||
import { ISessionStateService } from '#/session/state/sessionState';
|
||||
|
||||
import {
|
||||
ISessionToolPolicy,
|
||||
|
|
@ -22,6 +26,10 @@ interface SessionToolPolicyState {
|
|||
readonly disabledTools: readonly string[];
|
||||
}
|
||||
|
||||
export const sessionToolPolicyStateKey = defineState<SessionToolPolicyState>('sessionToolPolicy.state', () => ({
|
||||
disabledTools: [],
|
||||
}));
|
||||
|
||||
const STATE_KEY = 'state.json';
|
||||
|
||||
export class SessionToolPolicyService extends Disposable implements ISessionToolPolicy {
|
||||
|
|
@ -34,18 +42,27 @@ export class SessionToolPolicyService extends Disposable implements ISessionTool
|
|||
);
|
||||
private readonly scope: string;
|
||||
private updateQueue: Promise<void> = Promise.resolve();
|
||||
private state: SessionToolPolicyState = { disabledTools: [] };
|
||||
|
||||
constructor(
|
||||
@ISessionStateService private readonly states: ISessionStateService,
|
||||
@ISessionContext sessionContext: ISessionContext,
|
||||
@IAtomicDocumentStore private readonly store: IAtomicDocumentStore,
|
||||
) {
|
||||
super();
|
||||
this.states.register(sessionToolPolicyStateKey);
|
||||
this.scope = sessionContext.scope('tool-policy');
|
||||
this.onDidChange = this.changeEmitter.event;
|
||||
this.ready = this.load();
|
||||
}
|
||||
|
||||
private get state(): SessionToolPolicyState {
|
||||
return this.states.get(sessionToolPolicyStateKey);
|
||||
}
|
||||
|
||||
private set state(value: SessionToolPolicyState) {
|
||||
this.states.set(sessionToolPolicyStateKey, value);
|
||||
}
|
||||
|
||||
disabledTools(): readonly string[] {
|
||||
return this.state.disabledTools;
|
||||
}
|
||||
|
|
|
|||
20
packages/agent-core-v2/src/session/state/sessionState.ts
Normal file
20
packages/agent-core-v2/src/session/state/sessionState.ts
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
/**
|
||||
* `state` domain (L1) — Session-scope keyed state container contract.
|
||||
*
|
||||
* Defines `ISessionStateService`, the Session-scope state service:
|
||||
* Session-tier services declare their plain-data state as typed keys
|
||||
* (`defineState` from `_base`) and read/write them through this container, so
|
||||
* per-session shared state lives in one observable place and dies with the
|
||||
* session. Shares the `IStateRegistry` method set with its App/Agent
|
||||
* counterparts. Bound at Session scope.
|
||||
*/
|
||||
|
||||
import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation';
|
||||
import type { IStateRegistry } from '#/_base/state/stateRegistry';
|
||||
|
||||
export interface ISessionStateService extends IStateRegistry {
|
||||
readonly _serviceBrand: undefined;
|
||||
}
|
||||
|
||||
export const ISessionStateService: ServiceIdentifier<ISessionStateService> =
|
||||
createDecorator<ISessionStateService>('sessionStateService');
|
||||
|
|
@ -0,0 +1,25 @@
|
|||
/**
|
||||
* `state` domain (L1) — `ISessionStateService` implementation.
|
||||
*
|
||||
* Thin per-scope binding over the `_base` `StateRegistry`; the container owns
|
||||
* construction and disposal, so registered state dies with the scope. Bound
|
||||
* at Session scope.
|
||||
*/
|
||||
|
||||
import { InstantiationType } from '#/_base/di/extensions';
|
||||
import { LifecycleScope, registerScopedService } from '#/_base/di/scope';
|
||||
import { StateRegistry } from '#/_base/state/stateRegistry';
|
||||
|
||||
import { ISessionStateService } from './sessionState';
|
||||
|
||||
export class SessionStateService extends StateRegistry implements ISessionStateService {
|
||||
declare readonly _serviceBrand: undefined;
|
||||
}
|
||||
|
||||
registerScopedService(
|
||||
LifecycleScope.Session,
|
||||
ISessionStateService,
|
||||
SessionStateService,
|
||||
InstantiationType.Eager,
|
||||
'state',
|
||||
);
|
||||
|
|
@ -4,16 +4,21 @@
|
|||
* Coordinates session-level workspace mutations: resolves and persists
|
||||
* project-local config through `projectLocalConfig`, updates
|
||||
* `workspaceContext`, and mirrors command output into the main agent through
|
||||
* `agentLifecycle` and `contextMemory`. Bound at Session scope.
|
||||
* `agentLifecycle` and `contextMemory`. The plain-data state
|
||||
* (`pendingMainInjections`) is registered into `sessionState`
|
||||
* (`ISessionStateService`) and read/written through it. Bound at Session
|
||||
* scope.
|
||||
*/
|
||||
|
||||
import { InstantiationType } from '#/_base/di/extensions';
|
||||
import { Disposable } from '#/_base/di/lifecycle';
|
||||
import { LifecycleScope, registerScopedService } from '#/_base/di/scope';
|
||||
import { defineState } from '#/_base/state/stateRegistry';
|
||||
import { IAgentContextMemoryService } from '#/agent/contextMemory/contextMemory';
|
||||
import type { ContextMessage } from '#/agent/contextMemory/types';
|
||||
import { IProjectLocalConfigService } from '#/app/projectLocalConfig/projectLocalConfig';
|
||||
import { IAgentLifecycleService, MAIN_AGENT_ID } from '#/session/agentLifecycle/agentLifecycle';
|
||||
import { ISessionStateService } from '#/session/state/sessionState';
|
||||
import { ISessionWorkspaceContext } from '#/session/workspaceContext/workspaceContext';
|
||||
|
||||
import {
|
||||
|
|
@ -22,21 +27,27 @@ import {
|
|||
type WorkspaceAdditionalDirsResult,
|
||||
} from './workspaceCommand';
|
||||
|
||||
export const workspaceCommandPendingMainInjectionsKey = defineState<ContextMessage[]>(
|
||||
'workspaceCommand.pendingMainInjections',
|
||||
() => [],
|
||||
);
|
||||
|
||||
export class SessionWorkspaceCommandService
|
||||
extends Disposable
|
||||
implements ISessionWorkspaceCommandService
|
||||
{
|
||||
declare readonly _serviceBrand: undefined;
|
||||
private readonly pendingMainInjections: ContextMessage[] = [];
|
||||
private mutationQueue: Promise<void> = Promise.resolve();
|
||||
|
||||
constructor(
|
||||
@ISessionStateService private readonly states: ISessionStateService,
|
||||
@IProjectLocalConfigService
|
||||
private readonly localConfig: IProjectLocalConfigService,
|
||||
@ISessionWorkspaceContext private readonly workspace: ISessionWorkspaceContext,
|
||||
@IAgentLifecycleService private readonly agents: IAgentLifecycleService,
|
||||
) {
|
||||
super();
|
||||
this.states.register(workspaceCommandPendingMainInjectionsKey);
|
||||
this._register(
|
||||
this.agents.onDidCreate((handle) => {
|
||||
if (handle.id !== MAIN_AGENT_ID) return;
|
||||
|
|
@ -47,6 +58,10 @@ export class SessionWorkspaceCommandService
|
|||
);
|
||||
}
|
||||
|
||||
private get pendingMainInjections(): ContextMessage[] {
|
||||
return this.states.get(workspaceCommandPendingMainInjectionsKey);
|
||||
}
|
||||
|
||||
async addAdditionalDir(input: AddAdditionalDirInput): Promise<WorkspaceAdditionalDirsResult> {
|
||||
return this.enqueueMutation(() => this.applyAddAdditionalDir(input));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,25 +2,54 @@
|
|||
* `workspaceContext` domain (L1) — `ISessionWorkspaceContext` implementation.
|
||||
*
|
||||
* Holds the session work directory and additional dirs, resolves relative
|
||||
* paths, and checks whether a path falls within the workspace. Bound at
|
||||
* Session scope.
|
||||
* paths, and checks whether a path falls within the workspace. The plain-data
|
||||
* state (`workDir`, `additionalDirs`) is registered into `sessionState`
|
||||
* (`ISessionStateService`) and read/written through it. Bound at Session
|
||||
* scope.
|
||||
*/
|
||||
|
||||
import { isAbsolute, relative, resolve } from 'node:path';
|
||||
|
||||
import { InstantiationType } from '#/_base/di/extensions';
|
||||
import { LifecycleScope, registerScopedService } from '#/_base/di/scope';
|
||||
import { defineState } from '#/_base/state/stateRegistry';
|
||||
import { ISessionContext } from '#/session/sessionContext/sessionContext';
|
||||
import { ISessionStateService } from '#/session/state/sessionState';
|
||||
|
||||
import { ISessionWorkspaceContext, type PathAccessOperation } from './workspaceContext';
|
||||
|
||||
export const workspaceContextWorkDirKey = defineState<string>('workspaceContext.workDir', () => '');
|
||||
export const workspaceContextAdditionalDirsKey = defineState<string[]>(
|
||||
'workspaceContext.additionalDirs',
|
||||
() => [],
|
||||
);
|
||||
|
||||
export class SessionWorkspaceContextService implements ISessionWorkspaceContext {
|
||||
declare readonly _serviceBrand: undefined;
|
||||
private _workDir: string;
|
||||
private _additionalDirs: string[] = [];
|
||||
|
||||
constructor(@ISessionContext ctx: ISessionContext) {
|
||||
this._workDir = resolve(ctx.cwd);
|
||||
constructor(
|
||||
@ISessionStateService private readonly states: ISessionStateService,
|
||||
@ISessionContext ctx: ISessionContext,
|
||||
) {
|
||||
this.states.register(workspaceContextWorkDirKey);
|
||||
this.states.register(workspaceContextAdditionalDirsKey);
|
||||
this.setWorkDir(ctx.cwd);
|
||||
}
|
||||
|
||||
private get _workDir(): string {
|
||||
return this.states.get(workspaceContextWorkDirKey);
|
||||
}
|
||||
|
||||
private set _workDir(value: string) {
|
||||
this.states.set(workspaceContextWorkDirKey, value);
|
||||
}
|
||||
|
||||
private get _additionalDirs(): string[] {
|
||||
return this.states.get(workspaceContextAdditionalDirsKey);
|
||||
}
|
||||
|
||||
private set _additionalDirs(value: string[]) {
|
||||
this.states.set(workspaceContextAdditionalDirsKey, value);
|
||||
}
|
||||
|
||||
get workDir(): string {
|
||||
|
|
|
|||
|
|
@ -182,4 +182,54 @@ describe('Scope tree', () => {
|
|||
expect(() => session.createChild(LifecycleScope.Agent, 'a1')).toThrow(/disposed/);
|
||||
app.dispose();
|
||||
});
|
||||
|
||||
it('constructs every registered service at scope creation, in dependency order, without any get()', () => {
|
||||
const events: string[] = [];
|
||||
interface ITagged {
|
||||
tag: string;
|
||||
}
|
||||
const IDep = createDecorator<ITagged>('tree-eager-dep');
|
||||
const ITop = createDecorator<ITagged>('tree-eager-top');
|
||||
_clearScopedRegistryForTests();
|
||||
class Dep implements ITagged {
|
||||
tag = 'dep';
|
||||
constructor() {
|
||||
events.push('dep');
|
||||
}
|
||||
}
|
||||
class Top implements ITagged {
|
||||
tag = 'top';
|
||||
constructor(@IDep public readonly dep: ITagged) {
|
||||
events.push('top');
|
||||
}
|
||||
}
|
||||
// Register the dependent BEFORE its dependency: construction order must
|
||||
// come from the static dependency graph, not from registration order.
|
||||
registerScopedService(LifecycleScope.Session, ITop, Top, InstantiationType.Eager);
|
||||
registerScopedService(LifecycleScope.Session, IDep, Dep, InstantiationType.Eager);
|
||||
|
||||
const app = createAppScope();
|
||||
app.createChild(LifecycleScope.Session, 's1');
|
||||
expect(events).toEqual(['dep', 'top']);
|
||||
app.dispose();
|
||||
});
|
||||
|
||||
it('fails scope creation when a registered service constructor throws', () => {
|
||||
interface IBoom {
|
||||
tag: 'boom';
|
||||
}
|
||||
const IBoom = createDecorator<IBoom>('tree-eager-boom');
|
||||
_clearScopedRegistryForTests();
|
||||
class Boom implements IBoom {
|
||||
tag = 'boom' as const;
|
||||
constructor() {
|
||||
throw new Error('boom');
|
||||
}
|
||||
}
|
||||
registerScopedService(LifecycleScope.Session, IBoom, Boom, InstantiationType.Eager);
|
||||
|
||||
const app = createAppScope();
|
||||
expect(() => app.createChild(LifecycleScope.Session, 's1')).toThrow(/boom/);
|
||||
app.dispose();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
217
packages/agent-core-v2/test/_base/state/stateRegistry.test.ts
Normal file
217
packages/agent-core-v2/test/_base/state/stateRegistry.test.ts
Normal file
|
|
@ -0,0 +1,217 @@
|
|||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
|
||||
import { InstantiationType } from '#/_base/di/extensions';
|
||||
import {
|
||||
LifecycleScope,
|
||||
_clearScopedRegistryForTests,
|
||||
registerScopedService,
|
||||
} from '#/_base/di/scope';
|
||||
import { createScopedTestHost, type ScopedTestHost } from '#/_base/di/test';
|
||||
import { BugIndicatingError } from '#/_base/errors/errors';
|
||||
import { defineState, StateRegistry, type StateChange } from '#/_base/state/stateRegistry';
|
||||
import { IStateService } from '#/app/state/state';
|
||||
import { StateService } from '#/app/state/stateService';
|
||||
import { ISessionStateService } from '#/session/state/sessionState';
|
||||
import { SessionStateService } from '#/session/state/sessionStateService';
|
||||
import { IAgentStateService } from '#/agent/state/agentState';
|
||||
import { AgentStateService } from '#/agent/state/agentStateService';
|
||||
|
||||
describe('StateRegistry', () => {
|
||||
const countKey = defineState('test.count', () => 0);
|
||||
const nameKey = defineState('test.name', () => 'anonymous');
|
||||
|
||||
it('returns the initial value after register', () => {
|
||||
const registry = new StateRegistry();
|
||||
registry.register(countKey);
|
||||
expect(registry.get(countKey)).toBe(0);
|
||||
});
|
||||
|
||||
it('reads back the value written by set', () => {
|
||||
const registry = new StateRegistry();
|
||||
registry.register(countKey);
|
||||
registry.set(countKey, 42);
|
||||
expect(registry.get(countKey)).toBe(42);
|
||||
});
|
||||
|
||||
it('reports registered keys through has and entries', () => {
|
||||
const registry = new StateRegistry();
|
||||
expect(registry.has(countKey)).toBe(false);
|
||||
registry.register(countKey);
|
||||
registry.register(nameKey);
|
||||
expect(registry.has(countKey)).toBe(true);
|
||||
expect(registry.entries()).toEqual([
|
||||
['test.count', 0],
|
||||
['test.name', 'anonymous'],
|
||||
]);
|
||||
});
|
||||
|
||||
it('rejects duplicate registration', () => {
|
||||
const registry = new StateRegistry();
|
||||
registry.register(countKey);
|
||||
expect(() => registry.register(countKey)).toThrow(BugIndicatingError);
|
||||
});
|
||||
|
||||
it('rejects get and set on an unregistered key', () => {
|
||||
const registry = new StateRegistry();
|
||||
expect(() => registry.get(countKey)).toThrow(BugIndicatingError);
|
||||
expect(() => registry.set(countKey, 1)).toThrow(BugIndicatingError);
|
||||
});
|
||||
|
||||
it('notifies onDidChange only for the key that was set', () => {
|
||||
const registry = new StateRegistry();
|
||||
registry.register(countKey);
|
||||
registry.register(nameKey);
|
||||
const seen: number[] = [];
|
||||
registry.onDidChange(countKey)((value) => seen.push(value));
|
||||
registry.set(nameKey, 'bob');
|
||||
expect(seen).toEqual([]);
|
||||
registry.set(countKey, 7);
|
||||
expect(seen).toEqual([7]);
|
||||
});
|
||||
|
||||
it('notifies onDidChangeAny for every set', () => {
|
||||
const registry = new StateRegistry();
|
||||
registry.register(countKey);
|
||||
registry.register(nameKey);
|
||||
const seen: StateChange[] = [];
|
||||
registry.onDidChangeAny((change) => seen.push(change));
|
||||
registry.set(countKey, 1);
|
||||
registry.set(nameKey, 'alice');
|
||||
expect(seen).toEqual([
|
||||
{ key: 'test.count', value: 1 },
|
||||
{ key: 'test.name', value: 'alice' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('silences change events after dispose', () => {
|
||||
const registry = new StateRegistry();
|
||||
registry.register(countKey);
|
||||
const seen: StateChange[] = [];
|
||||
registry.onDidChangeAny((change) => seen.push(change));
|
||||
registry.dispose();
|
||||
registry.set(countKey, 1);
|
||||
expect(seen).toEqual([]);
|
||||
expect(registry.get(countKey)).toBe(1);
|
||||
});
|
||||
|
||||
it('snapshots Maps as plain objects and Sets as arrays', () => {
|
||||
const richKey = defineState('test.rich', () => ({
|
||||
map: new Map([['a', 1]]),
|
||||
set: new Set(['x', 'y']),
|
||||
list: [1, 2],
|
||||
flag: true,
|
||||
}));
|
||||
const registry = new StateRegistry();
|
||||
registry.register(richKey);
|
||||
expect(registry.snapshot()).toEqual({
|
||||
'test.rich': { map: { a: 1 }, set: ['x', 'y'], list: [1, 2], flag: true },
|
||||
});
|
||||
});
|
||||
|
||||
it('snapshots non-string-keyed Maps as entry arrays', () => {
|
||||
const id = { id: 1 };
|
||||
const pairKey = defineState('test.pairs', () => new Map<object, string>([[id, 'one']]));
|
||||
const registry = new StateRegistry();
|
||||
registry.register(pairKey);
|
||||
expect(registry.snapshot()).toEqual({ 'test.pairs': [[{ id: 1 }, 'one']] });
|
||||
});
|
||||
|
||||
it('drops functions and marks circular references in snapshots', () => {
|
||||
const trickyKey = defineState('test.tricky', () => {
|
||||
const obj: Record<string, unknown> = { fn: () => 1, value: 2 };
|
||||
obj['self'] = obj;
|
||||
return obj;
|
||||
});
|
||||
const registry = new StateRegistry();
|
||||
registry.register(trickyKey);
|
||||
expect(registry.snapshot()).toEqual({
|
||||
'test.tricky': { value: 2, self: '(circular)' },
|
||||
});
|
||||
});
|
||||
|
||||
it('shares referenced objects across branches without false circular marks', () => {
|
||||
const shared = { v: 1 };
|
||||
const sharedKey = defineState('test.shared', () => ({ a: shared, b: shared }));
|
||||
const registry = new StateRegistry();
|
||||
registry.register(sharedKey);
|
||||
expect(registry.snapshot()).toEqual({ 'test.shared': { a: { v: 1 }, b: { v: 1 } } });
|
||||
});
|
||||
|
||||
it('collapses class instances to a marker in snapshots but keeps recursing plain data', () => {
|
||||
class FakeService {
|
||||
constructor(readonly dep: object) {}
|
||||
}
|
||||
// A resource graph reachable from plain data: plain -> class -> plain…
|
||||
// The class boundary stops the walk, so the deep plain tail never copied.
|
||||
const service = new FakeService({ deep: { tail: 'unreachable' } });
|
||||
const mixedKey = defineState('test.mixed', () => ({
|
||||
plain: { nested: [1, { ok: true }] },
|
||||
controller: new AbortController(),
|
||||
tool: service,
|
||||
nullProto: Object.assign(Object.create(null) as Record<string, unknown>, { v: 1 }),
|
||||
}));
|
||||
const registry = new StateRegistry();
|
||||
registry.register(mixedKey);
|
||||
expect(registry.snapshot()).toEqual({
|
||||
'test.mixed': {
|
||||
plain: { nested: [1, { ok: true }] },
|
||||
controller: '(AbortController)',
|
||||
tool: '(FakeService)',
|
||||
nullProto: { v: 1 },
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('state services (scoped)', () => {
|
||||
let host: ScopedTestHost;
|
||||
|
||||
beforeEach(() => {
|
||||
_clearScopedRegistryForTests();
|
||||
registerScopedService(LifecycleScope.App, IStateService, StateService, InstantiationType.Eager, 'state');
|
||||
registerScopedService(
|
||||
LifecycleScope.Session,
|
||||
ISessionStateService,
|
||||
SessionStateService,
|
||||
InstantiationType.Eager,
|
||||
'state',
|
||||
);
|
||||
registerScopedService(
|
||||
LifecycleScope.Agent,
|
||||
IAgentStateService,
|
||||
AgentStateService,
|
||||
InstantiationType.Eager,
|
||||
'state',
|
||||
);
|
||||
host = createScopedTestHost();
|
||||
});
|
||||
|
||||
afterEach(() => host.dispose());
|
||||
|
||||
it('resolves a distinct state service per scope tier', () => {
|
||||
const appState = host.app.accessor.get(IStateService);
|
||||
const session = host.child(LifecycleScope.Session, 's1');
|
||||
const sessionState = session.accessor.get(ISessionStateService);
|
||||
const agent = host.childOf(session, LifecycleScope.Agent, 'main');
|
||||
const agentState = agent.accessor.get(IAgentStateService);
|
||||
expect(appState).not.toBe(sessionState);
|
||||
expect(sessionState).not.toBe(agentState);
|
||||
});
|
||||
|
||||
it('keeps registered state invisible to sibling scope tiers', () => {
|
||||
const sessionKey = defineState('test.sessionOnly', () => 'seed');
|
||||
const session = host.child(LifecycleScope.Session, 's1');
|
||||
const sessionState = session.accessor.get(ISessionStateService);
|
||||
sessionState.register(sessionKey);
|
||||
sessionState.set(sessionKey, 'live');
|
||||
expect(sessionState.get(sessionKey)).toBe('live');
|
||||
const agent = host.childOf(session, LifecycleScope.Agent, 'main');
|
||||
expect(agent.accessor.get(IAgentStateService).has(sessionKey)).toBe(false);
|
||||
expect(host.app.accessor.get(IStateService).has(sessionKey)).toBe(false);
|
||||
});
|
||||
|
||||
it('resolves the same instance within one scope', () => {
|
||||
const session = host.child(LifecycleScope.Session, 's1');
|
||||
expect(session.accessor.get(ISessionStateService)).toBe(session.accessor.get(ISessionStateService));
|
||||
});
|
||||
});
|
||||
|
|
@ -11,6 +11,8 @@ import { DisposableStore, type IDisposable } from '#/_base/di/lifecycle';
|
|||
import { TestInstantiationService } from '#/_base/di/test';
|
||||
import { type DomainEvent, IEventBus } from '#/app/event/eventBus';
|
||||
import { IAgentLoopService } from '#/agent/loop/loop';
|
||||
import { IAgentStateService } from '#/agent/state/agentState';
|
||||
import { AgentStateService } from '#/agent/state/agentStateService';
|
||||
import { IAgentTaskService } from '#/agent/task/task';
|
||||
import type { AgentTaskInfo } from '#/agent/task/types';
|
||||
import { AgentActivityView } from '#/agent/activityView/activityViewService';
|
||||
|
|
@ -72,6 +74,7 @@ function harness(
|
|||
ix.stub(IEventBus, bus as unknown as IEventBus);
|
||||
ix.stub(IAgentLoopService, loop);
|
||||
ix.stub(IAgentTaskService, tasks);
|
||||
ix.set(IAgentStateService, new AgentStateService());
|
||||
ix.stub(IAgentFullCompactionService, {
|
||||
_serviceBrand: undefined,
|
||||
compacting,
|
||||
|
|
|
|||
|
|
@ -20,6 +20,8 @@ import { IAgentContextMemoryService } from '#/agent/contextMemory/contextMemory'
|
|||
import type { ContextMessage } from '#/agent/contextMemory/types';
|
||||
import { IAgentLoopService } from '#/agent/loop/loop';
|
||||
import { IAgentProfileService } from '#/agent/profile/profile';
|
||||
import { IAgentStateService } from '#/agent/state/agentState';
|
||||
import { AgentStateService } from '#/agent/state/agentStateService';
|
||||
import { IAgentSystemReminderService } from '#/agent/systemReminder/systemReminder';
|
||||
import { AgentSystemReminderService } from '#/agent/systemReminder/systemReminderService';
|
||||
import { IEventBus } from '#/app/event/eventBus';
|
||||
|
|
@ -72,6 +74,7 @@ describe('AgentContextInjectorService', () => {
|
|||
additionalServices: (reg) => {
|
||||
reg.defineInstance(IAgentLoopService, stubLoopWithHooks());
|
||||
reg.defineInstance(IWireService, stubWire());
|
||||
reg.defineInstance(IAgentStateService, new AgentStateService());
|
||||
reg.define(IAgentSystemReminderService, AgentSystemReminderService);
|
||||
reg.define(IAgentContextInjectorService, AgentContextInjectorService);
|
||||
},
|
||||
|
|
|
|||
|
|
@ -18,6 +18,8 @@ import { IAgentContextProjectorService } from '#/agent/contextProjector/contextP
|
|||
import { AgentContextProjectorService } from '#/agent/contextProjector/contextProjectorService';
|
||||
import { toProtocolMessage } from '#/agent/contextMemory/messageProjection';
|
||||
import { IAgentScopeContext, makeAgentScopeContext } from '#/agent/scopeContext/scopeContext';
|
||||
import { IAgentStateService } from '#/agent/state/agentState';
|
||||
import { AgentStateService } from '#/agent/state/agentStateService';
|
||||
import type { Message } from '#/kosong/contract/message';
|
||||
import { ITelemetryService } from '#/app/telemetry/telemetry';
|
||||
import { recordingTelemetry, type TelemetryRecord } from '../../app/telemetry/stubs';
|
||||
|
|
@ -114,6 +116,7 @@ describe('projector tool-exchange normalization', () => {
|
|||
const ix = disposables.add(new TestInstantiationService());
|
||||
ix.set(ILogService, createCapturingLog(warnings));
|
||||
ix.set(ITelemetryService, recordingTelemetry(telemetryRecords));
|
||||
ix.set(IAgentStateService, new AgentStateService());
|
||||
ix.set(
|
||||
IAgentScopeContext,
|
||||
makeAgentScopeContext({ agentId: 'main', agentScope: '' }),
|
||||
|
|
|
|||
|
|
@ -36,7 +36,8 @@ import { MASTER_ENV } from '#/app/flag/flagService';
|
|||
import { estimateTokensForMessages } from '#/kosong/contract/tokens';
|
||||
import { recordingTelemetry, type TelemetryRecord } from '../../app/telemetry/stubs';
|
||||
import type { TestAgentContext, TestAgentOptions, TestAgentServiceOverride } from '../../harness';
|
||||
import { appServices, createCommandRunner, execEnvServices, hostEnvironmentServices, sessionServices, testAgent } from '../../harness';
|
||||
import { agentService, appServices, createCommandRunner, execEnvServices, hostEnvironmentServices, sessionServices, testAgent } from '../../harness';
|
||||
import { IAgentToolSelectAnnouncementsService } from '#/agent/toolSelect/toolSelectAnnouncements';
|
||||
import {
|
||||
IAgentFullCompactionService,
|
||||
IModelOAuthTokens,
|
||||
|
|
@ -1905,12 +1906,19 @@ describe('FullCompaction', () => {
|
|||
|
||||
it('does not trigger auto compaction from a deferred loaded MCP schema', async () => {
|
||||
vi.stubEnv(MASTER_ENV, '1');
|
||||
const ctx = testAgent({
|
||||
initialConfig: {
|
||||
providers: {},
|
||||
loopControl: { reservedContextSize: 0 },
|
||||
const ctx = testAgent(
|
||||
// Scope creation eagerly constructs every registered agent-scope service,
|
||||
// so the tool-select announcements service now runs in this harness. The
|
||||
// loadable-tools reminder it would inject for the MCP tool registered
|
||||
// below is unrelated to this test's assertions, so stub it out.
|
||||
agentService(IAgentToolSelectAnnouncementsService, { _serviceBrand: undefined }),
|
||||
{
|
||||
initialConfig: {
|
||||
providers: {},
|
||||
loopControl: { reservedContextSize: 0 },
|
||||
},
|
||||
},
|
||||
});
|
||||
);
|
||||
const parameters = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
|
|
|
|||
|
|
@ -23,6 +23,8 @@ import { AgentGoalService } from '#/agent/goal/goalService';
|
|||
import { GoalModel } from '#/agent/goal/goalOps';
|
||||
import { IAgentLoopService } from '#/agent/loop/loop';
|
||||
import { IAgentScopeContext } from '#/agent/scopeContext/scopeContext';
|
||||
import { IAgentStateService } from '#/agent/state/agentState';
|
||||
import { AgentStateService } from '#/agent/state/agentStateService';
|
||||
import { IAgentSystemReminderService } from '#/agent/systemReminder/systemReminder';
|
||||
import { IAgentToolExecutorService } from '#/agent/toolExecutor/toolExecutor';
|
||||
import { IAgentUsageService } from '#/agent/usage/usage';
|
||||
|
|
@ -126,6 +128,7 @@ function buildHost(key: string): {
|
|||
ix.stub(ITelemetryService, createTelemetryStub());
|
||||
ix.stub(IAgentToolExecutorService, createToolExecutorStub());
|
||||
ix.stub(IConfigService, createConfigStub());
|
||||
ix.set(IAgentStateService, new AgentStateService());
|
||||
ix.set(IGoalDeadlineScheduler, new SyncDescriptor(GoalDeadlineSchedulerService));
|
||||
const wire = registerTestAgentWire(ix, testWireScope(SCOPE, key), {
|
||||
log: ix.get(IAppendLogStore),
|
||||
|
|
|
|||
|
|
@ -30,6 +30,8 @@ import { AgentLLMRequesterService } from '#/agent/llmRequester/llmRequesterServi
|
|||
import { IAgentLLMRequesterService } from '#/agent/llmRequester/llmRequester';
|
||||
import { IAgentContextSizeService } from '#/agent/contextSize/contextSize';
|
||||
import { IAgentProfileService } from '#/agent/profile/profile';
|
||||
import { IAgentStateService } from '#/agent/state/agentState';
|
||||
import { AgentStateService } from '#/agent/state/agentStateService';
|
||||
import { IAgentToolRegistryService } from '#/agent/toolRegistry/toolRegistry';
|
||||
import { IAgentToolSelectService } from '#/agent/toolSelect/toolSelect';
|
||||
import { IAgentVideoResolverService } from '#/agent/media/videoResolver';
|
||||
|
|
@ -233,6 +235,7 @@ function createService(
|
|||
eventBus,
|
||||
});
|
||||
ix.set(IFaultInjectionService, new SyncDescriptor(FaultInjectionService));
|
||||
ix.set(IAgentStateService, new AgentStateService());
|
||||
ix.set(IAgentLLMRequesterService, new SyncDescriptor(AgentLLMRequesterService));
|
||||
|
||||
return {
|
||||
|
|
|
|||
|
|
@ -28,6 +28,8 @@ import { IAgentToolRegistryService } from '#/agent/toolRegistry/toolRegistry';
|
|||
import { AgentToolRegistryService } from '#/agent/toolRegistry/toolRegistryService';
|
||||
import { IAgentLoopService } from '#/agent/loop/loop';
|
||||
import { IAgentProfileService } from '#/agent/profile/profile';
|
||||
import { IAgentStateService } from '#/agent/state/agentState';
|
||||
import { AgentStateService } from '#/agent/state/agentStateService';
|
||||
|
||||
import { createTestAgent, mcpServices, type TestAgentContext } from '../../harness';
|
||||
import { recordingTelemetry, type TelemetryRecord } from '../../app/telemetry/stubs';
|
||||
|
|
@ -205,6 +207,7 @@ describe('AgentMcpService', () => {
|
|||
ix.set(IAgentToolExecutorService, new SyncDescriptor(AgentToolExecutorService));
|
||||
ix.stub(IAgentToolResultTruncationService, stubToolResultTruncationService());
|
||||
ix.stub(IAgentLoopService, stubLoopWithHooks());
|
||||
ix.set(IAgentStateService, new AgentStateService());
|
||||
wire = registerTestAgentWire(ix, 'mcp-test', {
|
||||
eventBus: ix.get(IEventBus),
|
||||
log: recordingWireLog([], (record) => {
|
||||
|
|
@ -246,9 +249,10 @@ describe('AgentMcpService', () => {
|
|||
});
|
||||
|
||||
it('resolves through the IAgentMcpService binding with no manager', () => {
|
||||
ix.set(IAgentMcpService, new SyncDescriptor(AgentMcpService));
|
||||
createService(new FakeMcpManager());
|
||||
const created = createService(new FakeMcpManager());
|
||||
ix.set(IAgentMcpService, created);
|
||||
const svc = ix.get(IAgentMcpService);
|
||||
expect(svc).toBe(created);
|
||||
expect(svc.list()).toEqual([]);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ import {
|
|||
} from '#/agent/media/image-compress';
|
||||
import { createVideoUploader, registerMediaTools } from '#/agent/media/registerMediaTools';
|
||||
import { AgentMediaToolsRegistrar } from '#/agent/media/mediaToolsRegistrar';
|
||||
import { AgentStateService } from '#/agent/state/agentStateService';
|
||||
import { AgentToolRegistryService } from '#/agent/toolRegistry/toolRegistryService';
|
||||
import {
|
||||
ToolAccesses,
|
||||
|
|
@ -852,6 +853,7 @@ describe('AgentMediaToolsRegistrar', () => {
|
|||
createTestEnv(),
|
||||
workspaceCtx,
|
||||
recordingTelemetry([]),
|
||||
new AgentStateService(),
|
||||
);
|
||||
const bindModel = (alias: string, caps: ModelCapability): void => {
|
||||
state.alias = alias;
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { describe, expect, it, vi } from 'vitest';
|
|||
|
||||
import { buildKimiFileUrl, parseKimiFileUrl } from '#/agent/media/kimiFileUrl';
|
||||
import { AgentVideoResolverService } from '#/agent/media/videoResolverService';
|
||||
import { AgentStateService } from '#/agent/state/agentStateService';
|
||||
import type { GetResult, IFileService } from '#/app/file/fileService';
|
||||
import type { ITelemetryService } from '#/app/telemetry/telemetry';
|
||||
import type { ModelCapability } from '#/kosong/contract/capability';
|
||||
|
|
@ -125,6 +126,7 @@ describe('AgentVideoResolverService', () => {
|
|||
fileService(new Map([[FILE_ID, { name: 'clip.mp4', bytes: VIDEO_BYTES }]])),
|
||||
blobStore(),
|
||||
telemetry,
|
||||
new AgentStateService(),
|
||||
);
|
||||
const req = requester({ uploadVideo: upload });
|
||||
const message = videoMessage(buildKimiFileUrl(FILE_ID, FALLBACK_PATH));
|
||||
|
|
@ -143,13 +145,13 @@ describe('AgentVideoResolverService', () => {
|
|||
const message = videoMessage(buildKimiFileUrl(FILE_ID, FALLBACK_PATH));
|
||||
|
||||
const upload1 = vi.fn(async (): Promise<VideoURLPart> => msPart('prov-1'));
|
||||
await new AgentVideoResolverService(fileService(files), blobs, telemetry).resolve(
|
||||
await new AgentVideoResolverService(fileService(files), blobs, telemetry, new AgentStateService()).resolve(
|
||||
[message],
|
||||
requester({ uploadVideo: upload1 }),
|
||||
);
|
||||
|
||||
const upload2 = vi.fn(async (): Promise<VideoURLPart> => msPart('prov-2'));
|
||||
const out = await new AgentVideoResolverService(fileService(files), blobs, telemetry).resolve(
|
||||
const out = await new AgentVideoResolverService(fileService(files), blobs, telemetry, new AgentStateService()).resolve(
|
||||
[message],
|
||||
requester({ uploadVideo: upload2 }),
|
||||
);
|
||||
|
|
@ -165,6 +167,7 @@ describe('AgentVideoResolverService', () => {
|
|||
fileService(new Map([[FILE_ID, { name: 'clip.mp4', bytes: VIDEO_BYTES }]])),
|
||||
blobStore(),
|
||||
telemetry,
|
||||
new AgentStateService(),
|
||||
).resolve([videoMessage(buildKimiFileUrl(FILE_ID, FALLBACK_PATH))], requester({ videoIn: false, uploadVideo: upload }));
|
||||
|
||||
expect(firstPart(out)).toEqual({ type: 'text', text: `<video path="${FALLBACK_PATH}"></video>` });
|
||||
|
|
@ -176,6 +179,7 @@ describe('AgentVideoResolverService', () => {
|
|||
fileService(new Map([[FILE_ID, { name: 'clip.mp4', bytes: VIDEO_BYTES }]])),
|
||||
blobStore(),
|
||||
telemetry,
|
||||
new AgentStateService(),
|
||||
).resolve([videoMessage(buildKimiFileUrl(FILE_ID, FALLBACK_PATH))], requester({ protocol: 'anthropic', uploadVideo: undefined }));
|
||||
|
||||
expect(firstPart(out)).toEqual({
|
||||
|
|
@ -189,6 +193,7 @@ describe('AgentVideoResolverService', () => {
|
|||
fileService(new Map([[FILE_ID, { name: 'clip.mp4', bytes: VIDEO_BYTES }]])),
|
||||
blobStore(),
|
||||
telemetry,
|
||||
new AgentStateService(),
|
||||
).resolve([videoMessage(buildKimiFileUrl(FILE_ID, FALLBACK_PATH))], requester({ protocol: 'openai', uploadVideo: undefined }));
|
||||
|
||||
expect(firstPart(out)).toEqual({ type: 'text', text: `<video path="${FALLBACK_PATH}"></video>` });
|
||||
|
|
@ -202,6 +207,7 @@ describe('AgentVideoResolverService', () => {
|
|||
fileService(new Map([[FILE_ID, { name: 'clip.mp4', bytes: VIDEO_BYTES }]])),
|
||||
blobStore(),
|
||||
telemetry,
|
||||
new AgentStateService(),
|
||||
);
|
||||
|
||||
await expect(
|
||||
|
|
@ -221,6 +227,7 @@ describe('AgentVideoResolverService', () => {
|
|||
fileService(new Map([[FILE_ID, { name: 'clip.mp4', bytes: VIDEO_BYTES }]])),
|
||||
blobStore(),
|
||||
telemetry,
|
||||
new AgentStateService(),
|
||||
);
|
||||
const message = videoMessage(buildKimiFileUrl(FILE_ID, FALLBACK_PATH));
|
||||
|
||||
|
|
@ -245,6 +252,7 @@ describe('AgentVideoResolverService', () => {
|
|||
fileService(new Map([[FILE_ID, { name: 'clip.mp4', bytes: VIDEO_BYTES }]])),
|
||||
blobStore(),
|
||||
telemetry,
|
||||
new AgentStateService(),
|
||||
);
|
||||
const message = videoMessage(buildKimiFileUrl(FILE_ID, FALLBACK_PATH));
|
||||
const req = requester({ uploadVideo: upload });
|
||||
|
|
@ -266,6 +274,7 @@ describe('AgentVideoResolverService', () => {
|
|||
fileService(new Map([[FILE_ID, { name: 'clip.mp4', bytes: PNG_BYTES }]])),
|
||||
blobStore(),
|
||||
telemetry,
|
||||
new AgentStateService(),
|
||||
).resolve([videoMessage(buildKimiFileUrl(FILE_ID, FALLBACK_PATH))], requester({ uploadVideo: upload }));
|
||||
|
||||
expect(firstPart(out)).toEqual({ type: 'text', text: `<video path="${FALLBACK_PATH}"></video>` });
|
||||
|
|
@ -273,7 +282,7 @@ describe('AgentVideoResolverService', () => {
|
|||
});
|
||||
|
||||
it('tags a stale reference by its materialization path', async () => {
|
||||
const out = await new AgentVideoResolverService(fileService(new Map()), blobStore(), telemetry).resolve(
|
||||
const out = await new AgentVideoResolverService(fileService(new Map()), blobStore(), telemetry, new AgentStateService()).resolve(
|
||||
[videoMessage(buildKimiFileUrl('missing', FALLBACK_PATH))],
|
||||
requester({ uploadVideo: vi.fn() }),
|
||||
);
|
||||
|
|
@ -282,7 +291,7 @@ describe('AgentVideoResolverService', () => {
|
|||
});
|
||||
|
||||
it('emits an unavailable placeholder when a stale reference has no fallback path', async () => {
|
||||
const out = await new AgentVideoResolverService(fileService(new Map()), blobStore(), telemetry).resolve(
|
||||
const out = await new AgentVideoResolverService(fileService(new Map()), blobStore(), telemetry, new AgentStateService()).resolve(
|
||||
[videoMessage(buildKimiFileUrl('missing'))],
|
||||
requester({ uploadVideo: vi.fn() }),
|
||||
);
|
||||
|
|
@ -298,6 +307,7 @@ describe('AgentVideoResolverService', () => {
|
|||
fileService(new Map([[FILE_ID, { name: 'clip.mp4', bytes: VIDEO_BYTES }]])),
|
||||
blobStore(),
|
||||
telemetry,
|
||||
new AgentStateService(),
|
||||
);
|
||||
const messages = [videoMessage('ms://already-uploaded')];
|
||||
|
||||
|
|
|
|||
|
|
@ -12,6 +12,8 @@ import { PermissionModeInjection } from '#/agent/permissionMode/injection/permis
|
|||
import { AgentPermissionModeService } from '#/agent/permissionMode/permissionModeService';
|
||||
import { PermissionModeModel } from '#/agent/permissionMode/permissionModeOps';
|
||||
import type { PermissionMode } from '#/agent/permissionPolicy/types';
|
||||
import { IAgentStateService } from '#/agent/state/agentState';
|
||||
import { AgentStateService } from '#/agent/state/agentStateService';
|
||||
import { AppendLogStore } from '#/persistence/backends/node-fs/appendLogStore';
|
||||
import { InMemoryStorageService } from '#/persistence/backends/memory/inMemoryStorageService';
|
||||
import { IAppendLogStore } from '#/persistence/interface/appendLogStore';
|
||||
|
|
@ -58,6 +60,7 @@ beforeEach(() => {
|
|||
ix.stub(IFileSystemStorageService, new InMemoryStorageService());
|
||||
ix.set(IAppendLogStore, new SyncDescriptor(AppendLogStore));
|
||||
ix.stub(IAgentContextInjectorService, injectorStub);
|
||||
ix.set(IAgentStateService, new AgentStateService());
|
||||
ix.set(IAgentPermissionModeService, new SyncDescriptor(AgentPermissionModeService));
|
||||
log = ix.get(IAppendLogStore);
|
||||
registerTestAgentWire(ix, testWireScope(SCOPE, KEY), { log });
|
||||
|
|
@ -179,6 +182,7 @@ describe('AgentPermissionModeService (wire-backed)', () => {
|
|||
},
|
||||
injectAfterCompaction: async () => {},
|
||||
});
|
||||
ix2.set(IAgentStateService, new AgentStateService());
|
||||
disposables.add(ix2.createInstance(PermissionModeInjection, svc));
|
||||
if (restoredProvider === undefined) throw new Error('expected restored provider');
|
||||
|
||||
|
|
|
|||
|
|
@ -2,6 +2,12 @@ import { mkdtemp, rm } from 'node:fs/promises';
|
|||
import { createHash } from 'node:crypto';
|
||||
import { tmpdir } from 'node:os';
|
||||
|
||||
// Imported first on purpose: scope creation now eagerly instantiates every
|
||||
// registered service in registry (module evaluation) order, and
|
||||
// `onBeforeExecuteTool` veto listeners fire in construction order. The plan
|
||||
// guard must register before the permission gate (reached via `#/index` in
|
||||
// the harness) so plan-file writes are allowed before deny rules adjudicate.
|
||||
import '#/agent/plan/planService';
|
||||
import type { ToolCall } from '#/kosong/contract/message';
|
||||
import { dirname, isAbsolute, join } from 'pathe';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
|
|
|||
|
|
@ -27,6 +27,8 @@ import type {
|
|||
} from '#/agent/permissionPolicy/types';
|
||||
import { IAgentPlanService } from '#/agent/plan/plan';
|
||||
import { AgentPlanService } from '#/agent/plan/planService';
|
||||
import { IAgentStateService } from '#/agent/state/agentState';
|
||||
import { AgentStateService } from '#/agent/state/agentStateService';
|
||||
import { IAgentToolApprovalService } from '#/agent/toolApproval/toolApproval';
|
||||
import { IAgentToolExecutorService } from '#/agent/toolExecutor/toolExecutor';
|
||||
import type {
|
||||
|
|
@ -194,6 +196,7 @@ describe('AgentPlanService plan-guard listener', () => {
|
|||
reg.defineInstance(IAgentToolApprovalService, toolApproval);
|
||||
reg.defineInstance(IAgentPermissionModeService, stubPermissionModeService(() => mode));
|
||||
reg.defineInstance(ITelemetryService, recordingTelemetry(records));
|
||||
reg.defineInstance(IAgentStateService, new AgentStateService());
|
||||
reg.define(IAgentPlanService, AgentPlanService);
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -16,6 +16,8 @@ import { ITelemetryService } from '#/app/telemetry/telemetry';
|
|||
import { IAgentTelemetryContextService } from '#/app/telemetry/agentTelemetryContext';
|
||||
import { AgentTelemetryContextService } from '#/app/telemetry/agentTelemetryContextService';
|
||||
import { IAgentScopeContext, makeAgentScopeContext } from '#/agent/scopeContext/scopeContext';
|
||||
import { IAgentStateService } from '#/agent/state/agentState';
|
||||
import { AgentStateService } from '#/agent/state/agentStateService';
|
||||
import { IHostEnvironment } from '#/os/interface/hostEnvironment';
|
||||
import { IHostFileSystem } from '#/os/interface/hostFileSystem';
|
||||
import { AppendLogStore } from '#/persistence/backends/node-fs/appendLogStore';
|
||||
|
|
@ -220,6 +222,7 @@ function buildHost(key: string): {
|
|||
disabledTools: () => [],
|
||||
setDisabledTools: () => Promise.resolve(),
|
||||
});
|
||||
host.set(IAgentStateService, new AgentStateService());
|
||||
host.set(IAgentProfileService, new SyncDescriptor(AgentProfileService));
|
||||
const wire = registerTestAgentWire(host, testWireScope(SCOPE, key), {
|
||||
log: host.get(IAppendLogStore),
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ import { IWireService } from '#/wire/wire';
|
|||
|
||||
import { stubContextMemory } from '../contextMemory/stubs';
|
||||
import { stubLoopWithHooks, stubToolExecutor, stubWire } from '../loop/stubs';
|
||||
import { registerStateServices } from '../../state/stubs';
|
||||
|
||||
function message(text: string): ContextMessage {
|
||||
return { role: 'user', content: [{ type: 'text', text }], toolCalls: [], origin: { kind: 'user' } };
|
||||
|
|
@ -47,6 +48,7 @@ function harness() {
|
|||
} as unknown as IAgentFullCompactionService;
|
||||
const ix = createServices(disposables, {
|
||||
strict: true, additionalServices: (reg) => {
|
||||
registerStateServices(reg);
|
||||
reg.defineInstance(IAgentContextMemoryService, context);
|
||||
reg.defineInstance(IAgentLoopService, loop);
|
||||
reg.defineInstance(IWireService, stubWire());
|
||||
|
|
|
|||
26
packages/agent-core-v2/test/agent/state/agentState.test.ts
Normal file
26
packages/agent-core-v2/test/agent/state/agentState.test.ts
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
/**
|
||||
* `state` domain — `IAgentStateService` snapshot safety over a fully
|
||||
* assembled agent scope. Registered keys hold plain data only: every
|
||||
* registered key must serialize, and the whole snapshot must stay small.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { IAgentStateService } from '#/agent/state/agentState';
|
||||
|
||||
import { createTestAgent } from '../../harness/agent';
|
||||
|
||||
describe('agent state snapshot (full agent scope)', () => {
|
||||
it('serializes every registered key and stays small', () => {
|
||||
const ctx = createTestAgent();
|
||||
const states = ctx.get(IAgentStateService);
|
||||
|
||||
const registered = states.entries().map(([name]) => name);
|
||||
const snapshot = states.snapshot();
|
||||
expect(Object.keys(snapshot).toSorted()).toEqual(registered.toSorted());
|
||||
|
||||
// The whole snapshot must be JSON-serializable and stay small.
|
||||
const json = JSON.stringify(snapshot);
|
||||
expect(json.length).toBeLessThan(5 * 1024 * 1024);
|
||||
});
|
||||
});
|
||||
|
|
@ -13,6 +13,9 @@ import { ContinuationStepRequest } from '#/agent/loop/stepRequest';
|
|||
|
||||
import { createTestAgent, llmGenerateServices, type TestAgentContext } from '../../harness';
|
||||
|
||||
// Captured before any `vi.useFakeTimers()` call, so this is always the real clock.
|
||||
const realSetTimeout = globalThis.setTimeout;
|
||||
|
||||
describe('stepRetry plugin', () => {
|
||||
let ctx: TestAgentContext;
|
||||
|
||||
|
|
@ -34,7 +37,28 @@ describe('stepRetry plugin', () => {
|
|||
const loop = ctx.get(IAgentLoopService);
|
||||
loop.enqueue(new ContinuationStepRequest());
|
||||
const resultPromise = loop.run({ turnId, signal });
|
||||
await vi.runAllTimersAsync();
|
||||
// Scope creation now eagerly instantiates every registered service, which
|
||||
// adds real-async hops to the step pipeline. `runAllTimersAsync` can then
|
||||
// return while the retry chain is parked on such a hop — before the next
|
||||
// backoff timer has been scheduled — leaving that timer unfired forever.
|
||||
// Keep draining, with a real-time yield between passes so the chain can
|
||||
// schedule the next timer, until the turn settles.
|
||||
let settled = false;
|
||||
void resultPromise.then(
|
||||
() => {
|
||||
settled = true;
|
||||
},
|
||||
() => {
|
||||
settled = true;
|
||||
},
|
||||
);
|
||||
for (let i = 0; i < 100; i += 1) {
|
||||
if (settled) break;
|
||||
await vi.runAllTimersAsync();
|
||||
if (!settled) {
|
||||
await new Promise((resolve) => realSetTimeout(resolve, 1));
|
||||
}
|
||||
}
|
||||
return resultPromise;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -33,6 +33,8 @@ import { IAgentContextMemoryService } from '#/agent/contextMemory/contextMemory'
|
|||
import type { ContextMessage } from '#/agent/contextMemory/types';
|
||||
import { IAgentLoopService } from '#/agent/loop/loop';
|
||||
import { IAgentScopeContext, makeAgentScopeContext } from '#/agent/scopeContext/scopeContext';
|
||||
import { IAgentStateService } from '#/agent/state/agentState';
|
||||
import { AgentStateService } from '#/agent/state/agentStateService';
|
||||
import { ISessionContext, makeSessionContext } from '#/session/sessionContext/sessionContext';
|
||||
import { IAtomicDocumentStore } from '#/persistence/interface/atomicDocumentStore';
|
||||
import { IFileSystemStorageService } from '#/persistence/interface/storage';
|
||||
|
|
@ -148,6 +150,7 @@ describe('AgentTaskService', () => {
|
|||
flush: async () => {},
|
||||
close: async () => {},
|
||||
});
|
||||
ix.set(IAgentStateService, new AgentStateService());
|
||||
ix.set(IAgentTaskService, new SyncDescriptor(AgentTaskService));
|
||||
});
|
||||
afterEach(() => disposables.dispose());
|
||||
|
|
@ -496,6 +499,7 @@ describe('AgentTaskService', () => {
|
|||
);
|
||||
ix.stub(IAtomicDocumentStore, docs);
|
||||
ix.stub(IFileSystemStorageService, bytes);
|
||||
ix.set(IAgentStateService, new AgentStateService());
|
||||
ix.set(IAgentTaskService, new SyncDescriptor(AgentTaskService));
|
||||
return ix;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,6 +10,8 @@ import { ITelemetryService } from '#/app/telemetry/telemetry';
|
|||
import { ISessionContext } from '#/session/sessionContext/sessionContext';
|
||||
import { IAgentScopeContext } from '#/agent/scopeContext/scopeContext';
|
||||
import { IAgentLoopService } from '#/agent/loop/loop';
|
||||
import { IAgentStateService } from '#/agent/state/agentState';
|
||||
import { AgentStateService } from '#/agent/state/agentStateService';
|
||||
import type { ExecutableTool, ExecutableToolContext, ExecutableToolResult, ToolExecution, ToolResult } from '#/tool/toolContract';
|
||||
import type { ToolDidExecuteContext, ResolvedToolExecutionHookContext, BeforeExecuteDecision } from '#/agent/toolExecutor/toolHooks';
|
||||
import { IAgentToolDedupeService, type ToolDedupeResult } from '#/agent/toolDedupe/toolDedupe';
|
||||
|
|
@ -85,6 +87,7 @@ function createHarness(
|
|||
homeDir: homedir,
|
||||
} as unknown as IBootstrapService);
|
||||
reg.defineInstance(IAgentLoopService, loop);
|
||||
reg.defineInstance(IAgentStateService, new AgentStateService());
|
||||
reg.define(IAgentToolRegistryService, AgentToolRegistryService);
|
||||
if (events === undefined) {
|
||||
reg.define(IAgentToolExecutorService, AgentToolExecutorService);
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@ import type { LLMRequestTrace } from '#/kosong/contract/requestTrace';
|
|||
import { ITelemetryService } from '#/app/telemetry/telemetry';
|
||||
import { registerLogServices } from '../../_base/log/stubs';
|
||||
import { recordingTelemetry, type TelemetryRecord } from '../../app/telemetry/stubs';
|
||||
import { registerStateServices } from '../../state/stubs';
|
||||
import { registerTestAgentWireServices } from '../../wire/stubs';
|
||||
|
||||
type ToolExecutorEvent =
|
||||
|
|
@ -49,6 +50,7 @@ beforeEach(() => {
|
|||
truncateForModel = async (input) => input.result;
|
||||
ix = createServices(disposables, {
|
||||
additionalServices: (reg) => {
|
||||
registerStateServices(reg);
|
||||
registerTestAgentWireServices(reg, 'wire/tool-executor');
|
||||
reg.define(IAgentToolRegistryService, AgentToolRegistryService);
|
||||
reg.define(IAgentToolExecutorService, AgentToolExecutorService);
|
||||
|
|
|
|||
|
|
@ -56,6 +56,7 @@ import { SelectToolsTool } from '#/agent/toolSelect/tools/select-tools';
|
|||
import { ITelemetryService } from '#/app/telemetry/telemetry';
|
||||
import { registerLogServices } from '../../_base/log/stubs';
|
||||
import { recordingTelemetry } from '../../app/telemetry/stubs';
|
||||
import { registerStateServices } from '../../state/stubs';
|
||||
import { stubToolExecutor } from '../loop/stubs';
|
||||
import { registerToolResultTruncationServices } from '../toolResultTruncation/stubs';
|
||||
|
||||
|
|
@ -300,6 +301,7 @@ function registerSharedServices(
|
|||
loop: FakeLoopService,
|
||||
eventBus: RecordingEventBus,
|
||||
): void {
|
||||
registerStateServices(reg);
|
||||
reg.defineInstance(IEventBus, eventBus);
|
||||
reg.defineInstance(IAgentLoopService, loop);
|
||||
reg.defineInstance(IAgentContextMemoryService, contextMemory);
|
||||
|
|
|
|||
|
|
@ -3,6 +3,8 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
|||
import { SyncDescriptor } from '#/_base/di/descriptors';
|
||||
import { DisposableStore } from '#/_base/di/lifecycle';
|
||||
import { TestInstantiationService } from '#/_base/di/test';
|
||||
import { IAgentStateService } from '#/agent/state/agentState';
|
||||
import { AgentStateService } from '#/agent/state/agentStateService';
|
||||
import {
|
||||
IAgentUsageService,
|
||||
type UsageRecordedContext,
|
||||
|
|
@ -34,6 +36,7 @@ beforeEach(() => {
|
|||
ix = disposables.add(new TestInstantiationService());
|
||||
ix.stub(IFileSystemStorageService, new InMemoryStorageService());
|
||||
ix.set(IAppendLogStore, new SyncDescriptor(AppendLogStore));
|
||||
ix.set(IAgentStateService, new AgentStateService());
|
||||
ix.set(IEventBus, new SyncDescriptor(EventBusService));
|
||||
ix.set(IAgentUsageService, new SyncDescriptor(AgentUsageService));
|
||||
log = ix.get(IAppendLogStore);
|
||||
|
|
@ -59,6 +62,7 @@ function createFreshWire(logKey: string): { readonly fresh: IWireService; readon
|
|||
const freshIx = disposables.add(new TestInstantiationService());
|
||||
freshIx.stub(IFileSystemStorageService, new InMemoryStorageService());
|
||||
freshIx.set(IAppendLogStore, new SyncDescriptor(AppendLogStore));
|
||||
freshIx.set(IAgentStateService, new AgentStateService());
|
||||
const freshLog = freshIx.get(IAppendLogStore);
|
||||
const fresh = registerTestAgentWire(freshIx, testWireScope(SCOPE, logKey), {
|
||||
log: freshLog,
|
||||
|
|
|
|||
|
|
@ -4,6 +4,8 @@ import { SyncDescriptor } from '#/_base/di/descriptors';
|
|||
import { DisposableStore } from '#/_base/di/lifecycle';
|
||||
import { TestInstantiationService } from '#/_base/di/test';
|
||||
import { IAgentProfileService } from '#/agent/profile/profile';
|
||||
import { IAgentStateService } from '#/agent/state/agentState';
|
||||
import { AgentStateService } from '#/agent/state/agentStateService';
|
||||
import { IAgentToolRegistryService } from '#/agent/toolRegistry/toolRegistry';
|
||||
import { AgentToolRegistryService } from '#/agent/toolRegistry/toolRegistryService';
|
||||
import { IAgentUserToolService, type UserToolRegistration } from '#/agent/userTool/userTool';
|
||||
|
|
@ -81,6 +83,7 @@ beforeEach(() => {
|
|||
ix = disposables.add(new TestInstantiationService());
|
||||
ix.stub(IFileSystemStorageService, new InMemoryStorageService());
|
||||
ix.set(IAppendLogStore, new SyncDescriptor(AppendLogStore));
|
||||
ix.set(IAgentStateService, new AgentStateService());
|
||||
ix.set(IAgentToolRegistryService, new SyncDescriptor(AgentToolRegistryService));
|
||||
profile = createProfileStub();
|
||||
ix.stub(IAgentProfileService, profile);
|
||||
|
|
@ -161,6 +164,7 @@ describe('AgentUserToolService (wire-backed)', () => {
|
|||
const ixChild = disposables.add(new TestInstantiationService());
|
||||
ixChild.stub(IFileSystemStorageService, new InMemoryStorageService());
|
||||
ixChild.set(IAppendLogStore, new SyncDescriptor(AppendLogStore));
|
||||
ixChild.set(IAgentStateService, new AgentStateService());
|
||||
ixChild.set(IAgentToolRegistryService, new SyncDescriptor(AgentToolRegistryService));
|
||||
const childProfile = createProfileStub();
|
||||
ixChild.stub(IAgentProfileService, childProfile);
|
||||
|
|
@ -220,6 +224,7 @@ describe('AgentUserToolService (wire-backed)', () => {
|
|||
const ix2 = disposables.add(new TestInstantiationService());
|
||||
ix2.stub(IFileSystemStorageService, new InMemoryStorageService());
|
||||
ix2.set(IAppendLogStore, new SyncDescriptor(AppendLogStore));
|
||||
ix2.set(IAgentStateService, new AgentStateService());
|
||||
ix2.set(IAgentToolRegistryService, new SyncDescriptor(AgentToolRegistryService));
|
||||
const profile2 = createProfileStub();
|
||||
ix2.stub(IAgentProfileService, profile2);
|
||||
|
|
|
|||
|
|
@ -2,8 +2,9 @@ import { beforeEach, describe, expect, it } from 'vitest';
|
|||
|
||||
import { InstantiationType } from '#/_base/di/extensions';
|
||||
import type { ServiceIdentifier } from '#/_base/di/instantiation';
|
||||
import { LifecycleScope, registerScopedService } from '#/_base/di/scope';
|
||||
import { LifecycleScope, _clearScopedRegistryForTests, registerScopedService } from '#/_base/di/scope';
|
||||
import { createScopedTestHost } from '#/_base/di/test';
|
||||
import { ILogService } from '#/_base/log/log';
|
||||
import {
|
||||
IBootstrapService,
|
||||
bootstrap,
|
||||
|
|
@ -11,16 +12,16 @@ import {
|
|||
resolveBootstrapOptions,
|
||||
} from '#/app/bootstrap/bootstrap';
|
||||
import { BootstrapService } from '#/app/bootstrap/bootstrapService';
|
||||
import { IKosongConfigService } from '#/app/kosongConfig/kosongConfig';
|
||||
import { FileStorageService } from '#/persistence/backends/node-fs/fileStorageService';
|
||||
import { IFileSystemStorageService } from '#/persistence/interface/storage';
|
||||
|
||||
import { stubLog } from '../../_base/log/stubs';
|
||||
|
||||
describe('BootstrapService (scoped)', () => {
|
||||
beforeEach(() => {
|
||||
// No `_clearScopedRegistryForTests()` here: the registry is process-wide,
|
||||
// and wiping it would break other suites sharing this worker.
|
||||
// Re-registering is enough — later registrations win in the scope
|
||||
// collection.
|
||||
// Scope creation eagerly instantiates every registered service of the
|
||||
// tier, so keep the registry minimal: only what this file needs.
|
||||
_clearScopedRegistryForTests();
|
||||
registerScopedService(
|
||||
LifecycleScope.App,
|
||||
IBootstrapService,
|
||||
|
|
@ -58,14 +59,11 @@ describe('resolveBootstrapOptions', () => {
|
|||
|
||||
describe('bootstrap() storage seeding', () => {
|
||||
it('seeds IFileSystemStorageService as a FileStorageService instance', () => {
|
||||
// `bootstrap()` eagerly instantiates the kosong persistence bridge; stub
|
||||
// it out so this test stays focused on the storage seed instead of
|
||||
// pulling the whole config/kosong graph into the module imports.
|
||||
// Scope creation eagerly instantiates everything in the collection,
|
||||
// including the seeded `ISkillDiscovery` (FileSkillDiscovery), whose
|
||||
// constructor needs `ILogService` — seed a stub for it.
|
||||
const { app } = bootstrap({ homeDir: '/tmp/kimi-home' }, [
|
||||
[
|
||||
IKosongConfigService as ServiceIdentifier<unknown>,
|
||||
{ _serviceBrand: undefined, ready: Promise.resolve() },
|
||||
],
|
||||
[ILogService as ServiceIdentifier<unknown>, stubLog()],
|
||||
]);
|
||||
try {
|
||||
const storage = app.accessor.get(IFileSystemStorageService);
|
||||
|
|
|
|||
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