From d4a6a20db81e0aeb2e8cb7c7ea1bd1e7025fa085 Mon Sep 17 00:00:00 2001 From: "haozhe.yang" Date: Wed, 1 Jul 2026 22:45:48 +0800 Subject: [PATCH] feat(agent-core-v2): add scope-typed IScopeHandle aliases - make IScopeHandle generic over LifecycleScope and add IAppScopeHandle / ISessionScopeHandle / IAgentScopeHandle so session and agent handles cannot be passed to each other's parameters at compile time - thread the aliases through IAgentLifecycleService / ISessionLifecycleService and their consumers (sessionLegacy, messageLegacy, server-v2 mainAgent and skills), narrowing at the lifecycle boundary - dep-graph viewer: render scope-mismatch (token registered at a scope the caller cannot see) distinctly from a genuinely missing implementation --- .../scripts/dep-graph/web/src/GraphView.tsx | 572 +++++++++++++++--- .../scripts/dep-graph/web/src/style.ts | 5 + packages/agent-core-v2/src/_base/di/scope.ts | 11 +- .../app/messageLegacy/messageLegacyService.ts | 4 +- .../app/session-lifecycle/sessionLifecycle.ts | 16 +- .../sessionLifecycleService.ts | 22 +- .../app/sessionLegacy/sessionLegacyService.ts | 19 +- .../session/agent-lifecycle/agentLifecycle.ts | 14 +- .../agent-lifecycle/agentLifecycleService.ts | 18 +- .../test/gateway/gateway.test.ts | 6 +- .../session-activity/sessionActivity.test.ts | 6 +- .../test/session/session.test.ts | 4 +- packages/server-v2/src/routes/skills.ts | 4 +- packages/server-v2/src/transport/mainAgent.ts | 8 +- 14 files changed, 544 insertions(+), 165 deletions(-) diff --git a/packages/agent-core-v2/scripts/dep-graph/web/src/GraphView.tsx b/packages/agent-core-v2/scripts/dep-graph/web/src/GraphView.tsx index 65360b1d6..bc18f7623 100644 --- a/packages/agent-core-v2/scripts/dep-graph/web/src/GraphView.tsx +++ b/packages/agent-core-v2/scripts/dep-graph/web/src/GraphView.tsx @@ -12,12 +12,18 @@ import { type Viewport, } from '@xyflow/react'; import '@xyflow/react/dist/style.css'; -import { useMemo } from 'react'; +import { Fragment, useMemo, useState } from 'react'; -import type { Edge, EdgeKind, Graph, ServiceNode } from '../../analyzer/types'; +import type { Edge, EdgeKind, EdgeRef, Graph, ServiceNode } from '../../analyzer/types'; import type { FilterState } from './Filters'; import { layoutDagre } from './layout-dagre'; -import { EDGE_STYLE, SCOPE_STYLE } from './style'; +import { + EDGE_STYLE, + SCOPE_MISMATCH_COLOR, + SCOPE_STYLE, + UNRESOLVED_COLOR, +} from './style'; +import { tagColor, type TagMap } from './tags'; /** Fixed node width so port rows have a stable horizontal box. */ const NODE_WIDTH = 300; @@ -27,6 +33,8 @@ const HEADER_HEIGHT = 68; const PORT_ROW_HEIGHT = 18; /** Vertical padding between the header divider and the first port row. */ const PORTS_PAD_TOP = 4; +/** Height reserved for the tag chip row when a node carries at least one tag. */ +const TAGS_ROW_HEIGHT = 20; /** * Per-node method port lists. `outPorts` are methods on this service that @@ -52,6 +60,10 @@ interface GraphViewProps { /** Selected `ServiceNode.id`. */ selectedId?: string; onSelect: (id?: string) => void; + /** User-authored tags, keyed by `ServiceNode.id`. */ + tags: TagMap; + /** Replace the full tag list for a node (empty list clears the entry). */ + onEditTags: (nodeId: string, tags: string[]) => void; } interface ServiceNodeData extends Record { @@ -65,6 +77,8 @@ interface ServiceNodeData extends Record { matched: boolean; dim: boolean; ports: ServicePortsInfo; + /** Tags attached to this node, in entry order. */ + tags: string[]; } const EVENT_KINDS: Set = new Set(['publish', 'subscribe', 'emit', 'on']); @@ -137,30 +151,35 @@ function computeServicePorts( return result; } -function nodeHeight(ports: ServicePortsInfo): number { +function nodeHeight(ports: ServicePortsInfo, hasTags: boolean): number { const rows = Math.max(ports.inPorts.length, ports.outPorts.length); - if (rows === 0) return HEADER_HEIGHT; - return HEADER_HEIGHT + PORTS_PAD_TOP + rows * PORT_ROW_HEIGHT + PORTS_PAD_TOP; + const base = rows === 0 ? HEADER_HEIGHT : HEADER_HEIGHT + PORTS_PAD_TOP + rows * PORT_ROW_HEIGHT + PORTS_PAD_TOP; + return hasTags ? base + TAGS_ROW_HEIGHT : base; } function ServiceNodeView({ data }: NodeProps>): JSX.Element { - const { service, selected, matched, dim, ports } = data; + const { service, selected, matched, dim, ports, tags } = data; const bg = SCOPE_STYLE[service.scope].color; const rowCount = Math.max(ports.inPorts.length, ports.outPorts.length); // Interface-only node: the token is referenced but has no registered impl. // Flagged with a dashed warning border so missing bindings stand out from // concrete services at a glance. Selection / search-match still win so the - // active node stays unambiguous. + // active node stays unambiguous. Scope-mismatch nodes (token registered, but + // at a scope the caller can't see) get a distinct amber dashed border. const isUnresolved = service.unresolved === true; + const isScopeMismatch = service.scopeMismatch === true; + const specialBorder = isUnresolved || isScopeMismatch; const borderColor = selected ? '#ffdf5d' : matched ? '#79c0ff' : isUnresolved - ? '#f85149' - : 'rgba(0,0,0,0.4)'; - const borderWidth = selected || matched || isUnresolved ? 2 : 1; - const borderStyle = isUnresolved && !selected && !matched ? 'dashed' : 'solid'; + ? UNRESOLVED_COLOR + : isScopeMismatch + ? SCOPE_MISMATCH_COLOR + : 'rgba(0,0,0,0.4)'; + const borderWidth = selected || matched || specialBorder ? 2 : 1; + const borderStyle = specialBorder && !selected && !matched ? 'dashed' : 'solid'; const glow = selected ? '0 0 0 3px rgba(255,223,93,0.25)' : matched @@ -224,11 +243,17 @@ function ServiceNodeView({ data }: NodeProps>): JSX.Elemen
- {isUnresolved ? 'no implementation registered' : service.token} + {isUnresolved + ? 'no implementation registered' + : isScopeMismatch + ? `registered at ${service.scope} · cross-scope ref` + : service.token}
{service.domain}
+ {tags.length > 0 && } + {rowCount > 0 && (
+ {tags.map((tag) => ( + + ))} +
+ ); +} + +interface TagChipProps { + tag: string; + /** When provided, renders a remove affordance. */ + onRemove?: () => void; +} + +function TagChip({ tag, onRemove }: TagChipProps): JSX.Element { + const { color, bg } = tagColor(tag); + return ( + + + {tag} + + {onRemove && ( + + )} + + ); +} + +interface TagEditorProps { + tags: string[]; + /** Known tags across the graph, offered as input suggestions. */ + allTags: string[]; + onChange: (next: string[]) => void; +} + +/** + * Per-node tag editor rendered in the side panel. Chips remove on click; the + * input adds on Enter or the add button, normalising whitespace and refusing + * duplicates. `allTags` feeds a `` so existing tags are one keystroke + * away — keeps spelling consistent so grouping actually groups. + */ +function TagEditor({ tags, allTags, onChange }: TagEditorProps): JSX.Element { + const [draft, setDraft] = useState(''); + const listId = 'tag-suggestions'; + + function commit(raw: string): void { + const tag = raw.trim(); + if (!tag || tags.includes(tag)) { + setDraft(''); + return; + } + onChange([...tags, tag]); + setDraft(''); + } + + return ( +
+
+ tags +
+
+ {tags.length === 0 ? ( + no tags + ) : ( + tags.map((tag) => ( + onChange(tags.filter((t) => t !== tag))} + /> + )) + )} +
+
+ setDraft(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') { + e.preventDefault(); + commit(draft); + } + }} + style={{ + flex: 1, + minWidth: 0, + padding: '4px 7px', + background: '#0e1116', + color: '#e6edf3', + border: '1px solid #30363d', + borderRadius: 4, + fontSize: 11, + }} + /> + + + {allTags + .filter((t) => !tags.includes(t)) + .map((t) => ( + +
+
+ ); +} + /** * Persist the pan/zoom viewport across dev-server reloads so a source-code * edit (which triggers a `full-reload` from the `virtual:dep-graph` plugin) @@ -416,6 +622,8 @@ export function GraphView({ filters, selectedId, onSelect, + tags, + onEditTags, }: GraphViewProps): JSX.Element { // Compute once at mount so a re-render that adds nodes doesn't yank the // viewport back to the stored value while the user is panning. @@ -451,10 +659,12 @@ export function GraphView({ // dead weight on the node, so we compute after filter+visibility. const ports = computeServicePorts(visibleServices, finalEdges); - // Compute the two focus drivers: + // Compute the three focus drivers: // • `selectedId` — the click-selected node (0 or 1 at a time). // • `matched` — every node whose identity or public surface hits // the current search string. + // • `tagMatched` — every node carrying at least one active tag + // (the "group by tag" view). // Their neighbours (nodes touched by any surviving edge) are folded in // so the graph keeps enough context around a hit to be readable — // this is the "act like a click" behaviour: nothing disappears, just @@ -467,6 +677,16 @@ export function GraphView({ } } + // Tag focus: every visible node carrying at least one active tag seeds + // the focus set, so the graph reads as "the group(s) these tags pick out". + const tagMatched = new Set(); + if (filters.activeTags.size > 0) { + for (const s of visibleServices) { + const st = tags[s.id]; + if (st && st.some((t) => filters.activeTags.has(t))) tagMatched.add(s.id); + } + } + const focused = new Set(); const seedFocus = (id: string): void => { focused.add(id); @@ -477,14 +697,21 @@ export function GraphView({ }; if (selectedId !== undefined) seedFocus(selectedId); for (const id of matched) seedFocus(id); + for (const id of tagMatched) seedFocus(id); - const focusActive = selectedId !== undefined || matched.size > 0; + const focusActive = + selectedId !== undefined || matched.size > 0 || tagMatched.size > 0; const layout = layoutDagre(visibleServices, finalEdges, { groupByScope: filters.groupByScope, nodeSize: (id) => { - const p = ports.get(id); - return { width: NODE_WIDTH, height: p ? nodeHeight(p) : HEADER_HEIGHT }; + const p = ports.get(id) ?? { + inPorts: [], + outPorts: [], + connectedIn: new Set(), + }; + const hasTags = (tags[id]?.length ?? 0) > 0; + return { width: NODE_WIDTH, height: nodeHeight(p, hasTags) }; }, }); const pos = layout.positions; @@ -504,6 +731,7 @@ export function GraphView({ outPorts: [], connectedIn: new Set(), }, + tags: tags[service.id] ?? [], }, }), ); @@ -574,7 +802,7 @@ export function GraphView({ : []; return { nodes: rfNodes, edges: rfEdges, selectedService, selectedEdges }; - }, [graph, filters, selectedId]); + }, [graph, filters, selectedId, tags]); return ( <> @@ -608,7 +836,11 @@ export function GraphView({ if (n.id.startsWith('band::')) return 'transparent'; const service = (n.data as ServiceNodeData | undefined)?.service; if (!service) return '#7d8590'; - return service.unresolved ? '#f85149' : SCOPE_STYLE[service.scope].color; + return service.unresolved + ? UNRESOLVED_COLOR + : service.scopeMismatch + ? SCOPE_MISMATCH_COLOR + : SCOPE_STYLE[service.scope].color; }} /> @@ -619,6 +851,8 @@ export function GraphView({ graph={graph} edges={selectedEdges} onClose={() => onSelect(undefined)} + tags={tags} + onEditTags={onEditTags} /> )} @@ -630,19 +864,33 @@ interface ServicePanelProps { graph: Graph; edges: Edge[]; onClose: () => void; + tags: TagMap; + onEditTags: (nodeId: string, tags: string[]) => void; } -function ServicePanel({ service, graph, edges, onClose }: ServicePanelProps): JSX.Element { +function ServicePanel({ + service, + graph, + edges, + onClose, + tags, + onEditTags, +}: ServicePanelProps): JSX.Element { const outgoing = edges.filter((e) => e.from === service.id); const incoming = edges.filter((e) => e.to === service.id && e.from !== service.id); const byId = new Map(graph.services.map((s) => [s.id, s])); + const nodeTags = tags[service.id] ?? []; + const allTags = useMemo( + () => [...new Set(Object.values(tags).flat())].sort(), + [tags], + ); return (
{service.impl}
{service.unresolved ? ( -
+
No implementation registered
+ ) : service.scopeMismatch ? ( +
+ Registered at {service.scope} — not visible from the caller's scope +
) : (
{service.token}
)}
{service.scope} · {service.domain}
- {!service.unresolved && ( + {!service.unresolved && !service.scopeMismatch && (
{service.file}:{service.line}
@@ -687,6 +939,14 @@ function ServicePanel({ service, graph, edges, onClose }: ServicePanelProps): JS
+ { + onEditTags(service.id, next); + }} + /> + ; } +interface EdgeGroup { + edge: Edge; + peerLabel: string; + peerToken?: string; + /** Refs that have at least one attributed method — one table row each. */ + methodRefs: EdgeRef[]; + /** Refs with neither `fromMethod` nor `toMethod` (ctor param decls etc.). */ + unattributedCount: number; +} + +function buildEdgeGroups( + edges: Edge[], + direction: 'in' | 'out', + byId: Map, +): EdgeGroup[] { + return edges.map((e) => { + const peerId = direction === 'out' ? e.to : e.from; + const peer = byId.get(peerId); + const peerLabel = peer ? peer.impl : peerId; + const peerToken = peer?.token; + const methodRefs = e.refs.filter( + (r) => r.toMethod !== undefined || r.fromMethod !== undefined, + ); + const unattributedCount = e.refs.length - methodRefs.length; + return { edge: e, peerLabel, peerToken, methodRefs, unattributedCount }; + }); +} + +/** + * Right-panel table of edges touching the selected service. One row per + * attributed call ref; consecutive rows belonging to the same edge share + * `kind` / `peer` cells via `rowSpan` so the grouping is visible without + * repeating them. The self-side method column is bold so the direction of + * each call reads at a glance (out ⇒ `from` bold, in ⇒ `to` bold). + */ function EdgeList({ title, edges, direction, byId }: EdgeListProps): JSX.Element { + const groups = buildEdgeGroups(edges, direction, byId); + const selfIsFrom = direction === 'out'; return (
{title}
- {edges.length === 0 &&
} - {edges.map((e) => { - const peerId = direction === 'out' ? e.to : e.from; - const peer = byId.get(peerId); - const label = peer ? `${peer.impl} (${peer.token})` : peerId; - const methodRefs = e.refs.filter((r) => r.toMethod !== undefined || r.fromMethod !== undefined); - return ( -
-
- - {e.kind} - - {label} - - ×{e.refs.length} -
- {methodRefs.length > 0 && ( -
- - {methodRefs.length} call{methodRefs.length === 1 ? '' : 's'} - -
- {methodRefs.map((r, i) => ( -
+ ) : ( + + + + + + + + + + + + + + + + + {groups.map((g) => { + const kindStyle = EDGE_STYLE[g.edge.kind]; + const kindCell = ( +
+ + {g.edge.kind} +
+ ); + const peerCell = ( +
+ {g.peerLabel} +
+ ); + const groupKey = `${g.edge.from}::${g.edge.kind}::${g.edge.to}`; + if (g.methodRefs.length === 0) { + return ( + + + + + + ); + } + return ( + + {g.methodRefs.map((r, i) => { + const isFirst = i === 0; + return ( + + {isFirst && ( + <> + + + + )} + + + + ); + })} + + ); + })} + +
kindpeerfrom → toline
{kindCell}{peerCell} - {direction === 'out' ? ( - <> - {r.fromMethod ?? '?'} - {' → '} - {r.toMethod ?? '?'} - - ) : ( - <> - {r.fromMethod ?? '?'} - {' → '} - {r.toMethod ?? '?'} - - )} - {` (:${r.line})`} - - ))} - - - )} - - ); - })} + — ×{g.edge.refs.length} +
+ {kindCell} + + {peerCell} + + + {r.fromMethod ?? '?'} + + + + {r.toMethod ?? '?'} + + :{r.line}
+ )}
); } + +const tableStyle: React.CSSProperties = { + width: '100%', + borderCollapse: 'collapse', + tableLayout: 'fixed', + fontFamily: + 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace', + fontSize: 10.5, +}; + +const thStyle: React.CSSProperties = { + textAlign: 'left', + fontWeight: 600, + color: '#7d8590', + fontSize: 9, + textTransform: 'uppercase', + letterSpacing: 0.5, + padding: '3px 6px', + borderBottom: '1px solid #30363d', +}; + +const tdStyle: React.CSSProperties = { + padding: '3px 6px', + verticalAlign: 'top', +}; + +const tdCallStyle: React.CSSProperties = { + ...tdStyle, + whiteSpace: 'nowrap', + overflow: 'hidden', + textOverflow: 'ellipsis', +}; + +const tdLineStyle: React.CSSProperties = { + ...tdStyle, + textAlign: 'right', + color: '#6e7681', + whiteSpace: 'nowrap', +}; + +const cellClipStyle: React.CSSProperties = { + overflow: 'hidden', + textOverflow: 'ellipsis', + whiteSpace: 'nowrap', +}; + +const groupBorderStyle: React.CSSProperties = { + borderTop: '1px solid #21262d', +}; diff --git a/packages/agent-core-v2/scripts/dep-graph/web/src/style.ts b/packages/agent-core-v2/scripts/dep-graph/web/src/style.ts index 2003f3b2b..2f4243220 100644 --- a/packages/agent-core-v2/scripts/dep-graph/web/src/style.ts +++ b/packages/agent-core-v2/scripts/dep-graph/web/src/style.ts @@ -22,4 +22,9 @@ export const SCOPE_STYLE: Record Agent: { color: '#2f8a4d', badge: 'Agt' }, }; +/** Border / minimap color for scope-mismatch nodes (token registered elsewhere). */ +export const SCOPE_MISMATCH_COLOR = '#f0883e'; +/** Border / minimap color for unresolved nodes (token registered nowhere). */ +export const UNRESOLVED_COLOR = '#f85149'; + export const EDGE_KINDS: EdgeKind[] = ['ctor', 'accessor', 'publish', 'subscribe', 'emit', 'on']; diff --git a/packages/agent-core-v2/src/_base/di/scope.ts b/packages/agent-core-v2/src/_base/di/scope.ts index bbffe90d3..6f66a56df 100644 --- a/packages/agent-core-v2/src/_base/di/scope.ts +++ b/packages/agent-core-v2/src/_base/di/scope.ts @@ -63,13 +63,20 @@ export interface ScopeOptions { readonly extra?: ScopeSeed; } -export interface IScopeHandle { +export interface IScopeHandle { readonly id: string; - readonly kind: LifecycleScope; + readonly kind: K; readonly accessor: ServicesAccessor; dispose(): void; } +/** Handle to the process-root App scope. */ +export type IAppScopeHandle = IScopeHandle; +/** Handle to a Session scope (child of App). */ +export type ISessionScopeHandle = IScopeHandle; +/** Handle to an Agent scope (child of Session). */ +export type IAgentScopeHandle = IScopeHandle; + function buildCollection(kind: LifecycleScope, extra?: ScopeSeed): ServiceCollection { const collection = new ServiceCollection(); for (const entry of _scopedRegistry) { diff --git a/packages/agent-core-v2/src/app/messageLegacy/messageLegacyService.ts b/packages/agent-core-v2/src/app/messageLegacy/messageLegacyService.ts index 7e0b6dfe4..8d59bdb0f 100644 --- a/packages/agent-core-v2/src/app/messageLegacy/messageLegacyService.ts +++ b/packages/agent-core-v2/src/app/messageLegacy/messageLegacyService.ts @@ -14,7 +14,7 @@ import type { Message, PageResponse } from '@moonshot-ai/protocol'; import { InstantiationType } from '#/_base/di/extensions'; -import { type IScopeHandle, LifecycleScope, registerScopedService } from '#/_base/di/scope'; +import { type IAgentScopeHandle, LifecycleScope, registerScopedService } from '#/_base/di/scope'; import { IAgentLifecycleService } from '#/session/agent-lifecycle'; import { IAgentContextMemoryService, @@ -125,7 +125,7 @@ export class MessageLegacyService implements IMessageLegacyService { * Returns `undefined` only when a cold session's workspace is gone and the * session directory cannot be reconstructed (mirrors the `fork` limitation). */ - private async resolveMainAgent(sessionId: string): Promise { + private async resolveMainAgent(sessionId: string): Promise { const session = await this.lifecycle.resume(sessionId); if (session === undefined) return undefined; const agents = session.accessor.get(IAgentLifecycleService); diff --git a/packages/agent-core-v2/src/app/session-lifecycle/sessionLifecycle.ts b/packages/agent-core-v2/src/app/session-lifecycle/sessionLifecycle.ts index a3f49d908..5b4ec48da 100644 --- a/packages/agent-core-v2/src/app/session-lifecycle/sessionLifecycle.ts +++ b/packages/agent-core-v2/src/app/session-lifecycle/sessionLifecycle.ts @@ -13,7 +13,7 @@ */ import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; -import type { IScopeHandle } from '#/_base/di/scope'; +import type { ISessionScopeHandle } from '#/_base/di/scope'; import type { Event } from '#/_base/event'; export interface CreateSessionOptions { @@ -32,7 +32,7 @@ export interface ForkSessionOptions { export interface SessionCreatedEvent { readonly sessionId: string; - readonly handle: IScopeHandle; + readonly handle: ISessionScopeHandle; } export interface SessionClosedEvent { @@ -46,7 +46,7 @@ export interface SessionArchivedEvent { export interface SessionForkedEvent { readonly sourceSessionId: string; readonly sessionId: string; - readonly handle: IScopeHandle; + readonly handle: ISessionScopeHandle; } export interface ISessionLifecycleService { @@ -55,9 +55,9 @@ export interface ISessionLifecycleService { readonly onDidCloseSession: Event; readonly onDidArchiveSession: Event; readonly onDidForkSession: Event; - create(opts: CreateSessionOptions): Promise; - get(sessionId: string): IScopeHandle | undefined; - list(): readonly IScopeHandle[]; + create(opts: CreateSessionOptions): Promise; + get(sessionId: string): ISessionScopeHandle | undefined; + list(): readonly ISessionScopeHandle[]; /** * Load a persisted session into the live scope tree and restore its main * agent from the persisted wire log. Returns the existing handle when the @@ -70,10 +70,10 @@ export interface ISessionLifecycleService { * a previous process or by v1 — without requiring a prior `create` in this * process. Restores only the main agent; sub-agents are materialized lazily. */ - resume(sessionId: string): Promise; + resume(sessionId: string): Promise; close(sessionId: string): Promise; archive(sessionId: string): Promise; - fork(opts: ForkSessionOptions): Promise; + fork(opts: ForkSessionOptions): Promise; } export const ISessionLifecycleService: ServiceIdentifier = diff --git a/packages/agent-core-v2/src/app/session-lifecycle/sessionLifecycleService.ts b/packages/agent-core-v2/src/app/session-lifecycle/sessionLifecycleService.ts index fb502b36c..848fc3931 100644 --- a/packages/agent-core-v2/src/app/session-lifecycle/sessionLifecycleService.ts +++ b/packages/agent-core-v2/src/app/session-lifecycle/sessionLifecycleService.ts @@ -15,7 +15,7 @@ import { InstantiationType } from '#/_base/di/extensions'; import { IInstantiationService } from '#/_base/di/instantiation'; import { createScopedChildHandle, - type IScopeHandle, + type ISessionScopeHandle, LifecycleScope, registerScopedService, } from '#/_base/di/scope'; @@ -55,7 +55,7 @@ import { export class SessionLifecycleService extends Disposable implements ISessionLifecycleService { declare readonly _serviceBrand: undefined; - private readonly sessions = new Map(); + private readonly sessions = new Map(); private readonly _onDidCreateSession = this._register(new Emitter()); readonly onDidCreateSession: Event = this._onDidCreateSession.event; private readonly _onDidCloseSession = this._register(new Emitter()); @@ -67,7 +67,7 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec /** In-flight `resume` promises, keyed by session id — de-dupes concurrent * cold loads so a hot read path (e.g. snapshot retry) cannot materialize * the same session twice and leak a handle. */ - private readonly resuming = new Map>(); + private readonly resuming = new Map>(); constructor( @IInstantiationService private readonly instantiation: IInstantiationService, @@ -81,7 +81,7 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec super(); } - async create(opts: CreateSessionOptions): Promise { + async create(opts: CreateSessionOptions): Promise { const workspaceId = encodeWorkDirKey(opts.workDir); const sessionDir = join(this.bootstrap.sessionsDir, workspaceId, opts.sessionId); // Metadata lives at `/state.json` (shared with v1's layout; the @@ -112,7 +112,7 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec ...execContextSeed(execCtx), ], }, - ); + ) as ISessionScopeHandle; this.sessions.set(opts.sessionId, handle); await handle.accessor.get(ISessionMetadata).ready; void handle.accessor.get(ISessionSkillCatalog).load(); @@ -120,11 +120,11 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec return handle; } - get(sessionId: string): IScopeHandle | undefined { + get(sessionId: string): ISessionScopeHandle | undefined { return this.sessions.get(sessionId); } - resume(sessionId: string): Promise { + resume(sessionId: string): Promise { const live = this.sessions.get(sessionId); if (live !== undefined) return Promise.resolve(live); const inflight = this.resuming.get(sessionId); @@ -134,7 +134,7 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec return promise; } - private async doResume(sessionId: string): Promise { + private async doResume(sessionId: string): Promise { // Re-check after the serialized entry: a prior `resume` for the same id may // have already materialized the session while this call was queued. const live = this.sessions.get(sessionId); @@ -158,7 +158,7 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec return handle; } - list(): readonly IScopeHandle[] { + list(): readonly ISessionScopeHandle[] { return [...this.sessions.values()]; } @@ -179,7 +179,7 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec this._onDidArchiveSession.fire({ sessionId }); } - async fork(opts: ForkSessionOptions): Promise { + async fork(opts: ForkSessionOptions): Promise { const sourceId = opts.sourceSessionId; // 1. Resolve the source: prefer a live handle, otherwise fall back to the @@ -283,7 +283,7 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec * sources (flush then read) and closed sources (read the persisted log). */ private async copyAgentWire(args: { - readonly sourceHandle: IScopeHandle | undefined; + readonly sourceHandle: ISessionScopeHandle | undefined; readonly sourceHomedir: string; readonly agentId: string; readonly targetSessionDir: string; diff --git a/packages/agent-core-v2/src/app/sessionLegacy/sessionLegacyService.ts b/packages/agent-core-v2/src/app/sessionLegacy/sessionLegacyService.ts index 235c70017..0634f2a17 100644 --- a/packages/agent-core-v2/src/app/sessionLegacy/sessionLegacyService.ts +++ b/packages/agent-core-v2/src/app/sessionLegacy/sessionLegacyService.ts @@ -10,9 +10,8 @@ */ import { InstantiationType } from '#/_base/di/extensions'; -import { type IScopeHandle, LifecycleScope, registerScopedService } from '#/_base/di/scope'; +import { type IAgentScopeHandle, LifecycleScope, registerScopedService } from '#/_base/di/scope'; import { IAgentLifecycleService } from '#/session/agent-lifecycle'; -import { IAuthSummaryService } from '#/app/auth'; import { IAgentContextMemoryService, toProtocolMessage, type ContextMessage } from '#/agent/contextMemory'; import { IAgentContextSizeService } from '#/agent/contextSize'; import { ErrorCodes, isKimiError, KimiError } from '#/errors'; @@ -37,7 +36,6 @@ import type { ForkSessionRequest, SessionAbortResponse, SessionStatusResponse, - StartBtwSessionResponse, UndoSessionRequest, UndoSessionResponse, } from '@moonshot-ai/protocol'; @@ -73,7 +71,6 @@ export class SessionLegacyService implements ISessionLegacyService { @ISessionLifecycleService private readonly lifecycle: ISessionLifecycleService, @ISessionIndex private readonly index: ISessionIndex, @IWorkspaceRegistry private readonly workspaceRegistry: IWorkspaceRegistry, - @IAuthSummaryService private readonly auth: IAuthSummaryService, ) {} async fork(sessionId: string, body: ForkSessionRequest): Promise { @@ -222,16 +219,6 @@ export class SessionLegacyService implements ISessionLegacyService { return { aborted: true }; } - async startBtw(sessionId: string): Promise { - if (this.lifecycle.get(sessionId) === undefined) { - throw new KimiError(ErrorCodes.SESSION_NOT_FOUND, `session ${sessionId} does not exist`); - } - await this.auth.ensureReady(); - const agent = await this.resolveMainAgent(sessionId); - const agentId = await agent.accessor.get(IAgentRPCService).startBtw({}); - return { agent_id: agentId }; - } - async archive(sessionId: string): Promise { // Native `ISessionLifecycleService.archive` is a no-op for sessions that // are not live, so gate on the live handle (matches the previous route @@ -278,7 +265,7 @@ export class SessionLegacyService implements ISessionLegacyService { * Resolve the session's main agent, creating it on demand (mirrors v1's * `resumeSession` + the server-v2 `ensureMainAgent` helper). */ - private async resolveMainAgent(sessionId: string): Promise { + private async resolveMainAgent(sessionId: string): Promise { const session = this.lifecycle.get(sessionId); if (session === undefined) { throw new KimiError(ErrorCodes.SESSION_NOT_FOUND, `session ${sessionId} does not exist`); @@ -289,7 +276,7 @@ export class SessionLegacyService implements ISessionLegacyService { return agents.createMain(); } - private async assembleStatus(sessionId: string, agent: IScopeHandle): Promise { + private async assembleStatus(sessionId: string, agent: IAgentScopeHandle): Promise { const session = this.lifecycle.get(sessionId); const profile = agent.accessor.get(IAgentProfileService); const contextSize = agent.accessor.get(IAgentContextSizeService); diff --git a/packages/agent-core-v2/src/session/agent-lifecycle/agentLifecycle.ts b/packages/agent-core-v2/src/session/agent-lifecycle/agentLifecycle.ts index 7eb283851..744233a98 100644 --- a/packages/agent-core-v2/src/session/agent-lifecycle/agentLifecycle.ts +++ b/packages/agent-core-v2/src/session/agent-lifecycle/agentLifecycle.ts @@ -8,7 +8,7 @@ */ import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; -import type { IScopeHandle } from '#/_base/di/scope'; +import type { IAgentScopeHandle } from '#/_base/di/scope'; import type { Event } from '#/_base/event'; export interface CreateAgentOptions { @@ -22,15 +22,15 @@ export interface CreateAgentOptions { export interface IAgentLifecycleService { readonly _serviceBrand: undefined; /** Fires after an agent is created and registered, with its scope handle. */ - readonly onDidCreate: Event; + readonly onDidCreate: Event; /** Fires after an agent is removed, with its agent id. */ readonly onDidDispose: Event; - create(opts: CreateAgentOptions): Promise; - createMain(): Promise; + create(opts: CreateAgentOptions): Promise; + createMain(): Promise; /** Create a child agent that inherits the parent's profile and context history. */ - fork(parentAgentId: string): Promise; - getHandle(agentId: string): IScopeHandle | undefined; - list(): readonly IScopeHandle[]; + fork(parentAgentId: string): Promise; + getHandle(agentId: string): IAgentScopeHandle | undefined; + list(): readonly IAgentScopeHandle[]; remove(agentId: string): Promise; } diff --git a/packages/agent-core-v2/src/session/agent-lifecycle/agentLifecycleService.ts b/packages/agent-core-v2/src/session/agent-lifecycle/agentLifecycleService.ts index 796ff6c55..1d8742cc7 100644 --- a/packages/agent-core-v2/src/session/agent-lifecycle/agentLifecycleService.ts +++ b/packages/agent-core-v2/src/session/agent-lifecycle/agentLifecycleService.ts @@ -15,7 +15,7 @@ import { Emitter } from '#/_base/event'; import { SyncDescriptor } from '#/_base/di/descriptors'; import { createScopedChildHandle, - type IScopeHandle, + type IAgentScopeHandle, LifecycleScope, registerScopedService, } from '#/_base/di/scope'; @@ -48,8 +48,8 @@ let nextAgentId = 0; export class AgentLifecycleService extends Disposable implements IAgentLifecycleService { declare readonly _serviceBrand: undefined; - private readonly handles = new Map(); - private readonly onDidCreateEmitter = this._register(new Emitter()); + private readonly handles = new Map(); + private readonly onDidCreateEmitter = this._register(new Emitter()); private readonly onDidDisposeEmitter = this._register(new Emitter()); private mcpManager: McpConnectionManager | undefined; @@ -72,7 +72,7 @@ export class AgentLifecycleService extends Disposable implements IAgentLifecycle super(); } - async create(opts: CreateAgentOptions): Promise { + async create(opts: CreateAgentOptions): Promise { const agentId = opts.agentId ?? `agent-${nextAgentId++}`; // Per-agent homedir → the wire-record persistence key (`hashKey(homedir)`). // Co-located under the session dir, mirroring v1's `/agents/`. @@ -95,7 +95,7 @@ export class AgentLifecycleService extends Disposable implements IAgentLifecycle [IAgentExternalHooksService, new SyncDescriptor(AgentExternalHooksService, [{}], true)], ], }, - ); + ) as IAgentScopeHandle; this.handles.set(agentId, handle); // Record the agent in the session registry so a closed-session fork can // enumerate every agent and relocate its wire log. @@ -114,7 +114,7 @@ export class AgentLifecycleService extends Disposable implements IAgentLifecycle return handle; } - async createMain(): Promise { + async createMain(): Promise { const handle = await this.create({ agentId: 'main' }); // Force-instantiate the plugin session-start injector so it registers its // turn-cadence injection before the first turn. Main-agent only, matching @@ -123,7 +123,7 @@ export class AgentLifecycleService extends Disposable implements IAgentLifecycle return handle; } - async fork(parentAgentId: string): Promise { + async fork(parentAgentId: string): Promise { const parent = this.handles.get(parentAgentId) ?? (parentAgentId === 'main' ? await this.createMain() : undefined); @@ -173,11 +173,11 @@ export class AgentLifecycleService extends Disposable implements IAgentLifecycle await manager.connectAll(servers); } - getHandle(agentId: string): IScopeHandle | undefined { + getHandle(agentId: string): IAgentScopeHandle | undefined { return this.handles.get(agentId); } - list(): readonly IScopeHandle[] { + list(): readonly IAgentScopeHandle[] { return [...this.handles.values()]; } diff --git a/packages/agent-core-v2/test/gateway/gateway.test.ts b/packages/agent-core-v2/test/gateway/gateway.test.ts index 7f57f6c45..c2ebdac50 100644 --- a/packages/agent-core-v2/test/gateway/gateway.test.ts +++ b/packages/agent-core-v2/test/gateway/gateway.test.ts @@ -3,7 +3,7 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { SyncDescriptor } from '#/_base/di/descriptors'; import type { ServiceIdentifier, ServicesAccessor } from '#/_base/di/instantiation'; import { DisposableStore } from '#/_base/di/lifecycle'; -import { type IScopeHandle, LifecycleScope } from '#/_base/di/scope'; +import { type IAgentScopeHandle, type ISessionScopeHandle, LifecycleScope } from '#/_base/di/scope'; import { TestInstantiationService } from '#/_base/di/test'; import { IAgentLifecycleService } from '#/session/agent-lifecycle/agentLifecycle'; import type { ContextMessage } from '#/agent/contextMemory'; @@ -59,7 +59,7 @@ describe('RestGateway', () => { clear: () => {}, }; - const agentHandle: IScopeHandle = { + const agentHandle: IAgentScopeHandle = { id: 'main', kind: LifecycleScope.Agent, accessor: makeAccessor([ @@ -79,7 +79,7 @@ describe('RestGateway', () => { list: () => [agentHandle], remove: () => Promise.resolve(), }; - const sessionHandle: IScopeHandle = { + const sessionHandle: ISessionScopeHandle = { id: 's1', kind: LifecycleScope.Session, accessor: makeAccessor([[IAgentLifecycleService, agents]]), diff --git a/packages/agent-core-v2/test/session-activity/sessionActivity.test.ts b/packages/agent-core-v2/test/session-activity/sessionActivity.test.ts index 90d50ffe8..41116b5af 100644 --- a/packages/agent-core-v2/test/session-activity/sessionActivity.test.ts +++ b/packages/agent-core-v2/test/session-activity/sessionActivity.test.ts @@ -3,7 +3,7 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { SyncDescriptor } from '#/_base/di/descriptors'; import type { ServiceIdentifier, ServicesAccessor } from '#/_base/di/instantiation'; import { DisposableStore } from '#/_base/di/lifecycle'; -import { type IScopeHandle, LifecycleScope } from '#/_base/di/scope'; +import { type IAgentScopeHandle, LifecycleScope } from '#/_base/di/scope'; import { TestInstantiationService } from '#/_base/di/test'; import { IAgentLifecycleService } from '#/session/agent-lifecycle/agentLifecycle'; import { ISessionInteractionService, type Interaction, type InteractionKind } from '#/session/interaction'; @@ -41,7 +41,7 @@ function makeAccessor(turn: IAgentTurnService): ServicesAccessor { }; } -function handle(id: string, active: boolean): IScopeHandle { +function handle(id: string, active: boolean): IAgentScopeHandle { const turn = makeTurnService(active); return { id, @@ -51,7 +51,7 @@ function handle(id: string, active: boolean): IScopeHandle { }; } -function lifecycle(handles: readonly IScopeHandle[]): IAgentLifecycleService { +function lifecycle(handles: readonly IAgentScopeHandle[]): IAgentLifecycleService { return { _serviceBrand: undefined, onDidCreate: () => ({ dispose: () => {} }), diff --git a/packages/agent-core-v2/test/session/session.test.ts b/packages/agent-core-v2/test/session/session.test.ts index 3b86c3b54..fdf705629 100644 --- a/packages/agent-core-v2/test/session/session.test.ts +++ b/packages/agent-core-v2/test/session/session.test.ts @@ -2,7 +2,7 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import type { ServicesAccessor } from '#/_base/di/instantiation'; import { DisposableStore } from '#/_base/di/lifecycle'; -import { type IScopeHandle, LifecycleScope } from '#/_base/di/scope'; +import { type IAgentScopeHandle, LifecycleScope } from '#/_base/di/scope'; import { createServices, type TestInstantiationService } from '#/_base/di/test'; import { IAgentLifecycleService } from '#/session/agent-lifecycle/agentLifecycle'; import { IEventService } from '#/app/event'; @@ -11,7 +11,7 @@ import { SessionService } from '#/session/session/sessionService'; import { ISessionContext } from '#/session/session-context'; import { ISessionMetadata } from '#/session/session-metadata'; -const handle: IScopeHandle = { +const handle: IAgentScopeHandle = { id: 'main', kind: LifecycleScope.Agent, accessor: { get: () => ({}) } as unknown as ServicesAccessor, diff --git a/packages/server-v2/src/routes/skills.ts b/packages/server-v2/src/routes/skills.ts index 82a19977a..8ccd9eb86 100644 --- a/packages/server-v2/src/routes/skills.ts +++ b/packages/server-v2/src/routes/skills.ts @@ -52,7 +52,7 @@ import { ISessionLifecycleService, ISessionSkillCatalog, isKimiError, - type IScopeHandle, + type ISessionScopeHandle, type Scope, } from '@moonshot-ai/agent-core-v2'; import { @@ -98,7 +98,7 @@ const skillTailParamsSchema = z.object({ }); type ResolvedSession = - | { readonly handle: IScopeHandle } + | { readonly handle: ISessionScopeHandle } | { readonly envelope: ReturnType }; /** diff --git a/packages/server-v2/src/transport/mainAgent.ts b/packages/server-v2/src/transport/mainAgent.ts index a65cba5ec..df4233e1b 100644 --- a/packages/server-v2/src/transport/mainAgent.ts +++ b/packages/server-v2/src/transport/mainAgent.ts @@ -7,7 +7,11 @@ * missing main agent is created instead of reported as `agent.not_found`. */ -import { IAgentLifecycleService, type IScopeHandle } from '@moonshot-ai/agent-core-v2'; +import { + IAgentLifecycleService, + type IAgentScopeHandle, + type ISessionScopeHandle, +} from '@moonshot-ai/agent-core-v2'; export const MAIN_AGENT_ID = 'main'; @@ -15,7 +19,7 @@ export const MAIN_AGENT_ID = 'main'; * Return the session's main agent, creating it on demand when it does not * exist yet. */ -export async function ensureMainAgent(session: IScopeHandle): Promise { +export async function ensureMainAgent(session: ISessionScopeHandle): Promise { const agents = session.accessor.get(IAgentLifecycleService); const existing = agents.getHandle(MAIN_AGENT_ID); if (existing !== undefined) return existing;